diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..3550a30f2 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fba2c1098..9769a3d7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -194,6 +194,11 @@ jobs: runs-on: windows-latest permissions: contents: write + # Step-level `if:` cannot read the `secrets` context, so the signing + # waterfall is driven by these boolean mirrors of the credential secrets. + env: + AZURE_SIGNING_CONFIGURED: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME != '' }} + PFX_CONFIGURED: ${{ secrets.WINDOWS_PFX_BASE64 != '' }} steps: - name: Checkout @@ -237,7 +242,11 @@ jobs: cargo build --locked --release cargo build --locked --release -p dwg-thumbnailer-win + # Forks do not inherit repository secrets, so every signing step is + # gated on its credentials being present and the pipeline falls through: + # Azure Trusted Signing (upstream setup) -> PFX certificate -> unsigned. - name: Sign executable (Azure Trusted Signing) + if: env.AZURE_SIGNING_CONFIGURED == 'true' uses: azure/artifact-signing-action@v2 with: azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -251,6 +260,37 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 + - name: Import signing certificate (PFX fallback) + if: env.AZURE_SIGNING_CONFIGURED != 'true' && env.PFX_CONFIGURED == 'true' + shell: pwsh + env: + WINDOWS_PFX_BASE64: ${{ secrets.WINDOWS_PFX_BASE64 }} + run: | + $pfxPath = Join-Path $env:RUNNER_TEMP "signing.pfx" + [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WINDOWS_PFX_BASE64)) + "Imported signing certificate to $pfxPath" + + - name: Sign executable (certificate) + if: env.AZURE_SIGNING_CONFIGURED != 'true' && env.PFX_CONFIGURED == 'true' + shell: pwsh + env: + WINDOWS_PFX_PASSWORD: ${{ secrets.WINDOWS_PFX_PASSWORD }} + run: | + $signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter signtool.exe | + Where-Object { $_.DirectoryName -match 'x64' } | + Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName + if (-not $signtool) { throw "signtool.exe not found in the Windows SDK" } + & $signtool sign /fd SHA256 /tr https://timestamp.digicert.com /td SHA256 ` + /f (Join-Path $env:RUNNER_TEMP "signing.pfx") /p $env:WINDOWS_PFX_PASSWORD ` + (Resolve-Path target\release\OpenCADStudio.exe).Path + if ($LASTEXITCODE -ne 0) { throw "signtool failed for the executable" } + + - name: Note unsigned executable + if: env.AZURE_SIGNING_CONFIGURED != 'true' && env.PFX_CONFIGURED != 'true' + shell: pwsh + run: | + "No signing credentials configured - add the Azure Trusted Signing secrets or WINDOWS_PFX_BASE64/WINDOWS_PFX_PASSWORD to sign. Building UNSIGNED." >> $env:GITHUB_STEP_SUMMARY + - name: Build MSI installer shell: pwsh run: | @@ -283,8 +323,9 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "light failed" } - name: Sign MSI installer (Azure Trusted Signing) - # The installer itself must be signed separately — light produces a + # The installer itself must be signed separately - light produces a # fresh, unsigned .msi even though the .exe inside is already signed. + if: env.AZURE_SIGNING_CONFIGURED == 'true' uses: azure/artifact-signing-action@v2 with: azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -298,6 +339,27 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 + - name: Sign MSI installer (certificate) + if: env.AZURE_SIGNING_CONFIGURED != 'true' && env.PFX_CONFIGURED == 'true' + shell: pwsh + env: + WINDOWS_PFX_PASSWORD: ${{ secrets.WINDOWS_PFX_PASSWORD }} + run: | + $signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter signtool.exe | + Where-Object { $_.DirectoryName -match 'x64' } | + Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty FullName + if (-not $signtool) { throw "signtool.exe not found in the Windows SDK" } + & $signtool sign /fd SHA256 /tr https://timestamp.digicert.com /td SHA256 ` + /f (Join-Path $env:RUNNER_TEMP "signing.pfx") /p $env:WINDOWS_PFX_PASSWORD ` + (Resolve-Path OpenCADStudio.msi).Path + if ($LASTEXITCODE -ne 0) { throw "signtool failed for the MSI" } + + - name: Note unsigned MSI + if: env.AZURE_SIGNING_CONFIGURED != 'true' && env.PFX_CONFIGURED != 'true' + shell: pwsh + run: | + "MSI left unsigned - no signing credentials configured." >> $env:GITHUB_STEP_SUMMARY + - name: Upload artifacts to release if: env.RELEASE_TAG != '' shell: pwsh @@ -429,8 +491,16 @@ jobs: - name: Ad-hoc code-sign the bundle run: | - codesign --force --deep --sign - --timestamp=none OpenCADStudio.app - codesign --verify --strict --verbose=2 OpenCADStudio.app + codesign --force --sign - --timestamp=none OpenCADStudio.app/Contents/MacOS/OpenCADStudio-App + codesign --force --sign - --timestamp=none \ + --entitlements crates/dwg-thumbnailer/macos/entitlements.plist \ + OpenCADStudio.app/Contents/PlugIns/DWGThumbnail.appex + codesign --force --sign - --timestamp=none OpenCADStudio.app + codesign --verify --deep --strict --verbose=2 OpenCADStudio.app + # --xml keeps newer codesign builds from prefixing text headers, + # which made the entitlements parse fail with InvalidFileException. + codesign -d --entitlements - --xml OpenCADStudio.app/Contents/PlugIns/DWGThumbnail.appex \ + | python3 -c 'import io, plistlib, sys; ent = plistlib.load(io.BytesIO(sys.stdin.buffer.read())); print("entitlements:", ent); assert ent.get("com.apple.security.app-sandbox") is True' - name: Create .dmg run: | diff --git a/.github/workflows/web-check.yml b/.github/workflows/web-check.yml new file mode 100644 index 000000000..d531f09b5 --- /dev/null +++ b/.github/workflows/web-check.yml @@ -0,0 +1,34 @@ +name: Web build check + +# The web bundle is only built when a release is published (pages.yml), so a +# desktop-only call in shared code can sit on main unnoticed until the next +# release fails at the wasm step. Type-check the web target on every change +# instead; this is a check, not a bundle build, so it stays fast. + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: web-check-${{ github.ref }} + cancel-in-progress: true + +jobs: + wasm32: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@v2 + + # The app and the parse worker that trunk's post-build hook compiles. + - name: cargo check (wasm32) + run: cargo check --locked --target wasm32-unknown-unknown -p OpenCADStudio -p ocs_web_worker diff --git a/.gitignore b/.gitignore index 80e292024..3684cd6dc 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,12 @@ __pycache__/ # Claude Code local preview/dev-server config .claude/launch.json + +# nix-direnv +.direnv/ + +# Worktrees created for isolated runs +.claude/worktrees/ + +# Single-instance lock files the app writes next to an open drawing +*.ocs.lock diff --git a/Cargo.lock b/Cargo.lock index 76a3880ef..2a087f73a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,9 +31,11 @@ dependencies = [ "iced_aw", "iced_core", "iced_runtime", + "iced_test", "image", "inventory", "js-sys", + "libloading", "log", "lyon_tessellation", "meshopt", @@ -43,6 +45,7 @@ dependencies = [ "objc2-foundation 0.2.2", "ocs_plugin_api", "open", + "pollster", "printpdf", "rayon", "resvg", @@ -83,7 +86,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=568a12c#568a12cb97c24e14b1cc488085b3f81cf89b94d9" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=7ea4247#7ea4247134af60c8c1617426849d4fa8233660c8" dependencies = [ "ahash 0.8.12", "anyhow", @@ -919,7 +922,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dcf0840#dcf0840d2e62afdde35cf38c0a3aa914ff305eed" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=a0b962a#a0b962ae8045e7221ecb5d478adc5ad58e2706ba" dependencies = [ "acadrust", "cavalier_contours", @@ -930,7 +933,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dcf0840#dcf0840d2e62afdde35cf38c0a3aa914ff305eed" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=a0b962a#a0b962ae8045e7221ecb5d478adc5ad58e2706ba" dependencies = [ "nalgebra", ] @@ -2677,6 +2680,30 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "iced_selector" +version = "0.15.0-dev" +source = "git+https://github.com/iced-rs/iced.git?rev=23604ff22ab0aad9e00b9327cb7b8546ed84db39#23604ff22ab0aad9e00b9327cb7b8546ed84db39" +dependencies = [ + "iced_core", +] + +[[package]] +name = "iced_test" +version = "0.15.0-dev" +source = "git+https://github.com/iced-rs/iced.git?rev=23604ff22ab0aad9e00b9327cb7b8546ed84db39#23604ff22ab0aad9e00b9327cb7b8546ed84db39" +dependencies = [ + "iced_futures", + "iced_program", + "iced_renderer", + "iced_runtime", + "iced_selector", + "nom 8.0.0", + "png 0.18.1", + "sha2 0.10.9", + "thiserror 2.0.18", +] + [[package]] name = "iced_tiny_skia" version = "0.15.0-dev" @@ -4252,7 +4279,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -5872,7 +5899,7 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0811b01ca2c4e8718760713911feaf4675c24f94e50530a015ec646cfb622f7c" dependencies = [ - "skrifa 0.42.1", + "skrifa 0.37.0", "yazi", "zeno", ] diff --git a/Cargo.toml b/Cargo.toml index ed9e2120a..05791c276 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,9 +32,10 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840" } +log = "0.4" +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7ea4247", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "a0b962a", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "a0b962a" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -54,13 +55,16 @@ ttf-parser = "0.25" cosmic-text = "0.15" unicode-bidi = "0.3" lyon_tessellation = "1.0.20" +pollster = "0.4" [dev-dependencies] fluent-syntax = "0.12" +iced_test = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } naga = { version = "27", features = ["wgsl-in"] } [target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing"] } +libloading = "0.8" +windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing", "Win32_Graphics_Gdi", "Win32_Storage_Xps"] } [target.'cfg(target_os = "linux")'.dependencies] ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } @@ -93,7 +97,6 @@ ocs_plugin_api = { path = "crates/ocs_plugin_api" } i18n-embed = { version = "0.16", features = ["web-sys-requester"] } rust-embed = { version = "8", features = ["debug-embed"] } console_error_panic_hook = "0.1" -log = "0.4" getrandom = { version = "0.3", features = ["wasm_js"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" diff --git a/assets/icons/constrain/perpendicular.svg b/assets/icons/constrain/perpendicular.svg index 520becabb..c4d2ac1a1 100644 --- a/assets/icons/constrain/perpendicular.svg +++ b/assets/icons/constrain/perpendicular.svg @@ -1,4 +1,4 @@ - - + + diff --git a/assets/icons/constrain/smooth.svg b/assets/icons/constrain/smooth.svg index 5986ce9d0..7f5425a8a 100644 --- a/assets/icons/constrain/smooth.svg +++ b/assets/icons/constrain/smooth.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/ui/spacemouse.svg b/assets/icons/ui/spacemouse.svg new file mode 100644 index 000000000..e9e73fc43 --- /dev/null +++ b/assets/icons/ui/spacemouse.svg @@ -0,0 +1 @@ + diff --git a/benches/performance_benchmarks.rs b/benches/performance_benchmarks.rs index 4ae2dcf9c..f51a08aa6 100644 --- a/benches/performance_benchmarks.rs +++ b/benches/performance_benchmarks.rs @@ -1180,15 +1180,18 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { 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; + // Both panes use a square grid, so one step per pane plus a shared + // major-line interval covers the new step_x / step_y / major_every args. + let major_every = 5u32; // ── 1. Uncached grid geometry generation ── if runner.should_run("ui_grid_geometry_uncached") { for _ in 0..10 { let _ = black_box(grid_segments( - view_rot1, eye1, bounds1, step1, origin1, axes1, limits1, + view_rot1, eye1, bounds1, step1, step1, major_every, origin1, axes1, limits1, )); let _ = black_box(grid_segments( - view_rot2, eye2, bounds2, step2, origin2, axes2, limits2, + view_rot2, eye2, bounds2, step2, step2, major_every, origin2, axes2, limits2, )); } @@ -1199,8 +1202,8 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { for _ in 0..runs { let t0 = Instant::now(); for _ in 0..n { - let g1 = grid_segments(view_rot1, eye1, bounds1, step1, origin1, axes1, limits1); - let g2 = grid_segments(view_rot2, eye2, bounds2, step2, origin2, axes2, limits2); + let g1 = grid_segments(view_rot1, eye1, bounds1, step1, step1, major_every, origin1, axes1, limits1); + let g2 = grid_segments(view_rot2, eye2, bounds2, step2, step2, major_every, origin2, axes2, limits2); black_box(g1); black_box(g2); } @@ -1225,7 +1228,9 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { view_rot: view_rot1, eye: eye1, bounds: bounds1, - step: step1, + step_x: step1, + step_y: step1, + major_every, origin: origin1, axes: axes1, limits: limits1, @@ -1234,7 +1239,9 @@ fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { view_rot: view_rot2, eye: eye2, bounds: bounds2, - step: step2, + step_x: step2, + step_y: step2, + major_every, origin: origin2, axes: axes2, limits: limits2, diff --git a/crates/dwg-thumbnailer/macos/ThumbnailProvider.swift b/crates/dwg-thumbnailer/macos/ThumbnailProvider.swift index c864311f2..f52bad1b6 100644 --- a/crates/dwg-thumbnailer/macos/ThumbnailProvider.swift +++ b/crates/dwg-thumbnailer/macos/ThumbnailProvider.swift @@ -39,11 +39,7 @@ final class ThumbnailProvider: QLThumbnailProvider { return } - // The embedded DWG preview bitmap is often much smaller than the - // requested icon size. Scale it up (or down) to fit request.maximumSize - // and center it — otherwise QuickLook draws it at its native pixel size - // anchored at the CGContext origin (bottom-left), producing a small - // thumbnail stuck in the corner instead of filling the icon (#365). + // Fit and center the embedded bitmap in the requested thumbnail. let maxSize = request.maximumSize let nativeSize = CGSize(width: cg.width, height: cg.height) let scale = min(maxSize.width / nativeSize.width, maxSize.height / nativeSize.height) diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index d0d28766c..23bd59fe6 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -15,7 +15,7 @@ serde_json = { version = "1", optional = true } # 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 = "568a12c", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7ea4247", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -39,7 +39,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 = "568a12c", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7ea4247", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index b77ca97ff..5f8086a08 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -193,7 +193,7 @@ fn call_timeout() -> Duration { /// Per-request-kind timeout floors. The user-configured default is raised to /// these minima so that no request kind can be configured into an unsafe value. -fn request_timeout(kind: &'static str) -> Duration { +pub(crate) fn request_timeout(kind: &'static str) -> Duration { base_max_floor(call_timeout(), kind) } @@ -228,7 +228,7 @@ fn base_max_floor(base: Duration, kind: &'static str) -> Duration { base.max(floor) } -fn request_kind(req: &HostRequest) -> &'static str { +pub(crate) fn request_kind(req: &HostRequest) -> &'static str { match req { HostRequest::GetManifest => "GetManifest", HostRequest::GetRibbon => "GetRibbon", @@ -554,6 +554,43 @@ impl PluginProcess { } } + /// Legacy (non-V4) round trip for simple interactive calls: send `req`, + /// then loop until the matching response arrives, answering any nested + /// plugin request with an error. `kind` drives the timeout floor; + /// `nested_what` names the operation in that error; `extract` pulls the + /// expected payload out of the response. + fn call_simple( + &self, + req: HostRequest, + kind: &'static str, + nested_what: &'static str, + extract: impl Fn(HostResponse) -> Result>, + ) -> Result { + self.send_request(req)?; + let timeout = request_timeout(kind); + let deadline = Instant::now() + timeout; + loop { + match recv_with_deadline::( + &self.stream, + &self.child, + deadline, + timeout, + kind, + )? { + PluginToHost::Response(resp) => match extract(resp) { + Ok(v) => return Ok(v), + Err(other) => return Err(PluginError::UnexpectedResponse(other)), + }, + PluginToHost::Request(req) => { + let resp = crate::ipc::protocol::PluginResponse::Error(format!( + "unexpected nested request during {nested_what}: {req:?}" + )); + self.send_response(resp)?; + } + } + } + } + /// Send an interactive event for `command_id` and return the step the /// plugin command produces. Interactive events are not expected to trigger /// nested host API calls, so this path does not supply a `HostApi`. @@ -573,30 +610,15 @@ impl PluginProcess { other => Err(PluginError::UnexpectedResponse(Box::new(other))), } } else { - self.send_request(HostRequest::InteractiveEvent { command_id, event })?; - let kind = "InteractiveEvent"; - let timeout = request_timeout(kind); - let deadline = Instant::now() + timeout; - loop { - match recv_with_deadline::( - &self.stream, - &self.child, - deadline, - timeout, - kind, - )? { - PluginToHost::Response(HostResponse::CommandStep(s)) => return Ok(*s), - PluginToHost::Response(other) => { - return Err(PluginError::UnexpectedResponse(Box::new(other))) - } - PluginToHost::Request(req) => { - let resp = crate::ipc::protocol::PluginResponse::Error(format!( - "unexpected nested request during interactive event: {req:?}" - )); - self.send_response(resp)?; - } - } - } + self.call_simple( + HostRequest::InteractiveEvent { command_id, event }, + "InteractiveEvent", + "interactive event", + |resp| match resp { + HostResponse::CommandStep(s) => Ok(*s), + other => Err(Box::new(other)), + }, + ) } } @@ -622,30 +644,15 @@ impl PluginProcess { other => Err(PluginError::UnexpectedResponse(Box::new(other))), } } else { - self.send_request(HostRequest::GetPrompt { command_id })?; - let kind = "GetPrompt"; - let timeout = request_timeout(kind); - let deadline = Instant::now() + timeout; - loop { - match recv_with_deadline::( - &self.stream, - &self.child, - deadline, - timeout, - kind, - )? { - PluginToHost::Response(HostResponse::Text(s)) => return Ok(s), - PluginToHost::Response(other) => { - return Err(PluginError::UnexpectedResponse(Box::new(other))) - } - PluginToHost::Request(req) => { - let resp = crate::ipc::protocol::PluginResponse::Error(format!( - "unexpected nested request during get_prompt: {req:?}" - )); - self.send_response(resp)?; - } - } - } + self.call_simple( + HostRequest::GetPrompt { command_id }, + "GetPrompt", + "get_prompt", + |resp| match resp { + HostResponse::Text(s) => Ok(s), + other => Err(Box::new(other)), + }, + ) } } @@ -662,30 +669,15 @@ impl PluginProcess { other => Err(PluginError::UnexpectedResponse(Box::new(other))), } } else { - self.send_request(HostRequest::NeedsEntityPick { command_id })?; - let kind = "NeedsEntityPick"; - let timeout = request_timeout(kind); - let deadline = Instant::now() + timeout; - loop { - match recv_with_deadline::( - &self.stream, - &self.child, - deadline, - timeout, - kind, - )? { - PluginToHost::Response(HostResponse::Bool(b)) => return Ok(b), - PluginToHost::Response(other) => { - return Err(PluginError::UnexpectedResponse(Box::new(other))) - } - PluginToHost::Request(req) => { - let resp = crate::ipc::protocol::PluginResponse::Error(format!( - "unexpected nested request during needs_entity_pick: {req:?}" - )); - self.send_response(resp)?; - } - } - } + self.call_simple( + HostRequest::NeedsEntityPick { command_id }, + "NeedsEntityPick", + "needs_entity_pick", + |resp| match resp { + HostResponse::Bool(b) => Ok(b), + other => Err(Box::new(other)), + }, + ) } } diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs index 1edac6e5b..dfcd7b4a1 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -16,65 +16,7 @@ use crate::ipc::v4::protocol::{HostToPluginV4, NotificationEnvelope}; use crate::ipc::v4::server::{ default_notify_rate_limit, run_host_reader_thread, HostIncoming, RateLimiter, V4HostShared, }; -use crate::process::PluginError; - -/// Default maximum time to wait for a plugin call to respond. -const CALL_TIMEOUT_DEFAULT: Duration = Duration::from_secs(30); - -fn call_timeout() -> Duration { - std::env::var("OCS_PLUGIN_CALL_TIMEOUT_SECS") - .ok() - .and_then(|s| s.parse().ok()) - .map(Duration::from_secs) - .unwrap_or(CALL_TIMEOUT_DEFAULT) -} - -/// Per-request-kind timeout floors. -fn request_timeout(kind: &'static str) -> Duration { - base_max_floor(call_timeout(), kind) -} - -fn execute_code_timeout() -> Duration { - const DEFAULT: Duration = Duration::from_secs(60); - std::env::var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS") - .ok() - .and_then(|s| s.parse().ok()) - .map(Duration::from_secs) - .unwrap_or(DEFAULT) - .max(DEFAULT) -} - -fn base_max_floor(base: Duration, kind: &'static str) -> Duration { - #[cfg(test)] - if let Some(secs) = std::env::var("OCS_PLUGIN_TEST_FLOOR_SECS") - .ok() - .and_then(|s| s.parse().ok()) - { - return base.max(Duration::from_secs(secs)); - } - let floor = match kind { - "GetManifest" | "GetRibbon" => Duration::from_secs(5), - "Dispatch" => Duration::from_secs(10), - "InteractiveEvent" | "GetPrompt" | "NeedsEntityPick" => Duration::from_secs(2), - "ExecuteCode" => execute_code_timeout(), - _ => Duration::from_secs(1), - }; - base.max(floor) -} - -fn request_kind(req: &HostRequest) -> &'static str { - match req { - HostRequest::GetManifest => "GetManifest", - HostRequest::GetRibbon => "GetRibbon", - HostRequest::Dispatch { .. } => "Dispatch", - HostRequest::InteractiveEvent { .. } => "InteractiveEvent", - HostRequest::GetPrompt { .. } => "GetPrompt", - HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick", - HostRequest::ExecuteCode { .. } => "ExecuteCode", - HostRequest::DropInteractive { .. } => "DropInteractive", - HostRequest::Shutdown => "Shutdown", - } -} +use crate::process::{request_kind, request_timeout, PluginError}; type DeferredRequest = (u64, Option, Box); diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 9aa9b8bfc..8b28027d5 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 = "568a12c", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7ea4247", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index d9a7576f1..0236b7294 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "8a28c21", features = ["serde"] } diff --git a/docs/images/perspective-depth-after.png b/docs/images/perspective-depth-after.png new file mode 100644 index 000000000..95d2a3ff2 Binary files /dev/null and b/docs/images/perspective-depth-after.png differ diff --git a/docs/images/perspective-depth-before.png b/docs/images/perspective-depth-before.png new file mode 100644 index 000000000..6e5a628be Binary files /dev/null and b/docs/images/perspective-depth-before.png differ diff --git a/docs/images/spacemouse-menu.png b/docs/images/spacemouse-menu.png new file mode 100644 index 000000000..996ae27c1 Binary files /dev/null and b/docs/images/spacemouse-menu.png differ diff --git a/docs/images/spacemouse-preferences.png b/docs/images/spacemouse-preferences.png new file mode 100644 index 000000000..f6376e456 Binary files /dev/null and b/docs/images/spacemouse-preferences.png differ diff --git a/docs/perspective-depth.md b/docs/perspective-depth.md new file mode 100644 index 000000000..7ebaa9301 --- /dev/null +++ b/docs/perspective-depth.md @@ -0,0 +1,38 @@ +# Perspective draw-order clipping regression + +Switching a flat model view from orthographic to perspective could hide otherwise +visible lines. The camera applied an OpenGL-to-WebGPU depth conversion to a +projection that already used WebGPU's depth range. The shaders then applied a +fixed draw-order offset that could push geometry past the far clipping boundary. + +The camera now uses the DirectX/WebGPU projection directly. The shared +`draw_order.wgsl` function keeps the existing offset away from clipping planes +and reduces it near either plane. Its adjustment is monotonic in draw order, +leaves neutral geometric depth unchanged, and does not move clipped vertices +back into range. It is shared by the native and compatibility shader paths. + +## GPU reproduction + +These are offscreen renders from the application's wire pipelines. All 17 lines +are on the same plane; only their draw-order values differ. The first two lines +(red when visible) were clipped in perspective. Both projections now show all +17 lines. This synthetic drawing contains no user CAD geometry. + +| Before | After | +| --- | --- | +| ![Two leftmost lines missing](images/perspective-depth-before.png) | ![All 17 lines visible](images/perspective-depth-after.png) | + +## Validation + +```sh +cargo test --locked --lib +cargo test --locked --lib scene::pipeline::depth_tests -- --ignored --nocapture +``` + +The GPU tests exercise storage and packed wire pipelines, near/far clipping, +coplanar draw order in both submission orders, and wire occlusion by a triangle +mesh. Normal tests check camera depth endpoints, picking, and validation of all +14 composed draw-order shaders. + +Set `OCS_DEPTH_TEST_IMAGES` to an output directory to save the orthographic and +perspective reproduction images while running the GPU tests. diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index b83e315a6..439c41d37 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -101,13 +101,16 @@ affect whether a prebuilt plugin can safely be loaded: A plugin's `plugin.toml` declares the same values under `[opencad]`. For API v4 and later, `rustc_version` is required and the host refuses to load the plugin -unless it matches exactly. This turns the current silent runner crash into a -one-line diagnosis such as: +unless it matches exactly — omitting it is treated as a mismatch, not skipped. +This turns the current silent runner crash into a one-line diagnosis such as: ``` Plugin built with rustc 1.96.0, host requires 1.98.0 - rebuild required ``` +`acadrust_source` is also required for API v4 and later. Missing, empty, or +mismatched metadata makes the package incompatible. + ### `PluginManifest` ```rust @@ -202,11 +205,14 @@ struct ExampleModule; impl CadModule for ExampleModule { fn id(&self) -> &'static str { "example" } fn title(&self) -> &'static str { "Example" } - fn ribbon_groups(&self) -> Vec { - vec![RibbonGroup { title: "Demo", tools: vec![RibbonItem::LargeTool(ToolDef { + // Returns a slice, not an owned Vec — build once into a static and hand + // back a reference, rather than rebuilding (and dropping) it every call. + fn ribbon_groups(&self) -> &[RibbonGroup] { + static GROUPS: std::sync::OnceLock> = std::sync::OnceLock::new(); + GROUPS.get_or_init(|| vec![RibbonGroup { title: "Demo", tools: vec![RibbonItem::LargeTool(ToolDef { id: "EX_HELLO", label: "Hello", icon: IconKind::Glyph("◆"), event: ModuleEvent::Command("EX_HELLO".to_string()), - })]}] + })]}]}) } } @@ -235,8 +241,9 @@ api_version = 5 ribbon_order = 50 command_prefixes = ["EX_"] xdata_apps = [] -# Filled with the exact compiler output while staging the release asset. +# Both are filled at release-build time; see the CI step below. rustc_version = "rustc 1.98.0 (hash date)" +acadrust_source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=#" ``` The full, buildable scaffold is in [`docs/plugin-template/`](plugin-template); @@ -255,6 +262,10 @@ tool fires `ModuleEvent::Command("EX_FOO")`, which round-trips to tool request a native file picker; on selection the host dispatches `" "` back with the path's original case preserved. +A leading `>` or the persistent literal-input toggle preserves both spaces +and case. Automation and `ModuleEvent::PluginFileDialog` already preserve +case without this prefix. + ### Interactive (click-to-place) commands For tools that collect points — placing a structure, drawing a pipe — call @@ -306,7 +317,18 @@ plugin.toml ``` A GitHub Actions matrix workflow (see the example repo / template) cross-builds -and uploads these on a `v*` tag. +and uploads these on a `v*` tag. It also fills `plugin.toml`'s `rustc_version` +and `acadrust_source` placeholders from the CI run's actual toolchain and +resolved dependency, rather than hand-editing them per release — see +`docs/plugin-template/.github/workflows/release.yml` for the exact `sed` +substitutions, including reading `acadrust_source` out of `Cargo.lock`'s +resolved `acadrust` package entry (`source = "git+...#"`) since that's +the same value the host embeds at its own build time. The template's CI pins +`rust-toolchain@stable`, which is a floating target — a real release pipeline +should pin the exact toolchain version the host was built with (check +`crates/ocs_plugin_api/src/version_info.rs`'s embedded value, or the host's +own CI config) rather than trusting "whatever's current stable today" to +keep matching it. --- @@ -383,6 +405,9 @@ dynamic libraries: - **Enable/disable** — toggles a loaded plugin's ribbon tab + dispatch without uninstalling. +One linked repository is one marketplace entry. Publish separate plugins from +separate repositories so release and update histories stay unambiguous. + --- ## Compatibility & ABI @@ -391,9 +416,9 @@ Plugins are loaded as `cdylib`s by a plugin-runner child process. The host spawn this child from its own executable (`--ocs-plugin-runner` mode), so the runner and host always share the same `ocs_plugin_api` build. The runner checks `ocs_plugin_api_version` before any plugin code runs. Each plugin runs in its -own OS process, so the host is protected from plugin crashes and memory -corruption. Process isolation removes the need for the host and plugin to share -a Rust toolchain ABI beyond the stable `ocs_plugin_api` contract. +own OS process, so a plugin crash or memory-corruption bug cannot take down the +host process itself. The runner loads the plugin library in-process, however, +so API v4+ requires matching `rustc_version` and `acadrust_source` metadata. A future hardening step is a `#[repr(C)]` vtable (a true C ABI) so binaries built by any toolchain interoperate — required before trusting prebuilt binaries from @@ -414,6 +439,12 @@ Done: reinstall / uninstall, enable/disable. - [x] Interactive command round-trip over IPC (prompt, point/enter/object-pick). - [x] Spawn and per-call IPC timeouts so a stuck runner cannot freeze the host. +- [x] External automation (`--mcp` — local stdio MCP/JSON-RPC server for AI + clients; `--serve` — headless JSON automation server; both drive OCS + from a separate process without the GUI, per `src/cli.rs`/`src/mcp.rs`/ + `app::serve`). This closes the "drive OCS headless from a process" goal + of issue #29; this line previously and incorrectly listed it as not yet + started. Next: @@ -421,7 +452,6 @@ Next: - [ ] `#[repr(C)]` vtable / strict handshake for cross-toolchain binaries. - [ ] Trust: checksums / signatures before spawning plugin processes. - [ ] Interchange (LandXML / SWMM) and live `on_entity_committed` hooks. -- [ ] External automation API (drive OCS headless from a process) — issue #29. --- diff --git a/docs/plugin-template-v2/Cargo.toml b/docs/plugin-template-v2/Cargo.toml index 23a662f1f..44d62f7eb 100644 --- a/docs/plugin-template-v2/Cargo.toml +++ b/docs/plugin-template-v2/Cargo.toml @@ -12,4 +12,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c" } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "8a28c21" } diff --git a/docs/plugin-template-v2/DESIGN.md b/docs/plugin-template-v2/DESIGN.md index 8a30078cf..0231ff6c9 100644 --- a/docs/plugin-template-v2/DESIGN.md +++ b/docs/plugin-template-v2/DESIGN.md @@ -64,8 +64,10 @@ From the workspace root: cargo build -p plugin-template-v2 ``` -The resulting cdylib plus `plugin.toml` can be installed into the host's -plugins folder (see `docs/plugin-architecture.md`). +The resulting cdylib plus `plugin.toml` (checked in alongside this file) can +be installed into the host's plugins folder (see `docs/plugin-architecture.md`). +Replace `plugin.toml`'s `rustc_version` and `acadrust_source` placeholders in +your packaging step. The release workflow in `docs/plugin-template` shows how. --- diff --git a/docs/plugin-template-v2/plugin.toml b/docs/plugin-template-v2/plugin.toml new file mode 100644 index 000000000..b57c58a3b --- /dev/null +++ b/docs/plugin-template-v2/plugin.toml @@ -0,0 +1,23 @@ +[plugin] +id = "com.example.plugin-template-v2" +name = "Plugin Template v2" +version = "0.1.0" +description = "Demonstrates read/write object round-trips with the host." +author = "Your Name" +license = "GPL-3.0-only" +repository = "your-account/your-plugin-repo" + +[opencad] +api_version = 5 +# Replace both placeholders in the packaging step. The v1 template's +# release workflow contains a working example. +rustc_version = "__RUSTC_VERSION__" +acadrust_source = "__ACADRUST_SOURCE__" +ribbon_order = 100 +command_prefixes = [ + "COUNT_SURVEY_POINTS", + "ADD_SURVEY_POINT", + "MARK_FIRST_SURVEY_POINT", + "COUNT_MARKED_SURVEY_POINTS", +] +xdata_apps = ["SURVEYMARK"] diff --git a/docs/plugin-template/.github/workflows/release.yml b/docs/plugin-template/.github/workflows/release.yml index 76588a1eb..3b3b1e45e 100644 --- a/docs/plugin-template/.github/workflows/release.yml +++ b/docs/plugin-template/.github/workflows/release.yml @@ -29,6 +29,14 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + # NOTE: @stable floats to whatever's current when this job runs, which + # is NOT the same thing as "matches the host's rustc exactly" — the API + # v4+ load check compares plugin.toml's rustc_version to the host's + # build-time compiler byte-for-byte (see plugin-architecture.md's + # "Build-time ABI metadata"). Pin this to the host's actual toolchain + # version (check crates/ocs_plugin_api/src/version_info.rs's embedded + # value, or the host's own CI config) before a real release, or your + # binary may be refused at load with a version-mismatch error. - uses: dtolnay/rust-toolchain@stable - run: cargo build --release - name: Stage release asset @@ -36,7 +44,11 @@ jobs: run: | mkdir -p dist cp target/release/*.${{ matrix.ext }} "dist/${{ matrix.asset }}" - sed "s|__RUSTC_VERSION__|$(rustc --version)|" plugin.toml > dist/plugin.toml + acadrust_source=$(grep -A3 '^name = "acadrust"' Cargo.lock | grep '^source' | head -1 | sed 's/^source = "//;s/"$//') + sed \ + -e "s|__RUSTC_VERSION__|$(rustc --version)|" \ + -e "s|__ACADRUST_SOURCE__|$acadrust_source|" \ + plugin.toml > dist/plugin.toml - uses: softprops/action-gh-release@v2 with: files: | diff --git a/docs/plugin-template/plugin.toml b/docs/plugin-template/plugin.toml index d07f0b702..60b3e7fab 100644 --- a/docs/plugin-template/plugin.toml +++ b/docs/plugin-template/plugin.toml @@ -10,6 +10,8 @@ repository = "your-account/your-plugin-repo" [opencad] api_version = 5 rustc_version = "__RUSTC_VERSION__" +# release.yml fills both placeholders from the compiler and Cargo.lock. +acadrust_source = "__ACADRUST_SOURCE__" ribbon_order = 60 command_prefixes = ["MP_"] xdata_apps = ["MYPLUGIN_RECORD"] diff --git a/docs/screenshots/dimension-layout/after-diameter.png b/docs/screenshots/dimension-layout/after-diameter.png new file mode 100644 index 000000000..c517f24fc Binary files /dev/null and b/docs/screenshots/dimension-layout/after-diameter.png differ diff --git a/docs/screenshots/dimension-layout/after-linear-fit.png b/docs/screenshots/dimension-layout/after-linear-fit.png new file mode 100644 index 000000000..22fac4c4b Binary files /dev/null and b/docs/screenshots/dimension-layout/after-linear-fit.png differ diff --git a/docs/screenshots/dimension-layout/after-linear-radius.png b/docs/screenshots/dimension-layout/after-linear-radius.png new file mode 100644 index 000000000..bbe802e0e Binary files /dev/null and b/docs/screenshots/dimension-layout/after-linear-radius.png differ diff --git a/docs/screenshots/dimension-layout/after-radial-leaders.png b/docs/screenshots/dimension-layout/after-radial-leaders.png new file mode 100644 index 000000000..61f56aa6e Binary files /dev/null and b/docs/screenshots/dimension-layout/after-radial-leaders.png differ diff --git a/docs/screenshots/dimension-layout/before-diameter.png b/docs/screenshots/dimension-layout/before-diameter.png new file mode 100644 index 000000000..39e5cd8a1 Binary files /dev/null and b/docs/screenshots/dimension-layout/before-diameter.png differ diff --git a/docs/screenshots/dimension-layout/before-linear-fit.png b/docs/screenshots/dimension-layout/before-linear-fit.png new file mode 100644 index 000000000..deaaca293 Binary files /dev/null and b/docs/screenshots/dimension-layout/before-linear-fit.png differ diff --git a/docs/screenshots/dimension-layout/before-linear-radius.png b/docs/screenshots/dimension-layout/before-linear-radius.png new file mode 100644 index 000000000..87e8370ed Binary files /dev/null and b/docs/screenshots/dimension-layout/before-linear-radius.png differ diff --git a/docs/screenshots/dimension-layout/before-radial-leaders.png b/docs/screenshots/dimension-layout/before-radial-leaders.png new file mode 100644 index 000000000..f220ea410 Binary files /dev/null and b/docs/screenshots/dimension-layout/before-radial-leaders.png differ diff --git a/docs/screenshots/dimension-limits/after.png b/docs/screenshots/dimension-limits/after.png new file mode 100644 index 000000000..7b7ac61d2 Binary files /dev/null and b/docs/screenshots/dimension-limits/after.png differ diff --git a/docs/screenshots/dimension-limits/before.png b/docs/screenshots/dimension-limits/before.png new file mode 100644 index 000000000..1c919e6ed Binary files /dev/null and b/docs/screenshots/dimension-limits/before.png differ diff --git a/docs/screenshots/dimension-placement/after-dimaligned.png b/docs/screenshots/dimension-placement/after-dimaligned.png new file mode 100644 index 000000000..3e5feabea Binary files /dev/null and b/docs/screenshots/dimension-placement/after-dimaligned.png differ diff --git a/docs/screenshots/dimension-placement/after-dimlinear.png b/docs/screenshots/dimension-placement/after-dimlinear.png new file mode 100644 index 000000000..12d15a7be Binary files /dev/null and b/docs/screenshots/dimension-placement/after-dimlinear.png differ diff --git a/docs/screenshots/dimension-placement/before-dimaligned.png b/docs/screenshots/dimension-placement/before-dimaligned.png new file mode 100644 index 000000000..72bb73263 Binary files /dev/null and b/docs/screenshots/dimension-placement/before-dimaligned.png differ diff --git a/docs/screenshots/dimension-placement/before-dimlinear.png b/docs/screenshots/dimension-placement/before-dimlinear.png new file mode 100644 index 000000000..ff44fdb0a Binary files /dev/null and b/docs/screenshots/dimension-placement/before-dimlinear.png differ diff --git a/docs/screenshots/dimension-preview/after-dimangular-placement.png b/docs/screenshots/dimension-preview/after-dimangular-placement.png new file mode 100644 index 000000000..974845828 Binary files /dev/null and b/docs/screenshots/dimension-preview/after-dimangular-placement.png differ diff --git a/docs/screenshots/dimension-preview/after-dimlinear-placement.png b/docs/screenshots/dimension-preview/after-dimlinear-placement.png new file mode 100644 index 000000000..da048d2db Binary files /dev/null and b/docs/screenshots/dimension-preview/after-dimlinear-placement.png differ diff --git a/docs/screenshots/dimension-preview/after-dimlinear-second-point.png b/docs/screenshots/dimension-preview/after-dimlinear-second-point.png new file mode 100644 index 000000000..6328cc973 Binary files /dev/null and b/docs/screenshots/dimension-preview/after-dimlinear-second-point.png differ diff --git a/docs/screenshots/dimension-preview/before-dimangular-placement.png b/docs/screenshots/dimension-preview/before-dimangular-placement.png new file mode 100644 index 000000000..c335d74d5 Binary files /dev/null and b/docs/screenshots/dimension-preview/before-dimangular-placement.png differ diff --git a/docs/screenshots/dimension-preview/before-dimlinear-placement.png b/docs/screenshots/dimension-preview/before-dimlinear-placement.png new file mode 100644 index 000000000..e655e5629 Binary files /dev/null and b/docs/screenshots/dimension-preview/before-dimlinear-placement.png differ diff --git a/docs/screenshots/dimension-preview/before-dimlinear-second-point.png b/docs/screenshots/dimension-preview/before-dimlinear-second-point.png new file mode 100644 index 000000000..1d4e7a1af Binary files /dev/null and b/docs/screenshots/dimension-preview/before-dimlinear-second-point.png differ diff --git a/docs/spacemouse.md b/docs/spacemouse.md new file mode 100644 index 000000000..4e0ac1129 --- /dev/null +++ b/docs/spacemouse.md @@ -0,0 +1,145 @@ +# SpaceMouse + +The Windows desktop app supports 3Dconnexion SpaceMouse devices through the +installed [3DxWare driver](https://3dconnexion.com/drivers/). Open CAD Studio +loads NavLib from the Windows system directory at runtime. An SDK installation +is not needed to build or run the app. macOS, Linux, and the web build currently +show an unavailable state; their device adapters are not implemented. + +## Navigation + +Open **Options → User Preferences → SpaceMouse**, or enter `SPACEMOUSE`. + +| Mode | Behavior | +| --- | --- | +| Follow context | Pan and zoom on a paper sheet; pan, zoom, and orbit in model views. | +| Pan only | Push or tilt the puck to pan in the current screen plane. Twist and vertical pressure are ignored. Scale, depth, and orientation stay fixed, including in perspective. | +| Pan and zoom | Uses the same push/tilt panning as Pan only. Lift or press the puck to zoom about the view center. Twist is ignored and orientation stays fixed. | +| 3D navigation | Pan, zoom, and orbit in model views. Paper sheets keep orientation fixed. | + +For a flat drawing, choose **Pan only** to use the puck as a panning joystick. +Push or tilt right/left to move the drawing right/left; push or tilt away/toward +you to move it up/down. Pushing and tilting together does not double the speed. +Enable **Reverse pan direction** if you prefer moving the viewpoint in the same +direction as the puck. **Reverse pan direction** and **Pan speed** apply to both +flat-navigation modes, including paper sheets in Follow context. A small dead zone +filters incidental forces, and releasing the puck stops movement. +The app does not infer that a model-space file is 2D from its extension or from +the current camera angle. These preferences belong to the user and persist +across drawings and sessions. + +The status bar shows the effective mode after a device connects, and keeps the +control available after disconnection. An explicitly selected navigation mode +also makes it visible. Its menu provides mode selection, Pause/Resume, preferences, +and a shortcut to 3Dconnexion settings. Status-bar customization can hide it. + +Only the focused drawing receives input. Dialogs suspend navigation. The +view metadata remains available to the driver while its settings UI has focus. +Native 3DxWare chooses the foreground application; the adapter marks its view +active without forcing the NLServer-specific keyboard-focus property. Entered +paper-space viewports use their own cameras; their display locks are respected. +The active model tile receives navigation in a split view. Moving the puck +preserves an unfinished drawing command, refreshes its preview at a stationary +pointer, and keeps an active selection-box anchor attached to the drawing. + +![SpaceMouse menu with Pan only selected](images/spacemouse-menu.png) + +![SpaceMouse settings in User Preferences](images/spacemouse-preferences.png) + +## Buttons and radial menus + +Use **Open 3Dconnexion settings…** to assign buttons and radial-menu entries. +The app exports registered CAD command IDs and reuses ribbon labels and icons +where available. Speed, axis inversion, dominant-axis filtering, and radial-menu +layout for SDK navigation remain in the driver settings. Pan only and Pan and zoom +share the app's push/tilt mapping and Pan speed control; driver axis remappings do +not apply to these flat-navigation modes. Button assignments continue to work in +all modes. + +For orbiting with a fixed up direction, enable **Lock Horizon** in the driver's +Advanced Settings for Open CAD Studio. The adapter reads `settings.LockHorizon` +through NavLib at connection/focus and after `settings.changed` notifications. +When enabled, accepted orbit matrices remove roll relative to world Z. Pitch +stops just short of the top/bottom pole, retaining heading instead of flipping +the view. In the driver's Object mode the eye receives the same pitch correction +around the SDK's orbit pivot, so the object stays in place; Camera/Helicopter +modes retain their proposed eye position. Disabling Lock Horizon restores +unrestricted driver rotation. +The app does not duplicate this control, the driver's rotation lock, or +its object/camera navigation modes. + +Useful actions include Undo, Redo, Cancel, Fit view, Top view, and these commands: + +| Command | Action | +| --- | --- | +| `SPACEMOUSE` | Open SpaceMouse preferences | +| `SPACEMOUSEPAUSE` | Pause/resume puck navigation for this session | +| `SPACEMOUSEAUTO` | Follow context | +| `SPACEMOUSEPAN` | Pan only | +| `SPACEMOUSEPANZOOM` | Pan and zoom | +| `SPACEMOUSE3D` | 3D navigation | + +Fit view (`SPACEMOUSEFIT`) and Top view (`SPACEMOUSETOP`) are also available as +button/keyboard actions. Button actions share the keyboard dispatcher: for +example, Undo removes the last entered point of an unfinished polyline. A +Pause/Resume button remains usable while puck navigation is paused. + +## Implementation + +- `src/input/spacemouse/`: portable preferences, camera snapshots, and a bounded + thread-safe bridge. `navlib.rs` contains the Windows ABI and runtime loading. + `raw_input.rs` receives SpaceMouse multi-axis HID reports for planar navigation; + `pan.rs` maps pushes and tilts to screen velocity and vertical pressure to zoom. + The planar path suppresses SDK camera updates so one gesture cannot be applied + twice. Only 3Dconnexion/legacy Logitech multi-axis + controllers are accepted. Other HID devices and report types are ignored. + `worker.rs` waits for app work and dispatches Windows messages on the SDK's + owning thread, including settings changes while the drawing is unfocused. +- `src/app/navigation.rs`: focus/context policy, cached selection bounds, camera + application, pivot feedback, and the action catalog. +- `src/scene/mspace.rs`: routing to the active model or floating-viewport camera. +- `src/ui/window/options/spacemouse.rs` and `src/ui/statusbar/spacemouse.rs`: + controls within the existing Options and status-menu systems. +- `src/app/settings.rs`: preferences; `src/app/shortcuts.rs`: shared actions. + +Driver callbacks operate on snapshots and never access the scene or GUI. A +navigation transaction is coalesced into one pending camera update. Target +stamps discard late motion after a tab, layout, viewport, tile, or camera change. +Acknowledging an applied frame retains newer callbacks from the same gesture; +an external camera change resets the SDK navigation model. Raw pan state is +cleared on context/focus changes, pause, disconnection, and stale reports. +Button requests retain their context across camera revisions. Losing focus or +changing drawing context discards queued input. Connection shutdown closes +NavLib before releasing callback, command, image, or library storage. +The subscription joins its worker at shutdown so NavLib can finish persisting +the driver's per-application preferences before the process exits. + +For driver-setting diagnostics, set +`RUST_LOG=OpenCADStudio::input::spacemouse=debug` before launching. The trace +records settings-change notifications and the Lock Horizon value read from +NavLib; it does not change the user's driver settings. + +Iced supplies monotonic frame timestamps while navigation is active. Idle +drawings do not request continuous rendering. Driver-originated camera updates +are not echoed back as external-camera notifications, which would reset the +driver's navigation model during a held input. +The optional SDK `view.target` property returns no-data: the app's camera center +does not represent the SDK's separate target-camera navigation model. + +The adapter uses the NavLib C ABI described in the 3DxWare SDK headers +(`navlib.h`, `navlib_types.h`, and `siappcmd_types.h`). No vendor headers or +runtime binaries are included. SpaceMouse and 3Dconnexion are trademarks of +3Dconnexion. + +## Validation + +```sh +cargo test --locked --lib spacemouse +``` + +An opt-in smoke test checks the installed driver, command/icon export, device +presence, and reading its Lock Horizon setting: + +```sh +cargo test --locked --lib installed_driver_connects_and_reports_device_presence -- --ignored --nocapture +``` diff --git a/docs/viewport-dimension-association.svg b/docs/viewport-dimension-association.svg new file mode 100644 index 000000000..8a7a06428 --- /dev/null +++ b/docs/viewport-dimension-association.svg @@ -0,0 +1,19 @@ + + + + +The dimension follows its paper-space centerline + + +BeforeCenterline moved 1 paper unit right + + + + + + + +5040 +The model endpoint stays fixed. The dimension updates from 50 to 40. +Association schematic; viewport 1:10, dimension style unit factor = 1, DIMASSOC = 2. + diff --git a/docs/viewport-dimension-creation.svg b/docs/viewport-dimension-creation.svg new file mode 100644 index 000000000..efd1464c1 --- /dev/null +++ b/docs/viewport-dimension-creation.svg @@ -0,0 +1,22 @@ + + + + +Dimension from a paper centerline to a model endpoint + +Viewport 1:10 + + + +Model geometry + + + +50 +Brown dashed line: paper-space centerline +5 paper units between the picked points +5 × 10 = 50 model units +The viewport supplies the length scale. +Either pick order gives the same value. +Measurement schematic; dimension style unit factor = 1. + diff --git a/docs/viewport-dimensions.md b/docs/viewport-dimensions.md new file mode 100644 index 000000000..2190bdeb2 --- /dev/null +++ b/docs/viewport-dimensions.md @@ -0,0 +1,74 @@ +# Dimensions through layout viewports + +In paper space, snap to model geometry displayed through a planar, orthographic +viewport. `DIMLINEAR`, `DIMALIGNED`, `DIMANGULAR`, `DIMRADIUS`, and `DIMDIAMETER` +also support their usual object-pick modes through these viewports. + +## Measurement + +- Model geometry supplies the viewport scale. A 100-unit model length displayed + at 1:10 measures 100, although it occupies 10 paper units. +- A paper-space centerline or free sheet point can be combined with model + geometry through one viewport, in either pick order. The paper-space span is + measured using that viewport's scale. +- Sheet-only dimensions measure paper units, even inside a viewport rectangle. +- Model picks from a second viewport are rejected without advancing the command. + Dimension-line and text placement do not change the measurement scale. +- The current dimension style's unit conversion is preserved. Angular dimensions + do not use length compensation. `DIMASSOC=0` explodes the compensated dimension. + +Snapping and object picking respect visibility, clipping, and viewport draw order. +Paper geometry has priority in object picks. Ordinary selection uses the active +drawing space. Object picks reuse the resident interaction index and resolve +nested block transforms. + +![Measurement schematic: a five-unit sheet span measures 50 through a 1:10 viewport.](viewport-dimension-creation.svg) + +## Saved scale + +Length dimensions store paper-space definition points and a negative `DIMLFAC` +override: user unit factor multiplied by `1 / viewport scale`. +`OCS_VIEWPORT_MEASUREMENT` XData stores version `1` (integer16), the positive user +factor (real), and viewport compensation (real). Keeping the factors separate +allows compensation to change without losing the user's unit conversion. +Both the override and XData survive DXF/DWG saves and undo/redo. + +## Associations + +With `DIMASSOC=2`, acquired features retain their viewport, block-instance path, +source entity, and snap feature. Intersections retain both source chains. +Source edits and viewport changes update the dimension and its compensation, +preserving style overrides and user-positioned text on the sheet. + +A sheet centerline stays on the sheet; a model endpoint follows its viewport. +Moving the viewport therefore changes the distance between them. Undo/redo and +DXF/DWG saves preserve the references. + +Properties shows association status. If a required reference is broken or cannot +be resolved, the entire dimension keeps its last valid geometry and reference +records. A successful refresh regenerates its displayed and exported geometry. + +![Association schematic: moving the sheet centerline changes the dimension from 50 to 40.](viewport-dimension-association.svg) + +## Limits + +Unsupported cases preserve saved dimensions: + +- Perspective or oblique views; missing or malformed reference chains. +- Array instances without an addressable instance index, nesting beyond eight + levels, and radial measurements of nonuniformly scaled circles or arcs. +- Intersections without two identifiable source chains inside a batched block. +- Ambiguous imported endpoint, midpoint, or quadrant markers. A stored point can + validate a feature; it cannot establish an otherwise unknown identity. +- Tangency without a verified solution, or two perpendicular/tangent references + requiring a coupled solve. +- Imported viewport scaling without OCS scale metadata or + `ACAD_DIMASSOC_CALC_DIMLFAC` identifying the saved compensation. + +## Tests + +Run `cargo test --locked --lib viewport_dimension`. Tests cover the five commands, +mixed sheet/model input, styles, clipping, object picks, source/viewport edits, +snap features, nested paths, intersections, preserved overrides, undo/redo, and +DXF/DWG persistence. The synthetic association fixture is in +`tests/fixtures/dim_assoc/`. diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..95a880e21 --- /dev/null +++ b/flake.lock @@ -0,0 +1,48 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1789149629, + "narHash": "sha256-H6GwaZzZf+4npqv0tph94w9tZddSjFjmQrVsW0z78uk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "eaad089433ca2bb662274377d33df3d0e51ef28b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1789196581, + "narHash": "sha256-yJr1Bt4fKkKIpPYbsKGqJ0VFdoDnURgRwuffmBQ2WzY=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "228ecefb6329d5a531b77b46b581a2f0c26ee056", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..89610a0b6 --- /dev/null +++ b/flake.nix @@ -0,0 +1,156 @@ +{ + description = "Open CAD Studio - 2D drafting and 3D modeling in Rust"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + # rust-overlay ships upstream Rust binaries rather than building them, so + # following nixpkgs costs it no binary cache. + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, rust-overlay }: + let + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forAllSystems = f: + nixpkgs.lib.genAttrs systems (system: f (import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + })); + in + { + packages = forAllSystems (pkgs: + let + inherit (pkgs) lib stdenv; + + toolchain = pkgs.rust-bin.stable.latest.default.override { + targets = [ "wasm32-unknown-unknown" ]; + }; + + # Build with the overlay's toolchain rather than the nixpkgs default, + # so the package and the devShell compile with the same rustc. + rustPlatform = pkgs.makeRustPlatform { + cargo = toolchain; + rustc = toolchain; + }; + + # winit/wgpu dlopen these at run time, so they must also be on the + # wrapped binary's LD_LIBRARY_PATH, not just at link time. + runtimeLibs = with pkgs; [ + libGL + libxkbcommon + vulkan-loader + wayland + xorg.libX11 + xorg.libXcursor + xorg.libXi + xorg.libXrandr + ]; + in + rec { + default = opencadstudio; + + opencadstudio = rustPlatform.buildRustPackage { + pname = "opencadstudio"; + version = (lib.importTOML ./Cargo.toml).package.version; + src = self; + + cargoLock = { + lockFile = ./Cargo.lock; + # One entry per distinct git source in Cargo.lock, keyed by any + # one package from it. Two of these are transitive and easy to + # miss by reading Cargo.toml alone: cryoglyph and dpi arrive + # through iced, which pins its own forks. Regenerate the list + # from the lock rather than the manifest: + # grep -B2 'source = "git+' Cargo.lock + # Each hash must be refreshed whenever the matching rev moves. + outputHashes = { + "acadrust-0.5.5" = "sha256-rYtJaJsb+lZZIDBtPTCfWRVyA1Gfinn3UqJlfzkhhGk="; + "cadkernel-0.1.0" = "sha256-7smcL9oo5mJwoCLYUbD4+lA9Xcm+/qpVswYxzoGjTDk="; + "cryoglyph-0.1.0" = "sha256-5BOJNDcjhpt17/XSri3JGtX8NwC7db18bddp5wanMes="; + "dpi-0.1.1" = "sha256-pQn1lCFSJMkjUfHoggEzMHnm5k+Chnzi5JEDjahnjUA="; + "iced-0.15.0-dev" = "sha256-Atg+nr1VgXwW5I7LiMAMqHemz/9ZiehTc2EWuSFEGHI="; + "iced_aw-0.14.1" = "sha256-OynSIjv8SgSoxpw1S4wH58vDdqga/YojOR5A9ZLKrd8="; + }; + }; + + nativeBuildInputs = with pkgs; [ + pkg-config + makeWrapper + ]; + + buildInputs = with pkgs; [ + fontconfig + freetype + ] ++ runtimeLibs + ++ lib.optionals stdenv.hostPlatform.isDarwin [ darwin.apple_sdk.frameworks.AppKit ]; + + # build.rs asks git for a revision and falls back to "unknown" + # when the repo or the binary is absent, which it is in the + # sandbox. Nothing to do but let it take the fallback. + doCheck = false; + + postInstall = lib.optionalString stdenv.hostPlatform.isLinux '' + wrapProgram $out/bin/OpenCADStudio \ + --prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath runtimeLibs} + ''; + + meta = { + description = "Open-source 2D drafting and 3D modeling, built with Rust"; + homepage = "https://www.opencadstudio.com"; + license = lib.licenses.gpl3Only; + mainProgram = "OpenCADStudio"; + platforms = systems; + }; + }; + }); + + devShells = forAllSystems (pkgs: + let + toolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ]; + targets = [ "wasm32-unknown-unknown" ]; + }; + runtimeLibs = with pkgs; [ + libGL + libxkbcommon + vulkan-loader + wayland + xorg.libX11 + xorg.libXcursor + xorg.libXi + xorg.libXrandr + ]; + in + { + default = pkgs.mkShell { + packages = with pkgs; [ + toolchain + pkg-config + fontconfig + freetype + git + # Web build: `trunk build` per Trunk.toml. wasm-bindgen-cli must + # match the wasm-bindgen version Cargo.lock resolves, the same + # constraint .github/workflows/pages.yml:83 reads out of the lock. + trunk + wasm-bindgen-cli + # Rasterises assets/logo.svg for packaging, as release.yml does. + librsvg + ] ++ runtimeLibs; + + # A dev binary is run straight out of ./target, so it never gets + # the wrapper the package build applies. + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs; + }; + }); + + checks = forAllSystems (pkgs: { + default = self.packages.${pkgs.stdenv.hostPlatform.system}.opencadstudio; + }); + + formatter = forAllSystems (pkgs: pkgs.nixpkgs-fmt); + }; +} diff --git a/fonts/README.md b/fonts/README.md new file mode 100644 index 000000000..29f88c7ff --- /dev/null +++ b/fonts/README.md @@ -0,0 +1,32 @@ +# OpenCADStudio Community Fonts + +Thư mục này là **kho font cộng đồng** của OpenCADStudio. Khi mở một bản vẽ +DWG/DXF tham chiếu font (`.shx`) không có trên máy, ứng dụng sẽ hỏi người dùng +và tự động tải font từ thư mục này, thay vì vẽ chữ bằng font thay thế bị lệch/vỡ. + +When a drawing references an `.shx` font that is missing on the machine, +OpenCADStudio offers to download it from this folder automatically instead of +rendering with a mismatched substitute font. + +## Cách đóng góp / How to contribute + +1. Fork repository, thêm font vào thư mục `fonts/` ở **thư mục gốc của repo** + (cùng cấp với `src/`, `crates/`, …), rồi mở Pull Request. +2. Giữ nguyên **tên file viết thường** như bản vẽ tham chiếu, ví dụ + `vnarial.shx`, `romans.shx`, `simplex.shx`. Ứng dụng so khớp tên file + không phân biệt hoa/thường. +3. Định dạng hỗ trợ: `.shx` (compiled shape font). +4. **Giấy phép / License**: chỉ đóng góp font bạn có quyền phân phối lại — + font do bạn tự vẽ, font nguồn mở, hoặc font thuộc public domain. + Ghi nguồn + giấy phép vào cuối file này theo mẫu bên dưới. + Only contribute fonts you have the right to redistribute, and record the + source and license below. + +Ứng dụng tải font về thư mục cài đặt người dùng +(`%APPDATA%/OpenCADStudio/fonts` trên Windows) và dùng lại cho các lần mở sau. + +## Danh mục font / Font catalog + +| File | Nguồn / Source | Giấy phép / License | +|------|----------------|---------------------| +| — | — | — | diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index ae2f9dcbb..11271f783 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = التطبيق الافتراضي .iced-theme = سمة Iced: .theme = السمة + .right-click-customization = تخصيص النقر بالزر الأيمن + .right-click-in-drawing-area = النقر بالزر الأيمن في منطقة الرسم + .hold-duration = مدة الضغط + .right-click-mode-help = قائمة الاختصارات: النقر بالزر الأيمن يفتح القائمة دائمًا. حساس للوقت: النقرة السريعة تعني Enter، والضغط المطوّل يفتح القائمة (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = قائمة الاختصارات + .right-click-mode-time-sensitive = حساس للوقت (نقرة سريعة = Enter) + .right-click-mode-enter-first = Enter أولًا، النقرة الثانية = القائمة command-line = .ready = ‏Open CAD Studio جاهز. @@ -249,6 +256,7 @@ command-move = *[other] { $count } كائن }]: .target = MOVE حدد الوجهة [الأساس { $x },{ $y }]: + .displacement = MOVE حدد الإزاحة: command-copy = .array-count = COPY أدخل عدد عناصر المصفوفة: @@ -271,6 +279,8 @@ command-copy = [many] { $count } نسخة حتى الآن *[other] { $count } نسخة حتى الآن } | مصفوفة | Enter=إنهاء | الأساس { $x },{ $y }]: + .displacement = COPY حدد الإزاحة: + .mode = COPY أدخل خيار وضع النسخ [Single/Multiple] : common = .jogged-radius = نصف قطر منكسر @@ -1041,7 +1051,7 @@ common = .loft-surface-created = LOFT: تم إنشاء السطح. .loft-solid-created = LOFT: أُنشئ المجسم. .attsync-no-editable-block-references = ATTSYNC: لا توجد مراجع كتل قابلة للتحرير. - .xref-reloaded-with-repairs = XREF أُعيد التحميل مع إصلاحات: "__ocs_fmt_0__" + .xref-reloaded-with-repairs = XREF تم إعادة التحميل مع إصلاحات: "__ocs_fmt_0__" .xref-reload-failed = XREF فشل إعادة التحميل: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = العرض السابق .zoom-no-previous-view = ZOOM: لا يوجد عرض سابق. @@ -1627,7 +1637,7 @@ common = .create-triangular-face = إنشاء وجه مثلث .location = { common.position } .creation-mode = وضع الإنشاء - .path = المسار + .path = المسار: .expression = تعبير .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1880,6 +1890,391 @@ common = .dimensional = بُعدي .show-hide = إظهار/إخفاء + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = تحذير الحفظ .unable-to-save-drawing = تعذر حفظ الرسم @@ -2451,6 +2846,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = إزالة + .pline-specify-starting-width = PLINE حدد عرض البداية <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE حدد عرض النهاية <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE حدد نصف عرض البداية <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE حدد نصف عرض النهاية <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (خط) حدد النقطة التالية: + .pline-arc-specify-endpoint-of-arc = PLINE (قوس) حدد نقطة نهاية القوس: + .pline-specify-length-of-line = PLINE حدد طول الخط: + .pline-specify-included-angle = PLINE حدد الزاوية المحصورة: + .pline-specify-endpoint-of-arc = PLINE حدد نقطة نهاية القوس: + .pline-specify-center-point-of-arc = PLINE حدد مركز القوس: + .pline-specify-radius-of-arc = PLINE حدد نصف قطر القوس: + .pline-specify-direction-of-chord-for-arc = PLINE حدد اتجاه وتر القوس: + .pline-specify-length-of-chord = PLINE حدد طول الوتر: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE حدد اتجاه المماس عند نقطة بداية القوس: + .pline-specify-second-point-on-arc = PLINE حدد النقطة الثانية على القوس: + .halfwidth = نصف العرض + .center-keyword = المركز + .close-keyword = إغلاق + .second-pt = النقطة الثانية + .arc-specify-start-point-of-arc = ARC حدد نقطة بداية القوس: + .arc-specify-second-point-of-arc = ARC حدد النقطة الثانية للقوس: + .arc-specify-end-point-of-arc = ARC حدد نقطة نهاية القوس: + .arc-specify-center-point-of-arc = ARC حدد مركز القوس: + .arc-specify-included-angle = ARC حدد الزاوية المحصورة: + .arc-specify-length-of-chord = ARC حدد طول الوتر: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC حدد اتجاه المماس عند نقطة بداية القوس: + .arc-specify-radius-of-arc = ARC حدد نصف قطر القوس: + .chord-length = طول الوتر modify = .add-vertex = إضافة رأس @@ -2603,6 +3026,13 @@ modify = .tol-scale-dimtfac = مقياس التفاوت (DIMTFAC) .trim = قص .exit = خروج + .area-keyword = المساحة + .displacement = إزاحة + .mode-keyword = الوضع + .array-keyword = مصفوفة + .use-first-point-as-displacement = استخدام النقطة الأولى كإزاحة + .offset-erase-source-object-after-offsetting = OFFSET مسح الكائن المصدر بعد الإزاحة؟ [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET أدخل خيار الطبقة للكائنات المزاحة [Current/Source] : groups = .group-enter-group-name-name = GROUP أدخل اسم المجموعة [__ocs_arg_name__]: @@ -3137,6 +3567,93 @@ insert = .xref-external-references = XREF المراجع الخارجية: .xref-no-external-references-in-this-drawing = XREF لا توجد مراجع خارجية في هذا الرسم. .xref-reloaded = XREF أُعيد تحميل "__ocs_fmt_0__" + .xref-set-to-overlay = XREF: تم تعيين "__ocs_fmt_0__" إلى تراكب. + .xref-set-to-attach = XREF: تم تعيين "__ocs_fmt_0__" إلى إرفاق. + .xref-cannot-unload-nested-reference = XREF: لا يمكن إلغاء تحميل المرجع المتداخل '__ocs_fmt_0__'. ألغ تحميله في رسمته المضيفة. + .xref-cannot-overlay-nested-reference = XREF: لا يمكن تراكب المرجع المتداخل '__ocs_fmt_0__'. راكبه في رسمته المضيفة. + .xref-cannot-reload-nested-reference = XREF: لا يمكن إعادة تحميل المرجع المتداخل '__ocs_fmt_0__'. أعد تحميله في رسمته المضيفة. + .xref-cannot-attach-nested-reference = XREF: لا يمكن إرفاق المرجع المتداخل '__ocs_fmt_0__'. أرفقه في رسمته المضيفة. + .xref-cannot-set-path-type-nested-reference = XREF: لا يمكن تعيين نوع المسار للمرجع المتداخل '__ocs_fmt_0__'. عينه في رسمته المضيفة. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: إعادة التحميل تنطبق على مراجع الرسومات فقط. + .xref-bound = XREF: تم دمج "__ocs_fmt_0__". + .xref-bound-with-unremapped-handles = XREF: تم دمج "__ocs_fmt_0__" مع __ocs_fmt_1__ مقبض نمط غير معاد تعيينها (انظر قيود الدمج). + .xref-cannot-bind-nested-reference = XREF: لا يمكن دمج المرجع المتداخل '__ocs_fmt_0__'. ادمجه في رسمته المضيفة. + .xref-cannot-bind = XREF: لا يمكن دمج "__ocs_fmt_0__": __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: دمج PDF (استيراد المتجهات) غير متوفر في هذا الإصدار. + .xref-cannot-detach-nested-reference = XREF: لا يمكن فصل المرجع المتداخل '__ocs_fmt_0__'. افصله في رسمته المضيفة. + .xref-cannot-make-path-relative = XREF: لا يمكن جعل المسار نسبيًا عبر محركات الأقراص. + .xref-detached = XREF: تم فصل "__ocs_fmt_0__". + .xref-no-loaded-reference-with-that-key = XREF: لا يوجد مرجع محمّل بهذا المفتاح. + .xref-no-references-match = XREF: لا توجد مراجع تطابق '__ocs_fmt_0__'. + .xref-manager-title = المراجع الخارجية + .xref-self-attach-blocked = XATTACH: لا يمكن إرفاق الرسمة المضيفة بنفسها. + .xref-overlays-drawing-references-only = XREF: التراكب ينطبق على مراجع الرسومات فقط. + .xref-path-set-for = XREF: تم تعيين المسار لـ "__ocs_fmt_0__" — أعد التحميل للتطبيق. + .xref-unloaded = XREF: تم إلغاء تحميل "__ocs_fmt_0__". + .xref-unknown-option = XREF: خيار غير معروف '__ocs_fmt_0__'. الخيارات: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: تم تحديث __ocs_fmt_0__ مرجع(مراجع). + .usage-xref-path-pattern = الاستخدام: XREF Path + .usage-xref-path-find = الاستخدام: XREF Path Find + .usage-xref-pathtype = الاستخدام: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ مرجع(مراجع) غير موجودة — افتح مدير المراجع باستخدام EXTERNALREFERENCES. + .xref-nested-not-rendered = (متداجئ — غير منقول) + .change-path = تغيير المسار: + .date = التاريخ + .details = التفاصيل + .xref-detach = فصل + .failed = فشل + .xref-file-attach-is-not-available-on-web = إرفاق الملف غير متاح على الويب — قائمة المراجع أدناه للقراءة فقط. + .xref-file-name = اسم الملف + .found-at = موجود في + .xref-help = مساعدة + .xref-nested-references-are-read-only = المراجع المتداخلة للقراءة فقط — عدلها في رسمتها المضيفة. + .not-found = غير موجود + .orphaned = يتيم + .unreferenced = غير مرجعي + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = مفتوح + .xref-untitled = بدون عنوان + .xref-unresolved = غير محلول + .xref-preview-not-available = المعاينة غير متاحة + .xref-reload-all = إعادة تحميل الكل + .xref-dwg = DWG + .xref-pixel-width = عرض البكسل + .xref-pixel-height = ارتفاع البكسل + .xref-resolution-unit = وحدة الدقة + .xref-overlay = تراكب + .xref-reference-manager-select-rows-bind = مدير المراجع — حدد الصفوف، ثم فصل، إلغاء تحميل، إعادة تحميل، دمج، تراكب، أو وضع تغيير المسار. + .xref-bind = دمج + .xref-refresh = تحديث + .relative = نسبي + .xref-reload = إعادة تحميل + .xref-replace = استبدال: + .saved-path = المسار المحفوظ + .xref-select-a-reference-first = حدد مرجعًا أولاً. + .xref-select-a-reference-to-inspect-its = حدد مرجعًا لفحص تفاصيله + .xref-set-path = تعيين المسار + .stale = قديم + .toggle-list-tree = تبديل القائمة/الشجرة + .tree = شجرة + .xref-type-a-new-path-first = اكتب مسارًا جديدًا أولاً. + .xref-type-the-path-prefix-to-find-first = اكتب بادئة المسار للبحث أولاً. + .xref-unload = إلغاء تحميل + .unloaded = غير محمل + .xref-new-path-for-selection = مسار جديد للتحديد + .xref-new-path-prefix = بادئة مسار جديدة + .xref-old-path-prefix = بادئة المسار القديم + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF احفظ الرسم أولًا لحل مسارات المراجع الخارجية النسبية. .object-s-copied-to-clipboard-base-3-3 = نُسخ __ocs_fmt_0__ كائن إلى الحافظة (الأساس __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = نُسخ __ocs_fmt_0__ كائن إلى الحافظة. @@ -3236,6 +3753,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE حدد خطًا أو شعاعًا أو خط إنشاء للمحور: .revolve-select-objects-or-mode = REVOLVE حدد عناصر التدوير أو [الوضع] (Enter للإنهاء): + .path = Path: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = إنشاء .boolean = عمليات منطقية @@ -4546,6 +5070,19 @@ plot = .export-pdf = تصدير PDF .fit-to-paper = ملاءمة للورق .layout-plots-the-current-sheet-using-the-selec = يطبع التخطيط الورقة الحالية باستخدام حجم الورق المحدد. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = دمج الخطوط المتداخلة .object-lineweights = سماكات خطوط الكائنات .orientation = الاتجاه @@ -4563,6 +5100,8 @@ plot = .plot-style-table-pen-assignments = جدول نمط الطباعة (تعيينات الأقلام) .plot-transparency = شفافية الطباعة .plot-upside-down = الطباعة مقلوبة + .named-plot-style-tables-stb-are-not-supported-yet-name = جداول أنماط الطباعة المسماة (‎.stb) غير مدعومة بعد: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = لم يتم العثور على __ocs_fmt_0__ في مجلد أنماط الطباعة (__ocs_fmt_1__). .preview = معاينة .printer-plotter = الطابعة / الراسم .properties = الخصائص… @@ -4574,6 +5113,34 @@ plot = .system-default-printer = طابعة النظام الافتراضية .what-to-plot = ما المراد طباعته .x-mm = X (مم) + .select-drawing-to-import-page-setups-from = اختر الرسم لاستيراد إعدادات الصفحة منه + .drawings-and-templates = الرسومات والقوالب + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: تم استيراد __ocs_fmt_0__ من إعدادات الصفحة، واستبدال __ocs_fmt_1__. + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: لا يوجد إعداد صفحة باسم "__ocs_fmt_0__" في __ocs_fmt_1__. + .psetupin-nothing-to-import-from-file = PSETUPIN: لا يوجد ما يمكن استيراده من __ocs_fmt_0__. + .no-named-page-setups-in-this-drawing = لا توجد إعدادات صفحة مسماة في هذا الرسم. + .import-ellipsis = استيراد… + .show-the-page-setup-for-new-layouts = إظهار إعداد الصفحة للتخطيطات الجديدة + .x-in = X (بوصة) + .y-in = Y (بوصة) + .hide-paperspace-objects = إخفاء كائنات مساحة الورق + .save-changes-to-layout = حفظ التغييرات في التخطيط + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ لا يدعم __ocs_fmt_1__؛ تم التبديل إلى الورقة الافتراضية __ocs_fmt_2__ (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ لا يدعم __ocs_fmt_1__؛ تم الاحتفاظ بالورقة (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: لا تبلغ الطابعة عن أي أوراق؛ يُستخدم كتالوج الورق. + .printer-count-sheet-s-reported = __ocs_fmt_0__: تم الإبلاغ عن __ocs_fmt_1__ ورقة/أوراق + .default-sheet-sheet = الورقة الافتراضية: __ocs_fmt_0__ + .default-sheet-not-reported = الورقة الافتراضية: غير مُبلّغ عنها + .default-printer-name = الطابعة الافتراضية: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = الطابعة الافتراضية: لم يبلّغ النظام عن أي طابعة + .printers-none-listed-by-the-system = الطابعات: لم يسرد النظام أي طابعة + .printers-count = الطابعات (__ocs_fmt_0__): + .could-not-list-printers-error = تعذّر سرد الطابعات: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = تمر المخططات عبر تطبيق PDF: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = تذهب المخططات مباشرة إلى الطابعة (لا يسجل أي تطبيق PDF أمر طباعة). + .via-the-pdf-application = عبر تطبيق PDF + .direct-print = طباعة مباشرة .y-mm = Y (مم) manage = @@ -4785,6 +5352,19 @@ ui = .what-s-new = ما الجديد .width-value = العرض: __ocs_arg_value__ .working = جارٍ العمل… + .recent-input = الإدخالات الأخيرة + .copy-with-base-point = نسخ مع نقطة أساس + .undo-label = تراجع عن __ocs_arg_label__ + .redo-label = إعادة __ocs_arg_label__ + .copy-selection = نسخ التحديد + .isolate = عزل + .deselect-all = إلغاء تحديد الكل + .options-ellipsis = خيارات... + .base-point = نقطة الأساس + .snap-overrides = تجاوزات الالتقاط + .mid-between-2-points = المنتصف بين نقطتين + .osnap-settings = إعدادات الالتقاط... + .snap-override-none-next-pick-only = تجاوز الالتقاط: بدون (للاختيار التالي فقط). dimstyle = .keep-text-between-extension-lines = إبقاء النص دائمًا بين خطوط الامتداد diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 44b46ade8..d3f4ed151 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Приложение по подразбиране .iced-theme = Iced тема: .theme = Тема + .right-click-customization = Настройка на десния бутон + .right-click-in-drawing-area = Десен бутон в чертожната област + .hold-duration = Време на задържане + .right-click-mode-help = Контекстно меню: десният бутон винаги отваря менюто. Зависимо от времето: бързо щракване е Enter, по-дълго задържане отваря менюто (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Контекстно меню + .right-click-mode-time-sensitive = Зависимо от времето (бързо щракване = Enter) + .right-click-mode-enter-first = Първо Enter, второ щракване = меню command-line = .ready = Open CAD Studio е готов. @@ -245,6 +252,7 @@ command-move = *[other] { $count } обекта }]: .target = MOVE Посочи местоназначение [база { $x },{ $y }]: + .displacement = MOVE Задайте преместване: command-copy = .array-count = COPY Въведи брой елементи за масив: @@ -259,6 +267,8 @@ command-copy = [one] { $count } копие досега *[other] { $count } копия досега } | Масив | Enter=готово | база { $x },{ $y }]: + .displacement = COPY Задайте преместване: + .mode = COPY Въведете режим на копиране [Single/Multiple] : common = .jogged-radius = Радиус с чупка .none-use-shortcuts-set-key-command = (няма — използвай: SHORTCUTS SET <клавиш> <команда>) @@ -1028,8 +1038,8 @@ common = .loft-surface-created = LOFT: повърхността е създадена. .loft-solid-created = LOFT: тялото е създадено. .attsync-no-editable-block-references = ATTSYNC: няма редактируеми препратки към блокове. - .xref-reloaded-with-repairs = XREF Презаредено с поправки: "__ocs_fmt_0__" - .xref-reload-failed = XREF Неуспешно презареждане: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reloaded-with-repairs = XREF Презареден с ремонти: "__ocs_fmt_0__" + .xref-reload-failed = XREF Презареждането неуспешно: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = Предишен изглед .zoom-no-previous-view = ZOOM: няма предишен изглед. .zoom-object = Мащабиране към обект @@ -1117,8 +1127,8 @@ common = .dwg-dxf-files = { common.files } (DWG/DXF) .inserting-from-file = Вмъкване на "__ocs_fmt_0__" от файл. .insert-file = INSERT FILE: __ocs_fmt_0__ - .xref-recovered-with-warnings = XREF Възстановено с предупреждения: "__ocs_fmt_0__" - .xref-recovery-failed = XREF Неуспешно възстановяване: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-recovered-with-warnings = XREF Възстановен с предупреждения: "__ocs_fmt_0__" + .xref-recovery-failed = XREF Възстановяването неуспешно: "__ocs_fmt_0__" (__ocs_fmt_1__) .select-at-least-one-layout = Изберете поне едно оформление. .printing-is-not-available-in-the-web-version = Печатът не е достъпен в уеб версията. .sending-selected-layouts-to-the-system-printer = Изпращане на избраните оформления към системния принтер… @@ -1614,7 +1624,7 @@ common = .create-triangular-face = Създаване на триъгълна стена .location = { common.position } .creation-mode = Режим на създаване - .path = Траектория + .path = Път: .expression = Израз .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1867,6 +1877,391 @@ common = .dimensional = Размерни .show-hide = Показване/скриване + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Предупреждение при запазване .unable-to-save-drawing = Чертежът не може да бъде запазен @@ -2438,6 +2833,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Премахване + .pline-specify-starting-width = PLINE Задайте начална ширина <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE Задайте крайна ширина <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE Задайте начална полуширина <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE Задайте крайна полуширина <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (Линия) Задайте следваща точка: + .pline-arc-specify-endpoint-of-arc = PLINE (Дъга) Задайте крайна точка на дъгата: + .pline-specify-length-of-line = PLINE Задайте дължина на линията: + .pline-specify-included-angle = PLINE Задайте вписан ъгъл: + .pline-specify-endpoint-of-arc = PLINE Задайте крайна точка на дъгата: + .pline-specify-center-point-of-arc = PLINE Задайте център на дъгата: + .pline-specify-radius-of-arc = PLINE Задайте радиус на дъгата: + .pline-specify-direction-of-chord-for-arc = PLINE Задайте посока на хордата на дъгата: + .pline-specify-length-of-chord = PLINE Задайте дължина на хордата: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Задайте посока на допирателната в началото на дъгата: + .pline-specify-second-point-on-arc = PLINE Задайте втора точка от дъгата: + .halfwidth = Полуширина + .center-keyword = Център + .close-keyword = Затвори + .second-pt = Втора точка + .arc-specify-start-point-of-arc = ARC Задайте начална точка на дъгата: + .arc-specify-second-point-of-arc = ARC Задайте втора точка на дъгата: + .arc-specify-end-point-of-arc = ARC Задайте крайна точка на дъгата: + .arc-specify-center-point-of-arc = ARC Задайте център на дъгата: + .arc-specify-included-angle = ARC Задайте вписан ъгъл: + .arc-specify-length-of-chord = ARC Задайте дължина на хордата: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Задайте посока на допирателната в началото на дъгата: + .arc-specify-radius-of-arc = ARC Задайте радиус на дъгата: + .chord-length = Дължина на хордата modify = .add-vertex = Добавяне на връх @@ -2590,6 +3013,13 @@ modify = .tol-scale-dimtfac = Мащаб на толеранс (DIMTFAC) .trim = Изрежи .exit = иЗход + .area-keyword = Площ + .displacement = Преместване + .mode-keyword = Режим + .array-keyword = Масив + .use-first-point-as-displacement = Първата точка като преместване + .offset-erase-source-object-after-offsetting = OFFSET Изтриване на изходния обект след отместване? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Слой за отместените обекти [Current/Source] : groups = .group-enter-group-name-name = GROUP Въведи име на групата [__ocs_arg_name__]: @@ -3124,6 +3554,93 @@ insert = .xref-external-references = XREF Външни референции: .xref-no-external-references-in-this-drawing = XREF Няма външни референции в този чертеж. .xref-reloaded = XREF Презареден "__ocs_fmt_0__" + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" зададен на Наслагване. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" зададен на Прикачване. + .xref-cannot-unload-nested-reference = XREF: не може да се изключи вложена референция '__ocs_fmt_0__'. Изключете я в нейния чертеж-домакин. + .xref-cannot-overlay-nested-reference = XREF: не може да се наслагне вложена референция '__ocs_fmt_0__'. Насложете я в нейния чертеж-домакин. + .xref-cannot-reload-nested-reference = XREF: не може да се презареди вложена референция '__ocs_fmt_0__'. Презаредете я в нейния чертеж-домакин. + .xref-cannot-attach-nested-reference = XREF: не може да се прикачи вложена референция '__ocs_fmt_0__'. Прикачете я в нейния чертеж-домакин. + .xref-cannot-set-path-type-nested-reference = XREF: не може да се зададе тип път за вложена референция '__ocs_fmt_0__'. Задайте го в нейния чертеж-домакин. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: презареждането се прилага само за референции на чертежи. + .xref-bound = XREF: вграден "__ocs_fmt_0__". + .xref-bound-with-unremapped-handles = XREF: вграден "__ocs_fmt_0__" с __ocs_fmt_1__ непредефинирани стилови манипулатори (вижте ограниченията на вграждането). + .xref-cannot-bind-nested-reference = XREF: не може да се вгради вложена референция '__ocs_fmt_0__'. Вградете я в нейния чертеж-домакин. + .xref-cannot-bind = XREF: не може да се вгради "__ocs_fmt_0__": __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: Вграждането на PDF (векторен импорт) не е налично в тази версия. + .xref-cannot-detach-nested-reference = XREF: не може да се изключи вложена референция '__ocs_fmt_0__'. Изключете я в нейния чертеж-домакин. + .xref-cannot-make-path-relative = XREF: не може да се направи относителен път между различни дискове. + .xref-detached = XREF: изключен "__ocs_fmt_0__". + .xref-no-loaded-reference-with-that-key = XREF: няма заредена референция с този ключ. + .xref-no-references-match = XREF: няма референции, съвпадащи с '__ocs_fmt_0__'. + .xref-manager-title = Външни референции + .xref-self-attach-blocked = XATTACH: не може да се прикачи чертежът-домакин в самия себе си. + .xref-overlays-drawing-references-only = XREF: наслагванията се прилагат само за референции на чертежи. + .xref-path-set-for = XREF: Пътят е зададен за "__ocs_fmt_0__" — Презаредете, за да се приложи. + .xref-unloaded = XREF: изключен от паметта "__ocs_fmt_0__". + .xref-unknown-option = XREF: неизвестна опция '__ocs_fmt_0__'. Опции: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: актуализирани __ocs_fmt_0__ референции(я). + .usage-xref-path-pattern = Употреба: XREF Path + .usage-xref-path-find = Употреба: XREF Path Find + .usage-xref-pathtype = Употреба: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ референция(и) не са намерени — отворете мениджъра на референции с EXTERNALREFERENCES. + .xref-nested-not-rendered = (вложена — не се визуализира) + .change-path = Промяна на пътя: + .date = Дата + .details = Подробности + .xref-detach = Изключване + .failed = Неуспешно + .xref-file-attach-is-not-available-on-web = Прикачването на файл не е достъпно в уеб версията — списъкът с референции по-долу е само за четене. + .xref-file-name = Име на файл + .found-at = Намерен на + .xref-help = Помощ + .xref-nested-references-are-read-only = Вложените референции са само за четене — редактирайте ги в техния чертеж-домакин. + .not-found = Не е намерен + .orphaned = Изолиран + .unreferenced = Без референции + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = Отворен + .xref-untitled = Без заглавие + .xref-unresolved = Неразрешен + .xref-preview-not-available = Нарази не е наличен + .xref-reload-all = Презареждане на всички + .xref-dwg = DWG + .xref-pixel-width = Ширина в пиксели + .xref-pixel-height = Височина в пиксели + .xref-resolution-unit = Единица за разделителна способност + .xref-overlay = Наслащване + .xref-reference-manager-select-rows-bind = Мениджър на референции — изберете редове, след това Изключване, Изключване от паметта, Презареждане, Вграждане, Наслагване или режим за промяна на пътя. + .xref-bind = Вграждане + .xref-refresh = Опресняване + .relative = Относителен + .xref-reload = Презареждане + .xref-replace = Замяна: + .saved-path = Запазен път + .xref-select-a-reference-first = Първо изберете референция. + .xref-select-a-reference-to-inspect-its = Изберете референция, за да видите нейните детайли + .xref-set-path = Задаване на път + .stale = Остарял + .toggle-list-tree = Превключване списък/дърво + .tree = Дърво + .xref-type-a-new-path-first = Първо въведете нов път. + .xref-type-the-path-prefix-to-find-first = Първо въведете префикса на пътя за търсене. + .xref-unload = Изключване от паметта + .unloaded = Изключен + .xref-new-path-for-selection = нов път за селекцията + .xref-new-path-prefix = нов префикс на път + .xref-old-path-prefix = стар префикс на път + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF Първо запази чертежа, за да се разрешат относителните XREF пътища. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ обект(а) копирани в клипборда (база __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ обект(а) копирани в клипборда. @@ -3223,6 +3740,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Изберете линия, лъч или конструктивна права за ос: .revolve-select-objects-or-mode = REVOLVE Изберете обекти за завъртане или [Режим] (Enter за край): + .path = Път: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Създаване .boolean = Булеви операции @@ -4534,6 +5058,19 @@ plot = .export-pdf = Експортирай PDF .fit-to-paper = Побери в хартията .layout-plots-the-current-sheet-using-the-selec = Оформлението плотира текущия лист, използвайки избрания размер хартия. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Обедини припокриващи се линии .object-lineweights = Дебелини на линии на обект .orientation = Ориентация @@ -4551,6 +5088,8 @@ plot = .plot-style-table-pen-assignments = Таблица със стилове за плотиране (присвоявания на пера) .plot-transparency = Прозрачност при плотиране .plot-upside-down = Плотирай обърнато наопаки + .named-plot-style-tables-stb-are-not-supported-yet-name = Именуваните таблици със стилове на печат (.stb) все още не се поддържат: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ не беше намерен в папката със стилове на печат (__ocs_fmt_1__). .preview = Преглед .printer-plotter = Принтер / плотер .properties = Свойства… @@ -4562,6 +5101,34 @@ plot = .system-default-printer = Системен принтер по подразбиране .what-to-plot = Какво да се плотира .x-mm = X (мм) + .select-drawing-to-import-page-setups-from = Изберете чертеж, от който да се импортират настройките на страницата + .drawings-and-templates = Чертежи и шаблони + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: импортирани __ocs_fmt_0__ настройки на страницата, __ocs_fmt_1__ заменени. + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: няма настройка на страницата с име „__ocs_fmt_0__“ в __ocs_fmt_1__. + .psetupin-nothing-to-import-from-file = PSETUPIN: няма нищо за импортиране от __ocs_fmt_0__. + .no-named-page-setups-in-this-drawing = В този чертеж няма именувани настройки на страницата. + .import-ellipsis = Импортиране… + .show-the-page-setup-for-new-layouts = Показване на настройката на страницата за нови оформления + .x-in = X (инча) + .y-in = Y (инча) + .hide-paperspace-objects = Скриване на обектите в пространството на листа + .save-changes-to-layout = Запазване на промените в оформлението + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ не поддържа __ocs_fmt_1__; превключено към листа по подразбиране __ocs_fmt_2__ (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ не поддържа __ocs_fmt_1__; листът се запазва (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: принтерът не съобщава листове; използва се каталогът с хартия. + .printer-count-sheet-s-reported = __ocs_fmt_0__: съобщени __ocs_fmt_1__ листа + .default-sheet-sheet = Лист по подразбиране: __ocs_fmt_0__ + .default-sheet-not-reported = Лист по подразбиране: не е съобщен + .default-printer-name = Принтер по подразбиране: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Принтер по подразбиране: системата не съобщава за такъв + .printers-none-listed-by-the-system = Принтери: системата не изброява нито един + .printers-count = Принтери (__ocs_fmt_0__): + .could-not-list-printers-error = Принтерите не могат да бъдат изброени: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = Печатът минава през PDF приложението: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = Печатът отива директно към принтера (никое PDF приложение не регистрира команда за печат). + .via-the-pdf-application = чрез PDF приложението + .direct-print = директен печат .y-mm = Y (мм) manage = @@ -4773,6 +5340,19 @@ ui = .what-s-new = Какво е новото .width-value = Ширина: __ocs_arg_value__ .working = Обработка… + .recent-input = Последни въведени + .copy-with-base-point = Копиране с базова точка + .undo-label = Отмени __ocs_arg_label__ + .redo-label = Повтори __ocs_arg_label__ + .copy-selection = Копиране на селекцията + .isolate = Изолиране + .deselect-all = Отмяна на избора + .options-ellipsis = Опции... + .base-point = Базова точка + .snap-overrides = Временни прихващания + .mid-between-2-points = Средата между 2 точки + .osnap-settings = Настройки на прихващане... + .snap-override-none-next-pick-only = Временно прихващане: Няма (само за следващата точка). dimstyle = .keep-text-between-extension-lines = Винаги пази текста между удължителните линии diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index ff24f0b1f..ad2751b9f 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Výchozí aplikace .iced-theme = Motiv Iced: .theme = Motiv + .right-click-customization = Přizpůsobení pravého tlačítka + .right-click-in-drawing-area = Pravé tlačítko v kreslicí ploše + .hold-duration = Doba podržení + .right-click-mode-help = Kontextová nabídka: pravé tlačítko vždy otevře nabídku. Časově citlivé: krátké kliknutí je Enter, delší podržení otevře nabídku (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Kontextová nabídka + .right-click-mode-time-sensitive = Časově citlivé (krátké kliknutí = Enter) + .right-click-mode-enter-first = Nejprve Enter, druhé kliknutí = nabídka command-line = .ready = Open CAD Studio je připraveno. @@ -244,6 +251,7 @@ command-move = *[other] { $count } objektů }]: .target = MOVE Zadejte cíl [základ { $x },{ $y }]: + .displacement = MOVE Zadejte posunutí: command-copy = .array-count = COPY Enter počet položek do pole: @@ -258,6 +266,8 @@ command-copy = [one] zatím { $count } kopie *[other] zatím { $count } kopií } | Pole | Enter=hotovo | základna { $x },{ $y }]: + .displacement = COPY Zadejte posunutí: + .mode = COPY Zadejte režim kopírování [Single/Multiple] : common = .jogged-radius = Zalomený poloměr @@ -1029,7 +1039,7 @@ common = .loft-solid-created = LOFT: těleso vytvořeno. .attsync-no-editable-block-references = ATTSYNC: žádné upravitelné reference bloků. .xref-reloaded-with-repairs = XREF Znovu načteno s opravami: "__ocs_fmt_0__" - .xref-reload-failed = XREF Opětovné načtení selhalo: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reload-failed = XREF Znovunačtení selhalo: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = Předchozí pohled .zoom-no-previous-view = ZOOM: žádný předchozí pohled. .zoom-object = Přiblížit objekt @@ -1117,8 +1127,8 @@ common = .dwg-dxf-files = { common.files } (DWG/DXF) .inserting-from-file = Vkládání "__ocs_fmt_0__" ze souboru. .insert-file = INSERT FILE: __ocs_fmt_0__ - .xref-recovered-with-warnings = XREF Obnoveno s varováními: "__ocs_fmt_0__" - .xref-recovery-failed = XREF Obnova selhala: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-recovered-with-warnings = XREF Obnoveno s varováníemi: "__ocs_fmt_0__" + .xref-recovery-failed = XREF Obnovení selhalo: "__ocs_fmt_0__" (__ocs_fmt_1__) .select-at-least-one-layout = Vyberte alespoň jedno rozvržení. .printing-is-not-available-in-the-web-version = Tisk není ve webové verzi dostupný. .sending-selected-layouts-to-the-system-printer = Odesílání vybraných rozvržení na systémovou tiskárnu… @@ -1614,7 +1624,7 @@ common = .create-triangular-face = Vytvořit trojúhelníkovou plochu .location = { common.position } .creation-mode = Režim vytváření - .path = Trajektorie + .path = Cesta: .expression = Výraz .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1867,6 +1877,391 @@ common = .dimensional = Rozměrové .show-hide = Zobrazit/skrýt + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Uložit varování .unable-to-save-drawing = Nákres nelze uložit @@ -2438,6 +2833,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Odebrat + .pline-specify-starting-width = PLINE Zadejte počáteční šířku <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE Zadejte koncovou šířku <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE Zadejte počáteční poloviční šířku <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE Zadejte koncovou poloviční šířku <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (Úsečka) Zadejte další bod: + .pline-arc-specify-endpoint-of-arc = PLINE (Oblouk) Zadejte koncový bod oblouku: + .pline-specify-length-of-line = PLINE Zadejte délku úsečky: + .pline-specify-included-angle = PLINE Zadejte středový úhel: + .pline-specify-endpoint-of-arc = PLINE Zadejte koncový bod oblouku: + .pline-specify-center-point-of-arc = PLINE Zadejte střed oblouku: + .pline-specify-radius-of-arc = PLINE Zadejte poloměr oblouku: + .pline-specify-direction-of-chord-for-arc = PLINE Zadejte směr tětivy oblouku: + .pline-specify-length-of-chord = PLINE Zadejte délku tětivy: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Zadejte směr tečny v počátečním bodě oblouku: + .pline-specify-second-point-on-arc = PLINE Zadejte druhý bod na oblouku: + .halfwidth = Poloviční šířka + .center-keyword = Střed + .close-keyword = Uzavřít + .second-pt = Druhý bod + .arc-specify-start-point-of-arc = ARC Zadejte počáteční bod oblouku: + .arc-specify-second-point-of-arc = ARC Zadejte druhý bod oblouku: + .arc-specify-end-point-of-arc = ARC Zadejte koncový bod oblouku: + .arc-specify-center-point-of-arc = ARC Zadejte střed oblouku: + .arc-specify-included-angle = ARC Zadejte středový úhel: + .arc-specify-length-of-chord = ARC Zadejte délku tětivy: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Zadejte směr tečny v počátečním bodě oblouku: + .arc-specify-radius-of-arc = ARC Zadejte poloměr oblouku: + .chord-length = Délka tětivy modify = .add-vertex = Přidat vrchol @@ -2590,6 +3013,13 @@ modify = .tol-scale-dimtfac = Tol. měřítko (DIMTFAC) .trim = Oříznout .exit = výstup + .area-keyword = Plocha + .displacement = Posunutí + .mode-keyword = Režim + .array-keyword = Pole + .use-first-point-as-displacement = První bod jako posunutí + .offset-erase-source-object-after-offsetting = OFFSET Vymazat zdrojový objekt po odsazení? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Hladina odsazených objektů [Current/Source] : groups = .group-enter-group-name-name = GROUP Enter název skupiny [__ocs_arg_name__]: @@ -3122,6 +3552,93 @@ insert = .xref-external-references = XREF Externí reference: .xref-no-external-references-in-this-drawing = XREF V tomto výkresu nejsou žádné externí odkazy. .xref-reloaded = XREF Znovu načteno "__ocs_fmt_0__" + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" nastaven na Překrýt. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" nastaven na Připojit. + .xref-cannot-unload-nested-reference = XREF: nelze odpojit vnořenou referenci '__ocs_fmt_0__'. Odpojte ji v jejím hostitelském výkresu. + .xref-cannot-overlay-nested-reference = XREF: nelze překrýt vnořenou referenci '__ocs_fmt_0__'. Překryjte ji v jejím hostitelském výkresu. + .xref-cannot-reload-nested-reference = XREF: nelze znovu načíst vnořenou referenci '__ocs_fmt_0__'. Znovu ji načtěte v jejím hostitelském výkresu. + .xref-cannot-attach-nested-reference = XREF: nelze připojit vnořenou referenci '__ocs_fmt_0__'. Připojte ji v jejím hostitelském výkresu. + .xref-cannot-set-path-type-nested-reference = XREF: nelze nastavit typ cesty pro vnořenou referenci '__ocs_fmt_0__'. Nastavte jej v jejím hostitelském výkresu. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: znovunačtení platí pouze pro výkresové reference. + .xref-bound = XREF: vložen "__ocs_fmt_0__". + .xref-bound-with-unremapped-handles = XREF: vložen "__ocs_fmt_0__" s __ocs_fmt_1__ nepřemapovanými úchopovými body stylu (viz omezení vkládání). + .xref-cannot-bind-nested-reference = XREF: nelze vložit vnořenou referenci '__ocs_fmt_0__'. Vložte ji v jejím hostitelském výkresu. + .xref-cannot-bind = XREF: nelze vložit "__ocs_fmt_0__": __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: Vložení PDF (vektorový import) není v této verzi dostupné. + .xref-cannot-detach-nested-reference = XREF: nelze oddělit vnořenou referenci '__ocs_fmt_0__'. Oddělte ji v jejím hostitelském výkresu. + .xref-cannot-make-path-relative = XREF: nelze nastavit relativní cestu mezi jednotkami. + .xref-detached = XREF: oddělen "__ocs_fmt_0__". + .xref-no-loaded-reference-with-that-key = XREF: žádná načtená reference s tímto klíčem. + .xref-no-references-match = XREF: žádné reference neodpovídají '__ocs_fmt_0__'. + .xref-manager-title = Externí reference + .xref-self-attach-blocked = XATTACH: nelze připojit hostitelský výkres do sebe sama. + .xref-overlays-drawing-references-only = XREF: překrytí se vztahují pouze na výkresové reference. + .xref-path-set-for = XREF: Cesta nastavena pro "__ocs_fmt_0__" — provedením znovunačtení aplikujte. + .xref-unloaded = XREF: odpojen "__ocs_fmt_0__". + .xref-unknown-option = XREF: neznámá volba '__ocs_fmt_0__'. Volby: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: aktualizováno __ocs_fmt_0__ referencí. + .usage-xref-path-pattern = Použití: XREF Path + .usage-xref-path-find = Použití: XREF Path Find + .usage-xref-pathtype = Použití: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ reference nenalezeny — otevřete správce referencí pomocí EXTERNALREFERENCES. + .xref-nested-not-rendered = (vnořená — nevykresluje se) + .change-path = Změnit cestu: + .date = Datum + .details = Podrobnosti + .xref-detach = Oddělit + .failed = Selhalo + .xref-file-attach-is-not-available-on-web = Připojení souboru není na webu dostupné — seznam referencí níže je pouze pro čtení. + .xref-file-name = Název souboru + .found-at = Nalezeno v + .xref-help = Nápověda + .xref-nested-references-are-read-only = Vnořené reference jsou pouze pro čtení — upravte je v jejich hostitelském výkresu. + .not-found = Nenalezeno + .orphaned = Osamocený + .unreferenced = Neodkazovaný + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = Otevřeno + .xref-untitled = Bez názvu + .xref-unresolved = Nevyřešený + .xref-preview-not-available = Náhled není k dispozici + .xref-reload-all = Znovu načíst vše + .xref-dwg = DWG + .xref-pixel-width = Šířka pixelů + .xref-pixel-height = Výška pixelů + .xref-resolution-unit = Jednotka rozlišení + .xref-overlay = Překrytí + .xref-reference-manager-select-rows-bind = Správce referencí — vyberte řádky, poté Oddělit, Odpojit, Znovu načíst, Vložit, Překrýt nebo režim změny cesty. + .xref-bind = Vložit + .xref-refresh = Obnovit + .relative = Relativní + .xref-reload = Znovu načíst + .xref-replace = Nahradit: + .saved-path = Uložená cesta + .xref-select-a-reference-first = Nejprve vyberte referenci. + .xref-select-a-reference-to-inspect-its = Vyberte referenci pro zobrazení jejích podrobností + .xref-set-path = Nastavit cestu + .stale = Zastaralý + .toggle-list-tree = Přepnout seznam/strom + .tree = Strom + .xref-type-a-new-path-first = Nejprve zadejte novou cestu. + .xref-type-the-path-prefix-to-find-first = Nejprve zadejte předponu cesty k vyhledání. + .xref-unload = Odpojit + .unloaded = Odpojeno + .xref-new-path-for-selection = nová cesta pro výběr + .xref-new-path-prefix = nová předpona cesty + .xref-old-path-prefix = stará předpona cesty + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF Nejprve uložte výkres, abyste vyřešili relativní cesty XREF. .object-s-copied-to-clipboard-base-3-3 = Objekt(y) __ocs_fmt_0__ zkopírován do schránky (základna __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = Objekt(y) __ocs_fmt_0__ zkopírován do schránky. @@ -3221,6 +3738,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Vyberte úsečku, polopřímku nebo konstrukční přímku jako osu: .revolve-select-objects-or-mode = REVOLVE Vyberte objekty k rotaci nebo [Režim] (Enter ukončí): + .path = Cesta: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Vytvořit .boolean = Booleovské operace @@ -4531,6 +5055,19 @@ plot = .export-pdf = Exportujte PDF .fit-to-paper = Přizpůsobit papíru .layout-plots-the-current-sheet-using-the-selec = Rozvržení vykreslí aktuální list pomocí zvolené velikosti papíru. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Sloučit překrývající se čáry .object-lineweights = Tloušťky čar objektů .orientation = Orientace @@ -4548,6 +5085,8 @@ plot = .plot-style-table-pen-assignments = Tabulka stylu vykreslování (přiřazení pera) .plot-transparency =Průhlednost plotru .plot-upside-down = Zápletka vzhůru nohama + .named-plot-style-tables-stb-are-not-supported-yet-name = Pojmenované tabulky stylů vykreslování (.stb) zatím nejsou podporovány: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ nebyl nalezen ve složce stylů vykreslování (__ocs_fmt_1__). .preview = Náhled .printer-plotter = Tiskárna / plotr .properties = Vlastnosti… @@ -4559,6 +5098,34 @@ plot = .system-default-printer = Výchozí tiskárna systému .what-to-plot = Co fabulovat .x-mm = X (mm) + .select-drawing-to-import-page-setups-from = Vyberte výkres pro import nastavení stránky + .drawings-and-templates = Výkresy a šablony + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: importováno __ocs_fmt_0__ nastavení stránky, __ocs_fmt_1__ nahrazeno. + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: žádné nastavení stránky s názvem „__ocs_fmt_0__“ v __ocs_fmt_1__. + .psetupin-nothing-to-import-from-file = PSETUPIN: z __ocs_fmt_0__ není co importovat. + .no-named-page-setups-in-this-drawing = V tomto výkresu nejsou pojmenovaná nastavení stránky. + .import-ellipsis = Importovat… + .show-the-page-setup-for-new-layouts = Zobrazit nastavení stránky pro nová rozvržení + .x-in = X (palce) + .y-in = Y (palce) + .hide-paperspace-objects = Skrýt objekty výkresového prostoru + .save-changes-to-layout = Uložit změny do rozvržení + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ nepodporuje __ocs_fmt_1__; přepnuto na výchozí list __ocs_fmt_2__ (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ nepodporuje __ocs_fmt_1__; list byl zachován (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: tiskárna nehlásí žádné listy; použije se katalog papíru. + .printer-count-sheet-s-reported = __ocs_fmt_0__: nahlášeno listů: __ocs_fmt_1__ + .default-sheet-sheet = Výchozí list: __ocs_fmt_0__ + .default-sheet-not-reported = Výchozí list: nenahlášen + .default-printer-name = Výchozí tiskárna: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Výchozí tiskárna: systém žádnou nehlásí + .printers-none-listed-by-the-system = Tiskárny: systém žádné neuvádí + .printers-count = Tiskárny (__ocs_fmt_0__): + .could-not-list-printers-error = Nelze vypsat tiskárny: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = Tisk prochází aplikací PDF: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = Tisk jde přímo na tiskárnu (žádná aplikace PDF neregistruje příkaz tisku). + .via-the-pdf-application = přes aplikaci PDF + .direct-print = přímý tisk .y-mm = Y (mm) manage = @@ -4770,6 +5337,19 @@ ui = .what-s-new = Co je nového .width-value = Šířka: __ocs_arg_value__ .working = Práce… + .recent-input = Poslední zadání + .copy-with-base-point = Kopírovat s referenčním bodem + .undo-label = Zpět __ocs_arg_label__ + .redo-label = Znovu __ocs_arg_label__ + .copy-selection = Kopírovat výběr + .isolate = Izolovat + .deselect-all = Zrušit výběr + .options-ellipsis = Možnosti... + .base-point = Referenční bod + .snap-overrides = Dočasné uchopení + .mid-between-2-points = Střed mezi 2 body + .osnap-settings = Nastavení uchopení... + .snap-override-none-next-pick-only = Dočasné uchopení: Žádné (jen pro další bod). dimstyle = .keep-text-between-extension-lines = Vždy ponechte text mezi vynášecími řádky diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index bad405a19..e7ec7a59e 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Standardanwendung .iced-theme = Iced-Motiv: .theme = Motiv + .right-click-customization = Rechtsklick-Anpassung + .right-click-in-drawing-area = Rechtsklick im Zeichenbereich + .hold-duration = Haltedauer + .right-click-mode-help = Kontextmenü: Rechtsklick öffnet immer das Menü. Zeitabhängig: ein kurzer Klick ist Enter, längeres Halten öffnet das Menü (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Kontextmenü + .right-click-mode-time-sensitive = Zeitabhängig (kurzer Klick = Enter) + .right-click-mode-enter-first = Erst Enter, zweiter Klick = Menü command-line = .ready = Open CAD Studio ist bereit. @@ -245,6 +252,7 @@ command-move = *[other] { $count } Objekte }]: .target = MOVE Zielpunkt angeben [Basis { $x },{ $y }]: + .displacement = MOVE Verschiebung angeben: command-copy = .array-count = COPY Anzahl der Elemente im Array eingeben: @@ -259,6 +267,8 @@ command-copy = [one] bisher { $count } Kopie *[other] bisher { $count } Kopien } | Array | Eingabe=fertig | Basis { $x },{ $y }]: + .displacement = COPY Verschiebung angeben: + .mode = COPY Kopiermodus eingeben [Single/Multiple] : common = .jogged-radius = Geknickter Radius @@ -1615,7 +1625,7 @@ common = .create-triangular-face = Dreieckige Fläche erstellen .location = { common.position } .creation-mode = Erstellungsmodus - .path = Pfad + .path = Pfad: .expression = Ausdruck .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1868,6 +1878,391 @@ common = .dimensional = Bemaßung .show-hide = Anzeigen/Ausblenden + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Warnhinweise speichern .unable-to-save-drawing = Zeichnung konnte nicht gespeichert werden @@ -2439,6 +2834,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Entfernen + .pline-specify-starting-width = PLINE Anfangsbreite angeben <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE Endbreite angeben <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE Anfangs-Halbbreite angeben <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE End-Halbbreite angeben <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (Linie) Nächsten Punkt angeben: + .pline-arc-specify-endpoint-of-arc = PLINE (Bogen) Endpunkt des Bogens angeben: + .pline-specify-length-of-line = PLINE Länge der Linie angeben: + .pline-specify-included-angle = PLINE Eingeschlossenen Winkel angeben: + .pline-specify-endpoint-of-arc = PLINE Endpunkt des Bogens angeben: + .pline-specify-center-point-of-arc = PLINE Mittelpunkt des Bogens angeben: + .pline-specify-radius-of-arc = PLINE Radius des Bogens angeben: + .pline-specify-direction-of-chord-for-arc = PLINE Richtung der Bogensehne angeben: + .pline-specify-length-of-chord = PLINE Sehnenlänge angeben: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Tangentenrichtung am Bogenanfang angeben: + .pline-specify-second-point-on-arc = PLINE Zweiten Punkt auf dem Bogen angeben: + .halfwidth = Halbbreite + .center-keyword = Zentrum + .close-keyword = Schließen + .second-pt = Zweiter Punkt + .arc-specify-start-point-of-arc = ARC Startpunkt des Bogens angeben: + .arc-specify-second-point-of-arc = ARC Zweiten Punkt des Bogens angeben: + .arc-specify-end-point-of-arc = ARC Endpunkt des Bogens angeben: + .arc-specify-center-point-of-arc = ARC Mittelpunkt des Bogens angeben: + .arc-specify-included-angle = ARC Eingeschlossenen Winkel angeben: + .arc-specify-length-of-chord = ARC Sehnenlänge angeben: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Tangentenrichtung am Bogenanfang angeben: + .arc-specify-radius-of-arc = ARC Radius des Bogens angeben: + .chord-length = Sehnenlänge modify = .add-vertex = Scheitelpunkt hinzufügen @@ -2591,6 +3014,13 @@ modify = .tol-scale-dimtfac = Tol.-Skala (DIMTFAC) .trim = Trimmen .exit = Beenden + .area-keyword = Fläche + .displacement = Verschiebung + .mode-keyword = Modus + .array-keyword = Reihe + .use-first-point-as-displacement = Ersten Punkt als Verschiebung + .offset-erase-source-object-after-offsetting = OFFSET Quellobjekt nach dem Versetzen löschen? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Layer für versetzte Objekte [Current/Source] : groups = .group-enter-group-name-name = GROUP Gruppenname eingeben [__ocs_arg_name__]: @@ -3123,6 +3553,93 @@ insert = .xref-external-references = XREF Externe Referenzen: .xref-no-external-references-in-this-drawing = XREF Keine externen Referenzen in dieser Zeichnung. .xref-reloaded = XREF Reloaded "__ocs_fmt_0__" + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" auf Überlagern festgelegt. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" auf Anhängen festgelegt. + .xref-cannot-unload-nested-reference = XREF: kann die geschachtelte Referenz '__ocs_fmt_0__' nicht entladen. Entladen Sie sie in der Host-Zeichnung. + .xref-cannot-overlay-nested-reference = XREF: kann die geschachtelte Referenz '__ocs_fmt_0__' nicht überlagern. Überlagern Sie sie in der Host-Zeichnung. + .xref-cannot-reload-nested-reference = XREF: kann die geschachtelte Referenz '__ocs_fmt_0__' nicht neu laden. Laden Sie sie in der Host-Zeichnung neu. + .xref-cannot-attach-nested-reference = XREF: kann die geschachtelte Referenz '__ocs_fmt_0__' nicht anhängen. Hängen Sie sie in der Host-Zeichnung an. + .xref-cannot-set-path-type-nested-reference = XREF: kann den Pfadtyp für die geschachtelte Referenz '__ocs_fmt_0__' nicht festlegen. Legen Sie ihn in der Host-Zeichnung fest. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: Neuladen gilt nur für Zeichnungsreferenzen. + .xref-bound = XREF: "__ocs_fmt_0__" eingebunden. + .xref-bound-with-unremapped-handles = XREF: "__ocs_fmt_0__" mit __ocs_fmt_1__ nicht neu zugeordneten Style-Griffen eingebunden (siehe Bind-Einschränkungen). + .xref-cannot-bind-nested-reference = XREF: kann die geschachtelte Referenz '__ocs_fmt_0__' nicht einbinden. Binden Sie sie in der Host-Zeichnung ein. + .xref-cannot-bind = XREF: "__ocs_fmt_0__" kann nicht eingebunden werden: __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: PDF-Einbindung (Vektorimport) ist in dieser Version nicht verfügbar. + .xref-cannot-detach-nested-reference = XREF: kann die geschachtelte Referenz '__ocs_fmt_0__' nicht trennen. Trennen Sie sie in der Host-Zeichnung. + .xref-cannot-make-path-relative = XREF: Pfad kann nicht über Laufwerkgrenzen hinweg relativiert werden. + .xref-detached = XREF: "__ocs_fmt_0__" getrennt. + .xref-no-loaded-reference-with-that-key = XREF: keine geladene Referenz mit diesem Schlüssel. + .xref-no-references-match = XREF: keine Referenzen stimmen mit '__ocs_fmt_0__' überein. + .xref-manager-title = Externe Referenzen + .xref-self-attach-blocked = XATTACH: kann die Host-Zeichnung nicht in sich selbst anhängen. + .xref-overlays-drawing-references-only = XREF: Überlagerungen gelten nur für Zeichnungsreferenzen. + .xref-path-set-for = XREF: Pfad für "__ocs_fmt_0__" festgelegt — zum Anwenden neu laden. + .xref-unloaded = XREF: "__ocs_fmt_0__" entladen. + .xref-unknown-option = XREF: unbekannte Option '__ocs_fmt_0__'. Optionen: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: __ocs_fmt_0__ Referenz(en) aktualisiert. + .usage-xref-path-pattern = Verwendung: XREF Path + .usage-xref-path-find = Verwendung: XREF Path Find + .usage-xref-pathtype = Verwendung: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ Referenz(en) nicht gefunden — öffnen Sie den Referenzmanager mit EXTERNALREFERENCES. + .xref-nested-not-rendered = (geschachtelt — nicht dargestellt) + .change-path = Pfad ändern: + .date = Datum + .details = Details + .xref-detach = Trennen + .failed = Fehlgeschlagen + .xref-file-attach-is-not-available-on-web = Dateianhängen ist im Web nicht verfügbar — die Referenzliste unten ist schreibgeschützt. + .xref-file-name = Dateiname + .found-at = Gefunden bei + .xref-help = Hilfe + .xref-nested-references-are-read-only = Geschachtelte Referenzen sind schreibgeschützt — bearbeiten Sie sie in ihrer Host-Zeichnung. + .not-found = Nicht gefunden + .orphaned = Verwaist + .unreferenced = Unreferenziert + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = Geöffnet + .xref-untitled = Ohne Titel + .xref-unresolved = Nicht aufgelöst + .xref-preview-not-available = Vorschau nicht verfügbar + .xref-reload-all = Alle neu laden + .xref-dwg = DWG + .xref-pixel-width = Pixelbreite + .xref-pixel-height = Pixelhöhe + .xref-resolution-unit = Auflösungseinheit + .xref-overlay = Überlagern + .xref-reference-manager-select-rows-bind = Referenzmanager — wählen Sie Zeilen aus, dann Trennen, Entladen, Neu laden, Einbinden, Überlagern oder einen Pfadänderungsmodus. + .xref-bind = Einbinden + .xref-refresh = Aktualisieren + .relative = Relativ + .xref-reload = Neu laden + .xref-replace = Ersetzen: + .saved-path = Gespeicherter Pfad + .xref-select-a-reference-first = Wählen Sie zuerst eine Referenz aus. + .xref-select-a-reference-to-inspect-its = Wählen Sie eine Referenz aus, um ihre Details anzuzeigen + .xref-set-path = Pfad festlegen + .stale = Veraltet + .toggle-list-tree = Liste/Baum umschalten + .tree = Baum + .xref-type-a-new-path-first = Geben Sie zuerst einen neuen Pfad ein. + .xref-type-the-path-prefix-to-find-first = Geben Sie zuerst die zu suchende Pfadpräfix ein. + .xref-unload = Entladen + .unloaded = Entladen + .xref-new-path-for-selection = neuer Pfad für die Auswahl + .xref-new-path-prefix = neue Pfadpräfix + .xref-old-path-prefix = alte Pfadpräfix + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF Speichern Sie zuerst die Zeichnung, um relative XREF Pfade aufzulösen. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ Objekt(e) kopiert ins Zwischenablage (Basis __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ Objekt(e) kopiert ins Clipboard. @@ -3222,6 +3739,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Linie, Strahl oder Konstruktionslinie als Achse wählen: .revolve-select-objects-or-mode = REVOLVE Objekte zum Rotieren wählen oder [Modus] (Enter zum Beenden): + .path = Path: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Erstellen .boolean = Boolesche Operationen @@ -4532,6 +5056,19 @@ plot = .export-pdf = PDF exportieren .fit-to-paper = An Papier anpassen .layout-plots-the-current-sheet-using-the-selec = Das Layout plottet das aktuelle Blatt mit dem ausgewählten Papierformat. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Zusammenführung überlappender Leitungen .object-lineweights = Objektliniengewichte .orientation = Ausrichtung @@ -4549,6 +5086,8 @@ plot = .plot-style-table-pen-assignments = Plot-Tabelle (Stiftzuweisungen) .plot-transparency = Transparenz der Parzellen .plot-upside-down = Plot auf dem Kopf + .named-plot-style-tables-stb-are-not-supported-yet-name = Benannte Plotstiltabellen (.stb) werden noch nicht unterstützt: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ wurde im Plotstil-Ordner (__ocs_fmt_1__) nicht gefunden. .preview = Vorschau .printer-plotter = Drucker/Plotter .properties = Eigenschaften... @@ -4560,6 +5099,34 @@ plot = .system-default-printer = Systemstandarddrucker .what-to-plot = Was zu plotten .x-mm = X (mm) + .select-drawing-to-import-page-setups-from = Zeichnung für den Seiteneinrichtungs-Import wählen + .drawings-and-templates = Zeichnungen und Vorlagen + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: __ocs_fmt_0__ Seiteneinrichtung(en) importiert, __ocs_fmt_1__ ersetzt. + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: keine Seiteneinrichtung namens „__ocs_fmt_0__“ in __ocs_fmt_1__. + .psetupin-nothing-to-import-from-file = PSETUPIN: nichts zu importieren aus __ocs_fmt_0__. + .no-named-page-setups-in-this-drawing = Keine benannten Seiteneinrichtungen in dieser Zeichnung. + .import-ellipsis = Importieren… + .show-the-page-setup-for-new-layouts = Seiteneinrichtung für neue Layouts anzeigen + .x-in = X (Zoll) + .y-in = Y (Zoll) + .hide-paperspace-objects = Papierbereichsobjekte ausblenden + .save-changes-to-layout = Änderungen im Layout speichern + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ unterstützt __ocs_fmt_1__ nicht; auf das Standardblatt __ocs_fmt_2__ gewechselt (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ unterstützt __ocs_fmt_1__ nicht; das Blatt wird beibehalten (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: Der Drucker meldet keine Blätter; der Papierkatalog wird verwendet. + .printer-count-sheet-s-reported = __ocs_fmt_0__: __ocs_fmt_1__ Blatt/Blätter gemeldet + .default-sheet-sheet = Standardblatt: __ocs_fmt_0__ + .default-sheet-not-reported = Standardblatt: nicht gemeldet + .default-printer-name = Standarddrucker: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Standarddrucker: vom System keiner gemeldet + .printers-none-listed-by-the-system = Drucker: vom System keiner aufgeführt + .printers-count = Drucker (__ocs_fmt_0__): + .could-not-list-printers-error = Drucker konnten nicht aufgelistet werden: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = Plots laufen über die PDF-Anwendung: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = Plots gehen direkt an den Drucker (keine PDF-Anwendung registriert einen Druckbefehl). + .via-the-pdf-application = über die PDF-Anwendung + .direct-print = Direktdruck .y-mm = Y (mm) manage = @@ -4771,6 +5338,19 @@ ui = .what-s-new = Was ist neu .width-value = Breite: __ocs_arg_value__ .working = Arbeitend... + .recent-input = Letzte Eingaben + .copy-with-base-point = Mit Basispunkt kopieren + .undo-label = Rückgängig: __ocs_arg_label__ + .redo-label = Wiederherstellen: __ocs_arg_label__ + .copy-selection = Auswahl kopieren + .isolate = Isolieren + .deselect-all = Auswahl aufheben + .options-ellipsis = Optionen... + .base-point = Basispunkt + .snap-overrides = Fang-Überschreibungen + .mid-between-2-points = Mitte zwischen 2 Punkten + .osnap-settings = Objektfang-Einstellungen... + .snap-override-none-next-pick-only = Fang-Überschreibung: Keine (nur nächster Punkt). dimstyle = .keep-text-between-extension-lines = Text immer zwischen Hilfslinien halten diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 652a44b80..612e50752 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Προεπιλεγμένη εφαρμογή .iced-theme = Θέμα Iced: .theme = Θέμα + .right-click-customization = Προσαρμογή δεξιού κλικ + .right-click-in-drawing-area = Δεξί κλικ στην περιοχή σχεδίασης + .hold-duration = Διάρκεια παρατεταμένου πατήματος + .right-click-mode-help = Μενού συντόμευσης: το δεξί κλικ ανοίγει πάντα το μενού. Χρονοευαίσθητο: γρήγορο κλικ = Enter, παρατεταμένο πάτημα ανοίγει το μενού (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Μενού συντόμευσης + .right-click-mode-time-sensitive = Χρονοευαίσθητο (γρήγορο κλικ = Enter) + .right-click-mode-enter-first = Πρώτα Enter, δεύτερο κλικ = μενού command-line = .ready = Το Open CAD Studio είναι έτοιμο. @@ -244,6 +251,7 @@ command-move = *[other] { $count } αντικείμενα }]: .target = MOVE Ορίστε προορισμό [βάση { $x },{ $y }]: + .displacement = MOVE Ορίστε μετατόπιση: command-copy = .array-count = COPY Δώστε πλήθος στοιχείων διάταξης: @@ -258,6 +266,8 @@ command-copy = [one] { $count } αντίγραφο μέχρι τώρα *[other] { $count } αντίγραφα μέχρι τώρα } | Διάταξη | Enter=τέλος | βάση { $x },{ $y }]: + .displacement = COPY Ορίστε μετατόπιση: + .mode = COPY Επιλέξτε λειτουργία αντιγραφής [Single/Multiple] : common = .revolve-created-objects-failed-sources = REVOLVE: δημιουργήθηκαν __ocs_arg_created__ αντικείμενα· __ocs_arg_failed__ αρχικά αντικείμενα δεν μπόρεσαν να περιστραφούν. @@ -1867,6 +1877,391 @@ common = .dimensional = Διαστατικοί .show-hide = Εμφάνιση/Απόκρυψη + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Προειδοποίηση αποθήκευσης .unable-to-save-drawing = Αδυναμία αποθήκευσης σχεδίου @@ -2439,6 +2834,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Αφαίρεση + .pline-specify-starting-width = PLINE Ορίστε αρχικό πλάτος <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE Ορίστε τελικό πλάτος <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE Ορίστε αρχικό ημιπλάτος <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE Ορίστε τελικό ημιπλάτος <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (Γραμμή) Ορίστε επόμενο σημείο: + .pline-arc-specify-endpoint-of-arc = PLINE (Τόξο) Ορίστε τελικό σημείο τόξου: + .pline-specify-length-of-line = PLINE Ορίστε μήκος γραμμής: + .pline-specify-included-angle = PLINE Ορίστε περιεχόμενη γωνία: + .pline-specify-endpoint-of-arc = PLINE Ορίστε τελικό σημείο τόξου: + .pline-specify-center-point-of-arc = PLINE Ορίστε κέντρο τόξου: + .pline-specify-radius-of-arc = PLINE Ορίστε ακτίνα τόξου: + .pline-specify-direction-of-chord-for-arc = PLINE Ορίστε διεύθυνση χορδής τόξου: + .pline-specify-length-of-chord = PLINE Ορίστε μήκος χορδής: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Ορίστε διεύθυνση εφαπτομένης στην αρχή του τόξου: + .pline-specify-second-point-on-arc = PLINE Ορίστε δεύτερο σημείο του τόξου: + .halfwidth = Ημιπλάτος + .center-keyword = Κέντρο + .close-keyword = Κλείσιμο + .second-pt = Δεύτερο σημείο + .arc-specify-start-point-of-arc = ARC Ορίστε αρχικό σημείο τόξου: + .arc-specify-second-point-of-arc = ARC Ορίστε δεύτερο σημείο τόξου: + .arc-specify-end-point-of-arc = ARC Ορίστε τελικό σημείο τόξου: + .arc-specify-center-point-of-arc = ARC Ορίστε κέντρο τόξου: + .arc-specify-included-angle = ARC Ορίστε περιεχόμενη γωνία: + .arc-specify-length-of-chord = ARC Ορίστε μήκος χορδής: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Ορίστε διεύθυνση εφαπτομένης στην αρχή του τόξου: + .arc-specify-radius-of-arc = ARC Ορίστε ακτίνα τόξου: + .chord-length = Μήκος χορδής modify = .count-objects = (__ocs_arg_count__ αντικείμενα) @@ -2591,6 +3014,13 @@ modify = .tol-scale-dimtfac = Κλίμακα ανοχής (DIMTFAC) .trim = Αποκοπή .exit = Έξοδος + .area-keyword = Εμβαδόν + .displacement = Μετατόπιση + .mode-keyword = Λειτουργία + .array-keyword = Διάταξη + .use-first-point-as-displacement = Πρώτο σημείο ως μετατόπιση + .offset-erase-source-object-after-offsetting = OFFSET Διαγραφή αρχικού αντικειμένου μετά την παράλληλη μετατόπιση; [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Επιλογή στρώσης για τα νέα αντικείμενα [Current/Source] : groups = .group-enter-group-name-name = GROUP Δώστε όνομα ομάδας [__ocs_arg_name__]: @@ -3133,6 +3563,95 @@ insert = .xref-no-external-references-in-this-drawing = XREF Δεν υπάρχουν εξωτερικές αναφορές σε αυτό το σχέδιο. .xref-reloaded = XREF Επαναφορτώθηκε το "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Αποθηκεύστε πρώτα το σχέδιο για να επιλυθούν οι σχετικές διαδρομές XREF. + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" set to Overlay. + .xref-bound = XREF: bound "__ocs_fmt_0__". + .xref-bound-with-unremapped-handles = XREF: bound "__ocs_fmt_0__" with __ocs_fmt_1__ unremapped style handles (see bind limitations). + .xref-cannot-bind-nested-reference = XREF: cannot bind nested reference '__ocs_fmt_0__'. Bind it in its host drawing. + .xref-cannot-bind = XREF: cannot bind "__ocs_fmt_0__": __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: PDF bind (vector import) is not available in this version. + .xref-cannot-detach-nested-reference = XREF: cannot detach nested reference '__ocs_fmt_0__'. Detach it in its host drawing. + .xref-cannot-make-path-relative = XREF: cannot make path relative across drives. + .xref-detached = XREF: detached "__ocs_fmt_0__". + .xref-no-loaded-reference-with-that-key = XREF: no loaded reference with that key. + .xref-no-references-match = XREF: no references match '__ocs_fmt_0__'. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" set to Attach. + .xref-cannot-unload-nested-reference = XREF: cannot unload nested reference '__ocs_fmt_0__'. Unload it in its host drawing. + .xref-cannot-overlay-nested-reference = XREF: cannot overlay nested reference '__ocs_fmt_0__'. Overlay it in its host drawing. + .xref-cannot-reload-nested-reference = XREF: cannot reload nested reference '__ocs_fmt_0__'. Reload it in its host drawing. + .xref-cannot-attach-nested-reference = XREF: cannot attach nested reference '__ocs_fmt_0__'. Attach it in its host drawing. + .xref-cannot-set-path-type-nested-reference = XREF: cannot set path type for nested reference '__ocs_fmt_0__'. Set it in its host drawing. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: reload applies to drawing references only. + .xref-manager-title = External References + .xref-self-attach-blocked = XATTACH: cannot attach the host drawing into itself. + .xref-overlays-drawing-references-only = XREF: overlays apply to drawing references only. + .xref-path-set-for = XREF: Path set for "__ocs_fmt_0__" — Reload to apply. + .xref-unloaded = XREF: unloaded "__ocs_fmt_0__". + .xref-unknown-option = XREF: unknown option '__ocs_fmt_0__'. Options: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: updated __ocs_fmt_0__ reference(s). + .usage-xref-path-pattern = Usage: XREF Path + .usage-xref-path-find = Usage: XREF Path Find + .usage-xref-pathtype = Usage: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ reference(s) not found — open the reference manager with EXTERNALREFERENCES. + .xref-nested-not-rendered = (nested — not rendered) + .change-path = Change Path: + .date = Date + .details = Details + .xref-detach = Detach + .failed = Failed + .xref-file-attach-is-not-available-on-web = File attach is not available on web — the reference list below is read-only. + .xref-file-name = File name + .found-at = Found At + .xref-help = Help + .xref-nested-references-are-read-only = Nested references are read-only — edit them in their host drawing. + .not-found = Not found + .orphaned = Orphaned + .unreferenced = Unreferenced + .xref-file-references = File References + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = Opened + .xref-untitled = Untitled + .xref-unresolved = Unresolved + .xref-preview-not-available = Preview not available + .xref-reload-all = Reload All + .xref-dwg = DWG + .xref-pixel-width = Pixel Width + .xref-pixel-height = Pixel Height + .xref-resolution-unit = Resolution Unit + .xref-overlay = Overlay + .path = Path: + .xref-reference-manager-select-rows-bind = Reference Manager — select rows, then Detach, Unload, Reload, Bind, Overlay, or a Change Path mode. + .xref-bind = Bind + .xref-refresh = Refresh + .relative = Relative + .xref-reload = Reload + .xref-replace = Replace: + .saved-path = Saved Path + .xref-select-a-reference-first = Select a reference first. + .xref-select-a-reference-to-inspect-its = Select a reference to inspect its details + .xref-set-path = Set Path + .stale = Stale + .toggle-list-tree = Toggle list/tree + .tree = Tree + .xref-type-a-new-path-first = Type a new path first. + .xref-type-the-path-prefix-to-find-first = Type the path prefix to find first. + .xref-unload = Unload + .unloaded = Unloaded + .xref-new-path-for-selection = new path for selection + .xref-new-path-prefix = new path prefix + .xref-old-path-prefix = old path prefix + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ αντικείμενο(α) αντιγράφηκαν στο πρόχειρο (βάση __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ αντικείμενο(α) αντιγράφηκαν στο πρόχειρο. .object-s-cut-to-clipboard = __ocs_fmt_0__ αντικείμενο(α) αποκόπηκαν στο πρόχειρο. @@ -3224,6 +3743,11 @@ insert = .select-at-least-one-guide-curve = { draw.select }: { common.guides } (≥1). .specify-the-requested-path-or-point-or-use-undo = { common.specify }: { common.path } / { common.point } [{ common.undo }]. + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Δημιουργία .boolean = Δυαδική πράξη @@ -4534,6 +5058,19 @@ plot = .export-pdf = Εξαγωγή PDF .fit-to-paper = Προσαρμογή στο χαρτί .layout-plots-the-current-sheet-using-the-selec = Η επιλογή Διάταξη εκτυπώνει το τρέχον φύλλο με το επιλεγμένο μέγεθος χαρτιού. + .sheet-sizes-and-printable-areas-reported-by-th = Μεγέθη φύλλων και εκτυπώσιμες περιοχές όπως τα αναφέρει ο εκτυπωτής. + .custom-paper-size = Προσαρμοσμένο μέγεθος χαρτιού + .printable-margins = Εκτυπώσιμα περιθώρια + .enter-a-positive-width-and-height = Δώστε θετικό πλάτος και ύψος. + .paper-size = Μέγεθος χαρτιού + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ επιλογή(-ές) driver ορισμένες για αυτόν τον εκτυπωτή. + .printer-properties-printer = Ιδιότητες εκτυπωτή — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = Ο εκτυπωτής δεν αναφέρει επιλογές driver. + .could-not-read-the-printer-s-options-error = Αδυναμία ανάγνωσης των επιλογών του εκτυπωτή: __ocs_fmt_0__ + .reading-the-printer-s-options = Ανάγνωση των επιλογών του εκτυπωτή… + .printing-preferences-saved-for-printer = Οι προτιμήσεις εκτύπωσης αποθηκεύτηκαν για τον εκτυπωτή __ocs_fmt_0__. + .printing-preferences-unchanged = Οι προτιμήσεις εκτύπωσης δεν άλλαξαν. + .printer-properties = Ιδιότητες εκτυπωτή .merge-overlapping-lines = Συγχώνευση επικαλυπτόμενων γραμμών .object-lineweights = Πάχη γραμμών αντικειμένων .orientation = Προσανατολισμός @@ -4551,6 +5088,8 @@ plot = .plot-style-table-pen-assignments = Πίνακας στυλ εκτύπωσης (αντιστοιχίσεις πενών) .plot-transparency = Εκτύπωση διαφάνειας .plot-upside-down = Εκτύπωση ανάποδα + .named-plot-style-tables-stb-are-not-supported-yet-name = Οι επώνυμοι πίνακες στυλ εκτύπωσης (.stb) δεν υποστηρίζονται ακόμα: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = Το __ocs_fmt_0__ δεν βρέθηκε στον φάκελο των στυλ εκτύπωσης (__ocs_fmt_1__). .preview = Προεπισκόπηση .printer-plotter = Εκτυπωτής / σχεδιογράφος .properties = Ιδιότητες… @@ -4562,6 +5101,34 @@ plot = .system-default-printer = Προεπιλεγμένος εκτυπωτής συστήματος .what-to-plot = Τι θα εκτυπωθεί .x-mm = X (mm) + .select-drawing-to-import-page-setups-from = Επιλογή σχεδίου για εισαγωγή page setups + .drawings-and-templates = Σχέδια και πρότυπα + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: εισήχθησαν __ocs_fmt_0__ page setup(s), __ocs_fmt_1__ αντικαταστάθηκαν. + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: δεν υπάρχει page setup με όνομα «__ocs_fmt_0__» στο __ocs_fmt_1__. + .psetupin-nothing-to-import-from-file = PSETUPIN: τίποτα προς εισαγωγή από το __ocs_fmt_0__. + .no-named-page-setups-in-this-drawing = Δεν υπάρχουν επώνυμα page setups σε αυτό το σχέδιο. + .import-ellipsis = Εισαγωγή… + .show-the-page-setup-for-new-layouts = Εμφάνιση του page setup για νέα layouts + .x-in = X (in) + .y-in = Y (in) + .hide-paperspace-objects = Απόκρυψη αντικειμένων χώρου χαρτιού + .save-changes-to-layout = Αποθήκευση αλλαγών στο layout + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = Ο __ocs_fmt_0__ δεν υποστηρίζει το __ocs_fmt_1__· έγινε αλλαγή στο προεπιλεγμένο φύλλο του __ocs_fmt_2__ (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = Ο __ocs_fmt_0__ δεν υποστηρίζει το __ocs_fmt_1__· το φύλλο διατηρείται (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: ο εκτυπωτής δεν αναφέρει φύλλα· χρησιμοποιείται ο κατάλογος χαρτιών. + .printer-count-sheet-s-reported = __ocs_fmt_0__: αναφέρθηκαν __ocs_fmt_1__ φύλλα + .default-sheet-sheet = Προεπιλεγμένο φύλλο: __ocs_fmt_0__ + .default-sheet-not-reported = Προεπιλεγμένο φύλλο: δεν αναφέρθηκε + .default-printer-name = Προεπιλεγμένος εκτυπωτής: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Προεπιλεγμένος εκτυπωτής: το σύστημα δεν αναφέρει κανέναν + .printers-none-listed-by-the-system = Εκτυπωτές: το σύστημα δεν αναφέρει κανέναν + .printers-count = Εκτυπωτές (__ocs_fmt_0__): + .could-not-list-printers-error = Δεν ήταν δυνατή η λίστα εκτυπωτών: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = Οι εκτυπώσεις περνούν από την εφαρμογή PDF: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = Οι εκτυπώσεις πάνε απευθείας στον εκτυπωτή (καμία εφαρμογή PDF δεν δηλώνει εντολή εκτύπωσης). + .via-the-pdf-application = μέσω της εφαρμογής PDF + .direct-print = απευθείας εκτύπωση .y-mm = Y (mm) manage = @@ -4773,6 +5340,19 @@ ui = .what-s-new = Τι νέο υπάρχει .width-value = Πλάτος: __ocs_arg_value__ .working = Εκτέλεση… + .recent-input = Πρόσφατες εισαγωγές + .copy-with-base-point = Αντιγραφή με σημείο βάσης + .undo-label = Αναίρεση __ocs_arg_label__ + .redo-label = Επανάληψη __ocs_arg_label__ + .copy-selection = Αντιγραφή επιλογής + .isolate = Απομόνωση + .deselect-all = Αποεπιλογή όλων + .options-ellipsis = Επιλογές... + .base-point = Σημείο βάσης + .snap-overrides = Προσωρινές έλξεις + .mid-between-2-points = Μέσο μεταξύ 2 σημείων + .osnap-settings = Ρυθμίσεις έλξης... + .snap-override-none-next-pick-only = Προσωρινή έλξη: Καμία (μόνο για το επόμενο σημείο). dimstyle = .keep-text-between-extension-lines = Το κείμενο να παραμένει πάντα μεταξύ των γραμμών προέκτασης diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index a0d1f0b71..e2beacbf0 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Default Application .iced-theme = Iced theme: .theme = Theme + .right-click-customization = Right-click Customization + .right-click-in-drawing-area = Right-click in drawing area + .hold-duration = Hold duration + .right-click-mode-help = Shortcut menu: right-click always opens the menu. Time-sensitive: a quick click is Enter, holding longer opens the menu (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Shortcut menu + .right-click-mode-time-sensitive = Time-sensitive (quick click = Enter) + .right-click-mode-enter-first = Enter first, second click = menu command-line = .ready = Open CAD Studio ready. @@ -244,6 +251,7 @@ command-move = *[other] { $count } objects }]: .target = MOVE Specify destination [base { $x },{ $y }]: + .displacement = MOVE Specify displacement: command-copy = .array-count = COPY Enter number of items to array: @@ -258,6 +266,8 @@ command-copy = [one] { $count } copy so far *[other] { $count } copies so far } | Array | Enter=done | base { $x },{ $y }]: + .displacement = COPY Specify displacement: + .mode = COPY Enter a copy mode option [Single/Multiple] : common = .none-use-shortcuts-set-key-command = (none — use: SHORTCUTS SET ) @@ -1868,6 +1878,391 @@ common = .dimensional = Dimensional .show-hide = Show/Hide + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Save Warning .unable-to-save-drawing = Unable to Save Drawing @@ -2440,6 +2835,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Remove + .pline-specify-starting-width = PLINE Specify starting width <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE Specify ending width <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE Specify starting half-width <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE Specify ending half-width <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (Line) Specify next point: + .pline-arc-specify-endpoint-of-arc = PLINE (Arc) Specify endpoint of arc: + .pline-specify-length-of-line = PLINE Specify length of line: + .pline-specify-included-angle = PLINE Specify included angle: + .pline-specify-endpoint-of-arc = PLINE Specify endpoint of arc: + .pline-specify-center-point-of-arc = PLINE Specify center point of arc: + .pline-specify-radius-of-arc = PLINE Specify radius of arc: + .pline-specify-direction-of-chord-for-arc = PLINE Specify direction of chord for arc: + .pline-specify-length-of-chord = PLINE Specify length of chord: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Specify tangent direction for the start point of arc: + .pline-specify-second-point-on-arc = PLINE Specify second point on arc: + .halfwidth = Halfwidth + .center-keyword = CEnter + .close-keyword = CLose + .second-pt = Second pt + .arc-specify-start-point-of-arc = ARC Specify start point of arc: + .arc-specify-second-point-of-arc = ARC Specify second point of arc: + .arc-specify-end-point-of-arc = ARC Specify end point of arc: + .arc-specify-center-point-of-arc = ARC Specify center point of arc: + .arc-specify-included-angle = ARC Specify included angle: + .arc-specify-length-of-chord = ARC Specify length of chord: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Specify tangent direction for the start point of arc: + .arc-specify-radius-of-arc = ARC Specify radius of arc: + .chord-length = chord Length modify = .count-objects = (__ocs_arg_count__ objects) @@ -2592,6 +3015,13 @@ modify = .tol-scale-dimtfac = Tol. scale (DIMTFAC) .trim = Trim .exit = eXit + .area-keyword = ARea + .displacement = Displacement + .mode-keyword = mOde + .array-keyword = Array + .use-first-point-as-displacement = Use first point as displacement + .offset-erase-source-object-after-offsetting = OFFSET Erase source object after offsetting? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Enter layer option for offset objects [Current/Source] : groups = .group-enter-group-name-name = GROUP Enter group name [__ocs_arg_name__]: @@ -3134,6 +3564,95 @@ insert = .xref-no-external-references-in-this-drawing = XREF No external references in this drawing. .xref-reloaded = XREF Reloaded "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Save the drawing first to resolve relative XREF paths. + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" set to Overlay. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" set to Attach. + .xref-cannot-unload-nested-reference = XREF: cannot unload nested reference '__ocs_fmt_0__'. Unload it in its host drawing. + .xref-cannot-overlay-nested-reference = XREF: cannot overlay nested reference '__ocs_fmt_0__'. Overlay it in its host drawing. + .xref-cannot-reload-nested-reference = XREF: cannot reload nested reference '__ocs_fmt_0__'. Reload it in its host drawing. + .xref-cannot-attach-nested-reference = XREF: cannot attach nested reference '__ocs_fmt_0__'. Attach it in its host drawing. + .xref-cannot-set-path-type-nested-reference = XREF: cannot set path type for nested reference '__ocs_fmt_0__'. Set it in its host drawing. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: reload applies to drawing references only. + .xref-bound = XREF: bound "__ocs_fmt_0__". + .xref-bound-with-unremapped-handles = XREF: bound "__ocs_fmt_0__" with __ocs_fmt_1__ unremapped style handles (see bind limitations). + .xref-cannot-bind-nested-reference = XREF: cannot bind nested reference '__ocs_fmt_0__'. Bind it in its host drawing. + .xref-cannot-bind = XREF: cannot bind "__ocs_fmt_0__": __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: PDF bind (vector import) is not available in this version. + .xref-cannot-detach-nested-reference = XREF: cannot detach nested reference '__ocs_fmt_0__'. Detach it in its host drawing. + .xref-cannot-make-path-relative = XREF: cannot make path relative across drives. + .xref-detached = XREF: detached "__ocs_fmt_0__". + .xref-no-loaded-reference-with-that-key = XREF: no loaded reference with that key. + .xref-no-references-match = XREF: no references match '__ocs_fmt_0__'. + .xref-manager-title = External References + .xref-self-attach-blocked = XATTACH: cannot attach the host drawing into itself. + .xref-overlays-drawing-references-only = XREF: overlays apply to drawing references only. + .xref-path-set-for = XREF: Path set for "__ocs_fmt_0__" — Reload to apply. + .xref-unloaded = XREF: unloaded "__ocs_fmt_0__". + .xref-unknown-option = XREF: unknown option '__ocs_fmt_0__'. Options: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: updated __ocs_fmt_0__ reference(s). + .usage-xref-path-pattern = Usage: XREF Path + .usage-xref-path-find = Usage: XREF Path Find + .usage-xref-pathtype = Usage: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ reference(s) not found — open the reference manager with EXTERNALREFERENCES. + .xref-nested-not-rendered = (nested — not rendered) + .change-path = Change Path: + .date = Date + .details = Details + .xref-detach = Detach + .failed = Failed + .xref-file-attach-is-not-available-on-web = File attach is not available on web — the reference list below is read-only. + .xref-file-name = File name + .found-at = Found At + .xref-help = Help + .xref-nested-references-are-read-only = Nested references are read-only — edit them in their host drawing. + .not-found = Not found + .orphaned = Orphaned + .unreferenced = Unreferenced + .xref-file-references = File References + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = Opened + .xref-untitled = Untitled + .xref-unresolved = Unresolved + .xref-preview-not-available = Preview not available + .xref-reload-all = Reload All + .xref-dwg = DWG + .xref-pixel-width = Pixel Width + .xref-pixel-height = Pixel Height + .xref-resolution-unit = Resolution Unit + .xref-overlay = Overlay + .path = Path: + .xref-reference-manager-select-rows-bind = Reference Manager — select rows, then Detach, Unload, Reload, Bind, Overlay, or a Change Path mode. + .xref-bind = Bind + .xref-refresh = Refresh + .relative = Relative + .xref-reload = Reload + .xref-replace = Replace: + .saved-path = Saved Path + .xref-select-a-reference-first = Select a reference first. + .xref-select-a-reference-to-inspect-its = Select a reference to inspect its details + .xref-set-path = Set Path + .stale = Stale + .toggle-list-tree = Toggle list/tree + .tree = Tree + .xref-type-a-new-path-first = Type a new path first. + .xref-type-the-path-prefix-to-find-first = Type the path prefix to find first. + .xref-unload = Unload + .unloaded = Unloaded + .xref-new-path-for-selection = new path for selection + .xref-new-path-prefix = new path prefix + .xref-old-path-prefix = old path prefix + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ object(s) copied to clipboard (base __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ object(s) copied to clipboard. .object-s-cut-to-clipboard = __ocs_fmt_0__ object(s) cut to clipboard. @@ -3225,6 +3744,11 @@ insert = .select-at-least-one-guide-curve = Select at least one guide curve. .specify-the-requested-path-or-point-or-use-undo = Specify the requested path or point, or use Undo. + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Create .boolean = Boolean @@ -3674,7 +4198,7 @@ view = .cmd-not-yet-implemented = __ocs_fmt_0__: not yet implemented. .viewport-s-in-layout = __ocs_fmt_0__ viewport(s) in layout "__ocs_fmt_1__": - .ucs-option-world-view-3point-z-x-y-origin-save-delete-or-name = UCS option [World/View/3Point/Z/X/Y/Origin/Save/Delete] or name: + .ucs-option-world-view-3point-z-x-y-origin-save-delete-or-name = UCS option [Face/OBject/World/View/3Point/Z/X/Y/Origin/Save/Delete] or name: .ucs-specify-new-origin = UCS specify new origin: .ucs-specify-point-on-positive-x-axis = UCS specify point on positive X axis: .ucs-specify-point-in-positive-xy-plane = UCS specify point in positive XY plane: @@ -4535,6 +5059,19 @@ plot = .export-pdf = Export PDF .fit-to-paper = Fit to paper .layout-plots-the-current-sheet-using-the-selec = Layout plots the current sheet using the selected paper size. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Merge overlapping lines .object-lineweights = Object lineweights .orientation = Orientation @@ -4552,6 +5089,8 @@ plot = .plot-style-table-pen-assignments = Plot style table (pen assignments) .plot-transparency = Plot transparency .plot-upside-down = Plot upside-down + .named-plot-style-tables-stb-are-not-supported-yet-name = Named plot style tables (.stb) are not supported yet: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ was not found in the plot styles folder (__ocs_fmt_1__). .preview = Preview .printer-plotter = Printer / plotter .properties = Properties… @@ -4563,6 +5102,34 @@ plot = .system-default-printer = System default printer .what-to-plot = What to plot .x-mm = X (mm) + .select-drawing-to-import-page-setups-from = Select Drawing to Import Page Setups From + .drawings-and-templates = Drawings and templates + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: __ocs_fmt_0__ page setup(s) imported, __ocs_fmt_1__ replaced. + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: no page setup named "__ocs_fmt_0__" in __ocs_fmt_1__. + .psetupin-nothing-to-import-from-file = PSETUPIN: nothing to import from __ocs_fmt_0__. + .no-named-page-setups-in-this-drawing = No named page setups in this drawing. + .import-ellipsis = Import… + .show-the-page-setup-for-new-layouts = Show the page setup for new layouts + .x-in = X (in) + .y-in = Y (in) + .hide-paperspace-objects = Hide paperspace objects + .save-changes-to-layout = Save changes to layout + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ does not support __ocs_fmt_1__; switched to its default sheet __ocs_fmt_2__ (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ does not support __ocs_fmt_1__; the sheet is kept (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: the printer reports no sheets; the paper catalogue is used. + .printer-count-sheet-s-reported = __ocs_fmt_0__: __ocs_fmt_1__ sheet(s) reported + .default-sheet-sheet = Default sheet: __ocs_fmt_0__ + .default-sheet-not-reported = Default sheet: not reported + .default-printer-name = Default printer: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Default printer: none reported by the system + .printers-none-listed-by-the-system = Printers: none listed by the system + .printers-count = Printers (__ocs_fmt_0__): + .could-not-list-printers-error = Could not list printers: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = Plots go through the PDF application: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = Plots go straight to the printer (no PDF application registers a print verb). + .via-the-pdf-application = via the PDF application + .direct-print = direct print .y-mm = Y (mm) manage = @@ -4774,6 +5341,19 @@ ui = .what-s-new = What's new .width-value = Width: __ocs_arg_value__ .working = Working… + .recent-input = Recent Input + .copy-with-base-point = Copy with Base Point + .undo-label = Undo __ocs_arg_label__ + .redo-label = Redo __ocs_arg_label__ + .copy-selection = Copy Selection + .isolate = Isolate + .deselect-all = Deselect All + .options-ellipsis = Options... + .base-point = Base Point + .snap-overrides = Snap Overrides + .mid-between-2-points = Mid Between 2 Points + .osnap-settings = Osnap Settings... + .snap-override-none-next-pick-only = Snap override: None (next pick only). dimstyle = .keep-text-between-extension-lines = Always keep text between extension lines diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 72ddf64f4..0842935a8 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Aplicación predeterminada .iced-theme = Tema Iced: .theme = Tema + .right-click-customization = Personalización del botón derecho + .right-click-in-drawing-area = Botón derecho en el área de dibujo + .hold-duration = Duración de pulsación + .right-click-mode-help = Menú contextual: el botón derecho siempre abre el menú. Sensible al tiempo: un clic rápido es Intro, mantener pulsado abre el menú (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Menú contextual + .right-click-mode-time-sensitive = Sensible al tiempo (clic rápido = Intro) + .right-click-mode-enter-first = Primero Intro, segundo clic = menú command-line = .ready = Open CAD Studio está listo. @@ -245,6 +252,7 @@ command-move = *[other] { $count } objetos }]: .target = MOVE Especifique el destino [base { $x },{ $y }]: + .displacement = MOVE Precise desplazamiento: command-copy = .array-count = COPY Introduzca el número de elementos de la matriz: @@ -259,6 +267,8 @@ command-copy = [one] { $count } copia hasta ahora *[other] { $count } copias hasta ahora } | Matriz | Enter=terminar | base { $x },{ $y }]: + .displacement = COPY Precise desplazamiento: + .mode = COPY Indique modo de copia [Single/Multiple] : common = .jogged-radius = Radio con recodo @@ -1030,7 +1040,7 @@ common = .loft-solid-created = LOFT: se creó el sólido. .attsync-no-editable-block-references = ATTSYNC: no hay referencias de bloque editables. .xref-reloaded-with-repairs = XREF Recargado con reparaciones: "__ocs_fmt_0__" - .xref-reload-failed = XREF Error al recargar: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reload-failed = XREF Falló la recarga: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = Zoom anterior .zoom-no-previous-view = ZOOM: no hay vista anterior. .zoom-object = Zoom a objeto @@ -1119,7 +1129,7 @@ common = .inserting-from-file = Insertando "__ocs_fmt_0__" desde el archivo. .insert-file = INSERT FILE: __ocs_fmt_0__ .xref-recovered-with-warnings = XREF Recuperado con advertencias: "__ocs_fmt_0__" - .xref-recovery-failed = XREF Error de recuperación: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-recovery-failed = XREF Falló la recuperación: "__ocs_fmt_0__" (__ocs_fmt_1__) .select-at-least-one-layout = Seleccione al menos una presentación. .printing-is-not-available-in-the-web-version = La impresión no está disponible en la versión web. .sending-selected-layouts-to-the-system-printer = Enviando las presentaciones seleccionadas a la impresora del sistema… @@ -1615,7 +1625,7 @@ common = .create-triangular-face = Crear una cara triangular .location = { common.position } .creation-mode = Modo de creación - .path = Trayectoria + .path = Ruta: .expression = Expresión .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1868,6 +1878,391 @@ common = .dimensional = Dimensionales .show-hide = Mostrar/Ocultar + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Advertencia de guardado .unable-to-save-drawing = No se puede guardar el dibujo @@ -2439,6 +2834,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Quitar + .pline-specify-starting-width = PLINE Precise grosor inicial <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE Precise grosor final <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE Precise semigrosor inicial <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE Precise semigrosor final <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (Línea) Precise punto siguiente: + .pline-arc-specify-endpoint-of-arc = PLINE (Arco) Precise punto final del arco: + .pline-specify-length-of-line = PLINE Precise longitud de la línea: + .pline-specify-included-angle = PLINE Precise ángulo incluido: + .pline-specify-endpoint-of-arc = PLINE Precise punto final del arco: + .pline-specify-center-point-of-arc = PLINE Precise centro del arco: + .pline-specify-radius-of-arc = PLINE Precise radio del arco: + .pline-specify-direction-of-chord-for-arc = PLINE Precise dirección de la cuerda del arco: + .pline-specify-length-of-chord = PLINE Precise longitud de la cuerda: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Precise dirección tangente en el punto inicial del arco: + .pline-specify-second-point-on-arc = PLINE Precise segundo punto del arco: + .halfwidth = Semigrosor + .center-keyword = Centro + .close-keyword = Cerrar + .second-pt = Segundo punto + .arc-specify-start-point-of-arc = ARC Precise punto inicial del arco: + .arc-specify-second-point-of-arc = ARC Precise segundo punto del arco: + .arc-specify-end-point-of-arc = ARC Precise punto final del arco: + .arc-specify-center-point-of-arc = ARC Precise centro del arco: + .arc-specify-included-angle = ARC Precise ángulo incluido: + .arc-specify-length-of-chord = ARC Precise longitud de la cuerda: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Precise dirección tangente en el punto inicial del arco: + .arc-specify-radius-of-arc = ARC Precise radio del arco: + .chord-length = Longitud de cuerda modify = .add-vertex = Añadir vértice @@ -2591,6 +3014,13 @@ modify = .tol-scale-dimtfac = Escala de tolerancia (DIMTFAC) .trim = Recortar .exit = Salir + .area-keyword = Área + .displacement = Desplazamiento + .mode-keyword = Modo + .array-keyword = Matriz + .use-first-point-as-displacement = Usar primer punto como desplazamiento + .offset-erase-source-object-after-offsetting = OFFSET ¿Borrar objeto de origen tras desfasar? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Indique capa para los objetos desfasados [Current/Source] : groups = .group-enter-group-name-name = GROUP Introduzca el nombre del grupo [__ocs_arg_name__]: @@ -3125,6 +3555,93 @@ insert = .xref-external-references = XREF Referencias externas: .xref-no-external-references-in-this-drawing = XREF No hay referencias externas en este dibujo. .xref-reloaded = XREF Se volvió a cargar «__ocs_fmt_0__» + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" establecido en Superponer. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" establecido en Adjuntar. + .xref-cannot-unload-nested-reference = XREF: no se puede descargar la referencia anidada '__ocs_fmt_0__'. Descárguela en su dibujo host. + .xref-cannot-overlay-nested-reference = XREF: no se puede superponer la referencia anidada '__ocs_fmt_0__'. Superpóngala en su dibujo host. + .xref-cannot-reload-nested-reference = XREF: no se puede recargar la referencia anidada '__ocs_fmt_0__'. Recárguela en su dibujo host. + .xref-cannot-attach-nested-reference = XREF: no se puede adjuntar la referencia anidada '__ocs_fmt_0__'. Adjuntela en su dibujo host. + .xref-cannot-set-path-type-nested-reference = XREF: no se puede establecer el tipo de ruta para la referencia anidada '__ocs_fmt_0__'. Establézcalo en su dibujo host. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: la recarga se aplica solo a referencias de dibujo. + .xref-bound = XREF: vinculado "__ocs_fmt_0__". + .xref-bound-with-unremapped-handles = XREF: vinculado "__ocs_fmt_0__" con __ocs_fmt_1__ manejadores de estilo sin reasignar (consulte las limitaciones de vinculación). + .xref-cannot-bind-nested-reference = XREF: no se puede vincular la referencia anidada '__ocs_fmt_0__'. Vinculéla en su dibujo host. + .xref-cannot-bind = XREF: no se puede vincular "__ocs_fmt_0__": __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: La vinculación de PDF (importación vectorial) no está disponible en esta versión. + .xref-cannot-detach-nested-reference = XREF: no se puede desvincular la referencia anidada '__ocs_fmt_0__'. Desvincúlela en su dibujo host. + .xref-cannot-make-path-relative = XREF: no se puede hacer la ruta relativa entre unidades. + .xref-detached = XREF: desvinculado "__ocs_fmt_0__". + .xref-no-loaded-reference-with-that-key = XREF: no hay referencia cargada con esa clave. + .xref-no-references-match = XREF: no hay referencias que coincidan con '__ocs_fmt_0__'. + .xref-manager-title = Referencias externas + .xref-self-attach-blocked = XATTACH: no se puede adjuntar el dibujo host en sí mismo. + .xref-overlays-drawing-references-only = XREF: las superposiciones se aplican solo a referencias de dibujo. + .xref-path-set-for = XREF: Ruta establecida para "__ocs_fmt_0__" — Recargue para aplicar. + .xref-unloaded = XREF: descargado "__ocs_fmt_0__". + .xref-unknown-option = XREF: opción desconocida '__ocs_fmt_0__'. Opciones: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: __ocs_fmt_0__ referencia(s) actualizadas. + .usage-xref-path-pattern = Uso: XREF Path + .usage-xref-path-find = Uso: XREF Path Find + .usage-xref-pathtype = Uso: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ referencia(s) no encontradas — abra el administrador de referencias con EXTERNALREFERENCES. + .xref-nested-not-rendered = (anidada — no renderizada) + .change-path = Cambiar ruta: + .date = Fecha + .details = Detalles + .xref-detach = Desvincular + .failed = Falló + .xref-file-attach-is-not-available-on-web = El adjunto de archivos no está disponible en la web — la lista de referencias a continuación es de solo lectura. + .xref-file-name = Nombre de archivo + .found-at = Encontrado en + .xref-help = Ayuda + .xref-nested-references-are-read-only = Las referencias anidadas son de solo lectura — edítelas en su dibujo host. + .not-found = No encontrado + .orphaned = Huérfano + .unreferenced = Sin referencia + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = Abierto + .xref-untitled = Sin título + .xref-unresolved = No resuelto + .xref-preview-not-available = Vista previa no disponible + .xref-reload-all = Recargar todo + .xref-dwg = DWG + .xref-pixel-width = Ancho en píxeles + .xref-pixel-height = Altura en píxeles + .xref-resolution-unit = Unidad de resolución + .xref-overlay = Superponer + .xref-reference-manager-select-rows-bind = Administrador de referencias — seleccione filas, luego Desvincular, Descargar, Recargar, Vincular, Superponer o un modo de cambio de ruta. + .xref-bind = Vincular + .xref-refresh = Actualizar + .relative = Relativa + .xref-reload = Recargar + .xref-replace = Reemplazar: + .saved-path = Ruta guardada + .xref-select-a-reference-first = Seleccione una referencia primero. + .xref-select-a-reference-to-inspect-its = Seleccione una referencia para inspeccionar sus detalles + .xref-set-path = Establecer ruta + .stale = Desactualizado + .toggle-list-tree = Alternar lista/árbol + .tree = Árbol + .xref-type-a-new-path-first = Escriba una nueva ruta primero. + .xref-type-the-path-prefix-to-find-first = Escriba primero el prefijo de ruta a buscar. + .xref-unload = Descargar + .unloaded = Descargado + .xref-new-path-for-selection = nueva ruta para la selección + .xref-new-path-prefix = nuevo prefijo de ruta + .xref-old-path-prefix = prefijo de ruta antiguo + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF Guarde primero el dibujo para resolver las rutas relativas de referencias externas. .object-s-copied-to-clipboard-base-3-3 = Se copiaron __ocs_fmt_0__ objetos al portapapeles (base __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = Se copiaron __ocs_fmt_0__ objetos al portapapeles. @@ -3224,6 +3741,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Seleccione una línea, un rayo o una línea de construcción como eje: .revolve-select-objects-or-mode = REVOLVE Seleccione objetos para revolucionar o [Modo] (Intro para terminar): + .path = Ruta: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Crear .boolean = Booleanas @@ -4534,6 +5058,19 @@ plot = .export-pdf = Exportar PDF .fit-to-paper = Ajustar al papel .layout-plots-the-current-sheet-using-the-selec = La presentación traza la hoja actual con el tamaño de papel seleccionado. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Fusionar líneas solapadas .object-lineweights = Grosores de línea de objetos .orientation = Orientación @@ -4551,6 +5088,8 @@ plot = .plot-style-table-pen-assignments = Tabla de estilos de trazado (asignaciones de plumillas) .plot-transparency = Transparencia de trazado .plot-upside-down = Trazar cabeza abajo + .named-plot-style-tables-stb-are-not-supported-yet-name = Las tablas de estilos de trazado con nombre (.stb) aún no se admiten: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = No se encontró __ocs_fmt_0__ en la carpeta de estilos de trazado (__ocs_fmt_1__). .preview = Vista preliminar .printer-plotter = Impresora / trazador .properties = Propiedades… @@ -4562,6 +5101,34 @@ plot = .system-default-printer = Impresora predeterminada del sistema .what-to-plot = Qué trazar .x-mm = X (mm) + .select-drawing-to-import-page-setups-from = Seleccionar dibujo del que importar configuraciones de página + .drawings-and-templates = Dibujos y plantillas + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: __ocs_fmt_0__ configuración(es) de página importada(s), __ocs_fmt_1__ reemplazada(s). + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: no hay ninguna configuración de página llamada "__ocs_fmt_0__" en __ocs_fmt_1__. + .psetupin-nothing-to-import-from-file = PSETUPIN: nada que importar de __ocs_fmt_0__. + .no-named-page-setups-in-this-drawing = No hay configuraciones de página con nombre en este dibujo. + .import-ellipsis = Importar… + .show-the-page-setup-for-new-layouts = Mostrar la configuración de página para las presentaciones nuevas + .x-in = X (pulg) + .y-in = Y (pulg) + .hide-paperspace-objects = Ocultar objetos del espacio papel + .save-changes-to-layout = Guardar cambios en la presentación + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ no admite __ocs_fmt_1__; se cambió a su hoja predeterminada __ocs_fmt_2__ (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ no admite __ocs_fmt_1__; se conserva la hoja (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: la impresora no informa hojas; se usa el catálogo de papel. + .printer-count-sheet-s-reported = __ocs_fmt_0__: __ocs_fmt_1__ hoja(s) informada(s) + .default-sheet-sheet = Hoja predeterminada: __ocs_fmt_0__ + .default-sheet-not-reported = Hoja predeterminada: no informada + .default-printer-name = Impresora predeterminada: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Impresora predeterminada: el sistema no informa ninguna + .printers-none-listed-by-the-system = Impresoras: el sistema no lista ninguna + .printers-count = Impresoras (__ocs_fmt_0__): + .could-not-list-printers-error = No se pudieron listar las impresoras: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = Los trazados pasan por la aplicación PDF: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = Los trazados van directamente a la impresora (ninguna aplicación PDF registra un comando de impresión). + .via-the-pdf-application = a través de la aplicación PDF + .direct-print = impresión directa .y-mm = Y (mm) manage = @@ -4773,6 +5340,19 @@ ui = .what-s-new = Novedades .width-value = Anchura: __ocs_arg_value__ .working = Procesando… + .recent-input = Entradas recientes + .copy-with-base-point = Copiar con punto base + .undo-label = Deshacer __ocs_arg_label__ + .redo-label = Rehacer __ocs_arg_label__ + .copy-selection = Copiar selección + .isolate = Aislar + .deselect-all = Anular selección + .options-ellipsis = Opciones... + .base-point = Punto base + .snap-overrides = Referencias temporales + .mid-between-2-points = Punto medio entre 2 puntos + .osnap-settings = Parámetros de referencia... + .snap-override-none-next-pick-only = Referencia temporal: Ninguna (solo el siguiente punto). dimstyle = .keep-text-between-extension-lines = Mantener siempre el texto entre las líneas de referencia diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index f8ece8573..6ccf9ff8e 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Oletussovellus .iced-theme = Iced-teema: .theme = Teema + .right-click-customization = Hiiren oikean painikkeen mukautus + .right-click-in-drawing-area = Oikea painike piirtoalueella + .hold-duration = Painalluksen kesto + .right-click-mode-help = Pikavalikko: oikea painike avaa aina valikon. Aikaherkkä: nopea napsautus on Enter, pidempi painallus avaa valikon (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Pikavalikko + .right-click-mode-time-sensitive = Aikaherkkä (nopea napsautus = Enter) + .right-click-mode-enter-first = Ensin Enter, toinen napsautus = valikko command-line = .ready = Open CAD Studio on valmis. @@ -244,6 +251,7 @@ command-move = *[other] { $count } objektit }]: .target = MOVE Määritä kohde [tukikohta { $x },{ $y }]: + .displacement = MOVE Määritä siirtymä: command-copy = .array-count = COPY Enter taulukon kohteiden määrä: @@ -258,6 +266,8 @@ command-copy = [one] { $count } kopio tähän mennessä *[other] { $count } kopiot tähän mennessä } | taulukko | Enter=valmis | pohja { $x },{ $y }]: + .displacement = COPY Määritä siirtymä: + .mode = COPY Valitse kopiointitila [Single/Multiple] : common = .jogged-radius = Katkaistu säde @@ -1614,7 +1624,7 @@ common = .create-triangular-face = Luo kolmionmuotoinen pinta .location = { common.position } .creation-mode = Luontitila - .path = Polku + .path = Polku: .expression = Lauseke .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1867,6 +1877,391 @@ common = .dimensional = Mitalliset .show-hide = Näytä/Piilota + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Tallenna varoitus .unable-to-save-drawing = Piirustusta ei voi tallentaa @@ -2438,6 +2833,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Poista + .pline-specify-starting-width = PLINE Määritä alkuleveys <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE Määritä loppuleveys <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE Määritä alun puolileveys <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE Määritä lopun puolileveys <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (Viiva) Määritä seuraava piste: + .pline-arc-specify-endpoint-of-arc = PLINE (Kaari) Määritä kaaren päätepiste: + .pline-specify-length-of-line = PLINE Määritä viivan pituus: + .pline-specify-included-angle = PLINE Määritä keskuskulma: + .pline-specify-endpoint-of-arc = PLINE Määritä kaaren päätepiste: + .pline-specify-center-point-of-arc = PLINE Määritä kaaren keskipiste: + .pline-specify-radius-of-arc = PLINE Määritä kaaren säde: + .pline-specify-direction-of-chord-for-arc = PLINE Määritä kaaren jänteen suunta: + .pline-specify-length-of-chord = PLINE Määritä jänteen pituus: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Määritä tangentin suunta kaaren alkupisteessä: + .pline-specify-second-point-on-arc = PLINE Määritä kaaren toinen piste: + .halfwidth = Puolileveys + .center-keyword = Keskipiste + .close-keyword = Sulje + .second-pt = Toinen piste + .arc-specify-start-point-of-arc = ARC Määritä kaaren alkupiste: + .arc-specify-second-point-of-arc = ARC Määritä kaaren toinen piste: + .arc-specify-end-point-of-arc = ARC Määritä kaaren päätepiste: + .arc-specify-center-point-of-arc = ARC Määritä kaaren keskipiste: + .arc-specify-included-angle = ARC Määritä keskuskulma: + .arc-specify-length-of-chord = ARC Määritä jänteen pituus: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Määritä tangentin suunta kaaren alkupisteessä: + .arc-specify-radius-of-arc = ARC Määritä kaaren säde: + .chord-length = Jänteen pituus modify = .add-vertex = Lisää kärkipiste @@ -2590,6 +3013,13 @@ modify = .tol-scale-dimtfac = Tol. asteikko (DIMTFAC) .trim = Leikkaa .exit = poistu + .area-keyword = Pinta-ala + .displacement = Siirtymä + .mode-keyword = Tila + .array-keyword = Sarja + .use-first-point-as-displacement = Ensimmäinen piste siirtymänä + .offset-erase-source-object-after-offsetting = OFFSET Poistetaanko lähdeobjekti siirron jälkeen? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Siirrettyjen objektien taso [Current/Source] : groups = .group-enter-group-name-name = GROUP Enter ryhmän nimi [__ocs_arg_name__]: @@ -3124,6 +3554,93 @@ insert = .xref-external-references = XREF Ulkoiset viitteet: .xref-no-external-references-in-this-drawing = XREF Tässä piirustuksessa ei ole ulkoisia viittauksia. .xref-reloaded = XREF Ladattu uudelleen "__ocs_fmt_0__" + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" asetettu Peitä-tilaan. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" asetettu Liitä-tilaan. + .xref-cannot-unload-nested-reference = XREF: ei voi purkaa sisäkkäistä viittausta '__ocs_fmt_0__'. Pura se isäntäpiirroksessa. + .xref-cannot-overlay-nested-reference = XREF: ei voi peittää sisäkkäistä viittausta '__ocs_fmt_0__'. Peitä se isäntäpiirrokseen. + .xref-cannot-reload-nested-reference = XREF: ei voi ladata uudelleen sisäkkäistä viittausta '__ocs_fmt_0__'. Lataa se uudelleen isäntäpiirroksessa. + .xref-cannot-attach-nested-reference = XREF: ei voi liittää sisäkkäistä viittausta '__ocs_fmt_0__'. Liitä se isäntäpiirrokseen. + .xref-cannot-set-path-type-nested-reference = XREF: ei voi asettaa polkutyyppiä sisäkkäiselle viittaukselle '__ocs_fmt_0__'. Aseta se isäntäpiirroksessa. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: lataaminen uudelleen koskee vain piirrosviittauksia. + .xref-bound = XREF: sidottu "__ocs_fmt_0__". + .xref-bound-with-unremapped-handles = XREF: sidottu "__ocs_fmt_0__" __ocs_fmt_1__ uudelleenohjaamattomalla tyylikahvalla (katso sidonnan rajoitukset). + .xref-cannot-bind-nested-reference = XREF: ei voi sitoa sisäkkäistä viittausta '__ocs_fmt_0__'. Sido se isäntäpiirrokseen. + .xref-cannot-bind = XREF: ei voi sitoa "__ocs_fmt_0__": __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: PDF:n sidonta (vektorituonti) ei ole käytettävissä tässä versiossa. + .xref-cannot-detach-nested-reference = XREF: ei voi irrottaa sisäkkäistä viittausta '__ocs_fmt_0__'. Irrota se isäntäpiirroksesta. + .xref-cannot-make-path-relative = XREF: polkua ei voi tehdä suhteelliseksi eri asemien välillä. + .xref-detached = XREF: irrotettu "__ocs_fmt_0__". + .xref-no-loaded-reference-with-that-key = XREF: ei ladattua viittausta tällä avaimella. + .xref-no-references-match = XREF: viittaukset eivät vastaa '__ocs_fmt_0__'. + .xref-manager-title = Ulkoiset viittaukset + .xref-self-attach-blocked = XATTACH: isäntäpiirrosta ei voi liittää itseensä. + .xref-overlays-drawing-references-only = XREF: peitot koskevat vain piirrosviittauksia. + .xref-path-set-for = XREF: Polku asetettu kohteelle "__ocs_fmt_0__" — Lataa uudelleen käyttääksesi. + .xref-unloaded = XREF: purettu "__ocs_fmt_0__". + .xref-unknown-option = XREF: tuntematon valinta '__ocs_fmt_0__'. Valinnat: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: päivitetty __ocs_fmt_0__ viittausta(sta). + .usage-xref-path-pattern = Käyttö: XREF Path + .usage-xref-path-find = Käyttö: XREF Path Find + .usage-xref-pathtype = Käyttö: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ viittausta(sta) ei löytynyt — avaa viittausten hallinta EXTERNALREFERENCES-komennolla. + .xref-nested-not-rendered = (sisäkkäinen — ei renderöity) + .change-path = Vaihda polku: + .date = Päiväys + .details = Tiedot + .xref-detach = Irrota + .failed = Epäonnistui + .xref-file-attach-is-not-available-on-web = Tiedoston liittäminen ei ole käytettävissä verkossa — alla oleva viittausluettelo on vain luku -muotoinen. + .xref-file-name = Tiedostonimi + .found-at = Löytyi + .xref-help = Ohje + .xref-nested-references-are-read-only = Sisäkkäiset viittaukset ovat vain luku -muotoisia — muokkaa niitä niiden isäntäpiirroksessa. + .not-found = Ei löytynyt + .orphaned = Orpo + .unreferenced = Ei viitattu + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = Avattu + .xref-untitled = Nimetön + .xref-unresolved = Ratkaisematon + .xref-preview-not-available = Esikatselu ei ole käytettävissä + .xref-reload-all = Lataa kaikki uudelleen + .xref-dwg = DWG + .xref-pixel-width = Leveys pikseleinä + .xref-pixel-height = Korkeus pikseleinä + .xref-resolution-unit = Resoluution yksikkö + .xref-overlay = Peitä + .xref-reference-manager-select-rows-bind = Viittausten hallinta — valitse rivit, sitten Irrota, Pura, Lataa uudelleen, Sido, Peitä tai polun muutostila. + .xref-bind = Sido + .xref-refresh = Päivitä + .relative = Suhteellinen + .xref-reload = Lataa uudelleen + .xref-replace = Korvaa: + .saved-path = Tallennettu polku + .xref-select-a-reference-first = Valitse ensin viittaus. + .xref-select-a-reference-to-inspect-its = Valitse viittaus tarkastellaksesi sen tietoja + .xref-set-path = Aseta polku + .stale = Vanhentunut + .toggle-list-tree = Vaihda lista/puu + .tree = Puu + .xref-type-a-new-path-first = Kirjoita ensin uusi polku. + .xref-type-the-path-prefix-to-find-first = Kirjoita ensin etsittävä polun etuliite. + .xref-unload = Pura + .unloaded = Ei ladattu + .xref-new-path-for-selection = uusi polku valinnalle + .xref-new-path-prefix = uusi polun etuliite + .xref-old-path-prefix = vanha polun etuliite + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF Tallenna piirustus ensin määrittääksesi suhteelliset XREF-polut. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ objekti(t) kopioitu leikepöydälle (perus __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ objekti(t) kopioitu leikepöydälle. @@ -3223,6 +3740,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Valitse akseliksi viiva, puolisuora tai apuviiva: .revolve-select-objects-or-mode = REVOLVE Valitse pyöräytettävät objektit tai [Tila] (Enter lopettaa): + .path = Path: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Luo .boolean = Boolen operaatiot @@ -4533,6 +5057,19 @@ plot = .export-pdf = Vie PDF .fit-to-paper = Sovita paperille .layout-plots-the-current-sheet-using-the-selec = Asettelu piirtää nykyisen arkin käyttämällä valittua paperikokoa. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Yhdistä päällekkäiset rivit .object-lineweights = Objektin viivan painot .orientation = Suuntautuminen @@ -4550,6 +5087,8 @@ plot = .plot-style-table-pen-assignments = Piirustyylitaulukko (kynätehtävät) .plot-transparency =Juonen läpinäkyvyys .plot-upside-down = Juoni ylösalaisin + .named-plot-style-tables-stb-are-not-supported-yet-name = Nimettyjä tulostustyylitaulukoita (.stb) ei vielä tueta: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = Tiedostoa __ocs_fmt_0__ ei löytynyt tulostustyylien kansiosta (__ocs_fmt_1__). .preview = Esikatselu .printer-plotter = Tulostin/plotteri .properties = Ominaisuudet… @@ -4561,6 +5100,34 @@ plot = .system-default-printer = Järjestelmän oletustulostin .what-to-plot = Mitä piirtää .x-mm = X (mm) + .select-drawing-to-import-page-setups-from = Valitse piirustus, josta sivuasetukset tuodaan + .drawings-and-templates = Piirustukset ja mallipohjat + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: __ocs_fmt_0__ sivuasetusta tuotu, __ocs_fmt_1__ korvattu. + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: sivuasetusta "__ocs_fmt_0__" ei ole tiedostossa __ocs_fmt_1__. + .psetupin-nothing-to-import-from-file = PSETUPIN: ei mitään tuotavaa tiedostosta __ocs_fmt_0__. + .no-named-page-setups-in-this-drawing = Tässä piirustuksessa ei ole nimettyjä sivuasetuksia. + .import-ellipsis = Tuo… + .show-the-page-setup-for-new-layouts = Näytä sivuasetus uusille asetteluille + .x-in = X (tuumaa) + .y-in = Y (tuumaa) + .hide-paperspace-objects = Piilota paperitilan objektit + .save-changes-to-layout = Tallenna muutokset asetteluun + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ ei tue arkkia __ocs_fmt_1__; vaihdettiin oletusarkkiin __ocs_fmt_2__ (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ ei tue arkkia __ocs_fmt_1__; arkki säilytetään (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: tulostin ei ilmoita arkkeja; käytetään paperiluetteloa. + .printer-count-sheet-s-reported = __ocs_fmt_0__: __ocs_fmt_1__ arkkia ilmoitettu + .default-sheet-sheet = Oletusarkki: __ocs_fmt_0__ + .default-sheet-not-reported = Oletusarkki: ei ilmoitettu + .default-printer-name = Oletustulostin: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Oletustulostin: järjestelmä ei ilmoita yhtään + .printers-none-listed-by-the-system = Tulostimet: järjestelmä ei luettele yhtään + .printers-count = Tulostimet (__ocs_fmt_0__): + .could-not-list-printers-error = Tulostimia ei voitu luetella: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = Tulosteet kulkevat PDF-sovelluksen kautta: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = Tulosteet menevät suoraan tulostimelle (mikään PDF-sovellus ei rekisteröi tulostuskomentoa). + .via-the-pdf-application = PDF-sovelluksen kautta + .direct-print = suoratulostus .y-mm = Y (mm) manage = @@ -4770,6 +5337,19 @@ ui = .what-s-new = Mitä uutta? .width-value = Leveys: __ocs_arg_value__ .working = Työskentely… + .recent-input = Viimeisimmät syötteet + .copy-with-base-point = Kopioi peruspisteellä + .undo-label = Kumoa __ocs_arg_label__ + .redo-label = Tee uudelleen __ocs_arg_label__ + .copy-selection = Kopioi valinta + .isolate = Eristä + .deselect-all = Poista valinta + .options-ellipsis = Asetukset... + .base-point = Peruspiste + .snap-overrides = Tilapäiset tartunnat + .mid-between-2-points = 2 pisteen keskikohta + .osnap-settings = Tartunta-asetukset... + .snap-override-none-next-pick-only = Tilapäinen tartunta: Ei mitään (vain seuraava piste). dimstyle = .keep-text-between-extension-lines = Pidä teksti aina jatkorivien välissä diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 56c3ec309..73cc69a1c 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Application par défaut .iced-theme = Thème iced : .theme = Thème + .right-click-customization = Personnalisation du clic droit + .right-click-in-drawing-area = Clic droit dans la zone de dessin + .hold-duration = Durée de maintien + .right-click-mode-help = Menu contextuel : le clic droit ouvre toujours le menu. Sensible au temps : un clic bref vaut Entrée, un appui prolongé ouvre le menu (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Menu contextuel + .right-click-mode-time-sensitive = Sensible au temps (clic bref = Entrée) + .right-click-mode-enter-first = Entrée d'abord, second clic = menu command-line = .ready = Open CAD Studio est prêt. @@ -245,6 +252,7 @@ command-move = *[other] { $count } objets }] : .target = MOVE Spécifiez la destination [base { $x },{ $y }] : + .displacement = MOVE Spécifiez le déplacement : command-copy = .array-count = COPY Saisissez le nombre d'éléments du réseau : @@ -259,6 +267,8 @@ command-copy = [one] { $count } copie jusqu'ici *[other] { $count } copies jusqu'ici } | Réseau | Entrée=terminer | base { $x },{ $y }] : + .displacement = COPY Spécifiez le déplacement : + .mode = COPY Entrez le mode de copie [Single/Multiple] : common = .jogged-radius = Rayon raccourci @@ -1030,7 +1040,7 @@ common = .loft-solid-created = LOFT : solide créé. .attsync-no-editable-block-references = ATTSYNC : aucune référence de bloc modifiable. .xref-reloaded-with-repairs = XREF Rechargé avec réparations : "__ocs_fmt_0__" - .xref-reload-failed = XREF Échec du rechargement : "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reload-failed = XREF Le rechargement a échoué : "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = Vue précédente .zoom-no-previous-view = ZOOM : aucune vue précédente. .zoom-object = Zoom sur l’objet @@ -1119,7 +1129,7 @@ common = .inserting-from-file = Insertion de "__ocs_fmt_0__" depuis le fichier. .insert-file = INSERT FILE: __ocs_fmt_0__ .xref-recovered-with-warnings = XREF Récupéré avec avertissements : "__ocs_fmt_0__" - .xref-recovery-failed = XREF Échec de la récupération : "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-recovery-failed = XREF La récupération a échoué : "__ocs_fmt_0__" (__ocs_fmt_1__) .select-at-least-one-layout = Sélectionnez au moins une présentation. .printing-is-not-available-in-the-web-version = L’impression n’est pas disponible dans la version web. .sending-selected-layouts-to-the-system-printer = Envoi des présentations sélectionnées à l’imprimante système… @@ -1615,7 +1625,7 @@ common = .create-triangular-face = Créer une face triangulaire .location = { common.position } .creation-mode = Mode de création - .path = Trajectoire + .path = Chemin : .expression = Expression .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1868,6 +1878,391 @@ common = .dimensional = Dimensionnelles .show-hide = Afficher/Masquer + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Enregistrer l' avertissement .unable-to-save-drawing = Impossible de sauvegarder le dessin @@ -2439,6 +2834,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Retirer + .pline-specify-starting-width = PLINE Spécifiez la largeur de départ <__ocs_arg_w__> : + .pline-specify-ending-width = PLINE Spécifiez la largeur de fin <__ocs_arg_w__> : + .pline-specify-starting-half-width = PLINE Spécifiez la demi-largeur de départ <__ocs_arg_w__> : + .pline-specify-ending-half-width = PLINE Spécifiez la demi-largeur de fin <__ocs_arg_w__> : + .pline-line-specify-next-point = PLINE (Ligne) Spécifiez le point suivant : + .pline-arc-specify-endpoint-of-arc = PLINE (Arc) Spécifiez l'extrémité de l'arc : + .pline-specify-length-of-line = PLINE Spécifiez la longueur de la ligne : + .pline-specify-included-angle = PLINE Spécifiez l'angle inscrit : + .pline-specify-endpoint-of-arc = PLINE Spécifiez l'extrémité de l'arc : + .pline-specify-center-point-of-arc = PLINE Spécifiez le centre de l'arc : + .pline-specify-radius-of-arc = PLINE Spécifiez le rayon de l'arc : + .pline-specify-direction-of-chord-for-arc = PLINE Spécifiez la direction de la corde de l'arc : + .pline-specify-length-of-chord = PLINE Spécifiez la longueur de la corde : + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Spécifiez la direction tangente au point de départ de l'arc : + .pline-specify-second-point-on-arc = PLINE Spécifiez le second point de l'arc : + .halfwidth = Demi-largeur + .center-keyword = Centre + .close-keyword = Clore + .second-pt = Second point + .arc-specify-start-point-of-arc = ARC Spécifiez le point de départ de l'arc : + .arc-specify-second-point-of-arc = ARC Spécifiez le second point de l'arc : + .arc-specify-end-point-of-arc = ARC Spécifiez l'extrémité de l'arc : + .arc-specify-center-point-of-arc = ARC Spécifiez le centre de l'arc : + .arc-specify-included-angle = ARC Spécifiez l'angle inscrit : + .arc-specify-length-of-chord = ARC Spécifiez la longueur de la corde : + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Spécifiez la direction tangente au point de départ de l'arc : + .arc-specify-radius-of-arc = ARC Spécifiez le rayon de l'arc : + .chord-length = Longueur de corde modify = .add-vertex = Ajouter un sommet @@ -2591,6 +3014,13 @@ modify = .tol-scale-dimtfac = Échelle de référence (DIMTFAC) .trim = Ajuster .exit = Quitter + .area-keyword = Aire + .displacement = Déplacement + .mode-keyword = Mode + .array-keyword = Réseau + .use-first-point-as-displacement = Premier point comme déplacement + .offset-erase-source-object-after-offsetting = OFFSET Effacer l'objet source après le décalage ? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Calque des objets décalés [Current/Source] : groups = .group-enter-group-name-name = GROUP Entrez le nom du groupe [__ocs_arg_name__]: @@ -3125,6 +3555,93 @@ insert = .xref-external-references = XREF Références externes: .xref-no-external-references-in-this-drawing = XREF Aucune référence externe dans ce dessin. .xref-reloaded = XREF Rechargé "__ocs_fmt_0__" + .xref-set-to-overlay = XREF : "__ocs_fmt_0__" défini sur Superposer. + .xref-set-to-attach = XREF : "__ocs_fmt_0__" défini sur Attacher. + .xref-cannot-unload-nested-reference = XREF : impossible de décharger la référence imbriquée '__ocs_fmt_0__'. Déchargez-la dans son dessin hôte. + .xref-cannot-overlay-nested-reference = XREF : impossible de superposer la référence imbriquée '__ocs_fmt_0__'. Superposez-la dans son dessin hôte. + .xref-cannot-reload-nested-reference = XREF : impossible de recharger la référence imbriquée '__ocs_fmt_0__'. Rechargez-la dans son dessin hôte. + .xref-cannot-attach-nested-reference = XREF : impossible d'attacher la référence imbriquée '__ocs_fmt_0__'. Attachez-la dans son dessin hôte. + .xref-cannot-set-path-type-nested-reference = XREF : impossible de définir le type de chemin pour la référence imbriquée '__ocs_fmt_0__'. Définissez-le dans son dessin hôte. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__ : le rechargement s'applique uniquement aux références de dessin. + .xref-bound = XREF : lié "__ocs_fmt_0__". + .xref-bound-with-unremapped-handles = XREF : lié "__ocs_fmt_0__" avec __ocs_fmt_1__ poignées de style non réaffectées (voir les limitations de liaison). + .xref-cannot-bind-nested-reference = XREF : impossible de lier la référence imbriquée '__ocs_fmt_0__'. Liez-la dans son dessin hôte. + .xref-cannot-bind = XREF : impossible de lier "__ocs_fmt_0__" : __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF : La liaison PDF (importation vectorielle) n'est pas disponible dans cette version. + .xref-cannot-detach-nested-reference = XREF : impossible de détacher la référence imbriquée '__ocs_fmt_0__'. Détachez-la dans son dessin hôte. + .xref-cannot-make-path-relative = XREF : impossible de rendre le chemin relatif entre lecteurs. + .xref-detached = XREF : détaché "__ocs_fmt_0__". + .xref-no-loaded-reference-with-that-key = XREF : aucune référence chargée avec cette clé. + .xref-no-references-match = XREF : aucune référence ne correspond à '__ocs_fmt_0__'. + .xref-manager-title = Références externes + .xref-self-attach-blocked = XATTACH : impossible d'attacher le dessin hôte dans lui-même. + .xref-overlays-drawing-references-only = XREF : les superpositions s'appliquent uniquement aux références de dessin. + .xref-path-set-for = XREF : Chemin défini pour "__ocs_fmt_0__" — Rechargez pour appliquer. + .xref-unloaded = XREF : déchargé "__ocs_fmt_0__". + .xref-unknown-option = XREF : option inconnue '__ocs_fmt_0__'. Options : ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF : __ocs_fmt_0__ référence(s) mise(s) à jour. + .usage-xref-path-pattern = Utilisation : XREF Path + .usage-xref-path-find = Utilisation : XREF Path Find + .usage-xref-pathtype = Utilisation : XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ référence(s) non trouvée(s) — ouvrez le gestionnaire de références avec EXTERNALREFERENCES. + .xref-nested-not-rendered = (imbriquée — non rendue) + .change-path = Changer le chemin : + .date = Date + .details = Détails + .xref-detach = Détacher + .failed = Échec + .xref-file-attach-is-not-available-on-web = L'attachement de fichier n'est pas disponible sur le web — la liste des références ci-dessous est en lecture seule. + .xref-file-name = Nom de fichier + .found-at = Trouvé à + .xref-help = Aide + .xref-nested-references-are-read-only = Les références imbriquées sont en lecture seule — modifiez-les dans leur dessin hôte. + .not-found = Introuvable + .orphaned = Orphelin + .unreferenced = Sans référence + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = Ouvert + .xref-untitled = Sans titre + .xref-unresolved = Non résolu + .xref-preview-not-available = Aperçu non disponible + .xref-reload-all = Tout recharger + .xref-dwg = DWG + .xref-pixel-width = Largeur en pixels + .xref-pixel-height = Hauteur en pixels + .xref-resolution-unit = Unité de résolution + .xref-overlay = Superposer + .xref-reference-manager-select-rows-bind = Gestionnaire de références — sélectionnez des lignes, puis Détacher, Décharger, Recharger, Lier, Superposer ou un mode de changement de chemin. + .xref-bind = Lier + .xref-refresh = Actualiser + .relative = Relatif + .xref-reload = Recharger + .xref-replace = Remplacer : + .saved-path = Chemin enregistré + .xref-select-a-reference-first = Sélectionnez d'abord une référence. + .xref-select-a-reference-to-inspect-its = Sélectionnez une référence pour inspecter ses détails + .xref-set-path = Définir le chemin + .stale = Obsolète + .toggle-list-tree = Basculer liste/arborescence + .tree = Arborescence + .xref-type-a-new-path-first = Tapez d'abord un nouveau chemin. + .xref-type-the-path-prefix-to-find-first = Tapez d'abord le préfixe de chemin à rechercher. + .xref-unload = Décharger + .unloaded = Déchargé + .xref-new-path-for-selection = nouveau chemin pour la sélection + .xref-new-path-prefix = nouveau préfixe de chemin + .xref-old-path-prefix = ancien préfixe de chemin + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF Enregistrez d’abord le dessin afin de résoudre les chemins XREF relatifs. .object-s-copied-to-clipboard-base-3-3 = Objets copiés dans le presse-papiers : __ocs_fmt_0__ (point de base __ocs_fmt_1__, __ocs_fmt_2__). .object-s-copied-to-clipboard = Objets copiés dans le presse-papiers : __ocs_fmt_0__. @@ -3224,6 +3741,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Sélectionnez une ligne, une demi-droite ou une droite de construction comme axe : .revolve-select-objects-or-mode = REVOLVE Sélectionnez les objets de révolution ou [Mode] (Entrée pour terminer) : + .path = Chemin : + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Créer .boolean = Opérations booléennes @@ -4534,6 +5058,19 @@ plot = .export-pdf = Exportation PDF .fit-to-paper = Ajuster au papier .layout-plots-the-current-sheet-using-the-selec = La présentation trace la feuille courante avec le format de papier sélectionné. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Fusionner les lignes qui se chevauchent .object-lineweights = Poids de ligne d'objet .orientation = Orientation @@ -4551,6 +5088,8 @@ plot = .plot-style-table-pen-assignments = Table des styles de tracé (affectation des plumes) .plot-transparency = Transparence du lot .plot-upside-down = Placer vers l'envers + .named-plot-style-tables-stb-are-not-supported-yet-name = Les tables de styles de tracé nommés (.stb) ne sont pas encore prises en charge : __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ est introuvable dans le dossier des styles de tracé (__ocs_fmt_1__). .preview = Prévisualisation .printer-plotter = Imprimante / traceur .properties = Propriétés... @@ -4562,6 +5101,34 @@ plot = .system-default-printer = Imprimante système par défaut .what-to-plot = Quoi faire? .x-mm = X (mm) + .select-drawing-to-import-page-setups-from = Sélectionner le dessin d'où importer les mises en page + .drawings-and-templates = Dessins et gabarits + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN : __ocs_fmt_0__ mise(s) en page importée(s), __ocs_fmt_1__ remplacée(s). + .psetupin-error = PSETUPIN : __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN : aucune mise en page nommée « __ocs_fmt_0__ » dans __ocs_fmt_1__. + .psetupin-nothing-to-import-from-file = PSETUPIN : rien à importer depuis __ocs_fmt_0__. + .no-named-page-setups-in-this-drawing = Aucune mise en page nommée dans ce dessin. + .import-ellipsis = Importer… + .show-the-page-setup-for-new-layouts = Afficher la mise en page pour les nouvelles présentations + .x-in = X (po) + .y-in = Y (po) + .hide-paperspace-objects = Masquer les objets de l'espace papier + .save-changes-to-layout = Enregistrer les modifications dans la présentation + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ ne prend pas en charge __ocs_fmt_1__ ; passage à sa feuille par défaut __ocs_fmt_2__ (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ ne prend pas en charge __ocs_fmt_1__ ; la feuille est conservée (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__ : l'imprimante ne signale aucune feuille ; le catalogue de papiers est utilisé. + .printer-count-sheet-s-reported = __ocs_fmt_0__ : __ocs_fmt_1__ feuille(s) signalée(s) + .default-sheet-sheet = Feuille par défaut : __ocs_fmt_0__ + .default-sheet-not-reported = Feuille par défaut : non signalée + .default-printer-name = Imprimante par défaut : __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Imprimante par défaut : aucune signalée par le système + .printers-none-listed-by-the-system = Imprimantes : aucune répertoriée par le système + .printers-count = Imprimantes (__ocs_fmt_0__) : + .could-not-list-printers-error = Impossible de lister les imprimantes : __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = Les tracés passent par l'application PDF : __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = Les tracés vont directement à l'imprimante (aucune application PDF n'enregistre de commande d'impression). + .via-the-pdf-application = via l'application PDF + .direct-print = impression directe .y-mm = Y (mm) manage = @@ -4772,6 +5339,19 @@ ui = .what-s-new = Quoi de neuf ? .width-value = Largeur: __ocs_arg_value__ .working = Travailler... + .recent-input = Saisies récentes + .copy-with-base-point = Copier avec point de base + .undo-label = Annuler __ocs_arg_label__ + .redo-label = Rétablir __ocs_arg_label__ + .copy-selection = Copier la sélection + .isolate = Isoler + .deselect-all = Tout désélectionner + .options-ellipsis = Options... + .base-point = Point de base + .snap-overrides = Remplacements d'accrochage + .mid-between-2-points = Milieu entre 2 points + .osnap-settings = Paramètres d'accrochage... + .snap-override-none-next-pick-only = Remplacement d'accrochage : Aucun (prochain point seulement). dimstyle = .keep-text-between-extension-lines = Toujours conserver le texte entre les lignes d’attache diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index a237913db..27f433885 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = डिफ़ॉल्ट अनुप्रयोग .iced-theme = आइस्ड थीम: .theme = विषय + .right-click-customization = राइट-क्लिक अनुकूलन + .right-click-in-drawing-area = ड्रॉइंग क्षेत्र में राइट-क्लिक + .hold-duration = दबाए रखने की अवधि + .right-click-mode-help = शॉर्टकट मेनू: राइट-क्लिक हमेशा मेनू खोलता है। समय-संवेदी: त्वरित क्लिक Enter है, देर तक दबाए रखने से मेनू खुलता है (SHORTCUTMENUDURATION)। + .right-click-mode-shortcut-menu = शॉर्टकट मेनू + .right-click-mode-time-sensitive = समय-संवेदी (त्वरित क्लिक = Enter) + .right-click-mode-enter-first = पहले Enter, दूसरा क्लिक = मेनू command-line = .ready = Open CAD Studio तैयार है। @@ -241,12 +248,15 @@ area = command-move = .base = MOVE आधार बिंदु निर्दिष्ट करें [{ $count } ऑब्जेक्ट]: .target = MOVE गंतव्य निर्दिष्ट करें [आधार { $x },{ $y }]: + .displacement = MOVE विस्थापन निर्दिष्ट करें: command-copy = .array-count = COPY ऐरे में आइटमों की संख्या दर्ज करें: .base = COPY आधार बिंदु निर्दिष्ट करें [{ $count } ऑब्जेक्ट]: .array-target = COPY { $count }-आइटम ऐरे के लिए दूसरा बिंदु निर्दिष्ट करें [आधार { $x },{ $y }]: .target = COPY गंतव्य निर्दिष्ट करें [अब तक { $count } प्रतियाँ | ऐरे | Enter=पूर्ण | आधार { $x },{ $y }]: + .displacement = COPY विस्थापन निर्दिष्ट करें: + .mode = COPY कॉपी मोड विकल्प दर्ज करें [Single/Multiple] : common = .jogged-radius = मोड़ वाला त्रिज्या माप @@ -1017,8 +1027,8 @@ common = .loft-surface-created = LOFT: सतह बनाई गई। .loft-solid-created = LOFT: ठोस बनाया गया। .attsync-no-editable-block-references = ATTSYNC: कोई संपादन योग्य ब्लॉक संदर्भ नहीं है। - .xref-reloaded-with-repairs = XREF सुधारों के साथ फिर लोड किया: "__ocs_fmt_0__" - .xref-reload-failed = XREF फिर लोड करना विफल: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reloaded-with-repairs = XREF मरम्मत के साथ पुनः लोड किया गया: "__ocs_fmt_0__" + .xref-reload-failed = XREF पुनः लोड विफल: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = पिछला दृश्य .zoom-no-previous-view = ZOOM: कोई पिछला दृश्य नहीं है। .zoom-object = ऑब्जेक्ट पर ज़ूम करें @@ -1106,7 +1116,7 @@ common = .dwg-dxf-files = { common.files } (DWG/DXF) .inserting-from-file = फ़ाइल से "__ocs_fmt_0__" सम्मिलित किया जा रहा है। .insert-file = INSERT FILE: __ocs_fmt_0__ - .xref-recovered-with-warnings = XREF चेतावनियों के साथ पुनर्प्राप्त: "__ocs_fmt_0__" + .xref-recovered-with-warnings = XREF चेतावनियों के साथ पुनर्प्राप्त किया गया: "__ocs_fmt_0__" .xref-recovery-failed = XREF पुनर्प्राप्ति विफल: "__ocs_fmt_0__" (__ocs_fmt_1__) .select-at-least-one-layout = कम से कम एक लेआउट चुनें। .printing-is-not-available-in-the-web-version = वेब संस्करण में प्रिंट उपलब्ध नहीं है। @@ -1603,7 +1613,7 @@ common = .create-triangular-face = त्रिकोणीय फलक बनाएँ .location = { common.position } .creation-mode = निर्माण मोड - .path = पथ + .path = पथ: .expression = व्यंजक .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1856,6 +1866,391 @@ common = .dimensional = आयामी .show-hide = दिखाएँ/छिपाएँ + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = चेतावनी सहेजें .unable-to-save-drawing = ड्रॉइंग सहेजी नहीं जा सकी @@ -2427,6 +2822,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = हटाएँ + .pline-specify-starting-width = PLINE प्रारंभिक चौड़ाई निर्दिष्ट करें <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE अंतिम चौड़ाई निर्दिष्ट करें <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE प्रारंभिक अर्ध-चौड़ाई निर्दिष्ट करें <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE अंतिम अर्ध-चौड़ाई निर्दिष्ट करें <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (रेखा) अगला बिंदु निर्दिष्ट करें: + .pline-arc-specify-endpoint-of-arc = PLINE (चाप) चाप का अंतिम बिंदु निर्दिष्ट करें: + .pline-specify-length-of-line = PLINE रेखा की लंबाई निर्दिष्ट करें: + .pline-specify-included-angle = PLINE अंतर्निहित कोण निर्दिष्ट करें: + .pline-specify-endpoint-of-arc = PLINE चाप का अंतिम बिंदु निर्दिष्ट करें: + .pline-specify-center-point-of-arc = PLINE चाप का केंद्र बिंदु निर्दिष्ट करें: + .pline-specify-radius-of-arc = PLINE चाप की त्रिज्या निर्दिष्ट करें: + .pline-specify-direction-of-chord-for-arc = PLINE चाप की जीवा की दिशा निर्दिष्ट करें: + .pline-specify-length-of-chord = PLINE जीवा की लंबाई निर्दिष्ट करें: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE चाप के प्रारंभिक बिंदु पर स्पर्शरेखा की दिशा निर्दिष्ट करें: + .pline-specify-second-point-on-arc = PLINE चाप पर दूसरा बिंदु निर्दिष्ट करें: + .halfwidth = अर्ध-चौड़ाई + .center-keyword = केंद्र + .close-keyword = बंद करें + .second-pt = दूसरा बिंदु + .arc-specify-start-point-of-arc = ARC चाप का प्रारंभिक बिंदु निर्दिष्ट करें: + .arc-specify-second-point-of-arc = ARC चाप का दूसरा बिंदु निर्दिष्ट करें: + .arc-specify-end-point-of-arc = ARC चाप का अंतिम बिंदु निर्दिष्ट करें: + .arc-specify-center-point-of-arc = ARC चाप का केंद्र बिंदु निर्दिष्ट करें: + .arc-specify-included-angle = ARC अंतर्निहित कोण निर्दिष्ट करें: + .arc-specify-length-of-chord = ARC जीवा की लंबाई निर्दिष्ट करें: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC चाप के प्रारंभिक बिंदु पर स्पर्शरेखा की दिशा निर्दिष्ट करें: + .arc-specify-radius-of-arc = ARC चाप की त्रिज्या निर्दिष्ट करें: + .chord-length = जीवा की लंबाई modify = .add-vertex = शीर्ष जोड़ें @@ -2579,6 +3002,13 @@ modify = .tol-scale-dimtfac = Tol. पैमाने (DIMTFAC) .trim = ट्रिम .exit = बाहर निकलें + .area-keyword = क्षेत्रफल + .displacement = विस्थापन + .mode-keyword = मोड + .array-keyword = सारणी + .use-first-point-as-displacement = पहले बिंदु को विस्थापन के रूप में उपयोग करें + .offset-erase-source-object-after-offsetting = OFFSET ऑफ़सेट के बाद स्रोत ऑब्जेक्ट मिटाएँ? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET ऑफ़सेट ऑब्जेक्ट के लिए परत विकल्प दर्ज करें [Current/Source] : groups = .group-enter-group-name-name = GROUP समूह का नाम दर्ज करें [__ocs_arg_name__]: @@ -3113,6 +3543,93 @@ insert = .xref-external-references = XREF बाहरी संदर्भ: .xref-no-external-references-in-this-drawing = XREF इस ड्रॉइंग में कोई बाहरी संदर्भ नहीं है। .xref-reloaded = XREF “__ocs_fmt_0__” पुनः लोड किया गया + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" को ओवरले पर सेट किया गया। + .xref-set-to-attach = XREF: "__ocs_fmt_0__" को अटैच पर सेट किया गया। + .xref-cannot-unload-nested-reference = XREF: नेस्टेड संदर्भ '__ocs_fmt_0__' अनलोड नहीं कर सकता। इसे अपने होस्ट ड्राइंग में अनलोड करें। + .xref-cannot-overlay-nested-reference = XREF: नेस्टेड संदर्भ '__ocs_fmt_0__' ओवरले नहीं कर सकता। इसे अपने होस्ट ड्राइंग में ओवरले करें। + .xref-cannot-reload-nested-reference = XREF: नेस्टेड संदर्भ '__ocs_fmt_0__' पुनः लोड नहीं कर सकता। इसे अपने होस्ट ड्राइंग में पुनः लोड करें। + .xref-cannot-attach-nested-reference = XREF: नेस्टेड संदर्भ '__ocs_fmt_0__' संलग्न नहीं कर सकता। इसे अपने होस्ट ड्राइंग में संलग्न करें। + .xref-cannot-set-path-type-nested-reference = XREF: नेस्टेड संदर्भ '__ocs_fmt_0__' के लिए पथ प्रकार सेट नहीं कर सकता। इसे अपने होस्ट ड्राइंग में सेट करें। + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: पुनः लोड करना केवल ड्राइंग संदर्भों पर लागू होता है। + .xref-bound = XREF: "__ocs_fmt_0__" बाइंड किया गया। + .xref-bound-with-unremapped-handles = XREF: "__ocs_fmt_0__" को __ocs_fmt_1__ अनरेमैप्ड स्टाइल हैंडल के साथ बाइंड किया गया (बाइंड सीमाएँ देखें)। + .xref-cannot-bind-nested-reference = XREF: नेस्टेड संदर्भ '__ocs_fmt_0__' बाइंड नहीं कर सकता। इसे अपने होस्ट ड्राइंग में बाइंड करें। + .xref-cannot-bind = XREF: "__ocs_fmt_0__" बाइंड नहीं कर सकता: __ocs_fmt_1__। + .xref-pdf-bind-unavailable = XREF: PDF बाइंड (वेक्टर आयात) इस संस्करण में उपलब्ध नहीं है। + .xref-cannot-detach-nested-reference = XREF: नेस्टेड संदर्भ '__ocs_fmt_0__' डिटैच नहीं कर सकता। इसे अपने होस्ट ड्राइंग में डिटैच करें। + .xref-cannot-make-path-relative = XREF: ड्राइव के पार पथ को सापेक्ष नहीं बना सकता। + .xref-detached = XREF: "__ocs_fmt_0__" डिटैच किया गया। + .xref-no-loaded-reference-with-that-key = XREF: उस कुंजी के साथ कोई लोडेड संदर्भ नहीं। + .xref-no-references-match = XREF: '__ocs_fmt_0__' से मेल खाने वाला कोई संदर्भ नहीं। + .xref-manager-title = बाहरी संदर्भ + .xref-self-attach-blocked = XATTACH: होस्ट ड्राइंग को स्वयं में संलग्न नहीं कर सकता। + .xref-overlays-drawing-references-only = XREF: ओवरले केवल ड्राइंग संदर्भों पर लागू होते हैं। + .xref-path-set-for = XREF: "__ocs_fmt_0__" के लिए पथ सेट किया गया — लागू करने के लिए पुनः लोड करें। + .xref-unloaded = XREF: "__ocs_fmt_0__" अनलोड किया गया। + .xref-unknown-option = XREF: अज्ञात विकल्प '__ocs_fmt_0__'. विकल्प: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: __ocs_fmt_0__ संदर्भ अद्यतन किए गए। + .usage-xref-path-pattern = उपयोग: XREF Path + .usage-xref-path-find = उपयोग: XREF Path Find + .usage-xref-pathtype = उपयोग: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ संदर्भ नहीं मिले — EXTERNALREFERENCES के साथ संदर्भ प्रबंधक खोलें। + .xref-nested-not-rendered = (नेस्टेड — रेंडर नहीं किया गया) + .change-path = पथ बदलें: + .date = दिनांक + .details = विवरण + .xref-detach = डिटैच + .failed = विफल + .xref-file-attach-is-not-available-on-web = फ़ाइल संलग्न करना वेब पर उपलब्ध नहीं है — नीचे दी गई संदर्भ सूची केवल पढ़ने के लिए है। + .xref-file-name = फ़ाइल नाम + .found-at = पर पाया गया + .xref-help = सहायता + .xref-nested-references-are-read-only = नेस्टेड संदर्भ केवल पढ़ने के लिए हैं — उन्हें अपने होस्ट ड्राइंग में संपादित करें। + .not-found = नहीं मिला + .orphaned = अनाथ + .unreferenced = संदर्भित नहीं + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = खोला गया + .xref-untitled = बिना शीर्षक + .xref-unresolved = अनिर्धारित + .xref-preview-not-available = पूर्वावलोकन उपलब्ध नहीं + .xref-reload-all = सभी पुनः लोड करें + .xref-dwg = DWG + .xref-pixel-width = पिक्सेल चौड़ाई + .xref-pixel-height = पिक्सेल ऊँचाई + .xref-resolution-unit = रिज़ॉल्यूशन इकाई + .xref-overlay = ओवरले + .xref-reference-manager-select-rows-bind = संदर्भ प्रबंधक — पंक्तियाँ चुनें, फिर डिटैच, अनलोड, पुनः लोड, बाइंड, ओवरले, या पथ परिवर्तन मोड। + .xref-bind = बाइंड + .xref-refresh = ताज़ा करें + .relative = सापेक्ष + .xref-reload = पुनः लोड करें + .xref-replace = बदलें: + .saved-path = सहेजा गया पथ + .xref-select-a-reference-first = पहले एक संदर्भ चुनें। + .xref-select-a-reference-to-inspect-its = इसके विवरण निरीक्षण करने के लिए एक संदर्भ चुनें + .xref-set-path = पथ सेट करें + .stale = पुराना + .toggle-list-tree = सूची/ट्री टॉगल करें + .tree = ट्री + .xref-type-a-new-path-first = पहले एक नया पथ टाइप करें। + .xref-type-the-path-prefix-to-find-first = पहले खोजने के लिए पथ उपसर्ग टाइप करें। + .xref-unload = अनलोड + .unloaded = अनलोड + .xref-new-path-for-selection = चयन के लिए नया पथ + .xref-new-path-prefix = नया पथ उपसर्ग + .xref-old-path-prefix = पुराना पथ उपसर्ग + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF सापेक्ष पथ निर्धारित करने के लिए पहले ड्रॉइंग सहेजें। .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ ऑब्जेक्ट (s) क्लिपबोर्ड (बेस __ocs_fmt_1__, __ocs_fmt_2__) को कॉपी किया गया। .object-s-copied-to-clipboard = __ocs_fmt_0__ ऑब्जेक्ट (s) क्लिपबोर्ड पर कॉपी किया गया। @@ -3212,6 +3729,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE अक्ष के लिए रेखा, किरण या निर्माण रेखा चुनें: .revolve-select-objects-or-mode = REVOLVE घुमाने के लिए ऑब्जेक्ट चुनें या [मोड] (समाप्त करने के लिए Enter): + .path = Path: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = बनाएँ .boolean = बूलियन संक्रियाएँ @@ -4522,6 +5046,19 @@ plot = .export-pdf = निर्यात PDF .fit-to-paper = कागज़ पर फ़िट करें .layout-plots-the-current-sheet-using-the-selec = लेआउट चयनित कागज़ आकार पर वर्तमान शीट प्लॉट करता है। + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = मर्ज ओवरलैपिंग लाइन .object-lineweights = ऑब्जेक्ट लाइनवेट .orientation = अभिविन्यास @@ -4539,6 +5076,8 @@ plot = .plot-style-table-pen-assignments = प्लॉट शैली तालिका (पेन असाइनमेंट) .plot-transparency = प्लॉट पारदर्शिता .plot-upside-down = ऊपर की ओर नीचे प्लॉट + .named-plot-style-tables-stb-are-not-supported-yet-name = नामित प्लॉट स्टाइल तालिकाएँ (.stb) अभी समर्थित नहीं हैं: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ प्लॉट स्टाइल फ़ोल्डर (__ocs_fmt_1__) में नहीं मिला। .preview = पूर्वावलोकन .printer-plotter = प्रिंटर / प्लॉटर .properties = गुण... @@ -4550,6 +5089,34 @@ plot = .system-default-printer = सिस्टम डिफ़ॉल्ट प्रिंटर .what-to-plot = क्या प्लॉट करना है .x-mm = एक्स (मिमी) + .select-drawing-to-import-page-setups-from = पेज सेटअप आयात करने के लिए ड्रॉइंग चुनें + .drawings-and-templates = ड्रॉइंग और टेम्पलेट + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: __ocs_fmt_0__ पेज सेटअप आयात किए गए, __ocs_fmt_1__ बदले गए। + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: __ocs_fmt_1__ में "__ocs_fmt_0__" नाम का कोई पेज सेटअप नहीं है। + .psetupin-nothing-to-import-from-file = PSETUPIN: __ocs_fmt_0__ से आयात करने के लिए कुछ नहीं है। + .no-named-page-setups-in-this-drawing = इस ड्रॉइंग में कोई नामित पेज सेटअप नहीं है। + .import-ellipsis = आयात करें… + .show-the-page-setup-for-new-layouts = नए लेआउट के लिए पेज सेटअप दिखाएँ + .x-in = एक्स (इंच) + .y-in = वाई (इंच) + .hide-paperspace-objects = पेपर स्पेस ऑब्जेक्ट छिपाएँ + .save-changes-to-layout = लेआउट में परिवर्तन सहेजें + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ __ocs_fmt_1__ का समर्थन नहीं करता; इसकी डिफ़ॉल्ट शीट __ocs_fmt_2__ पर स्विच किया गया (PAPERUPDATE = 1)। + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ __ocs_fmt_1__ का समर्थन नहीं करता; शीट रखी गई है (PAPERUPDATE = 0)। + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: प्रिंटर कोई शीट नहीं बताता; पेपर कैटलॉग का उपयोग किया जाता है। + .printer-count-sheet-s-reported = __ocs_fmt_0__: __ocs_fmt_1__ शीट बताई गईं + .default-sheet-sheet = डिफ़ॉल्ट शीट: __ocs_fmt_0__ + .default-sheet-not-reported = डिफ़ॉल्ट शीट: नहीं बताई गई + .default-printer-name = डिफ़ॉल्ट प्रिंटर: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = डिफ़ॉल्ट प्रिंटर: सिस्टम ने कोई नहीं बताया + .printers-none-listed-by-the-system = प्रिंटर: सिस्टम ने कोई सूचीबद्ध नहीं किया + .printers-count = प्रिंटर (__ocs_fmt_0__): + .could-not-list-printers-error = प्रिंटर सूचीबद्ध नहीं किए जा सके: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = प्लॉट PDF एप्लिकेशन के माध्यम से जाते हैं: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = प्लॉट सीधे प्रिंटर पर जाते हैं (कोई PDF एप्लिकेशन प्रिंट कमांड पंजीकृत नहीं करता)। + .via-the-pdf-application = PDF एप्लिकेशन के माध्यम से + .direct-print = सीधी प्रिंटिंग .y-mm = Y (मिमी) manage = @@ -4761,6 +5328,19 @@ ui = .what-s-new = नया क्या है .width-value = चौड़ाई: __ocs_arg_value__ .working = कार्य... + .recent-input = हाल के इनपुट + .copy-with-base-point = आधार बिंदु के साथ कॉपी करें + .undo-label = पूर्ववत करें __ocs_arg_label__ + .redo-label = फिर से करें __ocs_arg_label__ + .copy-selection = चयन कॉपी करें + .isolate = अलग करें + .deselect-all = सभी का चयन हटाएँ + .options-ellipsis = विकल्प... + .base-point = आधार बिंदु + .snap-overrides = स्नैप ओवरराइड + .mid-between-2-points = 2 बिंदुओं के बीच मध्य + .osnap-settings = ऑब्जेक्ट स्नैप सेटिंग्स... + .snap-override-none-next-pick-only = स्नैप ओवरराइड: कोई नहीं (केवल अगले चयन के लिए)। dimstyle = .keep-text-between-extension-lines = पाठ को हमेशा विस्तार रेखाओं के बीच रखें diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 83e0202fa..b9a9c2896 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Alapértelmezett alkalmazás .iced-theme = Iced-téma: .theme = Téma + .right-click-customization = Jobb kattintás testreszabása + .right-click-in-drawing-area = Jobb kattintás a rajzterületen + .hold-duration = Nyomva tartás ideje + .right-click-mode-help = Helyi menü: a jobb kattintás mindig a menüt nyitja. Időérzékeny: a rövid kattintás Enter, a hosszabb nyomva tartás a menüt nyitja (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Helyi menü + .right-click-mode-time-sensitive = Időérzékeny (rövid kattintás = Enter) + .right-click-mode-enter-first = Előbb Enter, második kattintás = menü command-line = .ready = Az Open CAD Studio használatra kész. @@ -244,6 +251,7 @@ command-move = *[other] { $count } objektumok }]: .target = MOVE Adja meg a rendeltetési helyet [bázis { $x },{ $y }]: + .displacement = MOVE Adja meg az eltolást: command-copy = .array-count = COPY Enter a tömbben lévő elemek száma: @@ -258,6 +266,8 @@ command-copy = [one] { $count } példány eddig *[other] { $count } példányok eddig } | tömb | Enter=kész | alap { $x },{ $y }]: + .displacement = COPY Adja meg az eltolást: + .mode = COPY Adja meg a másolási módot [Single/Multiple] : common = .jogged-radius = Tört sugarú méret @@ -1029,7 +1039,7 @@ common = .loft-solid-created = LOFT: tömör létrehozva. .attsync-no-editable-block-references = ATTSYNC: nincsenek szerkeszthető blokkhivatkozások. .xref-reloaded-with-repairs = XREF Újratöltve javításokkal: "__ocs_fmt_0__" - .xref-reload-failed = XREF Az újratöltés sikertelen: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reload-failed = XREF Újratöltés sikertelen: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = Előző nézet .zoom-no-previous-view = ZOOM: nincs előző nézet. .zoom-object = Nagyítás objektumra @@ -1118,7 +1128,7 @@ common = .inserting-from-file = "__ocs_fmt_0__" beszúrása fájlból. .insert-file = INSERT FILE: __ocs_fmt_0__ .xref-recovered-with-warnings = XREF Helyreállítva figyelmeztetésekkel: "__ocs_fmt_0__" - .xref-recovery-failed = XREF A helyreállítás sikertelen: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-recovery-failed = XREF Helyreállítás sikertelen: "__ocs_fmt_0__" (__ocs_fmt_1__) .select-at-least-one-layout = Válasszon legalább egy elrendezést. .printing-is-not-available-in-the-web-version = A nyomtatás nem érhető el a webes verzióban. .sending-selected-layouts-to-the-system-printer = Kijelölt elrendezések küldése a rendszer nyomtatójára… @@ -1614,7 +1624,7 @@ common = .create-triangular-face = Háromszögű lap létrehozása .location = { common.position } .creation-mode = Létrehozási mód - .path = Útvonal + .path = Útvonal: .expression = Kifejezés .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1867,6 +1877,391 @@ common = .dimensional = Méretkényszerek .show-hide = Megjelenítés/Elrejtés + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Figyelmeztetés mentése .unable-to-save-drawing = Nem sikerült menteni a rajzot @@ -2438,6 +2833,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Eltávolítás + .pline-specify-starting-width = PLINE Adja meg a kezdő szélességet <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE Adja meg a záró szélességet <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE Adja meg a kezdő félszélességet <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE Adja meg a záró félszélességet <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (Vonal) Adja meg a következő pontot: + .pline-arc-specify-endpoint-of-arc = PLINE (Ív) Adja meg az ív végpontját: + .pline-specify-length-of-line = PLINE Adja meg a vonal hosszát: + .pline-specify-included-angle = PLINE Adja meg a középponti szöget: + .pline-specify-endpoint-of-arc = PLINE Adja meg az ív végpontját: + .pline-specify-center-point-of-arc = PLINE Adja meg az ív középpontját: + .pline-specify-radius-of-arc = PLINE Adja meg az ív sugarát: + .pline-specify-direction-of-chord-for-arc = PLINE Adja meg az ív húrjának irányát: + .pline-specify-length-of-chord = PLINE Adja meg a húr hosszát: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Adja meg az érintő irányát az ív kezdőpontjában: + .pline-specify-second-point-on-arc = PLINE Adja meg az ív második pontját: + .halfwidth = Félszélesség + .center-keyword = Középpont + .close-keyword = Zárás + .second-pt = Második pont + .arc-specify-start-point-of-arc = ARC Adja meg az ív kezdőpontját: + .arc-specify-second-point-of-arc = ARC Adja meg az ív második pontját: + .arc-specify-end-point-of-arc = ARC Adja meg az ív végpontját: + .arc-specify-center-point-of-arc = ARC Adja meg az ív középpontját: + .arc-specify-included-angle = ARC Adja meg a középponti szöget: + .arc-specify-length-of-chord = ARC Adja meg a húr hosszát: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Adja meg az érintő irányát az ív kezdőpontjában: + .arc-specify-radius-of-arc = ARC Adja meg az ív sugarát: + .chord-length = Húrhossz modify = .add-vertex = Csúcspont hozzáadása @@ -2590,6 +3013,13 @@ modify = .tol-scale-dimtfac = Tol. mérleg (DIMTFAC) .trim = Vágás .exit = kilépés + .area-keyword = Terület + .displacement = Eltolás + .mode-keyword = Mód + .array-keyword = Kiosztás + .use-first-point-as-displacement = Első pont eltolásként + .offset-erase-source-object-after-offsetting = OFFSET Törli a forrásobjektumot eltolás után? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Az eltolt objektumok fóliája [Current/Source] : groups = .group-enter-group-name-name = GROUP Enter csoport neve [__ocs_arg_name__]: @@ -3124,6 +3554,93 @@ insert = .xref-external-references = XREF Külső hivatkozások: .xref-no-external-references-in-this-drawing = XREF Nincsenek külső hivatkozások ezen a rajzon. .xref-reloaded = XREF újratöltve "__ocs_fmt_0__" + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" beállítva Átfedésre. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" beállítva Csatolásra. + .xref-cannot-unload-nested-reference = XREF: nem rakható ki a beágyazott hivatkozás '__ocs_fmt_0__'. Rakja ki a befogadó rajzban. + .xref-cannot-overlay-nested-reference = XREF: nem fedhető át a beágyazott hivatkozás '__ocs_fmt_0__'. Fedje át a befogadó rajzban. + .xref-cannot-reload-nested-reference = XREF: nem tölthető újra a beágyazott hivatkozás '__ocs_fmt_0__'. Töltse újra a befogadó rajzban. + .xref-cannot-attach-nested-reference = XREF: nem csatolható a beágyazott hivatkozás '__ocs_fmt_0__'. Csatolja a befogadó rajzban. + .xref-cannot-set-path-type-nested-reference = XREF: nem állítható be útvonaltípus a beágyazott hivatkozáshoz '__ocs_fmt_0__'. Állítsa be a befogadó rajzban. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: az újratöltés csak rajzhivatkozásokra vonatkozik. + .xref-bound = XREF: "__ocs_fmt_0__" beágyazva. + .xref-bound-with-unremapped-handles = XREF: "__ocs_fmt_0__" beágyazva __ocs_fmt_1__ nem újratérképezett stílusfogóval (lásd a beágyazási korlátokat). + .xref-cannot-bind-nested-reference = XREF: nem beágyazható a beágyazott hivatkozás '__ocs_fmt_0__'. Ágyazza be a befogadó rajzban. + .xref-cannot-bind = XREF: "__ocs_fmt_0__" nem beágyazható: __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: PDF-beágyazás (vektorimport) nem érhető el ebben a verzióban. + .xref-cannot-detach-nested-reference = XREF: nem választható le a beágyazott hivatkozás '__ocs_fmt_0__'. Válassza le a befogadó rajzban. + .xref-cannot-make-path-relative = XREF: az útvonal nem tehető relatívvá meghajtók között. + .xref-detached = XREF: "__ocs_fmt_0__" leválasztva. + .xref-no-loaded-reference-with-that-key = XREF: nincs betöltött hivatkozás ezzel a kulccsal. + .xref-no-references-match = XREF: egyetlen hivatkozás sem egyezik a következővel: '__ocs_fmt_0__'. + .xref-manager-title = Külső hivatkozások + .xref-self-attach-blocked = XATTACH: a befogadó rajzot nem lehet önmagába csatolni. + .xref-overlays-drawing-references-only = XREF: az átfedések csak rajzhivatkozásokra vonatkoznak. + .xref-path-set-for = XREF: Útvonal beállítva a következőhöz: "__ocs_fmt_0__" — az alkalmazáshoz töltse újra. + .xref-unloaded = XREF: "__ocs_fmt_0__" kirakva. + .xref-unknown-option = XREF: ismeretlen opció '__ocs_fmt_0__'. Opciók: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: __ocs_fmt_0__ hivatkozás frissítve. + .usage-xref-path-pattern = Használat: XREF Path + .usage-xref-path-find = Használat: XREF Path Find + .usage-xref-pathtype = Használat: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ hivatkozás nem található — nyissa meg a hivatkozáskezelőt az EXTERNALREFERENCES paranccsal. + .xref-nested-not-rendered = (beágyazott — nem jelenik meg) + .change-path = Útvonal módosítása: + .date = Dátum + .details = Részletek + .xref-detach = Leválasztás + .failed = Sikertelen + .xref-file-attach-is-not-available-on-web = Fájl csatolása nem érhető el a weben — az alábbi hivatkozási lista csak olvasható. + .xref-file-name = Fájlnév + .found-at = Található itt: + .xref-help = Súgó + .xref-nested-references-are-read-only = A beágyazott hivatkozások csak olvashatók — szerkessze őket a befogadó rajzban. + .not-found = Nem található + .orphaned = Árva + .unreferenced = Nem hivatkozott + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = Megnyitva + .xref-untitled = Névtelen + .xref-unresolved = Feloldatlan + .xref-preview-not-available = Előnézet nem érhető el + .xref-reload-all = Összes újratöltése + .xref-dwg = DWG + .xref-pixel-width = Pixelszélesség + .xref-pixel-height = Pixelmagasság + .xref-resolution-unit = Felbontás egysége + .xref-overlay = Átfedés + .xref-reference-manager-select-rows-bind = Hivatkozáskezelő — válasszon sorokat, majd Leválasztás, Kirakás, Újratöltés, Beágyazás, Átfedés vagy útvonalváltási mód. + .xref-bind = Beágyazás + .xref-refresh = Frissítés + .relative = Relatív + .xref-reload = Újratöltés + .xref-replace = Csere: + .saved-path = Mentett útvonal + .xref-select-a-reference-first = Először válasszon ki egy hivatkozást. + .xref-select-a-reference-to-inspect-its = Válasszon egy hivatkozást a részletek megtekintéséhez + .xref-set-path = Útvonal beállítása + .stale = Elavult + .toggle-list-tree = Lista/fa nézet váltás + .tree = Fa + .xref-type-a-new-path-first = Először írjon be egy új útvonalat. + .xref-type-the-path-prefix-to-find-first = Először írja be a keresendő útvonal-előtagot. + .xref-unload = Kirakás + .unloaded = Kirakva + .xref-new-path-for-selection = új útvonal a kijelöléshez + .xref-new-path-prefix = új útvonal-előtag + .xref-old-path-prefix = régi útvonal-előtag + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF Először mentse a rajzot a relatív XREF útvonalak feloldásához. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ objektum(ok) a vágólapra másolva (alap __ocs_fmt_1__, __ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ objektum(ok) a vágólapra másolva. @@ -3223,6 +3740,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Válasszon vonalat, félegyenest vagy szerkesztővonalat tengelyként: .revolve-select-objects-or-mode = REVOLVE Válasszon megforgatandó objektumokat vagy [Mód] (Enter a befejezéshez): + .path = Path: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Létrehozás .boolean = Logikai műveletek @@ -4533,6 +5057,19 @@ plot = .export-pdf = PDF exportálása .fit-to-paper = Papírra illeszthető .layout-plots-the-current-sheet-using-the-selec = Az Elrendezés az aktuális lapot a kiválasztott papírmérettel ábrázolja. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Egyesítse az átfedő vonalakat .object-lineweights = Objektumvonalvastagságok .orientation = Tájékozódás @@ -4550,6 +5087,8 @@ plot = .plot-style-table-pen-assignments = Nyomtatási stílus táblázat (toll-hozzárendelések) .plot-transparency =A telek átláthatósága .plot-upside-down = Telek fejjel lefelé + .named-plot-style-tables-stb-are-not-supported-yet-name = A nevesített nyomtatási stílustáblák (.stb) még nem támogatottak: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = A(z) __ocs_fmt_0__ nem található a nyomtatási stílusok mappájában (__ocs_fmt_1__). .preview = Előnézet .printer-plotter = Nyomtató / plotter .properties = Tulajdonságok… @@ -4561,6 +5100,34 @@ plot = .system-default-printer = Rendszer alapértelmezett nyomtató .what-to-plot = Mit kell kitalálni .x-mm = X (mm) + .select-drawing-to-import-page-setups-from = Válassza ki a rajzot, amelyből oldalbeállításokat importál + .drawings-and-templates = Rajzok és sablonok + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: __ocs_fmt_0__ oldalbeállítás importálva, __ocs_fmt_1__ lecserélve. + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: nincs „__ocs_fmt_0__” nevű oldalbeállítás a(z) __ocs_fmt_1__ fájlban. + .psetupin-nothing-to-import-from-file = PSETUPIN: nincs mit importálni innen: __ocs_fmt_0__. + .no-named-page-setups-in-this-drawing = Ebben a rajzban nincsenek névvel ellátott oldalbeállítások. + .import-ellipsis = Importálás… + .show-the-page-setup-for-new-layouts = Oldalbeállítás megjelenítése új elrendezésekhez + .x-in = X (hüvelyk) + .y-in = Y (hüvelyk) + .hide-paperspace-objects = Papírtér-objektumok elrejtése + .save-changes-to-layout = Módosítások mentése az elrendezésbe + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = A(z) __ocs_fmt_0__ nem támogatja a(z) __ocs_fmt_1__ lapot; átváltva az alapértelmezett __ocs_fmt_2__ lapra (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = A(z) __ocs_fmt_0__ nem támogatja a(z) __ocs_fmt_1__ lapot; a lap megmarad (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: a nyomtató nem jelez lapokat; a papírkatalógus lesz használva. + .printer-count-sheet-s-reported = __ocs_fmt_0__: __ocs_fmt_1__ lap jelezve + .default-sheet-sheet = Alapértelmezett lap: __ocs_fmt_0__ + .default-sheet-not-reported = Alapértelmezett lap: nincs jelezve + .default-printer-name = Alapértelmezett nyomtató: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Alapértelmezett nyomtató: a rendszer nem jelez egyet sem + .printers-none-listed-by-the-system = Nyomtatók: a rendszer nem sorol fel egyet sem + .printers-count = Nyomtatók (__ocs_fmt_0__): + .could-not-list-printers-error = A nyomtatók nem listázhatók: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = A nyomtatás a PDF-alkalmazáson keresztül megy: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = A nyomtatás közvetlenül a nyomtatóra megy (egy PDF-alkalmazás sem regisztrál nyomtatási parancsot). + .via-the-pdf-application = a PDF-alkalmazáson keresztül + .direct-print = közvetlen nyomtatás .y-mm = Y (mm) manage = @@ -4772,6 +5339,19 @@ ui = .what-s-new = Újdonságok .width-value = Szélesség: __ocs_arg_value__ .working = Dolgozik… + .recent-input = Legutóbbi bevitel + .copy-with-base-point = Másolás bázisponttal + .undo-label = Visszavonás: __ocs_arg_label__ + .redo-label = Újra: __ocs_arg_label__ + .copy-selection = Kijelölés másolása + .isolate = Elkülönítés + .deselect-all = Kijelölés megszüntetése + .options-ellipsis = Beállítások... + .base-point = Bázispont + .snap-overrides = Ideiglenes tárgyraszter + .mid-between-2-points = 2 pont közötti felezőpont + .osnap-settings = Tárgyraszter beállítások... + .snap-override-none-next-pick-only = Ideiglenes tárgyraszter: Nincs (csak a következő pontra). dimstyle = .keep-text-between-extension-lines = Mindig legyen szöveg a hosszabbító sorok között diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 2e1a28c2c..394d9423e 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Applicazione predefinita .iced-theme = Tema Iced: .theme = Tema + .right-click-customization = Personalizzazione clic destro + .right-click-in-drawing-area = Clic destro nell'area di disegno + .hold-duration = Durata pressione + .right-click-mode-help = Menu di scelta rapida: il clic destro apre sempre il menu. Sensibile al tempo: un clic rapido è Invio, tenere premuto apre il menu (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Menu di scelta rapida + .right-click-mode-time-sensitive = Sensibile al tempo (clic rapido = Invio) + .right-click-mode-enter-first = Prima Invio, secondo clic = menu command-line = .ready = Open CAD Studio è pronto. @@ -244,6 +251,7 @@ command-move = *[other] { $count } oggetti }]: .target = MOVE Specificare la destinazione [base { $x },{ $y }]: + .displacement = MOVE Specificare spostamento: command-copy = .array-count = COPY Enter numero di elementi da mettere in serie: @@ -258,6 +266,8 @@ command-copy = [one] { $count } copia finora *[other] { $count } copie finora } | Serie | Enter=completato | base { $x },{ $y }]: + .displacement = COPY Specificare spostamento: + .mode = COPY Specificare modalità di copia [Single/Multiple] : common = .jogged-radius = Raggio spezzato @@ -1029,7 +1039,7 @@ common = .loft-solid-created = LOFT: solido creato. .attsync-no-editable-block-references = ATTSYNC: nessun riferimento di blocco modificabile. .xref-reloaded-with-repairs = XREF Ricaricato con riparazioni: "__ocs_fmt_0__" - .xref-reload-failed = XREF Ricaricamento non riuscito: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reload-failed = XREF Ricarica fallita: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = Zoom precedente .zoom-no-previous-view = ZOOM: nessuna vista precedente. .zoom-object = Zoom oggetto @@ -1118,7 +1128,7 @@ common = .inserting-from-file = Inserimento di "__ocs_fmt_0__" dal file. .insert-file = INSERT FILE: __ocs_fmt_0__ .xref-recovered-with-warnings = XREF Recuperato con avvisi: "__ocs_fmt_0__" - .xref-recovery-failed = XREF Recupero non riuscito: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-recovery-failed = XREF Recupero fallito: "__ocs_fmt_0__" (__ocs_fmt_1__) .select-at-least-one-layout = Seleziona almeno un layout. .printing-is-not-available-in-the-web-version = La stampa non è disponibile nella versione web. .sending-selected-layouts-to-the-system-printer = Invio dei layout selezionati alla stampante di sistema… @@ -1614,7 +1624,7 @@ common = .create-triangular-face = Crea faccia triangolare .location = { common.position } .creation-mode = Modalità di creazione - .path = Percorso + .path = Percorso: .expression = Espressione .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1867,6 +1877,391 @@ common = .dimensional = Dimensionali .show-hide = Mostra/Nascondi + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Salva avviso .unable-to-save-drawing = Impossibile salvare il disegno @@ -2438,6 +2833,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Rimuovi + .pline-specify-starting-width = PLINE Specificare larghezza iniziale <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE Specificare larghezza finale <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE Specificare semilarghezza iniziale <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE Specificare semilarghezza finale <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (Linea) Specificare punto successivo: + .pline-arc-specify-endpoint-of-arc = PLINE (Arco) Specificare punto finale dell'arco: + .pline-specify-length-of-line = PLINE Specificare lunghezza della linea: + .pline-specify-included-angle = PLINE Specificare angolo inscritto: + .pline-specify-endpoint-of-arc = PLINE Specificare punto finale dell'arco: + .pline-specify-center-point-of-arc = PLINE Specificare centro dell'arco: + .pline-specify-radius-of-arc = PLINE Specificare raggio dell'arco: + .pline-specify-direction-of-chord-for-arc = PLINE Specificare direzione della corda dell'arco: + .pline-specify-length-of-chord = PLINE Specificare lunghezza della corda: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Specificare direzione tangente nel punto iniziale dell'arco: + .pline-specify-second-point-on-arc = PLINE Specificare secondo punto dell'arco: + .halfwidth = Semilarghezza + .center-keyword = Centro + .close-keyword = Chiudi + .second-pt = Secondo punto + .arc-specify-start-point-of-arc = ARC Specificare punto iniziale dell'arco: + .arc-specify-second-point-of-arc = ARC Specificare secondo punto dell'arco: + .arc-specify-end-point-of-arc = ARC Specificare punto finale dell'arco: + .arc-specify-center-point-of-arc = ARC Specificare centro dell'arco: + .arc-specify-included-angle = ARC Specificare angolo inscritto: + .arc-specify-length-of-chord = ARC Specificare lunghezza della corda: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Specificare direzione tangente nel punto iniziale dell'arco: + .arc-specify-radius-of-arc = ARC Specificare raggio dell'arco: + .chord-length = Lunghezza corda modify = .add-vertex = Aggiungi vertice @@ -2590,6 +3013,13 @@ modify = .tol-scale-dimtfac = Tol. scala (DIMTFAC) .trim = Tagliare .exit = esci + .area-keyword = Area + .displacement = Spostamento + .mode-keyword = Modalità + .array-keyword = Serie + .use-first-point-as-displacement = Primo punto come spostamento + .offset-erase-source-object-after-offsetting = OFFSET Cancellare l'oggetto di origine dopo l'offset? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Layer degli oggetti di offset [Current/Source] : groups = .group-enter-group-name-name = GROUP Enter nome del gruppo [__ocs_arg_name__]: @@ -3124,6 +3554,93 @@ insert = .xref-external-references = XREF Riferimenti esterni: .xref-no-external-references-in-this-drawing = XREF Nessun riferimento esterno in questo disegno. .xref-reloaded = XREF Ricaricato "__ocs_fmt_0__" + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" impostato su Sovrapposto. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" impostato su Allegato. + .xref-cannot-unload-nested-reference = XREF: impossibile scaricare il riferimento nidificato '__ocs_fmt_0__'. Scaricarlo nel suo disegno host. + .xref-cannot-overlay-nested-reference = XREF: impossibile sovrapporre il riferimento nidificato '__ocs_fmt_0__'. Sovrapporlo nel suo disegno host. + .xref-cannot-reload-nested-reference = XREF: impossibile ricaricare il riferimento nidificato '__ocs_fmt_0__'. Ricaricarlo nel suo disegno host. + .xref-cannot-attach-nested-reference = XREF: impossibile allegare il riferimento nidificato '__ocs_fmt_0__'. Allegarlo nel suo disegno host. + .xref-cannot-set-path-type-nested-reference = XREF: impossibile impostare il tipo di percorso per il riferimento nidificato '__ocs_fmt_0__'. Impostarlo nel suo disegno host. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: il ricaricamento si applica solo ai riferimenti di disegno. + .xref-bound = XREF: associato "__ocs_fmt_0__". + .xref-bound-with-unremapped-handles = XREF: associato "__ocs_fmt_0__" con __ocs_fmt_1__ maniglie di stile non rimappate (vedere i limiti di associazione). + .xref-cannot-bind-nested-reference = XREF: impossibile associare il riferimento nidificato '__ocs_fmt_0__'. Associarlo nel suo disegno host. + .xref-cannot-bind = XREF: impossibile associare "__ocs_fmt_0__": __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: L'associazione PDF (importazione vettoriale) non è disponibile in questa versione. + .xref-cannot-detach-nested-reference = XREF: impossibile scollegare il riferimento nidificato '__ocs_fmt_0__'. Scollegarlo nel suo disegno host. + .xref-cannot-make-path-relative = XREF: impossibile rendere relativo il percorso tra unità. + .xref-detached = XREF: scollegato "__ocs_fmt_0__". + .xref-no-loaded-reference-with-that-key = XREF: nessun riferimento caricato con quella chiave. + .xref-no-references-match = XREF: nessun riferimento corrisponde a '__ocs_fmt_0__'. + .xref-manager-title = Riferimenti esterni + .xref-self-attach-blocked = XATTACH: impossibile allegare il disegno host in se stesso. + .xref-overlays-drawing-references-only = XREF: le sovrapposizioni si applicano solo ai riferimenti di disegno. + .xref-path-set-for = XREF: Percorso impostato per "__ocs_fmt_0__" — Ricaricare per applicare. + .xref-unloaded = XREF: scaricato "__ocs_fmt_0__". + .xref-unknown-option = XREF: opzione sconosciuta '__ocs_fmt_0__'. Opzioni: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: __ocs_fmt_0__ riferimento(i) aggiornati. + .usage-xref-path-pattern = Uso: XREF Path + .usage-xref-path-find = Uso: XREF Path Find + .usage-xref-pathtype = Uso: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ riferimento(i) non trovato(i) — aprire il gestore dei riferimenti con EXTERNALREFERENCES. + .xref-nested-not-rendered = (nidificato — non renderizzato) + .change-path = Cambia percorso: + .date = Data + .details = Dettagli + .xref-detach = Scollega + .failed = Non riuscito + .xref-file-attach-is-not-available-on-web = L'allegato di file non è disponibile sul web — l'elenco dei riferimenti sottostante è di sola lettura. + .xref-file-name = Nome file + .found-at = Trovato in + .xref-help = Guida + .xref-nested-references-are-read-only = I riferimenti nidificati sono di sola lettura — modificarli nel loro disegno host. + .not-found = Non trovato + .orphaned = Orfano + .unreferenced = Non referenziato + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = Aperto + .xref-untitled = Senza titolo + .xref-unresolved = Non risolto + .xref-preview-not-available = Anteprima non disponibile + .xref-reload-all = Ricarica tutto + .xref-dwg = DWG + .xref-pixel-width = Larghezza in pixel + .xref-pixel-height = Altezza in pixel + .xref-resolution-unit = Unità di risoluzione + .xref-overlay = Sovrapponi + .xref-reference-manager-select-rows-bind = Gestore riferimenti — selezionare righe, quindi Scollega, Scarica, Ricarica, Associa, Sovrapponi o una modalità di modifica percorso. + .xref-bind = Associa + .xref-refresh = Aggiorna + .relative = Relativo + .xref-reload = Ricarica + .xref-replace = Sostituisci: + .saved-path = Percorso salvato + .xref-select-a-reference-first = Selezionare prima un riferimento. + .xref-select-a-reference-to-inspect-its = Selezionare un riferimento per ispezionarne i dettagli + .xref-set-path = Imposta percorso + .stale = Non aggiornato + .toggle-list-tree = Alterna elenco/albero + .tree = Albero + .xref-type-a-new-path-first = Digitare prima un nuovo percorso. + .xref-type-the-path-prefix-to-find-first = Digitare prima il prefisso del percorso da cercare. + .xref-unload = Scarica + .unloaded = Scaricato + .xref-new-path-for-selection = nuovo percorso per la selezione + .xref-new-path-prefix = nuovo prefisso percorso + .xref-old-path-prefix = vecchio prefisso percorso + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF Salvare prima il disegno per risolvere i percorsi XREF relativi. .object-s-copied-to-clipboard-base-3-3 = Oggetto/i __ocs_fmt_0__ copiati negli appunti (base __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ oggetto/i copiati negli appunti. @@ -3223,6 +3740,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Seleziona una linea, semiretta o linea di costruzione come asse: .revolve-select-objects-or-mode = REVOLVE Seleziona oggetti da rivoluzionare o [Modalità] (Invio per terminare): + .path = Path: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Crea .boolean = Operazioni booleane @@ -4533,6 +5057,19 @@ plot = .export-pdf = Esporta PDF .fit-to-paper = Adatta alla carta .layout-plots-the-current-sheet-using-the-selec = Layout traccia il foglio corrente utilizzando il formato carta selezionato. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Unisci linee sovrapposte .object-lineweights = Spessori linea degli oggetti .orientation = Orientamento @@ -4550,6 +5087,8 @@ plot = .plot-style-table-pen-assignments = Tabella stili di stampa (assegnazioni penne) .plot-transparency =Trasparenza della trama .plot-upside-down = Trama sottosopra + .named-plot-style-tables-stb-are-not-supported-yet-name = Le tabelle di stili di stampa con nome (.stb) non sono ancora supportate: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ non è stato trovato nella cartella degli stili di stampa (__ocs_fmt_1__). .preview = Anteprima .printer-plotter = Stampante/plotter .properties = Proprietà… @@ -4561,6 +5100,34 @@ plot = .system-default-printer = Stampante predefinita del sistema .what-to-plot = Cosa tracciare .x-mm = X (mm) + .select-drawing-to-import-page-setups-from = Seleziona il disegno da cui importare le impostazioni di pagina + .drawings-and-templates = Disegni e modelli + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: __ocs_fmt_0__ impostazione/i di pagina importata/e, __ocs_fmt_1__ sostituita/e. + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: nessuna impostazione di pagina chiamata "__ocs_fmt_0__" in __ocs_fmt_1__. + .psetupin-nothing-to-import-from-file = PSETUPIN: niente da importare da __ocs_fmt_0__. + .no-named-page-setups-in-this-drawing = Nessuna impostazione di pagina con nome in questo disegno. + .import-ellipsis = Importa… + .show-the-page-setup-for-new-layouts = Mostra l'impostazione di pagina per i nuovi layout + .x-in = X (poll.) + .y-in = Y (poll.) + .hide-paperspace-objects = Nascondi oggetti dello spazio carta + .save-changes-to-layout = Salva le modifiche nel layout + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ non supporta __ocs_fmt_1__; passato al foglio predefinito __ocs_fmt_2__ (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ non supporta __ocs_fmt_1__; il foglio viene mantenuto (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: la stampante non segnala fogli; viene usato il catalogo carta. + .printer-count-sheet-s-reported = __ocs_fmt_0__: __ocs_fmt_1__ foglio/i segnalato/i + .default-sheet-sheet = Foglio predefinito: __ocs_fmt_0__ + .default-sheet-not-reported = Foglio predefinito: non segnalato + .default-printer-name = Stampante predefinita: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Stampante predefinita: nessuna segnalata dal sistema + .printers-none-listed-by-the-system = Stampanti: nessuna elencata dal sistema + .printers-count = Stampanti (__ocs_fmt_0__): + .could-not-list-printers-error = Impossibile elencare le stampanti: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = Le stampe passano dall'applicazione PDF: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = Le stampe vanno direttamente alla stampante (nessuna applicazione PDF registra un comando di stampa). + .via-the-pdf-application = tramite l'applicazione PDF + .direct-print = stampa diretta .y-mm = Y (mm) manage = @@ -4772,6 +5339,19 @@ ui = .what-s-new = Cosa c'è di nuovo .width-value = Larghezza: __ocs_arg_value__ .working = Lavorando… + .recent-input = Input recenti + .copy-with-base-point = Copia con punto base + .undo-label = Annulla __ocs_arg_label__ + .redo-label = Ripeti __ocs_arg_label__ + .copy-selection = Copia selezione + .isolate = Isola + .deselect-all = Deseleziona tutto + .options-ellipsis = Opzioni... + .base-point = Punto base + .snap-overrides = Snap temporanei + .mid-between-2-points = Punto medio tra 2 punti + .osnap-settings = Impostazioni snap... + .snap-override-none-next-pick-only = Snap temporaneo: Nessuno (solo il prossimo punto). dimstyle = .keep-text-between-extension-lines = Mantieni sempre il testo tra le linee di estensione diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index e14ae20c2..3f548f133 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = 既定のアプリケーション .iced-theme = Iced テーマ: .theme = テーマ + .right-click-customization = 右クリックのカスタマイズ + .right-click-in-drawing-area = 作図領域での右クリック + .hold-duration = 長押し時間 + .right-click-mode-help = ショートカットメニュー: 右クリックで常にメニューを開く。時間依存: 短いクリックは Enter、長押しでメニューを開く (SHORTCUTMENUDURATION)。 + .right-click-mode-shortcut-menu = ショートカットメニュー + .right-click-mode-time-sensitive = 時間依存 (短いクリック = Enter) + .right-click-mode-enter-first = 1 回目は Enter、2 回目でメニュー command-line = .ready = Open CAD Studio の準備ができました。 @@ -242,6 +249,7 @@ command-move = .base = MOVE 基点を指定 [{ $count } 個のオブジェクト]: .target = MOVE 移動先を指定 [基点 { $x },{ $y }]: + .displacement = MOVE 変位を指定: command-copy = .array-count = COPY 配列複写する項目数を入力: @@ -249,6 +257,8 @@ command-copy = COPY 基点を指定 [{ $count } 個のオブジェクト]: .array-target = COPY { $count } 項目の配列複写の2点目を指定 [基点 { $x },{ $y }]: .target = COPY 移動先を指定 [これまでに { $count } 個複写 | 配列 | Enter=完了 | 基点 { $x },{ $y }]: + .displacement = COPY 変位を指定: + .mode = COPY コピーモードを入力 [Single/Multiple] : common = .jogged-radius = 折れ曲がり半径 @@ -1019,7 +1029,7 @@ common = .loft-surface-created = LOFT: サーフェスを作成しました。 .loft-solid-created = LOFT: ソリッドを作成しました。 .attsync-no-editable-block-references = ATTSYNC: 編集可能なブロック参照がありません。 - .xref-reloaded-with-repairs = XREF 修復して再読み込みしました: "__ocs_fmt_0__" + .xref-reloaded-with-repairs = XREF 修復付きで再読み込みされました: "__ocs_fmt_0__" .xref-reload-failed = XREF 再読み込みに失敗しました: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = 前の表示 .zoom-no-previous-view = ZOOM: 前の表示はありません。 @@ -1108,8 +1118,8 @@ common = .dwg-dxf-files = { common.files } (DWG/DXF) .inserting-from-file = ファイルから "__ocs_fmt_0__" を挿入しています。 .insert-file = INSERT FILE: __ocs_fmt_0__ - .xref-recovered-with-warnings = XREF 警告付きで復元しました: "__ocs_fmt_0__" - .xref-recovery-failed = XREF 復元に失敗しました: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-recovered-with-warnings = XREF 警告付きでリカバリされました: "__ocs_fmt_0__" + .xref-recovery-failed = XREF リカバリに失敗しました: "__ocs_fmt_0__" (__ocs_fmt_1__) .select-at-least-one-layout = レイアウトを1つ以上選択してください。 .printing-is-not-available-in-the-web-version = Web版では印刷できません。 .sending-selected-layouts-to-the-system-printer = 選択したレイアウトをシステムプリンターに送信中… @@ -1605,7 +1615,7 @@ common = .create-triangular-face = 三角形の面を作成 .location = { common.position } .creation-mode = 作成モード - .path = パス + .path = パス: .expression = 式 .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1858,6 +1868,391 @@ common = .dimensional = 寸法拘束 .show-hide = 表示/非表示 + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = 保存の警告 .unable-to-save-drawing = 図面を保存できません @@ -2429,6 +2824,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = 除去 + .pline-specify-starting-width = PLINE 始点の幅を指定 <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE 終点の幅を指定 <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE 始点の半幅を指定 <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE 終点の半幅を指定 <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (線分) 次の点を指定: + .pline-arc-specify-endpoint-of-arc = PLINE (円弧) 円弧の終点を指定: + .pline-specify-length-of-line = PLINE 線分の長さを指定: + .pline-specify-included-angle = PLINE 中心角を指定: + .pline-specify-endpoint-of-arc = PLINE 円弧の終点を指定: + .pline-specify-center-point-of-arc = PLINE 円弧の中心を指定: + .pline-specify-radius-of-arc = PLINE 円弧の半径を指定: + .pline-specify-direction-of-chord-for-arc = PLINE 円弧の弦の方向を指定: + .pline-specify-length-of-chord = PLINE 弦の長さを指定: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE 円弧の始点での接線方向を指定: + .pline-specify-second-point-on-arc = PLINE 円弧上の 2 点目を指定: + .halfwidth = 半幅 + .center-keyword = 中心 + .close-keyword = 閉じる + .second-pt = 2 点目 + .arc-specify-start-point-of-arc = ARC 円弧の始点を指定: + .arc-specify-second-point-of-arc = ARC 円弧の 2 点目を指定: + .arc-specify-end-point-of-arc = ARC 円弧の終点を指定: + .arc-specify-center-point-of-arc = ARC 円弧の中心を指定: + .arc-specify-included-angle = ARC 中心角を指定: + .arc-specify-length-of-chord = ARC 弦の長さを指定: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC 円弧の始点での接線方向を指定: + .arc-specify-radius-of-arc = ARC 円弧の半径を指定: + .chord-length = 弦の長さ modify = .add-vertex = 頂点を追加 @@ -2581,6 +3004,13 @@ modify = .tol-scale-dimtfac = 許容差尺度 (DIMTFAC) .trim = トリム .exit = 終了 + .area-keyword = 面積 + .displacement = 変位 + .mode-keyword = モード + .array-keyword = 配列 + .use-first-point-as-displacement = 1 点目を変位として使用 + .offset-erase-source-object-after-offsetting = OFFSET オフセット後に元のオブジェクトを削除しますか? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET オフセットオブジェクトの画層オプションを入力 [Current/Source] : groups = .group-enter-group-name-name = GROUP グループ名を入力 [__ocs_arg_name__]: @@ -3113,6 +3543,93 @@ insert = .xref-external-references = XREF 外部参照: .xref-no-external-references-in-this-drawing = XREF この図面には外部参照がありません。 .xref-reloaded = XREF 「__ocs_fmt_0__」を再ロードしました + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" をオーバーレイに設定しました。 + .xref-set-to-attach = XREF: "__ocs_fmt_0__" をアタッチに設定しました。 + .xref-cannot-unload-nested-reference = XREF: ネストされた参照 '__ocs_fmt_0__' をアンロードできません。ホスト図面でアンロードしてください。 + .xref-cannot-overlay-nested-reference = XREF: ネストされた参照 '__ocs_fmt_0__' をオーバーレイできません。ホスト図面でオーバーレイしてください。 + .xref-cannot-reload-nested-reference = XREF: ネストされた参照 '__ocs_fmt_0__' を再読み込みできません。ホスト図面で再読み込みしてください。 + .xref-cannot-attach-nested-reference = XREF: ネストされた参照 '__ocs_fmt_0__' をアタッチできません。ホスト図面でアタッチしてください。 + .xref-cannot-set-path-type-nested-reference = XREF: ネストされた参照 '__ocs_fmt_0__' のパスタイプを設定できません。ホスト図面で設定してください。 + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: 再読み込みは図面参照にのみ適用されます。 + .xref-bound = XREF: "__ocs_fmt_0__" をバインドしました。 + .xref-bound-with-unremapped-handles = XREF: "__ocs_fmt_0__" を __ocs_fmt_1__ 個の未再マップのスタイルハンドルでバインドしました(バインド制限を参照)。 + .xref-cannot-bind-nested-reference = XREF: ネストされた参照 '__ocs_fmt_0__' をバインドできません。ホスト図面でバインドしてください。 + .xref-cannot-bind = XREF: "__ocs_fmt_0__" をバインドできません: __ocs_fmt_1__。 + .xref-pdf-bind-unavailable = XREF: PDFバインド(ベクターインポート)はこのバージョンでは利用できません。 + .xref-cannot-detach-nested-reference = XREF: ネストされた参照 '__ocs_fmt_0__' をデタッチできません。ホスト図面でデタッチしてください。 + .xref-cannot-make-path-relative = XREF: ドライブをまたいでパスを相対にできません。 + .xref-detached = XREF: "__ocs_fmt_0__" をデタッチしました。 + .xref-no-loaded-reference-with-that-key = XREF: そのキーを持つロードされた参照はありません。 + .xref-no-references-match = XREF: '__ocs_fmt_0__' に一致する参照はありません。 + .xref-manager-title = 外部参照 + .xref-self-attach-blocked = XATTACH: ホスト図面をそれ自体にアタッチできません。 + .xref-overlays-drawing-references-only = XREF: オーバーレイは図面参照にのみ適用されます。 + .xref-path-set-for = XREF: "__ocs_fmt_0__" のパスを設定しました — 適用するには再読み込みしてください。 + .xref-unloaded = XREF: "__ocs_fmt_0__" をアンロードしました。 + .xref-unknown-option = XREF: 不明なオプション '__ocs_fmt_0__'。オプション: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: __ocs_fmt_0__ 個の参照を更新しました。 + .usage-xref-path-pattern = 使用方法: XREF Path + .usage-xref-path-find = 使用方法: XREF Path Find + .usage-xref-pathtype = 使用方法: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ 個の参照が見つかりません — EXTERNALREFERENCES で参照マネージャーを開いてください。 + .xref-nested-not-rendered = (ネスト — レンダリングされません) + .change-path = パスを変更: + .date = 日付 + .details = 詳細 + .xref-detach = デタッチ + .failed = 失敗 + .xref-file-attach-is-not-available-on-web = ファイル添付はWebでは利用できません — 以下の参照リストは読み取り専用です。 + .xref-file-name = ファイル名 + .found-at = 見つかった場所 + .xref-help = ヘルプ + .xref-nested-references-are-read-only = ネストされた参照は読み取り専用です — ホスト図面で編集してください。 + .not-found = 見つかりません + .orphaned = 孤立 + .unreferenced = 未参照 + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = 開かれました + .xref-untitled = 無題 + .xref-unresolved = 未解決 + .xref-preview-not-available = プレビューは利用できません + .xref-reload-all = すべて再読み込み + .xref-dwg = DWG + .xref-pixel-width = ピクセル幅 + .xref-pixel-height = ピクセル高さ + .xref-resolution-unit = 解像度単位 + .xref-overlay = オーバーレイ + .xref-reference-manager-select-rows-bind = 参照マネージャー — 行を選択してから、デタッチ、アンロード、再読み込み、バインド、オーバーレイ、またはパス変更モードを選択してください。 + .xref-bind = バインド + .xref-refresh = 更新 + .relative = 相対 + .xref-reload = 再読み込み + .xref-replace = 置換: + .saved-path = 保存されたパス + .xref-select-a-reference-first = まず参照を選択してください。 + .xref-select-a-reference-to-inspect-its = 詳細を確認する参照を選択してください + .xref-set-path = パスを設定 + .stale = 古い + .toggle-list-tree = リスト/ツリー切り替え + .tree = ツリー + .xref-type-a-new-path-first = まず新しいパスを入力してください。 + .xref-type-the-path-prefix-to-find-first = まず検索するパスプレフィックスを入力してください。 + .xref-unload = アンロード + .unloaded = アンロード + .xref-new-path-for-selection = 選択に対する新しいパス + .xref-new-path-prefix = 新しいパスプレフィックス + .xref-old-path-prefix = 古いパスプレフィックス + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF 相対 XREF パスを解決するには、先に図面を保存してください。 .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ 個のオブジェクトをクリップボードにコピーしました(基点 __ocs_fmt_1__,__ocs_fmt_2__)。 .object-s-copied-to-clipboard = __ocs_fmt_0__ 個のオブジェクトをクリップボードにコピーしました。 @@ -3212,6 +3729,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE 軸にする線分、半直線、または構築線を選択: .revolve-select-objects-or-mode = REVOLVE 回転させるオブジェクトを選択、または[モード](Enterで終了): + .path = Path: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = 作成 .boolean = ブール演算 @@ -4522,6 +5046,19 @@ plot = .export-pdf = PDF 書き出し .fit-to-paper = 用紙にフィット .layout-plots-the-current-sheet-using-the-selec = 選択した用紙サイズで現在のシートを印刷します。 + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = 重複線を結合 .object-lineweights = オブジェクトの線の太さ .orientation = 方向 @@ -4539,6 +5076,8 @@ plot = .plot-style-table-pen-assignments = 印刷スタイルテーブル(ペン割当) .plot-transparency = 透明度を印刷 .plot-upside-down = 上下反転印刷 + .named-plot-style-tables-stb-are-not-supported-yet-name = 名前付き印刷スタイルテーブル (.stb) はまだサポートされていません: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ は印刷スタイルフォルダー (__ocs_fmt_1__) に見つかりませんでした。 .preview = プレビュー .printer-plotter = プリンター / プロッター .properties = プロパティ… @@ -4550,6 +5089,34 @@ plot = .system-default-printer = システムの既定プリンター .what-to-plot = 印刷対象 .x-mm = X(mm) + .select-drawing-to-import-page-setups-from = ページ設定を読み込む図面を選択 + .drawings-and-templates = 図面とテンプレート + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: __ocs_fmt_0__ 件のページ設定を読み込み、__ocs_fmt_1__ 件を置き換えました。 + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: __ocs_fmt_1__ に「__ocs_fmt_0__」というページ設定はありません。 + .psetupin-nothing-to-import-from-file = PSETUPIN: __ocs_fmt_0__ に読み込むものがありません。 + .no-named-page-setups-in-this-drawing = この図面には名前付きページ設定がありません。 + .import-ellipsis = 読み込み… + .show-the-page-setup-for-new-layouts = 新しいレイアウトでページ設定を表示 + .x-in = X(インチ) + .y-in = Y(インチ) + .hide-paperspace-objects = ペーパー空間のオブジェクトを隠す + .save-changes-to-layout = 変更をレイアウトに保存 + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ は __ocs_fmt_1__ に対応していません。既定の用紙 __ocs_fmt_2__ に切り替えました(PAPERUPDATE = 1)。 + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ は __ocs_fmt_1__ に対応していません。用紙はそのままです(PAPERUPDATE = 0)。 + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: プリンターは用紙を報告しません。用紙カタログを使用します。 + .printer-count-sheet-s-reported = __ocs_fmt_0__: __ocs_fmt_1__ 種類の用紙が報告されました + .default-sheet-sheet = 既定の用紙: __ocs_fmt_0__ + .default-sheet-not-reported = 既定の用紙: 報告なし + .default-printer-name = 既定のプリンター: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = 既定のプリンター: システムから報告なし + .printers-none-listed-by-the-system = プリンター: システムに一覧なし + .printers-count = プリンター(__ocs_fmt_0__): + .could-not-list-printers-error = プリンターを一覧できませんでした: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = 印刷は PDF アプリケーション経由です: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = 印刷はプリンターへ直接送られます(印刷コマンドを登録した PDF アプリケーションがありません)。 + .via-the-pdf-application = PDF アプリケーション経由 + .direct-print = 直接印刷 .y-mm = Y(mm) manage = @@ -4761,6 +5328,19 @@ ui = .what-s-new = 新機能 .width-value = 幅: __ocs_arg_value__ .working = 処理中… + .recent-input = 最近の入力 + .copy-with-base-point = 基点を指定してコピー + .undo-label = 元に戻す __ocs_arg_label__ + .redo-label = やり直し __ocs_arg_label__ + .copy-selection = 選択をコピー + .isolate = 分離 + .deselect-all = 選択解除 + .options-ellipsis = オプション... + .base-point = 基点 + .snap-overrides = 優先オブジェクトスナップ + .mid-between-2-points = 2 点間の中点 + .osnap-settings = オブジェクトスナップ設定... + .snap-override-none-next-pick-only = 優先スナップ: なし (次の点のみ)。 dimstyle = .keep-text-between-extension-lines = 寸法値を常に寸法補助線の間に配置 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 05151a434..8417cdab4 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = 기본 애플리케이션 .iced-theme = Iced 테마: .theme = 테마 + .right-click-customization = 오른쪽 클릭 사용자화 + .right-click-in-drawing-area = 도면 영역에서 오른쪽 클릭 + .hold-duration = 누르는 시간 + .right-click-mode-help = 바로 가기 메뉴: 오른쪽 클릭 시 항상 메뉴를 엽니다. 시간 민감: 짧은 클릭은 Enter, 길게 누르면 메뉴가 열립니다 (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = 바로 가기 메뉴 + .right-click-mode-time-sensitive = 시간 민감 (짧은 클릭 = Enter) + .right-click-mode-enter-first = 먼저 Enter, 두 번째 클릭 = 메뉴 command-line = .ready = Open CAD Studio가 준비되었습니다. @@ -244,6 +251,7 @@ command-move = *[other] { $count } 객체 }]: .target = MOVE 대상 지정 [기본 { $x },{ $y }]: + .displacement = MOVE 변위 지정: command-copy = .array-count = COPY Enter 배열할 항목 수: @@ -258,6 +266,8 @@ command-copy = [one] { $count } 지금까지 복사 *[other] { $count } 지금까지의 사본 } | 배열 | Enter=완료 | 베이스 { $x },{ $y }]: + .displacement = COPY 변위 지정: + .mode = COPY 복사 모드 옵션 입력 [Single/Multiple] : common = .jogged-radius = 꺾인 반지름 @@ -1028,8 +1038,8 @@ common = .loft-surface-created = LOFT: 표면을 생성했습니다. .loft-solid-created = LOFT: 솔리드가 생성되었습니다. .attsync-no-editable-block-references = ATTSYNC: 편집 가능한 블록 참조가 없습니다. - .xref-reloaded-with-repairs = XREF 복구하여 다시 로드했습니다: "__ocs_fmt_0__" - .xref-reload-failed = XREF 다시 로드하지 못했습니다: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reloaded-with-repairs = XREF 수리와 함께 다시 로드됨: "__ocs_fmt_0__" + .xref-reload-failed = XREF 다시 로드 실패: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = 이전 뷰 .zoom-no-previous-view = ZOOM: 이전 뷰가 없습니다. .zoom-object = 객체로 줌 @@ -1117,8 +1127,8 @@ common = .dwg-dxf-files = { common.files } (DWG/DXF) .inserting-from-file = 파일에서 "__ocs_fmt_0__"을(를) 삽입합니다. .insert-file = INSERT FILE: __ocs_fmt_0__ - .xref-recovered-with-warnings = XREF 경고와 함께 복구했습니다: "__ocs_fmt_0__" - .xref-recovery-failed = XREF 복구하지 못했습니다: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-recovered-with-warnings = XREF 경고와 함께 복구됨: "__ocs_fmt_0__" + .xref-recovery-failed = XREF 복구 실패: "__ocs_fmt_0__" (__ocs_fmt_1__) .select-at-least-one-layout = 배치를 하나 이상 선택하세요. .printing-is-not-available-in-the-web-version = 웹 버전에서는 인쇄할 수 없습니다. .sending-selected-layouts-to-the-system-printer = 선택한 배치를 시스템 프린터로 보내는 중… @@ -1614,7 +1624,7 @@ common = .create-triangular-face = 삼각형 면 작성 .location = { common.position } .creation-mode = 작성 모드 - .path = 경로 + .path = 경로: .expression = 표현식 .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1867,6 +1877,391 @@ common = .dimensional = 치수 구속 .show-hide = 표시/숨기기 + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = 경고 저장 .unable-to-save-drawing = 도면을 저장할 수 없습니다 @@ -2438,6 +2833,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = 제거 + .pline-specify-starting-width = PLINE 시작 폭 지정 <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE 끝 폭 지정 <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE 시작 반폭 지정 <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE 끝 반폭 지정 <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (선) 다음 점 지정: + .pline-arc-specify-endpoint-of-arc = PLINE (호) 호의 끝점 지정: + .pline-specify-length-of-line = PLINE 선의 길이 지정: + .pline-specify-included-angle = PLINE 사이각 지정: + .pline-specify-endpoint-of-arc = PLINE 호의 끝점 지정: + .pline-specify-center-point-of-arc = PLINE 호의 중심점 지정: + .pline-specify-radius-of-arc = PLINE 호의 반지름 지정: + .pline-specify-direction-of-chord-for-arc = PLINE 호의 현 방향 지정: + .pline-specify-length-of-chord = PLINE 현의 길이 지정: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE 호 시작점의 접선 방향 지정: + .pline-specify-second-point-on-arc = PLINE 호의 두 번째 점 지정: + .halfwidth = 반폭 + .center-keyword = 중심 + .close-keyword = 닫기 + .second-pt = 두 번째 점 + .arc-specify-start-point-of-arc = ARC 호의 시작점 지정: + .arc-specify-second-point-of-arc = ARC 호의 두 번째 점 지정: + .arc-specify-end-point-of-arc = ARC 호의 끝점 지정: + .arc-specify-center-point-of-arc = ARC 호의 중심점 지정: + .arc-specify-included-angle = ARC 사이각 지정: + .arc-specify-length-of-chord = ARC 현의 길이 지정: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC 호 시작점의 접선 방향 지정: + .arc-specify-radius-of-arc = ARC 호의 반지름 지정: + .chord-length = 현 길이 modify = .add-vertex = 정점 추가 @@ -2590,6 +3013,13 @@ modify = .tol-scale-dimtfac = 톨. 스케일(DIMTFAC) .trim = 트림 .exit = 종료 + .area-keyword = 면적 + .displacement = 변위 + .mode-keyword = 모드 + .array-keyword = 배열 + .use-first-point-as-displacement = 첫 번째 점을 변위로 사용 + .offset-erase-source-object-after-offsetting = OFFSET 간격띄우기 후 원본 객체를 지우시겠습니까? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET 간격띄우기 객체의 도면층 옵션 입력 [Current/Source] : groups = .group-enter-group-name-name = GROUP Enter 그룹 이름 [__ocs_arg_name__]: @@ -3124,6 +3554,93 @@ insert = .xref-external-references = XREF 외부 참조: .xref-no-external-references-in-this-drawing = XREF 이 도면에는 외부 참조가 없습니다. .xref-reloaded = XREF가 "__ocs_fmt_0__"를 다시 로드했습니다. + .xref-set-to-overlay = XREF: "__ocs_fmt_0__"을(를) 오버레이로 설정함. + .xref-set-to-attach = XREF: "__ocs_fmt_0__"을(를) 첨부로 설정함. + .xref-cannot-unload-nested-reference = XREF: 중첩 참조 '__ocs_fmt_0__'을(를) 언로드할 수 없습니다. 호스트 도면에서 언로드하세요. + .xref-cannot-overlay-nested-reference = XREF: 중첩 참조 '__ocs_fmt_0__'을(를) 오버레이할 수 없습니다. 호스트 도면에서 오버레이하세요. + .xref-cannot-reload-nested-reference = XREF: 중첩 참조 '__ocs_fmt_0__'을(를) 다시 로드할 수 없습니다. 호스트 도면에서 다시 로드하세요. + .xref-cannot-attach-nested-reference = XREF: 중첩 참조 '__ocs_fmt_0__'을(를) 첨부할 수 없습니다. 호스트 도면에서 첨부하세요. + .xref-cannot-set-path-type-nested-reference = XREF: 중첩 참조 '__ocs_fmt_0__'의 경로 유형을 설정할 수 없습니다. 호스트 도면에서 설정하세요. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: 다시 로드는 도면 참조에만 적용됩니다. + .xref-bound = XREF: "__ocs_fmt_0__" 바인드됨. + .xref-bound-with-unremapped-handles = XREF: "__ocs_fmt_0__" 바인드됨, __ocs_fmt_1__개의 재매핑되지 않은 스타일 핸들 (바인드 제한사항 참조). + .xref-cannot-bind-nested-reference = XREF: 중첩 참조 '__ocs_fmt_0__'을(를) 바인드할 수 없습니다. 호스트 도면에서 바인드하세요. + .xref-cannot-bind = XREF: "__ocs_fmt_0__"을(를) 바인드할 수 없음: __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: PDF 바인드(벡터 가져오기)는 이 버전에서 사용할 수 없습니다. + .xref-cannot-detach-nested-reference = XREF: 중첩 참조 '__ocs_fmt_0__'을(를) 분리할 수 없습니다. 호스트 도면에서 분리하세요. + .xref-cannot-make-path-relative = XREF: 드라이브 간 경로를 상대적으로 만들 수 없습니다. + .xref-detached = XREF: "__ocs_fmt_0__" 분리됨. + .xref-no-loaded-reference-with-that-key = XREF: 해당 키를 가진 로드된 참조가 없습니다. + .xref-no-references-match = XREF: '__ocs_fmt_0__'과(와) 일치하는 참조가 없습니다. + .xref-manager-title = 외부 참조 + .xref-self-attach-blocked = XATTACH: 호스트 도면을 자신에게 첨부할 수 없습니다. + .xref-overlays-drawing-references-only = XREF: 오버레이는 도면 참조에만 적용됩니다. + .xref-path-set-for = XREF: "__ocs_fmt_0__"에 대한 경로가 설정됨 — 적용하려면 다시 로드하세요. + .xref-unloaded = XREF: "__ocs_fmt_0__" 언로드됨. + .xref-unknown-option = XREF: 알 수 없는 옵션 '__ocs_fmt_0__'. 옵션: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: __ocs_fmt_0__개의 참조가 업데이트됨. + .usage-xref-path-pattern = 사용법: XREF Path + .usage-xref-path-find = 사용법: XREF Path Find + .usage-xref-pathtype = 사용법: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__개의 참조를 찾을 수 없음 — EXTERNALREFERENCES로 참조 관리자를 여세요. + .xref-nested-not-rendered = (중첩 — 렌더링되지 않음) + .change-path = 경로 변경: + .date = 날짜 + .details = 세부 정보 + .xref-detach = 분리 + .failed = 실패 + .xref-file-attach-is-not-available-on-web = 파일 첨부는 웹에서 사용할 수 없습니다 — 아래 참조 목록은 읽기 전용입니다. + .xref-file-name = 파일 이름 + .found-at = 찾은 위치 + .xref-help = 도움말 + .xref-nested-references-are-read-only = 중첩 참조는 읽기 전용입니다 — 호스트 도면에서 편집하세요. + .not-found = 찾을 수 없음 + .orphaned = 고아 + .unreferenced = 참조되지 않음 + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = 열림 + .xref-untitled = 제목 없음 + .xref-unresolved = 확인되지 않음 + .xref-preview-not-available = 미리보기를 사용할 수 없음 + .xref-reload-all = 모두 다시 로드 + .xref-dwg = DWG + .xref-pixel-width = 픽셀 너비 + .xref-pixel-height = 픽셀 높이 + .xref-resolution-unit = 해상도 단위 + .xref-overlay = 오버레이 + .xref-reference-manager-select-rows-bind = 참조 관리자 — 행을 선택한 다음 분리, 언로드, 다시 로드, 바인드, 오버레이 또는 경로 변경 모드를 선택하세요. + .xref-bind = 바인드 + .xref-refresh = 새로 고침 + .relative = 상대 + .xref-reload = 다시 로드 + .xref-replace = 바꾸기: + .saved-path = 저장된 경로 + .xref-select-a-reference-first = 먼저 참조를 선택하세요. + .xref-select-a-reference-to-inspect-its = 세부 정보를 확인할 참조를 선택하세요 + .xref-set-path = 경로 설정 + .stale = 오래됨 + .toggle-list-tree = 목록/트리 전환 + .tree = 트리 + .xref-type-a-new-path-first = 먼저 새 경로를 입력하세요. + .xref-type-the-path-prefix-to-find-first = 먼저 찾을 경로 접두어를 입력하세요. + .xref-unload = 언로드 + .unloaded = 언로드됨 + .xref-new-path-for-selection = 선택 항목에 대한 새 경로 + .xref-new-path-prefix = 새 경로 접두어 + .xref-old-path-prefix = 이전 경로 접두어 + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF 상대 XREF 경로를 확인하려면 먼저 도면을 저장하십시오. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ 개체가 클립보드에 복사되었습니다(기본 __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ 개체가 클립보드에 복사되었습니다. @@ -3223,6 +3740,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE 축으로 사용할 선, 반직선 또는 구성선을 선택하세요: .revolve-select-objects-or-mode = REVOLVE 회전할 객체 선택 또는 [모드] (Enter로 완료): + .path = Path: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = 생성 .boolean = 불리언 연산 @@ -4533,6 +5057,19 @@ plot = .export-pdf = PDF 내보내기 .fit-to-paper = 종이에 맞추기 .layout-plots-the-current-sheet-using-the-selec = 레이아웃은 선택한 용지 크기를 사용하여 현재 시트를 플롯합니다. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = 겹치는 선 병합 .object-lineweights = 객체 선가중치 .orientation = 오리엔테이션 @@ -4550,6 +5087,8 @@ plot = .plot-style-table-pen-assignments = 플롯 스타일 테이블(펜 지정) .plot-transparency =플롯 투명도 .plot-upside-down = 거꾸로 플롯 + .named-plot-style-tables-stb-are-not-supported-yet-name = 명명된 플롯 스타일 테이블(.stb)은 아직 지원되지 않습니다: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__을(를) 플롯 스타일 폴더(__ocs_fmt_1__)에서 찾을 수 없습니다. .preview = 미리보기 .printer-plotter = 프린터/플로터 .properties = 속성… @@ -4561,6 +5100,34 @@ plot = .system-default-printer = 시스템 기본 프린터 .what-to-plot = 무엇을 플롯할 것인가 .x-mm = X (mm) + .select-drawing-to-import-page-setups-from = 페이지 설정을 가져올 도면 선택 + .drawings-and-templates = 도면 및 템플릿 + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: 페이지 설정 __ocs_fmt_0__개를 가져왔고 __ocs_fmt_1__개를 교체했습니다. + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: __ocs_fmt_1__에 "__ocs_fmt_0__" 페이지 설정이 없습니다. + .psetupin-nothing-to-import-from-file = PSETUPIN: __ocs_fmt_0__에서 가져올 항목이 없습니다. + .no-named-page-setups-in-this-drawing = 이 도면에는 명명된 페이지 설정이 없습니다. + .import-ellipsis = 가져오기… + .show-the-page-setup-for-new-layouts = 새 배치에 페이지 설정 표시 + .x-in = X (인치) + .y-in = Y (인치) + .hide-paperspace-objects = 도면 공간 객체 숨기기 + .save-changes-to-layout = 변경 사항을 배치에 저장 + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__이(가) __ocs_fmt_1__을(를) 지원하지 않습니다. 기본 용지 __ocs_fmt_2__(으)로 전환했습니다 (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__이(가) __ocs_fmt_1__을(를) 지원하지 않습니다. 용지를 유지합니다 (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: 프린터가 용지를 보고하지 않습니다. 용지 카탈로그를 사용합니다. + .printer-count-sheet-s-reported = __ocs_fmt_0__: 용지 __ocs_fmt_1__개 보고됨 + .default-sheet-sheet = 기본 용지: __ocs_fmt_0__ + .default-sheet-not-reported = 기본 용지: 보고되지 않음 + .default-printer-name = 기본 프린터: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = 기본 프린터: 시스템이 보고하지 않음 + .printers-none-listed-by-the-system = 프린터: 시스템에 나열된 항목 없음 + .printers-count = 프린터 (__ocs_fmt_0__): + .could-not-list-printers-error = 프린터 목록을 가져올 수 없습니다: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = 출력은 PDF 응용 프로그램을 거칩니다: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = 출력이 프린터로 직접 전송됩니다 (인쇄 명령을 등록한 PDF 응용 프로그램이 없음). + .via-the-pdf-application = PDF 응용 프로그램 경유 + .direct-print = 직접 인쇄 .y-mm = Y (mm) manage = @@ -4772,6 +5339,19 @@ ui = .what-s-new = 새로운 소식 .width-value = 폭: __ocs_arg_value__ .working = 일하는 중… + .recent-input = 최근 입력 + .copy-with-base-point = 기준점과 함께 복사 + .undo-label = 명령 취소 __ocs_arg_label__ + .redo-label = 명령 복구 __ocs_arg_label__ + .copy-selection = 선택 복사 + .isolate = 분리 + .deselect-all = 선택 해제 + .options-ellipsis = 옵션... + .base-point = 기준점 + .snap-overrides = 스냅 재지정 + .mid-between-2-points = 두 점 사이의 중간 + .osnap-settings = 객체 스냅 설정... + .snap-override-none-next-pick-only = 스냅 재지정: 없음 (다음 선택만). dimstyle = .keep-text-between-extension-lines = 보조선 사이에 항상 텍스트 유지 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 600feb8a4..c486bd035 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Standaardtoepassing .iced-theme = Iced thema: .theme = Thema + .right-click-customization = Rechtsklik aanpassen + .right-click-in-drawing-area = Rechtsklik in tekengebied + .hold-duration = Vasthoudduur + .right-click-mode-help = Snelmenu: rechtsklik opent altijd het menu. Tijdgevoelig: een korte klik is Enter, langer vasthouden opent het menu (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Snelmenu + .right-click-mode-time-sensitive = Tijdgevoelig (korte klik = Enter) + .right-click-mode-enter-first = Eerst Enter, tweede klik = menu command-line = .ready = Open CAD Studio is gereed. @@ -245,6 +252,7 @@ command-move = *[other] { $count } objecten }]: .target = MOVE Geef de bestemming op [basis { $x },{ $y }]: + .displacement = MOVE Geef verplaatsing op: command-copy = .array-count = COPY Voer het aantal items in de reeks in: @@ -259,6 +267,8 @@ command-copy = [one] { $count } kopie tot nu toe *[other] { $count } kopieën tot nu toe } | Reeks | Enter=gereed | basis { $x },{ $y }]: + .displacement = COPY Geef verplaatsing op: + .mode = COPY Geef kopieermodus op [Single/Multiple] : common = .jogged-radius = Geknikte straal @@ -1027,8 +1037,8 @@ common = .loft-surface-created = LOFT: oppervlak gemaakt. .loft-solid-created = LOFT: vaste stof aangemaakt. .attsync-no-editable-block-references = ATTSYNC: geen bewerkbare blokreferenties. - .xref-reloaded-with-repairs = XREF Opnieuw geladen met reparaties: "__ocs_fmt_0__" - .xref-reload-failed = XREF Opnieuw laden mislukt: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reloaded-with-repairs = XREF Herladen met reparaties: "__ocs_fmt_0__" + .xref-reload-failed = XREF Herladen mislukt: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = Vorige weergave .zoom-no-previous-view = ZOOM: geen vorige weergave. .zoom-object = Inzoomen op object @@ -1613,7 +1623,7 @@ common = .create-triangular-face = Driehoekig vlak maken .location = { common.position } .creation-mode = Aanmaakmodus - .path = Pad + .path = Pad: .expression = Expressie .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1868,6 +1878,391 @@ common = .dimensional = Maatvoering .show-hide = Tonen/Verbergen + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Waarschuwing opslaan .unable-to-save-drawing = Kan tekening niet opslaan @@ -2439,6 +2834,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Verwijderen + .pline-specify-starting-width = PLINE Geef beginbreedte op <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE Geef eindbreedte op <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE Geef halve beginbreedte op <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE Geef halve eindbreedte op <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (Lijn) Geef volgend punt op: + .pline-arc-specify-endpoint-of-arc = PLINE (Boog) Geef eindpunt van boog op: + .pline-specify-length-of-line = PLINE Geef lengte van lijn op: + .pline-specify-included-angle = PLINE Geef ingesloten hoek op: + .pline-specify-endpoint-of-arc = PLINE Geef eindpunt van boog op: + .pline-specify-center-point-of-arc = PLINE Geef middelpunt van boog op: + .pline-specify-radius-of-arc = PLINE Geef straal van boog op: + .pline-specify-direction-of-chord-for-arc = PLINE Geef richting van koorde van boog op: + .pline-specify-length-of-chord = PLINE Geef lengte van koorde op: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Geef raaklijnrichting in beginpunt van boog op: + .pline-specify-second-point-on-arc = PLINE Geef tweede punt op boog op: + .halfwidth = Halve breedte + .center-keyword = Centrum + .close-keyword = Sluiten + .second-pt = Tweede punt + .arc-specify-start-point-of-arc = ARC Geef beginpunt van boog op: + .arc-specify-second-point-of-arc = ARC Geef tweede punt van boog op: + .arc-specify-end-point-of-arc = ARC Geef eindpunt van boog op: + .arc-specify-center-point-of-arc = ARC Geef middelpunt van boog op: + .arc-specify-included-angle = ARC Geef ingesloten hoek op: + .arc-specify-length-of-chord = ARC Geef lengte van koorde op: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Geef raaklijnrichting in beginpunt van boog op: + .arc-specify-radius-of-arc = ARC Geef straal van boog op: + .chord-length = Koordelengte modify = .add-vertex = Hoekpunt toevoegen @@ -2591,6 +3014,13 @@ modify = .tol-scale-dimtfac = Tol. schaal (DIMTFAC) .trim = Trimmen .exit = Afsluiten + .area-keyword = Oppervlakte + .displacement = Verplaatsing + .mode-keyword = Modus + .array-keyword = Patroon + .use-first-point-as-displacement = Eerste punt als verplaatsing + .offset-erase-source-object-after-offsetting = OFFSET Bronobject wissen na verschuiven? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Laag voor verschoven objecten [Current/Source] : groups = .group-enter-group-name-name = GROUP Groepsnaam invoeren [__ocs_arg_name__]: @@ -3123,6 +3553,93 @@ insert = .xref-external-references = XREF Externe verwijzingen: .xref-no-external-references-in-this-drawing = XREF Geen externe referenties in deze tekening. .xref-reloaded = XREF Herladen "__ocs_fmt_0__" + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" ingesteld op Overleggen. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" ingesteld op Koppelen. + .xref-cannot-unload-nested-reference = XREF: kan geneste verwijzing '__ocs_fmt_0__' niet ontladen. Ontlaad deze in de hosttekening. + .xref-cannot-overlay-nested-reference = XREF: kan geneste verwijzing '__ocs_fmt_0__' niet overleggen. Overleg deze in de hosttekening. + .xref-cannot-reload-nested-reference = XREF: kan geneste verwijzing '__ocs_fmt_0__' niet herladen. Herlaad deze in de hosttekening. + .xref-cannot-attach-nested-reference = XREF: kan geneste verwijzing '__ocs_fmt_0__' niet koppelen. Koppel deze in de hosttekening. + .xref-cannot-set-path-type-nested-reference = XREF: kan padtype niet instellen voor geneste verwijzing '__ocs_fmt_0__'. Stel dit in in de hosttekening. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: opnieuw laden geldt alleen voor tekeningreferenties. + .xref-bound = XREF: "__ocs_fmt_0__" ingevoegd. + .xref-bound-with-unremapped-handles = XREF: "__ocs_fmt_0__" ingevoegd met __ocs_fmt_1__ niet-hertoegewezen stijlgrepen (zie bindingsbeperkingen). + .xref-cannot-bind-nested-reference = XREF: kan geneste verwijzing '__ocs_fmt_0__' niet invoegen. Voeg deze in in de hosttekening. + .xref-cannot-bind = XREF: kan "__ocs_fmt_0__" niet invoegen: __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: PDF-invoeging (vectorimport) is niet beschikbaar in deze versie. + .xref-cannot-detach-nested-reference = XREF: kan geneste verwijzing '__ocs_fmt_0__' niet loskoppelen. Koppel deze los in de hosttekening. + .xref-cannot-make-path-relative = XREF: kan pad niet relatief maken tussen stations. + .xref-detached = XREF: "__ocs_fmt_0__" losgekoppeld. + .xref-no-loaded-reference-with-that-key = XREF: geen geladen referentie met die sleutel. + .xref-no-references-match = XREF: geen referenties komen overeen met '__ocs_fmt_0__'. + .xref-manager-title = Externe referenties + .xref-self-attach-blocked = XATTACH: kan de hosttekening niet in zichzelf koppelen. + .xref-overlays-drawing-references-only = XREF: overleggingen gelden alleen voor tekeningreferenties. + .xref-path-set-for = XREF: Pad ingesteld voor "__ocs_fmt_0__" — Herlaad om toe te passen. + .xref-unloaded = XREF: "__ocs_fmt_0__" ontladen. + .xref-unknown-option = XREF: onbekende optie '__ocs_fmt_0__'. Opties: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: __ocs_fmt_0__ referentie(s) bijgewerkt. + .usage-xref-path-pattern = Gebruik: XREF Path + .usage-xref-path-find = Gebruik: XREF Path Find + .usage-xref-pathtype = Gebruik: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ referentie(s) niet gevonden — open de referentiebeheerder met EXTERNALREFERENCES. + .xref-nested-not-rendered = (genest — niet weergegeven) + .change-path = Pad wijzigen: + .date = Datum + .details = Details + .xref-detach = Loskoppelen + .failed = Mislukt + .xref-file-attach-is-not-available-on-web = Bestand koppelen is niet beschikbaar op het web — de onderstaande referentielijst is alleen-lezen. + .xref-file-name = Bestandsnaam + .found-at = Gevonden op + .xref-help = Help + .xref-nested-references-are-read-only = Geneste referenties zijn alleen-lezen — bewerk ze in hun hosttekening. + .not-found = Niet gevonden + .orphaned = Wees + .unreferenced = Onverwezen + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = Geopend + .xref-untitled = Naamloos + .xref-unresolved = Onopgelost + .xref-preview-not-available = Voorbeeld niet beschikbaar + .xref-reload-all = Alles herladen + .xref-dwg = DWG + .xref-pixel-width = Pixelbreedte + .xref-pixel-height = Pixelhoogte + .xref-resolution-unit = Resolutie-eenheid + .xref-overlay = Overleggen + .xref-reference-manager-select-rows-bind = Referentiebeheerder — selecteer rijen, daarna Loskoppelen, Ontladen, Herladen, Invoegen, Overleggen of een padwijzigingsmodus. + .xref-bind = Invoegen + .xref-refresh = Vernieuwen + .relative = Relatief + .xref-reload = Herladen + .xref-replace = Vervangen: + .saved-path = Opgeslagen pad + .xref-select-a-reference-first = Selecteer eerst een referentie. + .xref-select-a-reference-to-inspect-its = Selecteer een referentie om de details te bekijken + .xref-set-path = Pad instellen + .stale = Verouderd + .toggle-list-tree = Lijst/structuur wisselen + .tree = Structuur + .xref-type-a-new-path-first = Typ eerst een nieuw pad. + .xref-type-the-path-prefix-to-find-first = Typ eerst het padprefix om te zoeken. + .xref-unload = Ontladen + .unloaded = Ontladen + .xref-new-path-for-selection = nieuw pad voor selectie + .xref-new-path-prefix = nieuw padprefix + .xref-old-path-prefix = oud padprefix + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF Sla de tekening eerst op om relatieve XREF paden op te lossen. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ object(en) naar het klembord gekopieerd (basispunt __ocs_fmt_1__, __ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ object(en) naar het klembord gekopieerd. @@ -3222,6 +3739,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Selecteer een lijn, straal of constructielijn als as: .revolve-select-objects-or-mode = REVOLVE Selecteer objecten om te wentelen of [Modus] (Enter om te voltooien): + .path = Path: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Maken .boolean = Booleaanse bewerkingen @@ -4532,6 +5056,19 @@ plot = .export-pdf = Exporteren PDF .fit-to-paper = Aan papier aanpassen .layout-plots-the-current-sheet-using-the-selec = De lay-out plot het huidige vel op het geselecteerde papierformaat. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Overlaplijnen samenvoegen .object-lineweights = Objectlijndiktes .orientation = Oriëntatie @@ -4549,6 +5086,8 @@ plot = .plot-style-table-pen-assignments = Plotstijltabel (pentoewijzingen) .plot-transparency = Doorzichtigheid van de plaats van uitvoering .plot-upside-down = Ondersteboven plotten + .named-plot-style-tables-stb-are-not-supported-yet-name = Benoemde plotstijltabellen (.stb) worden nog niet ondersteund: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ is niet gevonden in de map met plotstijlen (__ocs_fmt_1__). .preview = Voorbeeld .printer-plotter = Printer/plotter .properties = Eigenschappen... @@ -4560,6 +5099,34 @@ plot = .system-default-printer = Standaardprinter van het systeem .what-to-plot = Wat te plannen .x-mm = X (mm) + .select-drawing-to-import-page-setups-from = Tekening kiezen om pagina-instellingen uit te importeren + .drawings-and-templates = Tekeningen en sjablonen + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: __ocs_fmt_0__ pagina-instelling(en) geïmporteerd, __ocs_fmt_1__ vervangen. + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: geen pagina-instelling met de naam "__ocs_fmt_0__" in __ocs_fmt_1__. + .psetupin-nothing-to-import-from-file = PSETUPIN: niets te importeren uit __ocs_fmt_0__. + .no-named-page-setups-in-this-drawing = Geen benoemde pagina-instellingen in deze tekening. + .import-ellipsis = Importeren… + .show-the-page-setup-for-new-layouts = Pagina-instelling tonen voor nieuwe lay-outs + .x-in = X (inch) + .y-in = Y (inch) + .hide-paperspace-objects = Papierruimte-objecten verbergen + .save-changes-to-layout = Wijzigingen in lay-out opslaan + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ ondersteunt __ocs_fmt_1__ niet; overgeschakeld naar het standaardblad __ocs_fmt_2__ (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ ondersteunt __ocs_fmt_1__ niet; het blad blijft behouden (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: de printer meldt geen vellen; de papiercatalogus wordt gebruikt. + .printer-count-sheet-s-reported = __ocs_fmt_0__: __ocs_fmt_1__ vel(len) gemeld + .default-sheet-sheet = Standaardvel: __ocs_fmt_0__ + .default-sheet-not-reported = Standaardvel: niet gemeld + .default-printer-name = Standaardprinter: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Standaardprinter: geen gemeld door het systeem + .printers-none-listed-by-the-system = Printers: geen vermeld door het systeem + .printers-count = Printers (__ocs_fmt_0__): + .could-not-list-printers-error = Kon printers niet weergeven: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = Plots gaan via de PDF-toepassing: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = Plots gaan rechtstreeks naar de printer (geen PDF-toepassing registreert een afdrukopdracht). + .via-the-pdf-application = via de PDF-toepassing + .direct-print = rechtstreeks afdrukken .y-mm = Y (mm) manage = @@ -4771,6 +5338,19 @@ ui = .what-s-new = Wat is er nieuw? .width-value = Breedte: __ocs_arg_value__ .working = Werken... + .recent-input = Recente invoer + .copy-with-base-point = Kopiëren met basispunt + .undo-label = Ongedaan maken __ocs_arg_label__ + .redo-label = Opnieuw __ocs_arg_label__ + .copy-selection = Selectie kopiëren + .isolate = Isoleren + .deselect-all = Alles deselecteren + .options-ellipsis = Opties... + .base-point = Basispunt + .snap-overrides = Tijdelijke snaps + .mid-between-2-points = Midden tussen 2 punten + .osnap-settings = Objectsnap-instellingen... + .snap-override-none-next-pick-only = Tijdelijke snap: Geen (alleen volgend punt). dimstyle = .keep-text-between-extension-lines = Tekst altijd tussen hulplijnen houden diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index b8d7b9e80..503339cb1 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Aplikacja domyślna .iced-theme = Motyw Iced: .theme = Motyw + .right-click-customization = Dostosowanie prawego przycisku + .right-click-in-drawing-area = Prawy przycisk w obszarze rysunku + .hold-duration = Czas przytrzymania + .right-click-mode-help = Menu skrótów: prawy przycisk zawsze otwiera menu. Zależne od czasu: krótkie kliknięcie to Enter, dłuższe przytrzymanie otwiera menu (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Menu skrótów + .right-click-mode-time-sensitive = Zależne od czasu (krótkie kliknięcie = Enter) + .right-click-mode-enter-first = Najpierw Enter, drugie kliknięcie = menu command-line = .ready = Open CAD Studio jest gotowe. @@ -244,6 +251,7 @@ command-move = *[other] { $count } obiekty }]: .target = MOVE Określ miejsce docelowe [podstawa { $x },{ $y }]: + .displacement = MOVE Określ przesunięcie: command-copy = .array-count = COPY Enter liczba elementów do tablicy: @@ -258,6 +266,8 @@ command-copy = [one] { $count } kopia do tej pory *[other] { $count } kopii do tej pory } | Tablica | Enter=gotowe | baza { $x },{ $y }]: + .displacement = COPY Określ przesunięcie: + .mode = COPY Podaj tryb kopiowania [Single/Multiple] : common = .jogged-radius = Promień łamany @@ -1028,8 +1038,8 @@ common = .loft-surface-created = LOFT: utworzono powierzchnię. .loft-solid-created = LOFT: utworzono bryłę. .attsync-no-editable-block-references = ATTSYNC: brak edytowalnych odwołań do bloków. - .xref-reloaded-with-repairs = XREF Wczytano ponownie z naprawami: "__ocs_fmt_0__" - .xref-reload-failed = XREF Ponowne wczytanie nie powiodło się: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reloaded-with-repairs = XREF Przeładowano z naprawami: "__ocs_fmt_0__" + .xref-reload-failed = XREF Przeładowanie nie powiodło się: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = Poprzedni widok .zoom-no-previous-view = ZOOM: brak poprzedniego widoku. .zoom-object = Powiększ do obiektu @@ -1614,7 +1624,7 @@ common = .create-triangular-face = Utwórz trójkątną ścianę .location = { common.position } .creation-mode = Tryb tworzenia - .path = Ścieżka + .path = Ścieżka: .expression = Wyrażenie .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1867,6 +1877,391 @@ common = .dimensional = Wymiarowe .show-hide = Pokaż/Ukryj + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Zapisz ostrzeżenie .unable-to-save-drawing = Nie można zapisać rysunku @@ -2438,6 +2833,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Usuń + .pline-specify-starting-width = PLINE Określ szerokość początkową <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE Określ szerokość końcową <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE Określ początkową połowę szerokości <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE Określ końcową połowę szerokości <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (Linia) Określ następny punkt: + .pline-arc-specify-endpoint-of-arc = PLINE (Łuk) Określ punkt końcowy łuku: + .pline-specify-length-of-line = PLINE Określ długość linii: + .pline-specify-included-angle = PLINE Określ kąt rozwarcia: + .pline-specify-endpoint-of-arc = PLINE Określ punkt końcowy łuku: + .pline-specify-center-point-of-arc = PLINE Określ środek łuku: + .pline-specify-radius-of-arc = PLINE Określ promień łuku: + .pline-specify-direction-of-chord-for-arc = PLINE Określ kierunek cięciwy łuku: + .pline-specify-length-of-chord = PLINE Określ długość cięciwy: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Określ kierunek stycznej w punkcie początkowym łuku: + .pline-specify-second-point-on-arc = PLINE Określ drugi punkt na łuku: + .halfwidth = Połowa szerokości + .center-keyword = Środek + .close-keyword = Zamknij + .second-pt = Drugi punkt + .arc-specify-start-point-of-arc = ARC Określ punkt początkowy łuku: + .arc-specify-second-point-of-arc = ARC Określ drugi punkt łuku: + .arc-specify-end-point-of-arc = ARC Określ punkt końcowy łuku: + .arc-specify-center-point-of-arc = ARC Określ środek łuku: + .arc-specify-included-angle = ARC Określ kąt rozwarcia: + .arc-specify-length-of-chord = ARC Określ długość cięciwy: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Określ kierunek stycznej w punkcie początkowym łuku: + .arc-specify-radius-of-arc = ARC Określ promień łuku: + .chord-length = Długość cięciwy modify = .add-vertex = Dodaj wierzchołek @@ -2590,6 +3013,13 @@ modify = .tol-scale-dimtfac = Toł. skala (DIMTFAC) .trim = Przytnij .exit = Wyjdź + .area-keyword = Pole + .displacement = Przesunięcie + .mode-keyword = Tryb + .array-keyword = Szyk + .use-first-point-as-displacement = Pierwszy punkt jako przesunięcie + .offset-erase-source-object-after-offsetting = OFFSET Usunąć obiekt źródłowy po odsunięciu? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Warstwa odsuniętych obiektów [Current/Source] : groups = .group-enter-group-name-name = GROUP Enter nazwa grupy [__ocs_arg_name__]: @@ -3124,6 +3554,93 @@ insert = .xref-external-references = XREF Referencje zewnętrzne: .xref-no-external-references-in-this-drawing = XREF Brak zewnętrznych odniesień na tym rysunku. .xref-reloaded = XREF Załadowano ponownie „__ocs_fmt_0__” + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" ustawiono na Nałóż. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" ustawiono na Dołącz. + .xref-cannot-unload-nested-reference = XREF: nie można odłączyć zagnieżdżonego odwołania '__ocs_fmt_0__'. Odłącz je w rysunku nadrzędnym. + .xref-cannot-overlay-nested-reference = XREF: nie można nałożyć zagnieżdżonego odwołania '__ocs_fmt_0__'. Nałóż je w rysunku nadrzędnym. + .xref-cannot-reload-nested-reference = XREF: nie można przeładować zagnieżdżonego odwołania '__ocs_fmt_0__'. Przeładuj je w rysunku nadrzędnym. + .xref-cannot-attach-nested-reference = XREF: nie można dołączyć zagnieżdżonego odwołania '__ocs_fmt_0__'. Dołącz je w swoim rysunku nadrzędnym. + .xref-cannot-set-path-type-nested-reference = XREF: nie można ustawić typu ścieżki dla zagnieżdżonego odwołania '__ocs_fmt_0__'. Ustaw go w rysunku nadrzędnym. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: przeładowanie dotyczy tylko odwołań do rysunków. + .xref-bound = XREF: powiązano "__ocs_fmt_0__". + .xref-bound-with-unremapped-handles = XREF: powiązano "__ocs_fmt_0__" z __ocs_fmt_1__ nieprzypisanymi uchwytami stylu (patrz ograniczenia wiązania). + .xref-cannot-bind-nested-reference = XREF: nie można powiązać zagnieżdżonego odwołania '__ocs_fmt_0__'. Powiąż je w rysunku nadrzędnym. + .xref-cannot-bind = XREF: nie można powiązać "__ocs_fmt_0__": __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: Powiązanie PDF (import wektorowy) nie jest dostępne w tej wersji. + .xref-cannot-detach-nested-reference = XREF: nie można odłączyć zagnieżdżonego odwołania '__ocs_fmt_0__'. Odłącz je w rysunku nadrzędnym. + .xref-cannot-make-path-relative = XREF: nie można uczynić ścieżki względną między dyskami. + .xref-detached = XREF: odłączono "__ocs_fmt_0__". + .xref-no-loaded-reference-with-that-key = XREF: brak załadowanego odwołania o tym kluczu. + .xref-no-references-match = XREF: brak odwołań pasujących do '__ocs_fmt_0__'. + .xref-manager-title = Odwołania zewnętrzne + .xref-self-attach-blocked = XATTACH: nie można dołączyć rysunku nadrzędnego do samego siebie. + .xref-overlays-drawing-references-only = XREF: nałożenia dotyczą tylko odwołań do rysunków. + .xref-path-set-for = XREF: Ścieżka ustawiona dla "__ocs_fmt_0__" — Przeładuj, aby zastosować. + .xref-unloaded = XREF: odłączono z pamięci "__ocs_fmt_0__". + .xref-unknown-option = XREF: nieznana opcja '__ocs_fmt_0__'. Opcje: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: zaktualizowano __ocs_fmt_0__ odwołanie(a). + .usage-xref-path-pattern = Użycie: XREF Path + .usage-xref-path-find = Użycie: XREF Path Find + .usage-xref-pathtype = Użycie: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ odwołanie(a) nie znalezione — otwórz menedżera odwołań za pomocą EXTERNALREFERENCES. + .xref-nested-not-rendered = (zagnieżdżone — nie renderowane) + .change-path = Zmień ścieżkę: + .date = Data + .details = Szczegóły + .xref-detach = Odłącz + .failed = Niepowodzenie + .xref-file-attach-is-not-available-on-web = Dołączanie pliku nie jest dostępne w wersji internetowej — lista odwołań poniżej jest tylko do odczytu. + .xref-file-name = Nazwa pliku + .found-at = Znaleziono w + .xref-help = Pomoc + .xref-nested-references-are-read-only = Zagnieżdżone odwołania są tylko do odczytu — edytuj je w ich rysunku nadrzędnym. + .not-found = Nie znaleziono + .orphaned = Osierocony + .unreferenced = Nieodwołany + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = Otwarte + .xref-untitled = Bez tytułu + .xref-unresolved = Nierozwiązane + .xref-preview-not-available = Podgląd niedostępny + .xref-reload-all = Przeładuj wszystko + .xref-dwg = DWG + .xref-pixel-width = Szerokość w pikselach + .xref-pixel-height = Wysokość w pikselach + .xref-resolution-unit = Jednostka rozdzielczości + .xref-overlay = Nałóż + .xref-reference-manager-select-rows-bind = Menedżer odwołań — wybierz wiersze, następnie Odłącz, Odłącz z pamięci, Przeładuj, Powiąż, Nałóż lub tryb zmiany ścieżki. + .xref-bind = Powiąż + .xref-refresh = Odśwież + .relative = Względna + .xref-reload = Przeładuj + .xref-replace = Zamień: + .saved-path = Zapisana ścieżka + .xref-select-a-reference-first = Najpierw wybierz odwołanie. + .xref-select-a-reference-to-inspect-its = Wybierz odwołanie, aby wyświetlić jego szczegóły + .xref-set-path = Ustaw ścieżkę + .stale = Nieaktualny + .toggle-list-tree = Przełącz listę/drzewo + .tree = Drzewo + .xref-type-a-new-path-first = Najpierwprowadź nową ścieżkę. + .xref-type-the-path-prefix-to-find-first = Najpierw wprowadź prefiks ścieżki do wyszukania. + .xref-unload = Odłącz z pamięci + .unloaded = Odłączone + .xref-new-path-for-selection = nowa ścieżka dla zaznaczenia + .xref-new-path-prefix = nowy prefiks ścieżki + .xref-old-path-prefix = stary prefiks ścieżki + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF Zapisz najpierw rysunek, aby ustalić względne ścieżki XREF. .object-s-copied-to-clipboard-base-3-3 = Obiekt(y) __ocs_fmt_0__ skopiowano do schowka (bazowe __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = Obiekt(y) __ocs_fmt_0__ skopiowano do schowka. @@ -3223,6 +3740,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Wybierz linię, półprostą lub linię konstrukcyjną jako oś: .revolve-select-objects-or-mode = REVOLVE Wybierz obiekty do obrotu lub [Tryb] (Enter kończy): + .path = Path: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Utwórz .boolean = Operacje boolowskie @@ -4533,6 +5057,19 @@ plot = .export-pdf = Eksportuj PDF .fit-to-paper = Dopasuj do papieru .layout-plots-the-current-sheet-using-the-selec = Układ drukuje bieżący arkusz przy użyciu wybranego rozmiaru papieru. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Połącz nakładające się linie .object-lineweights = Szerokości linii obiektu .orientation = Orientacja @@ -4550,6 +5087,8 @@ plot = .plot-style-table-pen-assignments = Tabela stylów wydruku (przypisania pisaków) .plot-transparency =Przejrzystość fabuły .plot-upside-down = Fabuła do góry nogami + .named-plot-style-tables-stb-are-not-supported-yet-name = Nazwane tabele stylów wydruku (.stb) nie są jeszcze obsługiwane: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = Nie znaleziono __ocs_fmt_0__ w folderze stylów wydruku (__ocs_fmt_1__). .preview = Podgląd .printer-plotter = Drukarka / ploter .properties = Właściwości… @@ -4561,6 +5100,34 @@ plot = .system-default-printer = Domyślna drukarka systemowa .what-to-plot = Co nakreślić .x-mm = X (mm) + .select-drawing-to-import-page-setups-from = Wybierz rysunek, z którego zaimportować ustawienia strony + .drawings-and-templates = Rysunki i szablony + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: zaimportowano __ocs_fmt_0__ ustawień strony, zastąpiono __ocs_fmt_1__. + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: brak ustawienia strony o nazwie „__ocs_fmt_0__” w __ocs_fmt_1__. + .psetupin-nothing-to-import-from-file = PSETUPIN: nie ma nic do zaimportowania z __ocs_fmt_0__. + .no-named-page-setups-in-this-drawing = Brak nazwanych ustawień strony w tym rysunku. + .import-ellipsis = Importuj… + .show-the-page-setup-for-new-layouts = Pokaż ustawienia strony dla nowych arkuszy + .x-in = X (cale) + .y-in = Y (cale) + .hide-paperspace-objects = Ukryj obiekty przestrzeni papieru + .save-changes-to-layout = Zapisz zmiany w arkuszu + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ nie obsługuje __ocs_fmt_1__; przełączono na domyślny arkusz __ocs_fmt_2__ (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ nie obsługuje __ocs_fmt_1__; arkusz zachowano (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: drukarka nie zgłasza arkuszy; używany jest katalog papieru. + .printer-count-sheet-s-reported = __ocs_fmt_0__: zgłoszono __ocs_fmt_1__ arkuszy + .default-sheet-sheet = Domyślny arkusz: __ocs_fmt_0__ + .default-sheet-not-reported = Domyślny arkusz: nie zgłoszono + .default-printer-name = Drukarka domyślna: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Drukarka domyślna: system nie zgłasza żadnej + .printers-none-listed-by-the-system = Drukarki: system nie wymienia żadnej + .printers-count = Drukarki (__ocs_fmt_0__): + .could-not-list-printers-error = Nie można wyświetlić listy drukarek: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = Wydruki przechodzą przez aplikację PDF: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = Wydruki trafiają bezpośrednio do drukarki (żadna aplikacja PDF nie rejestruje polecenia drukowania). + .via-the-pdf-application = przez aplikację PDF + .direct-print = wydruk bezpośredni .y-mm = Y (mm) manage = @@ -4772,6 +5339,19 @@ ui = .what-s-new = Co nowego .width-value = Szerokość: __ocs_arg_value__ .working = Pracuję… + .recent-input = Ostatnie wpisy + .copy-with-base-point = Kopiuj z punktem bazowym + .undo-label = Cofnij __ocs_arg_label__ + .redo-label = Ponów __ocs_arg_label__ + .copy-selection = Kopiuj zaznaczenie + .isolate = Izoluj + .deselect-all = Odznacz wszystko + .options-ellipsis = Opcje... + .base-point = Punkt bazowy + .snap-overrides = Tymczasowe lokalizacje + .mid-between-2-points = Środek między 2 punktami + .osnap-settings = Ustawienia lokalizacji... + .snap-override-none-next-pick-only = Tymczasowa lokalizacja: Brak (tylko następny punkt). dimstyle = .keep-text-between-extension-lines = Zawsze przechowuj tekst pomiędzy liniami pomocniczymi diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index f5a0507f4..7b21b39cb 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Aplicativo padrão .iced-theme = Tema gelado: .theme = Tema + .right-click-customization = Personalização do clique direito + .right-click-in-drawing-area = Clique direito na área de desenho + .hold-duration = Duração do pressionamento + .right-click-mode-help = Menu de atalho: o clique direito sempre abre o menu. Sensível ao tempo: um clique rápido é Enter, segurar mais tempo abre o menu (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Menu de atalho + .right-click-mode-time-sensitive = Sensível ao tempo (clique rápido = Enter) + .right-click-mode-enter-first = Primeiro Enter, segundo clique = menu command-line = .ready = Open CAD Studio pronto. @@ -245,6 +252,7 @@ command-move = *[other] { $count } objetos }]: .target = MOVE Especifique o destino [base { $x },{ $y }]: + .displacement = MOVE Especifique o deslocamento: command-copy = .array-count = COPY Informe a quantidade de itens da matriz: @@ -259,6 +267,8 @@ command-copy = [one] { $count } cópia até agora *[other] { $count } cópias até agora } | Matriz | Enter=concluir | base { $x },{ $y }]: + .displacement = COPY Especifique o deslocamento: + .mode = COPY Informe o modo de cópia [Single/Multiple] : common = .jogged-radius = Raio com desvio @@ -1030,7 +1040,7 @@ common = .loft-solid-created = LOFT: sólido criado. .attsync-no-editable-block-references = ATTSYNC: nenhuma referência de bloco editável. .xref-reloaded-with-repairs = XREF Recarregado com reparos: "__ocs_fmt_0__" - .xref-reload-failed = XREF Falha ao recarregar: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reload-failed = XREF Falha no recarregamento: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = Zoom anterior .zoom-no-previous-view = ZOOM: nenhuma vista anterior. .zoom-object = Zoom no objeto @@ -1615,7 +1625,7 @@ common = .create-triangular-face = Criar face triangular .location = { common.position } .creation-mode = Modo de criação - .path = Caminho + .path = Caminho: .expression = Expressão .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1868,6 +1878,391 @@ common = .dimensional = Dimensionais .show-hide = Mostrar/Ocultar + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Aviso de salvamento .unable-to-save-drawing = Não foi possível salvar o desenho @@ -2439,6 +2834,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Remover + .pline-specify-starting-width = PLINE Especifique a largura inicial <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE Especifique a largura final <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE Especifique a meia largura inicial <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE Especifique a meia largura final <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (Linha) Especifique o próximo ponto: + .pline-arc-specify-endpoint-of-arc = PLINE (Arco) Especifique o ponto final do arco: + .pline-specify-length-of-line = PLINE Especifique o comprimento da linha: + .pline-specify-included-angle = PLINE Especifique o ângulo incluído: + .pline-specify-endpoint-of-arc = PLINE Especifique o ponto final do arco: + .pline-specify-center-point-of-arc = PLINE Especifique o centro do arco: + .pline-specify-radius-of-arc = PLINE Especifique o raio do arco: + .pline-specify-direction-of-chord-for-arc = PLINE Especifique a direção da corda do arco: + .pline-specify-length-of-chord = PLINE Especifique o comprimento da corda: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Especifique a direção tangente no ponto inicial do arco: + .pline-specify-second-point-on-arc = PLINE Especifique o segundo ponto do arco: + .halfwidth = Meia largura + .center-keyword = Centro + .close-keyword = Fechar + .second-pt = Segundo ponto + .arc-specify-start-point-of-arc = ARC Especifique o ponto inicial do arco: + .arc-specify-second-point-of-arc = ARC Especifique o segundo ponto do arco: + .arc-specify-end-point-of-arc = ARC Especifique o ponto final do arco: + .arc-specify-center-point-of-arc = ARC Especifique o centro do arco: + .arc-specify-included-angle = ARC Especifique o ângulo incluído: + .arc-specify-length-of-chord = ARC Especifique o comprimento da corda: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Especifique a direção tangente no ponto inicial do arco: + .arc-specify-radius-of-arc = ARC Especifique o raio do arco: + .chord-length = Comprimento da corda modify = .add-vertex = Adicionar vértice @@ -2591,6 +3014,13 @@ modify = .tol-scale-dimtfac = Escala de tolerância (DIMTFAC) .trim = Aparar .exit = Sair + .area-keyword = Área + .displacement = Deslocamento + .mode-keyword = Modo + .array-keyword = Matriz + .use-first-point-as-displacement = Usar o primeiro ponto como deslocamento + .offset-erase-source-object-after-offsetting = OFFSET Apagar o objeto de origem após deslocar? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Camada dos objetos deslocados [Current/Source] : groups = .group-enter-group-name-name = GROUP Insira o nome do grupo [__ocs_arg_name__]: @@ -3125,6 +3555,94 @@ insert = .xref-external-references = XREF Referências externas: .xref-no-external-references-in-this-drawing = XREF Nenhuma referência externa neste desenho. .xref-reloaded = XREF “__ocs_fmt_0__” recarregada + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" definido como Sobrepor. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" definido como Anexar. + .xref-cannot-unload-nested-reference = XREF: não é possível descarregar referência aninhada '__ocs_fmt_0__'. Descarregue-a no desenho host. + .xref-cannot-overlay-nested-reference = XREF: não é possível sobrepor referência aninhada '__ocs_fmt_0__'. Sobreponha-a no desenho host. + .xref-cannot-reload-nested-reference = XREF: não é possível recarregar referência aninhada '__ocs_fmt_0__'. Recarregue-a no desenho host. + .xref-cannot-attach-nested-reference = XREF: não é possível anexar referência aninhada '__ocs_fmt_0__'. Anexe-a no desenho host. + .xref-cannot-set-path-type-nested-reference = XREF: não é possível definir o tipo de caminho para referência aninhada '__ocs_fmt_0__'. Defina-o no desenho host. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: o recarregamento se aplica apenas a referências de desenho. + .xref-bound = XREF: vinculado "__ocs_fmt_0__". + .xref-bound-with-unremapped-handles = XREF: vinculado "__ocs_fmt_0__" com __ocs_fmt_1__ alças de estilo não remapeadas (consulte as limitações de vinculação). + .xref-cannot-bind-nested-reference = XREF: não é possível vincular referência aninhada '__ocs_fmt_0__'. Vincule-a no desenho host. + .xref-cannot-bind = XREF: não é possível vincular "__ocs_fmt_0__": __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: Vinculação de PDF (importação vetorial) não está disponível nesta versão. + .xref-cannot-detach-nested-reference = XREF: não é possível desanexar referência aninhada '__ocs_fmt_0__'. Desanexe-a no desenho host. + .xref-cannot-make-path-relative = XREF: não é possível tornar o caminho relativo entre unidades. + .xref-detached = XREF: desanexado "__ocs_fmt_0__". + .xref-no-loaded-reference-with-that-key = XREF: nenhuma referência carregada com essa chave. + .xref-no-references-match = XREF: nenhuma referência corresponde a '__ocs_fmt_0__'. + .xref-manager-title = Referências externas + .xref-self-attach-blocked = XATTACH: não é possível anexar o desenho host a si mesmo. + .xref-overlays-drawing-references-only = XREF: sobreposições se aplicam apenas a referências de desenho. + .xref-path-set-for = XREF: Caminho definido para "__ocs_fmt_0__" — Recarregue para aplicar. + .xref-unloaded = XREF: descarregado "__ocs_fmt_0__". + .xref-unknown-option = XREF: opção desconhecida '__ocs_fmt_0__'. Opções: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: __ocs_fmt_0__ referência(s) atualizada(s). + .usage-xref-path-pattern = Uso: XREF Path + .usage-xref-path-find = Uso: XREF Path Find + .usage-xref-pathtype = Uso: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ referência(s) não encontrada(s) — abra o gerenciador de referências com EXTERNALREFERENCES. + .xref-nested-not-rendered = (aninhada — não renderizada) + .change-path = Alterar caminho: + .date = Data + .details = Detalhes + .xref-detach = Desanexar + .failed = Falhou + .xref-file-attach-is-not-available-on-web = Anexar arquivo não está disponível na web — a lista de referências abaixo é somente leitura. + .xref-file-name = Nome do arquivo + .found-at = Encontrado em + .xref-help = Ajuda + .xref-nested-references-are-read-only = Referências aninhadas são somente leitura — edite-as em seu desenho host. + .not-found = Não encontrado + .orphaned = Órfão + .unreferenced = Não referenciado + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .path = Path: + .xref-opened = Aberto + .xref-untitled = Sem título + .xref-unresolved = Não resolvido + .xref-preview-not-available = Visualização não disponível + .xref-reload-all = Recarregar tudo + .xref-dwg = DWG + .xref-pixel-width = Largura em pixels + .xref-pixel-height = Altura em pixels + .xref-resolution-unit = Unidade de resolução + .xref-overlay = Sobrepor + .xref-reference-manager-select-rows-bind = Gerenciador de referências — selecione linhas, depois Desanexar, Descarregar, Recarregar, Vincular, Sobrepor ou um modo de alteração de caminho. + .xref-bind = Vincular + .xref-refresh = Atualizar + .relative = Relativo + .xref-reload = Recarregar + .xref-replace = Substituir: + .saved-path = Caminho salvo + .xref-select-a-reference-first = Selecione uma referência primeiro. + .xref-select-a-reference-to-inspect-its = Selecione uma referência para inspecionar seus detalhes + .xref-set-path = Definir caminho + .stale = Desatualizado + .toggle-list-tree = Alternar lista/árvore + .tree = Árvore + .xref-type-a-new-path-first = Digite um novo caminho primeiro. + .xref-type-the-path-prefix-to-find-first = Digite primeiro o prefixo do caminho a ser encontrado. + .xref-unload = Descarregar + .unloaded = Descarregado + .xref-new-path-for-selection = novo caminho para seleção + .xref-new-path-prefix = novo prefixo de caminho + .xref-old-path-prefix = prefixo de caminho antigo + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF Salve primeiro o desenho para resolver os caminhos relativos das referências externas. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ objetos copiados para a área de transferência (base __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ objetos copiados para a área de transferência. @@ -3223,7 +3741,13 @@ insert = .revolve-specify-start-angle = REVOLVE { common.specify }: { common.start-angle } .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Selecione uma linha, semirreta ou linha de construção como eixo: .revolve-select-objects-or-mode = REVOLVE Selecione objetos para revolução ou [Modo] (Enter para concluir): + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Criar .boolean = Operações booleanas @@ -4534,6 +5058,19 @@ plot = .export-pdf = Exportar PDF .fit-to-paper = Ajustar ao papel .layout-plots-the-current-sheet-using-the-selec = Layout plota a folha atual usando o tamanho de papel selecionado. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Mesclar linhas sobrepostas .object-lineweights = Espessuras de linha dos objetos .orientation = Orientação @@ -4551,6 +5088,8 @@ plot = .plot-style-table-pen-assignments = Tabela de estilos de plotagem (atribuições de penas) .plot-transparency = Transparência de plotagem .plot-upside-down = Plotar de cabeça para baixo + .named-plot-style-tables-stb-are-not-supported-yet-name = Tabelas de estilos de plotagem nomeados (.stb) ainda não são suportadas: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ não foi encontrado na pasta de estilos de plotagem (__ocs_fmt_1__). .preview = Visualização .printer-plotter = Impressora / plotadora .properties = Propriedades… @@ -4562,6 +5101,34 @@ plot = .system-default-printer = Impressora padrão do sistema .what-to-plot = O que plotar .x-mm = X (mm) + .select-drawing-to-import-page-setups-from = Selecionar desenho de onde importar configurações de página + .drawings-and-templates = Desenhos e modelos + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: __ocs_fmt_0__ configuração(ões) de página importada(s), __ocs_fmt_1__ substituída(s). + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: nenhuma configuração de página chamada "__ocs_fmt_0__" em __ocs_fmt_1__. + .psetupin-nothing-to-import-from-file = PSETUPIN: nada para importar de __ocs_fmt_0__. + .no-named-page-setups-in-this-drawing = Nenhuma configuração de página nomeada neste desenho. + .import-ellipsis = Importar… + .show-the-page-setup-for-new-layouts = Mostrar a configuração de página para novos layouts + .x-in = X (pol) + .y-in = Y (pol) + .hide-paperspace-objects = Ocultar objetos do espaço do papel + .save-changes-to-layout = Salvar alterações no layout + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ não suporta __ocs_fmt_1__; alterado para sua folha padrão __ocs_fmt_2__ (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ não suporta __ocs_fmt_1__; a folha foi mantida (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: a impressora não informa folhas; o catálogo de papel é usado. + .printer-count-sheet-s-reported = __ocs_fmt_0__: __ocs_fmt_1__ folha(s) informada(s) + .default-sheet-sheet = Folha padrão: __ocs_fmt_0__ + .default-sheet-not-reported = Folha padrão: não informada + .default-printer-name = Impressora padrão: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Impressora padrão: nenhuma informada pelo sistema + .printers-none-listed-by-the-system = Impressoras: nenhuma listada pelo sistema + .printers-count = Impressoras (__ocs_fmt_0__): + .could-not-list-printers-error = Não foi possível listar as impressoras: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = As plotagens passam pelo aplicativo de PDF: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = As plotagens vão direto para a impressora (nenhum aplicativo de PDF registra um comando de impressão). + .via-the-pdf-application = pelo aplicativo de PDF + .direct-print = impressão direta .y-mm = Y (mm) manage = @@ -4773,6 +5340,19 @@ ui = .what-s-new = Novidades .width-value = Largura: __ocs_arg_value__ .working = Processando… + .recent-input = Entradas recentes + .copy-with-base-point = Copiar com ponto base + .undo-label = Desfazer __ocs_arg_label__ + .redo-label = Refazer __ocs_arg_label__ + .copy-selection = Copiar seleção + .isolate = Isolar + .deselect-all = Desmarcar tudo + .options-ellipsis = Opções... + .base-point = Ponto base + .snap-overrides = Substituições de snap + .mid-between-2-points = Meio entre 2 pontos + .osnap-settings = Configurações de snap... + .snap-override-none-next-pick-only = Substituição de snap: Nenhum (somente o próximo ponto). dimstyle = .keep-text-between-extension-lines = Sempre manter o texto entre as linhas de extensão diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 6dfa348e3..9414c0b99 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Применение по умолчанию .iced-theme = Тема ICED: .theme = Тема + .right-click-customization = Настройка правой кнопки мыши + .right-click-in-drawing-area = Правая кнопка в области чертежа + .hold-duration = Длительность удержания + .right-click-mode-help = Контекстное меню: правая кнопка всегда открывает меню. С учётом времени: короткий щелчок — Enter, удержание открывает меню (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Контекстное меню + .right-click-mode-time-sensitive = С учётом времени (короткий щелчок = Enter) + .right-click-mode-enter-first = Сначала Enter, второй щелчок = меню command-line = .ready = Open CAD Studio готов. @@ -246,6 +253,7 @@ command-move = *[many] { $count } объектов }]: .target = MOVE Укажите точку назначения [база { $x },{ $y }]: + .displacement = MOVE Укажите перемещение: command-copy = .array-count = COPY Введите число элементов массива: @@ -262,6 +270,8 @@ command-copy = [few] создано { $count } копии *[many] создано { $count } копий } | Массив | Enter=готово | база { $x },{ $y }]: + .displacement = COPY Укажите перемещение: + .mode = COPY Задайте режим копирования [Single/Multiple] : common = .jogged-radius = Радиус с изломом @@ -1032,8 +1042,8 @@ common = .loft-surface-created = LOFT: поверхность создана. .loft-solid-created = LOFT: твердое создание. .attsync-no-editable-block-references = ATTSYNC: нет редактируемых вхождений блоков. - .xref-reloaded-with-repairs = XREF Перезагружено с исправлениями: "__ocs_fmt_0__" - .xref-reload-failed = XREF Не удалось перезагрузить: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reloaded-with-repairs = XREF Перезагружен с исправлениями: "__ocs_fmt_0__" + .xref-reload-failed = XREF Ошибка перезагрузки: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = Предыдущий вид .zoom-no-previous-view = ZOOM: предыдущего вида нет. .zoom-object = Масштаб по объекту @@ -1121,8 +1131,8 @@ common = .dwg-dxf-files = { common.files } (DWG/DXF) .inserting-from-file = Вставка "__ocs_fmt_0__" из файла. .insert-file = INSERT FILE: __ocs_fmt_0__ - .xref-recovered-with-warnings = XREF Восстановлено с предупреждениями: "__ocs_fmt_0__" - .xref-recovery-failed = XREF Не удалось восстановить: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-recovered-with-warnings = XREF Восстановлен с предупреждениями: "__ocs_fmt_0__" + .xref-recovery-failed = XREF Ошибка восстановления: "__ocs_fmt_0__" (__ocs_fmt_1__) .select-at-least-one-layout = Выберите хотя бы один лист. .printing-is-not-available-in-the-web-version = Печать недоступна в веб-версии. .sending-selected-layouts-to-the-system-printer = Отправка выбранных листов на системный принтер… @@ -1618,7 +1628,7 @@ common = .create-triangular-face = Создать треугольную грань .location = { common.position } .creation-mode = Режим создания - .path = Траектория + .path = Путь: .expression = Выражение .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1871,6 +1881,391 @@ common = .dimensional = Размерные .show-hide = Показать/Скрыть + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Спасти предостережение .unable-to-save-drawing = Не удалось сохранить чертёж @@ -2442,6 +2837,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Удалить + .pline-specify-starting-width = PLINE Укажите начальную ширину <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE Укажите конечную ширину <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE Укажите начальную полуширину <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE Укажите конечную полуширину <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (Линия) Укажите следующую точку: + .pline-arc-specify-endpoint-of-arc = PLINE (Дуга) Укажите конечную точку дуги: + .pline-specify-length-of-line = PLINE Укажите длину линии: + .pline-specify-included-angle = PLINE Укажите центральный угол: + .pline-specify-endpoint-of-arc = PLINE Укажите конечную точку дуги: + .pline-specify-center-point-of-arc = PLINE Укажите центр дуги: + .pline-specify-radius-of-arc = PLINE Укажите радиус дуги: + .pline-specify-direction-of-chord-for-arc = PLINE Укажите направление хорды дуги: + .pline-specify-length-of-chord = PLINE Укажите длину хорды: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Укажите направление касательной в начальной точке дуги: + .pline-specify-second-point-on-arc = PLINE Укажите вторую точку дуги: + .halfwidth = Полуширина + .center-keyword = Центр + .close-keyword = Замкнуть + .second-pt = Вторая точка + .arc-specify-start-point-of-arc = ARC Укажите начальную точку дуги: + .arc-specify-second-point-of-arc = ARC Укажите вторую точку дуги: + .arc-specify-end-point-of-arc = ARC Укажите конечную точку дуги: + .arc-specify-center-point-of-arc = ARC Укажите центр дуги: + .arc-specify-included-angle = ARC Укажите центральный угол: + .arc-specify-length-of-chord = ARC Укажите длину хорды: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Укажите направление касательной в начальной точке дуги: + .arc-specify-radius-of-arc = ARC Укажите радиус дуги: + .chord-length = Длина хорды modify = .add-vertex = Добавить вершину @@ -2594,6 +3017,13 @@ modify = .tol-scale-dimtfac = Масштаб допуска (DIMTFAC) .trim = Усечь .exit = ЭКСИТ + .area-keyword = Площадь + .displacement = Перемещение + .mode-keyword = Режим + .array-keyword = Массив + .use-first-point-as-displacement = Первая точка как перемещение + .offset-erase-source-object-after-offsetting = OFFSET Удалить исходный объект после смещения? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Слой для смещённых объектов [Current/Source] : groups = .group-enter-group-name-name = GROUP Введите название группы [__ocs_arg_name__]: @@ -3126,6 +3556,93 @@ insert = .xref-external-references = XREF Внешние ссылки: .xref-no-external-references-in-this-drawing = XREF В этом чертеже нет внешних ссылок. .xref-reloaded = XREF Перезагрузка "__ocs_fmt_0__" + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" установлен на Наложение. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" установлен на Присоединение. + .xref-cannot-unload-nested-reference = XREF: невозможно выгрузить вложенную ссылку '__ocs_fmt_0__'. Выгрузите ее в основном чертеже. + .xref-cannot-overlay-nested-reference = XREF: невозможно наложить вложенную ссылку '__ocs_fmt_0__'. Наложите ее в основном чертеже. + .xref-cannot-reload-nested-reference = XREF: невозможно перезагрузить вложенную ссылку '__ocs_fmt_0__'. Перезагрузите ее в основном чертеже. + .xref-cannot-attach-nested-reference = XREF: невозможно присоединить вложенную ссылку '__ocs_fmt_0__'. Присоедините ее в основном чертеже. + .xref-cannot-set-path-type-nested-reference = XREF: невозможно установить тип пути для вложенной ссылки '__ocs_fmt_0__'. Установите его в основном чертеже. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: перезагрузка применяется только к ссылкам на чертежи. + .xref-bound = XREF: включен "__ocs_fmt_0__". + .xref-bound-with-unremapped-handles = XREF: включен "__ocs_fmt_0__" с __ocs_fmt_1__ непереопределенными стилевыми маркерами (см. ограничения включения). + .xref-cannot-bind-nested-reference = XREF: невозможно включить вложенную ссылку '__ocs_fmt_0__'. Включите ее в основном чертеже. + .xref-cannot-bind = XREF: невозможно включить "__ocs_fmt_0__": __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: Включение PDF (векторный импорт) недоступно в этой версии. + .xref-cannot-detach-nested-reference = XREF: невозможно отсоединить вложенную ссылку '__ocs_fmt_0__'. Отсоедините ее в основном чертеже. + .xref-cannot-make-path-relative = XREF: невозможно сделать путь относительным между дисками. + .xref-detached = XREF: отсоединен "__ocs_fmt_0__". + .xref-no-loaded-reference-with-that-key = XREF: нет загруженной ссылки с этим ключом. + .xref-no-references-match = XREF: нет ссылок, соответствующих '__ocs_fmt_0__'. + .xref-manager-title = Внешние ссылки + .xref-self-attach-blocked = XATTACH: нельзя присоединить основной чертеж к самому себе. + .xref-overlays-drawing-references-only = XREF: наложения применяются только к ссылкам на чертежи. + .xref-path-set-for = XREF: Путь установлен для "__ocs_fmt_0__" — Перезагрузите для применения. + .xref-unloaded = XREF: выгружен "__ocs_fmt_0__". + .xref-unknown-option = XREF: неизвестная опция '__ocs_fmt_0__'. Опции: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: обновлено __ocs_fmt_0__ ссылок(и). + .usage-xref-path-pattern = Использование: XREF Path + .usage-xref-path-find = Использование: XREF Path Find + .usage-xref-pathtype = Использование: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ ссылка(и) не найдены — откройте диспетчер ссылок с помощью EXTERNALREFERENCES. + .xref-nested-not-rendered = (вложенная — не отображается) + .change-path = Изменить путь: + .date = Дата + .details = Подробности + .xref-detach = Отсоединить + .failed = Сбой + .xref-file-attach-is-not-available-on-web = Присоединение файла недоступно в веб-версии — список ссылок ниже доступен только для чтения. + .xref-file-name = Имя файла + .found-at = Найдено в + .xref-help = Справка + .xref-nested-references-are-read-only = Вложенные ссылки доступны только для чтения — редактируйте их в основном чертеже. + .not-found = Не найдено + .orphaned = Изолированный + .unreferenced = Неиспользуемый + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = Открыт + .xref-untitled = Без названия + .xref-unresolved = Неразрешенный + .xref-preview-not-available = Предпросмотр недоступен + .xref-reload-all = Перезагрузить все + .xref-dwg = DWG + .xref-pixel-width = Ширина в пикселях + .xref-pixel-height = Высота в пикселях + .xref-resolution-unit = Единица разрешения + .xref-overlay = Наложение + .xref-reference-manager-select-rows-bind = Диспетчер ссылок — выберите строки, затем Отсоединить, Выгрузить, Перезагрузить, Включить, Наложить или режим изменения пути. + .xref-bind = Включить + .xref-refresh = Обновить + .relative = Относительный + .xref-reload = Перезагрузить + .xref-replace = Заменить: + .saved-path = Сохраненный путь + .xref-select-a-reference-first = Сначала выберите ссылку. + .xref-select-a-reference-to-inspect-its = Выберите ссылку для просмотра ее деталей + .xref-set-path = Установить путь + .stale = Устаревший + .toggle-list-tree = Переключить список/дерево + .tree = Дерево + .xref-type-a-new-path-first = Сначала введите новый путь. + .xref-type-the-path-prefix-to-find-first = Сначала введите префикс пути для поиска. + .xref-unload = Выгрузить + .unloaded = Выгружен + .xref-new-path-for-selection = новый путь для выбора + .xref-new-path-prefix = новый префикс пути + .xref-old-path-prefix = старый префикс пути + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF Сначала сохраните чертёж, чтобы определить относительные пути внешних ссылок. .object-s-copied-to-clipboard-base-3-3 = В буфер обмена скопировано объектов: __ocs_fmt_0__ (базовая точка: __ocs_fmt_1__, __ocs_fmt_2__). .object-s-copied-to-clipboard = В буфер обмена скопировано объектов: __ocs_fmt_0__. @@ -3225,6 +3742,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Выберите линию, луч или вспомогательную прямую в качестве оси: .revolve-select-objects-or-mode = REVOLVE Выберите объекты для вращения или [Режим] (Enter — завершить): + .path = Путь: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Создание .boolean = Булевы операции @@ -4535,6 +5059,19 @@ plot = .export-pdf = Экспорт PDF .fit-to-paper = Вписать в лист .layout-plots-the-current-sheet-using-the-selec = Лист печатается на выбранном формате бумаги. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Слияние пересекающихся линий .object-lineweights = Весы для объектов .orientation = Ориентация @@ -4552,6 +5089,8 @@ plot = .plot-style-table-pen-assignments = Стол стиля сюжета (пен-задания) .plot-transparency = Прозрачность сюжета .plot-upside-down = Заговор вверх ногами + .named-plot-style-tables-stb-are-not-supported-yet-name = Именованные таблицы стилей печати (.stb) пока не поддерживаются: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ не найден в папке стилей печати (__ocs_fmt_1__). .preview = Предпросмотр .printer-plotter = Принтер / Плоттер .properties = Свойства... @@ -4563,6 +5102,34 @@ plot = .system-default-printer = Системный принтер по умолчанию .what-to-plot = Что спланировать .x-mm = X (мм) + .select-drawing-to-import-page-setups-from = Выберите чертёж для импорта наборов параметров листа + .drawings-and-templates = Чертежи и шаблоны + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: импортировано наборов: __ocs_fmt_0__, заменено: __ocs_fmt_1__. + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: набор параметров листа «__ocs_fmt_0__» не найден в __ocs_fmt_1__. + .psetupin-nothing-to-import-from-file = PSETUPIN: нечего импортировать из __ocs_fmt_0__. + .no-named-page-setups-in-this-drawing = В этом чертеже нет именованных наборов параметров листа. + .import-ellipsis = Импорт… + .show-the-page-setup-for-new-layouts = Показывать параметры листа для новых листов + .x-in = X (дюймы) + .y-in = Y (дюймы) + .hide-paperspace-objects = Скрыть объекты пространства листа + .save-changes-to-layout = Сохранить изменения в листе + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ не поддерживает __ocs_fmt_1__; выбран формат по умолчанию __ocs_fmt_2__ (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ не поддерживает __ocs_fmt_1__; формат сохранён (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: принтер не сообщает форматов; используется каталог бумаги. + .printer-count-sheet-s-reported = __ocs_fmt_0__: сообщено форматов: __ocs_fmt_1__ + .default-sheet-sheet = Формат по умолчанию: __ocs_fmt_0__ + .default-sheet-not-reported = Формат по умолчанию: не сообщён + .default-printer-name = Принтер по умолчанию: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Принтер по умолчанию: система не сообщает ни об одном + .printers-none-listed-by-the-system = Принтеры: система не перечисляет ни одного + .printers-count = Принтеры (__ocs_fmt_0__): + .could-not-list-printers-error = Не удалось получить список принтеров: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = Печать идёт через приложение PDF: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = Печать идёт напрямую на принтер (ни одно приложение PDF не регистрирует команду печати). + .via-the-pdf-application = через приложение PDF + .direct-print = прямая печать .y-mm = Y (мм) manage = @@ -4774,6 +5341,19 @@ ui = .what-s-new = Что нового .width-value = Ширина: __ocs_arg_value__ .working = Работать... + .recent-input = Недавний ввод + .copy-with-base-point = Копировать с базовой точкой + .undo-label = Отменить __ocs_arg_label__ + .redo-label = Повторить __ocs_arg_label__ + .copy-selection = Копировать выбранное + .isolate = Изолировать + .deselect-all = Снять выделение + .options-ellipsis = Параметры... + .base-point = Базовая точка + .snap-overrides = Разовые привязки + .mid-between-2-points = Середина между 2 точками + .osnap-settings = Настройки привязки... + .snap-override-none-next-pick-only = Разовая привязка: Нет (только следующая точка). dimstyle = .keep-text-between-extension-lines = Всегда размещать текст между выносными линиями diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index f0538ada9..698cb575a 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = Varsayılan Uygulama .iced-theme = Iced teması: .theme = Tema + .right-click-customization = Sağ tık özelleştirme + .right-click-in-drawing-area = Çizim alanında sağ tık + .hold-duration = Basılı tutma süresi + .right-click-mode-help = Kısayol menüsü: sağ tık her zaman menüyü açar. Zamana duyarlı: kısa tık Enter, uzun basma menüyü açar (SHORTCUTMENUDURATION). + .right-click-mode-shortcut-menu = Kısayol menüsü + .right-click-mode-time-sensitive = Zamana duyarlı (kısa tık = Enter) + .right-click-mode-enter-first = Önce Enter, ikinci tık = menü command-line = .ready = Open CAD Studio hazır. @@ -238,12 +245,15 @@ area = command-move = .base = MOVE Temel noktayı belirtin [{ $count } nesne]: .target = MOVE Hedef noktayı belirtin [temel { $x },{ $y }]: + .displacement = MOVE Yer değiştirmeyi belirtin: command-copy = .array-count = COPY Dizideki öğe sayısını girin: .base = COPY Temel noktayı belirtin [{ $count } nesne]: .array-target = COPY { $count } öğeli dizi için ikinci noktayı belirtin [temel { $x },{ $y }]: .target = COPY Hedef noktayı belirtin [şimdiye kadar { $count } kopya | Dizi | Enter=tamam | temel { $x },{ $y }]: + .displacement = COPY Yer değiştirmeyi belirtin: + .mode = COPY Kopyalama modu seçin [Single/Multiple] : common = .none-use-shortcuts-set-key-command = (none – use: SHORTCUTS SET ) @@ -1015,8 +1025,8 @@ common = .loft-surface-created = LOFT: yüzey oluşturuldu. .loft-solid-created = LOFT: Katı oluşturuldu. .attsync-no-editable-block-references = ATTSYNC: düzenlenebilir blok referansı yok. - .xref-reloaded-with-repairs = XREF Onarılarak yeniden yüklendi: "__ocs_fmt_0__" - .xref-reload-failed = XREF Yeniden yüklenemedi: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reloaded-with-repairs = XREF Onarımlarla yeniden yüklendi: "__ocs_fmt_0__" + .xref-reload-failed = XREF Yeniden yükleme başarısız: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = Önceki görünüme yakınlaştır .zoom-no-previous-view = ZOOM: önceki görünüm yok. .zoom-object = Nesneye yakınlaştır @@ -1105,7 +1115,7 @@ common = .inserting-from-file = Dosyadan "__ocs_fmt_0__" ekleniyor. .insert-file = INSERT FILE: __ocs_fmt_0__ .xref-recovered-with-warnings = XREF Uyarılarla kurtarıldı: "__ocs_fmt_0__" - .xref-recovery-failed = XREF Kurtarılamadı: "__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-recovery-failed = XREF Kurtarma başarısız: "__ocs_fmt_0__" (__ocs_fmt_1__) .select-at-least-one-layout = En az bir pafta seçin. .printing-is-not-available-in-the-web-version = Web sürümünde yazdırma kullanılamıyor. .sending-selected-layouts-to-the-system-printer = Seçilen paftalar sistem yazıcısına gönderiliyor… @@ -1601,7 +1611,7 @@ common = .create-triangular-face = Üçgen yüz oluştur .location = { common.position } .creation-mode = Oluşturma modu - .path = Yol + .path = Yol: .expression = İfade .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1852,6 +1862,391 @@ common = .dimensional = Boyutsal .show-hide = Göster/Gizle + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = Kaydetme Uyarısı .unable-to-save-drawing = Çizim Kaydedilemiyor @@ -2421,6 +2816,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = Kaldır + .pline-specify-starting-width = PLINE Başlangıç genişliğini belirtin <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE Bitiş genişliğini belirtin <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE Başlangıç yarı genişliğini belirtin <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE Bitiş yarı genişliğini belirtin <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (Çizgi) Sonraki noktayı belirtin: + .pline-arc-specify-endpoint-of-arc = PLINE (Yay) Yayın bitiş noktasını belirtin: + .pline-specify-length-of-line = PLINE Çizgi uzunluğunu belirtin: + .pline-specify-included-angle = PLINE İç açıyı belirtin: + .pline-specify-endpoint-of-arc = PLINE Yayın bitiş noktasını belirtin: + .pline-specify-center-point-of-arc = PLINE Yayın merkez noktasını belirtin: + .pline-specify-radius-of-arc = PLINE Yayın yarıçapını belirtin: + .pline-specify-direction-of-chord-for-arc = PLINE Yay kirişinin yönünü belirtin: + .pline-specify-length-of-chord = PLINE Kiriş uzunluğunu belirtin: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE Yayın başlangıç noktasındaki teğet yönünü belirtin: + .pline-specify-second-point-on-arc = PLINE Yay üzerindeki ikinci noktayı belirtin: + .halfwidth = Yarı genişlik + .center-keyword = Merkez + .close-keyword = Kapat + .second-pt = İkinci nokta + .arc-specify-start-point-of-arc = ARC Yayın başlangıç noktasını belirtin: + .arc-specify-second-point-of-arc = ARC Yayın ikinci noktasını belirtin: + .arc-specify-end-point-of-arc = ARC Yayın bitiş noktasını belirtin: + .arc-specify-center-point-of-arc = ARC Yayın merkez noktasını belirtin: + .arc-specify-included-angle = ARC İç açıyı belirtin: + .arc-specify-length-of-chord = ARC Kiriş uzunluğunu belirtin: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC Yayın başlangıç noktasındaki teğet yönünü belirtin: + .arc-specify-radius-of-arc = ARC Yayın yarıçapını belirtin: + .chord-length = Kiriş uzunluğu modify = .count-objects = (__ocs_arg_count__ nesne) @@ -2573,6 +2996,13 @@ modify = .tol-scale-dimtfac = Tolerans ölçeği (DIMTFAC) .trim = Kırp .exit = Çık + .area-keyword = Alan + .displacement = Yer değiştirme + .mode-keyword = Mod + .array-keyword = Dizi + .use-first-point-as-displacement = İlk noktayı yer değiştirme olarak kullan + .offset-erase-source-object-after-offsetting = OFFSET Ötelemeden sonra kaynak nesne silinsin mi? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET Ötelenen nesnelerin katmanı [Current/Source] : groups = .group-enter-group-name-name = GROUP Grup adını girin [__ocs_arg_name__]: @@ -3092,6 +3522,93 @@ insert = .xref-external-references = XREF Dış referanslar: .xref-no-external-references-in-this-drawing = XREF Bu çizimde dış referans yok. .xref-reloaded = XREF "__ocs_fmt_0__" yeniden yüklendi + .xref-set-to-overlay = XREF: "__ocs_fmt_0__" Üzerine Ekle olarak ayarlandı. + .xref-set-to-attach = XREF: "__ocs_fmt_0__" Ekle olarak ayarlandı. + .xref-cannot-unload-nested-reference = XREF: iç içe referans '__ocs_fmt_0__' kaldırılamaz. Ana çiziminde kaldırın. + .xref-cannot-overlay-nested-reference = XREF: iç içe referans '__ocs_fmt_0__' üzerine eklenemez. Ana çiziminde üzerine ekleyin. + .xref-cannot-reload-nested-reference = XREF: iç içe referans '__ocs_fmt_0__' yeniden yüklenemez. Ana çiziminde yeniden yükleyin. + .xref-cannot-attach-nested-reference = XREF: iç içe referans '__ocs_fmt_0__' eklenemez. Ana çiziminde ekleyin. + .xref-cannot-set-path-type-nested-reference = XREF: iç içe referans '__ocs_fmt_0__' için yol türü ayarlanamaz. Ana çiziminde ayarlayın. + .reload-applies-to-drawing-references-only = __ocs_fmt_0__: yeniden yükleme yalnızca çizim referansları için geçerlidir. + .xref-bound = XREF: "__ocs_fmt_0__" bağlandı. + .xref-bound-with-unremapped-handles = XREF: "__ocs_fmt_0__" bağlandı, __ocs_fmt_1__ yeniden eşlenmemiş stil tutamaçları (bağlama sınırlamalarına bakın). + .xref-cannot-bind-nested-reference = XREF: iç içe referans '__ocs_fmt_0__' bağlanamaz. Ana çiziminde bağlayın. + .xref-cannot-bind = XREF: "__ocs_fmt_0__" bağlanamıyor: __ocs_fmt_1__. + .xref-pdf-bind-unavailable = XREF: PDF bağlama (vektör içe aktarma) bu sürümde mevcut değil. + .xref-cannot-detach-nested-reference = XREF: iç içe referans '__ocs_fmt_0__' ayrılamaz. Ana çiziminde ayırın. + .xref-cannot-make-path-relative = XREF: sürücüler arasında yol göreceli yapılamaz. + .xref-detached = XREF: "__ocs_fmt_0__" ayrıldı. + .xref-no-loaded-reference-with-that-key = XREF: bu anahtara sahip yüklenmiş referans yok. + .xref-no-references-match = XREF: '__ocs_fmt_0__' ile eşleşen referans yok. + .xref-manager-title = Harici Referanslar + .xref-self-attach-blocked = XATTACH: ana çizim kendine eklenemez. + .xref-overlays-drawing-references-only = XREF: üzerine eklemeler yalnızca çizim referansları için geçerlidir. + .xref-path-set-for = XREF: "__ocs_fmt_0__" için yol ayarlandı — uygulamak için Yeniden Yükleyin. + .xref-unloaded = XREF: "__ocs_fmt_0__" kaldırıldı. + .xref-unknown-option = XREF: bilinmeyen seçenek '__ocs_fmt_0__'. Seçenekler: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: __ocs_fmt_0__ referans güncellendi. + .usage-xref-path-pattern = Kullanım: XREF Path + .usage-xref-path-find = Kullanım: XREF Path Find + .usage-xref-pathtype = Kullanım: XREF Pathtype [pattern] + .xref-missing-open-xrefman = __ocs_fmt_0__ referans bulunamadı — referans yöneticisini EXTERNALREFERENCES ile açın. + .xref-nested-not-rendered = (iç içe — oluşturulmadı) + .change-path = Yolu değiştir: + .date = Tarih + .details = Ayrıntılar + .xref-detach = Ayır + .failed = Başarısız + .xref-file-attach-is-not-available-on-web = Dosya ekleme web üzerinde mevcut değil — aşağıdaki referans listesi salt okunurdur. + .xref-file-name = Dosya adı + .found-at = Bulunduğu yer + .xref-help = Yardım + .xref-nested-references-are-read-only = İç içe referanslar salt okunurdur — ana çizimlerinde düzenleyin. + .not-found = Bulunamadı + .orphaned = Kimsesiz + .unreferenced = Referanssız + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = Açıldı + .xref-untitled = Başlıksız + .xref-unresolved = Çözümlenmedi + .xref-preview-not-available = Önizleme mevcut değil + .xref-reload-all = Tümünü Yeniden Yükle + .xref-dwg = DWG + .xref-pixel-width = Piksel Genişliği + .xref-pixel-height = Piksel Yüksekliği + .xref-resolution-unit = Çözünürlük Birimi + .xref-overlay = Üzerine ekle + .xref-reference-manager-select-rows-bind = Referans Yöneticisi — satırları seçin, ardından Ayır, Kaldır, Yeniden Yükle, Bağla, Üzerine Ekle veya bir Yol Değiştirme modu. + .xref-bind = Bağla + .xref-refresh = Yenile + .relative = Göreceli + .xref-reload = Yeniden Yükle + .xref-replace = Değiştir: + .saved-path = Kaydedilen yol + .xref-select-a-reference-first = Önce bir referans seçin. + .xref-select-a-reference-to-inspect-its = Ayrıntılarını incelemek için bir referans seçin + .xref-set-path = Yolu Ayarla + .stale = Güncel değil + .toggle-list-tree = Liste/ağaç görünümünü değiştir + .tree = Ağaç + .xref-type-a-new-path-first = Önce yeni bir yol yazın. + .xref-type-the-path-prefix-to-find-first = Önce bulunacak yol önekini yazın. + .xref-unload = Kaldır + .unloaded = Kaldırıldı + .xref-new-path-for-selection = seçim için yeni yol + .xref-new-path-prefix = yeni yol öneki + .xref-old-path-prefix = eski yol öneki + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF Göreli XREF yollarını çözümlemek için önce çizimi kaydedin. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ nesne panoya kopyalandı (taban __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ nesne panoya kopyalandı. @@ -3184,6 +3701,13 @@ insert = .select-at-least-one-guide-curve = { draw.select }: { common.guides } (≥1). .specify-the-requested-path-or-point-or-use-undo = { common.specify }: { common.path } / { common.point } [{ common.undo }]. + .path = Path: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = Oluştur .boolean = Boole işlemleri @@ -4474,6 +4998,19 @@ plot = .export-pdf = PDF'ye Aktar .fit-to-paper = Kağıda sığdır .layout-plots-the-current-sheet-using-the-selec = Yerleşim, geçerli sayfayı seçilen kağıt boyutuyla çizer. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Çakışan çizgileri birleştir .object-lineweights = Nesne çizgi kalınlıkları .orientation = Yönelim @@ -4491,6 +5028,8 @@ plot = .plot-style-table-pen-assignments = Çizim stili tablosu (kalem atamaları) .plot-transparency = Saydamlığı yazdır .plot-upside-down = Ters yazdır + .named-plot-style-tables-stb-are-not-supported-yet-name = Adlandırılmış çizim stili tabloları (.stb) henüz desteklenmiyor: __ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__, çizim stilleri klasöründe (__ocs_fmt_1__) bulunamadı. .preview = Önizleme .printer-plotter = Yazıcı / çizici .properties = Özellikler… @@ -4502,6 +5041,34 @@ plot = .system-default-printer = Varsayılan sistem yazıcısı .what-to-plot = Yazdırılacak Alan .x-mm = X (mm) + .select-drawing-to-import-page-setups-from = Sayfa ayarlarının içe aktarılacağı çizimi seçin + .drawings-and-templates = Çizimler ve şablonlar + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN: __ocs_fmt_0__ sayfa ayarı içe aktarıldı, __ocs_fmt_1__ değiştirildi. + .psetupin-error = PSETUPIN: __ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN: __ocs_fmt_1__ içinde "__ocs_fmt_0__" adlı sayfa ayarı yok. + .psetupin-nothing-to-import-from-file = PSETUPIN: __ocs_fmt_0__ içinden içe aktarılacak bir şey yok. + .no-named-page-setups-in-this-drawing = Bu çizimde adlandırılmış sayfa ayarı yok. + .import-ellipsis = İçe aktar… + .show-the-page-setup-for-new-layouts = Yeni yerleşimler için sayfa ayarını göster + .x-in = X (inç) + .y-in = Y (inç) + .hide-paperspace-objects = Kağıt alanı nesnelerini gizle + .save-changes-to-layout = Değişiklikleri yerleşime kaydet + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__, __ocs_fmt_1__ sayfasını desteklemiyor; varsayılan sayfası __ocs_fmt_2__ seçildi (PAPERUPDATE = 1). + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__, __ocs_fmt_1__ sayfasını desteklemiyor; sayfa korunuyor (PAPERUPDATE = 0). + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__: yazıcı sayfa bildirmiyor; kağıt kataloğu kullanılıyor. + .printer-count-sheet-s-reported = __ocs_fmt_0__: __ocs_fmt_1__ sayfa bildirildi + .default-sheet-sheet = Varsayılan sayfa: __ocs_fmt_0__ + .default-sheet-not-reported = Varsayılan sayfa: bildirilmedi + .default-printer-name = Varsayılan yazıcı: __ocs_fmt_0__ + .default-printer-none-reported-by-the-system = Varsayılan yazıcı: sistem hiçbirini bildirmiyor + .printers-none-listed-by-the-system = Yazıcılar: sistem hiçbirini listelemiyor + .printers-count = Yazıcılar (__ocs_fmt_0__): + .could-not-list-printers-error = Yazıcılar listelenemedi: __ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = Çizimler PDF uygulaması üzerinden gider: __ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = Çizimler doğrudan yazıcıya gider (hiçbir PDF uygulaması yazdırma komutu kaydetmiyor). + .via-the-pdf-application = PDF uygulaması üzerinden + .direct-print = doğrudan yazdırma .y-mm = Y (mm) manage = @@ -4696,6 +5263,19 @@ ui = .what-s-new = Yenilikler .width-value = Genişlik: __ocs_arg_value__ .working = Çalışıyor… + .recent-input = Son girişler + .copy-with-base-point = Taban noktasıyla kopyala + .undo-label = Geri al __ocs_arg_label__ + .redo-label = Yinele __ocs_arg_label__ + .copy-selection = Seçimi kopyala + .isolate = Yalıt + .deselect-all = Seçimi kaldır + .options-ellipsis = Seçenekler... + .base-point = Taban noktası + .snap-overrides = Geçici kenetlemeler + .mid-between-2-points = 2 nokta arası orta + .osnap-settings = Nesne kenetleme ayarları... + .snap-override-none-next-pick-only = Geçici kenetleme: Yok (yalnızca sonraki nokta). dimstyle = .keep-text-between-extension-lines = Metni her zaman uzatma çizgileri arasında tut diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index ba629a629..730334f4b 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = 默认应用程序 .iced-theme = Iced 主题: .theme = 主题 + .right-click-customization = 右键单击自定义 + .right-click-in-drawing-area = 在绘图区域中单击右键 + .hold-duration = 按住时长 + .right-click-mode-help = 快捷菜单:右键单击始终打开菜单。时间敏感:快速单击等于 Enter,长按打开菜单 (SHORTCUTMENUDURATION)。 + .right-click-mode-shortcut-menu = 快捷菜单 + .right-click-mode-time-sensitive = 时间敏感(快速单击 = Enter) + .right-click-mode-enter-first = 先 Enter,第二次单击 = 菜单 command-line = .ready = Open CAD Studio 已就绪。 @@ -240,12 +247,15 @@ area = command-move = .base = MOVE 指定基点 [{ $count } 个对象]: .target = MOVE 指定目标点 [基点 { $x },{ $y }]: + .displacement = MOVE 指定位移: command-copy = .array-count = COPY 输入阵列项目数: .base = COPY 指定基点 [{ $count } 个对象]: .array-target = COPY 为 { $count } 项阵列指定第二点 [基点 { $x },{ $y }]: .target = COPY 指定目标点 [已复制 { $count } 个 | 阵列 | 回车=完成 | 基点 { $x },{ $y }]: + .displacement = COPY 指定位移: + .mode = COPY 输入复制模式选项 [Single/Multiple] : common = .jogged-radius = 折弯半径 @@ -1016,8 +1026,8 @@ common = .loft-surface-created = LOFT:已创建曲面。 .loft-solid-created = LOFT: 已创建固态 。 .attsync-no-editable-block-references = ATTSYNC:没有可编辑的块参照。 - .xref-reloaded-with-repairs = XREF 已修复并重新加载:"__ocs_fmt_0__" - .xref-reload-failed = XREF 重新加载失败:"__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reloaded-with-repairs = XREF 已重新加载(带修复): "__ocs_fmt_0__" + .xref-reload-failed = XREF 重新加载失败: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = 上一个视图 .zoom-no-previous-view = ZOOM:没有上一个视图。 .zoom-object = 缩放到对象 @@ -1105,8 +1115,8 @@ common = .dwg-dxf-files = { common.files } (DWG/DXF) .inserting-from-file = 正在从文件插入 "__ocs_fmt_0__"。 .insert-file = INSERT FILE: __ocs_fmt_0__ - .xref-recovered-with-warnings = XREF 已恢复,但存在警告:"__ocs_fmt_0__" - .xref-recovery-failed = XREF 恢复失败:"__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-recovered-with-warnings = XREF 已恢复(带警告): "__ocs_fmt_0__" + .xref-recovery-failed = XREF 恢复失败: "__ocs_fmt_0__" (__ocs_fmt_1__) .select-at-least-one-layout = 请至少选择一个布局。 .printing-is-not-available-in-the-web-version = 网页版不支持打印。 .sending-selected-layouts-to-the-system-printer = 正在将选定布局发送到系统打印机… @@ -1602,7 +1612,7 @@ common = .create-triangular-face = 创建三角面 .location = { common.position } .creation-mode = 创建模式 - .path = 路径 + .path = 路径: .expression = 表达式 .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1855,6 +1865,391 @@ common = .dimensional = 标注 .show-hide = 显示/隐藏 + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = 保存警告 .unable-to-save-drawing = 无法保存图形 @@ -2426,6 +2821,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = 移除 + .pline-specify-starting-width = PLINE 指定起点宽度 <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE 指定端点宽度 <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE 指定起点半宽 <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE 指定端点半宽 <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (直线) 指定下一点: + .pline-arc-specify-endpoint-of-arc = PLINE (圆弧) 指定圆弧的端点: + .pline-specify-length-of-line = PLINE 指定直线的长度: + .pline-specify-included-angle = PLINE 指定夹角: + .pline-specify-endpoint-of-arc = PLINE 指定圆弧的端点: + .pline-specify-center-point-of-arc = PLINE 指定圆弧的圆心: + .pline-specify-radius-of-arc = PLINE 指定圆弧的半径: + .pline-specify-direction-of-chord-for-arc = PLINE 指定圆弧的弦方向: + .pline-specify-length-of-chord = PLINE 指定弦长: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE 指定圆弧起点的切线方向: + .pline-specify-second-point-on-arc = PLINE 指定圆弧上的第二点: + .halfwidth = 半宽 + .center-keyword = 圆心 + .close-keyword = 闭合 + .second-pt = 第二点 + .arc-specify-start-point-of-arc = ARC 指定圆弧的起点: + .arc-specify-second-point-of-arc = ARC 指定圆弧的第二点: + .arc-specify-end-point-of-arc = ARC 指定圆弧的端点: + .arc-specify-center-point-of-arc = ARC 指定圆弧的圆心: + .arc-specify-included-angle = ARC 指定夹角: + .arc-specify-length-of-chord = ARC 指定弦长: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC 指定圆弧起点的切线方向: + .arc-specify-radius-of-arc = ARC 指定圆弧的半径: + .chord-length = 弦长 modify = .add-vertex = 添加顶点 @@ -2578,6 +3001,13 @@ modify = .tol-scale-dimtfac = 公差比例 (DIMTFAC) .trim = 修剪 .exit = 退出 + .area-keyword = 面积 + .displacement = 位移 + .mode-keyword = 模式 + .array-keyword = 阵列 + .use-first-point-as-displacement = 将第一点用作位移 + .offset-erase-source-object-after-offsetting = OFFSET 偏移后是否删除源对象? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET 输入偏移对象的图层选项 [Current/Source] : groups = .group-enter-group-name-name = GROUP 输入组名 [__ocs_arg_name__]: @@ -3108,6 +3538,93 @@ insert = .xref-external-references = XREF 外部参考文献 : .xref-no-external-references-in-this-drawing = XREF 此绘图中没有外部引用 。 .xref-reloaded = XREF 重新装入了 “ __ocs_fmt_0__ ” + .xref-set-to-overlay = XREF: 已将 "__ocs_fmt_0__" 设置为覆盖。 + .xref-set-to-attach = XREF: 已将 "__ocs_fmt_0__" 设置为附着。 + .xref-cannot-unload-nested-reference = XREF: 无法卸载嵌套参照 '__ocs_fmt_0__'。请在宿主图形中卸载它。 + .xref-cannot-overlay-nested-reference = XREF: 无法覆盖嵌套参照 '__ocs_fmt_0__'。请在宿主图形中覆盖它。 + .xref-cannot-reload-nested-reference = XREF: 无法重新加载嵌套参照 '__ocs_fmt_0__'。请在宿主图形中重新加载它。 + .xref-cannot-attach-nested-reference = XREF: 无法附着嵌套参照 '__ocs_fmt_0__'。请在宿主图形中附着它。 + .xref-cannot-set-path-type-nested-reference = XREF: 无法为嵌套参照 '__ocs_fmt_0__' 设置路径类型。请在宿主图形中设置它。 + .reload-applies-to-drawing-references-only = __ocs_fmt_0__:重新加载仅适用于图形参照。 + .xref-bound = XREF: 已绑定 "__ocs_fmt_0__"。 + .xref-bound-with-unremapped-handles = XREF: 已绑定 "__ocs_fmt_0__",包含 __ocs_fmt_1__ 个未重新映射的样式手柄(参见绑定限制)。 + .xref-cannot-bind-nested-reference = XREF: 无法绑定嵌套参照 '__ocs_fmt_0__'。请在宿主图形中绑定它。 + .xref-cannot-bind = XREF: 无法绑定 "__ocs_fmt_0__":__ocs_fmt_1__。 + .xref-pdf-bind-unavailable = XREF: PDF 绑定(矢量导入)在此版本中不可用。 + .xref-cannot-detach-nested-reference = XREF: 无法拆离嵌套参照 '__ocs_fmt_0__'。请在宿主图形中拆离它。 + .xref-cannot-make-path-relative = XREF: 无法在驱动器之间设置相对路径。 + .xref-detached = XREF: 已拆离 "__ocs_fmt_0__"。 + .xref-no-loaded-reference-with-that-key = XREF: 没有该键的已加载参照。 + .xref-no-references-match = XREF: 没有与 '__ocs_fmt_0__' 匹配的参照。 + .xref-manager-title = 外部参照 + .xref-self-attach-blocked = XATTACH: 不能将宿主图形附着到自身。 + .xref-overlays-drawing-references-only = XREF: 覆盖仅适用于图形参照。 + .xref-path-set-for = XREF: 已为 "__ocs_fmt_0__" 设置路径 — 请重新加载以应用。 + .xref-unloaded = XREF: 已卸载 "__ocs_fmt_0__"。 + .xref-unknown-option = XREF: 未知选项 '__ocs_fmt_0__'。选项: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: 已更新 __ocs_fmt_0__ 个参照。 + .usage-xref-path-pattern = 用法: XREF Path + .usage-xref-path-find = 用法: XREF Path Find + .usage-xref-pathtype = 用法: XREF Pathtype [pattern] + .xref-missing-open-xrefman = 未找到 __ocs_fmt_0__ 个参照 — 请使用 EXTERNALREFERENCES 打开参照管理器。 + .xref-nested-not-rendered = (嵌套 — 未渲染) + .change-path = 更改路径: + .date = 日期 + .details = 详细信息 + .xref-detach = 拆离 + .failed = 失败 + .xref-file-attach-is-not-available-on-web = 文件附着在网页版中不可用 — 下面的参照列表为只读。 + .xref-file-name = 文件名 + .found-at = 位于 + .xref-help = 帮助 + .xref-nested-references-are-read-only = 嵌套参照为只读 — 请在宿主图形中编辑它们。 + .not-found = 未找到 + .orphaned = 孤立 + .unreferenced = 未参照 + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = 已打开 + .xref-untitled = 无标题 + .xref-unresolved = 未解析 + .xref-preview-not-available = 预览不可用 + .xref-reload-all = 全部重新加载 + .xref-dwg = DWG + .xref-pixel-width = 像素宽度 + .xref-pixel-height = 像素高度 + .xref-resolution-unit = 分辨率单位 + .xref-overlay = 覆盖 + .xref-reference-manager-select-rows-bind = 参照管理器 — 选择行,然后执行拆离、卸载、重新加载、绑定、覆盖或路径更改模式。 + .xref-bind = 绑定 + .xref-refresh = 刷新 + .relative = 相对 + .xref-reload = 重新加载 + .xref-replace = 替换: + .saved-path = 保存路径 + .xref-select-a-reference-first = 请先选择一个参照。 + .xref-select-a-reference-to-inspect-its = 选择参照以查看其详细信息 + .xref-set-path = 设置路径 + .stale = 过时 + .toggle-list-tree = 切换列表/树 + .tree = 树 + .xref-type-a-new-path-first = 请先输入新路径。 + .xref-type-the-path-prefix-to-find-first = 请先输入要查找的路径前缀。 + .xref-unload = 卸载 + .unloaded = 卸载 + .xref-new-path-for-selection = 选择的新路径 + .xref-new-path-prefix = 新路径前缀 + .xref-old-path-prefix = 旧路径前缀 + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF 先保存绘图以解析相亲的XREF路径 。 .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ 对象已复制到剪贴板( 基准 __ocs_fmt_1__,__ocs_fmt_2__) 。 .object-s-copied-to-clipboard = __ocs_fmt_0__ 对象被复制到剪贴板。 @@ -3207,6 +3724,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE 选择用作轴的直线、射线或构造线: .revolve-select-objects-or-mode = REVOLVE 选择要旋转的对象或 [模式](按 Enter 完成): + .path = Path: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = 创建 .boolean = 布尔运算 @@ -4517,6 +5041,19 @@ plot = .export-pdf = 导出 PDF .fit-to-paper = 布满图纸 .layout-plots-the-current-sheet-using-the-selec = 布局使用所选纸张尺寸打印当前图纸。 + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = 合并重叠线 .object-lineweights = 对象线宽 .orientation = 方向 @@ -4534,6 +5071,8 @@ plot = .plot-style-table-pen-assignments = 打印样式表(笔指定) .plot-transparency = 打印透明度 .plot-upside-down = 上下颠倒打印 + .named-plot-style-tables-stb-are-not-supported-yet-name = 尚不支持命名打印样式表 (.stb):__ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = 在打印样式文件夹 (__ocs_fmt_1__) 中找不到 __ocs_fmt_0__。 .preview = 预览 .printer-plotter = 打印机/绘图仪 .properties = 特性… @@ -4545,6 +5084,34 @@ plot = .system-default-printer = 系统默认打印机 .what-to-plot = 打印内容 .x-mm = X (毫米) + .select-drawing-to-import-page-setups-from = 选择要从中输入页面设置的图形 + .drawings-and-templates = 图形和样板 + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN:已输入 __ocs_fmt_0__ 个页面设置,替换 __ocs_fmt_1__ 个。 + .psetupin-error = PSETUPIN:__ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN:__ocs_fmt_1__ 中没有名为“__ocs_fmt_0__”的页面设置。 + .psetupin-nothing-to-import-from-file = PSETUPIN:__ocs_fmt_0__ 中没有可输入的内容。 + .no-named-page-setups-in-this-drawing = 此图形中没有命名页面设置。 + .import-ellipsis = 输入… + .show-the-page-setup-for-new-layouts = 为新布局显示页面设置 + .x-in = X(英寸) + .y-in = Y(英寸) + .hide-paperspace-objects = 隐藏图纸空间对象 + .save-changes-to-layout = 将修改保存到布局 + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ 不支持 __ocs_fmt_1__;已切换到其默认图纸 __ocs_fmt_2__(PAPERUPDATE = 1)。 + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ 不支持 __ocs_fmt_1__;保留原图纸(PAPERUPDATE = 0)。 + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__:打印机未报告任何纸张;使用纸张目录。 + .printer-count-sheet-s-reported = __ocs_fmt_0__:报告了 __ocs_fmt_1__ 种纸张 + .default-sheet-sheet = 默认纸张:__ocs_fmt_0__ + .default-sheet-not-reported = 默认纸张:未报告 + .default-printer-name = 默认打印机:__ocs_fmt_0__ + .default-printer-none-reported-by-the-system = 默认打印机:系统未报告 + .printers-none-listed-by-the-system = 打印机:系统未列出任何打印机 + .printers-count = 打印机(__ocs_fmt_0__): + .could-not-list-printers-error = 无法列出打印机:__ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = 打印通过 PDF 应用程序进行:__ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = 打印直接发送到打印机(没有 PDF 应用程序注册打印命令)。 + .via-the-pdf-application = 通过 PDF 应用程序 + .direct-print = 直接打印 .y-mm = Y (毫米) manage = @@ -4756,6 +5323,19 @@ ui = .what-s-new = 更新内容 .width-value = 宽度:__ocs_arg_value__ .working = 正在处理… + .recent-input = 最近的输入 + .copy-with-base-point = 带基点复制 + .undo-label = 放弃 __ocs_arg_label__ + .redo-label = 重做 __ocs_arg_label__ + .copy-selection = 复制选择 + .isolate = 隔离 + .deselect-all = 全部取消选择 + .options-ellipsis = 选项... + .base-point = 基点 + .snap-overrides = 捕捉替代 + .mid-between-2-points = 两点之间的中点 + .osnap-settings = 对象捕捉设置... + .snap-override-none-next-pick-only = 捕捉替代:无(仅下一次拾取)。 dimstyle = .keep-text-between-extension-lines = 始终将文字置于尺寸界线之间 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 86825c1c0..3d9e872d6 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -57,6 +57,13 @@ options = .default-application = 預設應用程式 .iced-theme = Iced 主題: .theme = 主題 + .right-click-customization = 右鍵自訂 + .right-click-in-drawing-area = 在繪圖區域按右鍵 + .hold-duration = 按住時間 + .right-click-mode-help = 快捷功能表:按右鍵一律開啟功能表。時間敏感:快速按一下等於 Enter,長按開啟功能表 (SHORTCUTMENUDURATION)。 + .right-click-mode-shortcut-menu = 快捷功能表 + .right-click-mode-time-sensitive = 時間敏感(快速按一下 = Enter) + .right-click-mode-enter-first = 先 Enter,第二次按一下 = 功能表 command-line = .ready = Open CAD Studio 已就緒。 @@ -244,6 +251,7 @@ command-move = *[other] { $count } 個物件 }]: .target = MOVE 指定目標點 [基點 { $x },{ $y }]: + .displacement = MOVE 指定位移: command-copy = .array-count = COPY 輸入陣列項目數: @@ -258,6 +266,8 @@ command-copy = [one] 目前 { $count } 個複本 *[other] 目前 { $count } 個複本 } | 陣列 | Enter=完成 | 基準點 { $x },{ $y }]: + .displacement = COPY 指定位移: + .mode = COPY 輸入複製模式選項 [Single/Multiple] : common = .jogged-radius = 折彎半徑 @@ -1028,8 +1038,8 @@ common = .loft-surface-created = LOFT:已建立曲面。 .loft-solid-created = LOFT: 已建立實體 。 .attsync-no-editable-block-references = ATTSYNC:沒有可編輯的圖塊參考。 - .xref-reloaded-with-repairs = XREF 已修復並重新載入:"__ocs_fmt_0__" - .xref-reload-failed = XREF 重新載入失敗:"__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-reloaded-with-repairs = XREF 已重新載入(含修復): "__ocs_fmt_0__" + .xref-reload-failed = XREF 重新載入失敗: "__ocs_fmt_0__" (__ocs_fmt_1__) .zoom-previous = 上一個視圖 .zoom-no-previous-view = ZOOM:沒有上一個視圖。 .zoom-object = 縮放至物件 @@ -1117,8 +1127,8 @@ common = .dwg-dxf-files = { common.files } (DWG/DXF) .inserting-from-file = 正在從檔案插入 "__ocs_fmt_0__"。 .insert-file = INSERT FILE: __ocs_fmt_0__ - .xref-recovered-with-warnings = XREF 已復原,但有警告:"__ocs_fmt_0__" - .xref-recovery-failed = XREF 復原失敗:"__ocs_fmt_0__" (__ocs_fmt_1__) + .xref-recovered-with-warnings = XREF 已復原(含警告): "__ocs_fmt_0__" + .xref-recovery-failed = XREF 復原失敗: "__ocs_fmt_0__" (__ocs_fmt_1__) .select-at-least-one-layout = 請至少選取一個配置。 .printing-is-not-available-in-the-web-version = 網頁版不支援列印。 .sending-selected-layouts-to-the-system-printer = 正在將選取的配置傳送至系統印表機… @@ -1614,7 +1624,7 @@ common = .create-triangular-face = 建立三角面 .location = { common.position } .creation-mode = 建立模式 - .path = 路徑 + .path = 路徑: .expression = 表示式 .reference-length = { common.reference }: { common.length } .new-length = { common.new }: { common.length } @@ -1867,6 +1877,391 @@ common = .dimensional = 標註 .show-hide = 顯示/隱藏 + .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. + .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. + .select-a-named-range = Select a named range. + .the-named-range-was-not-found = The named range was not found. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. + .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. + .the-selected-data-source-is-empty = The selected data source is empty. + .the-data-link-no-longer-exists = The data link no longer exists. + .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. + .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .data-source-not-found = Data source not found: __ocs_arg_path__ + .select-at-least-one-property = Select at least one property. + .select-a-data-link = Select a data link. + .the-selected-data-link-no-longer-exists = The selected data link no longer exists. + .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. + .choose-a-spreadsheet-file = Choose a Spreadsheet File + .spreadsheet-files = Spreadsheet Files + .enter-a-data-link-name = Enter a data link name. + .a-data-link-with-that-name-already-exists = A data link with that name already exists. + .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. + .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. + .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. + .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. + .select-at-least-one-object-type = Select at least one object type. + .choose-at-least-one-output = Choose at least one output. + .data-extraction-settings = Data Extraction Settings + .add-drawings = Add Drawings + .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): + .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: + .add-parameter = + Add parameter + .object-snap-3d = 3D Object Snap + .object-snap-on-f4-3d = 3D Object Snap On (F4) + .object-snap-modes-3d = 3D Object Snap modes + .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. + .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. + .a-selected-face-could-not-be-resolved = A selected face could not be resolved. + .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. + .adaptive-grid = Adaptive grid + .add-drawing-folder = Add Drawing Folder + .add-drawings-2 = Add drawings… + .add-folder = Add folder… + .add-parameter-2 = Add parameter + .all-other-rows = All other rows + .allow-selection-cycling = Allow selection cycling + .allow-writing-to-source-file = Allow writing to source file + .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .begin = Begin + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. + .cannot-delete-beyond-this = Cannot delete beyond this. + .cell-range = Cell range + .center-of-face = Center of face + .choose-output = Choose output + .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. + .column-row-settings = Column & row settings + .combine-identical-rows = Combine identical rows + .constraint-settings = Constraint Settings + .constraint-type = Constraint Type + .copied = Copied + .create-a-new-data-link = Create a New Data Link + .create-a-new-data-extraction = Create a new data extraction + .current-drawing = Current drawing + .cursor-dependent-position = Cursor-dependent position + .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: + .dataextraction-external-file-created = DATAEXTRACTION: external file created. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: + .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. + .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). + .dimbreak-select-a-dimension = DIMBREAK: select a dimension. + .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dimedit-select-objects = DIMEDIT Select objects: + .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-jog-added = DIMJOGLINE: jog added. + .dimjogline-jog-removed = DIMJOGLINE: jog removed. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. + .dof = DOF: __ocs_fmt_0__ + .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. + .data-extraction = Data Extraction + .data-extraction-wizard = Data Extraction Wizard + .data-link-manager = Data Link Manager + .data-extraction-2 = Data extraction + .data-link = Data link + .data-link-name = Data link name + .data-rows = Data rows + .define-data-source = Define data source + .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection + .display-selection-cycling-list-box = Display Selection Cycling List Box + .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs + .display-grid-beyond-limits = Display grid beyond Limits + .display-selection-candidate-list-box-on-click = Display selection candidate list box on click + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ + .draw-section = Draw section + .drawing-table = Drawing table + .drawings-folders = Drawings / folders + .dynamic-prompts = Dynamic Prompts + .edit-an-existing-data-extraction = Edit an existing data extraction + .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. + .empty-table = Empty table + .enable-pointer-input-f12 = Enable Pointer Input (F12) + .enable-dimension-input-fields = Enable dimension input fields + .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: + .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: + .entire-sheet = Entire sheet + .equal-x-and-y-spacing = Equal X and Y spacing + .external-file = External file + .extraction-settings = Extraction settings + .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .file = File + .file-not-found-check-the-saved-path = File not found — check the saved path. + .first-row = First row + .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. + .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. + .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. + .formula = Formula + .full-path = Full path + .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom + .grid-on-f7 = Grid On (F7) + .grid-x-spacing = Grid X spacing: + .grid-y-spacing = Grid Y spacing: + .grid-behavior = Grid behavior + .grid-spacing = Grid spacing + .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : + .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). + .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. + .intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type = INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type. + .intersect-the-retained-object-could-not-be-updated = INTERSECT: the retained object could not be updated. + .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. + .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. + .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. + .include-current-drawing = Include current drawing + .include-subfolders = Include subfolders + .increment-angle = Increment angle: + .indicator-fill-color = Indicator Fill Color + .indicator-transparency = Indicator Transparency + .insert-table = Insert Table + .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing + .insert-options = Insert options + .insert-table-after-saving-link = Insert table after saving link + .insertion-behavior = Insertion behavior + .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .knot = Knot + .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: + .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : + .leader-enter-leader-formatting-option-spline-straight-arrow-none-exit = LEADER Enter leader formatting option [Spline/Straight/Arrow/None] : + .leader-enter-next-line-of-annotation-text = LEADER Enter next line of annotation text: + .leader-specify-leader-start-point = LEADER Specify leader start point: + .leader-specify-next-point-or-annotation-format-undo-annotation = LEADER Specify next point or [Annotation/Format/Undo] : + .leader-specify-next-point = LEADER Specify next point: + .lengthen-select-an-object-to-change-or-undo = LENGTHEN Select an object to change or [Undo]: + .lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic = LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]: + .loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] : + .loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only = LOFT Enter an option [Guides/Path/Cross sections only/Settings] : + .loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish = LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish) + .link-option = Link option + .link-settings = Link settings + .live-section-enabled = Live Section Enabled + .ltype-gen = Ltype gen + .measure-0-markers-placed = MEASURE: 0 markers placed. + .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. + .maintain-associativity = Maintain associativity + .major-line-every = Major line every: + .midpoint-on-edge = Midpoint on edge + .moment-of-inertia = Moment of inertia + .more-options = More options + .move-down = Move Down + .move-up = Move Up + .named-parameters = Named Parameters + .named-range = Named range + .nearest-to-face = Nearest to face + .no-path = No path + .no-preview-available = No preview available. + .no-surfaces-selected = No surfaces selected. + .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. + .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. + .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). + .object-snap-on-f3 = Object Snap On (F3) + .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) + .object-snap-modes-2 = Object Snap modes + .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. + .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant + Click to remove one and re-solve + .ortho-mode-f8 = Ortho mode (F8) + .output-data-to-external-file = Output data to external file + .output-file = Output file + .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : + .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: + .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: + .palette-location = Palette Location + .parameter-already-exists = Parameter '__ocs_fmt_0__' already exists. + .parameter = Parameter '__ocs_fmt_0__': __ocs_fmt_1__ + .parameters = Parameters + .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point + .perpendicular-to-face = Perpendicular to face + .plane = Plane + .pointer-input = Pointer Input + .polar-angle-settings = Polar Angle Settings + .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. + .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: + .principal-directions = Principal directions + .principal-moments = Principal moments + .priority = Priority + .product-of-inertia = Product of inertia + .reverse-select-line-polyline-spline-or-helix-to-reverse = REVERSE Select line, polyline, spline or helix to reverse: + .radii-of-gyration = Radii of gyration + .range = Range + .recent-colors = Recent Colors + .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. + .refine-data = Refine data + .relative-path = Relative path + .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope + .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. + .requires-numeric-distance-or-two-points = Requires numeric distance or two points. + .sectionplane-align-section-to-front-back-top-bottom-left-right-top = SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] : + .sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane = SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] : + .sectionplane-specify-next-point-or-enter-to-complete = SECTIONPLANE Specify next point or ENTER to complete: + .sectionplane-specify-next-point = SECTIONPLANE Specify next point: + .sectionplane-specify-point-in-direction-of-section-view = SECTIONPLANE Specify point in direction of section view: + .sectionplane-specify-start-point = SECTIONPLANE Specify start point: + .sectionplane-specify-through-point = SECTIONPLANE Specify through point: + .setbylayer-no-properties-required-changes = SETBYLAYER: no properties required changes. + .setbylayer-reset-entity-entities-to-bylayer = SETBYLAYER: reset __ocs_fmt_0__ entity/entities to ByLayer. + .setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only = SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) + .shell-supports-rectangular-boxes-circular-cylinders-and-spheres = SHELL supports rectangular boxes, circular cylinders, and spheres. + .shell-solid-updated = SHELL: solid updated. + .splinedit-fit-data-add-delete-move-purge-tangents-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] : + .splinedit-fit-data-add-delete-move-purge-exit-exit = SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] : + .splinedit-select-any-open-curves-to-join-to-source = SPLINEDIT Select any open curves to join to source: + .splinedit-specify-a-point-on-the-spline-exit = SPLINEDIT Specify a point on the spline : + .splinedit-specify-control-vertex-to-delete = SPLINEDIT Specify control vertex to delete: + .splinedit-specify-control-vertex = SPLINEDIT Specify control vertex: + .splinedit-specify-end-tangent-or-system-default = SPLINEDIT Specify end tangent or [System default]: + .splinedit-specify-existing-fit-point-on-spline-exit = SPLINEDIT Specify existing fit point on spline : + .splinedit-specify-new-fit-point-to-add-exit = SPLINEDIT Specify new fit point to add : + .splinedit-specify-new-location-or-next-previous-select-point-exit-next = SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] : + .splinedit-specify-new-point-or-after-before-exit = SPLINEDIT Specify new point or [After/Before] : + .splinedit-specify-precision-0-99-10-straight-segments = SPLINEDIT Specify precision 0-99 <10> (straight segments): + .splinedit-specify-start-tangent-or-system-default = SPLINEDIT Specify start tangent or [System default]: + .splinedit-add-delete-elevate-order-move-weight-exit-exit = SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] : + .splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit = SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] : + .subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes = SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] : + .subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter = SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter: + .subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter = SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter: + .subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed = SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed. + .subtract-created-result-object-s = SUBTRACT: created %__ocs_fmt_0__ result object(s). + .subtract-no-compatible-base-and-cutter-types-or-planes-were-selected = SUBTRACT: no compatible base and cutter types or planes were selected. + .subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface = SUBTRACT: select at least one base and one cutter Solid, Region, or Surface. + .subtract-the-retained-base-object-could-not-be-updated = SUBTRACT: the retained base object could not be updated. + .subtract-the-selected-coincident-geometry-is-ambiguous = SUBTRACT: the selected coincident geometry is ambiguous. + .subtract-the-selected-geometry-includes-an-unsupported-surface-intersection = SUBTRACT: the selected geometry includes an unsupported surface intersection. + .subtract-the-selected-topology-could-not-be-cut-safely = SUBTRACT: the selected topology could not be cut safely. + .second-row = Second row + .section-plane = Section Plane + .section-plane-offset = Section Plane Offset + .select-a-3d-solid-or-surface-edge = Select a 3D solid or surface edge. + .select-a-3d-solid = Select a 3D solid. + .select-a-3d-solid-2 = Select a 3D solid: + .select-a-data-link-or-create-a-new-one = Select a data link or create a new one. + .select-an-edge-or-loop-distance = Select an edge or [Loop/Distance]: + .select-edge-of-loop-or-edge-distance = Select edge of loop or [Edge/Distance]: + .select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type = Select face or any point to locate section line or [Draw section/Orthographic/Type]: + .select-faces-to-add-undo-remove-all-done = Select faces to add [Undo/Remove/ALL] : + .select-faces-to-remove-undo-add-all-done = Select faces to remove [Undo/Add/ALL] : + .select-objects-in-the-current-drawing = Select objects in the current drawing + .select-properties = Select properties + .select-spline-fit-polylines = Select spline-fit polylines: + .select-surfaces-to-thicken = Select surfaces to thicken: + .selected-objects-in-current-drawing = Selected objects in current drawing + .set-cell-styles = Set cell styles + .sheet = Sheet + .show-associativity = Show associativity + .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs + .show-count-column = Show count column + .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap + .show-name-column = Show name column + .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers + .slice = Slice + .slice-depth = Slice Depth + .snap-on-f9 = Snap On (F9) + .snap-x-spacing = Snap X spacing: + .snap-y-spacing = Snap Y spacing: + .snap-spacing = Snap spacing + .snap-type = Snap type + .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: + .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: + .specify-insertion-point = Specify insertion point + .specify-point-or-enter-arc-length = Specify point or enter arc length: + .specify-point-or-enter-radius = Specify point or enter radius: + .specify-replacement-path-xref-path-find-old-new = Specify replacement path: XREF Path Find + .specify-second-point = Specify second point: + .specify-the-shell-offset-distance = Specify the shell offset distance <__ocs_fmt_0__>: + .specify-thickness = Specify thickness + .specify-window = Specify window + .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. + .spreadsheet-links = Spreadsheet Links + .start-from-data-extraction = Start from data extraction + .start-from-data-link = Start from data link + .start-from-empty-table = Start from empty table + .state2 = State2 + .static-quadrant-location = Static quadrant location + .status-linked = Status: Linked + .status-source-not-found = Status: Source not found + .surface-associativity = Surface Associativity + .surface-type-2 = Surface Type + .thicken-created-solid-s-surface-s-could-not-be-thickened-exactly = THICKEN: created __ocs_fmt_0__ solid(s); __ocs_fmt_1__ surface(s) could not be thickened exactly. + .tab-separated-text = Tab-separated text + .tangent-objects-must-share-an-intersection-point = Tangent objects must share an intersection point + .tangent-point-must-differ-from-the-fit-endpoint = Tangent point must differ from the fit endpoint. + .the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained = The INTERSECT result could not be displayed completely. The original objects were retained. + .the-intersect-result-could-not-be-encoded-as-acis = The INTERSECT result could not be encoded as ACIS. + .the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained = The SUBTRACT result could not be displayed completely. The original objects were retained. + .the-subtract-result-could-not-be-encoded-as-acis = The SUBTRACT result could not be encoded as ACIS. + .the-union-result-could-not-be-encoded-as-acis = The UNION result could not be encoded as ACIS. + .the-chamfer-history-could-not-be-recorded = The chamfer history could not be recorded. + .the-edge-body-could-not-be-updated = The edge body could not be updated. + .the-edge-body-history-could-not-be-created = The edge body history could not be created. + .the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back = The offset removes all material. Use a smaller distance or add a face back. + .the-region-could-not-be-encoded-as-acis = The region could not be encoded as ACIS. + .the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained = The result could not be displayed completely. The original edge body was retained. + .the-selected-base-face-no-longer-belongs-to-the-body = The selected base face no longer belongs to the body. + .the-selected-drawing-data-is-ready-to-be-extracted = The selected drawing data is ready to be extracted. + .the-selected-object-is-no-longer-available = The selected object is no longer available. + .the-shell-could-not-be-constructed-from-the-selected-solid-and-distance = The shell could not be constructed from the selected solid and distance. + .the-shell-offset-distance-must-be-nonzero = The shell offset distance must be nonzero. + .trimmed-surface = Trimmed surface + .trimming-edges = Trimming edges + .trims = Trims + .type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits = Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits. + .u-isolines-2 = U isolines + .union-created-result-object-s = UNION: created %__ocs_fmt_0__ result object(s). + .union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type = UNION: select at least two solids, Regions, or planar Surfaces of the same type. + .union-the-selected-coincident-geometry-is-ambiguous = UNION: the selected coincident geometry is ambiguous. + .union-the-selected-geometry-includes-an-unsupported-surface-intersection = UNION: the selected geometry includes an unsupported surface intersection. + .union-the-selected-topology-could-not-be-closed-safely = UNION: the selected topology could not be closed safely. + .use-a-previous-extraction-as-a-template = Use a previous extraction as a template + .use-source-formatting = Use source formatting + .used-by = Used by + .v-isolines-2 = V isolines + .value-must-be-nonzero = Value must be nonzero. + .viewing-direction = Viewing Direction + .wipeout-specify-next-point-or-close-undo = WIPEOUT Specify next point or [Close/Undo]: + .wipeout-specify-next-point-or-undo = WIPEOUT Specify next point or [Undo]: + .wipeout-specify-next-point = WIPEOUT Specify next point: + .wireframe-type = Wireframe type + .mtp-specify-first-point-of-mid = _mtp Specify first point of mid: + .mtp-specify-second-point-of-mid = _mtp Specify second point of mid: + .formula-2 = formula + .name = name + .aligned-object-s = __ocs_fmt_0__: aligned __ocs_fmt_1__ object(s). + .select-annotative-objects = __ocs_fmt_0__ Select annotative objects: + .parameter-s-applied = __ocs_fmt_0__ parameter(s) applied. + .row-s-skipped-duplicate-parameter-name = __ocs_fmt_0__ row(s) skipped: duplicate parameter name. + .surface-s-cannot-be-thickened-because-the-offset-intersects-itself = __ocs_fmt_0__ surface(s) cannot be thickened because the offset intersects itself. + .conflicting = ⚠ __ocs_fmt_0__ conflicting errors = .save-warning = 儲存警告 .unable-to-save-drawing = 無法儲存圖形 @@ -2438,6 +2833,34 @@ draw = .wpolygon = WPolygon .cpolygon = CPolygon .remove = 移除 + .pline-specify-starting-width = PLINE 指定起點寬度 <__ocs_arg_w__>: + .pline-specify-ending-width = PLINE 指定端點寬度 <__ocs_arg_w__>: + .pline-specify-starting-half-width = PLINE 指定起點半寬 <__ocs_arg_w__>: + .pline-specify-ending-half-width = PLINE 指定端點半寬 <__ocs_arg_w__>: + .pline-line-specify-next-point = PLINE (直線) 指定下一點: + .pline-arc-specify-endpoint-of-arc = PLINE (弧) 指定弧的端點: + .pline-specify-length-of-line = PLINE 指定直線的長度: + .pline-specify-included-angle = PLINE 指定夾角: + .pline-specify-endpoint-of-arc = PLINE 指定弧的端點: + .pline-specify-center-point-of-arc = PLINE 指定弧的圓心: + .pline-specify-radius-of-arc = PLINE 指定弧的半徑: + .pline-specify-direction-of-chord-for-arc = PLINE 指定弧的弦方向: + .pline-specify-length-of-chord = PLINE 指定弦長: + .pline-specify-tangent-direction-for-the-start-point-of-arc = PLINE 指定弧起點的切線方向: + .pline-specify-second-point-on-arc = PLINE 指定弧上的第二點: + .halfwidth = 半寬 + .center-keyword = 圓心 + .close-keyword = 閉合 + .second-pt = 第二點 + .arc-specify-start-point-of-arc = ARC 指定弧的起點: + .arc-specify-second-point-of-arc = ARC 指定弧的第二點: + .arc-specify-end-point-of-arc = ARC 指定弧的端點: + .arc-specify-center-point-of-arc = ARC 指定弧的圓心: + .arc-specify-included-angle = ARC 指定夾角: + .arc-specify-length-of-chord = ARC 指定弦長: + .arc-specify-tangent-direction-for-the-start-point-of-arc = ARC 指定弧起點的切線方向: + .arc-specify-radius-of-arc = ARC 指定弧的半徑: + .chord-length = 弦長 modify = .add-vertex = 新增頂點 @@ -2590,6 +3013,13 @@ modify = .tol-scale-dimtfac = 公差比例 (DIMTFAC) .trim = 修剪 .exit = 退出 + .area-keyword = 面積 + .displacement = 位移 + .mode-keyword = 模式 + .array-keyword = 陣列 + .use-first-point-as-displacement = 將第一點用作位移 + .offset-erase-source-object-after-offsetting = OFFSET 偏移後是否刪除來源物件? [Yes/No] : + .offset-enter-layer-option-for-offset-objects = OFFSET 輸入偏移物件的圖層選項 [Current/Source] : groups = .group-enter-group-name-name = GROUP 輸入組名 [__ocs_arg_name__]: @@ -3120,6 +3550,93 @@ insert = .xref-external-references = XREF 外部參考文獻 : .xref-no-external-references-in-this-drawing = XREF 此繪圖中沒有外部引用 。 .xref-reloaded = XREF 重新載入了 “ __ocs_fmt_0__ ” + .xref-set-to-overlay = XREF: 已將 "__ocs_fmt_0__" 設定為覆蓋。 + .xref-set-to-attach = XREF: 已將 "__ocs_fmt_0__" 設定為附著。 + .xref-cannot-unload-nested-reference = XREF: 無法卸載巢狀參考 '__ocs_fmt_0__'。請在主控圖面中卸載它。 + .xref-cannot-overlay-nested-reference = XREF: 無法覆蓋巢狀參考 '__ocs_fmt_0__'。請在主控圖面中覆蓋它。 + .xref-cannot-reload-nested-reference = XREF: 無法重新載入巢狀參考 '__ocs_fmt_0__'。請在主控圖面中重新載入它。 + .xref-cannot-attach-nested-reference = XREF: 無法附著巢狀參考 '__ocs_fmt_0__'。請在主控圖面中附著它。 + .xref-cannot-set-path-type-nested-reference = XREF: 無法為巢狀參考 '__ocs_fmt_0__' 設定路徑類型。請在主控圖面中設定它。 + .reload-applies-to-drawing-references-only = __ocs_fmt_0__:重新載入僅適用於圖面參考。 + .xref-bound = XREF: 已繫結 "__ocs_fmt_0__"。 + .xref-bound-with-unremapped-handles = XREF: 已繫結 "__ocs_fmt_0__",包含 __ocs_fmt_1__ 個未重新映射的樣式控點(參見繫結限制)。 + .xref-cannot-bind-nested-reference = XREF: 無法繫結巢狀參考 '__ocs_fmt_0__'。請在主控圖面中繫結它。 + .xref-cannot-bind = XREF: 無法繫結 "__ocs_fmt_0__":__ocs_fmt_1__。 + .xref-pdf-bind-unavailable = XREF: PDF 繫結(向量匯入)在此版本中不可用。 + .xref-cannot-detach-nested-reference = XREF: 無法分離巢狀參考 '__ocs_fmt_0__'。請在主控圖面中分離它。 + .xref-cannot-make-path-relative = XREF: 無法在磁碟機之間設定相對路徑。 + .xref-detached = XREF: 已分離 "__ocs_fmt_0__"。 + .xref-no-loaded-reference-with-that-key = XREF: 沒有該鍵的已載入參考。 + .xref-no-references-match = XREF: 沒有與 '__ocs_fmt_0__' 匹配的參考。 + .xref-manager-title = 外部參考 + .xref-self-attach-blocked = XATTACH: 不能將主控圖面附著到自身。 + .xref-overlays-drawing-references-only = XREF: 覆蓋僅適用於圖面參考。 + .xref-path-set-for = XREF: 已為 "__ocs_fmt_0__" 設定路徑 — 請重新載入以套用。 + .xref-unloaded = XREF: 已卸載 "__ocs_fmt_0__"。 + .xref-unknown-option = XREF: 未知選項 '__ocs_fmt_0__'。選項: ? Reload Unload Detach Path Pathtype Bind Overlay Attach + .xref-updated-references = XREF: 已更新 __ocs_fmt_0__ 個參考。 + .usage-xref-path-pattern = 用法: XREF Path + .usage-xref-path-find = 用法: XREF Path Find + .usage-xref-pathtype = 用法: XREF Pathtype [pattern] + .xref-missing-open-xrefman = 未找到 __ocs_fmt_0__ 個參考 — 請使用 EXTERNALREFERENCES 開啟參考管理員。 + .xref-nested-not-rendered = (巢狀 — 未算繪) + .change-path = 更改路徑: + .date = 日期 + .details = 詳細資訊 + .xref-detach = 分離 + .failed = 失敗 + .xref-file-attach-is-not-available-on-web = 檔案附著在網頁版中不可用 — 下面的參考清單為唯讀。 + .xref-file-name = 檔案名稱 + .found-at = 位於 + .xref-help = 說明 + .xref-nested-references-are-read-only = 巢狀參考為唯讀 — 請在主控圖面中編輯它們。 + .not-found = 未找到 + .orphaned = 孤立 + .unreferenced = 未參考 + .xref-change-path = Change Path + .xref-make-absolute = Make Absolute + .xref-make-relative = Make Relative + .xref-remove-path = Remove Path + .xref-select-new-path = Select New Path + .xref-select-a-single-reference-first = Select a single reference first. + .xref-reload-all-references = Reload All References + .xref-opened = 已開啟 + .xref-untitled = 無標題 + .xref-unresolved = 未解析 + .xref-preview-not-available = 預覽不可用 + .xref-reload-all = 全部重新載入 + .xref-dwg = DWG + .xref-pixel-width = 像素寬度 + .xref-pixel-height = 像素高度 + .xref-resolution-unit = 解析度單位 + .xref-overlay = 覆蓋 + .xref-reference-manager-select-rows-bind = 參考管理員 — 選擇列,然後執行分離、卸載、重新載入、繫結、覆蓋或路徑變更模式。 + .xref-bind = 繫結 + .xref-refresh = 重新整理 + .relative = 相對 + .xref-reload = 重新載入 + .xref-replace = 取代: + .saved-path = 儲存路徑 + .xref-select-a-reference-first = 請先選擇一個參考。 + .xref-select-a-reference-to-inspect-its = 選擇參考以檢視其詳細資訊 + .xref-set-path = 設定路徑 + .stale = 過時 + .toggle-list-tree = 切換清單/樹狀 + .tree = 樹狀 + .xref-type-a-new-path-first = 請先輸入新路徑。 + .xref-type-the-path-prefix-to-find-first = 請先輸入要尋找的路徑前綴。 + .xref-unload = 卸載 + .unloaded = 卸載 + .xref-new-path-for-selection = 選擇的新路徑 + .xref-new-path-prefix = 新路徑前綴 + .xref-old-path-prefix = 舊路徑前綴 + .xref-attach-dwg = Attach DWG + .xref-attach-image = Attach Image + .xref-attach-menu = Attach... + .xref-change-path-type = Change Path Type + .xref-find-and-replace-menu = Find and Replace... + .xref-display-width = Display Width + .xref-display-height = Display Height .xref-save-the-drawing-first-to-resolve-relativ = XREF 先儲存繪圖以解析相親的XREF路徑 。 .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ 物件已複製到剪貼簿( 基準 __ocs_fmt_1__,__ocs_fmt_2__) 。 .object-s-copied-to-clipboard = __ocs_fmt_0__ 物件被複製到剪貼簿。 @@ -3219,6 +3736,13 @@ insert = .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE 選取用作軸的直線、射線或建構線: .revolve-select-objects-or-mode = REVOLVE 選取要旋轉的物件或 [模式](按 Enter 完成): + .path = Path: + .xref-file-references = File References + .xattach = XATTACH + .xattach-specify-rotation-angle-0 = XATTACH Specify rotation angle <0>: + .xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner = XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner): + .xopen-opening = XOPEN: opening "__ocs_fmt_0__". + .xref = XREF: __ocs_fmt_0__ model = .create = 建立 .boolean = 布林運算 @@ -4529,6 +5053,19 @@ plot = .export-pdf = 匯出 PDF .fit-to-paper = 布滿圖紙 .layout-plots-the-current-sheet-using-the-selec = 配置使用所選紙張尺寸列印目前圖紙。 + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = 合併重疊線 .object-lineweights = 物件線寬 .orientation = 方向 @@ -4546,6 +5083,8 @@ plot = .plot-style-table-pen-assignments = 列印樣式表(筆指定) .plot-transparency = 列印透明度 .plot-upside-down = 上下顛倒列印 + .named-plot-style-tables-stb-are-not-supported-yet-name = 尚不支援具名出圖型式表 (.stb):__ocs_fmt_0__ + .name-was-not-found-in-the-plot-styles-folder-folder = 在出圖型式資料夾 (__ocs_fmt_1__) 中找不到 __ocs_fmt_0__。 .preview = 預覽 .printer-plotter = 印表機/繪圖儀 .properties = 特性… @@ -4557,6 +5096,34 @@ plot = .system-default-printer = 系統預設印表機 .what-to-plot = 列印內容 .x-mm = X (毫米) + .select-drawing-to-import-page-setups-from = 選擇要從中匯入頁面設定的圖面 + .drawings-and-templates = 圖面與樣板 + .psetupin-count-page-setup-s-imported-replaced-replaced = PSETUPIN:已匯入 __ocs_fmt_0__ 個頁面設定,取代 __ocs_fmt_1__ 個。 + .psetupin-error = PSETUPIN:__ocs_fmt_0__ + .psetupin-no-page-setup-named-want-in-file = PSETUPIN:__ocs_fmt_1__ 中沒有名為「__ocs_fmt_0__」的頁面設定。 + .psetupin-nothing-to-import-from-file = PSETUPIN:__ocs_fmt_0__ 中沒有可匯入的內容。 + .no-named-page-setups-in-this-drawing = 此圖面中沒有具名的頁面設定。 + .import-ellipsis = 匯入… + .show-the-page-setup-for-new-layouts = 為新配置顯示頁面設定 + .x-in = X(英吋) + .y-in = Y(英吋) + .hide-paperspace-objects = 隱藏圖紙空間物件 + .save-changes-to-layout = 將變更儲存到配置 + .printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1 = __ocs_fmt_0__ 不支援 __ocs_fmt_1__;已切換至其預設圖紙 __ocs_fmt_2__(PAPERUPDATE = 1)。 + .printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0 = __ocs_fmt_0__ 不支援 __ocs_fmt_1__;保留原圖紙(PAPERUPDATE = 0)。 + .printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used = __ocs_fmt_0__:印表機未回報任何紙張;使用紙張目錄。 + .printer-count-sheet-s-reported = __ocs_fmt_0__:回報了 __ocs_fmt_1__ 種紙張 + .default-sheet-sheet = 預設紙張:__ocs_fmt_0__ + .default-sheet-not-reported = 預設紙張:未回報 + .default-printer-name = 預設印表機:__ocs_fmt_0__ + .default-printer-none-reported-by-the-system = 預設印表機:系統未回報 + .printers-none-listed-by-the-system = 印表機:系統未列出任何印表機 + .printers-count = 印表機(__ocs_fmt_0__): + .could-not-list-printers-error = 無法列出印表機:__ocs_fmt_0__ + .plots-go-through-the-pdf-application-command = 列印透過 PDF 應用程式進行:__ocs_fmt_0__ + .plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb = 列印直接送到印表機(沒有 PDF 應用程式註冊列印命令)。 + .via-the-pdf-application = 透過 PDF 應用程式 + .direct-print = 直接列印 .y-mm = Y (毫米) manage = @@ -4768,6 +5335,19 @@ ui = .what-s-new = 更新內容 .width-value = 寬度:__ocs_arg_value__ .working = 正在處理… + .recent-input = 最近的輸入 + .copy-with-base-point = 帶基準點複製 + .undo-label = 復原 __ocs_arg_label__ + .redo-label = 重做 __ocs_arg_label__ + .copy-selection = 複製選取 + .isolate = 隔離 + .deselect-all = 全部取消選取 + .options-ellipsis = 選項... + .base-point = 基準點 + .snap-overrides = 鎖點取代 + .mid-between-2-points = 兩點之間的中點 + .osnap-settings = 物件鎖點設定... + .snap-override-none-next-pick-only = 鎖點取代:無(僅下一次點選)。 dimstyle = .keep-text-between-extension-lines = 始終將文字置於尺寸界線之間 diff --git a/packaging/build_macos_signed.sh b/packaging/build_macos_signed.sh index b5905a902..e1566049e 100755 --- a/packaging/build_macos_signed.sh +++ b/packaging/build_macos_signed.sh @@ -95,15 +95,8 @@ sed "s/__VERSION__/$VERSION/g" packaging/Info.plist > "$APP/Contents/Info.plist" echo "==> codesign" if [ "$DEVELOPER_ID" = "-" ]; then - # CI-parity ad-hoc signature; cannot be notarized. Still sign nested - # code individually, in the same order as the Developer ID path below, - # rather than one `--deep` pass over the outer bundle: `--deep` re-signs - # every nested binary with the *outer* invocation's own arguments, which - # here carried no --entitlements at all — so the appex ended up ad-hoc - # signed but missing its sandbox entitlement, and PlugInKit silently - # dropped it ("rejecting ... plug-ins must be sandboxed" in the system - # log, no error surfaced anywhere else) (#365). Ad-hoc signing itself is - # not the blocker here — carrying the entitlements through is. + # Sign inside out so the extension retains its sandbox entitlement. + # Ad-hoc signing cannot be notarized. codesign --force --sign - --timestamp=none \ "$APP/Contents/MacOS/OpenCADStudio-App" codesign --force --sign - --timestamp=none \ @@ -127,7 +120,9 @@ else codesign --force --timestamp --options runtime \ -s "$DEVELOPER_ID" "$APP" fi -codesign --verify --strict --verbose=2 "$APP" +codesign --verify --deep --strict --verbose=2 "$APP" +codesign -d --entitlements - "$APP/Contents/PlugIns/DWGThumbnail.appex" \ + | python3 -c 'import plistlib, sys; assert plistlib.load(sys.stdin.buffer).get("com.apple.security.app-sandbox") is True' echo "==> dmg" DMG="$DIST/OpenCADStudio-v$VERSION-macos-arm64.dmg" diff --git a/packaging/windows/main.wxs b/packaging/windows/main.wxs index 55132ffc0..0db903780 100644 --- a/packaging/windows/main.wxs +++ b/packaging/windows/main.wxs @@ -196,15 +196,6 @@ - - - NOT REMOVE - diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock index 1679360ae..4fdfea5f5 100644 --- a/plugins/opencad-python/Cargo.lock +++ b/plugins/opencad-python/Cargo.lock @@ -5,7 +5,7 @@ version = 4 [[package]] name = "acadrust" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=568a12c#568a12cb97c24e14b1cc488085b3f81cf89b94d9" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=7ea4247#7ea4247134af60c8c1617426849d4fa8233660c8" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/src/app/alias.rs b/src/app/alias.rs index d9c3aeb00..dc0a3a169 100644 --- a/src/app/alias.rs +++ b/src/app/alias.rs @@ -164,6 +164,13 @@ fn default_map() -> FxHashMap { parse_pgp(DEFAULT_ALIASES_PGP) } +/// Sorted factory-default rows for the ALIASEDIT reset action. +pub(super) fn default_alias_rows() -> Vec<(String, String)> { + let mut rows: Vec<(String, String)> = default_map().into_iter().collect(); + rows.sort_by(|a, b| a.0.cmp(&b.0)); + rows +} + /// Load the alias table at boot. Native reads `ocad.pgp`; web reads the same /// text from `localStorage`. Missing or unavailable storage falls back to the /// embedded defaults. @@ -315,10 +322,88 @@ impl OpenCADStudio { .alias_editor_rows .iter() .filter(|(a, c)| !a.trim().is_empty() && !c.trim().is_empty()) - .map(|(a, c)| (a.trim().to_string(), c.trim().to_string())) + .map(|(a, c)| { + ( + a.trim().to_uppercase(), + c.trim().to_uppercase(), + ) + }) .collect(); self.set_command_aliases(map); } + + /// Commit the working rows, discarding an unfinished draft and reporting + /// duplicates. Shared by Apply and Apply-and-Exit. Mirrors + /// `finish_shortcut_editor`. + pub(super) fn finish_alias_editor(&mut self) { + if self.alias_pending_add && !self.alias_editor_rows.is_empty() { + self.alias_editor_rows.remove(0); + self.alias_pending_add = false; + self.command_line + .push_error(crate::tf!("Incomplete alias row discarded.").as_ref()); + } + let mut seen = rustc_hash::FxHashSet::default(); + let mut duplicates = Vec::new(); + for (alias, _) in &self.alias_editor_rows { + let alias = alias.trim().to_uppercase(); + if alias.is_empty() { + continue; + } + if !seen.insert(alias.clone()) { + duplicates.push(alias); + } + } + if !duplicates.is_empty() { + self.command_line.push_error( + crate::tf!( + "Duplicate alias(es) ignored on Apply: {}", + duplicates.join(", ") + ) + .as_ref(), + ); + } + self.apply_alias_editor_rows(); + self.command_line.push_info( + crate::tf!("{} alias(es) applied.", self.command_aliases.len()).as_ref(), + ); + } + + /// Reset the working rows and live table to the shipped defaults. + pub(super) fn reset_aliases_to_defaults(&mut self) { + self.alias_editor_rows = default_alias_rows(); + self.alias_pending_add = false; + self.alias_reset_confirm = false; + self.apply_alias_editor_rows(); + } + + /// True when the working rows differ from the live table — the editor + /// has un-applied changes a close would discard. + pub(super) fn alias_editor_dirty(&self) -> bool { + let rows: FxHashMap = self + .alias_editor_rows + .iter() + .filter_map(|(alias, command)| { + let alias = alias.trim().to_uppercase(); + let command = command.trim().to_uppercase(); + (!alias.is_empty() && !command.is_empty()).then_some((alias, command)) + }) + .collect(); + rows != self.command_aliases + } + + /// A pending add finishes once its draft row (row 0) has both an alias + /// and a command; it then becomes a regular row. Called by the draft + /// row's check button — typing alone never finishes it. + pub(super) fn finish_pending_alias_add(&mut self) { + if !self.alias_pending_add { + return; + } + if let Some((alias, command)) = self.alias_editor_rows.first() { + if !alias.trim().is_empty() && !command.trim().is_empty() { + self.alias_pending_add = false; + } + } + } } #[cfg(test)] diff --git a/src/app/automation.rs b/src/app/automation.rs index afdc0fc5e..fe64eb253 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -296,7 +296,7 @@ impl OpenCADStudio { match req["op"].as_str().unwrap_or("") { "new" => { let i = self.active_tab; - self.tabs[i].scene.clear(); + self.tabs[i].scene.reset_to_new_drawing(); self.tabs[i].scene.material_base_dir = None; self.tabs[i].current_path = None; // The headless session starts on the welcome (Start) tab, which @@ -1459,7 +1459,8 @@ mod tests { doc.add_entity(acadrust::EntityType::Circle(good)).unwrap(); let mut corrupt = acadrust::entities::Circle::new(); corrupt.center = acadrust::types::Vector3::new(1.0, 1.0, 0.0); - corrupt.radius = 0.0; // io::is_entity_corrupt rejects a zero-radius circle + // An absurd radius is rejected; a zero radius is valid. + corrupt.radius = 1.0e11; doc.add_entity(acadrust::EntityType::Circle(corrupt)) .unwrap(); let bytes = crate::io::save_to_bytes(&doc, "dxf", doc.version) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 168d90be6..87abf6df6 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -97,6 +97,10 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].snap_result = None; self.last_point = None; + // Points collected in the space being left are meaningless in the new + // one. + self.clear_accepted_snaps(); + self.pending_click_snap = None; self.snapper.from_point = None; self.snapper.clear_tracking(); self.otrack_active = None; @@ -118,8 +122,50 @@ impl OpenCADStudio { let _ = self.on_viewport_exit(); } - /// Roll a hot grip back to its pre-drag image and remove every grip-owned - /// overlay. Shared by Escape and drawing-space transitions. + pub(super) fn solve_grip_constraints( + &mut self, + i: usize, + grip: &crate::scene::pick::grip::GripEdit, + ) { + let touched: Vec<_> = grip.targets.iter().map(|target| target.handle).collect(); + let connected = self.tabs[i].scene.parametric_connected_handles( + self.tabs[i].current_parametric_scope(), &touched, true, + ); + for handle in connected { + if !self.grip_originals.iter().any(|(original, _)| *original == handle) { + if let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() { + self.grip_originals.push((handle, entity)); + } + } + if !self.grip_preview_handles.contains(&handle) { + self.grip_preview_handles.push(handle); + if !self.tabs[i].scene.meshes.contains_key(&handle) { + self.tabs[i].scene.preview_hidden.insert(handle); + } + } + } + let driven_refs: Vec<_> = grip.targets.iter().flat_map(|target| { + self.tabs[i].scene.document.get_entity(target.handle) + .map(|entity| crate::scene::parametric_constraints::grip_solve_anchor_refs( + entity, target.handle, target.grip_id, + )).unwrap_or_default() + }).collect(); + let retain_size = self.constraint_solve_mode + && !driven_refs.is_empty() + && driven_refs.iter().all(|reference| reference.marker.is_some()); + let solved = self.tabs[i].scene.solve_parametric_constraints_preview( + &touched, + &driven_refs, + retain_size, + &self.grip_originals, + ); + for (handle, entity) in solved { + if let Some(slot) = self.tabs[i].scene.document.get_entity_mut(handle) { + *slot = entity; + } + } + } + pub(super) fn capture_grip_history_originals(&mut self, i: usize, handles: &[Handle]) { if !self.grip_history_originals.is_empty() { return; @@ -135,6 +181,9 @@ impl OpenCADStudio { pub(super) fn cancel_active_grip_edit(&mut self) -> bool { let i = self.active_tab; + // Grip-menu toggles live only as long as the gesture. + self.tabs[i].grip_copy = false; + self.tabs[i].grip_base_pending = false; let had_grip = self.tabs[i].active_grip.take().is_some() || self.grip_add_provisional.is_some() || !self.grip_preview_handles.is_empty() @@ -220,7 +269,7 @@ impl OpenCADStudio { .into_iter() .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.bump_entities_after_parametric_solve(&changes); if let Some(dirty_before) = self.grip_dirty_before.take() { self.tabs[i].dirty = dirty_before; } @@ -380,6 +429,13 @@ impl OpenCADStudio { if !self.command_point_allowed(i, *point) { return Task::none(); } + // Typed / dynamic-input / headless points carry no snap, but the + // accepted-snap list must stay index-parallel with the points the + // command collects. Interactive picks record themselves in + // the click handler and never reach here. + if !self.record_accepted_snap(i, None, None, *point) { + return Task::none(); + } } if default_start { let StepInput::Point(point) = &input else { @@ -393,6 +449,9 @@ impl OpenCADStudio { self.push_ucs_to_cmd(i); } if let StepInput::EntityPick(handle, point) = &input { + if !self.dimension_acquisition_allowed(i, None) { + return Task::none(); + } let solid_pick = matches!( self.tabs[i].scene.document.get_entity(*handle), Some( @@ -465,6 +524,11 @@ impl OpenCADStudio { } let ctrl = self.ctrl_down; let shift = self.shift_down; + let picked_handle = if let StepInput::EntityPick(h, _) = &input { + Some(*h) + } else { + None + }; let result: Option = { let Some(cmd) = self.tabs[i].active_cmd.as_mut() else { return Task::none(); @@ -483,6 +547,10 @@ impl OpenCADStudio { StepInput::Escape => Some(cmd.on_escape()), } }; + if let Some(handle) = picked_handle { + self.record_dimension_entity_points(i, None, handle, Vec::new()); + } + self.sync_dimension_snaps(i); match result { Some(r) => self.apply_cmd_result(r), None => Task::none(), @@ -699,6 +767,21 @@ impl OpenCADStudio { if self.command_point_allowed(i, wcs) { self.last_point = Some(wcs); self.push_ucs_to_cmd(i); + // A typed coordinate at an object prompt is a pick at + // that point, as in the reference. + let picks_entity = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.typed_point_picks_entity()); + if picks_entity { + let owner = self.tabs[i] + .current_parametric_scope() + .owner_handle(&self.tabs[i].scene.document); + let handle = + entity_at_typed_point(&self.tabs[i].scene.document, owner, wcs) + .unwrap_or(Handle::NULL); + return self.feed_command(StepInput::EntityPick(handle, wcs)); + } return self.feed_command(StepInput::Point(wcs)); } return Task::none(); @@ -723,6 +806,9 @@ impl OpenCADStudio { } return Task::none(); } + // Keywords, distances and points fed here (option buttons, the + // context menu, scripts) join the Recent Input list like typed ones. + self.command_line.record_recent_input(token); let is_mtp = token.trim_start_matches('_').eq_ignore_ascii_case("MTP") || token.trim_start_matches('_').eq_ignore_ascii_case("M2P"); if is_mtp { @@ -854,6 +940,9 @@ impl OpenCADStudio { ); let task = self.apply_cmd_result_inner(result); let i = self.active_tab; + // A transparent zoom that just finished hands control back to the + // command it interrupted before the "command ended" bookkeeping below. + self.resume_transparent_parent(i); let preview_hidden = self.tabs[i] .active_cmd .as_ref() @@ -1328,6 +1417,38 @@ impl OpenCADStudio { Task::none() } + /// Bring back the command a transparent zoom parked, once the zoom is + /// over (no command active any more). No-op otherwise. + pub(in crate::app) fn resume_transparent_parent(&mut self, i: usize) { + if !self.tabs[i].transparent_resume { + return; + } + if self.tabs[i].active_cmd.is_some() { + // The transparent prompt is still up (or the parent was already + // restored by a Cancel). + if self.tabs[i].suspended_cmd.is_none() { + self.tabs[i].transparent_resume = false; + } + return; + } + self.tabs[i].transparent_resume = false; + let Some(parent) = self.tabs[i].suspended_cmd.take() else { + return; + }; + self.tabs[i].active_cmd = Some(parent); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + 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); + self.refresh_active_cmd_preview(i); + } + pub(in crate::app) fn start_mtp_modifier(&mut self, i: usize) { let parent = self.tabs[i].active_cmd.take(); self.tabs[i].suspended_cmd = parent; @@ -1685,10 +1806,15 @@ impl OpenCADStudio { let mut touched = Vec::new(); for (kind, refs) in inferred { touched.extend(refs.iter().map(|reference| reference.entity)); - self.tabs[i] + let id = self.tabs[i] .scene .parametric_constraint_set_mut(scope) .add(kind, refs, None); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); } touched.sort_unstable_by_key(|handle| handle.value()); touched.dedup(); @@ -1834,7 +1960,7 @@ impl OpenCADStudio { }; if is_associative_dimension && association_enabled { if let Some(handle) = committed { - let sources = self.tabs[i].scene.infer_dimension_sources(handle); + let sources = self.infer_dimension_sources_guarded(i, handle); self.tabs[i] .scene .attach_dimension_association(handle, sources); @@ -2190,7 +2316,7 @@ impl OpenCADStudio { let scope = self.tabs[i].current_parametric_scope(); handles = self.tabs[i] .scene - .parametric_connected_handles(scope, &handles); + .parametric_connected_handles(scope, &handles, false); handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); } let label = self.history_label_from_active_cmd(i, "MOVE"); @@ -2264,7 +2390,7 @@ impl OpenCADStudio { self.sync_dyn_fields(); self.refresh_area_preview(i); } - CmdResult::CommitAndExit(entity) => { + CmdResult::CommitAndExit(mut entity) => { // For XATTACH: ensure the xref block definition exists before // committing the INSERT entity that references it. // Extract path early to avoid borrow conflicts. @@ -2281,10 +2407,39 @@ impl OpenCADStudio { } }; if let Some(path) = xattach_path { - crate::modules::insert::xattach::prepare_xref_block( + // XREF-Task6: the host drawing path lives in the tab + // (`current_path`), not in `Scene`, so the self-attach + // guard runs here via the pure `is_self_attach` helper. + let host_file = self.tabs[i].current_path.clone(); + let host_base = host_file + .as_deref() + .and_then(|p| p.parent()) + .map(|p| p.to_path_buf()); + if let Some(host) = host_file.as_deref() { + if crate::modules::insert::xattach::is_self_attach( + host, + &path, + host_base.as_deref(), + ) { + self.command_line.push_error(crate::t!("XATTACH: cannot attach the host drawing into itself.").as_ref()); + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + return Task::none(); + } + } + let prepared_name = crate::modules::insert::xattach::prepare_xref_block( &mut self.tabs[i].scene, &path, + host_base.as_deref(), ); + // `prepare_xref_block` may suffix a colliding stem. The + // command was created before that collision was known, so + // retarget its pending INSERT to the actual new block. + if let acadrust::EntityType::Insert(insert) = &mut entity { + insert.block_name = prepared_name; + } // Resolving the xref merged its layer / linetype tables // into the document — mirror them into the Layers panel // and ribbon dropdowns now, not on the next reopen (#407). @@ -2312,7 +2467,7 @@ impl OpenCADStudio { 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); + let sources = self.infer_dimension_sources_guarded(i, handle); self.tabs[i] .scene .attach_dimension_association(handle, sources); @@ -2345,6 +2500,20 @@ impl OpenCADStudio { &entity, acadrust::EntityType::Dimension(acadrust::entities::Dimension::Ordinate(_)) ); + // A dimension placed on the sheet but measuring model geometry + // through a viewport carries the compensation as a negative + // DIMLFAC override. + if !preserve_base_style { + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + } + if !self.apply_viewport_dimension_measurement(i, &mut entity) { + return Task::none(); + } + // Projected points cannot use direct paper-space source inference. + let association_allowed = self.dimension_association_allowed(i); let inherited_dimension = if preserve_base_style { match &entity { acadrust::EntityType::Dimension(dimension) => Some(( @@ -2408,12 +2577,12 @@ impl OpenCADStudio { if let Some(handle) = self.commit_entity_handle_with_dimension_policy(entity, preserve_base_style) { - if association_mode == 2 { + if association_mode == 2 && association_allowed { 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), + || self.infer_dimension_sources_guarded(i, handle), |source| { if single_source_dimension { vec![Some(source)] @@ -2442,6 +2611,9 @@ impl OpenCADStudio { changes.sort_by_key(|(handle, _)| handle.value()); changes.dedup_by_key(|(handle, _)| handle.value()); self.tabs[i].scene.bump_entities(&changes); + } else if association_mode == 2 { + // Preserve the acquired viewport and source paths. + self.attach_viewport_dimension_association(i, handle); } } pending @@ -2528,9 +2700,7 @@ impl OpenCADStudio { crate::command::DimensionAssociationInput::Infer(source) => { source.map_or_else( || { - self.tabs[i] - .scene - .infer_dimension_sources(handle) + self.infer_dimension_sources_guarded(i, handle) .into_iter() .map(|source| { source.map( @@ -3027,10 +3197,669 @@ impl OpenCADStudio { driving_param, label, } => { + use crate::scene::parametric_constraints::ConstraintKind; + // GCSMOOTH and FXCONSTRAINT re-prompt after a rejected pick. + let keep_command = self.tabs[i].active_cmd.as_ref().is_some_and(|command| { + (kind == ConstraintKind::Smooth && command.name() == "GCSMOOTH") + || (kind == ConstraintKind::Fixed && command.name() == "FXCONSTRAINT") + }); if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( kind, &refs, driving_param.as_ref(), + ) { + if !keep_command { + self.tabs[i].active_cmd = None; + } + self.tabs[i].snap_result = None; + self.command_line.push_error(message); + if keep_command { + if let Some(prompt) = + self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) + { + self.command_line.push_info(&prompt); + } + } + return Task::none(); + } + let scope = self.tabs[i].current_parametric_scope(); + if kind == ConstraintKind::Fixed + && self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|existing| { + existing.enabled && existing.kind == kind && existing.refs == refs + }) + }) + { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_error("The constraint already exists on the selected objects."); + return Task::none(); + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let touched: Vec = refs.iter().map(|r| r.entity).collect(); + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + let retain_size = self.constraint_solve_mode && driving_param.is_none(); + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + kind, + refs, + driving_param, + ); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + .into_iter() + .map(|h| (h, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + retain_size, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_output("Constraint applied."); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + CmdResult::AddEqualConstraint { + first, + others, + multiple, + label, + } => { + use crate::modules::parametric::EqualConstraintCommand; + use crate::scene::parametric_constraints::{ + equal_size, equal_size_follower, ConstraintKind, EqualSize, ParametricRef, + }; + + let scope = self.tabs[i].current_parametric_scope(); + // Enter ends a Multiple flow with the reference's summary line. + let finishing = multiple && others.is_empty(); + let mut followers: Vec = Vec::new(); + for other in others { + let refs = [first, other]; + if other == first + || self + .tabs[i] + .scene + .validate_parametric_constraint(ConstraintKind::Equal, &refs, None) + .is_err() + || equal_size_follower(&self.tabs[i].scene.document, first, other) + .is_none() + { + self.command_line + .push_error(EqualConstraintCommand::INVALID_OBJECT); + continue; + } + let exists = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|existing| { + existing.enabled + && existing.kind == ConstraintKind::Equal + && (existing.refs == refs || existing.refs == [other, first]) + }) + }); + if exists { + self.command_line + .push_error("The constraint already exists on the selected objects."); + continue; + } + followers.push(other); + } + if multiple && !finishing { + if let Some(prompt) = + self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) + { + self.command_line.push_info(&prompt); + } + } else { + self.tabs[i].active_cmd = None; + } + self.tabs[i].snap_result = None; + if finishing { + let summary = match equal_size(&self.tabs[i].scene.document, first) { + Some(EqualSize::Radius(_)) => "Radius of objects made equal", + _ => "Length of objects made equal", + }; + self.command_line.push_output(summary); + } + if followers.is_empty() { + return Task::none(); + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let mut touched = vec![first.entity]; + for follower in &followers { + if !touched.contains(&follower.entity) { + touched.push(follower.entity); + } + } + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + // The reference resizes the follower in place — its start (a + // circle its center) and direction stay, only its length or + // radius takes the first object's — so do that first and let + // the relation then hold what already fits. + for follower in &followers { + if let Some(resized) = + equal_size_follower(&self.tabs[i].scene.document, first, *follower) + { + self.tabs[i].scene.update_entity(resized); + } + let id = self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(ConstraintKind::Equal, vec![first, *follower], None); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + } + let changes = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + CmdResult::AddFixedConstraint(pick) => { + use crate::modules::parametric::FixConstraintCommand; + use crate::scene::parametric_constraints::{ + nearest_parametric_point, nearest_parametric_point_on_entity, + parametric_curve_ref_for_pick, ConstraintKind, + }; + + let scope = self.tabs[i].current_parametric_scope(); + let document = &self.tabs[i].scene.document; + let world = + acadrust::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z); + let resolved = match (pick.whole_curve, pick.handle) { + (true, Some(handle)) => { + parametric_curve_ref_for_pick(document, scope, handle, world) + .ok_or(FixConstraintCommand::INVALID_OBJECT) + } + (true, None) => Err(FixConstraintCommand::NO_OBJECT), + (false, Some(handle)) => { + nearest_parametric_point_on_entity(document, scope, handle, world) + .ok_or(FixConstraintCommand::INVALID_OBJECT) + } + (false, None) => nearest_parametric_point(document, scope, world, None) + .ok_or(FixConstraintCommand::NO_POINT), + }; + // An off-plane or 3D curve is "not a valid object" to the + // reference, not a solver limitation. + let resolved = resolved.and_then(|reference| { + let supported = !pick.whole_curve + || self.tabs[i] + .scene + .validate_parametric_constraint( + ConstraintKind::Fixed, + &[reference], + None, + ) + .is_ok(); + supported + .then_some(reference) + .ok_or(FixConstraintCommand::INVALID_OBJECT) + }); + return match resolved { + Ok(reference) => self.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Fixed, + refs: vec![reference], + driving_param: None, + label: "Fixed constraint", + }), + // A miss re-prompts: `ReportError` keeps the command. + Err(message) => { + self.apply_cmd_result(CmdResult::ReportError(message.to_string())) + } + }; + } + CmdResult::CheckHorizontalPoint { kind, pick } => { + use crate::modules::parametric::HorizontalConstraintCommand; + use crate::scene::parametric_constraints::nearest_parametric_point; + + // The reference rejects a missed first point right away and + // asks for it again; a hit moves on to the second point. + let scope = self.tabs[i].current_parametric_scope(); + let found = nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + acadrust::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z), + None, + ) + .is_some(); + if !found { + self.command_line + .push_error("No valid constraint point found."); + } + let command = HorizontalConstraintCommand::resume(kind, found.then_some(pick)); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.tabs[i].snap_result = None; + return Task::none(); + } + CmdResult::AddHorizontalConstraint { + kind, + selection, + direction, + label, + } => { + use crate::command::{EntityTransform, HorizontalConstraintSelection}; + use crate::modules::parametric::HorizontalConstraintCommand; + use crate::scene::parametric_constraints::{ + nearest_parametric_point, nearest_parametric_point_on_entity, resolve_point, + ConstraintKind, DirectionalAxis, ParametricRef, + }; + + let vertical = kind == ConstraintKind::Vertical; + let axis = if vertical { "Vertical" } else { "Horizontal" }; + let scope = self.tabs[i].current_parametric_scope(); + let to_world = |point: glam::DVec3| { + acadrust::types::Vector3::new(point.x, point.y, point.z) + }; + let (refs, initial_fixed) = match selection { + HorizontalConstraintSelection::Reference(reference) => { + // The reference turns the object about its first + // vertex: a line's start, the picked polyline + // segment's first vertex, a text's insertion point, + // an ellipse's center. + let initial_fixed = match reference.directional_axis() { + Some(DirectionalAxis::EllipseMajor | DirectionalAxis::EllipseMinor) => { + vec![ParametricRef::center(reference.entity)] + } + Some(DirectionalAxis::TextBaseline) => { + vec![ParametricRef::point(reference.entity, 0)] + } + None => vec![ParametricRef::point( + reference.entity, + reference.segment_index().map_or(0, |index| index as i32), + )], + }; + (vec![reference], initial_fixed) + } + HorizontalConstraintSelection::Points(first, second) => { + let resolve = |pick: crate::command::CoincidentPick| { + if let Some(handle) = pick.handle { + nearest_parametric_point_on_entity( + &self.tabs[i].scene.document, + scope, + handle, + to_world(pick.point), + ) + } else { + nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(pick.point), + None, + ) + } + }; + // A miss asks for that point again, keeping a good + // first pick; the same point twice asks for another + // second point — the reference's own re-prompts. + let (first_ref, second_ref) = match (resolve(first), resolve(second)) { + (Some(first_ref), Some(second_ref)) if first_ref != second_ref => { + (first_ref, second_ref) + } + (first_ref, second_ref) => { + let (message, keep_first) = if first_ref.is_none() { + ("No valid constraint point found.", None) + } else if second_ref.is_none() { + ("No valid constraint point found.", Some(first)) + } else { + ( + "The object or point is already selected. Select a different object or constraint point.", + Some(first), + ) + }; + let command = HorizontalConstraintCommand::resume(kind, keep_first); + self.command_line.push_error(message); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.tabs[i].snap_result = None; + return Task::none(); + } + }; + (vec![first_ref, second_ref], vec![first_ref]) + } + }; + let axis_length = direction.x.hypot(direction.y); + if axis_length <= 1.0e-12 { + self.command_line.push_error(&format!( + "{axis}: the current UCS {} axis is not supported.", + if vertical { "Y" } else { "X" } + )); + return Task::none(); + } + let direction = acadrust::types::Vector3::new( + direction.x / axis_length, + direction.y / axis_length, + 0.0, + ); + if let Err(message) = + self.tabs[i].scene.validate_parametric_constraint(kind, &refs, None) + { + self.command_line.push_error(message); + return Task::none(); + } + if self + .tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| set.contains_axis_constraint(kind, &refs, direction)) + { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_error("The constraint already exists on the selected objects."); + return Task::none(); + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let mut touched = Vec::new(); + for reference in &refs { + if !touched.contains(&reference.entity) { + touched.push(reference.entity); + } + } + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + // Two points on different objects: the reference slides the + // second object rigidly onto the axis through the first + // point instead of re-solving its shape, so move it first + // and let the constraint then hold what already fits. + if let [first_ref, second_ref] = refs.as_slice() { + if first_ref.entity != second_ref.entity { + let position = |reference: &ParametricRef| { + self.tabs[i] + .scene + .document + .get_entity(reference.entity) + .zip(reference.marker) + .and_then(|(entity, marker)| resolve_point(entity, marker)) + }; + if let (Some(first_point), Some(second_point)) = + (position(first_ref), position(second_ref)) + { + let normal = (-direction.y, direction.x); + let offset = (second_point.x - first_point.x) * normal.0 + + (second_point.y - first_point.y) * normal.1; + if offset.abs() > 1.0e-9 { + self.tabs[i].scene.transform_entities( + &[second_ref.entity], + &EntityTransform::Translate(glam::DVec3::new( + -offset * normal.0, + -offset * normal.1, + 0.0, + )), + ); + } + } + } + } + // The solver cannot start from an axis lying exactly across + // the datum (its equations are singular there), so turn the + // object onto the axis about its anchor first — the + // reference turns it about that anchor too — and let the + // constraint hold what already fits. + if let Some((handle, anchor, end, vertex)) = + crate::scene::parametric_constraints::axis_alignment_target( + &self.tabs[i].scene.document, + &refs, + ) + { + let axis = (end.x - anchor.x, end.y - anchor.y); + let length = axis.0.hypot(axis.1); + let target = if axis.0 * direction.x + axis.1 * direction.y >= 0.0 { + (direction.x, direction.y) + } else { + (-direction.x, -direction.y) + }; + let angle = (axis.0 * target.1 - axis.1 * target.0) + .atan2(axis.0 * target.0 + axis.1 * target.1); + if length > 1.0e-9 && angle.abs() > 1.0e-9 { + match vertex { + Some(index) => { + let moved = + self.tabs[i].scene.document.get_entity(handle).cloned(); + if let Some(mut entity) = moved { + if crate::scene::parametric_constraints::set_polyline_vertex( + &mut entity, + index, + anchor.x + length * target.0, + anchor.y + length * target.1, + ) { + self.tabs[i].scene.update_entity(entity); + } + } + } + None => self.tabs[i].scene.transform_entities( + &[handle], + &EntityTransform::Rotate { + center: glam::DVec3::new(anchor.x, anchor.y, anchor.z), + axis: glam::DVec3::Z, + angle_rad: angle, + }, + ), + } + } + } + let id = self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add_axis_constraint(kind, refs, direction); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &initial_fixed, + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output(&format!("{axis} constraint applied.")); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + CmdResult::AddSymmetricConstraint { + selection, + axis, + label, + } => { + use crate::command::SymmetricConstraintSelection; + use crate::scene::parametric_constraints::ConstraintKind; + + let scope = self.tabs[i].current_parametric_scope(); + let to_world = |point: glam::DVec3| { + acadrust::types::Vector3::new(point.x, point.y, point.z) + }; + let resolve_point = |pick: crate::command::CoincidentPick| { + if let Some(handle) = pick.handle { + crate::scene::parametric_constraints::nearest_parametric_point_on_entity( + &self.tabs[i].scene.document, + scope, + handle, + to_world(pick.point), + ) + } else { + crate::scene::parametric_constraints::nearest_parametric_point( + &self.tabs[i].scene.document, + scope, + to_world(pick.point), + None, + ) + } + }; + let (first, second) = match selection { + SymmetricConstraintSelection::Objects(first, second) => (first, second), + SymmetricConstraintSelection::Points(first, second) => { + let (Some(first), Some(second)) = + (resolve_point(first), resolve_point(second)) + else { + self.command_line.push_error( + "Symmetric: select supported endpoints, centers, midpoints, or vertices.", + ); + return Task::none(); + }; + (first, second) + } + }; + if first == second + || axis.entity == first.entity + || axis.entity == second.entity + { + self.command_line + .push_error("Symmetric: select two different references and a separate line axis."); + return Task::none(); + } + let refs = vec![first, second, axis]; + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + ConstraintKind::Symmetric, + &refs, + None, + ) { + self.command_line.push_error(message); + return Task::none(); + } + let duplicate = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Symmetric + && matches!(constraint.refs.as_slice(), [a, b, m] + if *m == axis + && ((*a == first && *b == second) + || (*a == second && *b == first))) + }) + }); + if duplicate { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_error("The constraint already exists on the selected objects."); + return Task::none(); + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let mut touched = Vec::new(); + for reference in &refs { + if !touched.contains(&reference.entity) { + touched.push(reference.entity); + } + } + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Symmetric, + refs, + None, + ); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities_with_initial_parametric_policy( + &changes, + &[first, axis], + false, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output("Symmetric constraint applied."); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + CmdResult::AddPerpendicularConstraint { + first, + second, + first_fixed, + second_start, + label, + } => { + use crate::scene::parametric_constraints::ConstraintKind; + + let refs = vec![first, second]; + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + ConstraintKind::Perpendicular, + &refs, + None, ) { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -3045,25 +3874,178 @@ impl OpenCADStudio { .unwrap_or_else(|| { crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) }); - let touched: Vec = refs.iter().map(|r| r.entity).collect(); + let touched = if first.entity == second.entity { + vec![first.entity] + } else { + vec![first.entity, second.entity] + }; let pending = self.begin_undo(i, label, touched.len(), true); self.tabs[i] .scene .record_undo_parametric_constraints_before(scope, constraints_before); - let retain_size = self.constraint_solve_mode && driving_param.is_none(); - self.tabs[i].scene.parametric_constraint_set_mut(scope).add( - kind, + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Perpendicular, refs, - driving_param, + None, ); - let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes = touched .into_iter() - .map(|h| (h, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities_with_parametric_policy( + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities_with_initial_parametric_policy( &changes, - &[], - retain_size, + &[first_fixed, second_start], + true, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_output("Constraint applied."); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + CmdResult::AddTangentConstraint { + first, + second, + label, + } => { + use crate::scene::parametric_constraints::ConstraintKind; + + let refs = vec![first, second]; + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + ConstraintKind::Tangent, + &refs, + None, + ) { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_error(message); + return Task::none(); + } + let scope = self.tabs[i].current_parametric_scope(); + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let touched = if first.entity == second.entity { + vec![first.entity] + } else { + vec![first.entity, second.entity] + }; + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Tangent, + refs, + None, + ); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities_with_initial_parametric_policy( + &changes, + &[first], + true, + ); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_output("Constraint applied."); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + CmdResult::AddConcentricConstraint { + first, + second, + label, + } => { + use crate::scene::parametric_constraints::ConstraintKind; + + let refs = vec![first, second]; + if let Err(message) = self.tabs[i].scene.validate_parametric_constraint( + ConstraintKind::Concentric, + &refs, + None, + ) { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line.push_error(message); + return Task::none(); + } + let scope = self.tabs[i].current_parametric_scope(); + let duplicate = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .is_some_and(|set| { + set.constraints.iter().any(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Concentric + && matches!(constraint.refs.as_slice(), [a, b] + if (*a == first && *b == second) + || (*a == second && *b == first)) + }) + }); + if duplicate { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.command_line + .push_output("The Concentric constraint already exists."); + return Task::none(); + } + let constraints_before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + let touched = if first.entity == second.entity { + vec![first.entity] + } else { + vec![first.entity, second.entity] + }; + let pending = self.begin_undo(i, label, touched.len(), true); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, constraints_before); + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Concentric, + refs, + None, + ); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); + let changes = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect::>(); + self.tabs[i].scene.bump_entities_with_initial_parametric_policy( + &changes, + &[first], + true, ); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; @@ -3156,10 +4138,15 @@ impl OpenCADStudio { self.tabs[i] .scene .record_undo_parametric_constraints_before(scope, constraints_before); - self.tabs[i] + let id = self.tabs[i] .scene .parametric_constraint_set_mut(scope) .add(kind, refs, None); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); let changes: Vec<_> = touched .iter() .copied() @@ -3215,10 +4202,15 @@ impl OpenCADStudio { .scene .record_undo_parametric_constraints_before(scope, constraints_before); for refs in inferred { - self.tabs[i] + let id = self.tabs[i] .scene .parametric_constraint_set_mut(scope) .add(ConstraintKind::Coincident, refs, None); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); } let changes: Vec<_> = handles .iter() @@ -4569,6 +5561,7 @@ impl OpenCADStudio { let x1 = p1.x.max(p2.x); let y1 = p1.y.max(p2.y); self.plot_window = Some((x0, y0, x1, y1)); + self.plot_dialog.window = self.plot_window; self.command_line.push_output( crate::tf!("Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}").as_ref(), ); @@ -4576,6 +5569,8 @@ impl OpenCADStudio { // receive the two clicks — bring the dialog back with the // window now active. self.plot_dialog.area = "Window".to_string(); + // Remember the pick immediately, like the printer choice. + self.save_config(); self.active_modal = Some(super::ModalKind::Plot); } else { // PLOTWINDOW always describes the plotted layout. In MSPACE @@ -4588,10 +5583,12 @@ impl OpenCADStudio { let x2 = p1.x.max(p2.x); let y2 = p1.y.max(p2.y); self.plot_window = Some((x1, y1, x2, y2)); + self.plot_dialog.window = self.plot_window; self.command_line.push_output( crate::tf!("Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}").as_ref(), ); self.plot_dialog.area = "Window".to_string(); + self.save_config(); self.active_modal = Some(super::ModalKind::Plot); } self.tabs[i].active_cmd = None; @@ -6339,6 +7336,12 @@ impl OpenCADStudio { } } } + { + let tab = &mut self.tabs[i]; + if let Some(command) = tab.active_cmd.as_mut() { + command.on_document_undone(&tab.scene.document); + } + } let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { self.command_line.push_info(&p); @@ -6785,6 +7788,40 @@ impl OpenCADStudio { /// returning the new root handle. `allocate_handle` advances the document's /// handle counter — `next_handle()` only peeks, so reusing it would hand every /// object the same handle and collapse the dictionary chain. +/// The entity a typed coordinate lands on while an object is asked for: +/// the nearest planar curve of the edited space within a small share of +/// that space's extent, the way a pick box takes the object under a click. +fn entity_at_typed_point( + document: &acadrust::CadDocument, + owner: Handle, + point: glam::DVec3, +) -> Option { + let mut extent = 0.0f64; + let mut nearest: Option<(f64, Handle)> = None; + for entity in document.entities() { + let common = entity.common(); + if common.owner_handle != owner { + continue; + } + let bounds = entity.as_entity().bounding_box(); + extent = extent + .max((bounds.max.x - bounds.min.x).abs()) + .max((bounds.max.y - bounds.min.y).abs()); + let Some(distance) = + crate::scene::viewport_dimension_pick::planar_pick_distance(entity, point) + else { + continue; + }; + if nearest.is_none_or(|(best, _)| distance < best) { + nearest = Some((distance, common.handle)); + } + } + let tolerance = (extent * 0.002).max(1.0e-9); + nearest + .filter(|(distance, _)| *distance <= tolerance) + .map(|(_, handle)| handle) +} + fn recreate_ext_subtree( doc: &mut acadrust::CadDocument, cap: &crate::app::ClipExtObjects, @@ -8146,6 +9183,162 @@ mod parametric_constraint_undo_tests { ); } + #[test] + fn perpendicular_initial_solve_rotates_parallel_second_line_in_kernel() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let first = add_line(&mut app, 0.0, 0.0, 10.0, 0.0); + let second = add_line(&mut app, 20.0, 0.0, 30.0, 0.0); + + let _ = app.apply_cmd_result(CmdResult::AddPerpendicularConstraint { + first: ParametricRef::whole(first), + second: ParametricRef::whole(second), + first_fixed: ParametricRef::whole(first), + second_start: ParametricRef::point(second, 0), + label: "Perpendicular constraint", + }); + + let line = |handle| match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(line)) => line.clone(), + other => panic!("expected a Line, got {other:?}"), + }; + let fixed = line(first); + let moving = line(second); + assert_eq!(fixed.start, acadrust::types::Vector3::new(0.0, 0.0, 0.0)); + assert_eq!(fixed.end, acadrust::types::Vector3::new(10.0, 0.0, 0.0)); + assert!((moving.start - acadrust::types::Vector3::new(20.0, 0.0, 0.0)).length() < 1.0e-9); + assert!((moving.length() - 10.0).abs() < 1.0e-7); + assert!( + (fixed.end - fixed.start) + .dot(&(moving.end - moving.start)) + .abs() + < 1.0e-7, + "fixed={fixed:?}, moving={moving:?}" + ); + } + + #[test] + fn horizontal_initial_solve_uses_the_captured_axis_in_kernel() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let handle = add_line(&mut app, 0.0, 0.0, 5.0, 2.0); + let (original_start, original_end, original_length) = match app.tabs[app.active_tab] + .scene + .document + .get_entity(handle) + { + Some(acadrust::EntityType::Line(line)) => (line.start, line.end, line.length()), + other => panic!("expected a Line, got {other:?}"), + }; + let direction = acadrust::types::Vector3::new(3.0, 4.0, 0.0).normalize(); + + let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, + selection: crate::command::HorizontalConstraintSelection::Reference( + ParametricRef::whole(handle), + ), + direction, + label: "Horizontal constraint", + }); + + let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(line)) => line, + other => panic!("expected a Line, got {other:?}"), + }; + let solved = (line.end - line.start).normalize(); + assert!(solved.cross(&direction).length() < 1.0e-7); + assert!((line.length() - original_length).abs() < 1.0e-7); + let constraint = &app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints[0]; + assert_eq!(constraint.axis_direction, Some(direction)); + + app.undo_steps(1); + let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(line)) => line, + other => panic!("expected a Line after undo, got {other:?}"), + }; + assert_eq!((line.start, line.end), (original_start, original_end)); + assert_eq!( + app.tabs[app.active_tab] + .scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .map(|set| set.constraints.len()) + .unwrap_or(0), + 0 + ); + + app.redo_steps(1); + let line = match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(line)) => line, + other => panic!("expected a Line after redo, got {other:?}"), + }; + assert!((line.end - line.start).normalize().cross(&direction).length() < 1.0e-7); + } + + #[test] + fn horizontal_two_point_solve_keeps_the_first_point_fixed() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let first = add_line(&mut app, 0.0, 0.0, 0.0, 5.0); + let second = add_line(&mut app, 8.0, 3.0, 8.0, 7.0); + let pick = |handle, point| crate::command::CoincidentPick { + handle: Some(handle), + point, + whole_curve: false, + }; + + let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, + selection: crate::command::HorizontalConstraintSelection::Points( + pick(first, glam::DVec3::ZERO), + pick(second, glam::DVec3::new(8.0, 3.0, 0.0)), + ), + direction: acadrust::types::Vector3::UNIT_X, + label: "Horizontal constraint", + }); + + let line = |handle| match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(line)) => line, + other => panic!("expected a Line, got {other:?}"), + }; + assert_eq!(line(first).start, acadrust::types::Vector3::ZERO); + assert!(line(second).start.y.abs() < 1.0e-8); + } + + #[test] + fn horizontal_minor_axis_rotates_an_ellipse_around_its_center() { + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let mut ellipse = acadrust::entities::Ellipse::default(); + ellipse.center = acadrust::types::Vector3::new(3.0, 7.0, 0.0); + ellipse.major_axis = acadrust::types::Vector3::new(3.0, 4.0, 0.0); + ellipse.minor_axis_ratio = 0.4; + let handle = app.tabs[app.active_tab] + .scene + .add_entity(acadrust::EntityType::Ellipse(ellipse)); + + let _ = app.apply_cmd_result(CmdResult::AddHorizontalConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind::Horizontal, + selection: crate::command::HorizontalConstraintSelection::Reference( + ParametricRef::ellipse_minor_axis(handle), + ), + direction: acadrust::types::Vector3::UNIT_X, + label: "Horizontal constraint", + }); + + let ellipse = match app.tabs[app.active_tab].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Ellipse(ellipse)) => ellipse, + other => panic!("expected an Ellipse, got {other:?}"), + }; + assert!((ellipse.center - acadrust::types::Vector3::new(3.0, 7.0, 0.0)).length() < 1.0e-9); + assert!(ellipse.major_axis.x.abs() < 1.0e-7); + assert!((ellipse.major_axis.length() - 5.0).abs() < 1.0e-7); + assert!((ellipse.minor_axis_ratio - 0.4).abs() < 1.0e-9); + } + /// Drawing commands must not create parametric constraints implicitly. #[test] fn drawing_a_line_onto_an_existing_endpoint_does_not_add_a_constraint() { diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index a0f5d2583..52a1465dc 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -1,6 +1,41 @@ use super::*; impl OpenCADStudio { + /// Shared per-reference status reporting for XRELOAD and XREF Reload. + /// Extracted verbatim from the XRELOAD arm — no behavior change. + pub(in crate::app) fn report_xref_status(&mut self, info: &crate::io::xref::XrefInfo) { + match info.status { + crate::io::xref::XrefStatus::Loaded => { + self.command_line + .push_output(crate::tf!("XREF Reloaded \"{}\"", info.name).as_ref()); + } + crate::io::xref::XrefStatus::Recovered => { + self.command_line.push_error(crate::tf!( + "XREF Reloaded with repairs: \"{}\"", + info.name + ).as_ref()); + } + crate::io::xref::XrefStatus::NotFound => { + self.command_line.push_error(crate::tf!( + "XREF Not found: \"{}\" ({})", + info.name, info.path + ).as_ref()); + } + crate::io::xref::XrefStatus::Failed => { + self.command_line.push_error(crate::tf!( + "XREF Reload failed: \"{}\" ({})", + info.name, info.path + ).as_ref()); + } + crate::io::xref::XrefStatus::Unloaded => { + self.command_line.push_info(crate::tf!( + "XREF Unloaded (skipped): \"{}\"", + info.name + ).as_ref()); + } + } + } + pub(in crate::app) fn copy_entities_to_clipboard( &mut self, i: usize, @@ -539,84 +574,715 @@ impl OpenCADStudio { } } - "XREF" => { - // List all xref blocks in the current drawing. - let xrefs: Vec = self.tabs[i] - .scene - .document - .block_records - .iter() - .filter(|br| br.flags.is_xref || br.flags.is_xref_overlay) - .map(|br| { - format!( - " {} — {}", - br.name, - if br.xref_path.is_empty() { + // EXTERNALREFERENCES — toggle the docked External References panel. + // (XREFMAN was a provisional name and is not kept, not even as an + // alias.) The XREF command below is untouched. + "EXTERNALREFERENCES" => { + self.show_external_references ^= true; + if self.show_external_references { + // Always open expanded so the panel is immediately usable; + // dock it on the right if the user closed it from the layout. + if self.dock.location(crate::ui::dock::PanelId::ExternalReferences).is_none() { + self.dock.dock( + crate::ui::dock::PanelId::ExternalReferences, + crate::app::config::DockSide::Right, + usize::MAX, + ); + } + self.dock_expanded = Some(crate::ui::dock::PanelId::ExternalReferences); + self.refresh_xref_manager(); + } + } + + cmd if cmd.eq_ignore_ascii_case("XREF") || cmd.to_ascii_uppercase().starts_with("XREF ") || cmd.eq_ignore_ascii_case("-XREF") || cmd.to_ascii_uppercase().starts_with("-XREF ") => { // XREF sub-option dispatcher (Task 5). The verb is + // case-insensitive; arguments split on whitespace. + // `-XREF` is accepted as an alias for industry muscle memory + // (modern CAD opens the palette on `XREF`); bare `XREF` keeps + // this repo's legacy list output for compatibility. + let rest = cmd + .splitn(2, char::is_whitespace) + .nth(1) + .unwrap_or("") + .trim(); + let mut parts = rest.split_whitespace(); + let op = parts.next().unwrap_or(""); + #[cfg(target_arch = "wasm32")] + if !op.is_empty() && op != "?" { + self.command_line.push_error(crate::t!( + "Reference changes are not available on web — the reference list is read-only." + ).as_ref()); + return Some(self.finish_dispatch(cmd)); + } + if op.is_empty() || op == "?" { + // List every reference via collect_entries_with_prev. The + // base dir is the drawing's parent (or "." unsaved); the + // tab's session sets keep CLI and palette in agreement. + // Header lines stay byte-identical to the legacy listing; + // each entry line gains an " [Attach]" / " [Overlay]" + // type suffix. + let base_dir: std::path::PathBuf = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + if entries.is_empty() { + self.command_line + .push_output(crate::t!("XREF No external references in this drawing.").as_ref()); + } else { + self.command_line.push_output(crate::t!("XREF External references:").as_ref()); + for entry in &entries { + let path = if entry.saved_path.is_empty() { "(no path)".to_string() } else { - br.xref_path.clone() - } - ) - }) - .collect(); - if xrefs.is_empty() { - self.command_line - .push_output(crate::t!("XREF No external references in this drawing.").as_ref()); - } else { - self.command_line.push_output(crate::t!("XREF External references:").as_ref()); - for line in xrefs { - self.command_line.push_output(&line); + entry.saved_path.clone() + }; + let suffix = + match entry.ref_type { + crate::io::xref_model::RefType::Overlay => " [Overlay]", + crate::io::xref_model::RefType::Attach => " [Attach]", + }; + self.command_line + .push_output(&format!(" {} — {}{}", entry.name, path, suffix)); + } } + } else if op.eq_ignore_ascii_case("Reload") { + // Reload all (no pattern) or the subset matching + // (case-insensitive, `*`/`?` wildcards). + let pattern = parts.next().unwrap_or("").trim(); + // A concrete pattern that matches no entry resolves + // nothing: pre-check the entry list (same base_dir + + // session sets as the list path) and return early + // without touching resolve_xrefs — no mutation, no + // populate/refresh. Bare Reload and `*` keep the + // resolve-everything path below. + if !pattern.is_empty() && pattern != "*" { + let base_dir: std::path::PathBuf = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + let any = entries + .iter() + .any(|e| crate::io::xref_model::wildcard_match(&e.name, pattern)); + if !any { + self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + pattern + ).as_ref()); + return Some(self.finish_dispatch(cmd)); + } + } + if let Some(path) = &self.tabs[i].current_path.clone() { + if let Some(base_dir) = path.parent() { + // Reloaded keys leave the session-unloaded set + // (so the listing agrees) with fresh stat + // baselines on the next palette refresh. + let reload_keys: Vec = { + let entries = + crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + // Drawing references only (mirrors the palette + // Reload): image/PDF rows keep their flags + // untouched and nested rows report per-entry + // instead of clearing state that resolve + // cannot rebuild. + let mut keys = Vec::new(); + for e in &entries { + let hit = pattern.is_empty() + || pattern == "*" + || crate::io::xref_model::wildcard_match( + &e.name, pattern, + ); + if !hit { + continue; + } + if e.parent_key.is_some() { + self.command_line.push_error(crate::tf!( + "XREF: cannot reload nested reference '{}'. Reload it in its host drawing.", + e.name + ).as_ref()); + } else if e.kind != crate::io::xref_model::RefKind::DwgXref { + self.command_line.push_error(crate::tf!( + "{}: reload applies to drawing references only.", + e.name + ).as_ref()); + } else { + keys.push(e.key); + } + } + keys + }; + // Nothing reloadable (all pattern hits were + // nested/image rows, already reported above): + // return before snapshot + resolve so no empty + // undo entry is pushed and no spurious + // no-match error follows the per-entry reports. + if reload_keys.is_empty() && !pattern.is_empty() && pattern != "*" { + return Some(self.finish_dispatch(cmd)); + } + self.push_undo_snapshot(i, "XREF-RELOAD"); + for key in &reload_keys { + self.tabs[i].xref_unloaded.remove(key); + self.tabs[i].xref_stat_cache.remove(key); + } + let handles: rustc_hash::FxHashSet = self.tabs[i] + .scene + .document + .block_records + .iter() + .filter(|br| reload_keys.contains(&br.handle.value())) + .map(|br| br.handle) + .collect(); + let (infos, _dropped) = crate::io::xref::resolve_xrefs_for_keys( + &mut self.tabs[i].scene.document, + base_dir, + &handles, + ); + // Targeted reload updates only the selected rows. + { + let fresh = + crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + for e in &fresh { + if !reload_keys.contains(&e.key) { + continue; + } + if e.status == crate::io::xref_model::RefStatus::Loaded { + if let Some(m) = e.modified { + self.tabs[i].xref_stat_cache.insert(e.key, m); + } + } + } + } + let matched: Vec<&crate::io::xref::XrefInfo> = if pattern.is_empty() { + infos.iter().collect() + } else { + infos + .iter() + .filter(|n| { + crate::io::xref_model::wildcard_match(&n.name, pattern) + }) + .collect() + }; + if matched.is_empty() { + self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + pattern + ).as_ref()); + } else { + for info in matched { + self.report_xref_status(info); + } + } + self.post_ref_op(i); + } + } else { + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + } + } else if op.eq_ignore_ascii_case("Unload") { + // Unload (empty/* = all): drop merged content + // per reference AND record the key session-unloaded, so + // listings (CLI + palette) agree until a Reload clears it. + let pattern = parts.next().unwrap_or("").trim(); + let base_dir: std::path::PathBuf = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + let matched: Vec<_> = entries + .iter() + .filter(|e| { + pattern.is_empty() + || pattern == "*" + || crate::io::xref_model::wildcard_match(&e.name, pattern) + }) + .collect(); + if matched.is_empty() { + self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + pattern + ).as_ref()); + } else { + self.push_undo_snapshot(i, "XREF-UNLOAD"); + let mut done = 0usize; + for e in matched { + if e.parent_key.is_some() { + self.command_line.push_error(crate::tf!( + "XREF: cannot unload nested reference '{}'. Unload it in its host drawing.", + e.name + ).as_ref()); + continue; + } + match crate::io::xref::unload_reference( + &mut self.tabs[i].scene.document, + e.key, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: unloaded \"{}\".", + name + ).as_ref()); + self.tabs[i].xref_unloaded.add(e.key); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + if done > 0 { + self.post_ref_op(i); + } + } + } else if op.eq_ignore_ascii_case("Detach") { + // Detach (empty/* = all). Nested entries are + // rejected per-entry; direct ones erase instances + + // definition + `name|*` dependents. + let pattern = parts.next().unwrap_or("").trim(); + let base_dir: std::path::PathBuf = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + let matched: Vec<_> = entries + .iter() + .filter(|e| { + pattern.is_empty() + || pattern == "*" + || crate::io::xref_model::wildcard_match(&e.name, pattern) + }) + .collect(); + if matched.is_empty() { + self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + pattern + ).as_ref()); + } else { + self.push_undo_snapshot(i, "XREF-DETACH"); + let mut done = 0usize; + for e in matched { + if e.parent_key.is_some() { + self.command_line.push_error(crate::tf!( + "XREF: cannot detach nested reference '{}'. Detach it in its host drawing.", + e.name + ).as_ref()); + continue; + } + match crate::io::xref::detach_reference( + &mut self.tabs[i].scene.document, + e.key, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: detached \"{}\".", + name + ).as_ref()); + self.tabs[i].xref_unloaded.remove(&e.key); + self.tabs[i].xref_stat_cache.remove(&e.key); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + if done > 0 { + self.post_ref_op(i); + } + } + } else if op.eq_ignore_ascii_case("Path") { + // Path Find → prefix rewrite across all; + // Path → verbatim per-ref store. + let after_op = rest.get(op.len()..).unwrap_or("").trim(); + if after_op.split_whitespace().next().is_some_and(|w| w.eq_ignore_ascii_case("Find")) { + // Find & Replace: tokens are Find + // (`new` keeps spaces via remainder slice). + let toks: Vec<&str> = after_op.split_whitespace().collect(); + let (old, new) = if toks.len() >= 3 { + // Find the exact token index for old to avoid substring + // contamination (e.g. "in" matching inside "Find"). + if let Some(idx) = toks.iter().position(|t| t.eq_ignore_ascii_case(&toks[1])) { + let pos = after_op.find(toks[idx]).unwrap_or(0) + toks[idx].len(); + (toks[idx].to_string(), after_op[pos..].trim().to_string()) + } else { + (String::new(), String::new()) + } + } else { + (String::new(), String::new()) + }; + if old.is_empty() || new.is_empty() { + self.command_line.push_error(crate::t!("Usage: XREF Path Find ").as_ref()); + } else { + self.push_undo_snapshot(i, "XREF-PATH"); + let n = crate::io::xref::replace_path_prefix( + &mut self.tabs[i].scene.document, + &old, + &new, + ); + self.command_line.push_output(crate::tf!( + "XREF: updated {} reference(s).", + n + ).as_ref()); + if n > 0 { + self.post_ref_op(i); + } + } + } else { + let pattern = parts.next().unwrap_or("").trim().to_string(); + // New path keeps spaces: slice after pattern. + let new_raw: String = if pattern.is_empty() { + String::new() + } else { + after_op + .find(&pattern) + .map(|p| after_op[p + pattern.len()..].trim().to_string()) + .unwrap_or_default() + }; + if pattern.is_empty() || new_raw.is_empty() { + self.command_line.push_error(crate::t!("Usage: XREF Path ").as_ref()); + } else { + let base_dir: std::path::PathBuf = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + let matched: Vec<_> = entries + .iter() + .filter(|e| crate::io::xref_model::wildcard_match(&e.name, &pattern)) + .collect(); + if matched.is_empty() { + self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + pattern + ).as_ref()); + } else { + self.push_undo_snapshot(i, "XREF-PATH"); + let mut done = 0usize; + for e in matched { + match crate::io::xref::set_ref_path( + &mut self.tabs[i].scene.document, + e.key, + &new_raw, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: Path set for \"{}\" — Reload to apply.", + name + ).as_ref()); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + if done > 0 { + self.post_ref_op(i); + } + } + } + } + } else if op.eq_ignore_ascii_case("Pathtype") { + // Pathtype [pattern] (empty/* = all). + let kind_tok = parts.next().unwrap_or("").trim(); + let pattern = parts.next().unwrap_or("").trim().to_string(); + let pathtype = if kind_tok.eq_ignore_ascii_case("Full") { + Some(crate::io::xref_model::Pathtype::Full) + } else if kind_tok.eq_ignore_ascii_case("Relative") { + Some(crate::io::xref_model::Pathtype::Relative) + } else if kind_tok.eq_ignore_ascii_case("None") { + Some(crate::io::xref_model::Pathtype::None) + } else { + None + }; + let Some(pathtype) = pathtype else { + self.command_line.push_error(crate::t!("Usage: XREF Pathtype [pattern]").as_ref()); + return Some(self.finish_dispatch(cmd)); + }; + let Some(host) = self.tabs[i].current_path.clone() else { + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + return Some(self.finish_dispatch(cmd)); + }; + let base_dir: std::path::PathBuf = host + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + let matched: Vec<_> = entries + .iter() + .filter(|e| { + pattern.is_empty() + || pattern == "*" + || crate::io::xref_model::wildcard_match(&e.name, &pattern) + }) + .collect(); + if matched.is_empty() { + self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + if pattern.is_empty() { "*" } else { &pattern } + ).as_ref()); + } else { + self.push_undo_snapshot(i, "XREF-PATHTYPE"); + let mut done = 0usize; + for e in matched { + match crate::io::xref::apply_pathtype( + &mut self.tabs[i].scene.document, + e.key, + pathtype, + &host, + ) { + Ok(_) => { + self.command_line.push_output(crate::tf!( + "XREF: Path set for \"{}\" — Reload to apply.", + e.name + ).as_ref()); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + if done > 0 { + self.post_ref_op(i); + } + } + } else if op.eq_ignore_ascii_case("Bind") { + // Bind (empty/* = all): fold each direct + // reference into a local block. Nested entries are + // rejected per-entry; images are collected next to the + // host file; PDFs report the unavailable-import error. + let pattern = parts.next().unwrap_or("").trim(); + let base_dir: std::path::PathBuf = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + let matched: Vec<_> = entries + .iter() + .filter(|e| { + pattern.is_empty() + || pattern == "*" + || crate::io::xref_model::wildcard_match(&e.name, pattern) + }) + .collect(); + if matched.is_empty() { + self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + pattern + ).as_ref()); + } else { + let Some(host) = self.tabs[i].current_path.clone() else { + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + return Some(self.finish_dispatch(cmd)); + }; + let host_dir: std::path::PathBuf = host + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + self.push_undo_snapshot(i, "XREF-BIND"); + let mut done = 0usize; + for e in matched { + if e.parent_key.is_some() { + self.command_line.push_error(crate::tf!( + "XREF: cannot bind nested reference '{}'. Bind it in its host drawing.", + e.name + ).as_ref()); + continue; + } + match crate::io::xref::bind_reference( + &mut self.tabs[i].scene.document, + e.key, + &base_dir, + &host_dir, + ) { + Ok(outcome) => { + if outcome.unremapped == 0 { + self.command_line.push_output(crate::tf!( + "XREF: bound \"{}\".", + outcome.name + ).as_ref()); + } else { + self.command_line.push_output(crate::tf!( + "XREF: bound \"{}\" with {} unremapped style handles (see bind limitations).", + outcome.name, outcome.unremapped + ).as_ref()); + } + self.tabs[i].xref_unloaded.remove(&e.key); + self.tabs[i].xref_stat_cache.remove(&e.key); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + if done > 0 { + self.post_ref_op(i); + } + } + } else if op.eq_ignore_ascii_case("Overlay") { + // Overlay : flip DWG refs to Overlay type. + let pattern = parts.next().unwrap_or("").trim(); + let base_dir: std::path::PathBuf = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + let matched: Vec<_> = entries + .iter() + .filter(|e| { + pattern.is_empty() + || pattern == "*" + || crate::io::xref_model::wildcard_match(&e.name, pattern) + }) + .collect(); + if matched.is_empty() { + self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + pattern + ).as_ref()); + } else { + self.push_undo_snapshot(i, "XREF-OVERLAY"); + let mut done = 0usize; + for e in matched { + match crate::io::xref::set_ref_type( + &mut self.tabs[i].scene.document, + e.key, + crate::io::xref_model::RefType::Overlay, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: \"{}\" set to Overlay.", + name + ).as_ref()); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + if done > 0 { + self.post_ref_op(i); + } + } + } else if op.eq_ignore_ascii_case("Attach") { + // Attach []: no-file-arg form launches the file + // picker (same flow as XATTACH); a file arg threads + // straight into placement as the pick result. + let after = rest.get(op.len()..).unwrap_or("").trim(); + if after.is_empty() { + return Some(Task::done(Message::XAttachPick)); + } + let cmd = + crate::modules::insert::xattach::XAttachCommand::with_path( + after.to_string(), + ); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } else { + self.command_line.push_error(crate::tf!( + "XREF: unknown option '{}'. Options: ? Reload Unload Detach Path Pathtype Bind Overlay Attach", + op + ).as_ref()); } } "XRELOAD" => { - // Reload all xrefs for the current drawing. + // Reload all xrefs for the current drawing. Every direct key + // leaves the session-unloaded set (reloaded content is live + // again) with fresh stat baselines on the next refresh. if let Some(path) = &self.tabs[i].current_path.clone() { if let Some(base_dir) = path.parent() { + let reload_keys: Vec = + crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ) + .iter() + .map(|e| e.key) + .collect(); + self.push_undo_snapshot(i, "XREF-RELOAD"); + for key in &reload_keys { + self.tabs[i].xref_unloaded.remove(key); + self.tabs[i].xref_stat_cache.remove(key); + } let (infos, _dropped) = crate::io::xref::resolve_xrefs( &mut self.tabs[i].scene.document, base_dir, ); - for info in &infos { - match info.status { - crate::io::xref::XrefStatus::Loaded => { - self.command_line - .push_output(crate::tf!("XREF Reloaded \"{}\"", info.name).as_ref()); - } - crate::io::xref::XrefStatus::Recovered => { - self.command_line.push_error(crate::tf!( - "XREF Reloaded with repairs: \"{}\"", - info.name - ).as_ref()); - } - crate::io::xref::XrefStatus::NotFound => { - self.command_line.push_error(crate::tf!( - "XREF Not found: \"{}\" ({})", - info.name, info.path - ).as_ref()); - } - crate::io::xref::XrefStatus::Failed => { - self.command_line.push_error(crate::tf!( - "XREF Reload failed: \"{}\" ({})", - info.name, info.path - ).as_ref()); - } - crate::io::xref::XrefStatus::Unloaded => { - self.command_line.push_info(crate::tf!( - "XREF Unloaded (skipped): \"{}\"", - info.name - ).as_ref()); + // `resolve_xrefs` re-merges ALL references — refresh stat + // baselines for every currently-Loaded entry so no row + // reports false Stale. + { + let fresh = + crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + for e in &fresh { + if e.status == crate::io::xref_model::RefStatus::Loaded { + if let Some(m) = e.modified { + self.tabs[i].xref_stat_cache.insert(e.key, m); + } } } } - self.tabs[i].scene.populate_hatches_from_document(); - self.tabs[i].scene.populate_images_from_document(); - self.tabs[i].scene.populate_meshes_from_document(); - // The reload may have merged new xref layers / - // linetypes — mirror them into the Layers panel and - // ribbon dropdowns (#407). - self.refresh_layer_panel(); + for info in &infos { + self.report_xref_status(info); + } + self.post_ref_op(i); } } else { self.command_line @@ -784,6 +1450,476 @@ mod tests { assert!(app.block_palette.blocks.iter().any(|b| b.name == "Widget")); } + #[test] + fn xref_dash_alias_lists_like_xref() { + // `-XREF` is accepted as an alias for industry muscle memory; bare + // `XREF` keeps the legacy list output for compatibility. + let mut app = fresh_app(); + let out = run_capture(&mut app, "-XREF ?"); + assert!(out.contains("No external references") || out.contains("External references")); + } + + #[test] + fn xref_question_mark_lists_count() { + let mut app = fresh_app(); + let out = run_capture(&mut app, "XREF ?"); + assert!(out.contains("No external references") || out.contains("External references")); + } + + #[test] + fn externalreferences_opens_palette() { + // EXTERNALREFERENCES is the palette command; XREFMAN is not kept, + // not even as an alias (single-token verbs take the no-suggest path, + // so an unknown verb reports "Unknown command"). + let mut app = fresh_app(); + let out = run_capture(&mut app, "EXTERNALREFERENCES"); + assert!(!out.contains("Unknown command"), "got: {out:?}"); + let out_old = run_capture(&mut app, "XREFMAN"); + assert!(out_old.contains("Unknown command"), "got: {out_old:?}"); + } + + #[test] + fn externalreferences_populates_panel_table() { + // The panel table must list whatever `XREF ?` lists: opening the + // palette refreshes entries synchronously in the command arm. + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", "old/plan.dwg"); + let list = run_capture(&mut app, "XREF ?"); + assert!(list.contains("PLAN"), "CLI lists it, got: {list:?}"); + let _ = run_capture(&mut app, "EXTERNALREFERENCES"); + assert!(app.show_external_references); + assert!( + app.xref_manager.entries.iter().any(|e| e.name == "PLAN"), + "panel table must list what XREF lists" + ); + assert!(app.xref_manager.display_rows().len() >= 2); + } + + #[test] + fn xref_reload_no_match_resolves_nothing() { + let mut app = fresh_app(); + let dirty_before = app.tabs[0].dirty; + let out = run_capture(&mut app, "XREF Reload ZZZ_NO_SUCH_REF"); + assert!(out.contains("no references match")); + assert_eq!(app.tabs[0].dirty, dirty_before); + } + + fn add_dwg_xref(app: &mut OpenCADStudio, name: &str, saved: &str) { + let i = app.active_tab; + let mut br = acadrust::tables::BlockRecord::new(name); + br.flags.is_xref = true; + br.xref_path = saved.to_string(); + br.handle = app.tabs[i].scene.document.allocate_handle(); + app.tabs[i].scene.document.block_records.add(br).unwrap(); + } + + #[test] + fn xref_path_stores_verbatim() { + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", "old/plan.dwg"); + let out = run_capture(&mut app, "XREF Path PLAN new\\raw path.dwg"); + assert!(out.contains("Path set"), "got: {out:?}"); + let i = app.active_tab; + assert_eq!( + app.tabs[i].scene.document.block_records.get("PLAN").unwrap().xref_path, + "new\\raw path.dwg" + ); + } + + #[test] + fn xref_pathtype_across_drives_errors() { + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", "D:/Lib/plan.dwg"); + let i = app.active_tab; + app.tabs[i].current_path = Some(std::path::PathBuf::from("C:/Drawings/host.dwg")); + let out = run_capture(&mut app, "XREF Pathtype Relative PLAN"); + assert!(out.contains("across drives"), "got: {out:?}"); + } + + #[test] + fn xref_overlay_on_image_errors() { + use acadrust::objects::{ImageDefinition, ObjectType}; + let mut app = fresh_app(); + let i = app.active_tab; + let h = app.tabs[i].scene.document.allocate_handle(); + let mut def = ImageDefinition::with_dimensions("img.png", 8, 8); + def.handle = h; + app.tabs[i].scene.document.objects.insert(h, ObjectType::ImageDefinition(def)); + // Reference it so collect_entries lists it. + let mut img = acadrust::entities::RasterImage::new( + "img.png", + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + img.definition_handle = Some(h); + app.tabs[i].scene.document.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + let out = run_capture(&mut app, "XREF Overlay img.png"); + assert!(out.contains("overlays apply to drawing references only"), "got: {out:?}"); + } + + #[test] + fn xref_reload_image_keeps_flag_no_spurious_match() { + // CLI mirror of the palette F7 guard: reloading an image row reports + // the drawing-only error, leaves its unloaded flag untouched, and + // does not follow with a spurious no-match error. + use acadrust::objects::{ImageDefinition, ObjectType}; + let mut app = fresh_app(); + let i = app.active_tab; + let h = app.tabs[i].scene.document.allocate_handle(); + let mut def = ImageDefinition::with_dimensions("img.png", 8, 8); + def.handle = h; + app.tabs[i].scene.document.objects.insert(h, ObjectType::ImageDefinition(def)); + let mut img = acadrust::entities::RasterImage::new( + "img.png", + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + img.definition_handle = Some(h); + app.tabs[i].scene.document.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + app.tabs[i].current_path = Some(std::path::PathBuf::from("C:/Drawings/host.dwg")); + app.tabs[i].xref_unloaded.add(h.value()); + let out = run_capture(&mut app, "XREF Reload img.png"); + assert!(out.contains("reload applies to drawing references only"), "got: {out:?}"); + assert!(!out.contains("no references match"), "spurious no-match, got: {out:?}"); + assert!( + app.tabs[i].xref_unloaded.is_unloaded(h.value()), + "image flag must stay untouched" + ); + } + + #[test] + fn xref_unload_reload_session_set_roundtrip() { + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", "old/plan.dwg"); + let i = app.active_tab; + let key = app.tabs[i] + .scene + .document + .block_records + .get("PLAN") + .unwrap() + .handle + .value(); + let out = run_capture(&mut app, "XREF Unload PLAN"); + assert!(out.contains("unloaded"), "got: {out:?}"); + assert!( + app.tabs[i].xref_unloaded.is_unloaded(key), + "unload must record the session set" + ); + // Reload clears the session set even though the file is missing + // (status report says Not found; the key leaves the set first). + let dir = std::env::temp_dir(); + app.tabs[i].current_path = Some(dir.join("ocs_xref_host.dwg")); + let out = run_capture(&mut app, "XREF Reload PLAN"); + assert!(!app.tabs[i].xref_unloaded.is_unloaded(key), "got: {out:?}"); + } + + #[test] + fn xref_detach_nested_guard_errors() { + use acadrust::tables::BlockRecord; + // Host file on disk containing its own xref "INNER". + let dir = std::env::temp_dir().join(format!( + "ocs_xref_nested_{}_{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut host_doc = acadrust::CadDocument::new(); + let mut inner = BlockRecord::new("INNER"); + inner.flags.is_xref = true; + inner.xref_path = "inner.dwg".to_string(); + host_doc.block_records.add(inner).unwrap(); + let bytes = crate::io::save_to_bytes(&host_doc, "dwg", host_doc.version).unwrap(); + std::fs::write(dir.join("host.dwg"), &bytes).unwrap(); + // App drawing references the host file; give the app a path in the + // same dir so nested enumeration resolves. + let mut app = fresh_app(); + let host_path = dir.join("host.dwg").to_string_lossy().into_owned(); + add_dwg_xref(&mut app, "HOST", &host_path); + let i = app.active_tab; + app.tabs[i].current_path = Some(dir.join("app.dwg")); + let out = run_capture(&mut app, "XREF Detach INNER"); + assert!(out.contains("cannot detach nested"), "got: {out:?}"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn xref_bind_basic_rewrites_symbols() { + let dir = std::env::temp_dir().join(format!( + "ocs_xref_bind_cli_{}_{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut xref_doc = acadrust::CadDocument::new(); + xref_doc + .layers + .add(acadrust::tables::Layer::new("WALLS")) + .unwrap(); + let mut line = acadrust::entities::Line::new(); + line.common.layer = "WALLS".to_string(); + xref_doc + .add_entity(acadrust::EntityType::Line(line)) + .unwrap(); + let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap(); + let xref_path = dir.join("plan.dwg"); + std::fs::write(&xref_path, &bytes).unwrap(); + + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", &xref_path.to_string_lossy()); + let i = app.active_tab; + app.tabs[i].current_path = Some(dir.join("app.dwg")); + let out = run_capture(&mut app, "XREF Bind PLAN"); + assert!(out.contains("bound \"PLAN\""), "got: {out:?}"); + let doc = &app.tabs[i].scene.document; + assert!(doc.layers.get("PLAN$0$WALLS").is_some()); + let br = doc.block_records.get("PLAN").unwrap(); + assert!(!br.flags.is_xref && br.xref_path.is_empty()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn xref_bind_reports_unremapped_handles() { + let dir = std::env::temp_dir().join(format!( + "ocs_xref_bind_limited_{}_{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut xref_doc = acadrust::CadDocument::new(); + let mut line = acadrust::entities::Line::new(); + line.common.plotstyle_flags = 0b11; + line.common.plotstyle_handle = Some(xref_doc.allocate_handle()); + xref_doc + .add_entity(acadrust::EntityType::Line(line)) + .unwrap(); + let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap(); + let xref_path = dir.join("plan.dwg"); + std::fs::write(&xref_path, &bytes).unwrap(); + + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", &xref_path.to_string_lossy()); + let i = app.active_tab; + app.tabs[i].current_path = Some(dir.join("app.dwg")); + let out = run_capture(&mut app, "XREF Bind PLAN"); + assert!(out.contains("with 1 unremapped style handles (see bind limitations)"), "got: {out:?}"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn xref_bind_pdf_errors() { + use acadrust::objects::{ObjectType, UnderlayDefinition}; + let mut app = fresh_app(); + let i = app.active_tab; + let h = app.tabs[i].scene.document.allocate_handle(); + let mut def = UnderlayDefinition::pdf("doc.pdf", "1"); + def.handle = h; + app.tabs[i] + .scene + .document + .objects + .insert(h, ObjectType::UnderlayDefinition(def)); + app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_xref_bind_host.dwg")); + let out = run_capture(&mut app, "XREF Bind doc.pdf"); + assert!(out.contains("PDF bind (vector import) is not available"), "got: {out:?}"); + } + + #[test] + fn xref_bind_nested_guard_errors() { + use acadrust::tables::BlockRecord; + let dir = std::env::temp_dir().join(format!( + "ocs_xref_bind_nested_{}_{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut host_doc = acadrust::CadDocument::new(); + let mut inner = BlockRecord::new("INNER"); + inner.flags.is_xref = true; + inner.xref_path = "inner.dwg".to_string(); + host_doc.block_records.add(inner).unwrap(); + let bytes = crate::io::save_to_bytes(&host_doc, "dwg", host_doc.version).unwrap(); + std::fs::write(dir.join("host.dwg"), &bytes).unwrap(); + let mut app = fresh_app(); + let host_path = dir.join("host.dwg").to_string_lossy().into_owned(); + add_dwg_xref(&mut app, "HOST", &host_path); + let i = app.active_tab; + app.tabs[i].current_path = Some(dir.join("app.dwg")); + let out = run_capture(&mut app, "XREF Bind INNER"); + assert!(out.contains("cannot bind nested"), "got: {out:?}"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn xattach_definition_undo_removes_definition() { + // F3: the CommitAndExit driver begins the undo transaction (structure + // image, delta-unsafe) BEFORE prepare_xref_block + the INSERT commit, + // so undo after XATTACH removes the definition, not just the entity. + // This mirrors that ordering: begin first, then prepare, then undo. + let mut app = fresh_app(); + let i = app.active_tab; + app.tabs[i].current_path = Some(std::path::PathBuf::from("C:/Drawings/host.dwg")); + let base = std::path::PathBuf::from("C:/Drawings"); + let pending = app.begin_undo(i, "ENTITY", 1, false).expect("begin undo"); + let name = crate::modules::insert::xattach::prepare_xref_block( + &mut app.tabs[i].scene, + "C:/refs/plan.dwg", + Some(&base), + ); + assert!( + app.tabs[i].scene.document.block_records.get(&name).is_some(), + "prepare must create the definition" + ); + app.commit_undo_delta(i, pending); + app.undo_active_tab(); + assert!( + app.tabs[i].scene.document.block_records.get(&name).is_none(), + "undo after XATTACH must remove the definition" + ); + } + + #[test] + fn xref_reload_pushes_undo() { + // F2: XREF Reload resolves (merges content) — it must push an undo + // snapshot first, mirroring the neighboring Unload/Detach arms. The + // snapshot sits in `pending` until the next history flush, so assert + // on the pending label, then that undo consumes it. + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", "old/plan.dwg"); + let i = app.active_tab; + app.tabs[i].current_path = + Some(std::path::PathBuf::from("C:/Drawings/host.dwg")); + let out = run_capture(&mut app, "XREF Reload PLAN"); + assert!(!out.contains("no references match"), "got: {out:?}"); + assert_eq!( + app.tabs[i].history.pending.as_ref().map(|p| p.label.as_str()), + Some("XREF-RELOAD"), + "reload must push an undo snapshot, got: {out:?}" + ); + let redo_before = app.tabs[i].history.redo_stack.len(); + app.undo_active_tab(); + assert!(app.tabs[i].history.pending.is_none()); + let _ = redo_before; + } + + #[test] + fn xref_attach_with_file_starts_placement() { + // F10: `XREF Attach ` threads the file into the XATTACH + // placement command (same flow as the picker result). + let mut app = fresh_app(); + let out = run_capture(&mut app, "XREF Attach C:/refs/plan.dwg"); + assert!( + !out.contains("ships with reference operations"), + "dead-end text must be gone, got: {out:?}" + ); + let i = app.active_tab; + assert_eq!( + app.tabs[i].active_cmd.as_ref().map(|c| c.name()), + Some("XATTACH") + ); + } + + #[test] + fn xref_attach_bare_requests_picker_without_dead_end() { + // F10: bare `XREF Attach` launches the file picker flow (the + // returned task); nothing starts synchronously and the Task 8/9 + // dead-end text is gone. + let mut app = fresh_app(); + let out = run_capture(&mut app, "XREF Attach"); + assert!( + !out.contains("ships with reference operations"), + "dead-end text must be gone, got: {out:?}" + ); + let i = app.active_tab; + assert!(app.tabs[i].active_cmd.is_none()); + } + + #[test] + fn xref_selective_reload_touches_only_matched_baselines() { + // F5 (targeted reload): `resolve_xrefs_for_keys` re-merges ONLY the + // matched refs, so stat baselines refresh for the matched subset + // alone. Unmatched rows keep their prior baseline (and their content + // is untouched), so no false Stale can arise from this reload. + let dir = std::env::temp_dir().join(format!( + "ocs_xref_reload_skew_{}_{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let a_path = dir.join("plan_a.dwg"); + let b_path = dir.join("plan_b.dwg"); + std::fs::write(&a_path, b"fake-a").unwrap(); + std::fs::write(&b_path, b"fake-b").unwrap(); + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN-A", &a_path.to_string_lossy()); + add_dwg_xref(&mut app, "PLAN-B", &b_path.to_string_lossy()); + let i = app.active_tab; + app.tabs[i].current_path = Some(dir.join("app.dwg")); + let key_a = app.tabs[i].scene.document.block_records.get("PLAN-A").unwrap().handle.value(); + let key_b = app.tabs[i].scene.document.block_records.get("PLAN-B").unwrap().handle.value(); + let out = run_capture(&mut app, "XREF Reload PLAN-A"); + assert!(!out.contains("no references match"), "got: {out:?}"); + assert!( + app.tabs[i].xref_stat_cache.0.contains_key(&key_a), + "matched entry baseline refreshed, got: {out:?}" + ); + assert!( + !app.tabs[i].xref_stat_cache.0.contains_key(&key_b), + "unmatched entry baseline untouched, got: {out:?}" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn xref_bind_undo_restores_xref_state() { + let dir = std::env::temp_dir().join(format!( + "ocs_xref_bind_undo_{}_{}", + std::process::id(), + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut xref_doc = acadrust::CadDocument::new(); + xref_doc + .layers + .add(acadrust::tables::Layer::new("WALLS")) + .unwrap(); + let mut line = acadrust::entities::Line::new(); + line.common.layer = "WALLS".to_string(); + xref_doc + .add_entity(acadrust::EntityType::Line(line)) + .unwrap(); + let bytes = crate::io::save_to_bytes(&xref_doc, "dwg", xref_doc.version).unwrap(); + let xref_path = dir.join("plan.dwg"); + std::fs::write(&xref_path, &bytes).unwrap(); + let saved = xref_path.to_string_lossy().into_owned(); + + let mut app = fresh_app(); + add_dwg_xref(&mut app, "PLAN", &saved); + let i = app.active_tab; + app.tabs[i].current_path = Some(dir.join("app.dwg")); + // Resolve first so `PLAN|*` names exist pre-bind. + let out = run_capture(&mut app, "XREF Reload PLAN"); + assert!(out.contains("Reloaded"), "got: {out:?}"); + assert!(app.tabs[i].scene.document.layers.get("PLAN|WALLS").is_some()); + let out = run_capture(&mut app, "XREF Bind PLAN"); + assert!(out.contains("bound \"PLAN\""), "got: {out:?}"); + assert!(app.tabs[i].scene.document.layers.get("PLAN$0$WALLS").is_some()); + + app.undo_active_tab(); + let doc = &app.tabs[i].scene.document; + let br = doc.block_records.get("PLAN").unwrap(); + assert!(br.flags.is_xref, "undo must restore the xref flag"); + assert_eq!(br.xref_path, saved, "undo must restore the xref path"); + assert!(doc.layers.get("PLAN|WALLS").is_some(), "undo must restore `|` names"); + assert!( + !doc.layers.names().any(|n| n.contains("PLAN$0")), + "undo must drop the bound names" + ); + std::fs::remove_dir_all(&dir).ok(); + } + #[test] fn blockpalette_refreshes_when_switching_to_same_named_block_in_another_tab() { let mut app = fresh_app(); diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index a7604403e..4dd6b2d5b 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -869,6 +869,24 @@ impl OpenCADStudio { "PRINTALL" => { return Some(Task::done(Message::PrintAllOpen)); } + // PRINTERS — what the system reports about printing, on the + // command line: the default printer, every printer it lists (or + // why it could not), and how a plot will reach them. The report + // a user pastes into a bug report instead of a screenshot. + "PRINTERS" => { + for line in crate::io::print_to_printer::printer_report() { + self.command_line.push_output(&line); + } + } + // PRINTERS — what that printer reports about its sheets + // and printable area (asks the driver; may take a moment for an + // offline network queue). + cmd if cmd.starts_with("PRINTERS ") => { + let name = cmd["PRINTERS ".len()..].trim(); + for line in crate::io::print_to_printer::printer_media_report(name) { + self.command_line.push_output(&line); + } + } "EXPORT" | "EXPORTPDF" => { return Some(Task::done(Message::PlotExport)); } @@ -1163,7 +1181,7 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(c)); } cmd if cmd.starts_with("HYPERLINK ") => { - use acadrust::xdata::{ExtendedDataRecord, XDataValue}; + use acadrust::xdata::XDataValue; let url = cmd.strip_prefix("HYPERLINK").unwrap_or("").trim().to_string(); if url.is_empty() { self.command_line.push_info(crate::t!("Usage: HYPERLINK (select objects first)").as_ref()); @@ -1183,15 +1201,19 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "HYPERLINK"); let mut n = 0usize; for h in &handles { - if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) { - let xd = &mut e.common_mut().extended_data; - let mut rec = ExtendedDataRecord::new("PE_URL"); - rec.add_value(XDataValue::String(url.clone())); - xd.add_record(rec); + if self.tabs[i].scene.document.get_entity(*h).is_some() { + crate::scene::view::dispatch::set_entity_xdata( + &mut self.tabs[i].scene.document, + *h, + "PE_URL", + Some(vec![XDataValue::String(url.clone())]), + ); n += 1; } } + self.invalidate_property_targets(i, &handles); self.tabs[i].dirty = true; + self.refresh_properties(); self.command_line .push_output(crate::tf!("HYPERLINK: attached to {n} object(s).").as_ref()); return Some(Task::none()); @@ -2094,3 +2116,23 @@ mod tests { } } } + +#[cfg(test)] +mod printers_command_tests { + use crate::app::OpenCADStudio; + + #[test] + fn printers_reports_the_default_and_the_list() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let before = app.command_line.history.len(); + let _ = app.run_command_line("PRINTERS"); + let lines: Vec = app.command_line.history[before..] + .iter() + .map(|line| line.text.clone()) + .collect(); + // One line for the default printer, at least one for the list (a + // count and names, "none", or the system's error). + assert!(lines.len() >= 2, "{lines:?}"); + } +} diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index d021b1d6b..582cbfb41 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -57,6 +57,10 @@ impl OpenCADStudio { return Some(Task::done(Message::ImagePick)); } + cmd if cmd == "IMAGEEMBED" => { + return Some(Task::done(Message::ImageEmbedPick)); + } + "REVCLOUD" | "REVCLOUD_RECTANGULAR" | "REVCLOUD_POLYGONAL" | "REVCLOUD_FREEHAND" => { use crate::modules::draw::draw::revcloud::RevCloudCommand; let view_height = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0; @@ -307,8 +311,8 @@ impl OpenCADStudio { } "ARC" => { - use crate::modules::draw::draw::arc::Arc3PCommand; - let new_cmd = Arc3PCommand::new(); + use crate::modules::draw::draw::arc::ArcCommand; + let new_cmd = ArcCommand::new(); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -319,8 +323,8 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } "ARC_CSE" => { - use crate::modules::draw::draw::arc::ArcCommand; - let new_cmd = ArcCommand::new(); + use crate::modules::draw::draw::arc::ArcCSECommand; + let new_cmd = ArcCSECommand::new(); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -1012,6 +1016,26 @@ impl OpenCADStudio { } // ── Persistent constraints ──────────────────────────────────── + "CONSTRAINTBAR" | "CONSTRAINTBAR_OPTIONS" => { + use crate::modules::parametric::ConstraintBarOptionCommand; + + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() && cmd == "CONSTRAINTBAR" { + use crate::modules::draw::select::SelectObjectsCommand; + let command = + SelectObjectsCommand::routed("CONSTRAINTBAR", "CONSTRAINTBAR_OPTIONS"); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else if handles.is_empty() { + self.command_line.push_output("No objects selected."); + } else { + let command = ConstraintBarOptionCommand::new(handles); + self.command_line.push_info(&command.prompt()); + self.command_line.set_step_options(command.options()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + } + "GCSHOW" | "GCHIDE" | "DCSHOW" | "DCHIDE" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { @@ -1044,6 +1068,24 @@ impl OpenCADStudio { .push_output(format!("{} constraint indicator(s) reset.", count).as_str()); } + "CONSTRAINTBAR_RESET" => { + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + self.command_line.push_output("No objects selected."); + } else { + let scope = self.tabs[i].current_parametric_scope(); + let count = self.tabs[i].scene.set_parametric_constraint_visibility( + scope, + Some(&handles), + false, + true, + ); + self.command_line.push_output( + format!("{} constraint bar(s) reset.", count).as_str(), + ); + } + } + "GCSHOWALL" | "GCHIDEALL" | "DCSHOWALL" | "DCHIDEALL" => { let scope = self.tabs[i].current_parametric_scope(); let dimensional = cmd.starts_with("DC"); @@ -1165,10 +1207,15 @@ impl OpenCADStudio { .record_undo_parametric_constraints_before(scope, before); let count = inferred.len(); for (kind, refs) in inferred { - self.tabs[i] + let id = self.tabs[i] .scene .parametric_constraint_set_mut(scope) .add(kind, refs, None); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); } let changes: Vec<_> = handles .iter() @@ -1197,17 +1244,38 @@ impl OpenCADStudio { } } - "SMOOTHCONSTRAINT" => { + "GCSMOOTH" => { + use crate::modules::parametric::SmoothConstraintCommand; let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { - use crate::modules::draw::select::SelectObjectsCommand; - let sel = SelectObjectsCommand::new(cmd); - self.command_line.push_info(&sel.prompt()); - self.tabs[i].active_cmd = Some(Box::new(sel)); + let command = SmoothConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else if handles.len() != 2 { + self.tabs[i].scene.deselect_all(); + self.command_line.push_error( + "Smooth requires an open spline first and a target curve second.", + ); + let command = SmoothConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } else { - let Some(refs) = self.tabs[i].scene.smooth_constraint_refs(&handles) else { - self.command_line - .push_output("Select one open spline and one open target curve."); + let first = self.tabs[i].scene.document.get_entity(handles[0]); + let second = self.tabs[i].scene.document.get_entity(handles[1]); + let refs = first.zip(second).and_then(|(first, second)| { + SmoothConstraintCommand::preselected_refs( + (first, handles[0]), + (second, handles[1]), + ) + }); + let Some(refs) = refs else { + self.tabs[i].scene.deselect_all(); + self.command_line.push_error( + "Smooth requires an open spline first and a line, arc, polyline segment or open spline second.", + ); + let command = SmoothConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); return None; }; use crate::command::CmdResult; @@ -1281,11 +1349,16 @@ impl OpenCADStudio { .scene .record_undo_parametric_constraints_before(scope, before); for (_, kind, refs, value) in conversions { - self.tabs[i].scene.parametric_constraint_set_mut(scope).add( + let id = self.tabs[i].scene.parametric_constraint_set_mut(scope).add( kind, refs, Some(value), ); + self.tabs[i].scene.note_parametric_constraint_applied( + scope, + id, + self.constraint_bar_display, + ); } self.tabs[i].scene.erase_entities(&dimensions); let changes: Vec<_> = touched @@ -1304,33 +1377,101 @@ impl OpenCADStudio { } } - "HCONSTRAINT" | "VCONSTRAINT" | "FXCONSTRAINT" => { + "GCHORIZONTAL" | "VCONSTRAINT" | "GCVERTICAL" => { + use crate::command::{CmdResult, HorizontalConstraintSelection}; + use crate::modules::parametric::HorizontalConstraintCommand; + use crate::scene::parametric_constraints::ConstraintKind; + + // Vertical is Horizontal mirrored onto the working plane's Y + // axis: same picks, same 2Points flow, same reference types. + let vertical = cmd != "GCHORIZONTAL"; + let (kind, axis, label) = if vertical { + (ConstraintKind::Vertical, "Vertical", "Vertical constraint") + } else { + (ConstraintKind::Horizontal, "Horizontal", "Horizontal constraint") + }; + let new_command = || { + if vertical { + HorizontalConstraintCommand::vertical() + } else { + HorizontalConstraintCommand::new() + } + }; let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { - use crate::modules::draw::select::SelectObjectsCommand; - let sel = SelectObjectsCommand::new(cmd); - self.command_line.push_info(&sel.prompt()); - self.tabs[i].active_cmd = Some(Box::new(sel)); + let command = new_command(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } else if handles.len() != 1 { self.command_line - .push_output("Select exactly one entity, then run this constraint again."); + .push_error(&format!("{axis}: select exactly one compatible object.")); } else { - use crate::command::CmdResult; - use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; - let (kind, label) = match cmd { - "HCONSTRAINT" => (ConstraintKind::Horizontal, "Horizontal constraint"), - "VCONSTRAINT" => (ConstraintKind::Vertical, "Vertical constraint"), - _ => (ConstraintKind::Fixed, "Fixed constraint"), - }; - return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { - kind, - refs: vec![ParametricRef::whole(handles[0])], - driving_param: None, - label, - })); + let handle = handles[0]; + let reference = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| { + HorizontalConstraintCommand::preselected_reference(entity, handle) + }); + if let Some(reference) = reference { + let plane = self.tabs[i].ucs_xform().working_plane(); + let direction = if vertical { plane.y } else { plane.x }; + return Some(self.apply_cmd_result(CmdResult::AddHorizontalConstraint { + kind, + selection: HorizontalConstraintSelection::Reference(reference), + direction: acadrust::types::Vector3::new( + direction.x, + direction.y, + direction.z, + ), + label, + })); + } + self.tabs[i].scene.deselect_all(); + self.command_line.push_error(&format!( + "Invalid selection for {axis}. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc." + )); + let command = new_command(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } } + "FXCONSTRAINT" | "GCFIX" => { + use crate::command::CmdResult; + use crate::modules::parametric::FixConstraintCommand; + use crate::scene::parametric_constraints::ConstraintKind; + + // One preselected whole curve applies at once (ribbon + // "select first, then click"); anything else goes through + // the reference's own point-or-object prompt. + let handles = self.tabs[i].scene.selected_handles_in_order(); + if let [handle] = handles.as_slice() { + let reference = self.tabs[i] + .scene + .document + .get_entity(*handle) + .and_then(|entity| { + FixConstraintCommand::preselected_reference(entity, *handle) + }); + if let Some(reference) = reference { + return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { + kind: ConstraintKind::Fixed, + refs: vec![reference], + driving_param: None, + label: "Fixed constraint", + })); + } + } + if !handles.is_empty() { + self.tabs[i].scene.deselect_all(); + } + let command = FixConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "CCONSTRAINT" | "GCCOINCIDENT" => { use crate::modules::parametric::CoincidentConstraintCommand; let new_cmd = CoincidentConstraintCommand::new(); @@ -1352,8 +1493,93 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "PCONSTRAINT" | "QCONSTRAINT" | "ECONSTRAINT" | "TCONSTRAINT" | "LCONSTRAINT" - | "NRCONSTRAINT" => { + "QCONSTRAINT" | "GCPERPENDICULAR" => { + use crate::command::CmdResult; + use crate::modules::parametric::PerpendicularConstraintCommand; + + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + let command = PerpendicularConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else if handles.len() != 2 { + self.command_line + .push_output("Select exactly two perpendicular-compatible objects."); + } else { + let picks = handles + .iter() + .filter_map(|handle| { + let entity = self.tabs[i].scene.document.get_entity(*handle)?; + PerpendicularConstraintCommand::preselected_reference(entity, *handle) + }) + .collect::>(); + if picks.len() == 2 && picks[0].reference != picks[1].reference { + return Some(self.apply_cmd_result(CmdResult::AddPerpendicularConstraint { + first: picks[0].reference, + second: picks[1].reference, + first_fixed: picks[0].fixed_reference, + second_start: picks[1].start_reference, + label: "Perpendicular constraint", + })); + } + self.tabs[i].scene.deselect_all(); + self.command_line.push_error( + "Invalid selection for Perpendicular. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc.", + ); + let command = PerpendicularConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + } + + "TCONSTRAINT" => { + use crate::command::CmdResult; + use crate::modules::parametric::TangentConstraintCommand; + + let handles = self.tabs[i].scene.selected_handles_in_order(); + if handles.is_empty() { + let command = TangentConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else if handles.len() != 2 { + self.command_line + .push_output("Select exactly two tangent-compatible objects."); + } else { + let refs = handles + .iter() + .filter_map(|handle| { + let entity = self.tabs[i].scene.document.get_entity(*handle)?; + TangentConstraintCommand::preselected_reference(entity, *handle) + }) + .collect::>(); + if refs.len() == 2 && refs[0] != refs[1] { + return Some(self.apply_cmd_result(CmdResult::AddTangentConstraint { + first: refs[0], + second: refs[1], + label: "Tangent constraint", + })); + } + self.tabs[i].scene.deselect_all(); + self.command_line.push_error( + "Invalid selection for Tangent. Select a line, polyline segment, circle, arc or ellipse.", + ); + let command = TangentConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + } + + "ECONSTRAINT" | "GCEQUAL" => { + use crate::modules::parametric::EqualConstraintCommand; + // Both objects are picked inside the command, as in the + // reference; a selection made beforehand is not used. + self.tabs[i].scene.deselect_all(); + let command = EqualConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + + "PCONSTRAINT" | "LCONSTRAINT" | "NRCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; @@ -1369,13 +1595,8 @@ impl OpenCADStudio { use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; let (kind, label) = match cmd { "PCONSTRAINT" => (ConstraintKind::Parallel, "Parallel constraint"), - "QCONSTRAINT" => { - (ConstraintKind::Perpendicular, "Perpendicular constraint") - } - "TCONSTRAINT" => (ConstraintKind::Tangent, "Tangent constraint"), "LCONSTRAINT" => (ConstraintKind::Colinear, "Colinear constraint"), - "NRCONSTRAINT" => (ConstraintKind::Normal, "Normal constraint"), - _ => (ConstraintKind::Equal, "Equal constraint"), + _ => (ConstraintKind::Normal, "Normal constraint"), }; return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { kind, @@ -1389,56 +1610,81 @@ impl OpenCADStudio { } } - "NCONSTRAINT" => { + "GCCONCENTRIC" => { + use crate::command::CmdResult; + use crate::modules::parametric::ConcentricConstraintCommand; + let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { - use crate::modules::draw::select::SelectObjectsCommand; - let sel = SelectObjectsCommand::new(cmd); - self.command_line.push_info(&sel.prompt()); - self.tabs[i].active_cmd = Some(Box::new(sel)); - } else if handles.len() != 2 { - self.command_line.push_output( - "Select exactly two circles/arcs, then run this constraint again.", - ); + let command = ConcentricConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } else { - use crate::command::CmdResult; - use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; - return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: ConstraintKind::Concentric, - refs: vec![ - ParametricRef::center(handles[0]), - ParametricRef::center(handles[1]), - ], - driving_param: None, - label: "Concentric constraint", - })); + let refs = handles + .iter() + .filter_map(|handle| { + let entity = self.tabs[i].scene.document.get_entity(*handle)?; + ConcentricConstraintCommand::preselected_reference(entity, *handle) + }) + .collect::>(); + if handles.len() == 2 && refs.len() == 2 && refs[0] != refs[1] { + return Some(self.apply_cmd_result(CmdResult::AddConcentricConstraint { + first: refs[0], + second: refs[1], + label: "Concentric constraint", + })); + } + self.tabs[i].scene.deselect_all(); + self.command_line.push_error( + "Invalid selection for Concentric. Select a circle, arc, ellipse or polyline arc segment.", + ); + let command = refs + .first() + .copied() + .map(ConcentricConstraintCommand::with_first) + .unwrap_or_else(ConcentricConstraintCommand::new); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } } "SYCONSTRAINT" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { - use crate::modules::draw::select::SelectObjectsCommand; - let sel = SelectObjectsCommand::new(cmd); - self.command_line.push_info(&sel.prompt()); - self.tabs[i].active_cmd = Some(Box::new(sel)); - } else if handles.len() != 3 { - self.command_line.push_output( - "Select exactly three entities (two circles/arcs, then the mirror line), then run this constraint again.", - ); + use crate::modules::parametric::SymmetricConstraintCommand; + let command = SymmetricConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } else { - use crate::command::CmdResult; - use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; - return Some(self.apply_cmd_result(CmdResult::AddParametricConstraint { - kind: ConstraintKind::Symmetric, - refs: vec![ - ParametricRef::center(handles[0]), - ParametricRef::center(handles[1]), - ParametricRef::whole(handles[2]), - ], - driving_param: None, - label: "Symmetric constraint", - })); + use crate::command::{ + CmdResult, SymmetricConstraintSelection, + }; + use crate::modules::parametric::SymmetricConstraintCommand; + + let refs = (handles.len() == 3).then(|| { + let first = self.tabs[i].scene.document.get_entity(handles[0])?; + let second = self.tabs[i].scene.document.get_entity(handles[1])?; + let axis = self.tabs[i].scene.document.get_entity(handles[2])?; + SymmetricConstraintCommand::preselected_refs( + (first, handles[0]), + (second, handles[1]), + (axis, handles[2]), + ) + }).flatten(); + if let Some([first, second, axis]) = refs { + return Some(self.apply_cmd_result(CmdResult::AddSymmetricConstraint { + selection: SymmetricConstraintSelection::Objects(first, second), + axis, + label: "Symmetric constraint", + })); + } + self.tabs[i].scene.deselect_all(); + self.command_line.push_error( + "Symmetric: select two compatible objects followed by a line axis.", + ); + let command = SymmetricConstraintCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } } @@ -2260,3 +2506,22 @@ mod region_tests { } } } + +#[cfg(test)] +mod degenerate_constraint_tests { + use crate::app::OpenCADStudio; + + /// Retaining a zero-length line's size used to hand the solver a NaN + /// Jacobian, and its SVD fallback never returned. + #[test] + fn equal_constraint_with_a_zero_length_line_returns() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 0,0 "}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 5,5 20,7 "}"#); + app.automation_op(r#"{"op":"select","type":"Line"}"#); + app.automation_op(r#"{"op":"run","cmd":"ECONSTRAINT"}"#); + let lines = app.automation_op(r#"{"op":"query","type":"Line","detail":"geometry"}"#); + assert_eq!(lines["ok"], true, "{lines}"); + } +} diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 161a6c28c..eb33b4ca0 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -1302,8 +1302,9 @@ impl OpenCADStudio { let all_mode = cmd.starts_with("FINDALL"); let rest = cmd.split_once(' ').map(|(_, r)| r.trim()).unwrap_or(""); - // Split at " REPLACE " keyword (case-insensitive) - let (search, replacement) = if let Some(pos) = rest.to_uppercase().find(" REPLACE ") + // Split at " REPLACE " keyword (case-insensitive). ASCII case + // mapping keeps byte offsets valid for slicing `rest`. + let (search, replacement) = if let Some(pos) = rest.to_ascii_uppercase().find(" REPLACE ") { (&rest[..pos], Some(rest[pos + 9..].trim())) } else { @@ -2386,3 +2387,20 @@ mod align_selected_bounds_tests { assert_eq!(line_start_x(&app, b), 2.0, "b must keep its offset from a, not collapse onto it"); } } + +#[cfg(test)] +mod find_replace_command_tests { + use super::*; + + /// Unicode case mapping can change a string's byte length (`ı` uppercases + /// to `I`), so the REPLACE keyword split must not slice with offsets + /// taken from a case-mapped copy. + #[test] + fn find_replace_accepts_search_text_whose_case_mapping_changes_length() { + for cmd in ["FIND ı REPLACE x", "FINDALL fi REPLACE fi", "FIND ʼn replace n"] { + let mut app = OpenCADStudio::new_for_test(); + let i = app.active_tab; + let _ = app.dispatch_inquiry(cmd, i); + } + } +} diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index 0ae2e63b5..34117610e 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -314,6 +314,135 @@ impl OpenCADStudio { self.command_line .push_output(crate::t!("UCS aligned to the current view.").as_ref()); } + // UCS FACE — adopt a solid's planar face + // as the drawing plane. This is the move Fusion calls + // "create sketch on a face": pick the face, draw flat on + // it, extrude. The pick comes from a surface pick, so it + // already lies on the face and serves as the origin. + "FACE" | "F" => { + let target = parts + .get(2) + .and_then(|token| { + u64::from_str_radix(token.trim().trim_start_matches("0x"), 16).ok() + }) + .map(acadrust::Handle::new) + .zip( + parts + .get(3) + .and_then(|value| super::super::helpers::parse_coord(value)) + .map(|(point, _)| point), + ); + match target { + // Bare `UCS FACE` starts the pick. The click comes + // back through this same arm with a handle and a + // surface point, so both entry points share the + // plane construction below. + None => { + let picker = crate::command::UcsPickCommand { face: true }; + self.command_line.push_info(&picker.prompt()); + self.tabs[i].active_cmd = Some(Box::new(picker)); + } + Some((handle, pick)) => { + let built = self.tabs[i] + .scene + .solid_models + .get(&handle) + .and_then(|body| { + use crate::scene::model::solid_model; + let face = + solid_model::nearest_planar_face(body, pick.to_array())?; + solid_model::planar_face_normal(body, face) + }) + .and_then(|normal| { + super::super::helpers::ucs_from_normal( + pick, + glam::DVec3::from_array(normal), + ) + }); + match built { + Some(ucs) => { + self.tabs[i].active_ucs = Some(ucs); + active_changed = true; + self.command_line.push_output( + crate::t!("UCS aligned to the selected face.").as_ref(), + ); + } + None => self.command_line.push_error( + crate::t!("UCS FACE: no planar face at that point.") + .as_ref(), + ), + } + } + } + } + // UCS OBJECT — adopt the plane of a planar + // entity. Reuses the profile extraction EXTRUDE already + // runs, so anything extrudable can also be sketched on. + "OBJECT" | "OB" => { + let target = parts + .get(2) + .and_then(|token| { + u64::from_str_radix(token.trim().trim_start_matches("0x"), 16).ok() + }) + .map(acadrust::Handle::new); + match target { + // Bare `UCS OBJECT` starts the pick; the click + // comes back through this arm with the handle. + None => { + let picker = crate::command::UcsPickCommand { face: false }; + self.command_line.push_info(&picker.prompt()); + self.tabs[i].active_cmd = Some(Box::new(picker)); + } + Some(handle) => { + let built = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(crate::scene::model::sweep_model::extrusion_profile_of) + .and_then(|(profile, _)| { + // The profile plane already carries the + // entity's own in-plane axes, so adopt + // them rather than re-deriving X from + // the normal: a sketch started on a + // circle then lines up with how that + // circle is stored, not with an + // arbitrary reference direction. + let plane = profile.plane; + let x = glam::DVec3::from_array(plane.x_axis); + let y = glam::DVec3::from_array(plane.y_axis); + if x.length_squared() < 1e-12 + || y.length_squared() < 1e-12 + { + return None; + } + let mut ucs = Ucs::new("*ACTIVE*"); + ucs.origin = Vector3::new( + plane.origin[0], + plane.origin[1], + plane.origin[2], + ); + ucs.x_axis = + Vector3::new(x.x, x.y, x.z); + ucs.y_axis = + Vector3::new(y.x, y.y, y.z); + Some(ucs) + }); + match built { + Some(ucs) => { + self.tabs[i].active_ucs = Some(ucs); + active_changed = true; + self.command_line.push_output( + crate::t!("UCS aligned to the selected object.") + .as_ref(), + ); + } + None => self.command_line.push_error( + crate::t!("UCS OBJECT: entity defines no plane.").as_ref(), + ), + } + } + } + } "3POINTW" => { let raw = parts.get(2).copied().unwrap_or(""); let points: Vec = raw @@ -506,6 +635,72 @@ impl OpenCADStudio { // PLAN — plan view of a UCS (#326). Shows its options right on // activation instead of flipping the view like a cube click; the // view only changes once an option (or the Current default) runs. + // ── Sketch mode ────────────────────────────────────────────── + // Fusion's sketch loop, assembled from pieces that already exist: + // a UCS for the plane, PLAN for the square-on view, the snapper + // for the drafting aids. A sketch is a mode, not a document + // object, so what it produces is ordinary model-space geometry + // and the file stays a DWG every other program can still open. + cmd if cmd == "CREATESKETCH" || cmd.starts_with("CREATESKETCH ") => { + if self.tabs[i].sketch_session.is_some() { + self.command_line.push_error( + crate::t!("A sketch is already open — FINISHSKETCH closes it.") + .as_ref(), + ); + return Some(Task::none()); + } + let arg = cmd["CREATESKETCH".len()..].trim().to_uppercase(); + // The three origin planes. Without them the first sketch in + // an empty drawing would have no face to land on. + let origin_plane = match arg.as_str() { + "XY" => Some(glam::DVec3::Z), + "XZ" => Some(glam::DVec3::NEG_Y), + "YZ" => Some(glam::DVec3::X), + _ => None, + }; + if let Some(normal) = origin_plane { + self.tabs[i].active_ucs = + super::super::helpers::ucs_from_normal(glam::DVec3::ZERO, normal); + self.commit_active_ucs_change(i, "CREATESKETCH"); + } else if !arg.is_empty() { + self.command_line.push_error( + crate::t!( + "Usage: CREATESKETCH [XY|XZ|YZ]; bare uses the current UCS." + ) + .as_ref(), + ); + return Some(Task::none()); + } + self.open_sketch(i); + } + // BROWSER — toggle the docked outline of planes, sketch and bodies. + "BROWSER" => { + self.show_browser ^= true; + if self.show_browser { + // Dock it on first use rather than shipping it in the + // default layout: an existing user's edges should not + // rearrange themselves on upgrade for a panel they have + // not asked for. + let id = crate::ui::dock::PanelId::Browser; + if self.dock.location(id).is_none() { + // dock() clamps an out-of-range index to the end of + // the edge, so this appends below whatever is there. + self.dock + .dock(id, crate::app::config::DockSide::Right, usize::MAX); + } + // Open expanded so it is usable straight away; the pin + // button still collapses it. + self.dock_expanded = Some(id); + self.command_line + .push_output(crate::t!("Browser opened.").as_ref()); + } else { + self.command_line + .push_output(crate::t!("Browser closed.").as_ref()); + } + } + "FINISHSKETCH" => { + self.finish_sketch(i); + } "PLAN" => { use crate::command::KeywordCommand; let c = KeywordCommand::new( @@ -1366,6 +1561,74 @@ impl OpenCADStudio { /// Snap to the plan view of the UCS whose rotation is `r_ucs`, straight — /// no "already there → flip to the opposite face" cube behaviour (#326). + /// Enter sketch mode on the current UCS. + /// + /// Squaring the view up is not cosmetic: drawing on a plane seen nearly + /// edge-on is the main way geometry ends up somewhere the user did not + /// mean, because a few pixels of cursor travel cover a long way in + /// world space. + fn open_sketch(&mut self, i: usize) { + let opened_with: std::collections::HashSet = self.tabs[i] + .scene + .document + .entities() + .map(|entity| entity.common().handle) + .collect(); + self.tabs[i].sketch_count += 1; + let name = format!("Sketch{}", self.tabs[i].sketch_count); + self.tabs[i].sketch_session = Some(crate::app::document::SketchSession { + name: name.clone(), + previous_ucs: self.tabs[i].active_ucs.clone(), + previous_snap_enabled: self.snapper.snap_enabled, + opened_with, + }); + let rotation = self.tabs[i].scene.viewcube_ucs_mat(); + self.plan_snap(i, rotation); + self.snapper.snap_enabled = true; + self.command_line.push_output( + crate::tf!("{} open. Draw on the plane, then FINISHSKETCH.", name).as_ref(), + ); + } + + /// Leave sketch mode, restoring what the sketch borrowed. + /// + /// Whatever was drawn becomes the "Previous" selection set, so the next + /// EXTRUDE can take it with the Previous keyword instead of making the + /// user reselect a profile they just finished drawing. + fn finish_sketch(&mut self, i: usize) { + let Some(session) = self.tabs[i].sketch_session.take() else { + self.command_line + .push_error(crate::t!("No sketch is open.").as_ref()); + return; + }; + let created: Vec = self.tabs[i] + .scene + .document + .entities() + .map(|entity| entity.common().handle) + .filter(|handle| !session.opened_with.contains(handle)) + .collect(); + self.tabs[i].active_ucs = session.previous_ucs; + self.snapper.snap_enabled = session.previous_snap_enabled; + self.commit_active_ucs_change(i, "FINISHSKETCH"); + if created.is_empty() { + self.command_line.push_output( + crate::tf!("{} closed; nothing was drawn.", session.name).as_ref(), + ); + } else { + let count = created.len(); + self.tabs[i].prev_selection = created; + self.command_line.push_output( + crate::tf!( + "{} closed with {} object(s). EXTRUDE then Previous uses them.", + session.name, + count + ) + .as_ref(), + ); + } + } + fn plan_snap(&mut self, i: usize, r_ucs: glam::Mat4) { let eye_dir = r_ucs.transform_vector3( crate::scene::CubeRegion::Face(crate::scene::pipeline::viewcube::FACE_TOP) diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 3e749827f..cc75d7fe1 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -12,6 +12,7 @@ mod fileops; mod inquiry; mod layerprops; mod layers; +mod plotvars; mod styleprops; mod view; @@ -63,17 +64,37 @@ impl OpenCADStudio { // routing, so every path below (Start-tab gate, plugins, all dispatch // families, the Repeat menu) sees the canonical command. Arguments after // the first space are left untouched. A non-alias passes through as-is. + // The transparent prefix of commercial solutions: `'PAN` / `'ZOOM` run in the middle + // of another command and hand control back to it afterwards. + let (cmd, quoted) = match cmd.trim().strip_prefix('\'') { + Some(rest) => (rest.trim(), true), + None => (cmd, false), + }; let resolved = self.resolve_alias(cmd); let cmd = resolved.as_deref().unwrap_or(cmd); + if is_spacemouse_command(cmd) { + return self.run_action(cmd); + } // A drafting aid only flips a flag, so it must not disturb whatever is // already running: pressing F8 partway through a LINE means "constrain // the rest of this line", not "abandon it". Everything below tears the // running command down, so a transparent one skips straight past it. - // (#677) - if is_transparent(cmd) { - return self - .dispatch_families(cmd, i) - .unwrap_or_else(Task::none); + // (#677) A quoted navigation command does the same; an interactive + // one (ZOOM Window) parks the running command and resumes it when the + // zoom ends, as does a ZOOM the zoom prompt itself hands off to. + let transparent = is_transparent(cmd) + || (quoted && is_transparent_capable(cmd)) + || (self.tabs[i].transparent_resume && cmd.starts_with("ZOOM")); + if transparent { + if zoom_prompts(cmd) && self.tabs[i].active_cmd.is_some() { + self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take(); + self.tabs[i].transparent_resume = true; + self.tabs[i].scene.clear_preview_wire(); + } + let task = self.dispatch_families(cmd, i).unwrap_or_else(Task::none); + // A one-shot zoom is over already — resume straight away. + self.resume_transparent_parent(i); + return task; } // A new command abandons any grip edit and its numeric input. let had_pending_grip_input = self.grip_pending.take().is_some(); @@ -112,6 +133,9 @@ impl OpenCADStudio { // template-property override too (#239). self.restore_add_selected_defaults(); } + // A command parked behind a transparent zoom / MTP goes with it. + self.tabs[i].suspended_cmd = None; + self.tabs[i].transparent_resume = false; // Starting any command leaves interactive navigation modes (their own // command arms below re-enable the selected one). self.tabs[i].pan_mode = false; @@ -120,6 +144,9 @@ impl OpenCADStudio { // Reset the last committed point so the first click of the new command // is not constrained by ortho/polar relative to a previous command's endpoint. self.last_point = None; + // A new command collects its own points, so the previous command's + // accepted snaps must not leak into it. + self.clear_accepted_snaps(); // Starting a command restarts the right-click cycle, so its first // right-click acts as Enter rather than opening the context menu. self.tabs[i] @@ -237,6 +264,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_layerprops(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_plotvars(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_styleprops(cmd, i) { return Some(t); } @@ -269,10 +299,38 @@ impl OpenCADStudio { /// point, so F8 during a MOVE both ended the move and made the dragged ghost /// vanish. Nothing here starts a command, opens a document or reads geometry, /// so there is nothing for the teardown to protect. (#677) -pub fn is_transparent(cmd: &str) -> bool { +/// Commands that may run transparently (`'PAN`, `'ZOOM …`): the navigation +/// set, which never edits the drawing. +fn is_transparent_capable(cmd: &str) -> bool { + cmd == "PAN" || cmd == "ZOOM" || cmd.starts_with("ZOOM ") || matches!(cmd, "ZW" | "ZE" | "ZA" | "ZP" | "ZI" | "ZO" | "ZD" | "ZEA" | "ZOBJ") +} + +/// ZOOM forms that install an interactive prompt (window corners, object +/// pick) and so need the running command parked while they last. +fn zoom_prompts(cmd: &str) -> bool { matches!( cmd, - "ORTHO" | "GRID" | "SNAP" | "POLAR" | "OSNAP" | "DSETTINGS" + "ZOOM" | "ZOOM WINDOW" | "ZOOM W" | "ZW" | "ZOOM OBJECT" | "ZOOM O" | "ZOBJ" + ) +} + +pub fn is_transparent(cmd: &str) -> bool { + is_spacemouse_command(cmd) + || matches!( + cmd, + "ORTHO" | "GRID" | "SNAP" | "POLAR" | "OSNAP" | "DSETTINGS" + ) +} + +fn is_spacemouse_command(cmd: &str) -> bool { + matches!( + cmd, + "SPACEMOUSE" + | "SPACEMOUSEPAUSE" + | "SPACEMOUSEPAN" + | "SPACEMOUSEPANZOOM" + | "SPACEMOUSEAUTO" + | "SPACEMOUSE3D" ) } @@ -281,7 +339,7 @@ pub fn is_transparent(cmd: &str) -> bool { /// source of truth: the dispatch gate refuses everything else, and the ribbon /// dims the tools this rejects. pub fn start_allowed(cmd: &str) -> bool { - matches!( + is_spacemouse_command(cmd) || matches!( cmd, "NEW" | "OPEN" @@ -484,6 +542,8 @@ inventory::submit!(crate::command::CommandRegistration { "MIRRTEXT", "ZOOMWHEEL", "ZOOMFACTOR", + "SHORTCUTMENU", + "SHORTCUTMENUDURATION", "CURSORSIZE", "PICKBOX", "CURSORTYPE", @@ -499,6 +559,16 @@ inventory::submit!(crate::command::CommandRegistration { "DELOBJ", "PLINEGEN", "PSLTSCALE", + // Plot preferences and the current-layout variables (commands/plotvars.rs). + "PLOTOFFSET", + "PAPERUPDATE", + "PLOTROTMODE", + "PLOTTRANSPARENCYOVERRIDE", + "BACKGROUNDPLOT", + "CTAB", + "TILEMODE", + "PSETUPIN", + "-PSETUPIN", "DISPSILH", "WORLDVIEW", "LIMCHECK", @@ -612,6 +682,7 @@ inventory::submit!(crate::command::CommandRegistration { "EXPORT", "EXPORTSTEP", "EXPORTSTL", + "EXTERNALREFERENCES", "EXTRIM", "FILETAB", "FIND", @@ -621,6 +692,7 @@ inventory::submit!(crate::command::CommandRegistration { "IM", "IMAGE", "IMAGEATTACH", + "IMAGEEMBED", "IMPORTOBJ", "ISOLATEOBJECTS", "LA", @@ -654,6 +726,7 @@ inventory::submit!(crate::command::CommandRegistration { "PERF", "PERSP", "PLOT", + "PRINTERS", "PLOTSTYLE", "PLOTSTYLEEDITOR", "PLOTSTYLEPANEL", @@ -681,6 +754,12 @@ inventory::submit!(crate::command::CommandRegistration { "SELSIM", // Draw a new object of the same type as the selected one. (#239) "ADDSELECTED", + // Outline of origin planes, open sketch and solid bodies. + "BROWSER", + // Open and close a sketch: a drawing plane, a square-on view and + // snapping, held together until the profile is done. + "CREATESKETCH", + "FINISHSKETCH", "SHEETSET", "SHORTCUTS", "SSM", @@ -713,6 +792,7 @@ inventory::submit!(crate::command::CommandRegistration { "XDATA", "XR", "XREF", + "-XREF", "XRELOAD", "ZOOM", "ZS", diff --git a/src/app/commands/plotvars.rs b/src/app/commands/plotvars.rs new file mode 100644 index 000000000..736d2b66d --- /dev/null +++ b/src/app/commands/plotvars.rs @@ -0,0 +1,259 @@ +//! Plot system variables and the page-setup import command. `PLOTOFFSET`, +//! `PAPERUPDATE`, `PLOTROTMODE`, `PLOTTRANSPARENCYOVERRIDE` and +//! `BACKGROUNDPLOT` are plot preferences of the application, kept with the +//! Plot dialog's persisted settings; `CTAB` and `TILEMODE` say which layout +//! of the drawing is current. Each is typeable on its own (`PLOTOFFSET 1`, +//! `CTAB Layout1`) and through `SETVAR`, and a bare name prompts for the +//! value like the other variables. `PSETUPIN` brings named page setups in +//! from another drawing (see `app::update::page_setup_import`). + +use crate::app::{Message, OpenCADStudio}; +use iced::Task; + +/// The variables this family answers for, for the command registry and the +/// `SETVAR ?` listing. +pub(super) const PLOT_SYSVARS: &[&str] = &[ + "PLOTOFFSET", + "PAPERUPDATE", + "PLOTROTMODE", + "PLOTTRANSPARENCYOVERRIDE", + "BACKGROUNDPLOT", + "CTAB", + "TILEMODE", +]; + +/// The `SETVAR ?` line for this family. +pub(super) fn setvar_listing() -> String { + format!("SETVAR (plot): {}", PLOT_SYSVARS.join(" ")) +} + +/// What setting a variable did. +enum Outcome { + /// The value was set (or was already that); the variable is part of the + /// application's settings and persists with them. + Preference(String), + /// The value was set and the drawing changed accordingly, possibly with + /// a follow-up task (a layout switch). + Drawing(String, Task), + /// No value given: the current one, for the prompt. + Current(String), +} + +impl OpenCADStudio { + pub(super) fn dispatch_plotvars(&mut self, cmd: &str, i: usize) -> Option> { + // PSETUPIN [file [* | name,name…]] — the dash form is the same + // command; both read the file and report on the command line. + for name in ["PSETUPIN", "-PSETUPIN"] { + if cmd == name { + return Some(self.on_psetupin("")); + } + if let Some(args) = cmd.strip_prefix(name).filter(|rest| rest.starts_with(' ')) { + return Some(self.on_psetupin(args)); + } + } + let rest = cmd.strip_prefix("SETVAR ").map(str::trim).unwrap_or(cmd); + let mut parts = rest.splitn(2, char::is_whitespace); + let name = parts.next().unwrap_or("").to_ascii_uppercase(); + if !PLOT_SYSVARS.contains(&name.as_str()) { + return None; + } + let value = parts.next().map(str::trim).filter(|v| !v.is_empty()); + let task = match self.plot_sysvar(i, &name, value) { + Ok(Outcome::Preference(shown)) => { + self.persist_settings_if_changed(); + self.command_line.push_output(&format!("{name} = {shown}")); + Task::none() + } + Ok(Outcome::Drawing(shown, task)) => { + self.command_line.push_output(&format!("{name} = {shown}")); + task + } + Ok(Outcome::Current(current)) => { + self.command_line.push_output( + crate::tf!("Enter new value for {name} <{current}>:").as_ref(), + ); + self.pending_setvar = Some(name); + Task::none() + } + Err(error) => { + self.command_line.push_error(&error); + Task::none() + } + }; + Some(task) + } + + fn plot_sysvar( + &mut self, + i: usize, + name: &str, + value: Option<&str>, + ) -> Result { + let parse_range = |value: &str, max: u8| -> Result { + value + .parse::() + .ok() + .filter(|v| *v <= max) + .ok_or_else(|| format!("SETVAR: integer from 0 to {max} required.")) + }; + let d = &mut self.plot_dialog; + match name { + "PLOTOFFSET" => Ok(match value { + Some(v) => { + d.plot_offset_from_edge = parse_range(v, 1)? == 1; + Outcome::Preference((d.plot_offset_from_edge as u8).to_string()) + } + None => Outcome::Current((d.plot_offset_from_edge as u8).to_string()), + }), + "PAPERUPDATE" => Ok(match value { + Some(v) => { + d.paper_update = parse_range(v, 1)?; + Outcome::Preference(d.paper_update.to_string()) + } + None => Outcome::Current(d.paper_update.to_string()), + }), + "PLOTROTMODE" => Ok(match value { + Some(v) => { + d.plot_rot_mode = parse_range(v, 2)?; + Outcome::Preference(d.plot_rot_mode.to_string()) + } + None => Outcome::Current(d.plot_rot_mode.to_string()), + }), + "PLOTTRANSPARENCYOVERRIDE" => Ok(match value { + Some(v) => { + d.transparency_override = parse_range(v, 2)?; + Outcome::Preference(d.transparency_override.to_string()) + } + None => Outcome::Current(d.transparency_override.to_string()), + }), + // Bit 1: single plots run in the background; bit 2: batch plots. + "BACKGROUNDPLOT" => { + let current = d.background as u8 | (d.background_publish as u8) << 1; + Ok(match value { + Some(v) => { + let bits = parse_range(v, 3)?; + d.background = bits & 1 != 0; + d.background_publish = bits & 2 != 0; + Outcome::Preference(bits.to_string()) + } + None => Outcome::Current(current.to_string()), + }) + } + "CTAB" => { + let current = self.tabs[i].scene.current_layout.clone(); + let Some(v) = value else { + return Ok(Outcome::Current(current)); + }; + let Some(target) = self.tabs[i] + .scene + .layout_names() + .into_iter() + .find(|layout| layout.eq_ignore_ascii_case(v)) + else { + return Err(format!("CTAB: no layout named \"{v}\".")); + }; + let task = if target == current { + Task::none() + } else { + self.on_layout_switch(target.clone()) + }; + Ok(Outcome::Drawing(target, task)) + } + // 1: model space; 0: the paper layout that was last current (the + // drawing's own `CTAB`), else the first one. + "TILEMODE" => { + let in_model = self.tabs[i].scene.current_layout == "Model"; + let Some(v) = value else { + return Ok(Outcome::Current((in_model as u8).to_string())); + }; + let model = parse_range(v, 1)? == 1; + if model == in_model { + return Ok(Outcome::Drawing((model as u8).to_string(), Task::none())); + } + let target = if model { + "Model".to_string() + } else { + let scene = &self.tabs[i].scene; + let layouts = scene.layout_names(); + crate::io::saved_active_layout(&scene.document) + .filter(|saved| saved != "Model" && layouts.contains(saved)) + .or_else(|| layouts.into_iter().find(|layout| layout != "Model")) + .ok_or_else(|| "TILEMODE: the drawing has no paper layout.".to_string())? + }; + let task = self.on_layout_switch(target); + Ok(Outcome::Drawing((model as u8).to_string(), task)) + } + _ => Err(format!("SETVAR: unknown variable \"{name}\".")), + } + } +} + +#[cfg(test)] +mod tests { + use crate::app::OpenCADStudio; + + fn app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app + } + + fn last_line(app: &OpenCADStudio) -> String { + app.command_line.history.last().map(|line| line.text.clone()).unwrap_or_default() + } + + #[test] + fn plot_preferences_are_typeable_directly_and_through_setvar() { + let mut app = app(); + let _ = app.run_command_line("PLOTOFFSET 1"); + assert!(app.plot_dialog.plot_offset_from_edge); + assert_eq!(last_line(&app), "PLOTOFFSET = 1"); + let _ = app.run_command_line("SETVAR PAPERUPDATE 1"); + assert_eq!(app.plot_dialog.paper_update, 1); + let _ = app.run_command_line("PLOTROTMODE 0"); + assert_eq!(app.plot_dialog.plot_rot_mode, 0); + let _ = app.run_command_line("PLOTTRANSPARENCYOVERRIDE 2"); + assert_eq!(app.plot_dialog.transparency_override, 2); + // Out-of-range values are refused and leave the setting alone. + let _ = app.run_command_line("PLOTROTMODE 3"); + assert_eq!(app.plot_dialog.plot_rot_mode, 0); + assert!(last_line(&app).contains("0 to 2")); + // The preferences reach the persisted plot config. + let config = app.current_config(); + assert!(config.plot.plot_offset_from_edge); + assert_eq!(config.plot.transparency_override, 2); + } + + #[test] + fn backgroundplot_maps_to_the_two_background_switches() { + let mut app = app(); + let _ = app.run_command_line("BACKGROUNDPLOT 2"); + assert!(!app.plot_dialog.background); + assert!(app.plot_dialog.background_publish); + let _ = app.run_command_line("BACKGROUNDPLOT 3"); + assert!(app.plot_dialog.background && app.plot_dialog.background_publish); + let _ = app.run_command_line("BACKGROUNDPLOT"); + assert_eq!(app.pending_setvar.as_deref(), Some("BACKGROUNDPLOT")); + assert!(last_line(&app).contains("<3>")); + } + + #[test] + fn ctab_and_tilemode_switch_the_current_layout() { + let mut app = app(); + assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Model"); + let _ = app.run_command_line("CTAB layout1"); + assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Layout1"); + assert_eq!(last_line(&app), "CTAB = Layout1"); + let _ = app.run_command_line("TILEMODE 1"); + assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Model"); + // Back to paper space: the layout that was current last. + let _ = app.run_command_line("SETVAR TILEMODE 0"); + assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Layout1"); + let _ = app.run_command_line("CTAB Nowhere"); + assert!(last_line(&app).contains("no layout named")); + assert_eq!(app.tabs[app.active_tab].scene.current_layout, "Layout1"); + let _ = app.run_command_line("CTAB"); + assert!(last_line(&app).contains("")); + assert_eq!(app.pending_setvar.as_deref(), Some("CTAB")); + } +} diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index f8ea83953..67353d6f8 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -956,6 +956,8 @@ impl OpenCADStudio { "MIRRTEXT" | "ZOOMWHEEL" | "ZOOMFACTOR" + | "SHORTCUTMENU" + | "SHORTCUTMENUDURATION" | "CURSORSIZE" | "PICKBOX" | "CURSORTYPE" @@ -1049,6 +1051,7 @@ impl OpenCADStudio { | "CONSTRAINTSOLVEMODE" | "CONSTRAINTINFER" | "CONSTRAINTBARDISPLAY" + | "CONSTRAINTBARMODE" ) => { return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); @@ -1070,8 +1073,9 @@ impl OpenCADStudio { let name = it.next().unwrap_or("").to_uppercase(); let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { + self.command_line.push_info(&super::plotvars::setvar_listing()); self.command_line.push_info( - crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), + crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), ); } else { if name == "CETRANSPARENCY" { @@ -1267,15 +1271,23 @@ impl OpenCADStudio { } if matches!( name.as_str(), - "CONSTRAINTSOLVEMODE" | "CONSTRAINTINFER" | "CONSTRAINTBARDISPLAY" + "CONSTRAINTSOLVEMODE" + | "CONSTRAINTINFER" + | "CONSTRAINTBARDISPLAY" + | "CONSTRAINTBARMODE" ) { let current = match name.as_str() { "CONSTRAINTSOLVEMODE" => i16::from(self.constraint_solve_mode), "CONSTRAINTINFER" => i16::from(self.constraint_infer), "CONSTRAINTBARDISPLAY" => self.constraint_bar_display, + "CONSTRAINTBARMODE" => self.constraint_bar_mode, _ => unreachable!(), }; - let maximum = if name == "CONSTRAINTBARDISPLAY" { 3 } else { 1 }; + let maximum = match name.as_str() { + "CONSTRAINTBARDISPLAY" => 3, + "CONSTRAINTBARMODE" => 4095, + _ => 1, + }; if let Some(value) = &value { match value .parse::() @@ -1291,6 +1303,7 @@ impl OpenCADStudio { "CONSTRAINTBARDISPLAY" => { self.constraint_bar_display = mode } + "CONSTRAINTBARMODE" => self.constraint_bar_mode = mode, _ => unreachable!(), } self.persist_settings_if_changed(); @@ -1490,6 +1503,51 @@ impl OpenCADStudio { Ok((format!("ZOOMFACTOR = {}", self.zoom_factor), false)) } }, + // Bit code of commercial solutions: 0 = no shortcut menus (right-click + // is Enter), 16 = time-sensitive; everything else is the + // regular shortcut menu. Reported as the defaults of commercial solutions + // (11 = default+edit+command menus, +16 when + // time-sensitive). + "SHORTCUTMENU" => { + use crate::app::settings::RightClickMode; + let code = |mode: RightClickMode| match mode { + RightClickMode::EnterFirst => 0, + RightClickMode::ShortcutMenu => 11, + RightClickMode::TimeSensitive => 27, + }; + match &value { + Some(v) => match v.parse::() { + Ok(bits) if (0..=31).contains(&bits) => { + self.right_click_mode = if bits == 0 { + RightClickMode::EnterFirst + } else if bits & 16 != 0 { + RightClickMode::TimeSensitive + } else { + RightClickMode::ShortcutMenu + }; + Ok((format!("SHORTCUTMENU = {}", code(self.right_click_mode)), true)) + } + _ => Err("SETVAR: integer from 0 to 31 required.".into()), + }, + None => Ok(( + format!("SHORTCUTMENU = {}", code(self.right_click_mode)), + false, + )), + } + } + "SHORTCUTMENUDURATION" => match &value { + Some(v) => match v.parse::() { + Ok(ms) if (100..=1000).contains(&ms) => { + self.right_click_hold_ms = ms; + Ok((format!("SHORTCUTMENUDURATION = {ms}"), true)) + } + _ => Err("SETVAR: integer from 100 to 1000 required.".into()), + }, + None => Ok(( + format!("SHORTCUTMENUDURATION = {}", self.right_click_hold_ms), + false, + )), + }, "CURSORSIZE" => match &value { Some(v) => match v.parse::() { Ok(size) if (1..=100).contains(&size) => { @@ -2347,6 +2405,8 @@ impl OpenCADStudio { name.as_str(), "ZOOMWHEEL" | "ZOOMFACTOR" + | "SHORTCUTMENU" + | "SHORTCUTMENUDURATION" | "CURSORSIZE" | "PICKBOX" | "CURSORTYPE" diff --git a/src/app/config.rs b/src/app/config.rs index 6d1c8f0eb..f8d2f26d3 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -176,11 +176,20 @@ impl UiThemePalette { } } -pub fn builtin_theme(name: &str) -> Option { +/// Every theme the user may pick: iced's built-ins followed by the Fusion +/// pair. The Fusion themes are `Theme::Custom`, so they are not in +/// `iced::Theme::ALL`; anything enumerating themes for display or for test +/// coverage must use this instead, or they silently vanish from the list. +pub fn all_themes() -> Vec { iced::Theme::ALL .iter() - .find(|theme| theme.to_string() == name) .cloned() + .chain(crate::ui::style::fusion_theme::fusion_themes()) + .collect() +} + +pub fn builtin_theme(name: &str) -> Option { + all_themes().into_iter().find(|theme| theme.to_string() == name) } fn color_to_rgb(color: iced::Color) -> [u8; 3] { @@ -376,7 +385,14 @@ pub fn theme_canvas_background(theme: &iced::Theme) -> [u8; 3] { iced::Theme::Nightfly => [1, 22, 39], iced::Theme::Oxocarbon => [22, 22, 22], iced::Theme::Ferra => [43, 41, 46], - _ => color_to_rgb(theme.palette().background.base.color), + // The Fusion pair deliberately breaks the "canvas follows chrome" + // rule: black chrome is paired with a white canvas, so falling + // through to the palette below would paint model space black and + // undo the whole point of the theme. + other => match crate::ui::style::fusion_theme::fusion_canvas(other) { + Some(rgb) => rgb, + None => color_to_rgb(other.palette().background.base.color), + }, } } @@ -407,6 +423,8 @@ pub fn parse_theme_name(s: &str) -> Option { "NIGHTFLY" => Some(iced::Theme::Nightfly), "OXOCARBON" => Some(iced::Theme::Oxocarbon), "FERRA" => Some(iced::Theme::Ferra), + "FUSIONBLACK" => Some(crate::ui::style::fusion_theme::fusion_black()), + "FUSIONWHITE" => Some(crate::ui::style::fusion_theme::fusion_white()), _ => None, } } diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 13ae83069..c5955c512 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -67,7 +67,54 @@ pub(super) const NAMES: &[&str] = &[ "undo", "redo", ]; + +/// Parse the `at` placement point for `embed_image`: `[x,y]` or `[x,y,z]`. +fn embed_point(req: &Value) -> Result { + let values = req["at"] + .as_array() + .filter(|v| (2..=3).contains(&v.len())) + .ok_or_else(|| failure("invalid_point", "Expected at:[x,y] or [x,y,z]"))?; + let mut p = [0.0_f64; 3]; + for (i, v) in values.iter().enumerate() { + p[i] = v + .as_f64() + .filter(|v| v.is_finite()) + .ok_or_else(|| failure("invalid_point", "Expected finite coordinates"))?; + } + Ok(acadrust::types::Vector3::new(p[0], p[1], p[2])) +} impl OpenCADStudio { + /// `embed_image` — pack the picture at `path` into an OLE2FRAME placed + /// with its lower-left corner at `at` (default width ≈ pixel_width/100, + /// like the interactive command's Enter answer) and commit it as one + /// undo step. The drawing stays self-contained: no external file to lose. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn control_embed_image(&mut self, req: &Value) -> Result, Value> { + let i = self.active_tab; + let path = string(req, "path")?; + let image = crate::io::ole_embed::EmbeddedImage::from_file(std::path::Path::new(&path)) + .map_err(|e| failure("embed_failed", e))?; + let at = embed_point(req)?; + let default_width = (image.pixel_width as f64 / 100.0).max(1.0); + let width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(default_width); + self.push_undo_snapshot(i, "IMAGEEMBED"); + let handle = crate::io::ole_embed::add_embedded_image( + &mut self.tabs[i].scene.document, + &image, + at, + width, + ) + .map_err(|e| failure("embed_failed", e))?; + self.post_ref_op(i); + self.set_control_result(json!({ + "handle": format!("{:X}", handle.value()), + "kind": "Ole2Frame", + "path": path, + "width": width, + })); + Ok(Task::none()) + } + pub(super) fn control_properties(&mut self) -> Value { self.refresh_properties(); json!({"ok":true,"sections":self.tabs[self.active_tab].properties.sections.iter().map(|s|json!({"title":s.title,"properties":s.props.iter().map(|p|{ @@ -75,6 +122,7 @@ impl OpenCADStudio { PropValue::ReadOnly(v)|PropValue::ReadOnlyWithTooltip{value:v,..}=>("readonly",json!(v),Value::Null), PropValue::EditText(v)=>("number",json!(v),Value::Null), PropValue::PlainText(v)=>("text",json!(v),Value::Null), + PropValue::Hyperlink(v)=>("hyperlink",json!(v),Value::Null), PropValue::Choice{selected,options}=>("choice",json!(selected),json!(options)), PropValue::EditChoice{value,options}=>("editable_choice",json!(value),json!(options)), PropValue::LayerChoice(v)=>("layer",json!(v),Value::Null), @@ -110,7 +158,10 @@ impl OpenCADStudio { .map(str::to_owned) .unwrap_or_else(|| req["value"].to_string()); Ok(match p.value { - PropValue::EditText(_) | PropValue::PlainText(_) | PropValue::EditChoice { .. } => { + PropValue::EditText(_) + | PropValue::PlainText(_) + | PropValue::Hyperlink(_) + | PropValue::EditChoice { .. } => { let input = self.update(Message::PropGeomInput { field: p.field, value, @@ -220,7 +271,8 @@ impl OpenCADStudio { "mtext_cancel" => Message::MTextCancel, "text_input" => Message::TextInlineInput(string(req, "value")?.into()), "text_commit" => Message::TextInlineOk, - "pointer_move" | "pointer_press" | "pointer_release" => { + "pointer_move" | "pointer_press" | "pointer_release" | "pointer_right_press" + | "pointer_right_release" => { let x = req["x"] .as_f64() .filter(|v| v.is_finite()) @@ -242,6 +294,10 @@ impl OpenCADStudio { let event = match name { "pointer_press" => self.update(Message::ViewportLeftPress), "pointer_release" => self.update(Message::ViewportLeftRelease), + // Right button: the context menu / Enter behaviour chosen in + // Options (see `Message::ViewportRightRelease`). + "pointer_right_press" => self.update(Message::ViewportRightPress), + "pointer_right_release" => self.update(Message::ViewportRightRelease), _ => Task::none(), }; return Ok(Task::batch([move_task, event])); diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 797f40365..97cf725f0 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -131,6 +131,24 @@ struct Operation { fn failure(code: &str, error: impl ToString) -> Value { json!({"ok":false,"status":"failed","code":code,"error":error.to_string()}) } +/// `{"op":"open","name":"plan.dwg","data_base64":"…"}`: the web build has no +/// filesystem path to open, so the caller sends the file itself. `name` is +/// reduced to its file name; it labels the tab and the recent-files entry. +fn open_bytes_request(req: &Value) -> Result<(String, Vec), Value> { + use base64::Engine as _; + let name = string(req, "name")?; + let name = name.rsplit(['/', '\\']).next().unwrap_or(name); + if name.is_empty() { + return Err(failure("invalid_request", "Missing name")); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(req["data_base64"].as_str().unwrap_or_default()) + .map_err(|e| failure("invalid_request", format!("data_base64: {e}")))?; + if bytes.is_empty() { + return Err(failure("invalid_request", "data_base64 is empty")); + } + Ok((name.to_string(), bytes)) +} fn string<'a>(req: &'a Value, key: &str) -> Result<&'a str, Value> { req[key] .as_str() @@ -220,6 +238,26 @@ fn command_category(name: &str) -> &'static str { "view" } else if name.starts_with("DIM") || ["LEADER", "MLEADER", "TOLERANCE"].contains(&name) { "annotate" + } else if [ + "GEOMCONSTRAINT", + "GCCOINCIDENT", + "GCCOLLINEAR", + "GCPERPENDICULAR", + "QCONSTRAINT", + "PCONSTRAINT", + "GCHORIZONTAL", + "VCONSTRAINT", + "ECONSTRAINT", + "GCEQUAL", + "TCONSTRAINT", + "GCCONCENTRIC", + "NRCONSTRAINT", + "LCONSTRAINT", + "DELCONSTRAINT", + ] + .contains(&name) + { + "parametric" } else if [ "BOX", "CYLINDER", @@ -289,11 +327,18 @@ fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value { accepts.push("entity"); } if accepts.is_empty() && !command.needs_tangent_pick() { - accepts.push(match command.input_kind() { + let kind = match command.input_kind() { InputKind::Point => "point", InputKind::SingleToken => "token", InputKind::FreeText => "text", - }); + }; + // A point step that also takes keyword letters (MOVE/COPY's base + // point with [Displacement]) reports SingleToken so the letters reach + // the command line, but it still takes a point. + if command.point_step_accepts_keywords() && kind != "point" { + accepts.push("point"); + } + accepts.push(kind); } if options.iter().any(|option| !option.keyword.is_empty()) && !accepts.contains(&"token") { accepts.push("token"); @@ -684,6 +729,24 @@ impl OpenCADStudio { let i = self.active_tab; Ok(match req["op"].as_str().unwrap_or("") { "new" => self.update(Message::TabNew), + "open" if req.get("data_base64").is_some() => { + let (name, bytes) = open_bytes_request(req)?; + #[cfg(target_arch = "wasm32")] + { + if self.opening.is_some() { + return Err(failure("busy", "Another drawing is still opening")); + } + self.open_web_bytes(name, bytes) + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (name, bytes); + return Err(failure( + "invalid_request", + "data_base64 is for the web build; supply path", + )); + } + } "open" => self.update(Message::OpenExternal(std::path::PathBuf::from(string( req, "path", )?))), @@ -781,6 +844,8 @@ impl OpenCADStudio { "set_properties" => self.control_set_record_properties(req)?, "action" => self.control_ui_action(req)?, #[cfg(not(target_arch = "wasm32"))] + "embed_image" => self.control_embed_image(req)?, + #[cfg(not(target_arch = "wasm32"))] "save" => { let path = req["path"] .as_str() @@ -809,8 +874,17 @@ impl OpenCADStudio { .main_window .ok_or_else(|| failure("gui_required", "Capture requires a GUI window"))?; let path = string(req, "path")?.to_owned(); - iced::window::screenshot(window) - .map(move |s| Message::ControlScreenshot(path.clone(), Some(s))) + // A minimized window has a 0x0 surface and the renderer + // panics reading it back, so report instead of capturing. + iced::window::size(window).then(move |size| { + let path = path.clone(); + if size.width <= 0.0 || size.height <= 0.0 { + Task::done(Message::ControlScreenshot(path, None)) + } else { + iced::window::screenshot(window) + .map(move |s| Message::ControlScreenshot(path.clone(), Some(s))) + } + }) } "stop" => { self.control.enabled = false; @@ -1005,7 +1079,8 @@ impl OpenCADStudio { screenshot: Option, ) { let result = (|| -> Result { - let s = screenshot.ok_or("Renderer did not return an image")?; + let s = screenshot + .ok_or("The window is minimized or has no size; restore it and capture again")?; let requested_scope = self .control .pending @@ -1387,4 +1462,108 @@ mod tests { ); let _ = std::fs::remove_file(path); } + + #[test] + fn open_from_bytes_validates_the_request() { + let ok = open_bytes_request(&json!({"name":"C:\\plans\\plan.dwg","data_base64":"AAEC"})); + assert_eq!(ok, Ok(("plan.dwg".to_string(), vec![0, 1, 2]))); + for (req, error) in [ + (json!({"data_base64":"AAEC"}), "Missing name"), + (json!({"name":"dir/","data_base64":"AAEC"}), "Missing name"), + ( + json!({"name":"a.dwg","data_base64":""}), + "data_base64 is empty", + ), + ] { + assert_eq!( + open_bytes_request(&req).unwrap_err()["error"], + error, + "{req}" + ); + } + let bad = open_bytes_request(&json!({"name":"a.dwg","data_base64":"not base64!"})); + assert_eq!(bad.unwrap_err()["code"], "invalid_request"); + } + #[test] + fn open_from_bytes_is_refused_natively_without_touching_documents() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + let tabs = app.tabs.len(); + let reply = request( + &mut app, + json!({"op":"open","name":"a.dxf","data_base64":"AAEC"}), + ); + assert_eq!(reply["code"], "invalid_request", "{reply}"); + assert_eq!(app.tabs.len(), tabs); + assert!(app.opening.is_none()); + } + + #[test] + fn point_steps_that_take_keywords_accept_points() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"run","cmd":"CIRCLE 0,0 3"})); + request(&mut app, json!({"op":"select","type":"CIRCLE"})); + let started = request(&mut app, json!({"op":"start","cmd":"MOVE"})); + assert_eq!(started["status"], "waiting_input", "{started}"); + let command = &started["state"]["command"]; + let accepts = command["accepts"].as_array().unwrap(); + assert!(accepts.contains(&json!("point")), "{command}"); + assert!(accepts.contains(&json!("token")), "{command}"); + assert_eq!(command["input_example"]["kind"], "point"); + let base = request( + &mut app, + json!({"op":"input","kind":"point","point":[0.,0.,0.]}), + ); + assert_eq!(base["status"], "waiting_input", "{base}"); + let moved = request( + &mut app, + json!({"op":"input","kind":"point","point":[0.,-10.,0.]}), + ); + assert_eq!(moved["status"], "completed", "{moved}"); + let circle = app.tabs[app.active_tab] + .scene + .document + .entities() + .find_map(|e| match e { + acadrust::EntityType::Circle(c) => Some(c.center.y), + _ => None, + }); + assert_eq!(circle, Some(-10.0)); + } + + #[test] + fn embed_image_op_places_an_ole2frame_and_undoes_it() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + let img = image::RgbaImage::from_pixel(40, 30, image::Rgba([9, 9, 9, 255])); + let path = std::env::temp_dir().join(format!("ocs-embed-{}.png", session_id())); + img.save(&path).unwrap(); + + let response = request( + &mut app, + json!({"op":"embed_image","path":path.to_string_lossy(),"at":[0,0,0],"width":20}), + ); + assert_eq!(response["status"], "completed", "{response}"); + let handle = response["result"]["handle"].as_str().unwrap().to_string(); + assert_eq!(response["result"]["kind"], "Ole2Frame"); + let ole_count = |app: &OpenCADStudio| { + app.tabs[app.active_tab] + .scene + .document + .entities() + .filter(|e| matches!(e, acadrust::EntityType::Ole2Frame(_))) + .count() + }; + assert_eq!(ole_count(&app), 1); + + // The entity must be queryable like any other and undoable as one step. + let queried = + app.automation_op(json!({"op":"entities","type":"OLE2FRAME"}).to_string().as_str()); + assert_eq!(queried["total"], 1, "{queried}"); + request(&mut app, json!({"op":"undo"})); + assert_eq!(ole_count(&app), 0); + let _ = std::fs::remove_file(path); + let _ = handle; + } } diff --git a/src/app/dim_viewport.rs b/src/app/dim_viewport.rs new file mode 100644 index 000000000..b5ddc83d2 --- /dev/null +++ b/src/app/dim_viewport.rs @@ -0,0 +1,361 @@ +//! Paper-space dimension acquisition and measurement. Acquiring model geometry +//! supplies a viewport scale; sheet-only dimensions retain paper units. + +use super::OpenCADStudio; +use crate::command::{DimensionAssociationSource, DimensionPreview}; +use crate::entities::dim_override; +use crate::scene::viewport_ref::{AcceptedSnap, MeasurementScale, SnapSourceRef, ViewportFrame}; +use acadrust::entities::Dimension; +use acadrust::types::Handle; +use acadrust::EntityType; +use glam::DVec3; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum DimensionMeasureSpace { + Direct, + Viewport(ViewportFrame), + ConflictingViewports, +} + +impl OpenCADStudio { + pub(crate) fn finish_command_click(&mut self, i: usize) { + let mut selection = self.tabs[i].scene.selection.borrow_mut(); + selection.left_down = false; + selection.left_press_pos = None; + selection.left_press_time = None; + selection.left_dragging = false; + } + + pub(crate) fn dimension_measure_space(&self, i: usize) -> DimensionMeasureSpace { + self.dimension_measure_space_with_candidate(i, None) + } + + fn dimension_measure_space_with_candidate( + &self, + i: usize, + candidate: Option, + ) -> DimensionMeasureSpace { + let scene = &self.tabs[i].scene; + if scene.current_layout == "Model" || scene.active_viewport.is_some() { + return DimensionMeasureSpace::Direct; + } + let mut frame: Option = None; + for f in self + .accepted_snaps() + .iter() + .filter_map(|snap| snap.frame) + .chain(candidate) + { + match frame { + None => frame = Some(f), + Some(previous) if previous.viewport == f.viewport => {} + Some(_) => return DimensionMeasureSpace::ConflictingViewports, + } + } + frame.map_or( + DimensionMeasureSpace::Direct, + DimensionMeasureSpace::Viewport, + ) + } + + /// Reject an incompatible measuring input before it advances the command. + pub(crate) fn dimension_acquisition_allowed( + &mut self, + i: usize, + viewport: Option, + ) -> bool { + if !self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|c| c.measures_through_viewports()) + { + return true; + } + // Sheet geometry and free sheet points may accompany one viewport. + // A second viewport would make the measurement scale ambiguous. + if viewport.is_some_and(|viewport| { + self.accepted_snaps() + .iter() + .filter_map(|s| s.viewport) + .any(|previous| previous != viewport) + }) { + self.command_line.push_error(crate::t!("Model geometry must come from one viewport. Paper-space points can be included.").as_ref()); + return false; + } + true + } + + /// A command can reject a degenerate point without advancing its step. + pub(crate) fn sync_dimension_snaps(&mut self, i: usize) { + if let Some(cmd) = self.tabs[i] + .active_cmd + .as_ref() + .filter(|c| c.measures_through_viewports()) + { + self.accepted_snaps + .truncate(cmd.dimension_acquired_points().len()); + } + } + + /// Record each definition point actually acquired by an object pick. + /// Arcs may supply three angular slots, lines two, and radial picks one. + pub(crate) fn record_dimension_entity_points( + &mut self, + i: usize, + frame: Option, + entity: Handle, + block_path: Vec, + ) { + let Some(cmd) = self.tabs[i] + .active_cmd + .as_ref() + .filter(|c| c.measures_through_viewports()) + else { + return; + }; + let points = cmd.dimension_acquired_points(); + for paper_point in points.into_iter().skip(self.accepted_snaps.len()) { + self.push_accepted_snap(AcceptedSnap { + paper_point, + model_point: frame.map_or(paper_point, |f| f.paper_to_model(paper_point)), + viewport: frame.map(|f| f.viewport), + frame, + source: Some(SnapSourceRef { + source: DimensionAssociationSource::inferred(entity), + block_path: block_path.clone(), + snap_type: crate::snap::SnapType::ObjectPick, + intersection: None, + }), + }); + } + } + + pub(crate) fn apply_viewport_dimension_measurement( + &mut self, + i: usize, + entity: &mut EntityType, + ) -> bool { + if !matches!(entity, EntityType::Dimension(_)) { + return true; + } + let angular = is_angular_dimension(entity); + let frame = match self.dimension_measure_space(i) { + DimensionMeasureSpace::Direct => return true, + DimensionMeasureSpace::ConflictingViewports => { + self.command_line.push_error(crate::t!("Model geometry must come from one viewport. Paper-space points can be included.").as_ref()); + return false; + } + DimensionMeasureSpace::Viewport(f) => f, + }; + if angular { + return true; + } + let Some(scale) = self.viewport_dimension_scale(i, entity, frame) else { + return false; + }; + // Owner is needed even by DIMASSOC=0's explode path, before insertion. + entity.common_mut().owner_handle = self.tabs[i].scene.current_layout_block_handle_pub(); + scale.write_to_entity(entity); + true + } + + fn viewport_dimension_scale( + &self, + i: usize, + entity: &EntityType, + frame: ViewportFrame, + ) -> Option { + let dimlfac = self.pending_dimension_style_dimlfac(i, entity); + let scale = MeasurementScale { + user_lfac: MeasurementScale::user_lfac_for_space(dimlfac, true), + viewport_compensation: frame.paper_to_model_length_factor(), + }; + (scale.paper_factor().is_finite() && scale.paper_factor() > 0.0).then_some(scale) + } + + /// Styled dimensions for the placement preview at `cursor`, carrying the + /// same style and viewport measurement the commit path would apply. + /// `None` when the active command has no committable dimension at this + /// stage; an empty list when the candidate geometry or scale is invalid. + pub(crate) fn dimension_preview_entities( + &self, + i: usize, + cursor: DVec3, + ) -> Option> { + let command = self.tabs[i].active_cmd.as_ref()?; + let previews = command.dimension_preview(cursor)?; + let scene = &self.tabs[i].scene; + let measure_space = if command.measures_through_viewports() { + // The hovered model snap supplies a provisional viewport only while + // the second definition point is still being acquired. + let candidate = if command.dimension_placement_pending() { + None + } else { + self.vp_snap_frame.filter(|frame| { + self.tabs[i] + .snap_result + .is_some_and(|hit| hit.viewport == Some(frame.viewport)) + }) + }; + self.dimension_measure_space_with_candidate(i, candidate) + } else { + DimensionMeasureSpace::Direct + }; + if matches!(measure_space, DimensionMeasureSpace::ConflictingViewports) { + return Some(Vec::new()); + } + let mut entities = Vec::with_capacity(previews.len()); + for DimensionPreview { + mut entity, + preserve_base_style, + } in previews + { + if !preserve_base_style { + crate::scene::creation_style::apply_current_creation_styles( + &scene.document, + &mut entity, + ); + } + entity.common_mut().owner_handle = scene.current_layout_block_handle_pub(); + if let DimensionMeasureSpace::Viewport(frame) = measure_space { + if !is_angular_dimension(&entity) { + let Some(scale) = self.viewport_dimension_scale(i, &entity, frame) else { + return Some(Vec::new()); + }; + scale.write_to_entity(&mut entity); + } + } + entities.push(entity); + } + Some(entities) + } + + pub(crate) fn dimension_preview_wires( + &self, + i: usize, + cursor: DVec3, + ) -> Option> { + let entities = self.dimension_preview_entities(i, cursor)?; + // Degenerate geometry, an invalid scale, or conflicting viewports + // produce no styled dimension. Returning None lets the command's + // plain rubber-band preview run, which shows no value to mistrust. + if entities.is_empty() { + return None; + } + let mut wires = self.tabs[i].scene.wires_for_entities(&entities); + for wire in &mut wires { + wire.color = crate::scene::WireModel::CYAN; + for vertex in &mut wire.text_verts { + vertex.color = crate::scene::WireModel::CYAN; + } + } + Some(wires) + } + + pub(crate) fn try_dimension_viewport_entity_pick( + &mut self, + i: usize, + paper: DVec3, + aperture_paper: f64, + ) -> Option { + let pick = self.tabs[i] + .scene + .dimension_pick_through_viewport(paper, aperture_paper)?; + if !self.dimension_acquisition_allowed(i, Some(pick.frame.viewport)) { + return Some(crate::command::CmdResult::NeedPoint); + } + let command = self.tabs[i].active_cmd.as_mut()?; + command.inject_picked_entity(pick.paper_entity); + let result = command.on_entity_pick(pick.entity_handle, pick.paper_point); + self.record_dimension_entity_points( + i, + Some(pick.frame), + pick.entity_handle, + pick.block_path, + ); + Some(result) + } + + fn pending_dimension_style_dimlfac(&self, i: usize, entity: &EntityType) -> f64 { + let EntityType::Dimension(dimension) = entity else { + return 1.0; + }; + dim_override::real( + &dimension.base().common.extended_data, + dim_override::DIMLFAC, + ) + .or_else(|| { + self.tabs[i] + .scene + .document + .dim_styles + .iter() + .find(|s| s.name.eq_ignore_ascii_case(&dimension.base().style_name)) + .map(|s| s.dimlfac) + }) + .unwrap_or(1.0) + } +} + +impl OpenCADStudio { + pub(crate) fn viewport_dimension_snaps( + &self, + i: usize, + dimension: Handle, + ) -> Vec> { + let slots = self.tabs[i] + .scene + .dimension_association_slot_points(dimension); + if slots.is_empty() { + return Vec::new(); + } + let measuring = self.accepted_snaps(); + slots + .iter() + .enumerate() + .map(|(index, slot)| { + let snap = measuring.get(index)?.clone(); + let frame = snap.frame; + let slot = DVec3::new(slot.x, slot.y, slot.z); + if snap + .paper_point + .truncate() + .distance_squared(slot.truncate()) + <= 1e-12 + { + return Some(snap); + } + Some(AcceptedSnap { + paper_point: slot, + model_point: frame.map_or(slot, |frame| frame.paper_to_model(slot)), + ..snap + }) + }) + .collect() + } + + /// Record the viewport association for a dimension that has just been + /// committed while measuring through a layout viewport. + /// + /// The stored reference chains establish dependencies. Only the new + /// dimension needs a render update; its sources have not changed. + pub(crate) fn attach_viewport_dimension_association(&mut self, i: usize, dimension: Handle) { + let snaps = self.viewport_dimension_snaps(i, dimension); + if snaps.iter().flatten().all(|snap| snap.source.is_none()) { + return; + } + self.tabs[i] + .scene + .attach_viewport_dimension_association(dimension, &snaps); + self.tabs[i] + .scene + .bump_entities(&[(dimension, crate::scene::ChangeKind::Modified)]); + } +} + +fn is_angular_dimension(entity: &EntityType) -> bool { + matches!( + entity, + EntityType::Dimension(Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) + ) +} diff --git a/src/app/dimension_preview_tests.rs b/src/app/dimension_preview_tests.rs new file mode 100644 index 000000000..8268d0979 --- /dev/null +++ b/src/app/dimension_preview_tests.rs @@ -0,0 +1,225 @@ +//! Styled placement previews must show the text and geometry the command +//! commits at the same stage. Viewport measurement cases live in +//! `viewport_dimension_tests`; these cover the remaining creation commands +//! in model space, style inheritance, batches, and degenerate stages. + +use super::viewport_dimension_tests::preview_text as text; +use super::OpenCADStudio; +use crate::command::StepInput; +use acadrust::entities::{Arc, Circle, Line}; +use acadrust::types::{Handle, Vector3}; +use acadrust::EntityType; +use glam::DVec3; + +fn app() -> (OpenCADStudio, usize) { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + (app, i) +} + +fn style_name(entity: &EntityType) -> String { + let EntityType::Dimension(dimension) = entity else { + panic!("expected dimension") + }; + dimension.base().style_name.clone() +} + +fn committed(app: &OpenCADStudio) -> Vec { + app.tabs[app.active_tab] + .scene + .document + .entities() + .filter(|entity| matches!(entity, EntityType::Dimension(_))) + .cloned() + .collect() +} + +/// Styled preview entities at `cursor`, asserting they also tessellate. +fn preview(app: &OpenCADStudio, i: usize, cursor: DVec3) -> Vec { + let entities = app.dimension_preview_entities(i, cursor).unwrap(); + assert!(!entities.is_empty(), "no styled preview at {cursor}"); + assert!(!app.dimension_preview_wires(i, cursor).unwrap().is_empty()); + entities +} + +fn commit_and_compare(app: &mut OpenCADStudio, i: usize, cursor: DVec3) -> EntityType { + let previewed = preview(app, i, cursor); + assert_eq!(previewed.len(), 1); + let before = committed(app).len(); + let _ = app.feed_command(StepInput::Point(cursor)); + let after = committed(app); + assert_eq!(after.len(), before + 1, "commit did not add one dimension"); + let entity = after.last().unwrap().clone(); + assert_eq!(text(app, &entity), text(app, &previewed[0])); + assert_eq!(style_name(&entity), style_name(&previewed[0])); + entity +} + +#[test] +fn ordinate_preview_matches_commit() { + let (mut app, i) = app(); + let _ = app.dispatch_command("DIMORDINATE"); + assert!(app.dimension_preview_entities(i, DVec3::ZERO).is_none()); + let _ = app.feed_command(StepInput::Point(DVec3::new(12.5, 40.0, 0.0))); + let entity = commit_and_compare(&mut app, i, DVec3::new(12.5, 90.0, 0.0)); + assert_eq!(text(&app, &entity), "12.5"); +} + +#[test] +fn arc_length_preview_matches_commit_for_full_and_partial_arcs() { + let (mut app, i) = app(); + let mut arc = Arc::new(); + arc.center = Vector3::ZERO; + arc.radius = 50.0; + arc.start_angle = 0.0; + arc.end_angle = std::f64::consts::FRAC_PI_2; + let handle = app.tabs[i].scene.add_entity(EntityType::Arc(arc)); + let on_arc = |degrees: f64| { + let radians = degrees.to_radians(); + DVec3::new(50.0 * radians.cos(), 50.0 * radians.sin(), 0.0) + }; + + let _ = app.dispatch_command("DIMARC"); + let _ = app.feed_command(StepInput::EntityPick(handle, on_arc(45.0))); + let full = commit_and_compare(&mut app, i, DVec3::new(60.0, 60.0, 0.0)); + + let _ = app.dispatch_command("DIMARC"); + let _ = app.feed_command(StepInput::EntityPick(handle, on_arc(45.0))); + let _ = app.feed_command(StepInput::Text("P".into())); + let _ = app.feed_command(StepInput::Point(on_arc(10.0))); + // While the second partial point is acquired the command has no + // committable dimension; the plain arc preview stays in charge. + assert!(app.dimension_preview_entities(i, on_arc(80.0)).is_none()); + let _ = app.feed_command(StepInput::Point(on_arc(80.0))); + let partial = commit_and_compare(&mut app, i, DVec3::new(60.0, 60.0, 0.0)); + assert_ne!(text(&app, &full), text(&app, &partial)); +} + +#[test] +fn jogged_radius_preview_matches_commit_at_text_and_jog_stages() { + let (mut app, i) = app(); + let mut circle = Circle::new(); + circle.center = Vector3::ZERO; + circle.radius = 100.0; + let handle = app.tabs[i].scene.add_entity(EntityType::Circle(circle)); + let _ = app.dispatch_command("DIMJOGGED"); + let _ = app.feed_command(StepInput::EntityPick(handle, DVec3::new(100.0, 0.0, 0.0))); + assert!(app + .dimension_preview_entities(i, DVec3::new(30.0, 0.0, 0.0)) + .is_none()); + let _ = app.feed_command(StepInput::Point(DVec3::new(30.0, 0.0, 0.0))); + let text_position = DVec3::new(150.0, 40.0, 0.0); + let staged = preview(&app, i, text_position); + assert_eq!(staged.len(), 1); + let _ = app.feed_command(StepInput::Point(text_position)); + let entity = commit_and_compare(&mut app, i, DVec3::new(90.0, 20.0, 0.0)); + assert_eq!(text(&app, &entity), text(&app, &staged[0])); + assert_eq!(text(&app, &entity), "R100"); +} + +#[test] +fn angular_three_point_preview_matches_commit() { + let (mut app, i) = app(); + let _ = app.dispatch_command("DIMANGULAR"); + let _ = app.feed_command(StepInput::Point(DVec3::ZERO)); + let _ = app.feed_command(StepInput::Point(DVec3::new(100.0, 0.0, 0.0))); + assert!(app + .dimension_preview_entities(i, DVec3::new(0.0, 100.0, 0.0)) + .is_none()); + let _ = app.feed_command(StepInput::Point(DVec3::new(0.0, 100.0, 0.0))); + let entity = commit_and_compare(&mut app, i, DVec3::new(70.0, 70.0, 0.0)); + assert_eq!(text(&app, &entity), "90°"); +} + +fn place_linear(app: &mut OpenCADStudio, first: DVec3, second: DVec3, line: DVec3) -> Handle { + let _ = app.dispatch_command("DIMLINEAR"); + let _ = app.feed_command(StepInput::Point(first)); + let _ = app.feed_command(StepInput::Point(second)); + let _ = app.feed_command(StepInput::Point(line)); + committed(app).last().unwrap().common().handle +} + +#[test] +fn continue_and_baseline_previews_follow_style_inheritance() { + let (mut app, i) = app(); + let default_style = app.tabs[i] + .scene + .document + .header + .current_dimstyle_name + .clone(); + let mut style = acadrust::tables::DimStyle::new("Inherit"); + style.dimlfac = 2.0; + app.tabs[i].scene.document.dim_styles.add(style).unwrap(); + app.tabs[i].scene.document.header.current_dimstyle_name = "Inherit".into(); + let base = place_linear( + &mut app, + DVec3::ZERO, + DVec3::new(100.0, 0.0, 0.0), + DVec3::new(50.0, -20.0, 0.0), + ); + assert_eq!(text(&app, committed(&app).last().unwrap()), "200"); + app.tabs[i].scene.document.header.current_dimstyle_name = default_style; + + // DIMCONTINUEMODE=1 keeps the base dimension's style in the preview. + app.dimension_continue_mode = 1; + app.tabs[i].scene.last_created_dimension = Some(base); + let _ = app.dispatch_command("DIMCONTINUE"); + let inherited = commit_and_compare(&mut app, i, DVec3::new(250.0, 0.0, 0.0)); + assert_eq!(style_name(&inherited), "Inherit"); + assert_eq!(text(&app, &inherited), "300"); + + // DIMCONTINUEMODE=0 runs the current creation defaults over the built + // dimension instead; preview and commit must still agree. + app.dimension_continue_mode = 0; + app.tabs[i].scene.last_created_dimension = Some(inherited.common().handle); + let _ = app.dispatch_command("DIMCONTINUE"); + commit_and_compare(&mut app, i, DVec3::new(350.0, 0.0, 0.0)); + + app.dimension_continue_mode = 1; + app.tabs[i].scene.last_created_dimension = Some(base); + let _ = app.dispatch_command("DIMBASELINE"); + let baseline = commit_and_compare(&mut app, i, DVec3::new(300.0, 0.0, 0.0)); + assert_eq!(style_name(&baseline), "Inherit"); + assert_eq!(text(&app, &baseline), "600"); +} + +#[test] +fn qdim_preview_matches_the_committed_batch() { + let (mut app, i) = app(); + let segments = [(0.0, 40.0), (40.0, 100.0), (100.0, 130.0)]; + for (start, end) in segments { + let line = Line::from_points(Vector3::new(start, 0.0, 0.0), Vector3::new(end, 0.0, 0.0)); + let handle = app.tabs[i].scene.add_entity(EntityType::Line(line)); + app.tabs[i].scene.select_entity(handle, false); + } + let _ = app.dispatch_command("QDIM"); + let cursor = DVec3::new(60.0, 40.0, 0.0); + let previewed = preview(&app, i, cursor); + assert_eq!(previewed.len(), segments.len()); + let mut preview_texts: Vec = previewed.iter().map(|e| text(&app, e)).collect(); + let _ = app.feed_command(StepInput::Point(cursor)); + let mut committed_texts: Vec = committed(&app).iter().map(|e| text(&app, e)).collect(); + preview_texts.sort(); + committed_texts.sort(); + assert_eq!(committed_texts, preview_texts); + assert_eq!(committed_texts, ["30", "40", "60"]); +} + +#[test] +fn degenerate_linear_preview_defers_to_plain_rubber_band() { + let (mut app, i) = app(); + let _ = app.dispatch_command("DIMLINEAR"); + assert!(app.dimension_preview_entities(i, DVec3::ZERO).is_none()); + let _ = app.feed_command(StepInput::Point(DVec3::ZERO)); + assert!(app + .dimension_preview_entities(i, DVec3::ZERO) + .is_some_and(|entities| entities.is_empty())); + assert!(app.dimension_preview_wires(i, DVec3::ZERO).is_none()); + assert!(committed(&app).is_empty()); + let cursor = DVec3::new(80.0, 0.0, 0.0); + let styled = preview(&app, i, cursor); + assert_eq!(text(&app, &styled[0]), "80"); + assert!(committed(&app).is_empty(), "hover must not commit"); +} diff --git a/src/app/document.rs b/src/app/document.rs index 50d4cfc4b..e7f9041ed 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -1,5 +1,4 @@ use crate::command::CadCommand; -use crate::io::linetypes; use crate::modules::draw::modify::block_edit::BlockEditSession; use crate::modules::draw::modify::refedit::RefEditSession; use crate::scene::pick::grip::GripEdit; @@ -33,7 +32,7 @@ pub(super) enum DynComponent { Z, /// Linear distance from the last point. Distance, - /// Angle from the last point, in degrees. + /// Angle from the last point, displayed and entered in the drawing's units. Angle, /// A scalar the command reads from the command line (a count, a radius, /// a delta). Typed-only — it has no geometric live value derived from @@ -101,6 +100,27 @@ fn default_role_for(component: DynComponent) -> crate::command::DynRole { } } +/// An open sketch — the state CREATESKETCH parks so FINISHSKETCH can put it +/// back. +/// +/// A sketch here is a *mode*, not a document object: the drawing plane, a +/// square-on view, and snapping forced live. The geometry it produces is +/// ordinary model-space entities, which is what keeps the result a normal +/// DWG that other software can open. `opened_with` is the handle set at the +/// moment the sketch opened, so the difference on finish is exactly what the +/// user drew — that difference is what gets offered to EXTRUDE. +pub(super) struct SketchSession { + /// Display name, e.g. `"Sketch1"`. + pub(super) name: String, + /// UCS active before the sketch opened. Restored on finish. + pub(super) previous_ucs: Option, + /// Snap master state before the sketch forced it on, so a user who works + /// with snapping off gets that back rather than silently keeping it. + pub(super) previous_snap_enabled: bool, + /// Entity handles already present when the sketch opened. + pub(super) opened_with: std::collections::HashSet, +} + // ── Per-document tab state ───────────────────────────────────────────────── pub(super) struct DocumentTab { @@ -146,6 +166,12 @@ pub(super) struct DocumentTab { pub(super) selected_grip_handles: Vec, /// Shift-selected grips, keyed by entity and object-local grip id. pub(super) hot_grips: rustc_hash::FxHashSet<(Handle, usize)>, + /// Grip-mode "Copy" toggle (context menu): each grip placement leaves the + /// original in place and adds a modified copy, until Enter / Esc. + pub(super) grip_copy: bool, + /// Grip-mode "Base Point" (context menu): the next left-click re-bases + /// the active grip edit instead of committing it. + pub(super) grip_base_pending: bool, pub(super) selected_handle: Option, /// Dynamic-block visibility grip for the current single selection. pub(super) visibility_grip: Option, @@ -180,6 +206,11 @@ pub(super) struct DocumentTab { pub(super) active_layer: String, /// Currently active UCS. `None` means WCS (identity transform). pub(super) active_ucs: Option, + /// Open sketch, if CREATESKETCH is in effect. `None` is the normal + /// direct-modelling state. + pub(super) sketch_session: Option, + /// Sketches opened in this tab so far, so each gets a distinct name. + pub(super) sketch_count: u32, /// Custom model-space background color. `None` = default dark grey. pub(super) bg_color: Option<[f32; 4]>, /// Custom paper-space background color. `None` = default off-white grey. @@ -195,6 +226,16 @@ 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, + /// Session set of unloaded reference keys (Task 8b). Owns the set that + /// `collect_entries` takes as `unloaded`, so CLI and palette agree. + pub(super) xref_unloaded: crate::io::xref_model::UnloadSet, + /// Load-time mtimes per reference key (Task 8b). Written on every + /// palette refresh; `Stale` is detectable from the second refresh on. + pub(super) xref_stat_cache: crate::io::xref_model::RefStatCache, + /// NotFound count from the last file-open xref resolution (Task 8b). + /// The palette renders a neutral "open EXTERNALREFERENCES" notice while non-zero; + /// never auto-opens a modal. Cleared by a clean palette refresh. + pub(super) xref_missing: usize, /// 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 @@ -215,6 +256,9 @@ pub(super) struct DocumentTab { #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(super) plugin_state: HashMap<&'static str, Box>, pub(super) suspended_cmd: Option>, + /// `suspended_cmd` was parked by a transparent command (`'ZOOM`) and is + /// restored as soon as the transparent one ends. + pub(super) transparent_resume: bool, } impl DocumentTab { @@ -548,24 +592,7 @@ impl DocumentTab { pub(super) fn new_drawing(n: usize) -> Self { let mut scene = Scene::new(); - linetypes::populate_document(&mut scene.document); - // Paper layouts start as A4 landscape. - for obj in scene.document.objects.values_mut() { - if let acadrust::objects::ObjectType::Layout(l) = obj { - if l.name != "Model" { - l.min_limits = (0.0, 0.0); - l.max_limits = (297.0, 210.0); - l.min_extents = (0.0, 0.0, 0.0); - l.max_extents = (297.0, 210.0, 0.0); - l.paper_width = 297.0; - l.paper_height = 210.0; - l.plot_paper_units = 1; - l.plot_scale_numerator = 1.0; - l.plot_scale_denominator = 1.0; - l.paper_size = "ISO_A4_(297.00_x_210.00_MM)".into(); - } - } - } + scene.populate_new_drawing_defaults(); Self { id: NEXT_DOCUMENT_TAB_ID.fetch_add(1, Ordering::Relaxed), scene, @@ -591,6 +618,8 @@ impl DocumentTab { selected_grips: vec![], selected_grip_handles: vec![], hot_grips: rustc_hash::FxHashSet::default(), + grip_copy: false, + grip_base_pending: false, selected_handle: None, visibility_grip: None, wireframe: false, @@ -608,6 +637,8 @@ impl DocumentTab { history: HistoryState::default(), active_layer: "0".to_string(), active_ucs: None, + sketch_session: None, + sketch_count: 0, bg_color: None, paper_bg_color: None, refedit_session: None, @@ -615,12 +646,16 @@ impl DocumentTab { active_block_edit: None, active_mleader_style: "Standard".to_string(), last_synced_camera_gen: 0, + xref_unloaded: crate::io::xref_model::UnloadSet::default(), + xref_stat_cache: crate::io::xref_model::RefStatCache::default(), + xref_missing: 0, is_start: false, pan_mode: false, orbit_mode: false, zoom_dynamic_mode: false, plugin_state: HashMap::new(), suspended_cmd: None, + transparent_resume: false, } } diff --git a/src/app/drafting_settings.rs b/src/app/drafting_settings.rs index 0a6a58a6a..04f5753ed 100644 --- a/src/app/drafting_settings.rs +++ b/src/app/drafting_settings.rs @@ -3,10 +3,28 @@ use crate::ui::window::drafting_settings::{DraftingSettingsState, DraftingSettin impl DraftingSettingsState { pub(crate) fn from_app(app: &OpenCADStudio) -> Self { + let snap_x = app.snapper.snap_spacing_x; + let snap_y = app.snapper.snap_spacing_y; + // The dialog has no stored "equal" flag, so infer it: equal when + // X and Y match. This keeps the checkbox checked for the common + // case (including fresh defaults of 10/10). + let snap_equal = (snap_x - snap_y).abs() < 1e-6; Self { active_tab: DraftingSettingsTab::SnapAndGrid, snap_on: app.snapper.grid_snap(), grid_on: app.show_grid, + snap_x_input: crate::ui::window::drafting_settings::format_snap_spacing(snap_x), + snap_y_input: crate::ui::window::drafting_settings::format_snap_spacing(snap_y), + snap_equal, + grid_x_input: crate::ui::window::drafting_settings::format_snap_spacing( + app.grid_spacing_x, + ), + grid_y_input: crate::ui::window::drafting_settings::format_snap_spacing( + app.grid_spacing_y, + ), + grid_major_input: format!("{}", app.grid_major_every), + grid_adaptive: app.grid_adaptive, + grid_beyond_limits: app.grid_beyond_limits, isometric: app.isometric_drafting, iso_plane: app.iso_plane, snap_angle_deg: app.snap_angle_deg, @@ -16,7 +34,8 @@ impl DraftingSettingsState { osnap_on: app.snapper.snap_enabled, otrack_on: app.snapper.otrack_enabled, snap_modes: app.snapper.enabled.clone(), - osnap3d_on: false, + osnap3d_on: app.snapper.snap3d_enabled, + snap3d_modes: app.snapper.enabled3d.clone(), dyn_input_on: app.dyn_input, quick_props_on: app.quick_properties, selection_cycling_on: app.selection_cycling, @@ -33,24 +52,59 @@ impl OpenCADStudio { } } - pub(super) fn apply_drafting_settings(&mut self) { - if let Some(state) = &self.drafting_settings_state { - self.show_grid = state.grid_on; - self.snapper.grid_snap_on = state.snap_on; - self.isometric_drafting = state.isometric; - self.iso_plane = state.iso_plane; - self.snap_angle_deg = state.snap_angle_deg; - self.polar_mode = state.polar_on; - self.ortho_mode = state.ortho_on; - self.polar_increment_deg = state.polar_increment_deg; - self.snapper.snap_enabled = state.osnap_on; - self.snapper.otrack_enabled = state.otrack_on; - self.snapper.enabled = state.snap_modes.clone(); - self.dyn_input = state.dyn_input_on; - self.quick_properties = state.quick_props_on; - self.selection_cycling = state.selection_cycling_on; - self.sync_vport_display(self.active_tab); + pub(super) fn apply_drafting_settings(&mut self) -> bool { + let Some(state) = &self.drafting_settings_state.clone() else { + return true; + }; + let x = crate::ui::window::drafting_settings::parse_snap_spacing(&state.snap_x_input); + let y = crate::ui::window::drafting_settings::parse_snap_spacing(&state.snap_y_input); + let (Some(sx), Some(sy)) = (x, y) else { + self.command_line + .push_error(crate::t!("Snap X and Y spacings must be positive numbers.").as_ref()); + return false; + }; + // When locked, Y follows X so the two can never diverge. + let sy = if state.snap_equal { sx } else { sy }; + if let Some(live) = &mut self.drafting_settings_state { + if live.snap_equal { + live.snap_y_input = live.snap_x_input.clone(); + } } + let gx = crate::ui::window::drafting_settings::parse_snap_spacing(&state.grid_x_input); + let gy = crate::ui::window::drafting_settings::parse_snap_spacing(&state.grid_y_input); + let gm = crate::ui::window::drafting_settings::parse_grid_major(&state.grid_major_input); + let (Some(gx), Some(gy), Some(gm)) = (gx, gy, gm) else { + self.command_line.push_error( + crate::t!("Grid X/Y spacings must be positive numbers and Major every 2-100.") + .as_ref(), + ); + return false; + }; + self.show_grid = state.grid_on; + self.grid_spacing_x = gx; + self.grid_spacing_y = gy; + self.grid_major_every = gm; + self.grid_adaptive = state.grid_adaptive; + self.grid_beyond_limits = state.grid_beyond_limits; + self.snapper.grid_snap_on = state.snap_on; + self.snapper.snap_spacing_x = sx; + self.snapper.snap_spacing_y = sy; + self.isometric_drafting = state.isometric; + self.iso_plane = state.iso_plane; + self.snap_angle_deg = state.snap_angle_deg; + self.polar_mode = state.polar_on; + self.ortho_mode = state.ortho_on; + self.polar_increment_deg = state.polar_increment_deg; + self.snapper.snap_enabled = state.osnap_on; + self.snapper.otrack_enabled = state.otrack_on; + self.snapper.enabled = state.snap_modes.clone(); + self.snapper.snap3d_enabled = state.osnap3d_on; + self.snapper.enabled3d = state.snap3d_modes.clone(); + self.dyn_input = state.dyn_input_on; + self.quick_properties = state.quick_props_on; + self.selection_cycling = state.selection_cycling_on; + self.sync_vport_display(self.active_tab); + true } } @@ -64,6 +118,14 @@ mod tests { active_tab: DraftingSettingsTab::SnapAndGrid, snap_on: false, grid_on: true, + snap_x_input: "10".to_string(), + snap_y_input: "10".to_string(), + snap_equal: true, + grid_x_input: "10".to_string(), + grid_y_input: "10".to_string(), + grid_major_input: "5".to_string(), + grid_adaptive: true, + grid_beyond_limits: true, isometric: false, iso_plane: crate::app::settings::IsoPlane::Left, snap_angle_deg: 0.0, @@ -74,6 +136,7 @@ mod tests { otrack_on: false, snap_modes: rustc_hash::FxHashSet::default(), osnap3d_on: false, + snap3d_modes: rustc_hash::FxHashSet::default(), dyn_input_on: false, quick_props_on: false, selection_cycling_on: false, @@ -96,6 +159,14 @@ mod tests { active_tab: DraftingSettingsTab::SnapAndGrid, snap_on: false, grid_on: true, + snap_x_input: "10".to_string(), + snap_y_input: "10".to_string(), + snap_equal: true, + grid_x_input: "10".to_string(), + grid_y_input: "10".to_string(), + grid_major_input: "5".to_string(), + grid_adaptive: true, + grid_beyond_limits: true, isometric: false, iso_plane: crate::app::settings::IsoPlane::Left, snap_angle_deg: 0.0, @@ -106,6 +177,7 @@ mod tests { otrack_on: false, snap_modes: rustc_hash::FxHashSet::default(), osnap3d_on: false, + snap3d_modes: rustc_hash::FxHashSet::default(), dyn_input_on: false, quick_props_on: false, selection_cycling_on: false, @@ -155,6 +227,10 @@ mod tests { modded.osnap3d_on = true; assert!(modded.is_dirty(&base)); + let mut modded = base.clone(); + modded.snap3d_modes.insert(crate::snap::SnapType::Vertex); + assert!(modded.is_dirty(&base)); + let mut modded = base.clone(); modded.dyn_input_on = true; assert!(modded.is_dirty(&base)); @@ -166,5 +242,55 @@ mod tests { let mut modded = base.clone(); modded.selection_cycling_on = true; assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.snap_x_input = "5".to_string(); + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.snap_y_input = "5".to_string(); + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.snap_equal = false; + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.grid_x_input = "5".to_string(); + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.grid_y_input = "5".to_string(); + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.grid_major_input = "10".to_string(); + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.grid_adaptive = false; + assert!(modded.is_dirty(&base)); + + let mut modded = base.clone(); + modded.grid_beyond_limits = false; + assert!(modded.is_dirty(&base)); + } + + #[test] + fn test_parse_snap_spacing() { + use crate::ui::window::drafting_settings::{ + format_snap_spacing, parse_grid_major, parse_snap_spacing, + }; + assert_eq!(parse_snap_spacing("10"), Some(10.0)); + assert_eq!(parse_snap_spacing(" 2.5 "), Some(2.5)); + assert_eq!(parse_snap_spacing("0"), None); + assert_eq!(parse_snap_spacing("-1"), None); + assert_eq!(parse_snap_spacing("abc"), None); + assert_eq!(parse_snap_spacing(""), None); + assert_eq!(format_snap_spacing(10.0), "10"); + assert_eq!(parse_grid_major("5"), Some(5)); + assert_eq!(parse_grid_major("1"), None); + assert_eq!(parse_grid_major("101"), None); + assert_eq!(parse_grid_major("abc"), None); } } diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 678ae2144..91e925bc0 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -247,6 +247,25 @@ pub(super) fn ucs_z_axis(ucs: &Ucs) -> glam::DVec3 { UcsXform::from_ucs(ucs).axes().3 } +/// Build a UCS whose Z axis is `normal`, anchored at `origin`. +/// +/// X and Y come from the arbitrary-axis algorithm in +/// [`crate::scene::view::transform::ocs_axes`], the same frame entities get +/// from their extrusion direction, so the same face always yields the same X +/// axis. Returns `None` when `normal` is degenerate and defines no plane. +pub(super) fn ucs_from_normal(origin: glam::DVec3, normal: glam::DVec3) -> Option { + let z = normal.normalize_or_zero(); + if z.length_squared() < 1e-12 { + return None; + } + let ((xx, xy, xz), (yx, yy, yz)) = crate::scene::view::transform::ocs_axes((z.x, z.y, z.z)); + let mut ucs = Ucs::new("*ACTIVE*"); + ucs.origin = acadrust::types::Vector3::new(origin.x, origin.y, origin.z); + ucs.x_axis = acadrust::types::Vector3::new(xx, xy, xz); + ucs.y_axis = acadrust::types::Vector3::new(yx, yy, yz); + Some(ucs) +} + /// Build a UCS with `origin` and axes rotated by `angle_z_rad` around the Z axis. pub(super) fn ucs_rotated_z(origin: glam::DVec3, angle_z: f32) -> Ucs { let cos = angle_z.cos() as f64; @@ -577,3 +596,78 @@ pub(super) fn build_window_icon() -> Option> { resvg::render(&tree, transform, &mut pixmap.as_mut()); Some(pixmap.take()) } + +#[cfg(test)] +mod ucs_from_normal_tests { + use super::ucs_from_normal; + use glam::DVec3; + + fn axes(ucs: &acadrust::tables::Ucs) -> (DVec3, DVec3, DVec3) { + let x = DVec3::new(ucs.x_axis.x, ucs.x_axis.y, ucs.x_axis.z); + let y = DVec3::new(ucs.y_axis.x, ucs.y_axis.y, ucs.y_axis.z); + (x, y, x.cross(y)) + } + + /// The contract `UCS FACE` depends on: the plane's Z is the face normal it + /// was handed. If this drifts, sketches tilt off the face they were + /// started on. + #[test] + fn z_axis_is_the_supplied_normal() { + for normal in [ + DVec3::Z, + DVec3::NEG_Z, + DVec3::X, + DVec3::Y, + DVec3::new(1.0, 2.0, 3.0).normalize(), + ] { + let ucs = ucs_from_normal(DVec3::ZERO, normal).expect("a normal defines a plane"); + let (_, _, z) = axes(&ucs); + assert!( + (z - normal).length() < 1e-9, + "expected Z {normal:?}, got {z:?}" + ); + } + } + + /// Right-handed and unit length, or every coordinate read off the plane is + /// skewed. + #[test] + fn axes_are_orthonormal() { + for normal in [DVec3::Z, DVec3::new(-2.0, 0.5, 1.0).normalize()] { + let ucs = ucs_from_normal(DVec3::ZERO, normal).unwrap(); + let (x, y, z) = axes(&ucs); + for (name, axis) in [("X", x), ("Y", y), ("Z", z)] { + assert!((axis.length() - 1.0).abs() < 1e-9, "{name} is not unit"); + } + assert!(x.dot(y).abs() < 1e-9, "X and Y are not perpendicular"); + assert!(x.dot(z).abs() < 1e-9, "X and Z are not perpendicular"); + } + } + + /// The 1/64 branch exists so a normal along world Z does not cross-product + /// with itself. Both sides of that branch must still produce a usable + /// plane. + #[test] + fn a_normal_along_world_z_still_yields_a_plane() { + let ucs = ucs_from_normal(DVec3::ZERO, DVec3::Z).expect("world Z is a valid normal"); + let (x, _, _) = axes(&ucs); + assert!(x.length() > 0.5, "X degenerated next to the world Z axis"); + } + + /// The same face must always give the same X axis, or a sketch would spin + /// each time the UCS was rebuilt from it. + #[test] + fn the_same_normal_is_reproducible() { + let normal = DVec3::new(0.3, -0.7, 0.2).normalize(); + let first = axes(&ucs_from_normal(DVec3::ZERO, normal).unwrap()); + let second = axes(&ucs_from_normal(DVec3::ZERO, normal).unwrap()); + assert!((first.0 - second.0).length() < 1e-12); + } + + /// A zero vector defines no plane; it must be refused rather than + /// producing a degenerate UCS that silently misplaces geometry. + #[test] + fn a_degenerate_normal_is_rejected() { + assert!(ucs_from_normal(DVec3::ZERO, DVec3::ZERO).is_none()); + } +} diff --git a/src/app/history.rs b/src/app/history.rs index 421c495d0..3b4ac1d89 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1072,7 +1072,7 @@ impl OpenCADStudio { // references visually stale after BEDIT closes. scene.bump_geometry(); } else { - scene.bump_entities(&final_changes); + scene.bump_entities_after_parametric_solve(&final_changes); } } } diff --git a/src/app/mod.rs b/src/app/mod.rs index b3d1aec92..7ede6f889 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -10,14 +10,21 @@ pub use automation::{export_headless, serve}; mod annotation_data; mod command_driver; pub(crate) mod commands; +pub(crate) mod dim_viewport; +#[cfg(test)] +mod viewport_dimension_tests; +#[cfg(test)] +mod dimension_preview_tests; mod document; mod drafting_settings; pub(crate) mod expr_eval; +mod options_session; mod find_replace; pub(crate) mod helpers; mod history; mod layers; mod model_ops; +mod navigation; mod mtext_editor; #[cfg(not(target_arch = "wasm32"))] pub mod plugin_host; @@ -83,6 +90,17 @@ pub const HOVER_DWELL_MS: u128 = 500; /// threshold gates that extra redraw-avoidance behavior. pub const HOVER_DWELL_DENSE_WIRES: usize = 50_000; +/// Keyboard navigation inside the open right-click context menu. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContextMenuNav { + Up, + Down, + /// Pick the highlighted row (or the default row when none is highlighted). + Enter, + /// Pick the next row whose mnemonic letter matches. + Mnemonic(char), +} + /// Open multi-functional-grip popup state. #[derive(Clone, Debug)] pub struct GripPopup { @@ -512,6 +530,10 @@ pub(super) struct OpenCADStudio { double_click_block_refedit: bool, /// Open ATTEDIT when double-clicking a block with attributes. double_click_block_attedit: bool, + /// What a right-click in the drawing area does (SHORTCUTMENU). + right_click_mode: settings::RightClickMode, + /// Time-sensitive right-click hold threshold, ms (SHORTCUTMENUDURATION). + right_click_hold_ms: i32, /// Selected-object count past which grips stop being generated /// (GRIPOBJLIMIT, 0..=32767; 0 = no limit). grip_object_limit: i32, @@ -536,10 +558,31 @@ pub(super) struct OpenCADStudio { snap_angle_deg: f32, /// Show grid lines in the viewport (F7). show_grid: bool, + /// GRIDUNIT X/Y display spacing backing the DSettings grid-resize inputs. + pub grid_spacing_x: f32, + pub grid_spacing_y: f32, + /// GRIDMAJOR: every Nth line draws as a brighter major line. + pub grid_major_every: u32, + /// Adaptive grid: scale GRIDUNIT up by 5x steps to stay readable. + pub grid_adaptive: bool, + /// Display the grid beyond LIMITS (infinite) instead of clipping to them. + pub grid_beyond_limits: bool, /// Dynamic input overlay (F12): show coordinate tooltip near cursor. dyn_input: bool, /// Currently visible page in the application Options dialog. options_tab: crate::ui::window::options::OptionsTab, + /// The Options window's commit point (see `options_session`). + options_saved: Option, + /// Close was pressed with unapplied changes; the discard guard is up. + options_close_confirm: bool, + spacemouse: crate::input::spacemouse::Service, + spacemouse_preferences: crate::input::spacemouse::Preferences, + spacemouse_paused: bool, + spacemouse_focused: bool, + spacemouse_details: bool, + spacemouse_was_moving: bool, + spacemouse_pivot: Option<(crate::input::spacemouse::Target, glam::DVec3)>, + spacemouse_selection: navigation::SelectionCache, /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). pub texteditmode: bool, /// QDIM extension-origin priority: 0 = endpoints, 1 = intersections. @@ -564,6 +607,7 @@ pub(super) struct OpenCADStudio { pub constraint_solve_mode: bool, pub constraint_infer: bool, pub constraint_bar_display: i16, + pub constraint_bar_mode: i16, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// 0 disables autosave. pub savetime_min: i32, @@ -639,6 +683,9 @@ pub(super) struct OpenCADStudio { /// `HOVER_DWELL_MS`. Skipping the pick mid-stroke avoids the per-frame /// O(N) wire+hatch+mesh sweep that froze the cursor on large drawings. hover_dwell: Option, + /// Constraint kind shown after the ordinary rollover dwell while the + /// cursor remains over one of its viewport indicators. + constraint_glyph_tooltip: Option, /// Snapshots of edited entities taken at the start of a grip drag. The drag /// mutates the document live, so Escape restores this group atomically. grip_originals: Vec<(acadrust::Handle, acadrust::EntityType)>, @@ -690,6 +737,14 @@ pub(super) struct OpenCADStudio { show_properties: bool, /// Docked Insert Block panel visibility. pub(crate) show_block_palette: bool, + /// Docked External References panel visibility (EXTERNALREFERENCES). + pub(crate) show_external_references: bool, + /// Whether the Browser panel is shown. Off until BROWSER opens it, so + /// the default layout is unchanged for existing users. + pub(crate) show_browser: bool, + /// Which viewport background the colour wheel is editing, or `None` when + /// it is closed. One slot, because only one wheel can be open at a time. + pub(crate) bg_picker: Option, /// General edge-stack dock layout for the side panels. pub(crate) dock: crate::ui::dock::DockState, /// Which panel is currently floated at full height (hovered, or a pinned @@ -703,14 +758,32 @@ pub(super) struct OpenCADStudio { pub(crate) dock_drag_last: Option, /// Live drag target (side + index), shown as a highlight while dragging. pub(crate) dock_drag_target: Option<(crate::app::config::DockSide, usize)>, + /// Reference-table column currently being width-resized (column index), + /// with the last pointer position. Mirrors the Layers Name-column drag. + pub(crate) xref_col_drag: Option, + pub(crate) xref_col_last: Option, + /// Table/lower-pane split divider drag in progress. + pub(crate) xref_split_drag: bool, /// Docked Insert Block panel state (search, preview size, cached thumbnails). pub(crate) block_palette: crate::ui::window::block_palette::BlockPalette, + /// Reference Manager palette state (display-only in Task 7). + pub(crate) xref_manager: crate::ui::window::xref_manager::XrefManagerPanel, /// Whether the document file tabs are shown at the top (FILETAB). show_file_tabs: bool, /// Whether the layout/paper-space tabs are shown at the bottom (LAYOUTTAB). show_layout_tabs: bool, /// Last point committed by a drawing command — used as ortho/polar base. last_point: Option, + /// Viewport used by the current snap; the displayed point is in paper space. + pub(crate) vp_snap_frame: Option, + /// Acquired coordinates and source identities in command-step order. + /// Cleared when the command starts or ends. + accepted_snaps: Vec, + /// Click result retained until the command accepts its point. + pending_click_snap: Option<( + crate::snap::SnapResult, + Option, + )>, /// Endpoint + unit exit-tangent of the most recently drawn line/arc, so /// `ARC_CONT` (Arc → Continue) can start tangentially from where drawing /// ended. `None` once a non-line/arc entity is committed. @@ -735,6 +808,12 @@ pub(super) struct OpenCADStudio { /// The open in-canvas modal dialog, if any (Plan B: shared overlay instead /// of OS windows). active_modal: Option, + /// Selection and staged values owned by the Properties hyperlink dialog. + hyperlink_editor_handles: Vec, + hyperlink_editor_url: String, + hyperlink_editor_description: String, + hyperlink_editor_mixed: bool, + hyperlink_editor_dirty: bool, pending_startup_modals: std::collections::VecDeque, /// What is drawing the scene, once the first frame has told us. Drives /// the graphics warning (popup, status-bar pill, command line). @@ -853,6 +932,12 @@ pub(super) struct OpenCADStudio { point_size_relative: bool, /// Whether the default-association prompt has been answered. default_assoc_prompted: bool, + /// Offer to download missing `.shx` fonts on open (see `io::font_repo`). + check_missing_fonts: bool, + /// Custom font source base URL; empty selects the community repository. + font_source_url: String, + /// Editable copy of `font_source_url` shown in the missing-fonts prompt. + font_source_input: String, donation_prompt_version: String, /// Read-only session (`--read-only`): editing is allowed but every save /// path is refused. Set once at boot from the CLI config. @@ -913,8 +998,10 @@ pub(super) struct OpenCADStudio { mtext_click_count: u8, /// Pending model-space plot window (x0, y0, x1, y1) in world XY, or None. plot_window: Option<(f64, f64, f64, f64)>, - plot_format: crate::io::paper_sizes::PaperSize, - plot_orientation: crate::io::paper_sizes::Orientation, + /// Sheet the Plot dialog opens with when the layout has no page setup; + /// updated from every plot / apply so the next dialog remembers it. + plot_paper: crate::io::paper_catalog::PaperSize, + plot_orientation: crate::io::paper_catalog::Orientation, /// Backing state for the full Plot / Print dialog. plot_dialog: crate::ui::window::plot::PlotDialogState, /// Snapshot of the dialog's settings taken when it opened, restored by the @@ -1069,6 +1156,16 @@ pub(super) struct OpenCADStudio { /// Working buffer for the ALIASEDIT modal: `(alias, command)` rows being /// edited. Seeded from `command_aliases` on open, committed back on close. alias_editor_rows: Vec<(String, String)>, + /// True while a freshly added (top) row is an unfinished draft: it exists + /// only until its alias and command are filled (accept) or it is cancelled + /// (Esc / ✕ / dialog close), so the list never keeps an empty row. + /// Mirrors `shortcut_pending_add`. + alias_pending_add: bool, + /// True while the "Reset to default" confirmation is showing. + alias_reset_confirm: bool, + /// True while the "unsaved changes will be discarded" confirmation + /// overlays the editor: the user tried to close with un-applied rows. + alias_close_confirm: bool, // ── Named Parameters ────────────────────────────────────────────────── /// Working buffer for the PARAMETERS modal. Unlike `alias_editor_rows`, @@ -1129,6 +1226,9 @@ pub(super) struct OpenCADStudio { open_job_serial: u64, /// Last repair or failed-open report shown in the recovery modal. recovery_report: Option, + /// Missing SHX fonts of the last opened drawing, offered for download + /// from the community repository (see `crate::io::font_repo`). + missing_fonts: Option>, /// Drawings handed to us by other launches while `opening` was busy. /// `opening` is a single slot that a second `OpenPathPicked` would /// overwrite, and `on_file_opened` drops any result arriving once it is @@ -1364,6 +1464,14 @@ pub struct SaveOutcome { refreshed_preview: Option>, result: Result<(), crate::io::SaveFailure>, } +/// Which viewport background a colour-wheel session is editing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BgTarget { + Model, + Paper, + Desk, +} + /// Active page in the shared CAD colour picker. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ColorPickerTab { @@ -1749,6 +1857,8 @@ pub enum ModalKind { Unsaved, SaveDialog, Recovery, + /// Fonts referenced by the drawing are missing on this machine. + MissingFonts, RecoveryPrompt, Options, FindReplace, @@ -1768,6 +1878,8 @@ pub enum ModalKind { /// Add / remove the annotation scales a single selected object has a /// per-object representation for. AnnoObjectScale, + /// URL/description collection editor opened by the Hyperlink property row. + Hyperlink, InsertTable, DataLinkManager, DataExtraction, @@ -1775,6 +1887,8 @@ pub enum ModalKind { /// means and what usually fixes it. Queued once per verdict; the status /// bar's ⚠ pill reopens it. GpuWarning, + /// Reference Manager help window (toolbar Help button). + XrefHelp, } /// A property group controlled by a layer state's restore mask. @@ -1887,6 +2001,17 @@ pub enum ArrowKey { #[derive(Debug, Clone)] pub enum Message { + SpaceMouseWake, + SpaceMouseFrame(iced::time::Instant), + SpaceMouseFocus(iced::window::Id, bool), + SpaceMouseEnabled(bool), + SpaceMouseMode(crate::input::spacemouse::NavigationMode), + SpaceMousePanSpeed(u16), + SpaceMousePanReversed(bool), + SpaceMousePause, + SpaceMousePreferences, + SpaceMouseDriverSettings, + SpaceMouseDetails, ControlRequest(control::Envelope), PollWebControl, ControlStep(String, Box), @@ -1952,6 +2077,8 @@ pub enum Message { /// Mid Between 2 Points from the snap menu: modal 2-pick modifier over /// the active point prompt. SnapOverrideMtp, + /// Snap Overrides ▸ None: the next pick ignores object snaps. + SnapOverrideNone, /// Close the one-shot snap override menu without picking. SnapOverrideClose, /// Open a path from the Start tab's recent-documents list (skips the @@ -2032,6 +2159,12 @@ pub enum Message { ModelSpaceModeChanged(config::ModelSpaceMode), /// Change Model Space custom background color as hex or empty for default. ModelSpaceBgChanged(String), + /// Open the colour wheel on one of the viewport backgrounds. + BgPickerOpen(BgTarget), + /// Dismiss the wheel, changing nothing. + BgPickerCancel, + /// Accept the wheel's colour for whichever background it was opened on. + BgPickerSubmit(iced::Color), /// Change Paper Space custom sheet background color as hex or empty for default. PaperSpaceBgChanged(String), /// Change Paper Space desk surround background (#RRGGBB). @@ -2073,6 +2206,10 @@ pub enum Message { SaveTimeChanged(i32), /// Toggle keeping a `.bak` copy when overwriting a drawing (ISAVEBAK). BackupOnSaveChanged(bool), + /// A drawing was picked to import page setups from (`PSETUPIN`). + PageSetupImportFile(std::path::PathBuf), + /// Options: open the Plot / Page Setup dialog for every new layout. + PageSetupOnNewLayoutChanged(bool), /// Toggle filled TrueType glyphs (TEXTFILL). TextFillChanged(bool), /// Change how many prompt lines sit above the command window (CLIPROMPTLINES). @@ -2083,6 +2220,10 @@ pub enum Message { ZoomWheelReversedChanged(bool), /// Change how far one wheel notch zooms (ZOOMFACTOR, 3..=100). ZoomFactorChanged(i32), + /// Options → User Preferences: right-click behaviour (SHORTCUTMENU). + RightClickModeChanged(settings::RightClickMode), + /// Options → User Preferences: time-sensitive hold threshold, ms. + RightClickHoldMsChanged(i32), /// Toggle TEXTEDIT ending after one object (TEXTEDITMODE). TextEditModeChanged(bool), /// Toggle continued dimensions inheriting the base style (DIMCONTINUEMODE). @@ -2346,6 +2487,63 @@ pub enum Message { /// active command as if typed (empty keyword = Enter). (#304) CommandOptionPick(String), ToggleLayers, + /// Open/focus (or close) the Reference Manager palette. + ToggleXrefManager, + /// Rescan the active drawing's references into the palette. + XrefManagerRefresh, + /// Toggle one palette row (entry index) in the multi-selection set. + XrefManagerSelect(usize), + /// Right-click on a palette row: single-select it when outside the + /// selection (the context menu then acts on the selection). + XrefRowRightClick(usize), + /// Flip the palette's list/tree presentation. + XrefManagerToggleTree, + /// Start a reference-table column divider drag (column index). + XrefColGrab(usize), + /// Pointer moved (table header space) during a column drag. + XrefColMove(iced::Point), + /// Pointer released during a column drag. + XrefColRelease, + /// Start a table/lower-pane split divider drag. + XrefSplitGrab, + /// Pointer moved (panel space) during a split drag. + XrefSplitMove(iced::Point), + /// Pointer released during a split drag. + XrefSplitRelease, + /// Flip the palette's details/preview lower pane. + XrefManagerTogglePreview, + /// Toggle the Attach dropdown menu. + XrefManagerAttachMenu, + /// Toggle the Refresh dropdown menu. + XrefManagerRefreshMenu, + /// Toggle the Change Path dropdown menu. + XrefManagerPathMenu, + /// Open the Reference Manager help window. + XrefHelpOpen, + /// Close all palette dropdown menus (overlay dismissal). + XrefManagerDismissMenus, + /// Open the file picker for Select New Path (anchor entry). + XrefPathPick, + /// Result of the Select New Path picker. + XrefPathPickResult(Result), + /// Reload every direct reference (toolbar Reload All). + XrefManagerReloadAll, + /// Expand/collapse one tree parent (block-record handle key). + XrefManagerToggleExpand(u64), + /// Selection-scoped palette operation (detach/unload/reload/overlay/pathtype). + XrefManagerOp(crate::ui::window::xref_manager::XrefPaletteOp), + /// Row-scoped palette operation: selects row `index`, then applies the + /// operation to it. + XrefRowOp(usize, crate::ui::window::xref_manager::XrefPaletteOp), + /// Prefill the command line for Find & Replace across references + /// (`XREF Path Find `); the CLI parses and runs it. + XrefFindReplacePrompt, + /// Pick a new path for a specific row index. + XrefRowPathPick(usize), + /// Prefill the command line for Find & Replace for a specific row index. + XrefRowFindReplacePrompt(usize), + XrefRowChangePathEnter, + XrefRowChangePathLeave, LayerToggleVisible(usize), LayerToggleLock(usize), LayerToggleFreeze(usize), @@ -2474,6 +2672,8 @@ pub enum Message { CommandEscape, /// Toggle the global snap on/off (OSNAP button body click). ToggleSnapEnabled, + /// Toggle 3D object snap on/off — F4. + ToggleSnap3dEnabled, /// Toggle grid-snap on/off — F9 / SNAP status-bar button. ToggleGridSnap, /// Enable or disable isometric drafting. @@ -2525,6 +2725,13 @@ pub enum Message { ScaleManagerOpen, /// Open the Annotation Object Scale dialog for the current single selection. AnnoObjectScaleOpen, + /// Open and edit the selected objects' PE_URL hyperlink collection. + PropHyperlinkOpen, + HyperlinkUrlChanged(String), + HyperlinkDescriptionChanged(String), + HyperlinkApply, + HyperlinkRemove, + HyperlinkCancel, /// Toggle whether the dialog's object has a representation for this scale. AnnoObjectScaleToggle(String), /// Select a scale row in the manager (loads it into the editor). @@ -2635,6 +2842,14 @@ pub enum Message { DraftingSettingsTabChanged(crate::ui::window::drafting_settings::DraftingSettingsTab), DraftingSettingsToggleGrid, DraftingSettingsToggleSnap, + DraftingSettingsSnapXChanged(String), + DraftingSettingsSnapYChanged(String), + DraftingSettingsGridXChanged(String), + DraftingSettingsGridYChanged(String), + DraftingSettingsGridMajorChanged(String), + DraftingSettingsToggleAdaptiveGrid, + DraftingSettingsToggleBeyondLimits, + DraftingSettingsToggleEqualSnap, DraftingSettingsToggleIsometric, DraftingSettingsSetIsoPlane(crate::app::settings::IsoPlane), DraftingSettingsResetRotation, @@ -2643,6 +2858,7 @@ pub enum Message { DraftingSettingsToggleOsnap, DraftingSettingsToggleOtrack, DraftingSettingsToggleSnapMode(crate::snap::SnapType), + DraftingSettingsToggleSnapMode3d(crate::snap::SnapType), DraftingSettingsSnapSelectAll, DraftingSettingsSnapClearAll, DraftingSettingsToggle3dOsnap, @@ -2654,6 +2870,14 @@ pub enum Message { DraftingSettingsClose, DraftingSettingsCloseDiscard, DraftingSettingsCloseKeep, + /// Options window: commit the changes made so far. + OptionsApply, + /// Options window: commit and close. + OptionsOk, + /// Options window: close, asking first when changes would be lost. + OptionsClose, + OptionsCloseDiscard, + OptionsCloseKeep, AutoConstrainSelectRow(usize), AutoConstrainToggleKind(settings::AutoConstraintKind), AutoConstrainMoveUp, @@ -2890,6 +3114,22 @@ pub enum Message { AliasEditorRemove(usize), /// Commit the edited rows to the alias table (Apply button); stays open. AliasEditorApply, + /// Apply the working rows and close the dialog. + AliasEditorApplyExit, + /// The check button on a draft row: finish the addition without applying. + AliasEditorDraftAccept, + /// Cancel a pending draft row (Esc / Cancel add). + AliasEditorDraftCancel, + /// Show the "Reset to default" confirmation. + AliasEditorResetAsk, + /// Reset aliases to the shipped defaults. + AliasEditorResetConfirm, + /// Hide the reset confirmation without resetting. + AliasEditorResetDeny, + /// Discard un-applied rows and close the editor. + AliasEditorCloseDiscard, + /// Keep editing: hide the discard confirmation. + AliasEditorCloseKeep, // ── Named Parameters (PARAMETERS) ─────────────────────────────────── /// Open the named-parameter editor, seeding rows from the active tab's /// `Scene::named_parameters`. @@ -3134,9 +3374,15 @@ pub enum Message { TextInlineInput(String), /// Commit the editor: create or update the TEXT entity. TextInlineOk, - // ── Draw Order context menu ───────────────────────────────────────── - /// Toggle the Draw Order sub-items in the viewport context menu. - DrawOrderSubmenuToggle, + // ── Viewport right-click context menu ─────────────────────────────── + /// A context-menu row was picked (mouse or keyboard). Closes the menu and + /// runs the row's action through the same message the equivalent typed + /// input / shortcut would have produced. + ContextMenuPick(crate::ui::popup::context_menu::MenuAction), + /// Expand / collapse an accordion submenu of the open context menu. + ContextMenuSubmenuToggle(crate::ui::popup::context_menu::SubmenuId), + /// Keyboard navigation inside the open context menu. + ContextMenuNavigate(ContextMenuNav), /// Begin an interactive reference-object pick to move the current /// selection above (`true`) or below (`false`) the picked object. DrawOrderPickRef(bool), @@ -3192,10 +3438,6 @@ pub enum Message { PlotExport, /// Callback after the user picks (or cancels) the export path. PlotExportPath(Option), - /// User picked a paper size for the model-space window plot. - PlotFormat(crate::io::paper_sizes::PaperSize), - /// User picked a sheet orientation for the model-space window plot. - PlotOrientation(crate::io::paper_sizes::Orientation), /// Export the pending model-space plot window (from PLOTWINDOW) to PDF. PlotWindowExport, /// Callback after the user picks (or cancels) the window-export path. @@ -3237,7 +3479,9 @@ pub enum Message { /// Open file dialog to load a CTB/STB plot style table. PlotStyleLoad, /// Callback when the user picks (or cancels) a CTB/STB file. - PlotStyleLoaded(Option), + /// The Load… picker finished: a table, nothing (cancelled), or why the + /// file could not be read. + PlotStyleLoaded(Result, String>), /// Clear the active plot style table. PlotStyleClear, /// Open/close the Plot Style panel. @@ -3454,6 +3698,20 @@ pub enum Message { ImagePick, /// Result of the image file picker + pixel dimension decode. ImagePickResult(Result<(std::path::PathBuf, u32, u32), String>), + /// Open file-picker dialog for IMAGEEMBED command (async). + ImageEmbedPick, + /// Result of the embedded-image picker: bytes prepared for an OLE2FRAME. + ImageEmbedPickResult(Result), + // ── Missing fonts ───────────────────────────────────────────────────── + /// Download the offered missing fonts from the community repository. + MissingFontsDownload, + /// Edit the custom font source URL in the missing-fonts prompt. + MissingFontsSourceChanged(String), + /// Close the missing-fonts prompt without downloading. + MissingFontsDismiss, + /// Fonts fetched (or failed); payload lists (name, saved-path) pairs. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + MissingFontsResult(Result, String>), // ── PDF Underlay ────────────────────────────────────────────────────── /// Open file-picker dialog for PDFATTACH command (async). PdfAttachPick, @@ -3641,6 +3899,8 @@ impl OpenCADStudio { pick_box: 3, double_click_block_refedit: false, double_click_block_attedit: true, + right_click_mode: settings::RightClickMode::ShortcutMenu, + right_click_hold_ms: 250, grip_object_limit: settings::DEFAULT_GRIP_OBJECT_LIMIT, ncopy_bind: false, cursor_type: settings::CursorType::Crosshair, @@ -3653,8 +3913,27 @@ impl OpenCADStudio { iso_plane: settings::IsoPlane::Left, snap_angle_deg: 0.0, show_grid: false, + grid_spacing_x: 10.0, + grid_spacing_y: 10.0, + grid_major_every: 5, + grid_adaptive: true, + grid_beyond_limits: true, dyn_input: true, options_tab: crate::ui::window::options::OptionsTab::General, + options_saved: None, + options_close_confirm: false, + spacemouse: { + let service = crate::input::spacemouse::Service::default(); + service.set_actions(navigation::actions()); + service + }, + spacemouse_preferences: crate::input::spacemouse::Preferences::default(), + spacemouse_paused: false, + spacemouse_focused: false, + spacemouse_details: false, + spacemouse_was_moving: false, + spacemouse_pivot: None, + spacemouse_selection: navigation::SelectionCache::default(), texteditmode: false, quick_dimension_snap_priority: 0, dimension_continue_mode: 1, @@ -3669,6 +3948,7 @@ impl OpenCADStudio { constraint_solve_mode: true, constraint_infer: false, constraint_bar_display: 3, + constraint_bar_mode: 4095, savetime_min: 10, default_bg_color: None, default_paper_bg_color: None, @@ -3694,6 +3974,7 @@ impl OpenCADStudio { grip_add_provisional: None, grip_preview_handles: Vec::new(), hover_dwell: None, + constraint_glyph_tooltip: None, grip_originals: Vec::new(), grip_history_originals: Vec::new(), grip_dirty_before: None, @@ -3710,16 +3991,26 @@ impl OpenCADStudio { render_mode_preview: None, show_properties: true, show_block_palette: false, + show_external_references: false, + show_browser: false, + bg_picker: None, block_palette: Default::default(), + xref_manager: Default::default(), dock: Default::default(), dock_expanded: None, dock_dragging: None, dock_resizing: None, dock_drag_last: None, dock_drag_target: None, + xref_col_drag: None, + xref_col_last: None, + xref_split_drag: false, show_file_tabs: true, show_layout_tabs: true, last_point: None, + vp_snap_frame: None, + accepted_snaps: Vec::new(), + pending_click_snap: None, main_window: None, thumbnail_capture_clean: false, #[cfg(not(target_arch = "wasm32"))] @@ -3730,6 +4021,11 @@ impl OpenCADStudio { color_picker_tab: ColorPickerTab::Index, recent_colors: Vec::new(), active_modal: None, + hyperlink_editor_handles: Vec::new(), + hyperlink_editor_url: String::new(), + hyperlink_editor_description: String::new(), + hyperlink_editor_mixed: false, + hyperlink_editor_dirty: false, pending_startup_modals: std::collections::VecDeque::new(), gpu_status: crate::scene::pipeline::GpuStatus::Unknown, gpu_status_generation: 0, @@ -3776,6 +4072,9 @@ impl OpenCADStudio { point_size_buf: String::new(), point_size_relative: true, default_assoc_prompted: false, + check_missing_fonts: true, + font_source_url: String::new(), + font_source_input: String::new(), donation_prompt_version: String::new(), read_only: false, update_notice_version: None, @@ -3800,8 +4099,8 @@ impl OpenCADStudio { mtext_click_off: 0, mtext_click_count: 0, plot_window: None, - plot_format: crate::io::paper_sizes::PaperSize::A4, - plot_orientation: crate::io::paper_sizes::Orientation::Landscape, + plot_paper: crate::io::paper_catalog::default_paper().clone(), + plot_orientation: crate::io::paper_catalog::Orientation::Landscape, plot_dialog: crate::ui::window::plot::PlotDialogState::default(), plot_prev: None, plot_setup_template: None, @@ -3816,6 +4115,7 @@ impl OpenCADStudio { layout_settling: false, open_job_serial: 0, recovery_report: None, + missing_fonts: None, pending_opens: std::collections::VecDeque::new(), active_interaction_index: None, queued_interaction_indices: std::collections::VecDeque::new(), @@ -3865,6 +4165,9 @@ impl OpenCADStudio { // Command aliases (populated from ocad.pgp just after construction) command_aliases: rustc_hash::FxHashMap::default(), alias_editor_rows: Vec::new(), + alias_pending_add: false, + alias_reset_confirm: false, + alias_close_confirm: false, named_parameter_editor_rows: Vec::new(), // Layout Manager layout_manager_selected: "Model".to_string(), @@ -4138,6 +4441,17 @@ impl OpenCADStudio { // `--read-only` disables saving. `--script` queues command lines. let cfg = crate::cli::gui_config(); s.read_only = cfg.read_only; + // GPU backend / renderer fallback: the resolver ran before iced + // booted, so surface its verdict here where the user can see it. + if let Some(notice) = cfg.gpu_fallback_notice { + s.command_line.push_warning(¬ice); + crate::scene::pipeline::report_gpu_line(&format!("[gpu] {notice}")); + } + if cfg.gpu_compat_auto { + let notice = crate::gpu_backend::compat_notice(); + s.command_line.push_warning(¬ice); + crate::scene::pipeline::report_gpu_line(&format!("[gpu] {notice}")); + } let cli_open: Task = if !cfg.files.is_empty() { Task::batch( cfg.files diff --git a/src/app/navigation.rs b/src/app/navigation.rs new file mode 100644 index 000000000..5a9575230 --- /dev/null +++ b/src/app/navigation.rs @@ -0,0 +1,699 @@ +//! Application policy for device navigation. All scene writes run on the UI +//! thread, preserving active commands and the scene's viewport locks. +use super::{Message, ModalKind, OpenCADStudio}; +use crate::input::spacemouse::{Action, NavigationMode, Target, View}; +use crate::scene::view::camera::Projection; +use iced::Task; + +#[derive(Default)] +pub(super) struct SelectionCache( + std::cell::RefCell)>>, +); +impl SelectionCache { + fn bounds( + &self, + scene: &crate::scene::Scene, + target: &Target, + ) -> Option<(glam::DVec3, glam::DVec3)> { + let mut cache = self.0.borrow_mut(); + if let Some((stamp, geometry, selection, bounds)) = cache.as_ref() { + if stamp.same_context(target) + && *geometry == scene.geometry_epoch + && *selection == scene.selection_generation + { + return *bounds; + } + } + let bounds = scene.navigation_selection_bounds(); + *cache = Some(( + target.clone(), + scene.geometry_epoch, + scene.selection_generation, + bounds, + )); + bounds + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &[ + "SPACEMOUSE", + "SPACEMOUSEPAUSE", + "SPACEMOUSEPAN", + "SPACEMOUSEPANZOOM", + "SPACEMOUSEAUTO", + "SPACEMOUSE3D" + ], +}); + +impl OpenCADStudio { + fn navigation_target(&self) -> Target { + let tab = &self.tabs[self.active_tab]; + Target { + tab: tab.id, + layout: format!("{}:{:?}", tab.scene.current_layout, tab.active_block_edit), + viewport: tab.scene.active_viewport.map(|h| h.value()), + tile: tab.scene.active_model_tile.get(), + generation: tab.scene.camera_generation, + } + } + pub(super) fn spacemouse_sheet(&self) -> bool { + let scene = &self.tabs[self.active_tab].scene; + scene.current_layout != "Model" && scene.active_viewport.is_none() + } + pub(super) fn spacemouse_label(&self) -> String { + if !self.spacemouse_preferences.enabled { + return crate::t!("Disabled").into_owned(); + } + if self.spacemouse_paused { + return crate::t!("Paused").into_owned(); + } + if self.tabs[self.active_tab].scene.navigation_locked() { + return crate::t!("Viewport locked").into_owned(); + } + if self.spacemouse.status() != crate::input::spacemouse::Status::Ready { + return crate::t!(self.spacemouse.status().label()).into_owned(); + } + let mode = self.spacemouse_preferences.mode; + let effective = if self.spacemouse_sheet() && mode.allows_zoom() { + NavigationMode::PanZoom + } else if mode == NavigationMode::Auto { + NavigationMode::Full3D + } else { + mode + }; + crate::t!(effective.label()).into_owned() + } + pub(super) fn sync_spacemouse(&self) { + let tab = &self.tabs[self.active_tab]; + let scene = &tab.scene; + let available = self.spacemouse_preferences.enabled + && !tab.is_start + && self.opening.is_none() + && self.spacemouse.status() == crate::input::spacemouse::Status::Ready; + if !available { + self.spacemouse.sync(None, false); + return; + } + // The driver's settings dialog must still be able to query this view + // after it takes keyboard focus. Gate input separately from its metadata. + let allowed = self.spacemouse_focused && self.active_modal.is_none(); + let camera = scene.navigation_camera(); + let (w, h) = scene.selection.borrow().vp_size; + let bounds = scene.active_model_tile_bounds(w, h); + let aspect = scene + .active_viewport + .and_then(|handle| match scene.document.get_entity(handle) { + Some(acadrust::EntityType::Viewport(vp)) => Some(vp.width / vp.height.max(1e-6)), + _ => None, + }) + .unwrap_or((bounds.width / bounds.height.max(1.)).max(0.01) as f64); + let center = camera.target; + let span = glam::DVec3::splat(camera.distance.max(1.) as f64); + let (min, max) = camera + .model_bounds + .or_else(|| { + scene + .model_space_extents() + .map(|(min, max)| (min.as_dvec3(), max.as_dvec3())) + }) + .unwrap_or((center - span, center + span)); + let target = self.navigation_target(); + let selected = self.spacemouse_selection.bounds(scene, &target); + let pivot = selected + .map(|(min, max)| (min + max) * 0.5) + .or_else(|| { + self.spacemouse_pivot + .as_ref() + .filter(|(stamp, _)| stamp.same_context(&target)) + .map(|(_, point)| *point) + }) + .unwrap_or(center); + let selection_bounds = + selected.map(|(min, max)| [min.x, min.y, min.z, max.x, max.y, max.z]); + self.spacemouse.sync( + Some(View { + target, + eye: camera.eye(), + rotation: camera.rotation.as_dquat().normalize(), + distance: camera.distance as f64, + fov: camera.fov_y as f64, + perspective: camera.projection == Projection::Perspective, + aspect, + model_bounds: [min.x, min.y, min.z, max.x, max.y, max.z], + selection_bounds, + pointer: tab.last_cursor_world, + pivot, + pivot_visible: self.spacemouse_pivot.is_some(), + rotate: self + .spacemouse_preferences + .mode + .allows_rotation(self.spacemouse_sheet()), + zoom: self.spacemouse_preferences.mode.allows_zoom(), + motion_enabled: !self.spacemouse_paused && !scene.navigation_locked(), + units_to_meters: super::properties::insunits_to_mm( + scene.document.header.insertion_units, + ) + .unwrap_or(1000.) + / 1000., + pan_speed: self.spacemouse_preferences.pan_speed.clamp(10, 300) as f64 / 100. + * if self.spacemouse_preferences.pan_reversed { + -1. + } else { + 1. + }, + }), + allowed, + ); + } + pub(super) fn on_spacemouse_wake(&mut self) -> Task { + let (view, commands) = self.spacemouse.drain(); + if !self.spacemouse_focused + || self.active_modal.is_some() + || !self.spacemouse_preferences.enabled + || self.tabs[self.active_tab].is_start + { + self.spacemouse_was_moving = false; + self.spacemouse_pivot = None; + return Task::none(); + } + let target = self.navigation_target(); + let mut tasks = Vec::new(); + let moving = self.spacemouse.moving() && !self.spacemouse_paused; + if moving && !self.spacemouse_was_moving { + self.tabs[self.active_tab].scene.remember_current_view(); + self.clear_navigation_hover(self.active_tab); + } + if !moving && self.spacemouse_was_moving { + self.arm_hover_after_navigation(self.active_tab); + } + self.spacemouse_was_moving = moving; + if !moving { + self.spacemouse_pivot = None; + } + if let Some(view) = view.filter(|view| view.target == target && !self.spacemouse_paused) { + let i = self.active_tab; + let mut camera = self.tabs[i].scene.navigation_camera(); + if view.rotate { + camera.rotation = view.rotation.as_quat(); + camera.sync_yaw_pitch(); + } + if view.zoom { + camera.distance = view.distance as f32; + } + // Use the same f32 basis/distance as Camera::eye so a callback + // that only updates the pivot cannot introduce camera drift. + camera.target = + view.eye - (camera.rotation * glam::Vec3::Z).as_dvec3() * camera.distance as f64; + let started = iced::time::Instant::now(); + if self.tabs[i].scene.apply_navigation_camera(camera) { + self.spacemouse + .acknowledge(&view.target, &self.navigation_target()); + self.spacemouse_pivot = (view.pivot_visible && moving) + .then_some((self.navigation_target(), view.pivot)); + let (w, h) = self.tabs[i].scene.selection.borrow().vp_size; + self.reproject_box_anchor(i, w, h); + // The pointer may be stationary while the left hand navigates. + // Refresh the active command's snap/preview against the new + // camera, using the existing pointer path without a click. + let cursor = { + let selection = self.tabs[i].scene.selection.borrow(); + (!selection.left_down + && !selection.middle_down + && self.tabs[i].active_cmd.is_some()) + .then_some(selection.last_move_pos) + .flatten() + }; + if let Some(cursor) = cursor { + tasks.push(self.on_viewport_move(cursor)); + } + self.tabs[i] + .scene + .record_nav_perf(crate::scene::NavPerfOp::Pan, started); + } + } + for (stamp, command) in commands { + if stamp.same_context(&self.navigation_target()) && self.active_modal.is_none() { + tasks.push(self.run_action(&command)); + } + } + Task::batch(tasks) + } + + pub(super) fn spacemouse_pivot_overlay(&self) -> Option> { + let (target, point) = self.spacemouse_pivot.as_ref()?; + if target != &self.navigation_target() || self.active_modal.is_some() { + return None; + } + let scene = &self.tabs[self.active_tab].scene; + let (w, h) = scene.selection.borrow().vp_size; + let rect = scene + .active_viewport + .and_then(|handle| scene.viewport_screen_rect(handle, (w, h))) + .unwrap_or_else(|| scene.active_model_tile_bounds(w, h)); + let p = scene.navigation_camera().project(*point, rect)?; + if p.x < 0. || p.y < 0. || p.x > rect.width || p.y > rect.height { + return None; + } + let marker = iced::widget::container(iced::widget::Space::new()) + .width(10) + .height(10) + .style(|theme: &iced::Theme| iced::widget::container::Style { + border: iced::Border { + color: theme.palette().primary.base.color, + width: 2., + radius: 5.0.into(), + }, + ..Default::default() + }); + Some( + iced::widget::pin(marker) + .position(iced::Point::new(rect.x + p.x - 5., rect.y + p.y - 5.)) + .into(), + ) + } + pub(super) fn open_spacemouse_preferences(&mut self) { + self.options_tab = crate::ui::window::options::OptionsTab::UserPreferences; + self.active_modal = Some(ModalKind::Options); + } + pub(super) fn open_spacemouse_driver_settings(&self) -> Task { + #[cfg(windows)] + { + if let Some(program_files) = + std::env::var_os("ProgramW6432").or_else(|| std::env::var_os("ProgramFiles")) + { + let path = std::path::PathBuf::from(program_files) + .join("3Dconnexion/3DxWare/3DxWinCore/3DxSmartUi.exe"); + if path.is_file() { + return Task::perform( + async move { + let _ = std::process::Command::new(path).spawn(); + }, + |_| Message::SpaceMouseWake, + ); + } + } + } + crate::sys::open_url("https://3dconnexion.com/drivers/", self.main_window) + } +} + +pub(super) fn actions() -> Vec { + use crate::modules::{IconKind, ModuleEvent, RibbonItem, ToolDef}; + use std::collections::BTreeMap; + let mut names = crate::command::all_registered_command_names(); + names.extend([ + "UNDO", + "REDO", + "CANCEL", + "QSAVE", + "OPEN", + "ZOOM", + "PLAN", + "SPACEMOUSEFIT", + "SPACEMOUSETOP", + ]); + names.sort_unstable(); + names.dedup(); + let mut actions: BTreeMap = names + .into_iter() + .map(|name| { + let label = match name { + "SPACEMOUSE" => "SpaceMouse preferences", + "SPACEMOUSEPAUSE" => "Pause / resume SpaceMouse", + "SPACEMOUSEPAN" => "SpaceMouse: pan only", + "SPACEMOUSEPANZOOM" => "SpaceMouse: pan and zoom", + "SPACEMOUSEAUTO" => "SpaceMouse: follow context", + "SPACEMOUSE3D" => "SpaceMouse: 3D navigation", + "SPACEMOUSEFIT" => "Fit view", + "SPACEMOUSETOP" => "Top view", + _ => name, + }; + ( + name.into(), + Action { + id: name.into(), + label: crate::t!(label).into_owned(), + description: format!("{} ({name})", crate::t!(label)), + icon: name + .starts_with("SPACEMOUSE") + .then_some(crate::ui::window::options::spacemouse::ICON), + }, + ) + }) + .collect(); + fn describe( + actions: &mut BTreeMap, + command: &str, + label: &str, + icon: IconKind, + ) { + if let Some(action) = actions.get_mut(command) { + action.label = crate::t!(label).into_owned(); + action.description = format!("{} ({command})", action.label); + action.icon = match icon { + IconKind::Svg(bytes) => Some(bytes), + _ => None, + }; + } + } + fn tool(actions: &mut BTreeMap, tool: &ToolDef) { + if let ModuleEvent::Command(command) = &tool.event { + describe(actions, command, tool.label, tool.icon); + } + } + for module in crate::modules::registry::all_modules() { + for group in module.ribbon_groups() { + for item in &group.tools { + match item { + RibbonItem::Tool(t) | RibbonItem::LabeledTool(t) | RibbonItem::LargeTool(t) => { + tool(&mut actions, t) + } + RibbonItem::Dropdown { items, .. } + | RibbonItem::LabeledDropdown { items, .. } + | RibbonItem::LargeDropdown { items, .. } => { + for (label, command, icon) in items { + describe(&mut actions, command, label, *icon); + } + } + RibbonItem::ToolGrid { columns } + | RibbonItem::StyleComboGroup { rows: columns, .. } => { + for t in columns.iter().flatten() { + tool(&mut actions, t); + } + } + RibbonItem::LayerComboGroup { row2, row3 } => { + for t in row2.iter().chain(row3) { + tool(&mut actions, t); + } + } + RibbonItem::PropertiesGroup { match_prop } => tool(&mut actions, match_prop), + } + } + } + } + actions.into_values().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use glam::DVec3; + + fn app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.tabs[app.active_tab] + .scene + .selection + .borrow_mut() + .vp_size = (800., 600.); + app.spacemouse_focused = true; + app.spacemouse.test_connect(); + app.sync_spacemouse(); + app + } + + #[test] + fn spacemouse_navigation_and_buttons_preserve_a_live_polyline() { + let mut app = app(); + let _ = app.dispatch_command("PLINE"); + for point in [ + DVec3::ZERO, + DVec3::new(10., 0., 0.), + DVec3::new(10., 10., 0.), + ] { + let _ = app.feed_command(crate::command::StepInput::Point(point)); + } + let i = app.active_tab; + let before = app.tabs[i].scene.navigation_camera(); + let epoch = app.tabs[i].scene.geometry_epoch; + app.sync_spacemouse(); + app.spacemouse.test_propose(|v| v.eye.x += 3.); + let _ = app.on_spacemouse_wake(); + assert_eq!( + app.tabs[i].scene.navigation_camera().target.x, + before.target.x + 3. + ); + assert_eq!(app.tabs[i].scene.geometry_epoch, epoch); + assert!(app.tabs[i].active_cmd.is_some()); + let _ = app.run_action("UNDO"); + assert!(app.tabs[i].active_cmd.is_some()); + // Finish after Undo: the last point was removed, leaving one segment. + let _ = app.feed_command(crate::command::StepInput::Enter); + let polyline = app.tabs[i] + .scene + .document + .entities() + .find_map(|e| { + if let acadrust::EntityType::LwPolyline(p) = e { + Some(p) + } else { + None + } + }) + .expect("finished polyline"); + assert_eq!(polyline.vertices.len(), 2); + } + + #[test] + fn spacemouse_refreshes_a_live_command_at_a_stationary_pointer() { + let mut app = app(); + app.snapper.snap_enabled = false; + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + let _ = app.dispatch_command("LINE"); + let _ = app.feed_command(crate::command::StepInput::Point(DVec3::ZERO)); + let _ = app.on_viewport_move(iced::Point::new(480., 300.)); + let i = app.active_tab; + let before = app.tabs[i].last_cursor_world; + let count = app.tabs[i].scene.document.entities().count(); + app.sync_spacemouse(); + app.spacemouse.test_propose(|v| v.eye.x += 3.); + let _ = app.on_spacemouse_wake(); + assert!((app.tabs[i].last_cursor_world.x - before.x - 3.).abs() < 1e-4); + assert!(app.tabs[i].active_cmd.is_some()); + assert_eq!(app.tabs[i].scene.document.entities().count(), count); + } + + #[test] + fn spacemouse_stale_camera_modal_and_pause_block_pending_motion() { + for guard in ["camera", "modal", "pause", "focus"] { + let mut app = app(); + let before = app.tabs[app.active_tab].scene.navigation_camera().target; + app.spacemouse.test_propose(|v| v.eye.x += 30.); + match guard { + "camera" => app.tabs[app.active_tab].scene.camera_generation += 1, + "modal" => app.active_modal = Some(ModalKind::Options), + "pause" => app.spacemouse_paused = true, + _ => app.spacemouse_focused = false, + } + let _ = app.on_spacemouse_wake(); + assert_eq!( + app.tabs[app.active_tab].scene.navigation_camera().target, + before, + "{guard}" + ); + } + } + + #[test] + fn spacemouse_settings_dialog_retains_view_metadata_without_accepting_motion() { + for modal in [false, true] { + let mut app = app(); + app.spacemouse_preferences.mode = NavigationMode::Full3D; + app.sync_spacemouse(); + let before = app.tabs[app.active_tab].scene.navigation_camera(); + if modal { + app.active_modal = Some(ModalKind::Options); + } else { + app.spacemouse_focused = false; + } + app.sync_spacemouse(); + let snapshot = app + .spacemouse + .test_view() + .expect("driver can describe the inactive view"); + assert!(snapshot.rotate); + assert_eq!(snapshot.distance, before.distance as f64); + app.spacemouse.test_propose(|v| v.eye.x += 10.); + let _ = app.on_spacemouse_wake(); + assert_eq!( + app.tabs[app.active_tab].scene.navigation_camera().target, + before.target + ); + } + } + + #[test] + fn spacemouse_floating_camera_round_trip_and_lock() { + let mut app = app(); + let scene = &mut app.tabs[app.active_tab].scene; + let paper_camera = scene.camera.borrow().clone(); + let mut vp = acadrust::entities::Viewport::default(); + vp.width = 200.; + vp.height = 100.; + vp.view_height = 50.; + let handle = scene.add_entity(acadrust::EntityType::Viewport(vp)); + scene.active_viewport = Some(handle); + let mut camera = scene.navigation_camera(); + camera.target = DVec3::new(120., 30., 8.); + camera.rotation = glam::Quat::from_euler(glam::EulerRot::XYZ, 0.7, 0.3, 0.2); + camera.distance = 80.; + assert!(scene.apply_navigation_camera(camera.clone())); + let round_trip = scene.navigation_camera(); + assert!(round_trip.target.distance(camera.target) < 1e-6); + assert!(round_trip.rotation.abs_diff_eq(camera.rotation, 1e-5)); + assert!((round_trip.distance - camera.distance).abs() < 1e-4); + assert_eq!(scene.camera.borrow().target, paper_camera.target); + assert_eq!(scene.camera.borrow().distance, paper_camera.distance); + if let Some(acadrust::EntityType::Viewport(vp)) = scene.document.get_entity_mut(handle) { + vp.status.locked = true; + } + camera.target.x += 100.; + assert!(!scene.apply_navigation_camera(camera)); + assert_eq!(scene.navigation_camera().target, round_trip.target); + } + + #[test] + fn spacemouse_push_and_tilt_move_the_drawing_in_screen_space_continuously() { + for mode in [NavigationMode::PanOnly, NavigationMode::PanZoom] { + for projection in [Projection::Orthographic, Projection::Perspective] { + for reversed in [false, true] { + for (axes, direction) in [ + ([350, 0, 0, 0, 0, 0], glam::Vec2::X), + ([0, 0, 0, 0, -350, 0], glam::Vec2::X), + ([-350, 0, 0, 0, 0, 0], -glam::Vec2::X), + ([0, 0, 0, 0, 350, 0], -glam::Vec2::X), + ([0, -350, 0, 0, 0, 0], -glam::Vec2::Y), + ([0, 0, 0, -350, 0, 0], -glam::Vec2::Y), + ([0, 350, 0, 0, 0, 0], glam::Vec2::Y), + ([0, 0, 0, 350, 0, 0], glam::Vec2::Y), + ] { + let mut app = app(); + app.spacemouse_preferences.mode = mode; + app.spacemouse_preferences.pan_reversed = reversed; + let i = app.active_tab; + { + let mut camera = app.tabs[i].scene.camera.borrow_mut(); + camera.rotation = + glam::Quat::from_euler(glam::EulerRot::XYZ, 0.7, 0.4, 0.2); + camera.projection = projection; + } + app.sync_spacemouse(); + let before = app.tabs[i].scene.navigation_camera(); + let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.)); + let point = before.target; + let screen = before.project(point, bounds).unwrap(); + let direction = direction * if reversed { -1. } else { 1. }; + let mut last = glam::Vec2::new(screen.x, screen.y); + for frame in 0..20 { + app.spacemouse.test_pan_report(axes); + app.spacemouse.frame(frame as f64 * 16.); + let _ = app.on_spacemouse_wake(); + app.sync_spacemouse(); + let camera = app.tabs[i].scene.navigation_camera(); + let moved = camera.project(point, bounds).unwrap(); + let moved = glam::Vec2::new(moved.x, moved.y); + let displacement = moved - last; + assert!(displacement.dot(direction) > 0., "{mode:?}: {axes:?}"); + assert!(displacement.perp_dot(direction).abs() < 0.001); + assert_eq!(camera.rotation, before.rotation); + assert_eq!(camera.distance, before.distance); + last = moved; + } + let stopped = app.tabs[i].scene.navigation_camera().target; + app.spacemouse.test_pan_report([0, 0, 0, 0, 0, 350]); + app.spacemouse.frame(400.); + let _ = app.on_spacemouse_wake(); + assert_eq!(app.tabs[i].scene.navigation_camera().target, stopped); + } + } + } + } + } + + #[test] + fn spacemouse_panzoom_lift_and_pressure_zoom_about_the_center_and_release_stops() { + for projection in [Projection::Orthographic, Projection::Perspective] { + for mode in [NavigationMode::PanOnly, NavigationMode::PanZoom] { + for pressure in [-350, 350] { + let mut app = app(); + app.spacemouse_preferences.mode = mode; + let i = app.active_tab; + { + let mut camera = app.tabs[i].scene.camera.borrow_mut(); + camera.rotation = + glam::Quat::from_euler(glam::EulerRot::XYZ, 0.7, 0.4, 0.2); + camera.projection = projection; + } + app.sync_spacemouse(); + let before = app.tabs[i].scene.navigation_camera(); + let mut distance = before.distance; + for frame in 0..20 { + app.spacemouse.test_pan_report([0, 0, pressure, 0, 0, 0]); + app.spacemouse.frame(frame as f64 * 16.); + let _ = app.on_spacemouse_wake(); + app.sync_spacemouse(); + let camera = app.tabs[i].scene.navigation_camera(); + assert!(camera.target.distance(before.target) < 1e-4); + assert_eq!(camera.rotation, before.rotation); + if mode == NavigationMode::PanOnly { + assert_eq!(camera.distance, distance); + } else { + assert!((camera.distance - distance) * pressure as f32 > 0.); + } + distance = camera.distance; + } + app.spacemouse.test_pan_report([0; 6]); + app.spacemouse.frame(400.); + let _ = app.on_spacemouse_wake(); + assert_eq!(app.tabs[i].scene.navigation_camera().distance, distance); + assert!(!app.spacemouse.moving()); + } + } + } + } + + #[test] + fn spacemouse_preferences_round_trip_and_old_settings_default() { + let old: super::super::settings::UserSettings = serde_json::from_str("{}").unwrap(); + assert_eq!( + old.spacemouse, + crate::input::spacemouse::Preferences::default() + ); + let mut app = app(); + app.spacemouse_preferences.mode = NavigationMode::PanOnly; + app.spacemouse_preferences.pan_speed = 170; + app.spacemouse_preferences.pan_reversed = true; + let encoded = serde_json::to_string(&app.current_settings()).unwrap(); + let restored: super::super::settings::UserSettings = + serde_json::from_str(&encoded).unwrap(); + app.spacemouse_preferences.mode = NavigationMode::Auto; + app.apply_settings(&restored); + assert_eq!(app.spacemouse_preferences.mode, NavigationMode::PanOnly); + assert_eq!(app.spacemouse_preferences.pan_speed, 170); + assert!(app.spacemouse_preferences.pan_reversed); + } + + #[test] + fn spacemouse_action_catalog_has_stable_unique_ids_and_ui_icons() { + let actions = actions(); + let ids: std::collections::HashSet<_> = actions.iter().map(|a| &a.id).collect(); + assert_eq!(ids.len(), actions.len()); + for id in [ + "LINE", + "UNDO", + "SPACEMOUSEPAN", + "SPACEMOUSEPAUSE", + "SPACEMOUSEFIT", + ] { + assert!(actions.iter().any(|a| a.id == id && !a.label.is_empty())); + } + assert!(actions.iter().any(|a| a.id == "LINE" && a.icon.is_some())); + assert!(crate::app::commands::is_transparent("SPACEMOUSEPAN")); + assert!(crate::app::commands::start_allowed("SPACEMOUSE")); + } +} diff --git a/src/app/options_session.rs b/src/app/options_session.rs new file mode 100644 index 000000000..b6f8d0378 --- /dev/null +++ b/src/app/options_session.rs @@ -0,0 +1,225 @@ +//! The Options window's OK / Apply / Close. Settings still change the moment +//! a control is touched, so a theme, a cursor size or a language is seen at +//! once — but nothing is committed until Apply or OK. Close (and the window's +//! × or Esc) puts everything back the way it was when the window opened, or +//! when Apply was last pressed, asking first when something would be lost. +//! +//! The commit point is a snapshot of the persisted configuration plus the +//! few drawing variables the window edits (the 3D Modeling page writes into +//! the active drawing's header); "dirty" is simply "the current snapshot +//! differs from the saved one", so every control the window gains later is +//! covered without registering it here. + +use super::OpenCADStudio; + +/// The drawing-header variables the Options window edits, for the active +/// tab, with the tab's modified flag so a revert leaves it as it found it. +#[derive(Clone, PartialEq)] +struct HeaderVars { + isolines: i16, + display_silhouette: bool, + surface_u_density: i16, + surface_v_density: i16, + surface_type: i16, + record_solid_history: bool, + show_solid_history: i16, + tab_dirty: bool, +} + +/// What the Options window can change, as it stood at the last commit. +#[derive(Clone, PartialEq)] +pub(super) struct OptionsSnapshot { + config: super::config::AppConfig, + header: Option, + textfill: bool, +} + +impl OpenCADStudio { + fn options_snapshot(&self) -> OptionsSnapshot { + let header = self.tabs.get(self.active_tab).map(|tab| { + let h = &tab.scene.document.header; + HeaderVars { + isolines: h.isolines, + display_silhouette: h.display_silhouette, + surface_u_density: h.surface_u_density, + surface_v_density: h.surface_v_density, + surface_type: h.surface_type, + record_solid_history: h.record_solid_history, + show_solid_history: h.show_solid_history, + tab_dirty: tab.dirty, + } + }); + OptionsSnapshot { + config: self.current_config(), + header, + textfill: crate::scene::text::sdf_atlas::textfill(), + } + } + + /// The window opens: remember the state to come back to. + pub(super) fn options_open(&mut self) { + self.active_modal = Some(super::ModalKind::Options); + self.options_saved = Some(self.options_snapshot()); + self.options_close_confirm = false; + } + + /// Whether anything changed since the window opened or Apply was pressed. + pub(super) fn options_dirty(&self) -> bool { + match &self.options_saved { + Some(saved) => *saved != self.options_snapshot(), + None => false, + } + } + + /// Apply: the current state becomes the state to come back to, and is + /// written to disk. + pub(super) fn options_apply(&mut self) { + self.options_saved = Some(self.options_snapshot()); + self.options_close_confirm = false; + self.save_config(); + } + + /// Close: put everything back to the last commit. The caller closes the + /// window afterwards. + pub(super) fn options_discard(&mut self) { + self.options_close_confirm = false; + if let Some(saved) = self.options_saved.take() { + self.options_revert_to(saved); + } + } + + /// Forget the commit point without reverting (OK, or a close that had + /// nothing to lose). + pub(super) fn options_forget(&mut self) { + self.options_saved = None; + self.options_close_confirm = false; + } + + fn options_revert_to(&mut self, saved: OptionsSnapshot) { + let current = self.options_snapshot(); + if current == saved { + return; + } + if current.config != saved.config { + // `apply_config` distributes every persisted preference, the + // theme and the language the way start-up does. + self.apply_config(saved.config.clone()); + } + if current.textfill != saved.textfill { + // The glyph atlas was re-baked for the other setting. + self.invalidate_text_everywhere(); + } + if let (Some(now), Some(then)) = (¤t.header, &saved.header) { + if now != then { + let i = self.active_tab; + if let Some(tab) = self.tabs.get_mut(i) { + let h = &mut tab.scene.document.header; + h.isolines = then.isolines; + h.display_silhouette = then.display_silhouette; + h.surface_u_density = then.surface_u_density; + h.surface_v_density = then.surface_v_density; + h.surface_type = then.surface_type; + h.record_solid_history = then.record_solid_history; + h.show_solid_history = then.show_solid_history; + tab.dirty = then.tab_dirty; + if now.show_solid_history != then.show_solid_history + || now.display_silhouette != then.display_silhouette + { + tab.scene.bump_geometry(); + } + } + if now.isolines != then.isolines { + self.isolines_awaiting_regen = false; + self.regenerate_meshes(); + } + } + } + self.save_config(); + } +} + +#[cfg(test)] +mod tests { + use crate::app::{Message, OpenCADStudio}; + + fn app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app + } + + #[test] + fn changes_show_at_once_but_close_puts_them_back() { + let mut app = app(); + let _ = app.update(Message::OptionsOpen); + assert!(!app.options_dirty()); + let before = app.zoom_factor; + let _ = app.update(Message::ZoomFactorChanged(before + 7)); + assert_eq!(app.zoom_factor, before + 7, "the change is live"); + assert!(app.options_dirty()); + // The first Close asks; the discard puts the value back. + let _ = app.update(Message::OptionsClose); + assert!(app.options_close_confirm); + assert_eq!(app.active_modal, Some(crate::app::ModalKind::Options)); + let _ = app.update(Message::OptionsCloseDiscard); + assert_eq!(app.zoom_factor, before); + assert_eq!(app.active_modal, None); + assert!(!app.options_dirty()); + } + + #[test] + fn apply_makes_the_change_the_new_baseline() { + let mut app = app(); + let _ = app.update(Message::OptionsOpen); + let before = app.zoom_factor; + let _ = app.update(Message::ZoomFactorChanged(before + 7)); + let _ = app.update(Message::OptionsApply); + assert!(!app.options_dirty(), "Apply commits"); + let _ = app.update(Message::ZoomFactorChanged(before + 9)); + let _ = app.update(Message::OptionsClose); + let _ = app.update(Message::OptionsCloseDiscard); + assert_eq!(app.zoom_factor, before + 7, "back to the applied value, not the original"); + } + + #[test] + fn ok_commits_and_closes_and_a_clean_close_asks_nothing() { + let mut app = app(); + let _ = app.update(Message::OptionsOpen); + let before = app.zoom_factor; + let _ = app.update(Message::ZoomFactorChanged(before + 7)); + let _ = app.update(Message::OptionsOk); + assert_eq!(app.active_modal, None); + assert_eq!(app.zoom_factor, before + 7); + let _ = app.update(Message::OptionsOpen); + let _ = app.update(Message::OptionsClose); + assert_eq!(app.active_modal, None, "nothing to lose, nothing to ask"); + assert!(!app.options_close_confirm); + // Keep editing returns to the window with the change intact. + let _ = app.update(Message::OptionsOpen); + let _ = app.update(Message::ZoomFactorChanged(before + 11)); + let _ = app.update(Message::CloseModal); + assert!(app.options_close_confirm, "the × asks like Close does"); + let _ = app.update(Message::OptionsCloseKeep); + assert_eq!(app.active_modal, Some(crate::app::ModalKind::Options)); + assert_eq!(app.zoom_factor, before + 11); + } + + #[test] + fn drawing_variables_of_the_modeling_page_are_reverted_too() { + let mut app = app(); + let i = app.active_tab; + let isolines = app.tabs[i].scene.document.header.isolines; + let silhouette = app.tabs[i].scene.document.header.display_silhouette; + let _ = app.update(Message::OptionsOpen); + let _ = app.update(Message::IsolinesChanged(isolines + 4)); + let _ = app.update(Message::DispSilhChanged(!silhouette)); + assert!(app.options_dirty()); + assert!(app.tabs[i].dirty); + let _ = app.update(Message::OptionsClose); + let _ = app.update(Message::OptionsCloseDiscard); + let h = &app.tabs[i].scene.document.header; + assert_eq!(h.isolines, isolines); + assert_eq!(h.display_silhouette, silhouette); + assert!(!app.tabs[i].dirty, "the drawing is as unmodified as it was"); + } +} diff --git a/src/app/properties.rs b/src/app/properties.rs index a942c5ca6..c0ed6ce64 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1277,6 +1277,35 @@ impl OpenCADStudio { &self.tabs[i].scene.document, d.base().common.handle, ); + let statuses = self.tabs[i] + .scene + .dimension_association_status(d.base().common.handle); + let slots = self.tabs[i] + .scene + .dimension_association_slot_points(d.base().common.handle) + .len(); + let status = if statuses.is_empty() { + t!("Nonassociative") + } else if statuses.iter().any(|(_, status)| { + matches!(status, crate::scene::ReferenceStatus::Broken(_)) + }) { + t!("Broken reference") + } else if statuses.iter().any(|(_, status)| { + matches!(status, crate::scene::ReferenceStatus::Unresolved) + }) { + t!("Unresolved reference") + } else if statuses.len() < slots { + t!("Partially associated") + } else { + t!("Associated") + }; + general.props.push(crate::scene::model::object::Property { + label: t!("Association status").into_owned(), + field: "association_status", + value: crate::scene::model::object::PropValue::ReadOnly( + status.into_owned(), + ), + }); general.props.push(crate::scene::model::object::Property { label: t!("Associative").into_owned(), field: "associative", @@ -2762,7 +2791,16 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} handles: &[Handle], driven_refs: &[crate::scene::parametric_constraints::ParametricRef], ) { - self.invalidate_property_targets_with_originals(i, handles, driven_refs, &[]); + let retain_size = self.constraint_solve_mode + && !driven_refs.is_empty() + && driven_refs.iter().all(|reference| reference.marker.is_some()); + self.invalidate_property_targets_with_originals( + i, + handles, + driven_refs, + retain_size, + &[], + ); } pub(super) fn invalidate_property_targets_with_originals( @@ -2770,6 +2808,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} i: usize, handles: &[Handle], driven_refs: &[crate::scene::parametric_constraints::ParametricRef], + retain_size: bool, retained_originals: &[(Handle, acadrust::EntityType)], ) { let mut context_object_changed = false; @@ -2806,7 +2845,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} .bump_entities_with_parametric_originals( &changes, driven_refs, - self.constraint_solve_mode && !driven_refs.is_empty(), + retain_size, retained_originals, ); } @@ -3205,6 +3244,7 @@ fn make_sections_read_only(sections: &mut [crate::scene::model::object::PropSect | PropValue::ReadOnlyWithTooltip { value, .. } | PropValue::EditText(value) | PropValue::PlainText(value) + | PropValue::Hyperlink(value) | PropValue::LayerChoice(value) | PropValue::LinetypeChoice(value) | PropValue::HatchPatternChoice(value) => value.clone(), @@ -3479,6 +3519,9 @@ fn merge_prop_value( (PropValue::PlainText(_), PropValue::PlainText(_)) => { PropValue::PlainText(VARIES_LABEL.into()) } + (PropValue::Hyperlink(_), PropValue::Hyperlink(_)) => { + PropValue::Hyperlink(VARIES_LABEL.into()) + } (PropValue::ReadOnly(_), PropValue::ReadOnly(_)) => { PropValue::ReadOnly(VARIES_LABEL.into()) } @@ -3552,7 +3595,8 @@ fn update_row_text( match &mut row.value { PropValue::ReadOnly(current) | PropValue::EditText(current) - | PropValue::PlainText(current) => *current = value, + | PropValue::PlainText(current) + | PropValue::Hyperlink(current) => *current = value, PropValue::ReadOnlyWithTooltip { value: current, .. } => *current = value, PropValue::Choice { selected, .. } => *selected = value, _ => {} @@ -3806,7 +3850,7 @@ fn format_unit_factor(factor: f64) -> String { } /// Convert INSUNITS (DXF group 70) to millimetres. -fn insunits_to_mm(code: i16) -> Option { +pub(super) fn insunits_to_mm(code: i16) -> Option { Some(match code { 1 => 25.4, // Inches 2 => 304.8, // Feet diff --git a/src/app/record_api.rs b/src/app/record_api.rs index 843c04802..e024bbc20 100644 --- a/src/app/record_api.rs +++ b/src/app/record_api.rs @@ -1136,7 +1136,7 @@ fn apply_updates( .pointer_mut(path) .ok_or_else(|| format!("property does not exist: {path}"))?; if let Some(expected) = update.get("expected") { - if target != expected { + if !json_values_match(target, expected) { return Err(format!("property changed before update: {path}")); } } @@ -1151,6 +1151,27 @@ fn apply_updates( Ok(paths) } +/// Structural equality in which numbers compare by value. serde_json keeps +/// `10` and `10.0` as different `Number`s, and a JavaScript client cannot send +/// `10.0` at all (`JSON.stringify(10.0) === "10"`), so a stored whole-number +/// float would otherwise never match its `expected` value. +fn json_values_match(actual: &Value, expected: &Value) -> bool { + match (actual, expected) { + (Value::Number(a), Value::Number(b)) if a.is_f64() || b.is_f64() => { + a.as_f64() == b.as_f64() + } + (Value::Array(a), Value::Array(b)) => { + a.len() == b.len() && a.iter().zip(b).all(|(a, b)| json_values_match(a, b)) + } + (Value::Object(a), Value::Object(b)) => { + a.len() == b.len() + && a.iter() + .all(|(key, a)| b.get(key).is_some_and(|b| json_values_match(a, b))) + } + _ => actual == expected, + } +} + fn patched_table_entry( table: &Table, request: &Value, @@ -1729,6 +1750,67 @@ mod tests { assert!(app.tabs[i].scene.document.summary_info.author.is_empty()); } + #[test] + fn set_properties_expected_matches_whole_number_floats_by_value() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let insert = Insert::new("A_CPT", Vector3::new(12.0, 34.0, 5.0)); + let entity = app.tabs[i].scene.add_entity(EntityType::Insert(insert)); + let handle = format!("{:X}", entity.value()); + + // `12` is how every JSON client sends the stored `12.0`. + let scalar = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"entities", + "handle":handle, + "updates":[{"path":"/insert_point/x","expected":12,"value":20.0}] + }), + "whole-number-scalar", + ); + assert_eq!(scalar["status"], "completed", "{scalar}"); + + let nested = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"entities", + "handle":handle, + "updates":[{ + "path":"/insert_point", + "expected":{"x":20,"y":34,"z":5}, + "value":{"x":1.5,"y":2.0,"z":3.0} + }] + }), + "whole-number-nested", + ); + assert_eq!(nested["status"], "completed", "{nested}"); + let EntityType::Insert(insert) = app.tabs[i].scene.document.get_entity(entity).unwrap() + else { + panic!("expected insert") + }; + assert_eq!(insert.insert_point.x, 1.5); + + let stale = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"entities", + "handle":handle, + "updates":[{"path":"/insert_point/x","expected":2,"value":9.0}] + }), + "stale-number", + ); + assert_ne!(stale["status"], "completed", "{stale}"); + let EntityType::Insert(insert) = app.tabs[i].scene.document.get_entity(entity).unwrap() + else { + panic!("expected insert") + }; + assert_eq!(insert.insert_point.x, 1.5); + } + #[test] fn record_schema_describes_absent_types_fields_enums_units_and_write_rules() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/settings.rs b/src/app/settings.rs index 9c2757542..938189196 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -132,6 +132,43 @@ impl CursorType { } } +/// What a right-click in the drawing area does (SHORTCUTMENU in commercial solutions / +/// "Right-click Customization"). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum RightClickMode { + /// The default of commercial solutions: a right-click always opens the shortcut menu (Enter / + /// Cancel / the command's options while a command runs; Repeat / edit + /// tools when idle). + #[default] + ShortcutMenu, + /// The "time-sensitive right-click" of commercial solutions: a quick click is Enter (or + /// repeats the last command when idle); holding the button longer than + /// `right_click_hold_ms` opens the shortcut menu. + TimeSensitive, + /// Original Open CAD Studio behaviour: while a command runs the first + /// right-click is Enter and a second consecutive one opens the menu; + /// when idle a right-click opens the menu. + EnterFirst, +} + +impl RightClickMode { + pub const ALL: [Self; 3] = [Self::ShortcutMenu, Self::TimeSensitive, Self::EnterFirst]; + + pub fn label(self) -> &'static str { + match self { + Self::ShortcutMenu => "Shortcut menu", + Self::TimeSensitive => "Time-sensitive (quick click = Enter)", + Self::EnterFirst => "Enter first, second click = menu", + } + } +} + +/// SHORTCUTMENUDURATION bounds: below 100 ms every click reads as a hold, +/// above 1000 ms the menu becomes unreachable in practice. +pub fn clamp_right_click_hold_ms(v: i32) -> i32 { + v.clamp(100, 1000) +} + /// Active pair of axes while isometric drafting is enabled. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum IsoPlane { @@ -193,7 +230,9 @@ const SNAP_ORDER: &[SnapType] = &[ ]; /// `$OSMODE` bit for each running object-snap mode. -/// `None` for OCS-only snaps (Grid, ObjectPick) that have no standard bit. +/// `None` for OCS-only snaps (Grid, ObjectPick) and the 3D solid snaps +/// (Vertex, EdgeMidpoint, FaceCenter, Knot, FacePerpendicular, NearestFace), +/// which live in the separate 3D set and have no standard bit. fn snap_bit(s: SnapType) -> Option { Some(match s { SnapType::Endpoint => 1, @@ -209,7 +248,14 @@ fn snap_bit(s: SnapType) -> Option { SnapType::ApparentIntersection => 2048, SnapType::Extension => 4096, SnapType::Parallel => 8192, - SnapType::Grid | SnapType::ObjectPick => return None, + SnapType::Grid + | SnapType::ObjectPick + | SnapType::Vertex + | SnapType::EdgeMidpoint + | SnapType::FaceCenter + | SnapType::Knot + | SnapType::FacePerpendicular + | SnapType::NearestFace => return None, }) } @@ -289,6 +335,7 @@ pub const DEFAULT_GRIP_OBJECT_LIMIT: i32 = 100; #[derive(Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct UserSettings { + pub spacemouse: crate::input::spacemouse::Preferences, pub dyn_input: bool, pub polar: bool, pub polar_increment_deg: f32, @@ -302,6 +349,11 @@ pub struct UserSettings { pub double_click_block_refedit: bool, /// When true, double-clicking a block with attributes opens ATTEDIT. pub double_click_block_attedit: bool, + /// What a right-click in the drawing area does (SHORTCUTMENU). + pub right_click_mode: RightClickMode, + /// Hold duration that turns a time-sensitive right-click into the menu, + /// in milliseconds (SHORTCUTMENUDURATION, 100..=1000). + pub right_click_hold_ms: i32, /// GRIPOBJLIMIT: past this many selected objects, no grips are drawn at /// all. 0 means no limit. The drawing header carries no slot for it. pub grip_object_limit: i32, @@ -325,7 +377,12 @@ pub struct UserSettings { pub crosshair_color: Option<[u8; 3]>, /// Model-space lineweight preview scale as a percentage. pub lineweight_display_scale: i32, - /// Isometric drafting changes the grid and crosshair to the active axis pair. + /// Isometric drafting changes the grid and crosshair to the active axis + /// pair. Session-only: a persisted On turned every drawing's crosshair + /// into the isoplane pair (one vertical, one diagonal line) on every + /// launch, with no visible control to switch it back off. Turn it on for + /// a session with ISODRAFT; ISOPLANE keeps its persisted value. + #[serde(skip)] pub isometric_drafting: bool, pub iso_plane: IsoPlane, /// SNAPANG in degrees, applied in the active UCS plane. @@ -338,6 +395,15 @@ pub struct UserSettings { /// prompt has already been shown. Set once the user answers (either way), /// so we never nag again on subsequent launches. pub default_assoc_prompted: bool, + /// Offer to download missing `.shx` fonts from the community repository + /// when a drawing opens (see `crate::io::font_repo`). + #[serde(default = "default_check_missing_fonts")] + pub check_missing_fonts: bool, + /// Custom font source base URL (empty = the OpenCADStudio community + /// repository). Each missing font is fetched as `{base}/{file_name}`, + /// so an intranet folder or a private GitHub raw folder both work. + #[serde(default)] + pub font_source_url: String, /// App version whose donation prompt has been displayed. pub donation_prompt_version: String, /// The graphics verdict (`GpuStatus::identity()`) whose warning popup the @@ -404,6 +470,9 @@ pub struct UserSettings { /// Constraint bar display bit mask: 1 after applying, 2 on selection. #[serde(default = "default_constraint_bar_display")] pub constraint_bar_display: i16, + /// Geometric constraint type bit mask (1..2048, combined; default all). + #[serde(default = "default_constraint_bar_mode")] + pub constraint_bar_mode: i16, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command). /// 0 disables autosave. pub savetime_min: i32, @@ -433,6 +502,27 @@ pub struct UserSettings { deserialize_with = "deserialize_commandline_fade_ms" )] pub commandline_fade_ms: i32, + /// SNAPUNIT X/Y spacing used by grid snap. 10 matches the Drafting + /// Settings dialog defaults; older configs without these keys fall + /// back via `default_snap_spacing`. + #[serde(default = "default_snap_spacing")] + pub snap_spacing_x: f32, + #[serde(default = "default_snap_spacing")] + pub snap_spacing_y: f32, + /// GRIDUNIT X/Y display spacing (grid resizing). Falls back to 10. + #[serde(default = "default_snap_spacing")] + pub grid_spacing_x: f32, + #[serde(default = "default_snap_spacing")] + pub grid_spacing_y: f32, + /// GRIDMAJOR: every Nth grid line is a brighter major line. + #[serde(default = "default_grid_major")] + pub grid_major_every: u32, + /// Adaptive grid scaling (default on). + #[serde(default = "default_true")] + pub grid_adaptive: bool, + /// Display grid beyond LIMITS (default on, matches dialog). + #[serde(default = "default_true")] + pub grid_beyond_limits: bool, /// Most-recently-inserted block names, most recent first, capped to 20. /// Used to rank INSERT suggestions without touching the drawing file. #[serde(default)] @@ -454,6 +544,33 @@ fn default_commandline_fade_ms() -> i32 { 3000 } +/// Default SNAPUNIT spacing shown in the Drafting Settings dialog. +fn default_snap_spacing() -> f32 { + 10.0 +} + +fn default_grid_major() -> u32 { + 5 +} + +fn default_true() -> bool { + true +} + +/// Clamp a major-line interval to the range the dialog accepts. +pub fn sanitize_grid_major(v: u32) -> u32 { + v.clamp(2, 100) +} + +/// Clamp a snap spacing to the positive range the dialog accepts. +pub fn sanitize_snap_spacing(v: f32) -> f32 { + if v.is_finite() && v > 0.0 && v <= 1e9 { + v + } else { + 10.0 + } +} + fn deserialize_commandline_fade_ms<'de, D>(de: D) -> Result where D: serde::Deserializer<'de>, @@ -470,6 +587,10 @@ fn default_dimension_continue_mode() -> i16 { 1 } +fn default_check_missing_fonts() -> bool { + true +} + fn default_show_constraint_values() -> bool { true } @@ -482,6 +603,10 @@ fn default_constraint_bar_display() -> i16 { 3 } +fn default_constraint_bar_mode() -> i16 { + 4095 +} + fn deserialize_clipromptlines<'de, D>(de: D) -> Result where D: serde::Deserializer<'de>, @@ -497,6 +622,7 @@ pub fn clamp_clipromptlines(v: i32) -> i32 { impl Default for UserSettings { fn default() -> Self { Self { + spacemouse: crate::input::spacemouse::Preferences::default(), dyn_input: true, polar: false, polar_increment_deg: 45.0, @@ -511,6 +637,8 @@ impl Default for UserSettings { selection_cycling: false, double_click_block_refedit: false, double_click_block_attedit: true, + right_click_mode: RightClickMode::ShortcutMenu, + right_click_hold_ms: 250, grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT, ncopy_bind: false, cursor_type: CursorType::Crosshair, @@ -521,6 +649,8 @@ impl Default for UserSettings { snap_angle_deg: 0.0, otrack: false, default_assoc_prompted: false, + check_missing_fonts: true, + font_source_url: String::new(), donation_prompt_version: String::new(), gpu_warning_silenced: String::new(), disabled_plugins: Vec::new(), @@ -528,8 +658,10 @@ impl Default for UserSettings { literal_spaces: false, command_history_height: crate::ui::command_line::HISTORY_HEIGHT_DEFAULT, // Snapper::default(): END|MID|CEN|NODE|QUAD|INT|NEA (575), master - // off (suppress bit 16384). - osmode: 575 | OSMODE_SUPPRESS, + // on. Object snap is a drafting aid users expect to be live from + // the first click; the suppress bit (16384) is left for the user + // to set via the status-bar pill or OSNAP. + osmode: 575, texteditmode: false, quick_dimension_snap_priority: 0, dimension_continue_mode: 1, @@ -542,6 +674,7 @@ impl Default for UserSettings { constraint_solve_mode: true, constraint_infer: false, constraint_bar_display: 3, + constraint_bar_mode: 4095, savetime_min: 10, default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), pick_add: true, @@ -550,6 +683,13 @@ impl Default for UserSettings { language: crate::i18n::Language::default(), cliprompt_lines: 3, commandline_fade_ms: 3000, + snap_spacing_x: 10.0, + snap_spacing_y: 10.0, + grid_spacing_x: 10.0, + grid_spacing_y: 10.0, + grid_major_every: 5, + grid_adaptive: true, + grid_beyond_limits: true, block_mru: Vec::new(), block_freq: std::collections::HashMap::new(), } @@ -560,6 +700,39 @@ impl Default for UserSettings { mod tests { use super::*; + /// Object snap ships live. The modes were always pre-selected; only the + /// master switch was off, so a new user got a configured snap set that + /// did nothing until they found the status-bar pill. + /// + /// This is asserted rather than checked by eye because the default is + /// only observable in a fresh profile: the app persists settings on + /// change, so an existing settings.json keeps whatever osmode it already + /// holds and never reveals what a new install would do. + #[test] + fn snapping_is_enabled_in_a_fresh_profile() { + let settings = UserSettings::default(); + assert_eq!( + settings.osmode & OSMODE_SUPPRESS, + 0, + "the suppress bit is set, so snapping ships off" + ); + + let (modes, master_on) = snaps_from_osmode(settings.osmode); + assert!(master_on, "decoding the default must report snapping on"); + for expected in [ + SnapType::Endpoint, + SnapType::Midpoint, + SnapType::Center, + SnapType::Intersection, + ] { + assert!(modes.contains(&expected), "{expected:?} is not in the default set"); + } + + // The Snapper and the persisted default have to agree, or the running + // state and the saved state disagree the moment anything is written. + assert_eq!(crate::snap::Snapper::default().snap_enabled, master_on); + } + #[test] fn osmode_encodes_bits_and_suppress() { // Endpoint(1) + Midpoint(2) + Intersection(32) = 35, master on. @@ -615,4 +788,27 @@ mod tests { assert!(!cfg.settings.pick_add, "the rest of the file must survive"); assert_eq!(cfg.settings.savetime_min, 42); } + + #[test] + fn isometric_drafting_never_persists_or_loads() { + // A persisted On used to bring the isoplane crosshair (vertical + + // diagonal arms) back on every launch for every drawing. The flag is + // session-only now: it is dropped when saving and ignored when a + // settings file still carries it. + let mut settings = UserSettings::default(); + settings.isometric_drafting = true; + let json = serde_json::to_string(&settings).expect("serialize settings"); + assert!( + !json.contains("isometric_drafting"), + "the saved settings must not carry the session-only flag: {json}" + ); + + let with_flag = r#"{ "isometric_drafting": true, "iso_plane": "Left" }"#; + let loaded: UserSettings = + serde_json::from_str(with_flag).expect("an old settings file must still parse"); + assert!( + !loaded.isometric_drafting, + "a persisted On must not turn isometric drafting back on" + ); + } } diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs index 3682bbbe3..dfb79bbe4 100644 --- a/src/app/shortcuts.rs +++ b/src/app/shortcuts.rs @@ -17,6 +17,7 @@ pub(super) fn default_bindings() -> BTreeMap { ("F1".to_string(), "HELP"), ("F2".to_string(), "COMMANDHISTORY"), ("F3".to_string(), "TOGGLEOSNAP"), + ("F4".to_string(), "TOGGLE3DOSNAP"), ("F5".to_string(), "ISOPLANE"), ("F7".to_string(), "GRID"), ("F8".to_string(), "ORTHO"), @@ -145,6 +146,8 @@ fn is_named_key(key: &str) -> bool { /// the command dispatcher — valid shortcut commands that never appear in the /// command registry. Used to validate the shortcut editor's command column. pub(super) const INPUT_ACTIONS: &[&str] = &[ + "SPACEMOUSEFIT", + "SPACEMOUSETOP", "FINALIZE", "COMMANDSPACE", "CANCEL", @@ -157,6 +160,7 @@ pub(super) const INPUT_ACTIONS: &[&str] = &[ "CARETRIGHT", "COMMANDHISTORY", "TOGGLEOSNAP", + "TOGGLE3DOSNAP", "OTRACK", "DYNINPUT", "SELECTALL", @@ -270,7 +274,36 @@ impl OpenCADStudio { let Some(action) = action.cloned() else { return Task::none(); }; - let message = match action.as_str() { + self.run_action(&action) + } + + /// Shared by keyboard bindings and exported device actions. In particular, + /// Undo retains its command-local behavior during PLINE and SPLINE. + pub(super) fn run_action(&mut self, action: &str) -> Task { + let message = match action { + "SPACEMOUSEFIT" => { + let i = self.active_tab; + self.clear_navigation_hover(i); + self.tabs[i].scene.remember_current_view(); + self.tabs[i].scene.fit_all(); + self.arm_hover_after_navigation(i); + return Task::none(); + } + "SPACEMOUSETOP" => Message::ViewCubeHome, + "SPACEMOUSE" => Message::SpaceMousePreferences, + "SPACEMOUSEPAUSE" => Message::SpaceMousePause, + "SPACEMOUSEPAN" => { + Message::SpaceMouseMode(crate::input::spacemouse::NavigationMode::PanOnly) + } + "SPACEMOUSEPANZOOM" => { + Message::SpaceMouseMode(crate::input::spacemouse::NavigationMode::PanZoom) + } + "SPACEMOUSEAUTO" => { + Message::SpaceMouseMode(crate::input::spacemouse::NavigationMode::Auto) + } + "SPACEMOUSE3D" => { + Message::SpaceMouseMode(crate::input::spacemouse::NavigationMode::Full3D) + } "FINALIZE" => Message::CommandFinalize, "COMMANDSPACE" => Message::CommandSpace, "CANCEL" => Message::CommandEscape, @@ -283,6 +316,7 @@ impl OpenCADStudio { "CARETRIGHT" => Message::MTextCaretMove(1), "COMMANDHISTORY" => Message::CommandHistoryToggle, "TOGGLEOSNAP" => Message::ToggleSnapEnabled, + "TOGGLE3DOSNAP" => Message::ToggleSnap3dEnabled, "OTRACK" => Message::ToggleOTrack, "DYNINPUT" => Message::ToggleDynInput, "SELECTALL" => Message::SelectAllShortcut, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 8a3c081c4..94502699b 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -573,6 +573,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .scene .apply_grip(handle, grip_id, apply); } + self.solve_grip_constraints(i, &grip); // Keep GripEdit synchronized so the normal commit // path records the exact final position. @@ -726,6 +727,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { crate::app::expr_eval::eval_to_string(&raw) }; self.command_line.input.clear(); + // Remember the token for the context menu's Recent Input + // list (prose steps excluded: a table cell or text body + // is not a reusable value). + let is_prose = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|c| c.input_kind().is_free_text()); + if !is_prose { + self.command_line.record_recent_input(&text); + } // Offer the typed text to the command's option handler // first (keywords like PLINE's A/L/C, a radius, …). If it @@ -931,6 +942,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if grip_dyn_locked { return self.update(Message::CommandSubmit); } + // Enter while a grip is hot places it where it is (or keeps + // it hot when it has not moved) — it must not fall through to + // "repeat the last command", which would discard the edit. + if self.tabs[i].active_grip.is_some() && self.tabs[i].active_cmd.is_none() { + return self.commit_active_grip_edit(); + } // Normal command Dynamic Input commit. if let Some(task) = self.try_dyn_commit() { @@ -1722,15 +1739,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .get_entity(popup.handle) .is_some_and(|entity| match (item.action, entity) { (GripMenuAction::ShowFit, acadrust::EntityType::Spline(spline)) => { - !spline.cv_frame_visible - && crate::entities::spline::uses_fit_method(spline) + crate::entities::spline::shows_fit_points(spline) } ( GripMenuAction::ShowControlVertices, acadrust::EntityType::Spline(spline), ) => { - spline.cv_frame_visible - || !crate::entities::spline::uses_fit_method(spline) + crate::entities::spline::shows_control_vertices(spline) } _ => false, }); @@ -3248,15 +3263,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].properties.active_field = None; let handles = self.property_target_handles(i); if !handles.is_empty() { - let driven_marker = match field { - "start_x" | "start_y" | "start_z" => Some(0), - "end_x" | "end_y" | "end_z" => Some(1), - "center_x" | "center_y" | "center_z" => Some(-3), + let driven_reference = match field { + "start_x" | "start_y" | "start_z" => Some(Some(0)), + "end_x" | "end_y" | "end_z" => Some(Some(1)), + "center_x" | "center_y" | "center_z" => Some(Some(-3)), + "radius" | "diameter" | "circumference" | "area" + | "major_r" | "minor_r" | "ratio" => Some(None), _ => None, }; - let retained_originals: Vec<_> = if self.constraint_solve_mode - && driven_marker.is_some() - { + let retain_size = self.constraint_solve_mode + && driven_reference.is_some_and(|marker| marker.is_some()); + let retained_originals: Vec<_> = if retain_size { handles .iter() .filter_map(|handle| { @@ -3587,13 +3604,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } - let driven_refs: Vec<_> = driven_marker + let driven_refs: Vec<_> = driven_reference .into_iter() .flat_map(|marker| { - handles.iter().copied().map(move |handle| { - crate::scene::parametric_constraints::ParametricRef::point( - handle, marker, - ) + handles.iter().copied().map(move |handle| match marker { + Some(marker) => { + crate::scene::parametric_constraints::ParametricRef::point( + handle, marker, + ) + } + None => { + crate::scene::parametric_constraints::ParametricRef::whole( + handle, + ) + } }) }) .collect(); @@ -3601,6 +3625,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { i, &handles, &driven_refs, + retain_size, &retained_originals, ); self.tabs[i].dirty = true; diff --git a/src/app/update/context_menu.rs b/src/app/update/context_menu.rs new file mode 100644 index 000000000..ea609b439 --- /dev/null +++ b/src/app/update/context_menu.rs @@ -0,0 +1,943 @@ +//! Viewport right-click context menu: gathering the menu's inputs from app +//! state, running a picked row, and keyboard navigation while it is open. +//! +//! The rows themselves are built by `ui::popup::context_menu` (a pure model); +//! this module is the glue between that model and the app's messages. + +use crate::app::{ArrowKey, ContextMenuNav, Message, OpenCADStudio}; +use crate::command::StepInput; +use crate::scene::pick::grip::GripEditMode; +use crate::ui::popup::context_menu::{ + build_context_menu, ContextMenu, GripMenuCmd, GripMenuContext, MenuAction, MenuContext, + SubmenuId, +}; +use acadrust::Handle; +use iced::Task; + +impl OpenCADStudio { + /// True while the viewport context menu is open on the active tab. + pub(in crate::app) fn context_menu_open(&self) -> bool { + self.tabs + .get(self.active_tab) + .is_some_and(|tab| tab.scene.selection.borrow().context_menu.is_some()) + } + + /// Everything the menu builder needs, read from the active tab. Called by + /// the view (to render) and by the keyboard handler (to resolve picks), so + /// both always see the same rows. + pub(in crate::app) fn context_menu_context(&self) -> MenuContext { + let tab = &self.tabs[self.active_tab]; + if let Some(grip) = tab.active_grip.as_ref() { + return MenuContext::Grip(GripMenuContext { + mode: grip.mode, + copy_on: tab.grip_copy, + moved: grip.last_world != grip.origin_world, + }); + } + if let Some(cmd) = tab.active_cmd.as_ref() { + // Same guard as typed MTP/M2P and SnapOverrideMtp: the step must + // take a point pick (possibly alongside keywords), not an entity. + let has_point_step = (!cmd.input_kind().wants_text() + || cmd.point_step_accepts_keywords()) + && !cmd.needs_entity_pick(); + return MenuContext::Command { + options: cmd.options(), + has_point_step, + recent_inputs: self.command_line.recent_inputs.clone(), + }; + } + MenuContext::Idle { + has_selection: !tab.scene.selected.is_empty(), + selected_constraint: tab.scene.selected_constraint, + isolation_active: tab.scene.is_isolation_active(), + recent_cmds: self + .command_line + .recent_commands + .iter() + .rev() + .take(crate::ui::popup::context_menu::RECENT_LIMIT) + .cloned() + .collect(), + clipboard_nonempty: !self.clipboard.is_empty(), + undo_label: tab.history.undo_stack.last().map(|s| s.label().to_string()), + redo_label: tab.history.redo_stack.last().map(|s| s.label().to_string()), + props_open: self.show_properties, + } + } + + /// The menu as currently shown (rows + expanded submenu). + pub(in crate::app) fn current_context_menu(&self) -> ContextMenu { + let open = self.tabs[self.active_tab] + .scene + .selection + .borrow() + .context_menu_ui + .open_submenu; + build_context_menu(&self.context_menu_context(), open) + } + + /// Close the menu and give the keyboard back to the command line. + pub(in crate::app) fn close_context_menu(&mut self) -> Task { + let i = self.active_tab; + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + if sel.context_menu.is_none() { + return Task::none(); + } + sel.close_context_menu(); + drop(sel); + self.focus_cmd_input() + } + + pub(in crate::app) fn on_context_menu_submenu_toggle(&mut self, id: SubmenuId) -> Task { + let i = self.active_tab; + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + let ui = &mut sel.context_menu_ui; + ui.open_submenu = if ui.open_submenu == Some(id) { None } else { Some(id) }; + drop(sel); + // Keep the keyboard highlight on the header that was toggled. + let menu = self.current_context_menu(); + let header = menu + .selectable() + .iter() + .position(|s| s.header == Some(id)); + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + if sel.context_menu_ui.highlighted.is_some() { + sel.context_menu_ui.highlighted = header; + } + Task::none() + } + + /// Run a picked row. The menu closes first so the action sees the same + /// state a typed keyword / shortcut would (no menu open, command line + /// focused), then the action is routed through the existing message for + /// that behaviour. + pub(in crate::app) fn on_context_menu_pick(&mut self, action: MenuAction) -> Task { + if let MenuAction::ToggleSubmenu(id) = action { + return self.on_context_menu_submenu_toggle(id); + } + let i = self.active_tab; + { + let mut sel = self.tabs[i].scene.selection.borrow_mut(); + sel.close_context_menu(); + // A pick counts as "another interaction" for the Enter-first + // right-click cycle: the next right-click acts as Enter again. + sel.right_click_entered = false; + } + let focus = self.focus_cmd_input(); + let task = match action { + MenuAction::Enter if self.tabs[i].active_grip.is_some() => { + self.commit_active_grip_edit() + } + MenuAction::Enter => self.update(Message::CommandFinalize), + MenuAction::Cancel => self.update(Message::CommandEscape), + MenuAction::Option(kw) => self.update(Message::CommandOptionPick(kw)), + MenuAction::FeedInput(token) => { + if self.tabs[i].active_cmd.is_some() { + self.feed_active_cmd(&token) + } else { + self.run_command_line(&token) + } + } + MenuAction::Command(cmd) => self.update(Message::Command(cmd)), + MenuAction::Mtp => self.update(Message::SnapOverrideMtp), + MenuAction::SnapOverride(t) => self.update(Message::SnapOverridePick(t)), + MenuAction::SnapOverrideNone => self.update(Message::SnapOverrideNone), + MenuAction::DeleteSelected => self.update(Message::DeleteSelected), + MenuAction::SelectSimilar => self.update(Message::SelectSimilar), + MenuAction::InvertSelection => self.update(Message::InvertSelection), + MenuAction::DeselectAll => { + self.tabs[i].scene.deselect_all(); + self.refresh_properties(); + Task::none() + } + MenuAction::QuickSelect => self.update(Message::QSelectOpen), + MenuAction::Properties => self.update(Message::ToggleProperties), + MenuAction::Options => self.update(Message::OptionsOpen), + MenuAction::Undo => self.update(Message::Undo), + MenuAction::Redo => self.update(Message::Redo), + MenuAction::DrawOrderPickRef(above) => self.update(Message::DrawOrderPickRef(above)), + MenuAction::ConstraintDelete(id) => self.update(Message::PropConstraintDelete(id)), + MenuAction::Grip(cmd) => self.on_grip_context_pick(cmd), + MenuAction::ToggleSubmenu(_) => Task::none(), + }; + Task::batch(vec![focus, task]) + } + + /// Keyboard navigation while the menu is open. + pub(in crate::app) fn on_context_menu_navigate(&mut self, nav: ContextMenuNav) -> Task { + if !self.context_menu_open() { + return Task::none(); + } + let i = self.active_tab; + let menu = self.current_context_menu(); + let rows = menu.selectable(); + if rows.is_empty() { + return Task::none(); + } + let current = self.tabs[i].scene.selection.borrow().context_menu_ui.highlighted; + let next_enabled = |from: usize, step: isize| -> usize { + let n = rows.len() as isize; + let mut idx = from as isize; + for _ in 0..n { + idx = (idx + step).rem_euclid(n); + if rows[idx as usize].enabled { + return idx as usize; + } + } + from + }; + match nav { + ContextMenuNav::Down => { + let idx = match current { + // First press lands on the default row itself. + None => menu.default_index(), + Some(cur) => next_enabled(cur, 1), + }; + self.tabs[i].scene.selection.borrow_mut().context_menu_ui.highlighted = Some(idx); + Task::none() + } + ContextMenuNav::Up => { + let idx = match current { + None => menu.default_index(), + Some(cur) => next_enabled(cur, -1), + }; + self.tabs[i].scene.selection.borrow_mut().context_menu_ui.highlighted = Some(idx); + Task::none() + } + ContextMenuNav::Enter => { + let idx = current.unwrap_or_else(|| menu.default_index()); + let row = &rows[idx]; + if !row.enabled { + return Task::none(); + } + self.on_context_menu_pick(row.action.clone()) + } + ContextMenuNav::Mnemonic(ch) => match menu.find_mnemonic(ch, current) { + Some(idx) => { + // A letter shared by several rows (PLINE's CEnter / CLose) + // cycles the highlight; a unique one picks at once. + let unique = menu.find_mnemonic(ch, Some(idx)) == Some(idx); + if unique { + self.on_context_menu_pick(rows[idx].action.clone()) + } else { + self.tabs[i].scene.selection.borrow_mut().context_menu_ui.highlighted = + Some(idx); + Task::none() + } + } + None => Task::none(), + }, + } + } + + /// Route keyboard-derived messages to the open menu. Returns `Some(task)` + /// when the message was consumed; `None` lets it fall through to the + /// normal handlers (after closing the menu when the key is not a menu key, + /// so typing a command name or a coordinate just works — the menu gets + /// out of the way exactly as it does in commercial solutions). + pub(in crate::app) fn intercept_context_menu_key(&mut self, msg: &Message) -> Option> { + match msg { + Message::ArrowKeyPressed { direction, .. } + | Message::CommandLineArrowProbe { direction, .. } => match direction { + ArrowKey::Up => Some(self.on_context_menu_navigate(ContextMenuNav::Up)), + ArrowKey::Down => Some(self.on_context_menu_navigate(ContextMenuNav::Down)), + ArrowKey::Left | ArrowKey::Right => { + // On a submenu header, Right expands and Left collapses. + let i = self.active_tab; + let ui = self.tabs[i].scene.selection.borrow().context_menu_ui.clone(); + let menu = self.current_context_menu(); + let rows = menu.selectable(); + let header = ui.highlighted.and_then(|c| rows.get(c)).and_then(|r| r.header); + let want_open = matches!(direction, ArrowKey::Right); + match header { + Some(id) if (ui.open_submenu == Some(id)) != want_open => { + Some(self.on_context_menu_submenu_toggle(id)) + } + _ => Some(Task::none()), + } + } + }, + Message::DynTabNext => Some(self.on_context_menu_navigate(ContextMenuNav::Down)), + Message::CommandFinalize | Message::CommandSpace => { + if self.command_line.input.trim().is_empty() { + Some(self.on_context_menu_navigate(ContextMenuNav::Enter)) + } else { + // Typed text pending: Enter runs it, the menu just closes. + let close = self.close_context_menu(); + let run = self.update(Message::CommandFinalize); + Some(Task::batch(vec![close, run])) + } + } + Message::CommandSubmit => { + if self.command_line.input.trim().is_empty() { + Some(self.on_context_menu_navigate(ContextMenuNav::Enter)) + } else { + let _ = self.close_context_menu(); + None + } + } + Message::CommandAppendChar(s) => self.context_menu_typed(s.chars().next()), + Message::CommandInput(buf) => { + // The focused command-line field delivers the whole buffer; + // only a single appended character is a candidate mnemonic. + let appended = buf + .strip_prefix(self.command_line.input.as_str()) + .filter(|rest| rest.chars().count() == 1) + .and_then(|rest| rest.chars().next()); + match appended { + Some(ch) => self.context_menu_typed(Some(ch)), + None => { + let _ = self.close_context_menu(); + None + } + } + } + _ => None, + } + } + + /// A printable key while the menu is open: a mnemonic picks its row; + /// anything else closes the menu and is typed into the command line. + fn context_menu_typed(&mut self, ch: Option) -> Option> { + let ch = ch?; + let menu = self.current_context_menu(); + if ch.is_ascii_alphanumeric() && menu.find_mnemonic(ch, None).is_some() { + return Some(self.on_context_menu_navigate(ContextMenuNav::Mnemonic(ch))); + } + let _ = self.close_context_menu(); + None + } + + /// Grip-mode shortcut menu (the grip menu of commercial solutions): the grip stays hot + /// for Stretch / Base Point / Copy / Undo; Move / Rotate / Scale / Mirror + /// hand the selection to the matching command with the grip as its base + /// point; Exit drops the edit. + pub(in crate::app) fn on_grip_context_pick(&mut self, cmd: GripMenuCmd) -> Task { + let i = self.active_tab; + let Some(grip) = self.tabs[i].active_grip.clone() else { + return Task::none(); + }; + match cmd { + GripMenuCmd::Exit => { + self.cancel_active_grip_edit(); + self.tabs[i].grip_copy = false; + self.tabs[i].grip_base_pending = false; + Task::none() + } + GripMenuCmd::Stretch => { + if let Some(active) = self.tabs[i].active_grip.as_mut() { + active.mode = GripEditMode::Stretch; + active.axis = None; + } + Task::none() + } + GripMenuCmd::Move | GripMenuCmd::Rotate | GripMenuCmd::Scale | GripMenuCmd::Mirror => { + let base = grip.origin_world; + let handles: Vec = grip.targets.iter().map(|t| t.handle).collect(); + self.cancel_active_grip_edit(); + self.tabs[i].grip_copy = false; + self.tabs[i].grip_base_pending = false; + // The command edits the current selection; make sure the + // gripped objects are in it. + if self.tabs[i].scene.selected.is_empty() { + self.tabs[i].scene.select_entities(&handles); + } + let name = match cmd { + GripMenuCmd::Move => "MOVE", + GripMenuCmd::Rotate => "ROTATE", + GripMenuCmd::Scale => "SCALE", + _ => "MIRROR", + }; + let start = self.dispatch_command(name); + if self.tabs[i].active_cmd.is_none() { + return start; + } + // The grip is the base point, so the command opens directly + // at its second prompt. + self.last_point = Some(base); + let fed = self.feed_command(StepInput::Point(base)); + Task::batch(vec![start, fed]) + } + GripMenuCmd::BasePoint => { + self.tabs[i].grip_base_pending = true; + Task::none() + } + GripMenuCmd::CopyToggle => { + self.tabs[i].grip_copy = !self.tabs[i].grip_copy; + Task::none() + } + GripMenuCmd::Undo => { + // Put the shapes back where the gesture started and keep the + // grip hot there (the Copy toggle survives). + let copy = self.tabs[i].grip_copy; + self.cancel_active_grip_edit(); + self.tabs[i].grip_copy = copy; + let mut again = grip; + let delta = again.last_world - again.origin_world; + again.last_world = again.origin_world; + for target in &mut again.targets { + target.last_world -= delta; + } + self.tabs[i].active_grip = Some(again); + Task::none() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::settings::RightClickMode; + use crate::command::{CadCommand, StepInput}; + use crate::ui::popup::context_menu::MenuAction; + use glam::DVec3; + use iced::Point; + + /// `update` recursion (right-click → CommandFinalize → CommandSubmit → + /// apply) is deeper than the default test-thread stack; production + /// threads are sized for it (see `.cargo/config.toml`), so tests get an + /// equivalent stack here. + fn with_stack(f: impl FnOnce() + Send + 'static) { + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(f) + .expect("spawn test thread") + .join() + .unwrap_or_else(|e| std::panic::resume_unwind(e)); + } + + fn app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.tabs[0].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let _ = app.update(Message::ViewportMove(Point::new(200.0, 150.0))); + app + } + + fn start(app: &mut OpenCADStudio, cmd: &str, points: &[(f64, f64)]) { + let _ = app.dispatch_command(cmd); + for &(x, y) in points { + let _ = app.feed_command(StepInput::Point(DVec3::new(x, y, 0.0))); + } + } + + fn right_click(app: &mut OpenCADStudio) { + let _ = app.update(Message::ViewportRightPress); + let _ = app.update(Message::ViewportRightRelease); + } + + fn menu_open(app: &OpenCADStudio) -> bool { + app.tabs[0].scene.selection.borrow().context_menu.is_some() + } + + fn active(app: &OpenCADStudio) -> Option<&'static str> { + app.tabs[0].active_cmd.as_ref().map(|c| c.name()) + } + + fn menu_actions(app: &OpenCADStudio) -> Vec { + app.current_context_menu() + .selectable() + .into_iter() + .map(|s| s.action) + .collect() + } + + #[test] + fn shortcut_menu_mode_single_rmb_opens_menu_during_command() { + with_stack(|| { + let mut app = app(); + assert_eq!(app.right_click_mode, RightClickMode::ShortcutMenu); + start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]); + right_click(&mut app); + assert!(menu_open(&app)); + assert_eq!(active(&app), Some("LINE")); + let acts = menu_actions(&app); + assert_eq!(acts[0], MenuAction::Enter); + assert_eq!(acts[1], MenuAction::Cancel); + assert!(acts.contains(&MenuAction::Option("C".into()))); + assert!(acts.contains(&MenuAction::Option("U".into()))); + assert!(acts.contains(&MenuAction::ToggleSubmenu(SubmenuId::SnapOverrides))); + assert!(acts.contains(&MenuAction::Command("'PAN".into()))); + }); + } + + #[test] + fn enter_first_mode_first_rmb_is_enter_second_opens_menu() { + with_stack(|| { + let mut app = app(); + app.right_click_mode = RightClickMode::EnterFirst; + start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]); + right_click(&mut app); + // Enter ends LINE. + assert!(!menu_open(&app)); + assert_eq!(active(&app), None); + // Idle: the next right-click opens the menu. + right_click(&mut app); + assert!(menu_open(&app)); + let acts = menu_actions(&app); + assert_eq!(acts[0], MenuAction::Command("LINE".into()), "Repeat LINE first"); + }); + } + + #[test] + fn time_sensitive_quick_click_is_enter_and_hold_opens_menu() { + with_stack(|| { + let mut app = app(); + app.right_click_mode = RightClickMode::TimeSensitive; + app.right_click_hold_ms = 250; + start(&mut app, "PLINE", &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]); + // Held: opens the menu, PLINE keeps running. + let _ = app.update(Message::ViewportRightPress); + { + let mut sel = app.tabs[0].scene.selection.borrow_mut(); + sel.right_press_time = iced::time::Instant::now() + .checked_sub(std::time::Duration::from_millis(400)); + } + let _ = app.update(Message::ViewportRightRelease); + assert!(menu_open(&app)); + assert_eq!(active(&app), Some("PLINE")); + // Dismiss, then a quick click finishes the polyline. + let _ = app.update(Message::CommandEscape); + assert!(!menu_open(&app)); + assert_eq!(active(&app), Some("PLINE")); + right_click(&mut app); + assert!(!menu_open(&app)); + assert_eq!(active(&app), None); + }); + } + + #[test] + fn pending_text_runs_as_enter_in_all_modes() { + with_stack(|| { + for mode in RightClickMode::ALL { + let mut app = app(); + app.right_click_mode = mode; + start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]); + app.command_line.input = "C".into(); + right_click(&mut app); + assert!(!menu_open(&app), "{mode:?}"); + assert_eq!(active(&app), None, "{mode:?}: Close should end LINE"); + let lines = app.tabs[0] + .scene + .document + .entities() + .filter(|e| matches!(e, acadrust::EntityType::Line(_))) + .count(); + assert_eq!(lines, 3, "{mode:?}"); + } + }); + } + + #[test] + fn context_menu_pick_option_feeds_keyword() { + with_stack(|| { + let mut app = app(); + start(&mut app, "PLINE", &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]); + right_click(&mut app); + assert!(menu_open(&app)); + let _ = app.update(Message::ContextMenuPick(MenuAction::Option("C".into()))); + assert!(!menu_open(&app)); + assert_eq!(active(&app), None); + let closed = app.tabs[0].scene.document.entities().any(|e| { + matches!(e, acadrust::EntityType::LwPolyline(p) if p.is_closed && p.vertices.len() == 3) + }); + assert!(closed, "PLINE should have been closed by the menu pick"); + }); + } + + #[test] + fn context_menu_pick_closes_menu_and_resets_enter_cycle() { + with_stack(|| { + let mut app = app(); + app.right_click_mode = RightClickMode::EnterFirst; + start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]); + right_click(&mut app); // Enter → LINE ends + right_click(&mut app); // idle → menu + assert!(menu_open(&app)); + let _ = app.update(Message::ContextMenuPick(MenuAction::Command("LINE".into()))); + assert!(!menu_open(&app)); + assert_eq!(active(&app), Some("LINE")); + assert!(!app.tabs[0].scene.selection.borrow().right_click_entered); + }); + } + + #[test] + fn menu_enter_with_no_highlight_picks_default() { + with_stack(|| { + let mut app = app(); + start(&mut app, "PLINE", &[(0.0, 0.0), (10.0, 0.0)]); + right_click(&mut app); + let _ = app.update(Message::CommandFinalize); + assert!(!menu_open(&app)); + assert_eq!(active(&app), None, "default row (Done) finishes PLINE"); + }); + } + + #[test] + fn menu_arrow_down_then_enter_picks_cancel() { + with_stack(|| { + let mut app = app(); + start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]); + right_click(&mut app); + let _ = app.update(Message::ContextMenuNavigate(ContextMenuNav::Down)); // Enter row + assert_eq!( + app.tabs[0].scene.selection.borrow().context_menu_ui.highlighted, + Some(0) + ); + let _ = app.update(Message::ContextMenuNavigate(ContextMenuNav::Down)); // Cancel + let _ = app.update(Message::ContextMenuNavigate(ContextMenuNav::Enter)); + assert!(!menu_open(&app)); + assert_eq!(active(&app), None); + }); + } + + #[test] + fn menu_mnemonic_c_closes_pline() { + with_stack(|| { + let mut app = app(); + start(&mut app, "PLINE", &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]); + right_click(&mut app); + let _ = app.update(Message::CommandAppendChar("c".into())); + assert!(!menu_open(&app)); + assert_eq!(active(&app), None); + let closed = app.tabs[0].scene.document.entities().any( + |e| matches!(e, acadrust::EntityType::LwPolyline(p) if p.is_closed), + ); + assert!(closed); + }); + } + + #[test] + fn menu_unmatched_char_closes_menu_and_types() { + with_stack(|| { + let mut app = app(); + app.dyn_input = false; + start(&mut app, "LINE", &[(0.0, 0.0)]); + right_click(&mut app); + assert!(menu_open(&app)); + let _ = app.update(Message::CommandAppendChar("7".into())); + assert!(!menu_open(&app)); + assert_eq!(active(&app), Some("LINE")); + assert!(app.command_line.input.contains('7'), "{:?}", app.command_line.input); + }); + } + + #[test] + fn recent_input_pick_refeeds_token() { + with_stack(|| { + let mut app = app(); + start(&mut app, "LINE", &[(0.0, 0.0)]); + let _ = app.feed_active_cmd("10,10"); + assert_eq!(app.command_line.recent_inputs, vec!["10,10".to_string()]); + let _ = app.feed_active_cmd("20,0"); + right_click(&mut app); + let _ = app.update(Message::ContextMenuSubmenuToggle(SubmenuId::RecentInput)); + let acts = menu_actions(&app); + assert!(acts.contains(&MenuAction::FeedInput("10,10".into()))); + let _ = app.update(Message::ContextMenuPick(MenuAction::FeedInput("10,10".into()))); + assert!(!menu_open(&app)); + let lines = app.tabs[0] + .scene + .document + .entities() + .filter(|e| matches!(e, acadrust::EntityType::Line(_))) + .count(); + assert_eq!(lines, 3); + }); + } + + #[test] + fn idle_selection_menu_erase_deletes_selection() { + with_stack(|| { + let mut app = app(); + start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]); + let _ = app.update(Message::CommandEscape); + let handle = app.tabs[0] + .scene + .document + .entities() + .find_map(|e| match e { + acadrust::EntityType::Line(l) => Some(l.common.handle), + _ => None, + }) + .expect("a line"); + app.tabs[0].scene.select_entities(&[handle]); + right_click(&mut app); + let acts = menu_actions(&app); + assert!(acts.contains(&MenuAction::DeleteSelected)); + assert!(acts.contains(&MenuAction::Command("ROTATE".into()))); + let _ = app.update(Message::ContextMenuPick(MenuAction::DeleteSelected)); + assert!(!menu_open(&app)); + let lines = app.tabs[0] + .scene + .document + .entities() + .filter(|e| matches!(e, acadrust::EntityType::Line(_))) + .count(); + assert_eq!(lines, 0); + }); + } + + // ── Grip menu ───────────────────────────────────────────────────── + + /// A drawing with one line, selected, its start grip hot at the origin. + fn grip_app() -> (OpenCADStudio, Handle) { + use acadrust::{entities::Line, types::Vector3, EntityType}; + let mut app = app(); + app.snapper.snap_enabled = false; + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + let handle = app.tabs[0].scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + ))); + app.tabs[0].scene.selected.insert(handle); + app.refresh_selected_grips(); + app.tabs[0].active_grip = Some(crate::scene::pick::grip::GripEdit::single( + handle, + 0, + false, + DVec3::ZERO, + )); + (app, handle) + } + + fn move_cursor_to(app: &mut OpenCADStudio, world: DVec3) { + let cursor = app.tabs[0] + .scene + .camera + .borrow() + .project(world, iced::Rectangle::with_size(iced::Size::new(800.0, 600.0))) + .unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + } + + fn line_start(app: &OpenCADStudio, handle: Handle) -> (f64, f64) { + match app.tabs[0].scene.document.get_entity(handle) { + Some(acadrust::EntityType::Line(l)) => (l.start.x, l.start.y), + other => panic!("{other:?}"), + } + } + + #[test] + fn rmb_on_hot_grip_opens_grip_menu() { + with_stack(|| { + let (mut app, _) = grip_app(); + right_click(&mut app); + assert!(menu_open(&app)); + let acts = menu_actions(&app); + assert_eq!(acts[0], MenuAction::Enter); + assert!(acts.contains(&MenuAction::Grip(GripMenuCmd::Move))); + assert!(acts.contains(&MenuAction::Grip(GripMenuCmd::Exit))); + assert!(app.tabs[0].active_grip.is_some(), "the grip stays hot under the menu"); + }); + } + + #[test] + fn grip_menu_move_dispatches_move_with_grip_base() { + with_stack(|| { + let (mut app, _) = grip_app(); + right_click(&mut app); + let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::Move))); + assert!(!menu_open(&app)); + assert!(app.tabs[0].active_grip.is_none()); + assert_eq!(active(&app), Some("MOVE")); + let prompt = app.tabs[0].active_cmd.as_ref().map(|c| c.prompt()).unwrap_or_default(); + let base_prompt = crate::tr!("command-move", "base", count = 1i64); + assert_ne!(prompt, base_prompt, "base already consumed"); + assert_eq!(app.last_point, Some(DVec3::ZERO)); + }); + } + + #[test] + fn grip_menu_exit_restores_original_geometry() { + with_stack(|| { + let (mut app, handle) = grip_app(); + move_cursor_to(&mut app, DVec3::new(3.0, 4.0, 0.0)); + let (x, y) = line_start(&app, handle); + assert!((x - 3.0).abs() < 1e-3 && (y - 4.0).abs() < 1e-3, "drag applied"); + right_click(&mut app); + let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::Exit))); + assert!(app.tabs[0].active_grip.is_none()); + let (x, y) = line_start(&app, handle); + assert!(x.abs() < 1e-9 && y.abs() < 1e-9, "Exit restores the original"); + }); + } + + #[test] + fn grip_menu_undo_snaps_back_but_stays_hot() { + with_stack(|| { + let (mut app, handle) = grip_app(); + move_cursor_to(&mut app, DVec3::new(3.0, 4.0, 0.0)); + right_click(&mut app); + let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::Undo))); + let (x, y) = line_start(&app, handle); + assert!(x.abs() < 1e-9 && y.abs() < 1e-9); + let grip = app.tabs[0].active_grip.as_ref().expect("grip still hot"); + assert_eq!(grip.last_world, grip.origin_world); + }); + } + + #[test] + fn grip_menu_copy_leaves_original_and_adds_copy() { + with_stack(|| { + let (mut app, handle) = grip_app(); + right_click(&mut app); + let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::CopyToggle))); + assert!(app.tabs[0].grip_copy); + move_cursor_to(&mut app, DVec3::new(3.0, 4.0, 0.0)); + let _ = app.update(Message::CommandFinalize); + let lines: Vec<(f64, f64)> = app.tabs[0] + .scene + .document + .entities() + .filter_map(|e| match e { + acadrust::EntityType::Line(l) => Some((l.start.x, l.start.y)), + _ => None, + }) + .collect(); + assert_eq!(lines.len(), 2, "{lines:?}"); + let (x, y) = line_start(&app, handle); + assert!(x.abs() < 1e-9 && y.abs() < 1e-9, "original untouched"); + assert!(lines.iter().any(|&(x, y)| (x - 3.0).abs() < 1e-3 && (y - 4.0).abs() < 1e-3)); + assert!(app.tabs[0].active_grip.is_some(), "grip re-armed for the next copy"); + // One undo step removes the copy again. + app.undo_steps(1); + let count = app.tabs[0] + .scene + .document + .entities() + .filter(|e| matches!(e, acadrust::EntityType::Line(_))) + .count(); + assert_eq!(count, 1); + }); + } + + #[test] + fn grip_menu_base_point_rebases_without_placing() { + with_stack(|| { + let (mut app, handle) = grip_app(); + right_click(&mut app); + let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::BasePoint))); + assert!(app.tabs[0].grip_base_pending); + move_cursor_to(&mut app, DVec3::new(1.0, 1.0, 0.0)); + let _ = app.update(Message::ViewportLeftPress); + let _ = app.update(Message::ViewportLeftRelease); + let grip = app.tabs[0].active_grip.as_ref().expect("grip still hot"); + assert_eq!(grip.origin_world, grip.last_world); + assert!(!app.tabs[0].grip_base_pending); + let (x, y) = line_start(&app, handle); + assert!((x - 1.0).abs() < 1e-3 && (y - 1.0).abs() < 1e-3); + }); + } +} + +#[cfg(test)] +mod transparent_tests { + use super::*; + use crate::command::StepInput; + use glam::DVec3; + + fn with_stack(f: impl FnOnce() + Send + 'static) { + std::thread::Builder::new() + .stack_size(32 * 1024 * 1024) + .spawn(f) + .expect("spawn test thread") + .join() + .unwrap_or_else(|e| std::panic::resume_unwind(e)); + } + + fn line_app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.tabs[0].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let _ = app.dispatch_command("LINE"); + let _ = app.feed_command(StepInput::Point(DVec3::ZERO)); + app + } + + fn active(app: &OpenCADStudio) -> Option<&'static str> { + app.tabs[0].active_cmd.as_ref().map(|c| c.name()) + } + + #[test] + fn transparent_pan_keeps_the_command_running() { + with_stack(|| { + let mut app = line_app(); + let _ = app.update(Message::ContextMenuPick(MenuAction::Command("'PAN".into()))); + assert!(app.tabs[0].pan_mode); + assert_eq!(active(&app), Some("LINE")); + // Esc leaves pan mode; the line is still being drawn. + let _ = app.update(Message::CommandEscape); + assert!(!app.tabs[0].pan_mode); + assert_eq!(active(&app), Some("LINE")); + }); + } + + #[test] + fn transparent_zoom_window_resumes_the_command() { + with_stack(|| { + let mut app = line_app(); + let _ = app.dispatch_command("'ZOOM W"); + assert_eq!(active(&app), Some("ZOOM WINDOW")); + assert!(app.tabs[0].transparent_resume); + let _ = app.feed_command(StepInput::Point(DVec3::new(-5.0, -5.0, 0.0))); + let _ = app.feed_command(StepInput::Point(DVec3::new(5.0, 5.0, 0.0))); + assert_eq!(active(&app), Some("LINE"), "LINE resumes after the zoom"); + assert!(!app.tabs[0].transparent_resume); + assert!(app.tabs[0].suspended_cmd.is_none()); + // The line still has its first point: the next pick draws a segment. + let _ = app.feed_command(StepInput::Point(DVec3::new(10.0, 0.0, 0.0))); + let lines = app.tabs[0] + .scene + .document + .entities() + .filter(|e| matches!(e, acadrust::EntityType::Line(_))) + .count(); + assert_eq!(lines, 1); + }); + } + + #[test] + fn zoom_prompt_handoff_and_escape_both_resume() { + with_stack(|| { + let mut app = line_app(); + let _ = app.dispatch_command("'ZOOM"); + let _ = app.feed_active_cmd("E"); // → ZOOM EXTENTS, one-shot + assert_eq!(active(&app), Some("LINE")); + let _ = app.dispatch_command("'ZOOM"); + assert_eq!(active(&app), Some("ZOOM WINDOW")); + let _ = app.update(Message::CommandEscape); + assert_eq!(active(&app), Some("LINE")); + assert!(!app.tabs[0].transparent_resume); + }); + } + + #[test] + fn new_command_drops_a_parked_one() { + with_stack(|| { + let mut app = line_app(); + let _ = app.dispatch_command("'ZOOM"); + let _ = app.dispatch_command("CIRCLE"); + assert_eq!(active(&app), Some("CIRCLE")); + assert!(app.tabs[0].suspended_cmd.is_none()); + let _ = app.update(Message::CommandEscape); + assert_eq!(active(&app), None, "no stale LINE comes back"); + }); + } + + #[test] + fn snap_override_none_is_one_shot() { + with_stack(|| { + let mut app = line_app(); + app.snapper.snap_enabled = true; + let _ = app.update(Message::ContextMenuPick(MenuAction::SnapOverrideNone)); + assert!(!app.snapper.snap_enabled); + app.snapper.clear_override(); + assert!(app.snapper.snap_enabled); + }); + } +} diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index fa0134635..7c20cb776 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -599,6 +599,12 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.show_block_palette = false; self.block_palette.placing = None; } + PanelId::ExternalReferences => { + self.show_external_references = false; + } + PanelId::Browser => { + self.show_browser = false; + } PanelId::Properties => { self.show_properties = false; self.ribbon.set_properties(false); @@ -635,6 +641,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven iced::Task::none() } DockMsg::DragMove(point) => { + // NOTE: xref column drags intentionally do NOT ride this + // path: its points live in workspace space while the header + // tracker reports header-local points, and mixing the two + // produced a one-time jump plus a stuck drag. Column moves + // arrive via Message::XrefColMove only. if self.dock_dragging.is_some() { let avail = self.tabs[self.active_tab].scene.selection.borrow().vp_size.1; let side = if point.x < self.win_size.0 * 0.5 { @@ -676,6 +687,9 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.dock_resizing = None; self.dock_drag_last = None; self.dock_drag_target = None; + self.xref_col_drag = None; + self.xref_col_last = None; + self.xref_split_drag = false; iced::Task::none() } } @@ -692,6 +706,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven match id { PanelId::Properties => self.show_properties, PanelId::BlockPalette => self.show_block_palette, + PanelId::ExternalReferences => self.show_external_references, + PanelId::Browser => self.show_browser, } } @@ -757,6 +773,493 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.refresh_block_palette(); } } + + /// Rebuild the Reference Manager's entry list from the active drawing. + /// Uses the tab's session sets (unloaded + stat baseline) so CLI and + /// palette agree; writes fresh load-time mtimes back into the tab's stat + /// cache (`Stale` detectable from the second refresh on). Records the + /// tab's `edit_revision` so the palette auto-rescans on doc changes. + pub(crate) fn refresh_xref_manager(&mut self) { + let i = self.active_tab; + let base_dir: std::path::PathBuf = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let tab_id = self.tabs[i].id; + let revision = self.tabs[i].edit_revision; + let (host_name, host_path) = match &self.tabs[i].current_path { + Some(p) => ( + p.file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(), + p.to_string_lossy().into_owned(), + ), + None => (String::new(), String::new()), + }; + let baselines = { + let tab = &self.tabs[i]; + self.xref_manager.refresh( + &tab.scene.document, + &base_dir, + tab.xref_unloaded.as_set(), + &tab.xref_stat_cache.0, + &host_name, + &host_path, + ) + }; + let tab = &mut self.tabs[i]; + for (key, mtime) in baselines { + tab.xref_stat_cache.insert(key, mtime); + } + // A clean rescan clears the open-time missing notice. + if !self + .xref_manager + .entries + .iter() + .any(|e| e.status == crate::io::xref_model::RefStatus::NotFound) + { + tab.xref_missing = 0; + } + self.xref_manager.source_tab_id = Some(tab_id); + self.xref_manager.source_edit_revision = revision; + } + + /// Re-scan when the palette is open but showing another tab's drawing or + /// the active drawing changed since the last scan (CLI/palette mutation, + /// XOPEN-return edit, undo/redo — all bump `edit_revision`). Cheap: + /// `collect_entries` stats files without re-parsing the host. + pub(crate) fn refresh_xref_manager_if_stale(&mut self) { + if !self.dock_panel_visible(crate::ui::dock::PanelId::ExternalReferences) { + return; + } + let i = self.active_tab; + if self.xref_manager.source_tab_id != Some(self.tabs[i].id) + || self.xref_manager.source_edit_revision != self.tabs[i].edit_revision + { + self.refresh_xref_manager(); + } + } + + /// Shared post-mutation sequence for reference ops (CLI + palette): + /// repopulate scene caches, mirror xref layers into the Layers panel, + /// and mark the tab dirty. Both call sites use it so the 5-line + /// sequence cannot drift. + pub(crate) fn post_ref_op(&mut self, i: usize) { + self.tabs[i].scene.populate_hatches_from_document(); + self.tabs[i].scene.populate_images_from_document(); + // Image/PDF unload is intentionally session-only (the file formats do + // not persist an unloaded bit), so keep their source entities but + // remove their decoded render models. A reload repopulates the cache + // after clearing the corresponding session key. + let hidden_images: Vec = self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| match entity { + acadrust::EntityType::RasterImage(image) + if image.definition_handle.is_some_and(|key| self.tabs[i].xref_unloaded.is_unloaded(key.value())) => + { + Some(entity.common().handle) + } + acadrust::EntityType::Underlay(underlay) + if matches!(underlay.underlay_type, acadrust::entities::UnderlayType::Pdf) + && self.tabs[i].xref_unloaded.is_unloaded(underlay.definition_handle.value()) => + { + Some(entity.common().handle) + } + _ => None, + }) + .collect(); + for handle in hidden_images { + self.tabs[i].scene.images.remove(&handle); + } + self.tabs[i].scene.populate_meshes_from_document(); + self.refresh_layer_panel(); + self.tabs[i].dirty = true; + } + + /// Execute a toolbar [`XrefPaletteOp`] on the palette's actionable + /// selection. Same engine fns as the CLI arms, batched with per-item + /// report lines in the CLI wording; finishes with [`post_ref_op`] plus + /// a palette rescan (mirror of the CLI post-op sequence). + pub(crate) fn xref_manager_op( + &mut self, + op: crate::ui::window::xref_manager::XrefPaletteOp, + ) { + if cfg!(target_arch = "wasm32") { + self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref()); + return; + } + use crate::ui::window::xref_manager::XrefPaletteOp; + let i = self.active_tab; + // Full selection (nested rows included) with per-entry metadata, so + // nested rows report per-entry errors instead of being silently + // dropped by `actionable_selection` (mirrors the CLI arms). + let picked: Vec<(u64, String, crate::io::xref_model::RefKind, bool)> = { + let mut idx: Vec = self.xref_manager.selected.iter().copied().collect(); + idx.sort_unstable(); + idx.iter() + .filter_map(|idx| { + self.xref_manager.entries.get(*idx).map(|e| { + ( + e.key, + e.name.clone(), + e.kind, + e.parent_key.is_some(), + ) + }) + }) + .collect() + }; + if picked.is_empty() { + self.command_line + .push_info(crate::t!("Select a reference first.").as_ref()); + return; + } + let needs_host = matches!( + op, + XrefPaletteOp::Reload | XrefPaletteOp::Bind | XrefPaletteOp::Pathtype(_) + ); + let host: Option = self.tabs[i].current_path.clone(); + if needs_host && host.is_none() { + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + return; + } + let label = match op { + XrefPaletteOp::Open => "XREF-OPEN", + XrefPaletteOp::Detach => "XREF-DETACH", + XrefPaletteOp::Unload => "XREF-UNLOAD", + XrefPaletteOp::Reload => "XREF-RELOAD", + XrefPaletteOp::Bind => "XREF-BIND", + XrefPaletteOp::Overlay => "XREF-OVERLAY", + XrefPaletteOp::Attach => "XREF-ATTACH", + XrefPaletteOp::Pathtype(_) => "XREF-PATHTYPE", + }; + self.push_undo_snapshot(i, label); + let mut done = 0usize; + match op { + XrefPaletteOp::Detach => { + for (key, name, _, is_nested) in &picked { + if *is_nested { + self.command_line.push_error(crate::tf!( + "XREF: cannot detach nested reference '{}'. Detach it in its host drawing.", + name + ).as_ref()); + continue; + } + match crate::io::xref::detach_reference( + &mut self.tabs[i].scene.document, + *key, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: detached \"{}\".", + name + ).as_ref()); + self.tabs[i].xref_unloaded.remove(key); + self.tabs[i].xref_stat_cache.remove(key); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + } + XrefPaletteOp::Unload => { + for (key, name, _, is_nested) in &picked { + if *is_nested { + self.command_line.push_error(crate::tf!( + "XREF: cannot unload nested reference '{}'. Unload it in its host drawing.", + name + ).as_ref()); + continue; + } + match crate::io::xref::unload_reference( + &mut self.tabs[i].scene.document, + *key, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: unloaded \"{}\".", + name + ).as_ref()); + self.tabs[i].xref_unloaded.add(*key); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + } + XrefPaletteOp::Reload => { + let base_dir: std::path::PathBuf = host + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + // Drawing references only: image/PDF rows keep their flags + // untouched and report per-entry (their names never match the + // DWG-only `XrefInfo` list, so matching after the flag clear + // would spuriously report no-match). + let dwg_picked: Vec<(u64, String)> = picked + .iter() + .filter(|(_, _, kind, is_nested)| { + if *is_nested { + return false; + } + *kind == crate::io::xref_model::RefKind::DwgXref + }) + .map(|(key, name, _, _)| (*key, name.clone())) + .collect(); + for (_, name, kind, is_nested) in &picked { + if *is_nested { + self.command_line.push_error(crate::tf!( + "XREF: cannot reload nested reference '{}'. Reload it in its host drawing.", + name + ).as_ref()); + } else if *kind != crate::io::xref_model::RefKind::DwgXref { + self.command_line.push_error(crate::tf!( + "{}: reload applies to drawing references only.", + name + ).as_ref()); + } + } + for (key, _) in &dwg_picked { + self.tabs[i].xref_unloaded.remove(key); + self.tabs[i].xref_stat_cache.remove(key); + } + let handles: rustc_hash::FxHashSet = self.tabs[i] + .scene + .document + .block_records + .iter() + .filter(|br| dwg_picked.iter().any(|(key, _)| *key == br.handle.value())) + .map(|br| br.handle) + .collect(); + let (infos, _dropped) = crate::io::xref::resolve_xrefs_for_keys( + &mut self.tabs[i].scene.document, + &base_dir, + &handles, + ); + // Targeted reload updates baselines only for selected rows. + { + let fresh = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + for e in &fresh { + if !dwg_picked.iter().any(|(key, _)| *key == e.key) { + continue; + } + if e.status == crate::io::xref_model::RefStatus::Loaded { + if let Some(m) = e.modified { + self.tabs[i].xref_stat_cache.insert(e.key, m); + } + } + } + } + for (_, name) in &dwg_picked { + match infos + .iter() + .find(|n| n.name.eq_ignore_ascii_case(name)) + { + Some(info) => { + self.report_xref_status(info); + done += 1; + } + None => self.command_line.push_error(crate::tf!( + "XREF: no references match '{}'.", + name + ).as_ref()), + } + } + } + XrefPaletteOp::Overlay => { + for (key, name, _, is_nested) in &picked { + if *is_nested { + self.command_line.push_error(crate::tf!( + "XREF: cannot overlay nested reference '{}'. Overlay it in its host drawing.", + name + ).as_ref()); + continue; + } + match crate::io::xref::set_ref_type( + &mut self.tabs[i].scene.document, + *key, + crate::io::xref_model::RefType::Overlay, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: \"{}\" set to Overlay.", + name + ).as_ref()); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + } + XrefPaletteOp::Attach => { + for (key, name, _, is_nested) in &picked { + if *is_nested { + self.command_line.push_error(crate::tf!( + "XREF: cannot attach nested reference '{}'. Attach it in its host drawing.", + name + ).as_ref()); + continue; + } + match crate::io::xref::set_ref_type( + &mut self.tabs[i].scene.document, + *key, + crate::io::xref_model::RefType::Attach, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: \"{}\" set to Attach.", + name + ).as_ref()); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + } + XrefPaletteOp::Bind => { + let base_dir: std::path::PathBuf = host + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let host_dir: std::path::PathBuf = host + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + for (key, name, _, is_nested) in &picked { + if *is_nested { + self.command_line.push_error(crate::tf!( + "XREF: cannot bind nested reference '{}'. Bind it in its host drawing.", + name + ).as_ref()); + continue; + } + match crate::io::xref::bind_reference( + &mut self.tabs[i].scene.document, + *key, + &base_dir, + &host_dir, + ) { + Ok(outcome) => { + if outcome.unremapped == 0 { + self.command_line.push_output(crate::tf!( + "XREF: bound \"{}\".", + outcome.name + ).as_ref()); + } else { + self.command_line.push_output(crate::tf!( + "XREF: bound \"{}\" with {} unremapped style handles (see bind limitations).", + outcome.name, outcome.unremapped + ).as_ref()); + } + self.tabs[i].xref_unloaded.remove(key); + self.tabs[i].xref_stat_cache.remove(key); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + } + XrefPaletteOp::Pathtype(pathtype) => { + let host = host.unwrap_or_else(|| std::path::PathBuf::from(".")); + for (key, name, _, is_nested) in &picked { + if *is_nested { + self.command_line.push_error(crate::tf!( + "XREF: cannot set path type for nested reference '{}'. Set it in its host drawing.", + name + ).as_ref()); + continue; + } + match crate::io::xref::apply_pathtype( + &mut self.tabs[i].scene.document, + *key, + pathtype, + &host, + ) { + Ok(_) => { + self.command_line.push_output(crate::tf!( + "XREF: Path set for \"{}\" — Reload to apply.", + name + ).as_ref()); + done += 1; + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + } + } + XrefPaletteOp::Open => { + // Handled in update/mod.rs XrefRowOp dispatch (navigation), not here. + } + } + if done > 0 { + self.post_ref_op(i); + } + self.refresh_xref_manager(); + } + + /// Reload every direct drawing reference (toolbar Reload All). Same + /// engine path as `XRELOAD`: undo snapshot, session flags cleared, + /// full resolve, per-ref report, stat baselines refreshed. + pub(crate) fn xref_manager_reload_all(&mut self) { + if cfg!(target_arch = "wasm32") { + self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref()); + return; + } + let i = self.active_tab; + let Some(path) = self.tabs[i].current_path.clone() else { + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + return; + }; + let Some(base_dir) = path.parent().map(|p| p.to_path_buf()) else { + self.command_line + .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + return; + }; + let reload_keys: Vec = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ) + .iter() + .filter(|e| e.kind == crate::io::xref_model::RefKind::DwgXref && e.parent_key.is_none()) + .map(|e| e.key) + .collect(); + self.push_undo_snapshot(i, "XREF-RELOAD"); + for key in &reload_keys { + self.tabs[i].xref_unloaded.remove(key); + self.tabs[i].xref_stat_cache.remove(key); + } + let (infos, _dropped) = + crate::io::xref::resolve_xrefs(&mut self.tabs[i].scene.document, &base_dir); + let fresh = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + &base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); + for e in &fresh { + if e.status == crate::io::xref_model::RefStatus::Loaded { + if let Some(m) = e.modified { + self.tabs[i].xref_stat_cache.insert(e.key, m); + } + } + } + for info in &infos { + self.report_xref_status(info); + } + self.post_ref_op(i); + self.refresh_xref_manager(); + } } #[cfg(test)] @@ -1254,4 +1757,310 @@ mod tests { assert_eq!(app.block_name_from_file("Chair"), "Chair (2)"); assert_eq!(app.block_name_from_file("Table"), "Table"); } + + #[test] + fn save_as_rebases_live_doc_paths() { + // F4: on Save-As completion the live document's relative reference + // paths are rebased onto the new base dir (same helper the save + // snapshot used), so the session agrees with the file just written. + let mut app = fresh(); + let i = app.active_tab; + let dir = std::env::temp_dir().join(format!( + "ocs_saveas_rebase_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let old_dir = dir.join("old"); + let new_dir = dir.join("new"); + std::fs::create_dir_all(&old_dir).unwrap(); + std::fs::create_dir_all(&new_dir).unwrap(); + let mut br = acadrust::tables::BlockRecord::new("PLAN"); + br.flags.is_xref = true; + br.xref_path = "refs/plan.dwg".to_string(); + br.handle = app.tabs[i].scene.document.allocate_handle(); + app.tabs[i] + .scene + .document + .block_records + .add(br) + .unwrap(); + app.tabs[i].current_path = Some(old_dir.join("host.dwg")); + let tab_id = app.tabs[i].id; + let job_id = 4242u64; + app.active_save_jobs.insert(tab_id, job_id); + let outcome = crate::app::SaveOutcome { + job_id, + tab_id, + epoch: app.tabs[i].scene.geometry_epoch, + revision: app.tabs[i].edit_revision, + camera_generation: app.tabs[i].scene.camera_generation, + path: new_dir.join("host.dwg"), + version: app.tabs[i].scene.document.version, + previous_autosave: None, + set_current_path: true, + purpose: crate::app::SavePurpose::SaveAs, + continuation: crate::app::SaveContinuation::None, + refreshed_preview: None, + result: Ok(()), + }; + let _ = app.on_save_finished(outcome); + assert_eq!( + app.tabs[i].current_path.as_deref(), + Some(new_dir.join("host.dwg").as_path()) + ); + let br = app.tabs[i] + .scene + .document + .block_records + .get("PLAN") + .unwrap(); + assert_eq!(br.xref_path, "../old/refs/plan.dwg"); + std::fs::remove_dir_all(&dir).ok(); + } + + fn palette_tmpdir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "ocs_palette_{tag}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn palette_output(app: &OpenCADStudio, start: usize) -> String { + app.command_line.history[start..] + .iter() + .map(|e| e.text.as_str()) + .collect::>() + .join("\n") + } + + #[test] + fn palette_rowop_nested_reports_per_entry() { + // Behavioral matrix: every row op on a NESTED row via the GUI + // message path. Nested rows must report per-entry errors (CLI + // wording), never silently skip. + use acadrust::tables::BlockRecord; + use crate::app::Message; + use crate::ui::window::xref_manager::XrefPaletteOp; + let dir = palette_tmpdir("nestedmatrix"); + let mut host_doc = acadrust::CadDocument::new(); + let mut inner = BlockRecord::new("INNER"); + inner.flags.is_xref = true; + inner.xref_path = "inner.dwg".to_string(); + host_doc.block_records.add(inner).unwrap(); + let bytes = crate::io::save_to_bytes(&host_doc, "dwg", host_doc.version).unwrap(); + std::fs::write(dir.join("host.dwg"), &bytes).unwrap(); + let mut app = fresh(); + let i = app.active_tab; + let mut br = BlockRecord::new("HOST"); + br.flags.is_xref = true; + br.xref_path = dir.join("host.dwg").to_string_lossy().into_owned(); + br.handle = app.tabs[i].scene.document.allocate_handle(); + app.tabs[i].scene.document.block_records.add(br).unwrap(); + app.tabs[i].current_path = Some(dir.join("app.dwg")); + app.refresh_xref_manager(); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "INNER").expect("nested INNER listed"); + assert!(app.xref_manager.entries[idx].parent_key.is_some()); + for op in [ + XrefPaletteOp::Detach, + XrefPaletteOp::Unload, + XrefPaletteOp::Reload, + XrefPaletteOp::Bind, + XrefPaletteOp::Overlay, + ] { + let start = app.command_line.history.len(); + let _ = app.update(Message::XrefRowOp(idx, op)); + let out = palette_output(&app, start); + assert!(out.contains("INNER"), "op {op:?} on nested row gave: {out:?}"); + assert!( + app.tabs[i].scene.document.block_records.get("HOST").is_some(), + "op {op:?} must not mutate the direct reference" + ); + } + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn palette_detach_unload_rowop_gui_path() { + // Reproduction for "Detach/Unload from the palette do nothing": + // drive the exact GUI message path (row right-click menu item). + use acadrust::tables::BlockRecord; + use crate::app::Message; + use crate::ui::window::xref_manager::XrefPaletteOp; + let mut app = fresh(); + let i = app.active_tab; + for name in ["PLAN", "SITE"] { + let mut br = BlockRecord::new(name); + br.flags.is_xref = true; + br.xref_path = format!("old/{}.dwg", name.to_lowercase()); + br.handle = app.tabs[i].scene.document.allocate_handle(); + app.tabs[i].scene.document.block_records.add(br).unwrap(); + } + app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_repro_host.dwg")); + app.refresh_xref_manager(); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "PLAN").expect("PLAN listed"); + let start = app.command_line.history.len(); + let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Detach)); + let out = palette_output(&app, start); + assert!(out.contains("PLAN"), "detach output missing, got: {out:?}"); + assert!(app.tabs[i].scene.document.block_records.get("PLAN").is_none(), "PLAN definition must be gone"); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "SITE").expect("SITE listed"); + let start = app.command_line.history.len(); + let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Unload)); + let out = palette_output(&app, start); + assert!(out.contains("SITE"), "unload output missing, got: {out:?}"); + assert_eq!( + app.xref_manager.entries.iter().find(|e| e.name == "SITE").unwrap().status, + crate::io::xref_model::RefStatus::Unloaded + ); + } + + #[test] + fn palette_overlay_and_pathtype_rowop_gui_path() { + // Row-menu Overlay + Change-Path row ops on a direct row via the + // GUI message path: type flag flips, saved path clears. + use acadrust::tables::BlockRecord; + use crate::app::Message; + use crate::io::xref_model::{Pathtype, RefType}; + use crate::ui::window::xref_manager::XrefPaletteOp; + let mut app = fresh(); + let i = app.active_tab; + let mut br = BlockRecord::new("PLAN"); + br.flags.is_xref = true; + br.xref_path = "old/plan.dwg".to_string(); + br.handle = app.tabs[i].scene.document.allocate_handle(); + app.tabs[i].scene.document.block_records.add(br).unwrap(); + app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_rowop_host.dwg")); + app.refresh_xref_manager(); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "PLAN").expect("PLAN listed"); + let start = app.command_line.history.len(); + let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Overlay)); + let out = palette_output(&app, start); + assert!(out.contains("PLAN"), "got: {out:?}"); + let br = app.tabs[i].scene.document.block_records.get("PLAN").unwrap(); + assert!(br.flags.is_xref_overlay && !br.flags.is_xref); + assert_eq!(app.xref_manager.entries.iter().find(|e| e.name == "PLAN").unwrap().ref_type, RefType::Overlay); + let start = app.command_line.history.len(); + let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Pathtype(Pathtype::None))); + let out = palette_output(&app, start); + assert!(out.contains("PLAN"), "got: {out:?}"); + let br = app.tabs[i].scene.document.block_records.get("PLAN").unwrap(); + assert_eq!(br.xref_path, "plan.dwg", "Remove Path strips to the bare filename"); + } + + #[test] + fn palette_reload_all_resolves_direct_refs() { + // Toolbar Reload All against a real on-disk reference: full + // resolve, per-ref report, entry back to Loaded. + use acadrust::tables::BlockRecord; + let dir = palette_tmpdir("reloadall"); + let ref_doc = acadrust::CadDocument::new(); + let bytes = crate::io::save_to_bytes(&ref_doc, "dwg", ref_doc.version).unwrap(); + std::fs::write(dir.join("plan.dwg"), &bytes).unwrap(); + let mut app = fresh(); + let i = app.active_tab; + let mut br = BlockRecord::new("PLAN"); + br.flags.is_xref = true; + br.xref_path = dir.join("plan.dwg").to_string_lossy().into_owned(); + br.handle = app.tabs[i].scene.document.allocate_handle(); + app.tabs[i].scene.document.block_records.add(br).unwrap(); + app.tabs[i].current_path = Some(dir.join("host.dwg")); + let start = app.command_line.history.len(); + app.xref_manager_reload_all(); + let out = palette_output(&app, start); + assert!(out.contains("PLAN"), "reload-all must report the reference, got: {out:?}"); + let entry = app.xref_manager.entries.iter().find(|e| e.name == "PLAN").expect("PLAN listed"); + assert_eq!(entry.status, crate::io::xref_model::RefStatus::Loaded, "PLAN must resolve Loaded"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn find_replace_prompt_prefills_command_line() { + // Toolbar Find and Replace hands the existing `XREF Path Find` + // parsing a prefilled command line. + use crate::app::Message; + let mut app = fresh(); + let _ = app.update(Message::XrefFindReplacePrompt); + assert_eq!(app.command_line.input, "XREF Path Find "); + } + + #[test] + fn palette_unload_nested_reports_per_entry() { + // F6: palette Unload on a nested row reports the per-entry nested + // error (CLI wording) instead of the confusing 'no loaded reference'. + use acadrust::tables::BlockRecord; + use crate::ui::window::xref_manager::XrefPaletteOp; + let dir = palette_tmpdir("nested"); + let mut host_doc = acadrust::CadDocument::new(); + let mut inner = BlockRecord::new("INNER"); + inner.flags.is_xref = true; + inner.xref_path = "inner.dwg".to_string(); + host_doc.block_records.add(inner).unwrap(); + let bytes = crate::io::save_to_bytes(&host_doc, "dwg", host_doc.version).unwrap(); + std::fs::write(dir.join("host.dwg"), &bytes).unwrap(); + let mut app = fresh(); + let i = app.active_tab; + let mut br = BlockRecord::new("HOST"); + br.flags.is_xref = true; + br.xref_path = dir.join("host.dwg").to_string_lossy().into_owned(); + br.handle = app.tabs[i].scene.document.allocate_handle(); + app.tabs[i].scene.document.block_records.add(br).unwrap(); + app.tabs[i].current_path = Some(dir.join("app.dwg")); + app.refresh_xref_manager(); + let idx = app.xref_manager.entries.iter().position(|e| e.name == "INNER").expect("nested INNER listed"); + assert!(app.xref_manager.entries[idx].parent_key.is_some()); + app.xref_manager.selected.insert(idx); + let start = app.command_line.history.len(); + app.xref_manager_op(XrefPaletteOp::Unload); + let out = palette_output(&app, start); + assert!(out.contains("INNER"), "got: {out:?}"); + assert_eq!(app.command_line.history.len(), start + 1); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn palette_reload_skips_images_without_clearing_flags() { + // F7: palette Reload on an image/PDF row reports the drawing-only + // error and leaves its unloaded flag untouched (no spurious + // no-match after a flag clear). + use acadrust::objects::{ImageDefinition, ObjectType}; + use crate::io::xref_model::RefKind; + use crate::ui::window::xref_manager::XrefPaletteOp; + let dir = palette_tmpdir("imgreload"); + let mut app = fresh(); + let i = app.active_tab; + let h = app.tabs[i].scene.document.allocate_handle(); + let mut def = ImageDefinition::with_dimensions("img.png", 8, 8); + def.handle = h; + app.tabs[i].scene.document.objects.insert(h, ObjectType::ImageDefinition(def)); + let mut img = acadrust::entities::RasterImage::new( + "img.png", + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + img.definition_handle = Some(h); + app.tabs[i].scene.document.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + app.tabs[i].current_path = Some(dir.join("host.dwg")); + app.refresh_xref_manager(); + let idx = app.xref_manager.entries.iter().position(|e| e.kind == RefKind::Image).expect("image listed"); + let key = app.xref_manager.entries[idx].key; + app.xref_manager.selected.insert(idx); + app.tabs[i].xref_unloaded.add(key); + let start = app.command_line.history.len(); + app.xref_manager_op(XrefPaletteOp::Reload); + let out = palette_output(&app, start); + assert!(out.contains("img.png"), "got: {out:?}"); + assert_eq!(app.command_line.history.len(), start + 1); + assert!(app.tabs[i].xref_unloaded.is_unloaded(key), "image flag must stay untouched"); + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 2e9161ac0..d03d1c2d3 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -20,6 +20,10 @@ use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; +fn eval_dynamic_angle(text: &str) -> Option { + let value = crate::app::expr_eval::eval_number(&text.trim().replace(',', "."))?; + crate::entities::common::parse_angle(&value.to_string()) +} impl OpenCADStudio { pub(in crate::app) fn active_distance_ray( @@ -402,9 +406,9 @@ impl OpenCADStudio { let explicit_sign = raw .as_deref() .is_some_and(|s| s.starts_with('-') || s.starts_with('+')); - match raw.and_then(|s| crate::app::expr_eval::eval_number(&s)) { - Some(v) if explicit_sign => v.to_radians(), - Some(mag) => mag.abs().to_radians().copysign(dy), + match raw.as_deref().and_then(eval_dynamic_angle) { + Some(v) if explicit_sign => v, + Some(mag) => mag.abs().copysign(dy), None => live_a, } }; @@ -474,7 +478,11 @@ impl OpenCADStudio { // absolute CCW angle in the UCS plane, not a cursor-signed // magnitude — keep it literal. Only the polar Distance+Angle // pair uses the cursor-signed `angle_rad`. - let a = val(0, live_a.to_degrees()).to_radians(); + let a = fields[0] + .buffer + .as_deref() + .and_then(eval_dynamic_angle) + .unwrap_or(live_a); Some(rel(glam::DVec3::new(live_d * a.cos(), live_d * a.sin(), 0.0))) } _ => None, @@ -794,3 +802,33 @@ impl OpenCADStudio { self.tabs[i].scene.set_preview_wires(previews); } } + +#[cfg(test)] +mod tests { + use super::eval_dynamic_angle; + use crate::entities::common::{set_unit_context, unit_context}; + + #[test] + fn dynamic_angle_expressions_follow_drawing_units() { + let previous = unit_context(); + let mut context = previous; + + context.aunits = 2; + set_unit_context(context); + assert!( + (eval_dynamic_angle("2 * 50").unwrap() - std::f64::consts::FRAC_PI_2).abs() + < 1e-12 + ); + + context.aunits = 3; + set_unit_context(context); + assert!( + (eval_dynamic_angle("1.5707963267948966").unwrap() + - std::f64::consts::FRAC_PI_2) + .abs() + < 1e-12 + ); + + set_unit_context(previous); + } +} diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 8f7ca88f2..bb43488b2 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -88,35 +88,215 @@ fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool { use crate::io::pdf_export::{PdfPageInput, PlotContent}; -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" { +/// The dialog's sheet orientation as a catalogue value. +fn plot_dialog_orientation( + d: &crate::ui::window::plot::PlotDialogState, +) -> crate::io::paper_catalog::Orientation { + use crate::io::paper_catalog::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), - "Letter" => Some(PaperSize::Letter), - "Legal" => Some(PaperSize::Legal), - "Tabloid" => Some(PaperSize::Tabloid), - _ => 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), +} + +/// The sheet the dialog names, or — when the name carries no size (an old +/// config, a bare driver name) — a custom sheet built from the dialog's stored +/// dimensions, so a drawing whose paper we cannot identify still plots at its +/// own size. +fn plot_dialog_paper( + d: &crate::ui::window::plot::PlotDialogState, +) -> crate::io::paper_catalog::PaperSize { + use crate::io::paper_catalog::{from_drawing, resolve, PaperSize, PaperUnits}; + if d.paper_width_mm > 0.0 && d.paper_height_mm > 0.0 { + from_drawing(&d.paper, d.paper_width_mm, d.paper_height_mm) + } else { + resolve(&d.paper) + .unwrap_or_else(|| PaperSize::custom(210.0, 297.0, PaperUnits::Millimeters)) } } +fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) { + plot_dialog_paper(d).sheet_mm(plot_dialog_orientation(d)) +} + +/// Whether the dialog still names the sheet the stored settings carry. +fn paper_unchanged( + d: &crate::ui::window::plot::PlotDialogState, + stored: &acadrust::objects::PlotSettings, +) -> bool { + !stored.paper_size.is_empty() + && crate::io::paper_catalog::from_drawing( + &stored.paper_size, + stored.paper_width, + stored.paper_height, + ) + .canonical + == d.paper +} + +/// The media name to store for the dialog's sheet. A drawing keeps its own +/// spelling of a sheet the user did not change — a system printer's `A4` or a +/// plotter's custom name must not turn into the catalogue's canonical name +/// just by passing through the dialog, or the source application would look the sheet up +/// under a name that driver never reported. Only a genuinely different +/// selection writes the canonical name. +fn paper_name_for_settings( + d: &crate::ui::window::plot::PlotDialogState, + stored: &acadrust::objects::PlotSettings, +) -> String { + if paper_unchanged(d, stored) { + stored.paper_size.clone() + } else { + d.paper.clone() + } +} + +/// The stored rotation for the dialog's sheet on a medium that is itself +/// `natural`-oriented: a quarter turn when the requested orientation differs +/// from the medium's own, and "upside-down" adds a half turn on top. This is +/// how the commercial application writes it — a system printer's portrait +/// `A3` plotted landscape carries 90°, while its PDF driver's landscape +/// `ISO_A4_(297.00_x_210.00_MM)` medium plotted landscape carries 0°. +fn plot_dialog_rotation( + d: &crate::ui::window::plot::PlotDialogState, + natural: crate::io::paper_catalog::Orientation, +) -> acadrust::objects::PlotRotation { + use acadrust::objects::PlotRotation; + match (plot_dialog_orientation(d) != natural, d.upside_down) { + (false, false) => PlotRotation::None, + (true, false) => PlotRotation::Degrees90, + (false, true) => PlotRotation::Degrees180, + (true, true) => PlotRotation::Degrees270, + } +} + +/// Millimetres in the paper unit a page setup counts in. +fn plot_paper_unit_mm(units: acadrust::objects::PlotPaperUnits) -> f64 { + match units { + acadrust::objects::PlotPaperUnits::Inches => 25.4, + _ => 1.0, + } +} + +/// The factor the commercial application stores next to the scale (DXF 147) +/// is the `paper : drawing` ratio times 25.4 on millimetre page setups and +/// the plain ratio on inch ones — observed in its files, whatever the +/// published description says. Both directions of that convention live here. +fn stored_scale_factor(ratio: f64, units: acadrust::objects::PlotPaperUnits) -> f64 { + match units { + acadrust::objects::PlotPaperUnits::Millimeters => ratio * 25.4, + _ => ratio, + } +} + +fn ratio_from_stored_scale_factor(factor: f64, units: acadrust::objects::PlotPaperUnits) -> f64 { + match units { + acadrust::objects::PlotPaperUnits::Millimeters => factor / 25.4, + _ => factor, + } +} + +/// The file format's standard scale for a `paper : drawing` ratio, when it +/// has one. Only the metric ratios exist as standard codes here; an +/// architectural scale is stored as a custom ratio with the same value. +fn standard_scale_for_ratio(paper: f64, drawing: f64) -> Option { + use acadrust::objects::ScaledType as S; + const STANDARD: &[(f64, f64, S)] = &[ + (1.0, 1.0, S::OneToOne), + (1.0, 2.0, S::OneToTwo), + (1.0, 4.0, S::OneToFour), + (1.0, 8.0, S::OneToEight), + (1.0, 10.0, S::OneToTen), + (1.0, 16.0, S::OneToSixteen), + (1.0, 20.0, S::OneToTwenty), + (1.0, 30.0, S::OneToThirty), + (1.0, 40.0, S::OneToForty), + (1.0, 50.0, S::OneToFifty), + (1.0, 100.0, S::OneToHundred), + (2.0, 1.0, S::TwoToOne), + (4.0, 1.0, S::FourToOne), + (8.0, 1.0, S::EightToOne), + (10.0, 1.0, S::TenToOne), + (100.0, 1.0, S::HundredToOne), + ]; + let ratio = paper / drawing; + STANDARD + .iter() + .find(|(p, d, _)| (p / d - ratio).abs() <= 1e-9 * ratio.abs().max(1.0)) + .map(|(_, _, scale)| *scale) +} + +/// `12.5000` → `12.5`, `3.0000` → `3`: a fixed-precision number without its +/// trailing zeros, for fields the user reads back. +fn trim_decimals(text: &str) -> String { + if text.contains('.') { + text.trim_end_matches('0').trim_end_matches('.').to_string() + } else { + text.to_string() + } +} + +/// A scale-list name for a plot scale the drawing has no name for, spelled as +/// the ratio it is (`1:250`, `25.4:1`) so the value survives in the picker +/// instead of collapsing to 1:1. +fn ratio_scale_name(factor: f64) -> String { + if factor >= 1.0 { + format!("{}:1", trim_decimals(&format!("{factor:.4}"))) + } else { + format!("1:{}", trim_decimals(&format!("{:.4}", 1.0 / factor))) + } +} + +/// The plot device the dialog currently targets. +fn plot_dialog_device(d: &crate::ui::window::plot::PlotDialogState) -> crate::io::plot_device::PlotDevice { + use crate::io::plot_device::PlotDevice; + if d.to_file { + PlotDevice::Pdf + } else { + match &d.printer { + Some(name) => PlotDevice::Printer(name.clone()), + None => PlotDevice::None, + } + } +} + +/// The plotter name to store. Same rule as the sheet: a device name the source application +/// wrote (its PDF driver, a plotter `.pc3` we plot to PDF for it) stays as it +/// is while the dialog still points at the same kind of device; a changed +/// device, or one of our own legacy labels, is written in the source application's spelling. +fn device_name_for_settings( + d: &crate::ui::window::plot::PlotDialogState, + stored: &acadrust::objects::PlotSettings, +) -> String { + use crate::io::plot_device::PlotDevice; + let device = plot_dialog_device(d); + let (stored_device, keep_stored) = PlotDevice::from_stored_name(&stored.printer_name); + if keep_stored && stored_device == device { + stored.printer_name.clone() + } else { + device.canonical_name() + } +} + +/// The unprintable margins to store: the drawing's own while neither the +/// sheet nor the device changed (they came from the source application's driver and we have +/// no better source), otherwise what our device reports for the new sheet — +/// the equivalent of the source application's `PAPERUPDATE`. +fn margins_for_settings( + d: &crate::ui::window::plot::PlotDialogState, + stored: &acadrust::objects::PlotSettings, +) -> acadrust::objects::PaperMargin { + use crate::io::plot_device::PlotDevice; + let device = plot_dialog_device(d); + let (stored_device, _) = PlotDevice::from_stored_name(&stored.printer_name); + if paper_unchanged(d, stored) && stored_device == device { + return stored.margins; + } + let margins = device.margins_mm(&plot_dialog_paper(d), &d.custom_papers); + acadrust::objects::PaperMargin::new(margins.left, margins.bottom, margins.right, margins.top) +} + fn plot_content_extents(content: &PlotContent) -> Option<(f64, f64, f64, f64)> { let PlotContent { wires, hatches, wipeouts, images, .. } = content; let mut bounds = ( @@ -382,6 +562,7 @@ impl OpenCADStudio { /// Snapshot the persisted UI preferences from live state. pub(in crate::app) fn current_settings(&self) -> crate::app::settings::UserSettings { crate::app::settings::UserSettings { + spacemouse: self.spacemouse_preferences, dyn_input: self.dyn_input, polar: self.polar_mode, polar_increment_deg: self.polar_increment_deg, @@ -396,6 +577,8 @@ impl OpenCADStudio { selection_cycling: self.selection_cycling, double_click_block_refedit: self.double_click_block_refedit, double_click_block_attedit: self.double_click_block_attedit, + right_click_mode: self.right_click_mode, + right_click_hold_ms: self.right_click_hold_ms, grip_object_limit: self.grip_object_limit, ncopy_bind: self.ncopy_bind, cursor_type: self.cursor_type, @@ -406,6 +589,8 @@ impl OpenCADStudio { snap_angle_deg: self.snap_angle_deg, otrack: self.snapper.otrack_enabled, default_assoc_prompted: self.default_assoc_prompted, + check_missing_fonts: self.check_missing_fonts, + font_source_url: self.font_source_url.clone(), donation_prompt_version: self.donation_prompt_version.clone(), gpu_warning_silenced: self.gpu_warning_silenced.clone(), disabled_plugins: { @@ -432,6 +617,7 @@ impl OpenCADStudio { constraint_solve_mode: self.constraint_solve_mode, constraint_infer: self.constraint_infer, constraint_bar_display: self.constraint_bar_display, + constraint_bar_mode: self.constraint_bar_mode, savetime_min: self.savetime_min, default_save_format: self.default_save_format.clone(), pick_add: self.pick_add, @@ -442,6 +628,13 @@ impl OpenCADStudio { commandline_fade_ms: crate::app::settings::clamp_commandline_fade_ms( self.commandline_fade_ms, ), + snap_spacing_x: self.snapper.snap_spacing_x, + snap_spacing_y: self.snapper.snap_spacing_y, + grid_spacing_x: self.grid_spacing_x, + grid_spacing_y: self.grid_spacing_y, + grid_major_every: self.grid_major_every, + grid_adaptive: self.grid_adaptive, + grid_beyond_limits: self.grid_beyond_limits, block_mru: self.block_mru.clone(), block_freq: self.block_freq.clone(), } @@ -457,6 +650,7 @@ impl OpenCADStudio { self.cursor_size = s.cursor_size.clamp(1, 100); self.pick_box = s.pick_box.clamp(0, 50); self.options_tab = s.options_tab; + self.spacemouse_preferences = s.spacemouse; // These four drive real features with commands and status-bar pills, // but they lived only on the app struct: turning the ViewCube off and // restarting brought it straight back. @@ -466,6 +660,8 @@ impl OpenCADStudio { self.selection_cycling = s.selection_cycling; self.double_click_block_refedit = s.double_click_block_refedit; self.double_click_block_attedit = s.double_click_block_attedit; + self.right_click_mode = s.right_click_mode; + self.right_click_hold_ms = super::super::settings::clamp_right_click_hold_ms(s.right_click_hold_ms); self.grip_object_limit = s.grip_object_limit.clamp(0, 32767); self.ncopy_bind = s.ncopy_bind; self.cursor_type = s.cursor_type; @@ -489,6 +685,9 @@ impl OpenCADStudio { // open / tab switch), not app-global, so they are not applied here. self.snapper.otrack_enabled = s.otrack; self.default_assoc_prompted = s.default_assoc_prompted; + self.check_missing_fonts = s.check_missing_fonts; + self.font_source_url = s.font_source_url.clone(); + self.font_source_input = s.font_source_url.clone(); self.donation_prompt_version = s.donation_prompt_version.clone(); self.gpu_warning_silenced = s.gpu_warning_silenced.clone(); self.disabled_plugins = s.disabled_plugins.iter().cloned().collect(); @@ -522,6 +721,7 @@ impl OpenCADStudio { self.constraint_solve_mode = s.constraint_solve_mode; self.constraint_infer = s.constraint_infer; self.constraint_bar_display = s.constraint_bar_display.clamp(0, 3); + self.constraint_bar_mode = s.constraint_bar_mode.clamp(0, 4095); self.savetime_min = s.savetime_min; self.default_save_format = crate::io::canonical_save_format(&s.default_save_format).to_string(); @@ -538,6 +738,15 @@ impl OpenCADStudio { crate::app::settings::clamp_commandline_fade_ms(s.commandline_fade_ms); self.command_line .set_commandline_fade_ms(self.commandline_fade_ms.clamp(0, 60000) as u32); + self.snapper.snap_spacing_x = + crate::app::settings::sanitize_snap_spacing(s.snap_spacing_x); + self.snapper.snap_spacing_y = + crate::app::settings::sanitize_snap_spacing(s.snap_spacing_y); + self.grid_spacing_x = crate::app::settings::sanitize_snap_spacing(s.grid_spacing_x); + self.grid_spacing_y = crate::app::settings::sanitize_snap_spacing(s.grid_spacing_y); + self.grid_major_every = crate::app::settings::sanitize_grid_major(s.grid_major_every); + self.grid_adaptive = s.grid_adaptive; + self.grid_beyond_limits = s.grid_beyond_limits; // 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 @@ -979,31 +1188,53 @@ impl OpenCADStudio { } #[cfg(target_arch = "wasm32")] { - // `FileOpened` only installs the result when an open is in - // progress, so mark one. The browser picker + parse happen - // inside `pick_and_load_web`; the real name is unknown until - // then, so show a generic label meanwhile. - let state = std::sync::Arc::new(crate::io::OpenProgressState::new( - crate::app::OPEN_PHASE_READING, - )); - let open_id = self.next_open_id(); - self.opening = Some(crate::app::OpenProgress { - id: open_id, - name: "Opening…".into(), - source_path: None, - size_bytes: 0, - state: state.clone(), - started: Instant::now(), - recovery_error: None, - recovery_read_stats: None, - recovery_bytes: None, - }); + // The browser picker + parse happen inside `pick_and_load_web`; + // the real name is unknown until then, so show a generic label + // meanwhile. + let (open_id, state) = self.begin_web_open("Opening…".into(), 0); Task::perform(crate::io::pick_and_load_web(state), move |outcome| { Message::WebFileOpened(open_id, outcome) }) } } + /// Web: open a drawing from bytes the caller already holds, through the + /// same path as a file chosen in the browser picker. + #[cfg(target_arch = "wasm32")] + pub(in crate::app) fn open_web_bytes(&mut self, name: String, bytes: Vec) -> Task { + let (open_id, state) = self.begin_web_open(name.clone(), bytes.len() as u64); + Task::perform( + crate::io::open_bytes_web(name, std::sync::Arc::from(bytes), state), + move |outcome| Message::WebFileOpened(open_id, outcome), + ) + } + + /// `FileOpened` only installs the result when an open is in progress, so + /// mark one. + #[cfg(target_arch = "wasm32")] + fn begin_web_open( + &mut self, + name: String, + size_bytes: u64, + ) -> (u64, std::sync::Arc) { + let state = std::sync::Arc::new(crate::io::OpenProgressState::new( + crate::app::OPEN_PHASE_READING, + )); + let open_id = self.next_open_id(); + self.opening = Some(crate::app::OpenProgress { + id: open_id, + name, + source_path: None, + size_bytes, + state: state.clone(), + started: Instant::now(), + recovery_error: None, + recovery_read_stats: None, + recovery_bytes: None, + }); + (open_id, state) + } + pub(in crate::app) fn next_open_id(&mut self) -> u64 { self.open_job_serial = self.open_job_serial.wrapping_add(1).max(1); self.open_job_serial @@ -1420,6 +1651,21 @@ impl OpenCADStudio { idx }; + // Missing-on-open: neutral command-line notice plus a flag + // the Reference Manager reads. Never an auto-open modal. + let missing = caches + .xrefs + .iter() + .filter(|item| item.status == crate::io::xref::XrefStatus::NotFound) + .count(); + self.tabs[i].xref_missing = missing; + if missing > 0 { + self.command_line.push_output(crate::tf!( + "{} reference(s) not found — open the reference manager with EXTERNALREFERENCES.", + missing + ).as_ref()); + } + let mut recovery_report = recovery_needed.then(|| { crate::io::recovery::RecoveryReport::recovered( self.tabs[i].id, @@ -1621,6 +1867,21 @@ impl OpenCADStudio { self.active_modal = Some(crate::app::ModalKind::Recovery); Task::none() } else { + #[cfg(not(target_arch = "wasm32"))] + { + // A drawing referencing fonts this machine lacks offers to + // fetch them from the community repository before the user + // studies garbled substitute text (unless recovery already + // owns the modal slot). + let missing = crate::io::font_repo::missing_shx_fonts( + &self.tabs[i].scene.document, + ); + if !missing.is_empty() && self.check_missing_fonts { + self.font_source_input = self.font_source_url.clone(); + self.missing_fonts = Some(missing); + self.active_modal = Some(crate::app::ModalKind::MissingFonts); + } + } self.drain_pending_open() }; Task::batch([thumbs_task, pending_open_task, interaction_task]) @@ -2037,7 +2298,25 @@ impl OpenCADStudio { crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID) }); let clone_started = iced::time::Instant::now(); - let snapshot = self.tabs[i].scene.document.clone(); + let mut snapshot = self.tabs[i].scene.document.clone(); + // Save-As across folders: rebase relative reference paths onto the + // new base dir inside the snapshot only (live strings are untouched). + if purpose == crate::app::SavePurpose::SaveAs { + let old_base = self.tabs[i] + .current_path + .as_ref() + .and_then(|p| p.parent().map(|p| p.to_path_buf())); + let new_base = path.parent().map(|p| p.to_path_buf()); + if let (Some(old), Some(new)) = (old_base, new_base) { + if old != new { + crate::io::xref::rebase_relative_paths_for_save_as( + &mut snapshot, + &old, + &new, + ); + } + } + } let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0; if crate::perf::enabled() { crate::perf_record!( @@ -2321,10 +2600,30 @@ impl OpenCADStudio { } } if outcome.set_current_path { + let old_path = self.tabs[i].current_path.clone(); self.tabs[i].current_path = Some(outcome.path.clone()); self.tabs[i].scene.document.version = outcome.version; if outcome.purpose == crate::app::SavePurpose::SaveAs { self.tabs[i].recovery_save_as_required = false; + // Save-As across folders: the save snapshot was rebased + // in `queue_native_save`; rebase the live document too + // so the running session agrees with the file just + // written. Saves are not undoable, so no undo snapshot + // is pushed. + if let Some(old) = old_path.as_deref() { + let old_base = old.parent().map(|p| p.to_path_buf()); + let new_base = + outcome.path.parent().map(|p| p.to_path_buf()); + if let (Some(old), Some(new)) = (old_base, new_base) { + if old != new { + crate::io::xref::rebase_relative_paths_for_save_as( + &mut self.tabs[i].scene.document, + &old, + &new, + ); + } + } + } } } tasks.push(self.push_recent(outcome.path.clone())); @@ -2825,127 +3124,328 @@ impl OpenCADStudio { iced::exit() } - /// 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)] - pub(super) fn apply_plot_page_settings( - &mut self, - w: f64, - h: f64, - plot_area: &str, - center: bool, - offset_x: f64, - offset_y: f64, - rotation: i16, - ) { - let i = self.active_tab; - let dialog = self.plot_dialog.clone(); - let layout_name = self.tabs[i].scene.current_layout.clone(); - let plot_window = self.plot_window; - 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 + /// The plot scale as `paper : drawing` in the dialog's page-setup unit, + /// with the standard code it names when it names one. The dialog's + /// factor is per the drawing's scale-family unit (inches for an imperial + /// drawing, else millimetres) and already includes the drawing-unit + /// factor; the page setup counts per its own unit. A named ratio is kept + /// as written when no drawing-unit factor hides inside it — a metre + /// drawing plotting "1:100" really plots 10 mm per unit, which is what + /// the file must say — and its standard code is kept even across a + /// paper-unit conversion, the way the commercial application writes + /// "1:1" on an inch page setup over a millimetre paper space as + /// `1 in = 25.4 units`. + fn dialog_plot_scale_ratio(&self) -> ((f64, f64), Option) { + let d = &self.plot_dialog; + let scene = &self.tabs[self.active_tab].scene; + let factor = plot_dialog_scale_factor(d); + let conversion = scene.scale_family_unit_mm() / d.paper_units.to_mm(1.0); + let named = scene.scale_ratio(&d.scale).filter(|(paper, drawing)| { + (paper / drawing - factor).abs() <= 1e-9 * factor.abs().max(1.0) + }); + let standard = named.and_then(|(paper, drawing)| standard_scale_for_ratio(paper, drawing)); + let ratio = match named { + Some((paper, drawing)) if (conversion - 1.0).abs() <= 1e-12 => (paper, drawing), + _ => { + let per_unit = (factor * conversion).max(1e-9); + if per_unit >= 1.0 { + (per_unit, 1.0) + } else { + (1.0, 1.0 / per_unit) + } + } + }; + (ratio, standard) + } + + /// `PAPERUPDATE`: when the printer that just answered does not list the + /// dialog's sheet, either switch to the printer's default sheet (1) or + /// keep the sheet and say so (0). + fn reconcile_sheet_with_printer(&mut self, printer: &str) { + let d = &self.plot_dialog; + let Some(caps) = d.printer_media.as_ref() else { + return; + }; + if caps.media.is_empty() { + return; + } + let paper = plot_dialog_paper(d); + if caps.margins_for(&paper).is_some() { + return; + } + let sheet = paper.display(); + if d.paper_update == 1 { + let Some(default) = caps + .default_paper .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, + .or_else(|| caps.media.first().map(|media| media.paper.clone())) + else { + return; }; - 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(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; - } - } - } - - self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry_no_blocks(); + let d = &mut self.plot_dialog; + d.paper = default.canonical.to_string(); + (d.paper_width_mm, d.paper_height_mm) = default.sheet_mm(plot_dialog_orientation(d)); + let default = default.display(); self.command_line.push_info( crate::tf!( - "Page setup: {w:.1}×{h:.1} mm area={plot_area} \ - center={center} rot={rotation}°" + "{printer} does not support {sheet}; switched to its default sheet \ + {default} (PAPERUPDATE = 1)." + ) + .as_ref(), + ); + } else { + self.command_line.push_info( + crate::tf!( + "{printer} does not support {sheet}; the sheet is kept (PAPERUPDATE = 0)." ) .as_ref(), ); } } + /// Spell the picked scale in the custom fields. + fn refresh_custom_scale_fields(&mut self) { + let ((paper, drawing), _) = self.dialog_plot_scale_ratio(); + let d = &mut self.plot_dialog; + d.custom_scale_paper = trim_decimals(&format!("{paper:.6}")); + d.custom_scale_drawing = trim_decimals(&format!("{drawing:.6}")); + } + + /// Make the typed custom ratio the plot scale: the scale list's name for + /// it when it has one, else the ratio itself. + fn adopt_custom_scale(&mut self) { + let d = &self.plot_dialog; + if d.area == "Layout" { + return; + } + let (Ok(paper), Ok(drawing)) = ( + d.custom_scale_paper.trim().parse::(), + d.custom_scale_drawing.trim().parse::(), + ) else { + return; + }; + if !(paper > 0.0 && drawing > 0.0 && paper.is_finite() && drawing.is_finite()) { + return; + } + let conversion = + self.tabs[self.active_tab].scene.scale_family_unit_mm() / d.paper_units.to_mm(1.0); + let factor = paper / drawing / conversion; + let d = &mut self.plot_dialog; + d.scale = scale_name_for_factor(&d.scales, factor).unwrap_or_else(|| { + let name = ratio_scale_name(factor); + if !d.scales.iter().any(|(n, _)| n == &name) { + d.scales.push((name.clone(), factor)); + d.scales + .sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + } + name + }); + } + + /// The plot origin in millimetres from the sheet's corner: the dialog's + /// offsets in the page-setup unit, counted from the printable area's + /// corner unless `PLOTOFFSET` says from the paper edge. + fn dialog_plot_origin_mm(&self) -> (f64, f64) { + let d = &self.plot_dialog; + let x = d.paper_units.to_mm(d.offset_x.trim().parse::().unwrap_or(0.0)); + let y = d.paper_units.to_mm(d.offset_y.trim().parse::().unwrap_or(0.0)); + if d.plot_offset_from_edge { + return (x, y); + } + let ps = self.plot_settings_from_dialog(self.dialog_base_settings()); + let m = ps.margins; + let (left, bottom, ..) = crate::scene::rotated_margins( + (m.left, m.bottom, m.right, m.top), + ps.rotation.to_code(), + ); + (x + left, y + bottom) + } + + /// The settings the dialog edits on top of: the named page setup it was + /// loaded from, else the active layout's own, else a fresh metric sheet. + /// Everything the dialog has no control for (viewport-border and + /// paper-update flags, the plot view handle, the paper unit that fixes + /// the layout's coordinate system) is carried over from here untouched. + fn dialog_base_settings(&self) -> acadrust::objects::PlotSettings { + use acadrust::objects::{PlotPaperUnits, PlotSettings}; + let scene = &self.tabs[self.active_tab].scene; + self.plot_setup_template + .clone() + .or_else(|| scene.plot_settings_for(&scene.current_layout)) + .unwrap_or_else(|| { + let mut settings = PlotSettings::new(""); + settings.paper_units = PlotPaperUnits::Millimeters; + settings + }) + } + + /// The page setup the dialog describes, written the way the file format + /// expects it: the portrait medium with the orientation in `rotation`, + /// standard scales by their code, custom ones as a readable ratio in the + /// layout's own paper unit. Named page setups and the layout's embedded + /// settings both come from here so the two can never drift apart. + fn plot_settings_from_dialog( + &self, + mut ps: acadrust::objects::PlotSettings, + ) -> acadrust::objects::PlotSettings { + use crate::io::paper_catalog::Orientation; + use acadrust::objects::{ + PlotPaperUnits, PlotType, ScaledType, ShadePlotMode, ShadePlotResolutionLevel, + }; + let d = &self.plot_dialog; + // Names and margins are decided against the stored settings before + // any field is overwritten. + let paper_size = paper_name_for_settings(d, &ps); + let printer_name = device_name_for_settings(d, &ps); + let margins = margins_for_settings(d, &ps); + let same_medium = paper_unchanged(d, &ps) && ps.paper_width > 0.0 && ps.paper_height > 0.0; + ps.paper_size = paper_size; + ps.printer_name = printer_name; + ps.margins = margins; + // An unchanged sheet keeps the medium exactly as the drawing stores + // it — its driver's precise dimensions in the orientation the driver + // defines it (a PDF driver lists landscape media of its own) — and + // only the rotation says how the sheet is plotted on it. A new + // selection is written as the catalogue's portrait medium. + let natural = if same_medium { + if ps.paper_width > ps.paper_height { + Orientation::Landscape + } else { + Orientation::Portrait + } + } else { + let (width, height) = plot_dialog_paper(d).portrait_mm(); + ps.paper_width = width.max(1.0); + ps.paper_height = height.max(1.0); + Orientation::Portrait + }; + ps.rotation = plot_dialog_rotation(d, natural); + let layout_area = d.area == "Layout"; + 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); + } + } + ps.flags.plot_centered = d.center && !layout_area; + // Offsets are typed in the page-setup unit; the file keeps millimetres. + let (origin_x, origin_y) = if layout_area { + (0.0, 0.0) + } else { + ( + d.paper_units.to_mm(d.offset_x.trim().parse::().unwrap_or(0.0)), + d.paper_units.to_mm(d.offset_y.trim().parse::().unwrap_or(0.0)), + ) + }; + ps.origin_x = origin_x; + ps.origin_y = origin_y; + ps.paper_units = match d.paper_units { + crate::io::paper_catalog::PaperUnits::Inches => PlotPaperUnits::Inches, + crate::io::paper_catalog::PaperUnits::Millimeters => PlotPaperUnits::Millimeters, + }; + if d.fit_to_paper && !layout_area { + ps.set_scale_to_fit(); + // The commercial application marks fitted plots with the + // "standard scale" bit and leaves the ratio it computed. + ps.flags.use_standard_scale = true; + } else { + // A layout plot goes through the same path as the other areas: + // the dialog holds it at 1:1, which the unit conversion turns + // into whatever keeps the paper space's unit intact. + let ((numerator, denominator), standard) = self.dialog_plot_scale_ratio(); + // The commercial application leaves the "standard scale" bit + // clear even for a standard code and keeps the ratio in the + // numerator/denominator pair; readers that trust the bit would + // otherwise take the stored factor as the ratio itself. + ps.scale_type = standard.unwrap_or(ScaledType::CustomScale); + ps.flags.use_standard_scale = false; + ps.scale_numerator = numerator; + ps.scale_denominator = denominator; + ps.standard_scale_factor = + stored_scale_factor(numerator / denominator, ps.paper_units); + } + 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.apply_plot_styles; + ps.flags.show_plot_styles = d.show_plot_styles; + ps.flags.draw_viewports_first = d.paperspace_last; + ps.flags.plot_hidden = d.hide_paperspace; + ps.shade_plot_mode = match d.shade.as_str() { + "2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe, + "Hidden Line" => ShadePlotMode::Hidden, + "As displayed" => ShadePlotMode::AsDisplayed, + _ => ShadePlotMode::Rendered, + }; + ps.shade_plot_resolution = match d.quality.as_str() { + "Low" => ShadePlotResolutionLevel::Draft, + "High" => ShadePlotResolutionLevel::Presentation, + _ => ShadePlotResolutionLevel::Normal, + }; + ps.shade_plot_dpi = 300; + ps + } + + /// Write the dialog's page setup 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. + pub(super) fn apply_plot_page_settings(&mut self) { + let i = self.active_tab; + let layout_name = self.tabs[i].scene.current_layout.clone(); + if layout_name == "Model" { + return; + } + let ps = self.plot_settings_from_dialog(self.dialog_base_settings()); + let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog); + let plot_area = self.plot_dialog.area.clone(); + let center = ps.flags.plot_centered; + let rotation = ps.rotation.to_degrees() as i32; + self.tabs[i] + .scene + .set_layout_plot_settings(&layout_name, &ps); + // Layout limits are the sheet in paper-space units, which the + // settings just written define, placed the way the commercial + // application places it: the printable area's corner is the paper + // space origin, so the sheet starts at minus its displayed margins. + let units_per_mm = self.tabs[i].scene.paper_space_unit_factor().max(1e-9); + let m = ps.margins; + let (left, bottom, ..) = crate::scene::rotated_margins( + (m.left, m.bottom, m.right, m.top), + ps.rotation.to_code(), + ); + let (x0, y0) = (-left * units_per_mm, -bottom * units_per_mm); + let (lw, lh) = (w * units_per_mm, h * units_per_mm); + for obj in self.tabs[i].scene.document.objects.values_mut() { + if let acadrust::objects::ObjectType::Layout(layout) = obj { + if layout.name == layout_name { + layout.min_limits = (x0, y0); + layout.max_limits = (x0 + lw, y0 + lh); + layout.min_extents = (x0, y0, 0.0); + layout.max_extents = (x0 + lw, y0 + lh, 0.0); + break; + } + } + } + + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_geometry_no_blocks(); + self.command_line.push_info( + crate::tf!( + "Page setup: {w:.1}×{h:.1} mm area={plot_area} \ + center={center} rot={rotation}°" + ) + .as_ref(), + ); + } + pub(in crate::app) fn on_plot_export_path_some( &mut self, path: std::path::PathBuf, @@ -2959,9 +3459,15 @@ impl OpenCADStudio { .into_iter() .map(|(name, _, factor)| (name, factor)) .collect(); + // This load only refreshes paper/scale context from the layout; + // the device the user just plotted with must survive it. + let kept_printer = self.plot_dialog.printer.clone(); + let kept_to_file = self.plot_dialog.to_file; if let Some(settings) = self.tabs[i].scene.effective_plot_settings() { self.load_plotsettings_into_dialog(&settings); } + self.plot_dialog.printer = kept_printer; + self.plot_dialog.to_file = kept_to_file; } let Some(page) = self.direct_plot_page() else { @@ -3125,6 +3631,7 @@ impl OpenCADStudio { } else { self.plot_dialog.paper_space = true; self.plot_window = None; + self.plot_dialog.window = None; self.load_plotsettings_into_dialog(&page_setup); } let dialog = self.plot_dialog.clone(); @@ -3206,7 +3713,7 @@ impl OpenCADStudio { }) .map_err(|error| crate::tf!("Export failed: {error}").into_owned()) }; - self.run_print_all_work(dialog.background, work) + self.run_print_all_work(dialog.background_publish, work) } pub(super) fn on_print_all_print(&mut self) -> Task { @@ -3324,8 +3831,7 @@ impl OpenCADStudio { // Only the explicit upside-down choice rotates layout content here. let rotation = dialog_rotation; let centered = self.plot_dialog.center; - let origin_x = self.plot_dialog.offset_x.parse::().unwrap_or(0.0); - let origin_y = self.plot_dialog.offset_y.parse::().unwrap_or(0.0); + let (origin_x, origin_y) = self.dialog_plot_origin_mm(); let (scale, offset_x, offset_y) = if plot_extents { let (min_x, min_y, max_x, max_y) = @@ -3576,7 +4082,7 @@ impl OpenCADStudio { /// Open the full Plot / Print dialog, seeding its state from the active /// layout's plot settings and the printers found on the system. pub(super) fn on_plot_dialog_open(&mut self) -> Task { - use crate::io::paper_sizes::Orientation; + use crate::io::paper_catalog::Orientation; let previous = self.plot_dialog.clone(); let scales: Vec<(String, f64)> = self.tabs[self.active_tab] .scene @@ -3598,7 +4104,17 @@ impl OpenCADStudio { .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(); + match crate::io::print_to_printer::list_printers() { + Ok(printers) => { + d.printers = printers; + d.printers_error = None; + } + Err(error) => { + d.printers = Vec::new(); + d.printers_error = Some(error); + } + } + d.default_printer = crate::io::plot_device::default_printer_name(); d.plot_styles = crate::io::plot_style::available_ctb_names(); d.scales = scales; d.plot_views = plot_views; @@ -3610,7 +4126,8 @@ impl OpenCADStudio { d.scale = one_to_one.clone(); } d.paper_space = paper_space; - d.paper = self.plot_format.label().to_string(); + d.paper = self.plot_paper.canonical.to_string(); + (d.paper_width_mm, d.paper_height_mm) = self.plot_paper.sheet_mm(self.plot_orientation); d.orientation = match self.plot_orientation { Orientation::Portrait => "Portrait", Orientation::Landscape => "Landscape", @@ -3647,9 +4164,20 @@ impl OpenCADStudio { self.plot_dialog.scale_lw = false; } self.refresh_page_setups(); + if let Some(error) = self.plot_dialog.printers_error.clone() { + self.command_line + .push_warning(crate::tf!("Could not list printers: {error}").as_ref()); + } self.plot_prev = Some(previous); let cur = self.tabs[self.active_tab].scene.current_layout.clone(); let layout_entry = format!("*{cur}*"); + // The auto-applied page setup must not reset the output device: a + // drawing rarely stores one, so honouring it would snap the dialog + // back to the system default on every open. The user's last choice + // (persisted across sessions with the rest of the dialog) survives + // unless the setup names an explicit device of its own. + let kept_printer = self.plot_dialog.printer.clone(); + let kept_to_file = self.plot_dialog.to_file; if self.tabs[self.active_tab] .scene .plot_settings_for(&cur) @@ -3659,6 +4187,19 @@ impl OpenCADStudio { } else { self.select_page_setup(crate::ui::window::plot::SETUP_PREV); } + let setup_named_a_device = + self.plot_dialog.printer.is_some() || self.plot_dialog.to_file; + if !setup_named_a_device { + self.plot_dialog.printer = kept_printer; + self.plot_dialog.to_file = kept_to_file; + } + // A persisted plot window (saved with the dialog settings) restores + // across sessions; a freshly loaded one wins so the two stay in sync. + if self.plot_window.is_none() { + self.plot_window = self.plot_dialog.window; + } else { + self.plot_dialog.window = self.plot_window; + } 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(); @@ -3681,7 +4222,194 @@ impl OpenCADStudio { } } self.active_modal = Some(crate::app::ModalKind::Plot); - Task::none() + self.request_printer_media() + } + + /// Keep the dialog's view of the selected printer's media current: use the + /// session cache when the printer was asked before, otherwise ask it in + /// the background and clear the stale answer meanwhile. PDF output and + /// the default printer list the catalogue instead. + fn request_printer_media(&mut self) -> Task { + let d = &mut self.plot_dialog; + let Some(printer) = (!d.to_file).then(|| d.printer.clone()).flatten() else { + d.printer_media = None; + return Task::none(); + }; + if let Some(caps) = crate::io::plot_device::cached_printer_capabilities(&printer) { + d.printer_media = Some(caps); + return Task::none(); + } + d.printer_media = None; + let name = printer.clone(); + background_task( + move || crate::io::plot_device::printer_capabilities(&printer), + move |caps| Message::PlotDlg(crate::ui::window::plot::PlotDlgMsg::PrinterMedia(name, caps)), + ) + } + + /// The inline custom-sheet editor of the Plot dialog. A sheet is added + /// to (or replaced in) the persisted list under its media name and + /// selected at once; the list is saved immediately so it survives a + /// crash the way a plotter configuration would. + fn on_custom_paper_msg(&mut self, msg: crate::ui::window::plot::CustomPaperMsg) { + use crate::ui::window::plot::{CustomPaperDraft, CustomPaperMsg as C, MarginSide}; + let d = &mut self.plot_dialog; + match msg { + C::Open => { + let paper = plot_dialog_paper(d); + let existing = d + .custom_papers + .iter() + .find(|custom| custom.canonical() == paper.canonical); + let margins = match existing { + Some(custom) => custom.margins_mm(), + None => plot_dialog_device(d).margins_mm(&paper, &d.custom_papers), + }; + let mut draft = CustomPaperDraft::from_paper(&paper, margins); + // A catalogue sheet is only the starting size; the new sheet + // gets its own name (the source application's generic one when left blank). + if existing.is_none() { + draft.name.clear(); + } + d.custom_editor = Some(draft); + } + C::Cancel => d.custom_editor = None, + C::Name(value) => Self::edit_custom_draft(d, |draft| draft.name = value), + C::Width(value) => Self::edit_custom_draft(d, |draft| draft.width = value), + C::Height(value) => Self::edit_custom_draft(d, |draft| draft.height = value), + C::Units(value) => Self::edit_custom_draft(d, |draft| draft.inches = value == "Inches"), + C::Margin(side, value) => Self::edit_custom_draft(d, |draft| { + let index = match side { + MarginSide::Left => 0, + MarginSide::Bottom => 1, + MarginSide::Right => 2, + MarginSide::Top => 3, + }; + draft.margins[index] = value; + }), + C::Add => { + let Some(draft) = d.custom_editor.as_mut() else { + return; + }; + match draft.build() { + Ok(custom) => { + let canonical = custom.canonical(); + d.custom_papers.retain(|existing| existing.canonical() != canonical); + d.custom_papers.push(custom); + d.custom_editor = None; + d.paper = canonical; + let (w, h) = plot_dialog_sheet_mm(d); + d.paper_width_mm = w; + d.paper_height_mm = h; + self.save_config(); + } + Err(error) => draft.error = Some(error), + } + } + C::Remove => { + let selected = plot_dialog_paper(d).canonical.into_owned(); + let before = d.custom_papers.len(); + d.custom_papers.retain(|custom| custom.canonical() != selected); + if d.custom_papers.len() != before { + self.save_config(); + } + } + } + } + + fn edit_custom_draft( + d: &mut crate::ui::window::plot::PlotDialogState, + edit: impl FnOnce(&mut crate::ui::window::plot::CustomPaperDraft), + ) { + if let Some(draft) = d.custom_editor.as_mut() { + edit(draft); + draft.error = None; + } + } + + /// Properties… for the selected printer. + /// + /// On CUPS platforms (Linux, macOS) the driver's options are listed in an + /// in-line editor and applied to every job for that printer through + /// `lp -o`. On Windows the driver's own document-properties sheet is + /// shown and its result stored as the printer's preferences, which the + /// print job honours. Without a named printer, or where the options + /// cannot be listed, the platform's printer settings open instead. + fn on_printer_properties(&mut self) -> Task { + // "Default" is a real printer: its preferences sheet is the one to + // open, not the system's printer list. + let chosen = self + .plot_dialog + .printer + .clone() + .or_else(|| self.plot_dialog.default_printer.clone()); + let Some(printer) = chosen.filter(|_| !self.plot_dialog.to_file) else { + self.open_printer_settings_fallback(); + return Task::none(); + }; + #[cfg(target_os = "windows")] + { + return self.edit_windows_printer_preferences(printer); + } + #[allow(unreachable_code)] + { + self.plot_dialog.printer_editor = Some(crate::ui::window::plot::PrinterOptionsDraft { + printer: printer.clone(), + options: None, + choices: Default::default(), + error: None, + }); + let name = printer.clone(); + background_task( + move || crate::io::print_to_printer::printer_options(&printer), + move |result| { + Message::PlotDlg(crate::ui::window::plot::PlotDlgMsg::PrinterOptionsLoaded( + name, result, + )) + }, + ) + } + } + + /// The driver's document-properties sheet, owned by the main window so + /// it sits on top of the plot dialog. + #[cfg(target_os = "windows")] + fn edit_windows_printer_preferences(&mut self, printer: String) -> Task { + let Some(window) = self.main_window else { + self.open_printer_settings_fallback(); + return Task::none(); + }; + iced::window::run(window, move |w| { + use iced::window::raw_window_handle::RawWindowHandle; + let owner = match w.window_handle().ok().map(|h| h.as_raw()) { + Some(RawWindowHandle::Win32(handle)) => handle.hwnd.get(), + _ => 0, + }; + match crate::io::print_to_printer::edit_printer_preferences(&printer, owner) { + Ok(true) => Ok(crate::tf!("Printing preferences saved for {printer}.").into_owned()), + Ok(false) => Ok(crate::t!("Printing preferences unchanged.").into_owned()), + Err(error) => Err(error), + } + }) + .then(|result| { + Task::done(Message::BackgroundIoFinished( + result.map(|message| message), + false, + )) + }) + } + + /// The platform's printer settings surface: a settings panel on Linux, + /// the printers control panel on Windows, System Settings on macOS. + fn open_printer_settings_fallback(&mut self) { + match crate::io::print_to_printer::open_printer_properties( + self.plot_dialog.printer.as_deref(), + ) { + Ok(()) => self + .command_line + .push_info(crate::t!("Opened printer properties.").as_ref()), + Err(error) => self.command_line.push_error(&error), + } } fn normalize_common_plot_dialog(&mut self) { @@ -3706,6 +4434,9 @@ impl OpenCADStudio { Task::none() } M::Printer(s) => { + // Driver choices belong to a printer; the editor closes when + // the target changes. + self.plot_dialog.printer_editor = None; if s == OUT_PDF { self.plot_dialog.to_file = true; } else if s == OUT_DEFAULT { @@ -3715,19 +4446,89 @@ impl OpenCADStudio { self.plot_dialog.to_file = false; self.plot_dialog.printer = Some(s); } + self.save_config(); + self.request_printer_media() + } + M::PrinterMedia(printer, caps) => { + let d = &mut self.plot_dialog; + if !d.to_file && d.printer.as_deref() == Some(printer.as_str()) { + d.printer_media = caps; + self.reconcile_sheet_with_printer(&printer); + } Task::none() } - M::PrinterProperties => { - match crate::io::print_to_printer::open_printer_properties( - self.plot_dialog.printer.as_deref(), - ) { - Ok(()) => self - .command_line - .push_info(crate::t!("Opened printer properties.").as_ref()), - Err(error) => self.command_line.push_error(&error), + M::CustomPaper(msg) => { + self.on_custom_paper_msg(msg); + Task::none() + } + M::PrinterProperties => self.on_printer_properties(), + M::Import(msg) => self.on_page_setup_import(msg), + M::PrinterOptionsLoaded(printer, result) => { + if let Some(draft) = self.plot_dialog.printer_editor.as_mut() { + if draft.printer == printer { + match result { + Ok(options) => { + // Start from the remembered choices, then the + // driver's defaults. + let remembered = self + .plot_dialog + .driver_options + .get(&printer) + .cloned() + .unwrap_or_default(); + let draft = self.plot_dialog.printer_editor.as_mut().unwrap(); + draft.choices = options + .iter() + .map(|option| { + let choice = remembered + .get(&option.key) + .filter(|c| option.choices.iter().any(|x| &x.keyword == *c)) + .cloned() + .unwrap_or_else(|| option.default.clone()); + (option.key.clone(), choice) + }) + .collect(); + draft.options = Some(options); + } + Err(error) => draft.error = Some(error), + } + } } Task::none() } + M::PrinterOptionSet(key, choice) => { + if let Some(draft) = self.plot_dialog.printer_editor.as_mut() { + draft.choices.insert(key, choice); + } + Task::none() + } + M::PrinterOptionsReset => { + if let Some(draft) = self.plot_dialog.printer_editor.as_mut() { + if let Some(options) = &draft.options { + draft.choices = options + .iter() + .map(|option| (option.key.clone(), option.default.clone())) + .collect(); + } + } + Task::none() + } + M::PrinterOptionsApply => { + if let Some(draft) = self.plot_dialog.printer_editor.take() { + let overrides = draft.overrides(); + if overrides.is_empty() { + self.plot_dialog.driver_options.remove(&draft.printer); + } else { + self.plot_dialog.driver_options.insert(draft.printer.clone(), overrides); + } + self.save_config(); + } + Task::none() + } + M::PrinterOptionsCancel => { + self.plot_dialog.printer_editor = None; + Task::none() + } M::Paper(s) => { self.plot_dialog.paper = s; let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog); @@ -3768,9 +4569,44 @@ impl OpenCADStudio { M::Scale(s) => { if self.plot_dialog.area != "Layout" { self.plot_dialog.scale = s; + self.refresh_custom_scale_fields(); } Task::none() } + M::PaperUnits(s) => { + use crate::io::paper_catalog::PaperUnits; + let units = if s == "Inches" { + PaperUnits::Inches + } else { + PaperUnits::Millimeters + }; + let d = &mut self.plot_dialog; + if units != d.paper_units { + // The offsets are physical distances: re-spell them in the + // new unit. The scale converts with the unit on its own. + let convert = |text: &str| { + text.parse::() + .map(|value| units.from_mm(d.paper_units.to_mm(value))) + .map(|value| trim_decimals(&format!("{value:.6}"))) + .unwrap_or_else(|_| text.to_string()) + }; + d.offset_x = convert(&d.offset_x); + d.offset_y = convert(&d.offset_y); + d.paper_units = units; + self.refresh_custom_scale_fields(); + } + Task::none() + } + M::CustomScalePaper(s) => { + self.plot_dialog.custom_scale_paper = s; + self.adopt_custom_scale(); + Task::none() + } + M::CustomScaleDrawing(s) => { + self.plot_dialog.custom_scale_drawing = s; + self.adopt_custom_scale(); + Task::none() + } M::Quality(s) => { self.plot_dialog.quality = s; Task::none() @@ -3819,6 +4655,8 @@ impl OpenCADStudio { d.paperspace_last = !d.paperspace_last } PlotFlag::Stamp => d.stamp = !d.stamp, + PlotFlag::HidePaperspace => d.hide_paperspace = !d.hide_paperspace, + PlotFlag::SaveToLayout => d.save_to_layout = !d.save_to_layout, _ => {} } Task::none() @@ -3832,18 +4670,22 @@ impl OpenCADStudio { self.plot_dialog.apply_plot_styles = false; self.plot_dialog.show_plot_styles = false; self.plot_dialog.style_missing = false; + self.plot_dialog.style_error = None; } 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.plot_dialog.style_error = None; + self.report_plot_style_warnings(&table); self.active_plot_style = Some(table); } Err(error) => { self.plot_dialog.style_name = name; self.plot_dialog.style_missing = true; self.command_line.push_error(&error); + self.plot_dialog.style_error = Some(error); } } } @@ -3877,7 +4719,8 @@ impl OpenCADStudio { self.plot_dialog.paper_space = true; self.plot_dialog.area = "Layout".into(); } - Task::none() + // The setup may name a different printer. + self.request_printer_media() } M::SetCurrent => { self.apply_dialog_to_layout(); @@ -4017,7 +4860,7 @@ impl OpenCADStudio { } } - fn refresh_page_setups(&mut self) { + pub(super) fn refresh_page_setups(&mut self) { use crate::ui::window::plot::{SETUP_NONE, SETUP_PREV}; let scene = &self.tabs[self.active_tab].scene; // / , then layouts (`*name*`), then named setups. @@ -4030,7 +4873,7 @@ impl OpenCADStudio { /// Apply a page-setup list selection to the editor. Handles the pseudo /// entries (`` / ``), layout rows (`*name*`) and named /// setups. - fn select_page_setup(&mut self, name: &str) { + pub(super) fn select_page_setup(&mut self, name: &str) { use crate::ui::window::plot::{SETUP_NONE, SETUP_PREV}; self.plot_dialog.selected_setup = name.to_string(); if name == SETUP_NONE { @@ -4039,10 +4882,11 @@ impl OpenCADStudio { 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(); + let a4 = crate::io::paper_catalog::default_paper(); + d.paper = a4.canonical.to_string(); d.orientation = "Landscape".into(); - d.paper_width_mm = 297.0; - d.paper_height_mm = 210.0; + (d.paper_width_mm, d.paper_height_mm) = + a4.sheet_mm(crate::io::paper_catalog::Orientation::Landscape); d.area = if is_model { "Window".into() } else { @@ -4051,12 +4895,15 @@ impl OpenCADStudio { d.center = true; d.offset_x = "0.0".into(); d.offset_y = "0.0".into(); + d.paper_units = crate::io::paper_catalog::PaperUnits::Millimeters; d.upside_down = false; + d.hide_paperspace = false; d.fit_to_paper = is_model; d.scale_lw = false; d.scale = scale_name_for_factor(&d.scales, 1.0) .or_else(|| d.scales.first().map(|(name, _)| name.clone())) .unwrap_or_else(|| "1:1".into()); + self.refresh_custom_scale_fields(); } else if name == SETUP_PREV { if let Some(prev) = self.plot_prev.clone() { self.plot_dialog.copy_settings_from(&prev); @@ -4088,98 +4935,14 @@ impl OpenCADStudio { /// Build a `PlotSettings` from the current dialog fields (for saving a named /// page setup). + /// The dialog's values as a named page setup. fn dialog_to_plotsettings(&self) -> acadrust::objects::PlotSettings { - use acadrust::objects::{ - PlotPaperUnits, PlotRotation, PlotSettings, PlotType, ScaledType, ShadePlotMode, - ShadePlotResolutionLevel, - }; - let d = &self.plot_dialog; - 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 - } - }; - ps.paper_width = w; - ps.paper_height = h; - ps.paper_size = d.paper.clone(); - 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); - } - } - ps.flags.plot_centered = d.center && d.area != "Layout"; - ps.origin_x = if d.area == "Layout" { - 0.0 - } else { - d.offset_x.parse::().unwrap_or(0.0) - }; - ps.origin_y = if d.area == "Layout" { - 0.0 - } else { - d.offset_y.parse::().unwrap_or(0.0) - }; - let rot: i16 = if d.upside_down { 180 } else { 0 }; - ps.rotation = match rot { - 90 => PlotRotation::Degrees90, - 180 => PlotRotation::Degrees180, - 270 => PlotRotation::Degrees270, - _ => PlotRotation::None, - }; - 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 { - let factor = plot_dialog_scale_factor(d); - 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() - } else { - d.printer.clone().unwrap_or_default() - }; - 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.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 = d.shade == "Hidden Line"; - ps.shade_plot_mode = match d.shade.as_str() { - "2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe, - "Hidden Line" => ShadePlotMode::Hidden, - "As displayed" => ShadePlotMode::AsDisplayed, - _ => ShadePlotMode::Rendered, - }; - ps.shade_plot_resolution = match d.quality.as_str() { - "Low" => ShadePlotResolutionLevel::Draft, - "High" => ShadePlotResolutionLevel::Presentation, - _ => ShadePlotResolutionLevel::Normal, - }; - ps.shade_plot_dpi = 300; - ps + self.plot_settings_from_dialog(self.dialog_base_settings()) } /// Load a `PlotSettings` into the dialog editor fields. fn load_plotsettings_into_dialog(&mut self, ps: &acadrust::objects::PlotSettings) { + use crate::io::paper_catalog::PaperUnits; 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() { @@ -4189,17 +4952,21 @@ impl OpenCADStudio { ps.plot_window.upper_right_x, ps.plot_window.upper_right_y, )); + self.plot_dialog.window = self.plot_window; } + let mut style_error = None; if !ps.current_style_sheet.is_empty() && self .active_plot_style .as_ref() .is_none_or(|table| !table.name.eq_ignore_ascii_case(&ps.current_style_sheet)) { - if let Ok(table) = - crate::io::plot_style::PlotStyleTable::load_named(&ps.current_style_sheet) - { - self.active_plot_style = Some(table); + match crate::io::plot_style::PlotStyleTable::load_named(&ps.current_style_sheet) { + Ok(table) => { + self.report_plot_style_warnings(&table); + self.active_plot_style = Some(table); + } + Err(error) => style_error = Some(error), } } let active_style_name = self @@ -4220,16 +4987,24 @@ impl OpenCADStudio { && active_style_name .as_deref() .is_some_and(|name| name.eq_ignore_ascii_case(&style_name)); - let (mut paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); - if !matches!( - paper.as_str(), - "A4" | "A3" | "A2" | "A1" | "A0" | "Letter" | "Legal" | "Tabloid" - ) && !ps.paper_size.is_empty() - { - paper = ps.paper_size.clone(); + // The stored media name wins (it is what the source application wrote); a drawing + // without a usable name is matched by size, and an unknown size keeps + // its own dimensions as a custom sheet. The sheet's orientation is the + // stored width/height order; the rotation below may flip it again. + let paper = crate::io::paper_catalog::from_drawing( + &ps.paper_size, + ps.paper_width, + ps.paper_height, + ); + let orient = if ps.paper_width >= ps.paper_height { + "Landscape" + } else { + "Portrait" } + .to_string(); + let family_unit_mm = self.tabs[self.active_tab].scene.scale_family_unit_mm(); let d = &mut self.plot_dialog; - d.paper = paper; + d.paper = paper.canonical.to_string(); d.paper_width_mm = ps.paper_width.max(1.0); d.paper_height_mm = ps.paper_height.max(1.0); d.orientation = orient; @@ -4250,8 +5025,13 @@ impl OpenCADStudio { d.area = "Extents".into(); } d.center = ps.flags.plot_centered; - d.offset_x = format!("{:.2}", ps.origin_x); - d.offset_y = format!("{:.2}", ps.origin_y); + d.paper_units = match ps.paper_units { + acadrust::objects::PlotPaperUnits::Inches => PaperUnits::Inches, + _ => PaperUnits::Millimeters, + }; + d.offset_x = trim_decimals(&format!("{:.4}", d.paper_units.from_mm(ps.origin_x))); + d.offset_y = trim_decimals(&format!("{:.4}", d.paper_units.from_mm(ps.origin_y))); + d.hide_paperspace = ps.flags.plot_hidden; let deg = ps.rotation.to_degrees() as i32; if matches!(deg, 90 | 270) { d.orientation = if d.orientation == "Portrait" { @@ -4263,22 +5043,39 @@ impl OpenCADStudio { } d.upside_down = matches!(deg, 180 | 270); 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 + let ratio = ps.scale_numerator / ps.scale_denominator; + let stored_factor = if ratio.is_finite() && ratio > 0.0 { + ratio + } else if ps.flags.use_standard_scale + && ps.standard_scale_factor.is_finite() + && ps.standard_scale_factor > 0.0 + { + ratio_from_stored_scale_factor(ps.standard_scale_factor, ps.paper_units) + } else { + ps.scale_type.scale_factor() + }; + // The file's factor is per the layout's paper unit; the picker's are + // per the drawing's scale-family unit (see `plot_settings_from_dialog`). + let target_factor = if stored_factor.is_finite() && stored_factor > 0.0 { + stored_factor * plot_paper_unit_mm(ps.paper_units) / family_unit_mm } else { 1.0 }; - if !d.fit_to_paper || !d.scales.iter().any(|(name, _)| name == &d.scale) { - d.scale = scale_name_for_factor(&d.scales, target_factor) - .or_else(|| scale_name_for_factor(&d.scales, 1.0)) + if !d.fit_to_paper { + d.scale = scale_name_for_factor(&d.scales, target_factor).unwrap_or_else(|| { + // A scale the drawing has no name for stays what it is rather + // than snapping to 1:1; the ratio entry disappears again with + // the next scale-list refresh once it is no longer selected. + let name = ratio_scale_name(target_factor); + d.scales.push((name.clone(), target_factor)); + d.scales + .sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + name + }); + } else if !d.scales.iter().any(|(name, _)| name == &d.scale) { + // A fitted plot's stored ratio is what the fit computed, not a + // choice; the picker just needs a valid entry behind it. + d.scale = scale_name_for_factor(&d.scales, 1.0) .or_else(|| d.scales.first().map(|(name, _)| name.clone())) .unwrap_or_else(|| "1:1".into()); } @@ -4304,51 +5101,50 @@ impl OpenCADStudio { _ => "Normal", } .into(); - if ps.printer_name.to_ascii_lowercase().contains("pdf") { - d.to_file = true; - d.printer = None; - } else { - d.to_file = false; - d.printer = (!ps.printer_name.is_empty()).then(|| ps.printer_name.clone()); + match crate::io::plot_device::PlotDevice::from_stored_name(&ps.printer_name).0 { + crate::io::plot_device::PlotDevice::Pdf => { + d.to_file = true; + d.printer = None; + } + crate::io::plot_device::PlotDevice::Printer(name) => { + d.to_file = false; + d.printer = Some(name); + } + crate::io::plot_device::PlotDevice::None => { + d.to_file = false; + d.printer = None; + } } 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; + d.style_error = if d.style_missing { style_error } else { None }; + self.refresh_custom_scale_fields(); + } + + /// Tell the user what a plot style table's loader had to tolerate. + pub(in crate::app) fn report_plot_style_warnings( + &mut self, + table: &crate::io::plot_style::PlotStyleTable, + ) { + for warning in &table.load_warnings { + let name = &table.name; + self.command_line.push_warning(&format!("{name}: {warning}")); + } } /// Copy transient paper/scale choices out of the dialog without changing /// the active layout. fn sync_dialog_plot_runtime(&mut self) { - use crate::io::paper_sizes::{Orientation, PaperSize}; - let d = self.plot_dialog.clone(); - let paper = match d.paper.as_str() { - "A3" => PaperSize::A3, - "A2" => PaperSize::A2, - "A1" => PaperSize::A1, - "A0" => PaperSize::A0, - "Letter" => PaperSize::Letter, - "Legal" => PaperSize::Legal, - "Tabloid" => PaperSize::Tabloid, - _ => PaperSize::A4, - }; - let orient = if d.orientation == "Portrait" { - Orientation::Portrait - } else { - Orientation::Landscape - }; - self.plot_format = paper; - self.plot_orientation = orient; + let d = &self.plot_dialog; + self.plot_paper = plot_dialog_paper(d); + self.plot_orientation = plot_dialog_orientation(d); } fn apply_dialog_to_layout(&mut self) { self.sync_dialog_plot_runtime(); - let d = self.plot_dialog.clone(); - 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); - self.apply_plot_page_settings(sheet_w, sheet_h, &d.area, d.center, off_x, off_y, rotation); + self.apply_plot_page_settings(); } /// Open a preview PDF, export a PDF, or send the job to the chosen printer. @@ -4362,9 +5158,15 @@ impl OpenCADStudio { } // Remember the user's print preferences across sessions. self.save_config(); - // Preview and normal printing must not resize/mutate the live layout; - // the runtime paper/scale choices still drive this one plot operation. - self.sync_dialog_plot_runtime(); + // A preview never touches the layout; a sent plot writes the dialog + // into it when "Save changes to layout" is on, so the page setup + // remembers the last plot the way other applications expect. The + // runtime paper/scale choices drive this one plot operation either way. + if !preview && d.save_to_layout && d.paper_space { + self.apply_dialog_to_layout(); + } else { + self.sync_dialog_plot_runtime(); + } self.active_modal = None; self.reset_modal_geometry(); @@ -4458,6 +5260,12 @@ impl OpenCADStudio { printer: d.printer.clone(), copies: d.copies.trim().parse::().unwrap_or(1).max(1), quality: Some(d.quality.clone()), + driver_options: d + .printer + .as_ref() + .and_then(|printer| d.driver_options.get(printer)) + .map(|options| options.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(), } } @@ -4467,7 +5275,12 @@ impl OpenCADStudio { crate::io::pdf_export::PdfPlotOptions { object_lineweights: d.lineweights, scale_lineweights: d.scale_lw && !d.fit_to_paper, - transparency: d.transparency, + // PLOTTRANSPARENCYOVERRIDE: 0 never, 2 always, 1 as the dialog says. + transparency: match d.transparency_override { + 0 => false, + 2 => true, + _ => d.transparency, + }, stamp: d.stamp, merge_lines: d.merge_lines, } @@ -4590,7 +5403,7 @@ impl OpenCADStudio { /// 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::{window_to_sheet, PlotScale}; + use crate::io::paper_catalog::{window_to_sheet, PlotScale}; let i = self.active_tab; let (x0, y0, x1, y1) = window; if (x1 - x0) < 1e-6 || (y1 - y0) < 1e-6 { @@ -4616,15 +5429,16 @@ impl OpenCADStudio { }; let (scale, centered_x, centered_y) = window_to_sheet((win_w, win_h), (sheet_w, sheet_h), scale_sel); + let (origin_x, origin_y) = self.dialog_plot_origin_mm(); let target_x = if self.plot_dialog.center { centered_x } else { - self.plot_dialog.offset_x.parse::().unwrap_or(0.0) + origin_x }; let target_y = if self.plot_dialog.center { centered_y } else { - self.plot_dialog.offset_y.parse::().unwrap_or(0.0) + origin_y }; let scene = &self.tabs[i].scene; let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); @@ -4750,3 +5564,926 @@ impl OpenCADStudio { ) } } + +#[cfg(test)] +mod plot_paper_tests { + use crate::app::{Message, OpenCADStudio}; + use crate::ui::window::plot::PlotDlgMsg; + + /// A drawing on `Layout1` whose page setup names its sheet the way a + /// Windows printer driver does — a bare `A4` with landscape dimensions. + fn app_with_printer_named_sheet() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let _ = app.update(Message::LayoutSwitch("Layout1".into())); + let i = app.active_tab; + let mut ps = app.tabs[i] + .scene + .plot_settings_for("Layout1") + .expect("a fresh layout carries plot settings"); + ps.paper_size = "A4".into(); + ps.paper_width = 297.0; + ps.paper_height = 210.0; + // Such drivers spell the orientation in the dimensions, not the rotation. + ps.rotation = acadrust::objects::PlotRotation::None; + assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps)); + app + } + + fn layout_paper(app: &OpenCADStudio) -> (String, f64, f64) { + let ps = layout_settings(app); + (ps.paper_size, ps.paper_width, ps.paper_height) + } + + fn layout_settings(app: &OpenCADStudio) -> acadrust::objects::PlotSettings { + app.tabs[app.active_tab] + .scene + .plot_settings_for("Layout1") + .unwrap() + } + + fn layout_rotation(app: &OpenCADStudio) -> acadrust::objects::PlotRotation { + layout_settings(app).rotation + } + + /// Store the drawing's plot settings for `Layout1` after `edit` touched them. + fn edit_layout_settings( + app: &mut OpenCADStudio, + edit: impl FnOnce(&mut acadrust::objects::PlotSettings), + ) { + let i = app.active_tab; + let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + edit(&mut ps); + assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps)); + } + + #[test] + fn a_new_drawing_starts_with_a_compatible_default_layout() { + use acadrust::objects::{PlotRotation, PlotType, ScaledType}; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let _ = app.update(Message::LayoutSwitch("Layout1".into())); + let ps = layout_settings(&app); + assert_eq!(ps.paper_size, "ISO_A4_(210.00_x_297.00_MM)"); + assert_eq!((ps.paper_width, ps.paper_height), (210.0, 297.0)); + assert_eq!(ps.rotation, PlotRotation::Degrees90); + assert_eq!(ps.printer_name, "none_device"); + assert_eq!(ps.plot_type, PlotType::Layout); + assert_eq!(ps.scale_type, ScaledType::OneToOne); + assert!(ps.flags.use_standard_scale && ps.flags.print_lineweights); + // The sheet on screen is the landscape A4 the rotation describes. + let ((x0, y0), (x1, y1)) = app.tabs[app.active_tab].scene.paper_limits().unwrap(); + assert_eq!((x1 - x0, y1 - y0), (297.0, 210.0)); + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_dialog.orientation, "Landscape"); + assert!(!app.plot_dialog.upside_down); + } + + #[test] + fn orientation_is_stored_as_a_rotation_of_the_medium() { + use acadrust::objects::PlotRotation as R; + use crate::ui::window::plot::PlotFlag; + // A newly picked sheet is the catalogue's portrait medium… + let portrait_medium = [ + ("Portrait", false, R::None), + ("Landscape", false, R::Degrees90), + ("Portrait", true, R::Degrees180), + ("Landscape", true, R::Degrees270), + ]; + // …while the drawing's own landscape `A4` medium is kept, so the + // same choices rotate relative to it. + let landscape_medium = [ + ("Landscape", false, R::None), + ("Portrait", false, R::Degrees90), + ("Landscape", true, R::Degrees180), + ("Portrait", true, R::Degrees270), + ]; + for (pick_a3, cases) in [(true, portrait_medium), (false, landscape_medium)] { + for (orientation, upside_down, rotation) in cases { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + if pick_a3 { + let a3 = "ISO_A3_(297.00_x_420.00_MM)"; + let _ = app.on_plot_dlg(PlotDlgMsg::Paper(a3.into())); + } + let _ = app.on_plot_dlg(PlotDlgMsg::Orientation(orientation.into())); + if upside_down { + let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::UpsideDown)); + } + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = layout_settings(&app); + let medium = if pick_a3 { (297.0, 420.0) } else { (297.0, 210.0) }; + assert_eq!((ps.paper_width, ps.paper_height), medium, "{orientation}"); + assert_eq!(ps.rotation, rotation, "{orientation} upside-down={upside_down}"); + // The next dialog reads the same choices back out of the rotation. + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_dialog.orientation, orientation); + assert_eq!(app.plot_dialog.upside_down, upside_down); + let sheet = super::plot_dialog_sheet_mm(&app.plot_dialog); + let long_side = medium.0.max(medium.1); + assert_eq!(sheet.0 > sheet.1, orientation == "Landscape", "{sheet:?}"); + assert_eq!(sheet.0.max(sheet.1), long_side); + } + } + } + + #[test] + fn standard_scales_are_written_by_code_and_others_as_ratios() { + use acadrust::objects::ScaledType; + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper)); + assert!(!app.plot_dialog.fit_to_paper); + let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = layout_settings(&app); + assert_eq!(ps.scale_type, ScaledType::OneToHundred); + assert!( + !ps.flags.use_standard_scale, + "the bit stays clear, as the source application writes it" + ); + assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 100.0)); + // The stored factor follows the millimetre convention: ratio × 25.4. + assert!((ps.standard_scale_factor - 0.254).abs() < 1e-12); + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_dialog.scale, "1:100"); + // A ratio the format has no code for is a custom scale, spelled as the ratio. + let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:3".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = layout_settings(&app); + assert_eq!(ps.scale_type, ScaledType::CustomScale); + assert!(!ps.flags.use_standard_scale); + assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 3.0)); + // …and survives a reopen even though the drawing's scale list lacks it. + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_dialog.scale, "1:3"); + assert!((super::plot_dialog_scale_factor(&app.plot_dialog) - 1.0 / 3.0).abs() < 1e-12); + // Fit to paper is its own scale type and carries the bit; a Layout + // plot goes back to the 1:1 the dialog holds it at. + let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper)); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = layout_settings(&app); + assert_eq!(ps.scale_type, ScaledType::ScaleToFit); + assert!(ps.flags.use_standard_scale); + let _ = app.on_plot_dlg(PlotDlgMsg::Area("Layout".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = layout_settings(&app); + assert_eq!(ps.scale_type, ScaledType::OneToOne); + assert!(!ps.flags.use_standard_scale); + assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 1.0)); + assert!((ps.standard_scale_factor - 25.4).abs() < 1e-12); + } + + #[test] + fn a_metre_drawing_stores_the_millimetres_it_really_plots_per_unit() { + use acadrust::objects::ScaledType; + let mut app = app_with_printer_named_sheet(); + // Insertion units: metres. "1:100" then plots 10 mm per drawing unit. + app.tabs[app.active_tab].scene.document.header.insertion_units = 6; + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper)); + let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into())); + assert!((super::plot_dialog_scale_factor(&app.plot_dialog) - 10.0).abs() < 1e-9); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = layout_settings(&app); + assert_eq!(ps.scale_type, ScaledType::CustomScale, "not the format's 1:100"); + assert_eq!((ps.scale_numerator, ps.scale_denominator), (10.0, 1.0)); + assert!((ps.standard_scale_factor - 254.0).abs() < 1e-9); + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_dialog.scale, "1:100"); + } + + #[test] + fn an_inch_layout_keeps_its_unit_and_converts_the_scale() { + use acadrust::objects::{PlotPaperUnits, ScaledType}; + let mut app = app_with_printer_named_sheet(); + edit_layout_settings(&mut app, |ps| ps.paper_units = PlotPaperUnits::Inches); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper)); + let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = layout_settings(&app); + assert_eq!( + ps.paper_units, + PlotPaperUnits::Inches, + "the layout's unit is not ours to change" + ); + // 1 mm = 100 units is 1 inch = 2540 units on an inch page setup; the + // standard code still names the choice, as the source application does. + assert_eq!(ps.scale_type, ScaledType::OneToHundred); + assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 2540.0)); + assert!((ps.standard_scale_factor - 1.0 / 2540.0).abs() < 1e-15); + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_dialog.scale, "1:100"); + // A layout plot keeps the paper space in millimetres: "1:1" is + // `1 in = 25.4 units`. + let _ = app.on_plot_dlg(PlotDlgMsg::Area("Layout".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = layout_settings(&app); + assert_eq!(ps.scale_type, ScaledType::OneToOne); + assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 25.4)); + assert!((ps.standard_scale_factor - 1.0 / 25.4).abs() < 1e-15); + } + + #[test] + fn a_named_setup_and_the_layout_settings_are_written_alike() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::FitToPaper)); + let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:50".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Flag(crate::ui::window::plot::PlotFlag::UpsideDown)); + let named = app.dialog_to_plotsettings(); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let layout = layout_settings(&app); + assert_eq!(layout.paper_size, named.paper_size); + assert_eq!(layout.printer_name, named.printer_name); + assert_eq!(layout.margins, named.margins); + assert_eq!( + (layout.paper_width, layout.paper_height), + (named.paper_width, named.paper_height) + ); + assert_eq!(layout.rotation, named.rotation); + assert_eq!(layout.plot_type, named.plot_type); + assert_eq!((layout.origin_x, layout.origin_y), (named.origin_x, named.origin_y)); + assert_eq!(layout.scale_type, named.scale_type); + assert_eq!( + (layout.scale_numerator, layout.scale_denominator), + (named.scale_numerator, named.scale_denominator) + ); + assert_eq!(layout.standard_scale_factor, named.standard_scale_factor); + assert_eq!(layout.flags, named.flags); + assert_eq!(layout.shade_plot_mode, named.shade_plot_mode); + assert_eq!(layout.shade_plot_resolution, named.shade_plot_resolution); + assert_eq!(named.rotation, acadrust::objects::PlotRotation::Degrees270); + assert_eq!(named.scale_type, acadrust::objects::ScaledType::OneToFifty); + } + + #[test] + fn flags_the_dialog_does_not_edit_survive_a_round_trip() { + let mut app = app_with_printer_named_sheet(); + edit_layout_settings(&mut app, |ps| { + ps.flags.plot_viewport_borders = true; + ps.flags.update_paper = true; + ps.flags.zoom_to_paper_on_update = true; + ps.flags.unknown_bits = 0x8000; + ps.paper_image_origin_x = 3.5; + }); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = layout_settings(&app); + assert!(ps.flags.plot_viewport_borders); + assert!(ps.flags.update_paper && ps.flags.zoom_to_paper_on_update); + assert_eq!(ps.flags.unknown_bits, 0x8000); + assert_eq!(ps.paper_image_origin_x, 3.5); + } + + /// Open the metric page-setup fixture written by the commercial + /// application (see `tests/fixtures/plot/README.md`). + #[cfg(not(target_arch = "wasm32"))] + fn app_with_fixture() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/plot/page-setups-metric.dxf" + ); + // json! escapes the path: a Windows CARGO_MANIFEST_DIR carries + // backslashes, and a raw format! would put invalid \X escapes in the + // JSON string. + let reply = app.automation_op( + &serde_json::json!({"op": "open", "path": path}).to_string(), + ); + assert_eq!(reply["ok"], true, "{reply}"); + app + } + + /// The page-setup fields the file format stores, minus handles. + fn stored_fields(ps: &acadrust::objects::PlotSettings) -> String { + format!( + "{} | {} | {:?} | {:.6} {:.6} | {:.6} {:.6} {:.6} {:.6} | {:.6} {:.6} | {} {} \ + | {:.6} / {:.6} | {:.9} | {:?} | {:?} | {:?} {:?} {}", + ps.printer_name, + ps.paper_size, + ps.paper_units, + ps.paper_width, + ps.paper_height, + ps.margins.left, + ps.margins.bottom, + ps.margins.right, + ps.margins.top, + ps.origin_x, + ps.origin_y, + ps.rotation.to_code(), + ps.plot_type.to_code(), + ps.scale_numerator, + ps.scale_denominator, + ps.standard_scale_factor, + ps.scale_type, + ps.flags, + ps.shade_plot_mode, + ps.shade_plot_resolution, + ps.shade_plot_dpi + ) + } + + /// Every layout of the fixture survives "open the dialog, apply" without + /// a single stored field changing — the dialog reads the source + /// application's conventions and writes them back the same way. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn fixture_page_setups_round_trip_through_the_dialog_unchanged() { + use acadrust::objects::{PlotRotation, ScaledType}; + let mut app = app_with_fixture(); + let a3_medium = (297.0106506347656, 419.9889831542968); + let arch_d_medium = (914.4000244140626, 609.5999755859375); + let expectations = [ + ("A4 1-100", PlotRotation::None, (297.0, 210.0), true), + ("A3 - plotter", PlotRotation::Degrees90, a3_medium, false), + ("ARCH D - PLOTTER", PlotRotation::None, arch_d_medium, true), + ]; + for (layout, rotation, medium, pdf) in expectations { + let orientation = "Landscape"; + let _ = app.update(Message::LayoutSwitch(layout.into())); + let i = app.active_tab; + let before = app.tabs[i].scene.plot_settings_for(layout).unwrap(); + assert_eq!(before.rotation, rotation, "{layout}"); + assert_eq!((before.paper_width, before.paper_height), medium, "{layout}"); + assert_eq!(before.scale_type, ScaledType::OneToOne, "{layout}"); + let _ = app.on_plot_dialog_open(); + let d = &app.plot_dialog; + assert_eq!(d.orientation, orientation, "{layout}"); + assert!(!d.upside_down); + assert_eq!(d.area, "Layout"); + assert_eq!(d.scale, "1:1", "{layout}: a 1:1 plot in either paper unit"); + assert_eq!(d.to_file, pdf, "{layout}"); + assert_eq!(super::plot_dialog_sheet_mm(d).0 > super::plot_dialog_sheet_mm(d).1, true); + assert!(d.lineweights && d.apply_plot_styles && d.paperspace_last, "{layout}"); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let after = app.tabs[i].scene.plot_settings_for(layout).unwrap(); + assert_eq!(stored_fields(&after), stored_fields(&before), "{layout}"); + } + // The system printer's sheet keeps its driver's margins and lands on + // the layout the way the source application places it: the medium's + // top margin on the left, its left margin at the bottom. + let _ = app.update(Message::LayoutSwitch("A3 - plotter".into())); + let ((x0, y0), (x1, y1)) = app.tabs[app.active_tab].scene.paper_limits().unwrap(); + assert!((x0 + 20.02365112304687).abs() < 1e-9 && (y0 + 4.995333194732666).abs() < 1e-9); + assert!((x1 - 399.96533203125).abs() < 1e-6 && (y1 - 292.015317440033).abs() < 1e-6); + } + + /// Picking a new sheet on the fixture's PDF layout writes our canonical + /// portrait medium with the orientation in the rotation and the PDF + /// driver's margins for that medium. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn fixture_pdf_layout_takes_a_new_sheet_in_canonical_form() { + use acadrust::objects::{PaperMargin, PlotRotation}; + let mut app = app_with_fixture(); + let _ = app.update(Message::LayoutSwitch("A4 1-100".into())); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[app.active_tab].scene.plot_settings_for("A4 1-100").unwrap(); + assert_eq!(ps.printer_name, "DWG To PDF.pc3"); + assert_eq!(ps.paper_size, "ISO_A3_(297.00_x_420.00_MM)"); + assert_eq!((ps.paper_width, ps.paper_height), (297.0, 420.0)); + assert_eq!(ps.rotation, PlotRotation::Degrees90); + assert_eq!(ps.margins, PaperMargin::new(5.0, 17.0, 6.0, 18.0)); + // The sheet sits at minus its displayed margins: top on the left, + // left at the bottom. + let ((x0, y0), _) = app.tabs[app.active_tab].scene.paper_limits().unwrap(); + assert!((x0 + 18.0).abs() < 1e-9 && (y0 + 5.0).abs() < 1e-9, "{x0} {y0}"); + } + + #[test] + fn hide_paperspace_objects_is_its_own_flag() { + use crate::ui::window::plot::PlotFlag; + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + assert!(!app.plot_dialog.hide_paperspace); + // Shade plot "Hidden Line" is a shade mode, not this flag. + let _ = app.on_plot_dlg(PlotDlgMsg::Shade("Hidden Line".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + assert!(!layout_settings(&app).flags.plot_hidden); + let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::HidePaperspace)); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + assert!(layout_settings(&app).flags.plot_hidden); + let _ = app.on_plot_dialog_open(); + assert!(app.plot_dialog.hide_paperspace); + } + + #[test] + fn switching_the_page_setup_unit_respells_offsets_and_keeps_the_output() { + use acadrust::objects::{PlotPaperUnits, ScaledType}; + use crate::ui::window::plot::PlotFlag; + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let sheet_before = app.tabs[app.active_tab].scene.paper_limits().unwrap(); + let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::FitToPaper)); + let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::Center)); + let _ = app.on_plot_dlg(PlotDlgMsg::OffsetX("25.4".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::PaperUnits("Inches".into())); + let d = &app.plot_dialog; + assert_eq!(d.offset_x, "1", "25.4 mm is 1 inch"); + assert_eq!((d.custom_scale_paper.as_str(), d.custom_scale_drawing.as_str()), ("1", "2540")); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = layout_settings(&app); + assert_eq!(ps.paper_units, PlotPaperUnits::Inches); + assert!((ps.origin_x - 25.4).abs() < 1e-9, "the file keeps millimetres"); + assert_eq!(ps.scale_type, ScaledType::OneToHundred); + assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 2540.0)); + // Back to millimetres: the same physical setup, spelled in mm. + let _ = app.on_plot_dlg(PlotDlgMsg::PaperUnits("Millimeters".into())); + assert_eq!(app.plot_dialog.offset_x, "25.4"); + assert_eq!(app.plot_dialog.custom_scale_drawing, "100"); + let _ = app.on_plot_dlg(PlotDlgMsg::Area("Layout".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::PaperUnits("Inches".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + // An inch page setup over the millimetre paper space leaves the + // sheet where it was. + let ps = layout_settings(&app); + assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 25.4)); + let sheet_after = app.tabs[app.active_tab].scene.paper_limits().unwrap(); + let size = |((x0, y0), (x1, y1)): ((f64, f64), (f64, f64))| (x1 - x0, y1 - y0); + let (before, after) = (size(sheet_before), size(sheet_after)); + assert!((before.0 - after.0).abs() < 1e-9 && (before.1 - after.1).abs() < 1e-9); + } + + #[test] + fn custom_scale_fields_follow_and_drive_the_picker() { + use acadrust::objects::ScaledType; + use crate::ui::window::plot::PlotFlag; + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::FitToPaper)); + let _ = app.on_plot_dlg(PlotDlgMsg::Scale("1:100".into())); + let d = &app.plot_dialog; + assert_eq!((d.custom_scale_paper.as_str(), d.custom_scale_drawing.as_str()), ("1", "100")); + // A typed ratio the list knows is picked under its name… + let _ = app.on_plot_dlg(PlotDlgMsg::CustomScalePaper("2".into())); + assert_eq!(app.plot_dialog.scale, "1:50"); + // …one it does not know becomes a ratio entry of its own. + let _ = app.on_plot_dlg(PlotDlgMsg::CustomScaleDrawing("6".into())); + assert_eq!(app.plot_dialog.scale, "1:3"); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = layout_settings(&app); + assert_eq!(ps.scale_type, ScaledType::CustomScale); + assert_eq!((ps.scale_numerator, ps.scale_denominator), (1.0, 3.0)); + // Half-typed or nonsense values leave the scale alone. + let _ = app.on_plot_dlg(PlotDlgMsg::CustomScaleDrawing("".into())); + assert_eq!(app.plot_dialog.scale, "1:3"); + let _ = app.on_plot_dlg(PlotDlgMsg::CustomScaleDrawing("-4".into())); + assert_eq!(app.plot_dialog.scale, "1:3"); + } + + #[test] + fn the_plot_origin_counts_from_the_printable_corner_unless_plotoffset_says_edge() { + use crate::ui::window::plot::PlotFlag; + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Area("Extents".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::Center)); + let _ = app.on_plot_dlg(PlotDlgMsg::OffsetX("10".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::OffsetY("20".into())); + // The PDF driver's margins on the drawing's landscape A4 medium: + // 5 left, 17 bottom. + assert_eq!(app.dialog_plot_origin_mm(), (15.0, 37.0)); + let _ = app.run_command_line("PLOTOFFSET 1"); + assert_eq!(app.dialog_plot_origin_mm(), (10.0, 20.0)); + let _ = app.on_plot_dlg(PlotDlgMsg::PaperUnits("Inches".into())); + let (x, y) = app.dialog_plot_origin_mm(); + assert!((x - 10.0).abs() < 1e-4, "unit change keeps the distance"); + assert!((y - 20.0).abs() < 1e-4); + } + + #[test] + fn sending_a_plot_saves_the_dialog_into_the_layout_when_asked() { + use crate::ui::window::plot::PlotFlag; + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + assert!(app.plot_dialog.save_to_layout, "on by default, as expected of a page setup"); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into())); + // A preview never writes the layout. + let _ = app.on_plot_dlg(PlotDlgMsg::Preview); + assert_eq!(layout_paper(&app).0, "A4"); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Commit); + assert_eq!(layout_paper(&app).0, "ISO_A3_(297.00_x_420.00_MM)"); + // Switched off, a plot leaves the layout alone. + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Flag(PlotFlag::SaveToLayout)); + let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A2_(420.00_x_594.00_MM)".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::Commit); + assert_eq!(layout_paper(&app).0, "ISO_A3_(297.00_x_420.00_MM)"); + } + + #[test] + fn paperupdate_decides_what_happens_to_an_unsupported_sheet() { + use crate::io::paper_catalog::{resolve, Margins}; + use crate::io::plot_device::{PrinterCapabilities, PrinterMedia}; + let a3 = resolve("ISO_A3_(297.00_x_420.00_MM)").unwrap(); + let media = PrinterMedia { paper: a3.clone(), margins: Margins::uniform(3.0), borderless: false }; + let caps = std::sync::Arc::new(PrinterCapabilities { media: vec![media], default_paper: Some(a3) }); + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into())); + // PAPERUPDATE 0: the sheet is kept and the mismatch reported. + let media = |caps| PlotDlgMsg::PrinterMedia("OCS Test Printer".into(), Some(caps)); + let _ = app.on_plot_dlg(media(caps.clone())); + assert_eq!(app.plot_dialog.paper, "ISO_A4_(210.00_x_297.00_MM)"); + let last = app.command_line.history.last().map(|line| line.text.clone()); + assert!(last.as_deref().unwrap_or("").contains("PAPERUPDATE = 0"), "{last:?}"); + // PAPERUPDATE 1: the printer's default sheet takes over, in the + // dialog's orientation. + let _ = app.run_command_line("PAPERUPDATE 1"); + let _ = app.on_plot_dlg(media(caps)); + assert_eq!(app.plot_dialog.paper, "ISO_A3_(297.00_x_420.00_MM)"); + assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (420.0, 297.0)); + } + + #[test] + fn scale_names_parse_to_their_ratios() { + use crate::scene::Scene; + assert_eq!(Scene::parse_scale_name_ratio("1:250"), Some((1.0, 250.0))); + assert_eq!(Scene::parse_scale_name_ratio("2:1"), Some((2.0, 1.0))); + assert_eq!(Scene::parse_scale_name_ratio("1/8\" = 1'-0\""), Some((0.125, 12.0))); + assert_eq!(Scene::parse_scale_name_ratio("3/32\" = 1'-0\""), Some((0.09375, 12.0))); + assert_eq!(Scene::parse_scale_name_ratio("1'-0\" = 1'-0\""), Some((12.0, 12.0))); + assert_eq!(Scene::parse_scale_name_ratio("6\" = 1'-0\""), Some((6.0, 12.0))); + assert_eq!(Scene::parse_scale_name_ratio("Fit"), None); + assert_eq!(Scene::parse_scale_name_ratio("1:0"), None); + assert_eq!(super::ratio_scale_name(0.004), "1:250"); + assert_eq!(super::ratio_scale_name(25.4), "25.4:1"); + assert_eq!(super::ratio_scale_name(1.0 / 3.0), "1:3"); + } + + #[test] + fn dialog_shows_the_catalogue_sheet_for_a_driver_name() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_dialog.paper, "ISO_A4_(210.00_x_297.00_MM)"); + assert_eq!(app.plot_dialog.orientation, "Landscape"); + assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (297.0, 210.0)); + } + + #[test] + fn applying_an_unchanged_sheet_keeps_the_drawing_media_name() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let (name, w, h) = layout_paper(&app); + assert_eq!(name, "A4", "the driver's own spelling must survive the dialog"); + // …and so must the driver's landscape medium: unrotated, as stored. + assert_eq!((w, h), (297.0, 210.0)); + assert_eq!(layout_rotation(&app), acadrust::objects::PlotRotation::None); + } + + #[test] + fn picking_another_sheet_writes_the_canonical_name() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into())); + assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (420.0, 297.0)); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let (name, w, h) = layout_paper(&app); + assert_eq!(name, "ISO_A3_(297.00_x_420.00_MM)"); + assert_eq!((w, h), (297.0, 420.0)); + assert_eq!(layout_rotation(&app), acadrust::objects::PlotRotation::Degrees90); + // The next dialog remembers the sheet the user chose. + assert_eq!(app.plot_paper.canonical, "ISO_A3_(297.00_x_420.00_MM)"); + } + + #[test] + fn pdf_output_is_stored_as_the_source_app_pdf_driver() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(ps.printer_name, "DWG To PDF.pc3"); + // A changed device takes the PDF driver's printable area for the sheet. + assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0)); + } + + #[test] + fn legacy_pdf_label_is_upgraded_on_save() { + let mut app = app_with_printer_named_sheet(); + let i = app.active_tab; + let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + ps.printer_name = "Save to PDF file…".into(); + assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps)); + let _ = app.on_plot_dialog_open(); + assert!(app.plot_dialog.to_file, "the legacy label still means PDF output"); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(ps.printer_name, "DWG To PDF.pc3"); + } + + #[test] + fn a_plotter_configuration_from_source_app_is_preserved_with_its_margins() { + let mut app = app_with_printer_named_sheet(); + let i = app.active_tab; + let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + ps.printer_name = "DWF6 ePlot.pc3".into(); + ps.paper_size = "ISO_A4_(210.00_x_297.00_MM)".into(); + ps.margins = acadrust::objects::PaperMargin::new(5.793749, 17.793753, 5.793744, 17.793747); + assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps)); + let _ = app.on_plot_dialog_open(); + assert!(app.plot_dialog.to_file, "an unknown plotter plots to PDF here"); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let after = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(after.printer_name, "DWF6 ePlot.pc3"); + assert_eq!(after.margins, ps.margins, "the source application's driver margins survive an unchanged setup"); + } + + #[test] + fn changing_the_sheet_updates_the_printable_area() { + let mut app = app_with_printer_named_sheet(); + let i = app.active_tab; + let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + ps.printer_name = "DWG To PDF.pc3".into(); + ps.paper_size = "ISO_A4_(210.00_x_297.00_MM)".into(); + ps.margins = acadrust::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0); + assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps)); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_full_bleed_A4_(210.00_x_297.00_MM)".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(ps.paper_size, "ISO_full_bleed_A4_(210.00_x_297.00_MM)"); + assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(0.0, 1.0, 0.0, 1.0)); + let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0)); + assert_eq!((ps.paper_width, ps.paper_height), (297.0, 420.0)); + } + + #[test] + fn a_selected_printer_is_stored_by_name() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into())); + assert!(!app.plot_dialog.to_file); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(ps.printer_name, "OCS Test Printer"); + // Reopening resolves the stored name back to the same printer. + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_dialog.printer.as_deref(), Some("OCS Test Printer")); + // A media answer for a printer that is no longer selected is ignored. + let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into())); + let caps = std::sync::Arc::new(crate::io::plot_device::PrinterCapabilities::default()); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterMedia("OCS Test Printer".into(), Some(caps))); + assert!(app.plot_dialog.printer_media.is_none()); + } + + #[test] + fn a_custom_sheet_is_added_selected_persisted_and_written_with_its_margins() { + use crate::ui::window::plot::{CustomPaperMsg as C, MarginSide}; + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into())); + let custom = |m: C| PlotDlgMsg::CustomPaper(m); + let _ = app.on_plot_dlg(custom(C::Open)); + // Seeded from the selected ISO A4 on the PDF driver, nameless. + let draft = app.plot_dialog.custom_editor.clone().expect("editor open"); + assert_eq!((draft.width.as_str(), draft.height.as_str()), ("210", "297")); + assert_eq!(draft.margins, ["5", "17", "6", "18"]); + assert!(draft.name.is_empty()); + let _ = app.on_plot_dlg(custom(C::Name("Roll 24".into()))); + let _ = app.on_plot_dlg(custom(C::Width("1500".into()))); + let _ = app.on_plot_dlg(custom(C::Height("609.6".into()))); + let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Left, "3".into()))); + let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Top, "4".into()))); + let _ = app.on_plot_dlg(custom(C::Add)); + assert!(app.plot_dialog.custom_editor.is_none(), "Add closes the editor"); + assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)"); + assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (1500.0, 609.6)); + assert_eq!(app.current_config().plot.custom_papers.len(), 1, "persisted in the plot config"); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(ps.paper_size, "Roll_24_(609.60_x_1500.00_MM)"); + assert_eq!((ps.paper_width, ps.paper_height), (609.6, 1500.0)); + assert_eq!(ps.rotation, acadrust::objects::PlotRotation::Degrees90); + assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(3.0, 17.0, 6.0, 4.0)); + // Re-adding the same size replaces the definition instead of duplicating it. + let _ = app.on_plot_dlg(custom(C::Open)); + assert_eq!(app.plot_dialog.custom_editor.as_ref().map(|d| d.name.as_str()), Some("Roll 24")); + let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Left, "9".into()))); + let _ = app.on_plot_dlg(custom(C::Add)); + assert_eq!(app.plot_dialog.custom_papers.len(), 1); + assert_eq!(app.plot_dialog.custom_papers[0].margins.left, 9.0); + // Remove drops the definition; the sheet itself stays selectable. + let _ = app.on_plot_dlg(custom(C::Remove)); + assert!(app.plot_dialog.custom_papers.is_empty()); + assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)"); + } + + #[test] + fn an_invalid_custom_sheet_is_refused_with_a_reason() { + use crate::ui::window::plot::{CustomPaperError, CustomPaperMsg as C}; + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Open)); + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Width("-5".into()))); + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Add)); + let draft = app.plot_dialog.custom_editor.as_ref().expect("editor stays open"); + assert_eq!(draft.error, Some(CustomPaperError::Size)); + assert!(app.plot_dialog.custom_papers.is_empty()); + // Typing again clears the message. + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Width("500".into()))); + assert!(app.plot_dialog.custom_editor.as_ref().unwrap().error.is_none()); + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Cancel)); + assert!(app.plot_dialog.custom_editor.is_none()); + } + + // The driver-options editor is the CUPS flow; on Windows + // PrinterProperties routes to the system preferences dialog instead, so + // the editor under test never opens there. + #[cfg(not(target_os = "windows"))] + #[test] + fn driver_options_are_edited_remembered_and_sent_with_the_job() { + use crate::io::print_to_printer::parse_lpoptions; + const LPOPTIONS: &str = "MediaType/Media Type: *Stationery Photographic\n\ +ColorModel/Print Color Mode: *RGB Gray\n\ +cupsPrintQuality/Print Quality: *Normal High\n"; + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties); + let draft = app.plot_dialog.printer_editor.as_ref().expect("editor opens"); + assert_eq!(draft.printer, "OCS Test Printer"); + assert!(draft.options.is_none(), "options load in the background"); + // An answer for another printer is ignored; the right one fills the editor. + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded( + "Other".into(), + Ok(parse_lpoptions(LPOPTIONS)), + )); + assert!(app.plot_dialog.printer_editor.as_ref().unwrap().options.is_none()); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded( + "OCS Test Printer".into(), + Ok(parse_lpoptions(LPOPTIONS)), + )); + let draft = app.plot_dialog.printer_editor.as_ref().unwrap(); + assert_eq!(draft.options.as_ref().map(|o| o.len()), Some(3)); + assert_eq!(draft.choices["ColorModel"], "RGB"); + assert!(draft.overrides().is_empty(), "defaults are not overrides"); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionSet("ColorModel".into(), "Gray".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionSet("cupsPrintQuality".into(), "High".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsApply); + assert!(app.plot_dialog.printer_editor.is_none(), "Apply closes the editor"); + let remembered = &app.plot_dialog.driver_options["OCS Test Printer"]; + assert_eq!(remembered.len(), 2); + assert_eq!(remembered["ColorModel"], "Gray"); + // The job carries exactly those overrides… + let opts = app.plot_print_options(&app.plot_dialog); + assert_eq!( + opts.driver_options, + vec![("ColorModel".to_string(), "Gray".to_string()), ("cupsPrintQuality".to_string(), "High".to_string())] + ); + // …they survive in the config, and another printer gets none of them. + assert_eq!(app.current_config().plot.driver_options.len(), 1); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer("Another".into())); + assert!(app.plot_print_options(&app.plot_dialog).driver_options.is_empty()); + // Reopening the editor starts from the remembered choices; Reset then + // Apply forgets them. + let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded( + "OCS Test Printer".into(), + Ok(parse_lpoptions(LPOPTIONS)), + )); + assert_eq!(app.plot_dialog.printer_editor.as_ref().unwrap().choices["ColorModel"], "Gray"); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsReset); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsApply); + assert!(app.plot_dialog.driver_options.get("OCS Test Printer").is_none()); + // A failed listing is reported inside the editor, not lost. + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded( + "OCS Test Printer".into(), + Err("no cups".into()), + )); + assert_eq!(app.plot_dialog.printer_editor.as_ref().unwrap().error.as_deref(), Some("no cups")); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsCancel); + assert!(app.plot_dialog.printer_editor.is_none()); + } + + #[test] + fn an_unknown_sheet_from_a_drawing_keeps_its_own_dimensions() { + let mut app = app_with_printer_named_sheet(); + let i = app.active_tab; + let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + ps.paper_size = "Roll_24_(609.60_x_1500.00_MM)".into(); + ps.paper_width = 1500.0; + ps.paper_height = 609.6; + assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps)); + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)"); + assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (1500.0, 609.6)); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let (name, w, h) = layout_paper(&app); + assert_eq!(name, "Roll_24_(609.60_x_1500.00_MM)"); + // The drawing's own landscape medium stays as it was stored. + assert_eq!((w, h), (1500.0, 609.6)); + assert_eq!(layout_rotation(&app), acadrust::objects::PlotRotation::None); + } +} + +#[cfg(test)] +mod plot_device_persistence_tests { + use super::*; + + /// Reopening the Plot dialog must keep the user's printer. Layouts in + /// real drawings carry plot settings with no device, and the auto-applied + /// page setup used to reset the choice to the system default on every + /// open. + #[test] + fn reopening_the_plot_dialog_keeps_the_chosen_printer() { + let mut app = OpenCADStudio::new_for_test(); + // A real drawing's layout carries plot settings with no device; the + // auto-applied setup used to reset the choice to the default. + let mut ps = acadrust::objects::PlotSettings::default(); + ps.printer_name = String::new(); + let layout = app.tabs[app.active_tab].scene.current_layout.clone(); + assert!( + app.tabs[app.active_tab] + .scene + .set_layout_plot_settings(&layout, &ps), + "the test layout must accept plot settings" + ); + app.plot_dialog.printer = Some("Brother DCP-L2520D series".into()); + app.plot_dialog.to_file = false; + let _ = app.on_plot_dialog_open(); + assert_eq!( + app.plot_dialog.printer.as_deref(), + Some("Brother DCP-L2520D series"), + "the chosen printer must survive the auto-applied page setup" + ); + assert!(!app.plot_dialog.to_file); + } + + /// A layout whose plot settings name an explicit device wins over the + /// saved preference: selecting its setup is an explicit device choice. + #[test] + fn layout_device_overrides_the_saved_preference() { + let mut app = OpenCADStudio::new_for_test(); + app.plot_dialog.printer = Some("Brother DCP-L2520D series".into()); + let mut ps = acadrust::objects::PlotSettings::default(); + ps.printer_name = "Godex G500".into(); + app.load_plotsettings_into_dialog(&ps); + assert_eq!(app.plot_dialog.printer.as_deref(), Some("Godex G500")); + assert!(!app.plot_dialog.to_file); + } + + /// A persisted plot window (saved with the dialog settings) seeds the + /// runtime window when the dialog reopens, so `area == "Window"` keeps + /// plotting the same rectangle instead of reporting an empty area. + #[test] + fn persisted_window_seeds_the_dialog_on_reopen() { + let mut app = OpenCADStudio::new_for_test(); + assert!(app.plot_window.is_none()); + app.plot_dialog.area = "Window".into(); + app.plot_dialog.window = Some((-10.0, -5.0, 30.0, 15.0)); + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_window, Some((-10.0, -5.0, 30.0, 15.0))); + } + + /// A window freshly loaded from a layout's plot settings is mirrored into + /// the dialog so it persists with the next config save. + #[test] + fn layout_window_is_mirrored_into_the_dialog() { + let mut app = OpenCADStudio::new_for_test(); + let mut ps = acadrust::objects::PlotSettings::default(); + ps.plot_type = acadrust::objects::PlotType::Window; + ps.set_plot_window(1.0, 2.0, 3.0, 4.0); + app.load_plotsettings_into_dialog(&ps); + assert_eq!(app.plot_window, Some((1.0, 2.0, 3.0, 4.0))); + assert_eq!(app.plot_dialog.window, Some((1.0, 2.0, 3.0, 4.0))); + } + + /// A layout pointing at a PDF device switches the dialog to file output. + #[test] + fn pdf_device_switches_to_file_output() { + let mut app = OpenCADStudio::new_for_test(); + app.plot_dialog.printer = Some("Brother DCP-L2520D series".into()); + let mut ps = acadrust::objects::PlotSettings::default(); + ps.printer_name = "Microsoft Print to PDF".into(); + app.load_plotsettings_into_dialog(&ps); + assert!(app.plot_dialog.to_file); + assert!(app.plot_dialog.printer.is_none()); + } +} diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index d498abbac..3c30ccf62 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -26,6 +26,7 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool { | Message::MTextCaretMove(_) | Message::DeleteSelected | Message::ToggleSnapEnabled + | Message::ToggleSnap3dEnabled | Message::ToggleGrid | Message::ToggleOrtho | Message::ToggleGridSnap @@ -89,12 +90,15 @@ fn reorder_insertion_index(from: usize, to: usize, after: bool, len: usize) -> O } mod command; +mod context_menu; mod dialog; mod dynamic; mod file; +mod page_setup_import; mod style; mod util; mod viewport; +mod viewport_snap; impl OpenCADStudio { pub(in crate::app) fn reset_modal_geometry(&mut self) { @@ -173,6 +177,10 @@ impl OpenCADStudio { self.drafting_settings_state = None; self.drafting_settings_saved = None; } + if self.active_modal == Some(Options) { + self.options_saved = None; + self.options_close_confirm = false; + } #[cfg(not(target_arch = "wasm32"))] if self.active_modal == Some(FileInUse) { self.pending_save_failure = None; @@ -198,9 +206,21 @@ impl OpenCADStudio { self.attr_editor_tab = crate::ui::window::attribute_editor::AttrTab::Attribute; } Some(GeometricTolerance) => self.geometric_tolerance = None, + Some(Hyperlink) => { + self.hyperlink_editor_handles.clear(); + self.hyperlink_editor_url.clear(); + self.hyperlink_editor_description.clear(); + self.hyperlink_editor_mixed = false; + self.hyperlink_editor_dirty = false; + } // 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(), + Some(Aliases) => { + self.alias_editor_rows.clear(); + self.alias_pending_add = false; + self.alias_reset_confirm = false; + self.alias_close_confirm = false; + } Some(Shortcuts) => { self.shortcut_editor_rows.clear(); self.shortcut_capture_row = None; @@ -300,12 +320,26 @@ impl OpenCADStudio { if matches!(msg, Message::CommandEscape) || matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ESCAPE")) { + // Esc backs out of a pending ALIASEDIT draft first, mirroring + // the shortcut editor's capture cancel; the next Esc closes. + if self.active_modal == Some(super::ModalKind::Aliases) && self.alias_pending_add + { + return self.update(Message::AliasEditorDraftCancel); + } return self.update(Message::CloseModal); } if is_modal_blocked_key_msg(&msg) { return Task::none(); } } + // The open right-click context menu owns the keyboard the same way: + // arrows / Enter / mnemonic letters drive it, any other key closes it + // and falls through to the command line (the behaviour of commercial solutions). + if self.context_menu_open() { + if let Some(task) = self.intercept_context_menu_key(&msg) { + return task; + } + } #[cfg(not(target_arch = "wasm32"))] let command_before = self.tabs.get(self.active_tab).map(|tab| { (tab.id, tab.active_cmd.as_ref().map(|command| command.name().to_owned())) @@ -347,6 +381,9 @@ 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(); + // The Reference Manager watches the active drawing and re-scans when + // the palette is open on another tab's entries. + self.refresh_xref_manager_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). @@ -389,6 +426,7 @@ impl OpenCADStudio { self.block_palette.placing = None; } self.control_settle(); + self.sync_spacemouse(); task } @@ -404,6 +442,48 @@ impl OpenCADStudio { fn update_inner(&mut self, msg: Message) -> Task { match msg { + Message::SpaceMouseWake => self.on_spacemouse_wake(), + Message::SpaceMouseFrame(time) => { + self.spacemouse.frame( + time.saturating_duration_since(self.start).as_secs_f64() * 1000., + ); + Task::none() + } + Message::SpaceMouseFocus(id, focused) => { + if Some(id) == self.main_window { + self.spacemouse_focused = focused; + } + Task::none() + } + Message::SpaceMouseEnabled(enabled) => { + self.spacemouse_preferences.enabled = enabled; + Task::none() + } + Message::SpaceMouseMode(mode) => { + self.spacemouse_preferences.mode = mode; + Task::none() + } + Message::SpaceMousePanSpeed(speed) => { + self.spacemouse_preferences.pan_speed = speed.clamp(10, 300); + Task::none() + } + Message::SpaceMousePanReversed(reversed) => { + self.spacemouse_preferences.pan_reversed = reversed; + Task::none() + } + Message::SpaceMousePause => { + self.spacemouse_paused = !self.spacemouse_paused; + Task::none() + } + Message::SpaceMousePreferences => { + self.open_spacemouse_preferences(); + Task::none() + } + Message::SpaceMouseDriverSettings => self.open_spacemouse_driver_settings(), + Message::SpaceMouseDetails => { + self.spacemouse_details = !self.spacemouse_details; + Task::none() + } Message::ControlRequest(envelope) => { let (response, task) = self.control_request(envelope.request); envelope.reply.send(response); @@ -721,6 +801,14 @@ impl OpenCADStudio { Task::none() } + Message::SnapOverrideNone => { + self.snap_override_popup = None; + self.snapper.set_override_none(); + self.command_line + .push_info(crate::t!("Snap override: None (next pick only).").as_ref()); + Task::none() + } + Message::SnapOverrideMtp => { self.snap_override_popup = None; self.tabs[self.active_tab] @@ -983,6 +1071,93 @@ impl OpenCADStudio { } Task::none() } + + Message::ImageEmbedPick => { + Task::perform(crate::io::pick_embedded_image_file(), Message::ImageEmbedPickResult) + } + + Message::ImageEmbedPickResult(Ok(image)) => { + use crate::command::CadCommand; + use crate::modules::draw::draw::raster_image::ImageCommand; + self.command_line.push_output(crate::tf!( + "IMAGEEMBED \"{name}\": {w}×{h} px (embedded)", + name = image.name.as_str(), + w = image.pixel_width, + h = image.pixel_height, + ).as_ref()); + let cmd = ImageCommand::new_embedded(image); + let i = self.active_tab; + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + Task::none() + } + + Message::ImageEmbedPickResult(Err(e)) => { + if e != "Cancelled" { + self.command_line + .push_error(crate::tf!("IMAGEEMBED: {e}").as_ref()); + } + Task::none() + } + + Message::MissingFontsSourceChanged(url) => { + self.font_source_input = url; + Task::none() + } + Message::MissingFontsDownload => { + // Remember the source across sessions — and across drawings. + let source = self.font_source_input.trim().to_string(); + if self.font_source_url != source { + self.font_source_url = source.clone(); + self.save_config(); + } + let fonts = self.missing_fonts.take().unwrap_or_default(); + Task::perform( + async move { + let source = crate::io::font_repo::FontSource::from_url(&source); + crate::io::font_repo::download_fonts(&fonts, &source) + }, + Message::MissingFontsResult, + ) + } + Message::MissingFontsDismiss => { + self.missing_fonts = None; + self.close_active_modal(); + Task::none() + } + Message::MissingFontsResult(result) => { + self.missing_fonts = None; + self.close_active_modal(); + match result { + Ok(pairs) if pairs.is_empty() => { + self.command_line.push_error(crate::t!( + "None of the missing fonts are in the community repository yet. Contribute them at github.com/huaninstratech/OpenCADStudio/tree/main/fonts." + ).as_ref()); + } + Ok(pairs) => { + for (name, path) in &pairs { + self.command_line.push_output(crate::tf!( + "FONT Downloaded {name} → {path}", + path = path.display() + ).as_ref()); + } + // The downloaded files change glyph resolution for the + // whole drawing — reload it through the standard open + // pipeline so every wire is rebuilt with the real fonts. + let i = self.active_tab; + if let Some(path) = self.tabs[i].current_path.clone() { + return Task::done(Message::OpenExternal(path)); + } + self.command_line.push_info(crate::t!( + "Save and reopen the drawing to apply the new fonts." + ).as_ref()); + } + Err(e) => { + self.command_line.push_error(crate::tf!("Font download failed: {e}").as_ref()); + } + } + Task::none() + } Message::PdfAttachPick => Task::perform( async { let handle = crate::sys::file_dialog() @@ -1380,8 +1555,7 @@ impl OpenCADStudio { Message::ClearScene => { let i = self.active_tab; self.push_undo_snapshot(i, "CLEAR"); - self.tabs[i].scene.clear(); - crate::io::linetypes::populate_document(&mut self.tabs[i].scene.document); + self.tabs[i].scene.reset_to_new_drawing(); self.tabs[i].properties = PropertiesPanel::empty(); let doc_layers = self.tabs[i].scene.document.layers.clone(); let vp_info = self.tabs[i].scene.viewport_list(); @@ -1702,7 +1876,8 @@ impl OpenCADStudio { // MTEXT bodies), where the typed case is the content and // Space must stay in the buffer. let text_with_spaces = self.is_free_text_active(); - let s = if text_with_spaces { + let literal = self.command_line.literal_spaces || s.starts_with('>'); + let s = if text_with_spaces || literal { s } else { s.to_uppercase() @@ -1719,7 +1894,7 @@ impl OpenCADStudio { && s.contains(' ') { self.command_line.input = s; - return Task::batch(vec![sweep, self.update(Message::CommandSubmit)]); + return Task::batch(vec![sweep, self.on_command_submit()]); } let live_input = s.clone(); self.command_line.input = live_input.clone(); @@ -2162,6 +2337,269 @@ impl OpenCADStudio { Task::none() } + Message::ToggleXrefManager => { + self.show_external_references ^= true; + if self.show_external_references { + use crate::app::config::DockSide; + use crate::ui::dock::PanelId; + if self.dock.location(PanelId::ExternalReferences).is_none() { + self.dock.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX); + } + self.dock_expanded = Some(PanelId::ExternalReferences); + self.refresh_xref_manager(); + } + Task::none() + } + Message::XrefManagerRefresh => { + self.refresh_xref_manager(); + Task::none() + } + Message::XrefManagerSelect(index) => { + use crate::ui::window::xref_manager::SelectExtend; + // Normal GUI list behavior off the globally tracked + // modifiers (same source as the layer list): plain click + // selects one row, Ctrl/Cmd toggles, Shift extends a range. + let extend = if self.ctrl_down { + SelectExtend::Toggle + } else if self.shift_down { + SelectExtend::Range + } else { + SelectExtend::Single + }; + self.xref_manager.click_select(index, extend); + Task::none() + } + Message::XrefRowRightClick(index) => { + self.xref_manager.row_change_path_open = false; + self.xref_manager.right_click_select(index); + Task::none() + } + Message::XrefManagerToggleTree => { + self.xref_manager.toggle_tree(); + Task::none() + } + Message::XrefColGrab(i) => { + // Start a table-column divider drag; moves arrive via the + // header's mouse_area (XrefColMove), like the Layers + // Name-column divider rides ModalDragMove. + self.xref_col_drag = Some(i); + self.xref_col_last = None; + Task::none() + } + Message::XrefColMove(p) => { + if let Some(i) = self.xref_col_drag { + if let Some(last) = self.xref_col_last { + self.xref_manager.drag_col_by(i, p.x - last.x); + } + self.xref_col_last = Some(p); + } + Task::none() + } + Message::XrefColRelease => { + self.xref_col_drag = None; + self.xref_col_last = None; + Task::none() + } + Message::XrefSplitGrab => { + self.xref_split_drag = true; + self.xref_col_last = None; + Task::none() + } + Message::XrefSplitMove(p) => { + if self.xref_split_drag { + if let Some(last) = self.xref_col_last { + self.xref_manager.drag_table_by(p.y - last.y); + } + self.xref_col_last = Some(p); + } + Task::none() + } + Message::XrefSplitRelease => { + self.xref_split_drag = false; + self.xref_col_last = None; + Task::none() + } + Message::XrefManagerTogglePreview => { + self.xref_manager.show_preview ^= true; + Task::none() + } + Message::XrefManagerAttachMenu => { + self.xref_manager.attach_open ^= true; + self.xref_manager.refresh_open = false; + self.xref_manager.path_open = false; + Task::none() + } + Message::XrefManagerRefreshMenu => { + self.xref_manager.refresh_open ^= true; + self.xref_manager.attach_open = false; + self.xref_manager.path_open = false; + Task::none() + } + Message::XrefManagerPathMenu => { + self.xref_manager.path_open ^= true; + self.xref_manager.attach_open = false; + self.xref_manager.refresh_open = false; + Task::none() + } + Message::XrefHelpOpen => { + self.active_modal = Some(super::ModalKind::XrefHelp); + Task::none() + } + Message::XrefManagerDismissMenus => { + self.xref_manager.attach_open = false; + self.xref_manager.refresh_open = false; + self.xref_manager.path_open = false; + self.xref_manager.row_change_path_open = false; + Task::none() + } + Message::XrefPathPick => Task::perform( + async { + let handle = crate::sys::file_dialog() + .set_title(crate::t!("Select New Path").as_ref()) + .pick_file() + .await; + match handle { + Some(h) => Ok(crate::sys::handle_path(&h)), + None => Err("Cancelled".to_string()), + } + }, + Message::XrefPathPickResult, + ), + Message::XrefPathPickResult(Ok(path)) => { + // Select New Path applies the picked file to the single + // direct anchor entry (menu gates the rest). + let i = self.active_tab; + let anchor = self.xref_manager.anchor.and_then(|a| { + self.xref_manager + .entries + .get(a) + .filter(|_| !self.xref_manager.nested.contains(&a)) + .map(|e| (e.key, e.name.clone())) + }); + let Some((key, _)) = anchor else { + return Task::none(); + }; + let new_raw = path.to_string_lossy().into_owned(); + self.push_undo_snapshot(i, "XREF-PATH"); + match crate::io::xref::set_ref_path( + &mut self.tabs[i].scene.document, + key, + &new_raw, + ) { + Ok(name) => { + self.command_line.push_output(crate::tf!( + "XREF: Path set for \"{}\" — Reload to apply.", + name + ).as_ref()); + self.post_ref_op(i); + } + Err(msg) => self.command_line.push_error(msg.as_str()), + } + self.refresh_xref_manager(); + Task::none() + } + Message::XrefPathPickResult(Err(e)) => { + if e != "Cancelled" { + self.command_line.push_error(crate::tf!("XREF: {e}").as_ref()); + } + Task::none() + } + Message::XrefManagerReloadAll => { + self.xref_manager_reload_all(); + Task::none() + } + Message::XrefManagerToggleExpand(key) => { + self.xref_manager.toggle_expand(key); + Task::none() + } + Message::XrefManagerOp(op) => { + self.xref_manager_op(op); + Task::none() + } + Message::XrefRowOp(index, op) => { + // Open is not a palette op — it navigates to the file. + if op == crate::ui::window::xref_manager::XrefPaletteOp::Open { + self.xref_manager.right_click_select(index); + self.xref_manager.row_change_path_open = false; + if let Some(entry) = self.xref_manager.entries.get(index) { + if let Some(found) = entry.found_at.clone() { + let is_dwg = found.to_ascii_lowercase().ends_with(".dwg") + || found.to_ascii_lowercase().ends_with(".dxf"); + self.command_line.push_output(crate::tf!("XOPEN: opening \"{}\".", found).as_ref()); + if is_dwg { + return self.update(Message::OpenRecent(std::path::PathBuf::from(found))); + } else { + #[cfg(not(target_arch = "wasm32"))] + let _ = open::that_detached(&found); + } + } else { + self.command_line.push_error( + crate::t!("File not found — check the saved path.").as_ref(), + ); + } + } + return Task::none(); + } + // Attach prompts the file dialog to pick a drawing to attach. + if matches!(op, crate::ui::window::xref_manager::XrefPaletteOp::Attach) { + self.xref_manager.right_click_select(index); + self.xref_manager.row_change_path_open = false; + self.command_line.push_output(crate::t!("XATTACH").as_ref()); + return self.update(Message::XAttachPick); + } + // Row-scoped op: select the row first, then run the op. + self.xref_manager.right_click_select(index); + self.xref_manager.row_change_path_open = false; + self.xref_manager_op(op); + Task::none() + } + Message::XrefRowPathPick(index) => { + self.xref_manager.right_click_select(index); + self.xref_manager.row_change_path_open = false; + self.update(Message::XrefPathPick) + } + Message::XrefRowFindReplacePrompt(index) => { + self.xref_manager.right_click_select(index); + self.xref_manager.row_change_path_open = false; + let prefill = if let Some(entry) = self.xref_manager.entries.get(index) { + let saved = &entry.saved_path; + if let Some(parent) = std::path::Path::new(saved).parent().and_then(|p| p.to_str()) { + if !parent.is_empty() { + format!("XREF Path Find \"{}\" ", parent) + } else { + "XREF Path Find ".to_string() + } + } else { + "XREF Path Find ".to_string() + } + } else { + "XREF Path Find ".to_string() + }; + self.command_line.input = prefill; + self.command_line.autocomplete_cursor = None; + self.command_line.cancel_history_navigation(); + self.command_line.push_info( + crate::t!("Specify replacement path: XREF Path Find ").as_ref(), + ); + return self.focus_cmd_input(); + } + Message::XrefRowChangePathEnter => { + self.xref_manager.row_change_path_open = true; + Task::none() + } + Message::XrefRowChangePathLeave => { + self.xref_manager.row_change_path_open = false; + Task::none() + } + Message::XrefFindReplacePrompt => { + // No fields in the panel: prefill the command line so the + // existing `XREF Path Find ` parsing runs it. + self.command_line.input = "XREF Path Find ".to_string(); + self.command_line.autocomplete_cursor = None; + self.command_line.cancel_history_navigation(); + return self.focus_cmd_input(); + } + Message::LayerStateManagerOpen => { let i = self.active_tab; self.ribbon.close_dropdown(); @@ -3166,6 +3604,10 @@ impl OpenCADStudio { return Task::none(); } let was_click = !sel.right_dragging; + // How long the button was held, for the time-sensitive mode. + let held_ms = sel + .right_press_time + .map_or(0, |t| t.elapsed().as_millis() as i32); sel.right_down = false; sel.right_press_pos = None; sel.right_press_time = None; @@ -3190,21 +3632,43 @@ impl OpenCADStudio { drop(sel); return self.update(Message::CommandFinalize); } - // A right-click (no orbit). While a command is active the first - // right-click acts as Enter (commit / close); a second - // consecutive right-click opens the context menu instead. When - // idle it always opens the menu. (Right-drag, handled above, - // always orbits.) Any other interaction — a left-click pick or a - // new command — resets the cycle so the next right-click is Enter. - if self.tabs[i].active_cmd.is_some() && !sel.right_click_entered { + // A right-click. What it does is the user's choice (Options → + // User Preferences, SHORTCUTMENU in commercial solutions): + // • Shortcut menu — always open the context menu, whose + // default row (Enter / Repeat) sits under the pointer. + // • Time-sensitive — a quick click is Enter while a command + // runs (repeat the last command when idle); a held click + // opens the menu. + // • Enter first — while a command is active the first + // right-click acts as Enter and a second consecutive one + // opens the menu; idle always opens the menu. Any other + // interaction — a left-click pick or a new command — resets + // that cycle so the next right-click is Enter again. + let has_cmd = self.tabs[i].active_cmd.is_some(); + let open_menu = match self.right_click_mode { + super::settings::RightClickMode::ShortcutMenu => true, + super::settings::RightClickMode::TimeSensitive => { + held_ms >= self.right_click_hold_ms + } + super::settings::RightClickMode::EnterFirst => { + !(has_cmd && !sel.right_click_entered) + } + }; + if !open_menu { sel.right_click_entered = true; drop(sel); + // CommandFinalize is Enter during a command and "repeat + // the last command" when idle — exactly the quick + // right-click. return self.update(Message::CommandFinalize); } sel.right_click_entered = false; - sel.context_menu = Some(click_pos); - sel.draworder_submenu = false; - Task::none() + sel.open_context_menu(click_pos); + drop(sel); + // Take the keyboard away from the command-line field so keys + // reach the menu through the global subscription; the field + // is re-focused when the menu closes. + self.unfocus_widgets() } Message::ViewportMiddlePress => self.on_viewport_middle_press(), @@ -3421,6 +3885,11 @@ impl OpenCADStudio { self.persist_settings_if_changed(); Task::none() } + Message::ToggleSnap3dEnabled => { + self.snapper.toggle_snap3d(); + self.sync_vport_display(self.active_tab); + Task::none() + } Message::ToggleGridSnap => { self.snapper.toggle_grid_snap(); self.sync_vport_display(self.active_tab); @@ -3710,6 +4179,106 @@ impl OpenCADStudio { } Task::none() } + Message::PropHyperlinkOpen => { + let i = self.active_tab; + let handles = self.property_target_handles(i); + if handles.is_empty() { + return Task::none(); + } + let mut first: Option<(String, String)> = None; + let mut mixed = false; + for handle in &handles { + let Some(entity) = self.tabs[i].scene.document.get_entity(*handle) else { + continue; + }; + let current = ( + crate::scene::pe_url_of(entity).unwrap_or_default().to_owned(), + crate::scene::pe_url_description_of(entity) + .unwrap_or_default() + .to_owned(), + ); + if first.as_ref().is_some_and(|value| value != ¤t) { + mixed = true; + } else if first.is_none() { + first = Some(current); + } + } + let (url, description) = if mixed { + (String::new(), String::new()) + } else { + first.unwrap_or_default() + }; + self.hyperlink_editor_handles = handles; + self.hyperlink_editor_url = url; + self.hyperlink_editor_description = description; + self.hyperlink_editor_mixed = mixed; + self.hyperlink_editor_dirty = false; + self.active_modal = Some(crate::app::ModalKind::Hyperlink); + Task::none() + } + Message::HyperlinkUrlChanged(value) => { + self.hyperlink_editor_url = value; + self.hyperlink_editor_dirty = true; + Task::none() + } + Message::HyperlinkDescriptionChanged(value) => { + self.hyperlink_editor_description = value; + self.hyperlink_editor_dirty = true; + Task::none() + } + Message::HyperlinkApply => { + if !self.hyperlink_editor_dirty { + self.close_active_modal(); + return Task::none(); + } + let url = self.hyperlink_editor_url.trim().to_owned(); + if self.hyperlink_editor_mixed && url.is_empty() { + self.command_line.push_info( + crate::t!("Enter a URL, or use Remove to clear all hyperlinks.").as_ref(), + ); + return Task::none(); + } + let description = self.hyperlink_editor_description.trim().to_owned(); + let values = if url.is_empty() { + None + } else { + let mut values = vec![acadrust::xdata::XDataValue::String(url)]; + if !description.is_empty() { + values.push(acadrust::xdata::XDataValue::String(description)); + } + Some(values) + }; + let i = self.active_tab; + let handles = self.hyperlink_editor_handles.clone(); + self.apply_property_op(i, "HYPERLINK", &handles, |app, handle| { + crate::scene::view::dispatch::set_entity_xdata( + &mut app.tabs[i].scene.document, + handle, + "PE_URL", + values.clone(), + ); + }); + self.close_active_modal(); + Task::none() + } + Message::HyperlinkRemove => { + let i = self.active_tab; + let handles = self.hyperlink_editor_handles.clone(); + self.apply_property_op(i, "HYPERLINK", &handles, |app, handle| { + crate::scene::view::dispatch::set_entity_xdata( + &mut app.tabs[i].scene.document, + handle, + "PE_URL", + None, + ); + }); + self.close_active_modal(); + Task::none() + } + Message::HyperlinkCancel => { + self.close_active_modal(); + Task::none() + } Message::AnnoObjectScaleToggle(name) => { let i = self.active_tab; if let Some(entity) = self.anno_object_scale_target { @@ -4203,6 +4772,63 @@ impl OpenCADStudio { } Task::none() } + Message::DraftingSettingsSnapXChanged(value) => { + if let Some(state) = &mut self.drafting_settings_state { + state.snap_x_input = value.clone(); + if state.snap_equal { + state.snap_y_input = value; + } + } + Task::none() + } + Message::DraftingSettingsSnapYChanged(value) => { + if let Some(state) = &mut self.drafting_settings_state { + state.snap_y_input = value.clone(); + if state.snap_equal { + state.snap_x_input = value; + } + } + Task::none() + } + Message::DraftingSettingsGridXChanged(value) => { + if let Some(state) = &mut self.drafting_settings_state { + state.grid_x_input = value; + } + Task::none() + } + Message::DraftingSettingsGridYChanged(value) => { + if let Some(state) = &mut self.drafting_settings_state { + state.grid_y_input = value; + } + Task::none() + } + Message::DraftingSettingsGridMajorChanged(value) => { + if let Some(state) = &mut self.drafting_settings_state { + state.grid_major_input = value; + } + Task::none() + } + Message::DraftingSettingsToggleAdaptiveGrid => { + if let Some(state) = &mut self.drafting_settings_state { + state.grid_adaptive = !state.grid_adaptive; + } + Task::none() + } + Message::DraftingSettingsToggleBeyondLimits => { + if let Some(state) = &mut self.drafting_settings_state { + state.grid_beyond_limits = !state.grid_beyond_limits; + } + Task::none() + } + Message::DraftingSettingsToggleEqualSnap => { + if let Some(state) = &mut self.drafting_settings_state { + state.snap_equal = !state.snap_equal; + if state.snap_equal { + state.snap_y_input = state.snap_x_input.clone(); + } + } + Task::none() + } Message::DraftingSettingsToggleIsometric => { if let Some(state) = &mut self.drafting_settings_state { state.isometric = !state.isometric; @@ -4254,6 +4880,14 @@ impl OpenCADStudio { } Task::none() } + Message::DraftingSettingsToggleSnapMode3d(snap_type) => { + if let Some(state) = &mut self.drafting_settings_state { + if !state.snap3d_modes.remove(&snap_type) { + state.snap3d_modes.insert(snap_type); + } + } + Task::none() + } Message::DraftingSettingsSnapSelectAll => { if let Some(state) = &mut self.drafting_settings_state { for &(snap_type, _, _) in crate::snap::ALL_SNAP_MODES { @@ -4293,17 +4927,19 @@ impl OpenCADStudio { Task::none() } Message::DraftingSettingsApply => { - self.apply_drafting_settings(); - self.drafting_settings_saved = self.drafting_settings_state.clone(); - self.persist_settings_if_changed(); + if self.apply_drafting_settings() { + self.drafting_settings_saved = self.drafting_settings_state.clone(); + self.persist_settings_if_changed(); + } Task::none() } Message::DraftingSettingsOk => { - self.apply_drafting_settings(); - self.drafting_settings_saved = self.drafting_settings_state.clone(); - self.drafting_settings_close_confirm = false; - self.persist_settings_if_changed(); - self.close_active_modal(); + if self.apply_drafting_settings() { + self.drafting_settings_saved = self.drafting_settings_state.clone(); + self.drafting_settings_close_confirm = false; + self.persist_settings_if_changed(); + self.close_active_modal(); + } Task::none() } Message::DraftingSettingsClose => { @@ -4894,12 +5530,9 @@ impl OpenCADStudio { self.post_editor_closed(committed) } - Message::DrawOrderSubmenuToggle => { - let i = self.active_tab; - let mut sel = self.tabs[i].scene.selection.borrow_mut(); - sel.draworder_submenu = !sel.draworder_submenu; - Task::none() - } + Message::ContextMenuPick(action) => self.on_context_menu_pick(action), + Message::ContextMenuSubmenuToggle(id) => self.on_context_menu_submenu_toggle(id), + Message::ContextMenuNavigate(nav) => self.on_context_menu_navigate(nav), Message::DrawOrderPickRef(above) => { let i = self.active_tab; @@ -6307,6 +6940,9 @@ impl OpenCADStudio { .collect(); rows.sort_by(|a, b| a.0.cmp(&b.0)); self.alias_editor_rows = rows; + self.alias_pending_add = false; + self.alias_reset_confirm = false; + self.alias_close_confirm = false; self.active_modal = Some(super::ModalKind::Aliases); Task::none() } @@ -6321,25 +6957,80 @@ impl OpenCADStudio { AliasField::Command => rowdata.1 = value, } } + // Typing never finishes the addition — only the draft row's + // check button does, so no half-visible "ghost" row appears. Task::none() } Message::AliasEditorAdd => { - self.alias_editor_rows.push((String::new(), String::new())); + if self.alias_pending_add { + // One draft at a time: keep the existing top draft. + } else { + // The draft row goes to the top of the list so it is + // visible without scrolling. + self.alias_editor_rows + .insert(0, (String::new(), String::new())); + self.alias_pending_add = true; + } + Task::none() + } + Message::AliasEditorDraftAccept => { + // The check button: finish the addition (keep the row in the + // working table) without applying it. Only complete drafts + // can be accepted — the button is disabled otherwise. + self.finish_pending_alias_add(); + Task::none() + } + Message::AliasEditorDraftCancel => { + // Esc backs out and abandons an unfinished draft. + if self.alias_pending_add && !self.alias_editor_rows.is_empty() { + self.alias_editor_rows.remove(0); + self.alias_pending_add = false; + } Task::none() } Message::AliasEditorRemove(idx) => { if idx < self.alias_editor_rows.len() { self.alias_editor_rows.remove(idx); } + if idx == 0 { + // Removing the draft row is the ✕ cancel path. + self.alias_pending_add = false; + } Task::none() } Message::AliasEditorApply => { - self.apply_alias_editor_rows(); + self.finish_alias_editor(); + Task::none() + } + Message::AliasEditorApplyExit => { + self.finish_alias_editor(); + self.close_active_modal(); + Task::none() + } + Message::AliasEditorResetAsk => { + self.alias_reset_confirm = true; + Task::none() + } + Message::AliasEditorResetDeny => { + self.alias_reset_confirm = false; + Task::none() + } + Message::AliasEditorResetConfirm => { + self.reset_aliases_to_defaults(); self.command_line.push_info( crate::tf!("{} alias(es) applied.", self.command_aliases.len()).as_ref(), ); Task::none() } + Message::AliasEditorCloseDiscard => { + self.alias_close_confirm = false; + self.close_active_modal(); + Task::none() + } + Message::AliasEditorCloseKeep => { + self.alias_close_confirm = false; + Task::none() + } // ── Named Parameters (PARAMETERS) ───────────────────────────────── Message::NamedParametersOpen => { @@ -6413,7 +7104,35 @@ impl OpenCADStudio { } // ── Options / About windows ─────────────────────────────────── Message::OptionsOpen => { - self.active_modal = Some(super::ModalKind::Options); + self.options_open(); + Task::none() + } + Message::OptionsApply => { + self.options_apply(); + Task::none() + } + Message::OptionsOk => { + self.options_apply(); + self.options_forget(); + self.close_active_modal(); + Task::none() + } + Message::OptionsClose => { + if !self.options_close_confirm && self.options_dirty() { + self.options_close_confirm = true; + return Task::none(); + } + self.options_discard(); + self.close_active_modal(); + Task::none() + } + Message::OptionsCloseDiscard => { + self.options_discard(); + self.close_active_modal(); + Task::none() + } + Message::OptionsCloseKeep => { + self.options_close_confirm = false; Task::none() } @@ -6524,6 +7243,39 @@ impl OpenCADStudio { Task::none() } + Message::BgPickerOpen(target) => { + self.bg_picker = Some(target); + Task::none() + } + + Message::BgPickerCancel => { + self.bg_picker = None; + Task::none() + } + + Message::BgPickerSubmit(color) => { + // Hand the result to the same handler the typed hex field + // uses, so the wheel and the field cannot drift apart on + // validation, persistence or the MatchTheme fallback. + let hex = crate::app::config::rgb_to_hex([ + (color.r * 255.0).round() as u8, + (color.g * 255.0).round() as u8, + (color.b * 255.0).round() as u8, + ]); + match self.bg_picker.take() { + Some(crate::app::BgTarget::Model) => { + Task::done(Message::ModelSpaceBgChanged(hex)) + } + Some(crate::app::BgTarget::Paper) => { + Task::done(Message::PaperSpaceBgChanged(hex)) + } + Some(crate::app::BgTarget::Desk) => { + Task::done(Message::DeskSpaceBgChanged(hex)) + } + None => Task::none(), + } + } + Message::ModelSpaceBgChanged(hex) => { self.model_bg_input = hex.clone(); if hex.trim().is_empty() { @@ -6694,6 +7446,12 @@ impl OpenCADStudio { Task::none() } + Message::PageSetupImportFile(path) => self.on_page_setup_import_file(path), + Message::PageSetupOnNewLayoutChanged(enabled) => { + self.plot_dialog.page_setup_on_new_layout = enabled; + self.persist_settings_if_changed(); + Task::none() + } Message::BackupOnSaveChanged(enabled) => { self.backup_on_save = enabled; self.persist_settings_if_changed(); @@ -6736,6 +7494,18 @@ impl OpenCADStudio { Task::none() } + Message::RightClickModeChanged(mode) => { + self.right_click_mode = mode; + self.persist_settings_if_changed(); + Task::none() + } + + Message::RightClickHoldMsChanged(ms) => { + self.right_click_hold_ms = super::settings::clamp_right_click_hold_ms(ms); + self.persist_settings_if_changed(); + Task::none() + } + Message::TextEditModeChanged(single) => { self.texteditmode = single; self.persist_settings_if_changed(); @@ -6979,6 +7749,10 @@ impl OpenCADStudio { if self.active_modal == Some(super::ModalKind::RecoveryPrompt) { return self.update(Message::RecoveryDecline); } + // The Options window's × and Esc behave like its Close button. + if self.active_modal == Some(super::ModalKind::Options) { + return self.update(Message::OptionsClose); + } // Closing the shortcut editor with un-applied rows needs an // explicit discard; a second close attempt (or the overlay's // Discard button) goes through. @@ -6990,6 +7764,14 @@ impl OpenCADStudio { return Task::none(); } self.shortcut_close_confirm = false; + if self.active_modal == Some(super::ModalKind::Aliases) + && !self.alias_close_confirm + && self.alias_editor_dirty() + { + self.alias_close_confirm = true; + return Task::none(); + } + self.alias_close_confirm = false; if self.active_modal == Some(super::ModalKind::DraftingSettings) && !self.drafting_settings_close_confirm && self.drafting_settings_dirty() @@ -8055,7 +8837,7 @@ impl OpenCADStudio { .and_then(|path| path.file_stem()) .map(|name| format!("{}_layouts", name.to_string_lossy())) .unwrap_or_else(|| "drawing_layouts".into()); - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] { let Some(window_id) = self.main_window else { return Task::done(Message::PrintAllPdfPath(None)); @@ -8065,6 +8847,13 @@ impl OpenCADStudio { }) .map(Message::PrintAllPdfPath) } + #[cfg(target_os = "windows")] + { + Task::perform( + crate::io::pdf_export::pick_pdf_path_async(stem), + Message::PrintAllPdfPath, + ) + } #[cfg(target_arch = "wasm32")] { let _ = stem; @@ -8100,7 +8889,7 @@ impl OpenCADStudio { .and_then(|p: &std::path::Path| p.file_stem()) .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "drawing".into()); - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] { let Some(window_id) = self.main_window else { return Task::done(Message::PlotExportPath(None)); @@ -8110,6 +8899,13 @@ impl OpenCADStudio { }) .map(Message::PlotExportPath) } + #[cfg(target_os = "windows")] + { + Task::perform( + crate::io::pdf_export::pick_pdf_path_async(stem), + Message::PlotExportPath, + ) + } #[cfg(target_arch = "wasm32")] { Task::perform( @@ -8121,14 +8917,6 @@ impl OpenCADStudio { Message::PlotExportPath(None) => Task::none(), Message::PlotExportPath(Some(path)) => self.on_plot_export_path_some(path), - Message::PlotFormat(f) => { - self.plot_format = f; - Task::none() - } - Message::PlotOrientation(o) => { - self.plot_orientation = o; - Task::none() - } Message::PlotWindowExport => { let i = self.active_tab; let stem = self.tabs[i] @@ -8137,7 +8925,7 @@ impl OpenCADStudio { .and_then(|p: &std::path::Path| p.file_stem()) .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "drawing".into()); - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] { let Some(window_id) = self.main_window else { return Task::done(Message::PlotWindowExportPath(None)); @@ -8147,6 +8935,13 @@ impl OpenCADStudio { }) .map(Message::PlotWindowExportPath) } + #[cfg(target_os = "windows")] + { + Task::perform( + crate::io::pdf_export::pick_pdf_path_async(stem), + Message::PlotWindowExportPath, + ) + } #[cfg(target_arch = "wasm32")] { Task::perform( @@ -8186,7 +8981,7 @@ impl OpenCADStudio { Message::PlotStyleLoad => { Task::perform(crate::io::pick_plot_style(), Message::PlotStyleLoaded) } - Message::PlotStyleLoaded(Some(table)) => { + Message::PlotStyleLoaded(Ok(Some(table))) => { if table.is_stb { self.command_line.push_error( crate::t!( @@ -8198,6 +8993,8 @@ impl OpenCADStudio { } self.plot_dialog.style_name = table.name.clone(); self.plot_dialog.style_missing = false; + self.plot_dialog.style_error = None; + self.report_plot_style_warnings(&table); self.command_line.push_output( crate::tf!( "Plot style '{}' loaded ({} color entries).", @@ -8214,7 +9011,15 @@ impl OpenCADStudio { self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names(); Task::none() } - Message::PlotStyleLoaded(None) => Task::none(), + Message::PlotStyleLoaded(Ok(None)) => Task::none(), + Message::PlotStyleLoaded(Err(error)) => { + // The file the user pointed at could not be read: say why, + // in the dialog as well as on the command line. + self.command_line.push_error(&error); + self.plot_dialog.style_missing = true; + self.plot_dialog.style_error = Some(error); + Task::none() + } Message::PlotStyleClear => { self.active_plot_style = None; self.plot_dialog.style_name.clear(); @@ -8241,12 +9046,15 @@ impl OpenCADStudio { if needs_load { match crate::io::plot_style::PlotStyleTable::load_named(&selected_style) { Ok(table) => { + self.report_plot_style_warnings(&table); self.active_plot_style = Some(table); self.plot_dialog.style_missing = false; + self.plot_dialog.style_error = None; } Err(error) => { self.plot_dialog.style_missing = true; self.command_line.push_error(&error); + self.plot_dialog.style_error = Some(error); return Task::none(); } } @@ -9453,6 +10261,14 @@ mod free_text_entry_tests { ); assert_eq!(app.text_entry_mode(), TextEntryMode::Command); } + + #[test] + fn literal_command_input_preserves_case() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let _ = app.update(Message::CommandInput(">Plugin MixedCase".into())); + assert_eq!(app.command_line.input, ">Plugin MixedCase"); + } } /// Set or clear one bit of `SELECTIONPREVIEW`. diff --git a/src/app/update/page_setup_import.rs b/src/app/update/page_setup_import.rs new file mode 100644 index 000000000..4894e6b95 --- /dev/null +++ b/src/app/update/page_setup_import.rs @@ -0,0 +1,347 @@ +//! Importing named page setups from another drawing (`PSETUPIN`): the +//! "Import…" chooser of the Plot dialog and the command that drives the same +//! path from the command line. + +use crate::app::{Message, OpenCADStudio}; +use crate::ui::window::plot::{PageSetupImportDraft, PageSetupImportMsg, PlotDlgMsg}; +use iced::Task; +use std::path::{Path, PathBuf}; + +/// Read the named page setups of the drawing at `path` off the UI thread. +/// The result names the file the way the chooser shows it. +fn read_page_setups_task(path: PathBuf) -> Task { + super::file::background_task( + move || { + let file = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + crate::io::load_file(&path) + .map(|document| (file, crate::scene::document_page_setups(&document))) + }, + |result| Message::PlotDlg(PlotDlgMsg::Import(PageSetupImportMsg::Loaded(result))), + ) +} + +impl OpenCADStudio { + pub(super) fn on_page_setup_import(&mut self, msg: PageSetupImportMsg) -> Task { + match msg { + PageSetupImportMsg::Pick => Task::perform( + async { + let handle = rfd::AsyncFileDialog::new() + .set_title(crate::t!("Select Drawing to Import Page Setups From").as_ref()) + .add_filter( + crate::t!("Drawings and templates").as_ref(), + &["dwg", "dxf", "dwt", "DWG", "DXF", "DWT"], + ) + .pick_file() + .await; + handle.map(|h| crate::sys::handle_path(&h)) + }, + |picked| match picked { + Some(path) => Message::PageSetupImportFile(path), + None => Message::Noop, + }, + ), + PageSetupImportMsg::Loaded(result) => { + self.plot_dialog.import_draft = Some(match result { + Ok((file, setups)) => PageSetupImportDraft { + file, + setups: setups.into_iter().map(|(name, ps)| (name, ps, true)).collect(), + error: None, + }, + Err(error) => PageSetupImportDraft { + file: String::new(), + setups: Vec::new(), + error: Some(error), + }, + }); + Task::none() + } + PageSetupImportMsg::Toggle(name) => { + if let Some(draft) = self.plot_dialog.import_draft.as_mut() { + if let Some(entry) = draft.setups.iter_mut().find(|(n, _, _)| *n == name) { + entry.2 = !entry.2; + } + } + Task::none() + } + PageSetupImportMsg::All(on) => { + if let Some(draft) = self.plot_dialog.import_draft.as_mut() { + for entry in &mut draft.setups { + entry.2 = on; + } + } + Task::none() + } + PageSetupImportMsg::Apply => { + let Some(draft) = self.plot_dialog.import_draft.take() else { + return Task::none(); + }; + let chosen: Vec<(String, acadrust::objects::PlotSettings)> = draft + .selected() + .map(|(name, ps)| (name.to_string(), ps.clone())) + .collect(); + let imported = self.import_page_setups(chosen); + if let Some(first) = imported.first() { + let first = first.clone(); + self.select_page_setup(&first); + } + Task::none() + } + PageSetupImportMsg::Cancel => { + self.plot_dialog.import_draft = None; + Task::none() + } + } + } + + /// A drawing was picked for import: read it in the background; the + /// chooser appears in the Plot dialog once it is read. + pub(super) fn on_page_setup_import_file(&mut self, path: PathBuf) -> Task { + // The chooser lives in the Plot dialog; open it if the import was + // started from the command line. + let open = if self.active_modal == Some(crate::app::ModalKind::Plot) { + Task::none() + } else { + self.on_plot_dialog_open() + }; + Task::batch([open, read_page_setups_task(path)]) + } + + /// Bring the given setups into the drawing, replacing same-named ones, + /// as one undoable step. Returns the names imported. + pub(super) fn import_page_setups( + &mut self, + setups: Vec<(String, acadrust::objects::PlotSettings)>, + ) -> Vec { + if setups.is_empty() { + return Vec::new(); + } + let i = self.active_tab; + self.push_undo_snapshot(i, "PSETUPIN"); + let mut replaced = 0usize; + let existing = self.tabs[i].scene.page_setup_names(); + let mut names = Vec::with_capacity(setups.len()); + for (name, ps) in setups { + if existing.iter().any(|n| n.eq_ignore_ascii_case(&name)) { + replaced += 1; + } + self.tabs[i].scene.import_page_setup(&name, ps); + names.push(name); + } + self.tabs[i].dirty = true; + self.refresh_page_setups(); + let count = names.len(); + self.command_line.push_output( + crate::tf!("PSETUPIN: {count} page setup(s) imported, {replaced} replaced.").as_ref(), + ); + names + } + + /// `PSETUPIN` / `-PSETUPIN`: with no file, ask for one and offer its + /// setups in the Plot dialog; with ` *` or ` name,name` + /// import those straight away; with just `` open the chooser on it. + pub(in crate::app) fn on_psetupin(&mut self, args: &str) -> Task { + let args = args.trim(); + if args.is_empty() { + return self.on_page_setup_import(PageSetupImportMsg::Pick); + } + // The file may carry spaces; the setup list, when given, is the last + // word (`*` or a comma-separated list without spaces), so the + // arguments are a file followed by names only when what precedes the + // last word is itself a file. + let (file, names) = match args.rsplit_once(char::is_whitespace) { + Some((file, names)) if Path::new(file.trim()).is_file() => { + (file.trim(), Some(names.trim())) + } + _ => (args, None), + }; + let path = PathBuf::from(file); + let Some(names) = names else { + return self.on_page_setup_import_file(path); + }; + let available = match crate::io::load_file(&path) { + Ok(document) => crate::scene::document_page_setups(&document), + Err(error) => { + self.command_line + .push_error(crate::tf!("PSETUPIN: {error}").as_ref()); + return Task::none(); + } + }; + let chosen: Vec<(String, acadrust::objects::PlotSettings)> = if names == "*" { + available + } else { + let wanted: Vec<&str> = names.split(',').map(str::trim).collect(); + let mut chosen = Vec::new(); + for want in wanted { + match available.iter().find(|(n, _)| n.eq_ignore_ascii_case(want)) { + Some(found) => chosen.push(found.clone()), + None => self.command_line.push_error( + crate::tf!("PSETUPIN: no page setup named \"{want}\" in {file}.") + .as_ref(), + ), + } + } + chosen + }; + if chosen.is_empty() { + self.command_line + .push_info(crate::tf!("PSETUPIN: nothing to import from {file}.").as_ref()); + return Task::none(); + } + self.import_page_setups(chosen); + Task::none() + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use crate::app::{Message, OpenCADStudio}; + use crate::ui::window::plot::{PageSetupImportMsg as I, PlotDlgMsg}; + + const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/plot/page-setups-metric.dxf" + ); + + fn app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let _ = app.update(Message::LayoutSwitch("Layout1".into())); + app + } + + fn last_line(app: &OpenCADStudio) -> String { + app.command_line.history.last().map(|line| line.text.clone()).unwrap_or_default() + } + + /// The fixture's page setups as the chooser would hold them, read + /// through the same document reader the command uses. + fn fixture_setups() -> Vec<(String, acadrust::objects::PlotSettings)> { + let document = crate::io::load_file(std::path::Path::new(FIXTURE)).unwrap(); + crate::scene::document_page_setups(&document) + } + + #[test] + fn the_fixture_reader_lists_its_named_page_setups() { + // The fixture carries its page setups on the layouts, not as named + // setups; a drawing with named ones lists them in dictionary order. + assert!(fixture_setups().is_empty()); + let mut app = app(); + let i = app.active_tab; + let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + app.tabs[i].scene.page_setup_save("Site", ps.clone()); + app.tabs[i].scene.page_setup_save("Detail", ps); + let names: Vec = crate::scene::document_page_setups(&app.tabs[i].scene.document) + .into_iter() + .map(|(name, _)| name) + .collect(); + assert_eq!(names, ["Site", "Detail"]); + } + + #[test] + fn psetupin_imports_named_setups_from_a_saved_drawing() { + use acadrust::objects::PlotRotation; + // Author a source drawing with two named setups and save it. + let dir = std::env::temp_dir().join(format!("ocs-psetupin-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("source.dxf"); + { + let mut app = app(); + let i = app.active_tab; + let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + ps.printer_name = "DWG To PDF.pc3".into(); + ps.paper_size = "ISO_A3_(297.00_x_420.00_MM)".into(); + ps.paper_width = 297.0; + ps.paper_height = 420.0; + ps.rotation = PlotRotation::Degrees90; + ps.plot_view_handle = acadrust::Handle::new(0xBEEF); + app.tabs[i].scene.page_setup_save("Site", ps.clone()); + ps.paper_size = "ISO_A2_(420.00_x_594.00_MM)".into(); + app.tabs[i].scene.page_setup_save("Overview", ps); + let save = format!(r#"{{"op":"save","path":"{}"}}"#, source.display()); + let reply = app.automation_op(&save); + assert_eq!(reply["ok"], true, "{reply}"); + } + let mut app = app(); + let i = app.active_tab; + // One named setup already exists under a name the file also has. + let own = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + app.tabs[i].scene.page_setup_save("Site", own); + // `*` imports everything, replacing the same-named one. + let _ = app.run_command_line(&format!("PSETUPIN {} *", source.display())); + // The report is translated on localized systems; the counts are not. + let report = last_line(&app); + assert!(report.starts_with("PSETUPIN"), "{report}"); + assert!(report.contains('2') && report.contains('1'), "{report}"); + let names = app.tabs[i].scene.page_setup_names(); + assert_eq!(names, ["Site", "Overview"]); + let site = app.tabs[i].scene.page_setup_get("Site").unwrap(); + assert_eq!(site.paper_size, "ISO_A3_(297.00_x_420.00_MM)"); + assert_eq!(site.rotation, PlotRotation::Degrees90); + assert!(site.plot_view_handle.is_null(), "foreign handles are dropped"); + assert!(app.tabs[i].dirty); + // Undo takes the whole import back. + let _ = app.update(Message::Undo); + let names = app.tabs[i].scene.page_setup_names(); + assert_eq!(names, ["Site"]); + assert_ne!( + app.tabs[i].scene.page_setup_get("Site").unwrap().paper_size, + "ISO_A3_(297.00_x_420.00_MM)" + ); + // A name list imports just those and reports the unknown ones. + let _ = app.run_command_line(&format!("PSETUPIN {} Overview,Nothing", source.display())); + assert_eq!(app.tabs[i].scene.page_setup_names(), ["Site", "Overview"]); + // A missing file is an error, not a crash. + let _ = app.run_command_line("PSETUPIN /nowhere/at/all.dwg *"); + assert!(last_line(&app).starts_with("PSETUPIN:")); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn the_chooser_imports_the_ticked_setups_and_selects_the_first() { + let mut app = app(); + let i = app.active_tab; + let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + let _ = app.on_plot_dialog_open(); + let setups = ["A", "B", "C"] + .into_iter() + .map(|name| (name.to_string(), ps.clone())) + .collect(); + let loaded = Ok(("other.dwg".to_string(), setups)); + let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Loaded(loaded))); + let draft = app.plot_dialog.import_draft.as_ref().expect("chooser open"); + assert_eq!(draft.file, "other.dwg"); + assert_eq!(draft.selected().count(), 3, "everything ticked to start with"); + let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::All(false))); + assert_eq!(app.plot_dialog.import_draft.as_ref().unwrap().selected().count(), 0); + let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Toggle("B".into()))); + let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Toggle("C".into()))); + let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Apply)); + assert!(app.plot_dialog.import_draft.is_none(), "Apply closes the chooser"); + assert_eq!(app.tabs[i].scene.page_setup_names(), ["B", "C"]); + assert_eq!(app.plot_dialog.selected_setup, "B"); + assert!(app.plot_dialog.page_setups.iter().any(|name| name == "C")); + // A failed read shows its reason in the chooser; Cancel closes it. + let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Loaded(Err("unreadable".into())))); + assert_eq!( + app.plot_dialog.import_draft.as_ref().unwrap().error.as_deref(), + Some("unreadable") + ); + let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Cancel)); + assert!(app.plot_dialog.import_draft.is_none()); + } + + #[test] + fn a_new_layout_opens_its_page_setup_when_the_option_is_on() { + let mut app = app(); + let _ = app.update(Message::LayoutCreate); + assert_ne!(app.active_modal, Some(crate::app::ModalKind::Plot)); + let _ = app.update(Message::PageSetupOnNewLayoutChanged(true)); + assert!(app.current_config().plot.page_setup_on_new_layout, "persisted"); + let _ = app.update(Message::LayoutCreate); + assert_eq!(app.active_modal, Some(crate::app::ModalKind::Plot)); + assert_eq!(app.plot_dialog.selected_setup, "*Layout3*"); + } +} diff --git a/src/app/update/util.rs b/src/app/update/util.rs index cb78c36ab..8115d3f40 100644 --- a/src/app/update/util.rs +++ b/src/app/update/util.rs @@ -62,27 +62,6 @@ pub(super) fn layout_entry_name(s: &str) -> &str { s.trim_start_matches('*').trim_end_matches('*') } -/// 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" }; - let (short, long) = if w <= h { (w, h) } else { (h, w) }; - let mut best = ("A4".to_string(), f64::INFINITY); - for p in PaperSize::ALL { - let (pw, ph) = p.dimensions_mm(); // portrait: pw < ph - let err = (pw - short).abs() + (ph - long).abs(); - if err < best.1 { - best = (p.label().to_string(), err); - } - } - 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) { if s == "Fit" { return (1.0, 1.0); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 958df1fd6..f316976fd 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -35,6 +35,17 @@ fn pt_pt_d2(a: Point, b: Point) -> f32 { (a.x - b.x).powi(2) + (a.y - b.y).powi(2) } +/// Whether a command point keeps the elevation of its snap instead of being +/// clamped to the world XY plane. +/// +/// A genuine object snap carries its Z into the command point, so 3D vertices +/// stay snappable. Grid snaps, misses and entity picks stay on the +/// construction plane, preserving the long-standing 2D behavior exactly +/// (a planar snap still resolves to z = 0 as before). +pub(in crate::app) fn snap_keeps_elevation(hit: Option) -> bool { + matches!(hit, Some(t) if t != crate::snap::SnapType::Grid) +} + fn cursor_on_projected_axis( cursor: Point, bounds: iced::Rectangle, @@ -922,7 +933,7 @@ impl OpenCADStudio { Task::none() } - pub(super) fn on_viewport_move(&mut self, p: Point) -> Task { + pub(in crate::app) fn on_viewport_move(&mut self, p: Point) -> Task { // A ribbon dropdown is open over the viewport. Its backdrop // cannot swallow cursor motion — in iced 0.14 mouse_area/opaque // capture only button presses, never CursorMoved — so the move @@ -933,9 +944,10 @@ impl OpenCADStudio { return Task::none(); } let i = self.active_tab; + self.constraint_glyph_tooltip = None; let constraint_hover = self .constraint_glyph_under(i, p) - .map(|(_, handles)| handles) + .map(|(_, references)| references) .unwrap_or_default(); self.tabs[i] .scene @@ -1243,7 +1255,7 @@ impl OpenCADStudio { }; let mut seen_handles = rustc_hash::FxHashSet::default(); - let mut edited_handles: Vec<_> = grip + let edited_handles: Vec<_> = grip .targets .iter() .map(|target| target.handle) @@ -1253,6 +1265,9 @@ impl OpenCADStudio { // Initialize once: constraint solves may add neighbors to this gesture. // Wire entities use the overlay; solid meshes stay visible and move live. if self.grip_preview_handles.is_empty() { + let preview_handles = self.tabs[i].scene.parametric_connected_handles( + self.tabs[i].current_parametric_scope(), &edited_handles, true, + ); if self.grip_dirty_before.is_none() { self.grip_dirty_before = Some(self.tabs[i].dirty); } @@ -1260,7 +1275,7 @@ impl OpenCADStudio { // before insertion so append + placement is one undo step. // Normal grip drags still snapshot their current entities here. if self.grip_originals.is_empty() { - self.grip_originals = edited_handles + self.grip_originals = preview_handles .iter() .filter_map(|&handle| { self.tabs[i] @@ -1273,17 +1288,17 @@ impl OpenCADStudio { .collect(); } self.capture_grip_history_originals(i, &edited_handles); - for &handle in &edited_handles { + for &handle in &preview_handles { if !self.tabs[i].scene.meshes.contains_key(&handle) { self.tabs[i].scene.preview_hidden.insert(handle); } } - let changes: Vec<_> = edited_handles + let changes: Vec<_> = preview_handles .iter() .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); - self.grip_preview_handles = edited_handles.clone(); + self.tabs[i].scene.bump_entities_after_parametric_solve(&changes); + self.grip_preview_handles = preview_handles; // Snapshot the entity's glyph quads once so each move can // slide the already-shaped text rather than re-shaping it // (issue #316). The fast slide path only runs for a rigid @@ -1291,7 +1306,7 @@ impl OpenCADStudio { // dimension re-tessellates) and a Square insertion grip (so // an MTEXT width handle, a Triangle, still re-tessellates so // the re-wrap is exact). - let snap = self.tabs[i].scene.wire_models_for(&edited_handles); + let snap = self.tabs[i].scene.wire_models_for(&self.grip_preview_handles); // Preserve the exact geometry from the instant the grip drag began. // This snapshot is visual/reference-only: do NOT append it to @@ -1316,7 +1331,7 @@ impl OpenCADStudio { grip_def.shape == crate::scene::model::object::GripShape::Square }) .unwrap_or(false); - self.grip_text_slide = edited_handles.len() == 1 + self.grip_text_slide = self.grip_preview_handles.len() == 1 && grip.targets.len() == 1 && !self.grip_text_verts.is_empty() && snap.iter().all(|w| w.points.is_empty()) @@ -1744,62 +1759,17 @@ impl OpenCADStudio { ); } } - let driven_refs: Vec<_> = grip - .targets - .iter() - .filter_map(|target| { - let entity = self.tabs[i] - .scene - .document - .get_entity(target.handle)?; - crate::scene::parametric_constraints::driven_ref_for_grip( - entity, - target.handle, - target.grip_id, - ) - }) - .collect(); - // Re-solve constrained neighbors on each drag frame and include - // their original state in the gesture's undo record. - let solved_by_constraints = self.tabs[i] - .scene - .solve_parametric_constraints_preview( - &edited_handles, - &driven_refs, - self.constraint_solve_mode && !driven_refs.is_empty(), - &self.grip_originals, - ); - for (handle, _) in &solved_by_constraints { - let handle = *handle; - if !self.grip_preview_handles.contains(&handle) { - if let Some(original) = self.tabs[i].scene.document.get_entity(handle).cloned() - { - self.grip_originals.push((handle, original)); - } - self.grip_preview_handles.push(handle); - if !self.tabs[i].scene.meshes.contains_key(&handle) { - self.tabs[i].scene.preview_hidden.insert(handle); - } - } - if !edited_handles.contains(&handle) { - edited_handles.push(handle); - } - } - for (handle, new_entity) in solved_by_constraints { - if let Some(slot) = self.tabs[i].scene.document.get_entity_mut(handle) { - *slot = new_entity; - } - } - let mesh_changes: Vec<_> = edited_handles + self.solve_grip_constraints(i, &grip); + let mesh_changes: Vec<_> = self.grip_preview_handles .iter() .copied() .filter(|handle| self.tabs[i].scene.meshes.contains_key(handle)) .map(|handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); if !mesh_changes.is_empty() { - self.tabs[i].scene.bump_entities(&mesh_changes); + self.tabs[i].scene.bump_entities_after_parametric_solve(&mesh_changes); } - self.tabs[i].scene.set_preview_hatches(&edited_handles); + self.tabs[i].scene.set_preview_hatches(&self.grip_preview_handles); self.tabs[i].dirty = true; if let Some(active) = self.tabs[i].active_grip.as_mut() { active.last_world = snapped; @@ -1825,7 +1795,7 @@ impl OpenCADStudio { self.tabs[i].scene.set_preview_wires(Vec::new()); } else { // Current deformed geometry. - let mut preview = self.tabs[i].scene.grip_wire_models_for(&edited_handles); + let mut preview = self.tabs[i].scene.grip_wire_models_for(&self.grip_preview_handles); // Also show the drag-start geometry as a faint ghost. // @@ -1954,22 +1924,31 @@ impl OpenCADStudio { // content), so snap / hit-test / preview run exactly like the // main model view — no paper projection, tracks pan/zoom/twist. let cursor_world = self.cursor_model_point(i, &edit_cam, p, bounds); - let (view_rot, eye, grid_spacing) = match &edit_cam { + let (gx, gy, adaptive) = (self.grid_spacing_x, self.grid_spacing_y, self.grid_adaptive); + let visible_step = |distance: f32, fov_y: f32| { + let (sx, sy) = crate::ui::overlay::compute_grid_steps( + gx, gy, distance, fov_y, bounds, adaptive, + ); + sx.max(sy) + }; + let (view_rot, eye, grid_spacing) = match &edit_cam { Some(cam) => ( cam.view_proj_rte(bounds), cam.eye(), - crate::ui::overlay::compute_grid_step(cam.distance, cam.fov_y, bounds), + visible_step(cam.distance, cam.fov_y), ), None => { let cam = self.tabs[i].scene.camera.borrow(); ( cam.view_proj_rte(bounds), cam.eye(), - crate::ui::overlay::compute_grid_step(cam.distance, cam.fov_y, bounds), + visible_step(cam.distance, cam.fov_y), ) } }; - // Sync grid-snap spacing to the adaptive spacing of the visible grid. + // Sync the adaptive visible-grid step. It drives display and + // snap-aperture tolerances — grid *snap* itself locks to the + // fixed SNAPUNIT X/Y spacing from the Drafting Settings dialog. self.snapper.grid_spacing = grid_spacing; // Cursor and wires are model-space; the snap result is model. let snap_cursor = cursor_world; @@ -2058,6 +2037,64 @@ impl OpenCADStudio { ) }; + // Paper-space snapping through layout viewports. `edit_cam` + // is None here, so `cursor_world` is the paper point and the sheet + // fills the canvas (pane-local == canvas pixels). The viewport hit + // arrives already projected onto the sheet, so everything + // downstream keeps working in paper coordinates — LINE still draws + // at the projected paper point. + self.vp_snap_frame = None; + if edit_cam.is_none() + && !needs_entity + && !is_gathering + && !needs_structure + && !needs_tan + { + if let Some((vp_hit, frame)) = + self.paper_viewport_snap(i, p_full, vp_size, cursor_world) + { + let merged = + crate::snap::merge_snap(self.tabs[i].snap_result, Some(vp_hit), p_full); + if merged.is_some_and(|hit| hit.viewport.is_some()) { + self.vp_snap_frame = Some(frame); + } + self.tabs[i].snap_result = merged; + } + } + + // Solid-face snaps (Nearest/Perpendicular to face): live + // mesh-triangle evaluation merged priority-aware with the wire + // snap above. Gated like ordinary point picks; the helper itself + // no-ops unless a face mode is on. + if !needs_entity + && !is_gathering + && !needs_structure + && !needs_tan + && !is_window_corner + && (self + .snapper + .is_on_3d(crate::snap::SnapType::FacePerpendicular) + || self + .snapper + .is_on_3d(crate::snap::SnapType::NearestFace)) + { + let face_hit = self.tabs[i].scene.solid_face_snaps( + p, + view_rot, + eye, + bounds, + self.snapper.osnap_radius_px, + self.last_point, + self.snapper + .is_on_3d(crate::snap::SnapType::FacePerpendicular), + self.snapper.is_on_3d(crate::snap::SnapType::NearestFace), + ); + if face_hit.is_some() { + self.tabs[i].snap_result = + crate::snap::merge_snap(self.tabs[i].snap_result, face_hit, p); + } + } + let wants_point = self.tabs[i].active_cmd.as_ref().is_some_and(|command| { !command.needs_entity_pick() && !command.needs_tangent_pick() @@ -2225,10 +2262,15 @@ impl OpenCADStudio { pt }; // Clamp to world XY only when no UCS is active; with a - // UCS the point already lies on the UCS XY plane. + // UCS the point already lies on the UCS XY plane. A genuine + // 3D object snap keeps its elevation — only grid snaps and + // misses are flattened. if self.tabs[i].active_cmd.is_some() && self.tabs[i].active_ucs.is_none() && !uses_command_cursor_plane + && !snap_keeps_elevation( + self.tabs[i].snap_result.map(|s| s.snap_type), + ) { pt.z = 0.0; } @@ -2347,6 +2389,10 @@ impl OpenCADStudio { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: None, + secondary_source: None, + model_point: None, }); if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { cmd.set_acquisition_hint(Some(pick.label)); @@ -2508,6 +2554,8 @@ impl OpenCADStudio { } } p + } else if let Some(wires) = self.dimension_preview_wires(i, effective) { + wires } else { self.tabs[i] .active_cmd @@ -2747,18 +2795,26 @@ impl OpenCADStudio { // Object/grid snap, same path as an entity grip or command drag: the // dragged UCS point sticks to endpoints/midpoints/grid under the cursor, // and the snap marker is published via `snap_result`. + let (dgx, dgy, dadaptive) = + (self.grid_spacing_x, self.grid_spacing_y, self.grid_adaptive); + let dvisible_step = |distance: f32, fov_y: f32| { + let (sx, sy) = crate::ui::overlay::compute_grid_steps( + dgx, dgy, distance, fov_y, bounds, dadaptive, + ); + sx.max(sy) + }; let (view_rot, eye, grid_spacing) = match &edit_cam { Some(cam) => ( cam.view_proj_rte(bounds), cam.eye(), - crate::ui::overlay::compute_grid_step(cam.distance, cam.fov_y, bounds), + dvisible_step(cam.distance, cam.fov_y), ), None => { let cam = self.tabs[i].scene.camera.borrow(); ( cam.view_proj_rte(bounds), cam.eye(), - crate::ui::overlay::compute_grid_step(cam.distance, cam.fov_y, bounds), + dvisible_step(cam.distance, cam.fov_y), ) } }; @@ -2983,7 +3039,7 @@ impl OpenCADStudio { cursor: iced::Point, ) -> Option<( crate::scene::parametric_constraints::ConstraintKind, - Vec, + Vec, )> { if self.tabs[i].scene.current_layout != "Model" { return None; @@ -2995,14 +3051,12 @@ impl OpenCADStudio { vp_size, self.show_constraint_values, self.constraint_bar_display, + self.constraint_bar_mode, cursor, )?; let set = self.tabs[i].scene.parametric_constraint_set(scope)?; let constraint = set.get(id)?; - let mut handles: Vec<_> = constraint.refs.iter().map(|r| r.entity).collect(); - handles.sort_unstable_by_key(|handle| handle.value()); - handles.dedup(); - Some((constraint.kind, handles)) + Some((constraint.kind, constraint.refs.clone())) } pub(super) fn on_viewport_left_press(&mut self) -> Task { @@ -3061,7 +3115,10 @@ impl OpenCADStudio { let (vw, vh) = vp_size; if self.tabs[i].active_cmd.is_none() { - if let Some((kind, handles)) = self.constraint_glyph_under(i, p) { + if let Some((kind, references)) = self.constraint_glyph_under(i, p) { + let mut handles: Vec<_> = references.iter().map(|reference| reference.entity).collect(); + handles.sort_unstable_by_key(|handle| handle.value()); + handles.dedup(); self.tabs[i].scene.deselect_all(); self.tabs[i].scene.select_entities(&handles); self.refresh_selected_grips(); @@ -3395,102 +3452,21 @@ impl OpenCADStudio { let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.clear_left_selection_gesture(); } + // Grip-menu Base Point: this click re-bases the gesture at the + // cursor instead of placing anything. + if self.tabs[i].grip_base_pending { + self.tabs[i].grip_base_pending = false; + if let Some(active) = self.tabs[i].active_grip.as_mut() { + active.origin_world = active.last_world; + } + return Task::none(); + } let moved = grip.last_world != grip.origin_world; if is_click && !moved { // Engaging click — stay hot, wait for the placement click. return Task::none(); } - if matches!( - grip.mode, - GripEditMode::Lengthen - | GripEditMode::Radius - | GripEditMode::ArcLength - | GripEditMode::RectangleWidth - | GripEditMode::RectangleHeight - | GripEditMode::MoveParallel - ) { - self.grip_pending = None; - self.command_line.input.clear(); - } - let added_vertex_focus = grip.targets.iter().find_map(|target| { - let original = self - .grip_originals - .iter() - .find(|(handle, _)| *handle == target.handle) - .map(|(_, entity)| entity)?; - let current = self.tabs[i].scene.document.get_entity(target.handle)?; - is_added_polyline_vertex(original, current, target.grip_id) - .then_some(target.grip_id) - }); - // Keep originals available until every history shape has a valid - // final display. A rejected rebuild cancels the entire gesture. - let history_handles: Vec<_> = self - .grip_preview_handles - .iter() - .copied() - .filter(|handle| { - self.tabs[i] - .scene - .document - .solid_history_operation(*handle) - .is_some() - }) - .collect(); - for handle in history_handles { - if !self.tabs[i].scene.finalize_solid_history(handle) { - self.cancel_active_grip_edit(); - self.command_line.push_error(crate::t!("The edited shape could not be displayed; the original geometry was restored.").as_ref()); - self.refresh_properties(); - return Task::none(); - } - } - self.tabs[i].active_grip = None; - // Commit the grip drag as one undoable group, then put every - // edited entity back into the resident tessellation. - let handles = std::mem::take(&mut self.grip_preview_handles); - let originals = std::mem::take(&mut self.grip_originals); - let history_originals = std::mem::take(&mut self.grip_history_originals); - let dirty_before = self.grip_dirty_before.take().unwrap_or(self.tabs[i].dirty); - if !handles.is_empty() { - if !originals.is_empty() { - self.push_entity_group_history( - i, - "GRIP", - originals - .into_iter() - .map(|(handle, entity)| (handle, std::sync::Arc::new(entity))) - .collect(), - history_originals - .into_iter() - .flat_map(|(_, objects)| objects) - .collect(), - dirty_before, - ); - self.tabs[i].dirty = true; - } - self.grip_reference_wires.clear(); - self.grip_text_verts = Vec::new(); - self.grip_text_slide = false; - for &handle in &handles { - self.tabs[i].scene.preview_hidden.remove(&handle); - } - self.tabs[i].scene.clear_preview_wire(); - let changes: Vec<_> = handles - .into_iter() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities(&changes); - } - // Placement confirmed — keep the just-added leader. - self.grip_add_provisional = None; - self.tabs[i].snap_result = None; - if let Some(vertex_id) = added_vertex_focus { - self.tabs[i].properties.prop_vertex = vertex_id; - self.tabs[i].properties.prop_vertex_indicator_active = true; - crate::scene::view::dispatch::set_prop_current_vertex(vertex_id); - } - self.refresh_properties(); - return Task::none(); + return self.commit_active_grip_edit(); } // Map the release point into the active Model tile so the @@ -3671,17 +3647,65 @@ impl OpenCADStudio { } } } + // Paper-space snapping through layout viewports. Mirrors + // the cursor-move pass: the click recomputes snapping from + // scratch, so the viewport query has to run here too. The hit + // comes back already projected onto the sheet. + let mut click_frame: Option = None; + if edit_cam.is_none() && !needs_entity_click && !needs_tan { + if let Some((vp_hit, frame)) = + self.paper_viewport_snap(i, p_full, (vw, vh), raw) + { + let merged = crate::snap::merge_snap(snap_hit, Some(vp_hit), p_full); + if merged.is_some_and(|hit| hit.viewport.is_some()) { + click_frame = Some(frame); + } + snap_hit = merged; + } + } + // Solid-face snaps (Nearest/Perpendicular to face), mirroring + // the cursor-move pass so click and preview agree. Merged + // before the accepted-snap record so face sources associate. + if !needs_entity_click + && !needs_tan + && !is_window_corner + && (self + .snapper + .is_on_3d(crate::snap::SnapType::FacePerpendicular) + || self + .snapper + .is_on_3d(crate::snap::SnapType::NearestFace)) + { + let face_hit = self.tabs[i].scene.solid_face_snaps( + p, + view_rot, + eye, + bounds, + self.snapper.osnap_radius_px, + self.last_point, + self.snapper + .is_on_3d(crate::snap::SnapType::FacePerpendicular), + self.snapper.is_on_3d(crate::snap::SnapType::NearestFace), + ); + if face_hit.is_some() { + snap_hit = crate::snap::merge_snap(snap_hit, face_hit, p); + } + } + self.pending_click_snap = snap_hit.map(|hit| (hit, click_frame)); // Snap runs in model space; the result is already model. let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw); // When no UCS is active clamp to world XY; with a UCS the point is // already constrained to that plane by the ray–plane intersection. + // A genuine 3D object snap keeps its elevation — only grid snaps + // and misses are flattened. let uses_command_cursor_plane = self.tabs[i] .active_cmd .as_ref() .and_then(|command| command.cursor_plane()) .is_some(); - let clamp_world_xy = - self.tabs[i].active_ucs.is_none() && !uses_command_cursor_plane; + let clamp_world_xy = self.tabs[i].active_ucs.is_none() + && !uses_command_cursor_plane + && !snap_keeps_elevation(snap_hit.map(|s| s.snap_type)); if clamp_world_xy { pt.z = 0.0; } @@ -3998,6 +4022,10 @@ impl OpenCADStudio { } } + if !self.dimension_acquisition_allowed(i, None) { + self.finish_command_click(i); + return Task::none(); + } let shift = self.shift_down; let result = self.tabs[i].active_cmd.as_mut().map(|c| { // Shift-swap state for TRIM/EXTEND (#336). @@ -4005,6 +4033,7 @@ impl OpenCADStudio { c.set_entity_pick_direction(entity_pick_direction); c.on_entity_pick(handle, entity_pick_point) }); + self.record_dimension_entity_points(i, None, handle, Vec::new()); // HATCHEDIT: after pick, inject hatch model data into the command. if self.tabs[i] .active_cmd @@ -4075,11 +4104,39 @@ impl OpenCADStudio { } } result + } else if let Some(result) = { + let dimension = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|c| c.measures_through_viewports()); + if dimension { + let per_px = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0 + / (bounds.height as f64).max(1.0); + self.try_dimension_viewport_entity_pick( + i, + pick_wcs, + crate::ui::overlay::pick_box_aperture_px(self.pick_box) as f64 * per_px, + ) + } else { + None + } + } { + Some(result) } else if self.tabs[i] .active_cmd .as_ref() .is_some_and(|command| command.entity_pick_accepts_points()) { + let pending = self.pending_click_snap.take(); + if !self.record_accepted_snap( + i, + pending.map(|(hit, _)| hit), + pending.and_then(|(_, frame)| frame), + pick_wcs, + ) { + self.finish_command_click(i); + return Task::none(); + } self.refresh_command_point_pick_context(i); self.tabs[i] .active_cmd @@ -4148,6 +4205,20 @@ impl OpenCADStudio { self.tabs[i].dyn_active = 0; } } + // Record what this point step accepted, alongside the point + // itself: paper point, model point, viewport, frame and + // geometry identity. Commands that don't care keep consuming + // `world_pt` exactly as before. + let pending = self.pending_click_snap.take(); + if !self.record_accepted_snap( + i, + pending.map(|(hit, _)| hit), + pending.and_then(|(_, frame)| frame), + world_pt, + ) { + self.finish_command_click(i); + return Task::none(); + } self.last_point = Some(world_pt); // The one-shot snap override is spent by this pick — // restore the running osnap configuration (#337). @@ -4183,6 +4254,7 @@ impl OpenCADStudio { } }; + self.sync_dimension_snaps(i); if let Some(r) = result { let task = self.apply_cmd_result(r); let mut sel = self.tabs[i].scene.selection.borrow_mut(); @@ -4506,6 +4578,7 @@ properties={:.1}ms picked={}", canvas_sz, self.show_constraint_values, self.constraint_bar_display, + self.constraint_bar_mode, p_full, ) }) @@ -5179,6 +5252,7 @@ properties={:.1}ms picked={}", } self.tabs[i].scene.set_hover_highlight(None); self.hover_dwell = None; + self.constraint_glyph_tooltip = None; self.grip_hover = None; self.ucs_icon_hover = false; } @@ -5324,7 +5398,7 @@ properties={:.1}ms picked={}", /// model-space view moves (zoom/pan), so the selection rectangle tracks the /// drawing instead of staying frozen at its original pixel. No-op when no /// box is in progress. (#234) - pub(super) fn reproject_box_anchor(&mut self, i: usize, vw: f32, vh: f32) { + pub(in crate::app) fn reproject_box_anchor(&mut self, i: usize, vw: f32, vh: f32) { let world = self.tabs[i].scene.selection.borrow().box_anchor_world; let Some(world) = world else { return }; let tile_b = self.tabs[i].scene.active_model_tile_bounds(vw, vh); @@ -5437,9 +5511,10 @@ properties={:.1}ms picked={}", } let eye_dir = r_ucs.transform_vector3(region.snap_direction()); - // Faces snap to a canonical upright orientation (never upside - // down); edges/corners keep the current up-sense so they spin - // smoothly around the clicked feature. + // Faces and edges/corners all snap deterministically (world +Z + // horizon, north +Y for top/bottom) so a cube click is repeatable + // and matches the turntable orbit; snap_to_direction preserves an + // intentional upside-down sense by sign-flipping only. let is_face = matches!(region, scene::CubeRegion::Face(_)); if self.tabs[i].scene.active_viewport.is_some() { if is_face { @@ -5500,6 +5575,10 @@ properties={:.1}ms picked={}", self.clear_navigation_hover(i); return Task::none(); } + let cursor = self.tabs[i].scene.selection.borrow().last_move_pos; + self.constraint_glyph_tooltip = cursor.and_then(|point| { + self.constraint_glyph_under(i, point).map(|(kind, _)| kind) + }); let bounds = iced::Rectangle { x: 0.0, y: 0.0, @@ -5955,24 +6034,7 @@ properties={:.1}ms picked={}", 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); - l.max_extents = (297.0, 210.0, 0.0); - l.paper_width = 297.0; - l.paper_height = 210.0; - 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(); + crate::scene::apply_default_page_setup(l, &plot_style); break; } } @@ -5986,6 +6048,12 @@ properties={:.1}ms picked={}", crate::tf!("Layout \"{new_name}\" created — use MVIEW to add a viewport") .as_ref(), ); + // The option that greets every new layout with its page setup. + let switch_task = if self.plot_dialog.page_setup_on_new_layout { + Task::batch([switch_task, self.on_plot_dialog_open()]) + } else { + switch_task + }; self.tabs[i].dirty = true; return Task::batch([cancel_task, switch_task]); } @@ -6062,6 +6130,194 @@ properties={:.1}ms picked={}", } Task::none() } + + /// Commit the active grip edit at its current (already applied) position: + /// one undoable group per gesture, every edited entity back into the + /// resident tessellation. With the grip-menu Copy toggle on, the + /// originals are put back and the moved shapes are added as new + /// entities, and the grip stays hot for the next copy. + pub(in crate::app) fn commit_active_grip_edit(&mut self) -> Task { + let i = self.active_tab; + let Some(grip) = self.tabs[i].active_grip.clone() else { + return Task::none(); + }; + if grip.last_world == grip.origin_world { + // Nothing placed yet — the grip simply stays hot. + return Task::none(); + } + if self.tabs[i].grip_copy { + return self.commit_active_grip_edit_as_copy(grip); + } + self.tabs[i].grip_base_pending = false; + if matches!( + grip.mode, + GripEditMode::Lengthen + | GripEditMode::Radius + | GripEditMode::ArcLength + | GripEditMode::RectangleWidth + | GripEditMode::RectangleHeight + | GripEditMode::MoveParallel + ) { + self.grip_pending = None; + self.command_line.input.clear(); + } + let added_vertex_focus = grip.targets.iter().find_map(|target| { + let original = self + .grip_originals + .iter() + .find(|(handle, _)| *handle == target.handle) + .map(|(_, entity)| entity)?; + let current = self.tabs[i].scene.document.get_entity(target.handle)?; + is_added_polyline_vertex(original, current, target.grip_id) + .then_some(target.grip_id) + }); + // Keep originals available until every history shape has a valid + // final display. A rejected rebuild cancels the entire gesture. + let history_handles: Vec<_> = self + .grip_preview_handles + .iter() + .copied() + .filter(|handle| { + self.tabs[i] + .scene + .document + .solid_history_operation(*handle) + .is_some() + }) + .collect(); + for handle in history_handles { + if !self.tabs[i].scene.finalize_solid_history(handle) { + self.cancel_active_grip_edit(); + self.command_line.push_error(crate::t!("The edited shape could not be displayed; the original geometry was restored.").as_ref()); + self.refresh_properties(); + return Task::none(); + } + } + self.tabs[i].active_grip = None; + // Commit the grip drag as one undoable group, then put every + // edited entity back into the resident tessellation. + let handles = std::mem::take(&mut self.grip_preview_handles); + let originals = std::mem::take(&mut self.grip_originals); + let history_originals = std::mem::take(&mut self.grip_history_originals); + let dirty_before = self.grip_dirty_before.take().unwrap_or(self.tabs[i].dirty); + if !handles.is_empty() { + if !originals.is_empty() { + self.push_entity_group_history( + i, + "GRIP", + originals + .into_iter() + .map(|(handle, entity)| (handle, std::sync::Arc::new(entity))) + .collect(), + history_originals + .into_iter() + .flat_map(|(_, objects)| objects) + .collect(), + dirty_before, + ); + self.tabs[i].dirty = true; + } + self.grip_reference_wires.clear(); + self.grip_text_verts = Vec::new(); + self.grip_text_slide = false; + for &handle in &handles { + self.tabs[i].scene.preview_hidden.remove(&handle); + } + self.tabs[i].scene.clear_preview_wire(); + let changes: Vec<_> = handles + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_after_parametric_solve(&changes); + } + // Placement confirmed — keep the just-added leader. + self.grip_add_provisional = None; + self.tabs[i].snap_result = None; + if let Some(vertex_id) = added_vertex_focus { + self.tabs[i].properties.prop_vertex = vertex_id; + self.tabs[i].properties.prop_vertex_indicator_active = true; + crate::scene::view::dispatch::set_prop_current_vertex(vertex_id); + } + self.refresh_properties(); + Task::none() + } + + /// Grip-menu Copy: leave the originals where they were and add the + /// edited shapes as new entities, then re-arm the same grip at its + /// origin so the next placement makes another copy (grip Copy, as in commercial solutions). + fn commit_active_grip_edit_as_copy(&mut self, grip: GripEdit) -> Task { + let i = self.active_tab; + let handles = std::mem::take(&mut self.grip_preview_handles); + let originals = std::mem::take(&mut self.grip_originals); + let _ = std::mem::take(&mut self.grip_history_originals); + self.grip_dirty_before = None; + self.grip_add_provisional = None; + self.grip_reference_wires.clear(); + self.grip_text_verts = Vec::new(); + self.grip_text_slide = false; + for &handle in &handles { + self.tabs[i].scene.preview_hidden.remove(&handle); + } + self.tabs[i].scene.clear_preview_wire(); + if !originals.is_empty() { + self.push_undo_snapshot(i, "GRIP"); + let mut restored = Vec::with_capacity(originals.len()); + for (handle, original) in &originals { + let Some(moved) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + continue; + }; + if self.tabs[i].scene.update_entity(original.clone()) { + restored.push((*handle, crate::scene::ChangeKind::Modified)); + } + self.tabs[i].scene.add_entity_clone(moved); + } + self.tabs[i].scene.bump_entities_after_parametric_solve(&restored); + self.finish_pending_history(i); + self.tabs[i].dirty = true; + } + self.tabs[i].snap_result = None; + // Re-arm: the grip sits at its origin again over the restored shape. + let mut again = grip; + let delta = again.last_world - again.origin_world; + again.last_world = again.origin_world; + for target in &mut again.targets { + target.last_world -= delta; + } + self.tabs[i].active_grip = Some(again); + self.refresh_properties(); + Task::none() + } + +} + +#[cfg(test)] +mod snap_elevation_tests { + use super::snap_keeps_elevation; + use crate::snap::SnapType; + + #[test] + fn only_object_snaps_keep_elevation() { + assert!(!snap_keeps_elevation(None), "a miss stays on the plane"); + assert!(!snap_keeps_elevation(Some(SnapType::Grid)), "grid stays planar"); + for t in [ + SnapType::Endpoint, + SnapType::Midpoint, + SnapType::Center, + SnapType::Node, + SnapType::Quadrant, + SnapType::Intersection, + SnapType::Insertion, + SnapType::Perpendicular, + SnapType::Tangent, + SnapType::Nearest, + SnapType::ApparentIntersection, + SnapType::Extension, + SnapType::Parallel, + SnapType::ObjectPick, + ] { + assert!(snap_keeps_elevation(Some(t)), "{t:?} must keep its Z"); + } + } } #[cfg(test)] @@ -6069,6 +6325,128 @@ mod selection_preview_tests { use super::*; use crate::app::{HoverDwell, OpenCADStudio, HOVER_DWELL_MS}; + #[test] + fn lower_endpoint_drag_previews_connected_tangent_profile_and_keeps_top_fixed() { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; + use acadrust::{entities::{Arc, Line}, types::Vector3, EntityType}; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = false; + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.constraint_solve_mode = true; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let scene = &mut app.tabs[i].scene; + let [outer, inner, upper, lower] = [ + ([0.0, 0.0], [0.0, 5.0]), + ([-1.0, -1.0], [-1.0, 5.0]), + ([4.0, 0.0], [0.0, 0.0]), + ([4.0, -1.0], [-1.0, -1.0]), + ].map(|(a, b)| scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(a[0], a[1], 0.0), Vector3::new(b[0], b[1], 0.0), + )))); + let top = scene.add_entity(EntityType::Arc(Arc::from_coords( + -0.5, 5.0, 0.0, 0.5, 0.0, std::f64::consts::PI, + ))); + let right = scene.add_entity(EntityType::Arc(Arc::from_coords( + 4.0, -0.5, 0.0, 0.5, -std::f64::consts::FRAC_PI_2, + std::f64::consts::FRAC_PI_2, + ))); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + for (a, ai, b, bi) in [ + (outer, 1, top, 0), (top, 1, inner, 1), + (outer, 0, upper, 1), (upper, 0, right, 1), + (inner, 0, lower, 1), (right, 0, lower, 0), + ] { + set.add(ConstraintKind::Coincident, + vec![ParametricRef::point(a, ai), ParametricRef::point(b, bi)], None); + } + for (kind, a, b) in [ + (ConstraintKind::Parallel, outer, inner), + (ConstraintKind::Parallel, upper, lower), + (ConstraintKind::Tangent, outer, top), + (ConstraintKind::Tangent, inner, top), + (ConstraintKind::Tangent, upper, right), + (ConstraintKind::Tangent, lower, right), + (ConstraintKind::Equal, top, right), + ] { + set.add(kind, vec![ParametricRef::whole(a), ParametricRef::whole(b)], None); + } + set.add(ConstraintKind::Horizontal, vec![ParametricRef::whole(lower)], None); + let handles = if let Ok(path) = std::env::var("OCS_GRIP_TEST_DRAWING") { + let result = app.automation_op(&serde_json::json!({"op":"open","path":path}).to_string()); + assert_eq!(result["ok"], true, "{result}"); + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + [0x57F, 0x57D, 0x57C, 0x57A, 0x57E, 0x57B].map(Handle::new) + } else { + [outer, inner, upper, lower, top, right] + }; + let inner = handles[1]; + let geometry = |app: &OpenCADStudio| handles.map(|handle| + app.tabs[i].scene.document.get_entity(handle).unwrap().clone()); + let before = geometry(&app); + let EntityType::Line(original_line) = &before[1] else { panic!("line") }; + let lower_endpoint = original_line.start; + let upper_endpoint = original_line.end; + let EntityType::Arc(original_arc) = &before[4] else { panic!("arc") }; + let radius = original_arc.radius; + app.tabs[i].scene.selected.insert(inner); + app.refresh_selected_grips(); + app.tabs[i].active_grip = Some(GripEdit::single( + inner, 0, false, glam::DVec3::new(lower_endpoint.x, lower_endpoint.y, 0.0), + )); + let check_profile = |app: &OpenCADStudio| { + let entities = geometry(app); + let EntityType::Line(line) = &entities[1] else { panic!("line") }; + assert_eq!(line.end, upper_endpoint, "top endpoint drifted"); + let EntityType::Arc(arc) = &entities[4] else { panic!("arc") }; + assert!((arc.end_point() - line.end).length() < 1e-7, "top arc detached"); + for index in [4, 5] { + let EntityType::Arc(arc) = &entities[index] else { panic!("arc") }; + assert!((arc.radius - radius).abs() < 1e-7, "parallel gap changed"); + } + for (line_index, arc_index, marker) in [(0, 4, 0), (1, 4, 1), (2, 5, 1), (3, 5, 0)] { + let EntityType::Line(line) = &entities[line_index] else { panic!("line") }; + let EntityType::Arc(arc) = &entities[arc_index] else { panic!("arc") }; + let contact = if marker == 0 { arc.start_point() } else { arc.end_point() }; + let endpoint = if line_index < 2 { line.end } else { line.start }; + assert!((contact - endpoint).length() < 1e-7, "tangent contact detached"); + let direction = line.end - line.start; + assert!(direction.dot(&(contact - arc.center)).abs() < 1e-7, + "tangency lost: line={line:?}, arc={arc:?}, error={}", direction.dot(&(contact - arc.center))); + } + }; + for offset in [[-0.4, -0.5], [-1.0, -1.0], [-1.0, -1.0], [0.3, 0.4]] { + let target = [lower_endpoint.x + offset[0], lower_endpoint.y + offset[1]]; + let cursor = app.tabs[i].scene.camera.borrow().project( + glam::DVec3::new(target[0], target[1], 0.0), + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)), + ).unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + check_profile(&app); + let EntityType::Line(line) = app.tabs[i].scene.document.get_entity(inner).unwrap() + else { panic!("line") }; + assert!((line.start - Vector3::new(target[0], target[1], 0.0)).length() < 1e-5); + for handle in handles { + assert!(app.tabs[i].scene.preview_wires.iter().any(|wire| + wire.name == handle.value().to_string() && wire.color[3] > 0.9), + "connected entity {handle:?} missing from preview"); + } + } + let preview = geometry(&app); + let _ = app.on_viewport_left_release(); + check_profile(&app); + assert_eq!(geometry(&app), preview, "release changed the preview solution"); + app.undo_steps(1); + assert_eq!(geometry(&app), before); + app.redo_steps(1); + assert_eq!(geometry(&app), preview); + } + #[test] fn grip_moves_keep_perpendicular_constraints_live_and_undoable() { use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; @@ -6133,7 +6511,11 @@ mod selection_preview_tests { assert!(direction(current[0]).dot(direction(current[1])).abs() < 1e-8); assert_ne!( current[1], before[1], - "constrained neighbor must follow each grip frame" + "the constrained neighbor must follow the edited line" + ); + assert_eq!( + current[0][0], before[0][0], + "the endpoint opposite the dragged grip must stay fixed" ); } let _ = app.on_viewport_left_release(); @@ -6145,6 +6527,173 @@ mod selection_preview_tests { assert_eq!(geometry(&app), after); } + #[test] + fn constrained_rectangle_grips_distinguish_perpendicular_corner_and_free_ends() { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; + use acadrust::{entities::LwPolyline, types::Vector2, EntityType}; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = false; + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.constraint_solve_mode = true; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let mut rectangle = LwPolyline::from_points(vec![ + Vector2::new(0.0, 0.0), + Vector2::new(4.0, 0.0), + Vector2::new(4.0, 2.0), + Vector2::new(0.0, 2.0), + ]); + rectangle.is_closed = true; + let handle = app.tabs[i].scene.add_entity(EntityType::LwPolyline(rectangle)); + let set = app.tabs[i].scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add(ConstraintKind::Parallel, vec![ParametricRef::segment(handle, 0), ParametricRef::segment(handle, 2)], None); + set.add(ConstraintKind::Parallel, vec![ParametricRef::segment(handle, 1), ParametricRef::segment(handle, 3)], None); + set.add(ConstraintKind::Perpendicular, vec![ParametricRef::segment(handle, 3), ParametricRef::segment(handle, 2)], None); + set.add(ConstraintKind::Horizontal, vec![ParametricRef::segment(handle, 2)], None); + let handle = if let Ok(path) = std::env::var("OCS_GRIP_TEST_DRAWING") { + let result = app.automation_op(&serde_json::json!({"op":"open","path":path}).to_string()); + assert_eq!(result["ok"], true, "{result}"); + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + Handle::new(0x556) + } else { + handle + }; + let vertices = |app: &OpenCADStudio| { + let EntityType::LwPolyline(polyline) = + app.tabs[i].scene.document.get_entity(handle).unwrap() + else { + panic!("expected polyline") + }; + polyline.vertices.iter().map(|vertex| vertex.location).collect::>() + }; + let before = vertices(&app); + let height = before[3].y - before[0].y; + let width = before[1].x - before[0].x; + for grip_id in 0..8 { + let vertex = if grip_id < 4 { before[grip_id] } + else { (before[grip_id - 4] + before[(grip_id - 3) % 4]) * 0.5 }; + let origin = glam::DVec3::new(vertex.x, vertex.y, 0.0); + app.tabs[i].active_grip = Some(GripEdit::single(handle, grip_id, grip_id >= 4, origin)); + for offset in [[-1.0, height], [-1.0, 3.0], [-2.0, 6.0], [-2.0, 6.0], [1.0, -4.0]] { + let target = origin + glam::DVec3::new(offset[0], offset[1], 0.0); + let cursor = app.tabs[i].scene.camera.borrow().project(target, + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0))).unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + let after = vertices(&app); + let (left, right, bottom, top) = match grip_id { + 0 => (target.x, target.x + width, target.y, before[3].y), + 1 => (before[0].x, target.x, target.y, before[3].y), + 2 => (before[0].x, target.x, target.y - height, target.y), + 3 => (target.x, target.x + width, target.y - height, target.y), + 4 => (before[0].x + offset[0], before[1].x + offset[0], target.y, before[3].y), + 5 => (before[0].x, target.x, before[0].y + offset[1], before[3].y + offset[1]), + 6 => (before[0].x + offset[0], before[1].x + offset[0], before[0].y, target.y), + 7 => (target.x, before[1].x, before[0].y + offset[1], before[3].y + offset[1]), + _ => unreachable!(), + }; + let expected = [Vector2::new(left, bottom), Vector2::new(right, bottom), + Vector2::new(right, top), Vector2::new(left, top)]; + for (actual, expected) in after.iter().zip(expected) { + assert!((*actual - expected).length_squared() < 1e-10, + "grip {grip_id}: rectangle collapsed or drifted: {after:?}, expected {expected:?}"); + } + } + let preview = vertices(&app); + let _ = app.on_viewport_left_release(); + assert_eq!(vertices(&app), preview); + app.undo_steps(1); + assert_eq!(vertices(&app), before); + app.redo_steps(1); + assert_eq!(vertices(&app), preview); + app.undo_steps(1); + } + } + + #[test] + fn axis_constrained_line_grip_changes_length_and_translates_normal_to_axis() { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; + use acadrust::{entities::Line, types::Vector3, EntityType}; + + for vertical in [false, true] { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = false; + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.constraint_solve_mode = true; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let end = if vertical { Vector3::new(0.0, 4.0, 0.0) } else { Vector3::new(4.0, 0.0, 0.0) }; + let handle = app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points(Vector3::ZERO, end))); + app.tabs[i].scene.parametric_constraint_set_mut(ParametricScope::ModelSpace).add( + if vertical { ConstraintKind::Vertical } else { ConstraintKind::Horizontal }, + vec![ParametricRef::whole(handle)], None); + app.tabs[i].active_grip = Some(GripEdit::single(handle, 0, false, glam::DVec3::ZERO)); + for target in [glam::DVec3::new(-1.0, 3.0, 0.0), glam::DVec3::new(2.0, -2.0, 0.0)] { + let cursor = app.tabs[i].scene.camera.borrow().project(target, + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0))).unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + let EntityType::Line(line) = app.tabs[i].scene.document.get_entity(handle).unwrap() + else { panic!("line") }; + let opposite = if vertical { Vector3::new(target.x, end.y, 0.0) } + else { Vector3::new(end.x, target.y, 0.0) }; + assert!((line.start - Vector3::new(target.x, target.y, 0.0)).length() < 1e-5); + assert!((line.end - opposite).length() < 1e-5, "{line:?}"); + } + } + } + + #[test] + fn constraint_glyph_tooltip_appears_after_hover_dwell() { + use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef, ParametricScope}; + use acadrust::{entities::Line, types::Vector3, EntityType}; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let handle = app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(-1.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + app.constraint_bar_display = 3; + let _ = app.apply_cmd_result(crate::command::CmdResult::AddParametricConstraint { + kind: ConstraintKind::Horizontal, + refs: vec![ParametricRef::whole(handle)], + driving_param: None, + label: "Horizontal constraint", + }); + let anchor = app.tabs[i].scene.constraint_glyph_placements_screen( + ParametricScope::ModelSpace, + (800.0, 600.0), + true, + 3, + 4095, + )[0].1; + let point = (-30..=30).find_map(|y| { + (-30..=30).find_map(|x| { + let point = Point::new(anchor.x + x as f32, anchor.y + y as f32); + app.constraint_glyph_under(i, point).is_some().then_some(point) + }) + }).expect("projected constraint glyph should be hit-testable"); + + let _ = app.on_viewport_move(point); + assert_eq!(app.constraint_glyph_tooltip, None); + app.hover_dwell.as_mut().unwrap().last_move_at = + Instant::now() + - std::time::Duration::from_millis(crate::app::HOVER_DWELL_MS as u64 + 1); + let _ = app.on_hover_dwell_tick(); + + assert_eq!(app.constraint_glyph_tooltip, Some(ConstraintKind::Horizontal)); + } + #[cfg(target_os = "linux")] #[test] fn hyperlink_click_respects_existing_pointer_owners() { @@ -6241,4 +6790,525 @@ mod selection_preview_tests { "only the in-command bit: the idle rollover stays off", ); } + + /// Snapping the first LINE point to a 3D vertex must keep its elevation: + /// the marker, the preview point and (via the click path) the committed + /// point all carry the snapped Z instead of dropping to the XY plane. + #[test] + fn line_snap_to_3d_endpoint_keeps_elevation() { + use acadrust::{entities::Line, types::Vector3, EntityType}; + use crate::snap::SnapType; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = true; + app.snapper.enabled.insert(SnapType::Endpoint); + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 25.0), + ))); + let _ = app.run_command_line("ZOOM EXTENTS"); + let _ = app.run_command_line("LINE"); + assert!(app.tabs[i].active_cmd.is_some(), "LINE did not start"); + + let cursor = app.tabs[i] + .scene + .camera + .borrow() + .project( + glam::DVec3::new(10.0, 0.0, 25.0), + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)), + ) + .unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + + let hit = app.tabs[i] + .snap_result + .expect("endpoint snap missed the 3D vertex"); + assert_eq!(hit.snap_type, SnapType::Endpoint); + assert!( + (hit.world.z - 25.0).abs() < 1e-6, + "snap landed off-elevation: {:?}", + hit.world + ); + let preview = app.tabs[i].last_cursor_world; + assert!( + (preview.z - 25.0).abs() < 1e-6, + "command point lost its elevation: {preview:?}" + ); + } + + /// The user's repro: BOX, then LINE snapping its first point to a box + /// corner. Solid B-rep corners snap as 3D Vertex points, gated by the + /// separate 3D master toggle — the 2D Node mode must not catch them. + #[test] + fn line_snaps_to_box_corner() { + use acadrust::{entities::Solid3D, EntityType}; + use crate::snap::SnapType; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + // 2D osnap fully off: only the 3D system may fire. + app.snapper.snap_enabled = false; + app.snapper.snap3d_enabled = true; + app.snapper.enabled3d.insert(SnapType::Vertex); + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + + // In-app BOX equivalent: kernel box committed as a SAT solid. + let base = + crate::scene::model::solid_model::box_solid([0.0, 0.0, 2.5], 10.0, 10.0, 5.0) + .expect("kernel box"); + let placed = crate::scene::model::solid_model::placed( + &base, + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + ) + .expect("place box"); + let sat = crate::scene::convert::acis_export::solid_to_sat(&placed).expect("sat"); + let mut entity = Solid3D::new(); + entity.set_sat_document(&sat); + entity.wires = crate::scene::model::solid_model::edge_wires(&placed); + let h = app.tabs[i].scene.add_entity(EntityType::Solid3D(entity)); + + // A real corner straight from the solid's edge data: the topmost + // one, since coincident corners resolve to the nearer eye. + let target = { + let set = app.tabs[i].scene.meshes.get(&h).expect("box mesh"); + let (edges, lows) = set.geometry_edges(); + assert!(edges.len() >= 2, "box mesh has no B-rep edges"); + let at = |index: usize| { + let hi = edges[index]; + let lo = lows.get(index).copied().unwrap_or([0.0; 3]); + glam::DVec3::new( + hi[0] as f64 + lo[0] as f64, + hi[1] as f64 + lo[1] as f64, + hi[2] as f64 + lo[2] as f64, + ) + }; + (0..edges.len()) + .map(at) + .max_by(|a, b| a.z.total_cmp(&b.z)) + .expect("box mesh has no endpoints") + }; + + let _ = app.run_command_line("ZOOM EXTENTS"); + let _ = app.run_command_line("LINE"); + assert!(app.tabs[i].active_cmd.is_some(), "LINE did not start"); + let cursor = app.tabs[i] + .scene + .camera + .borrow() + .project( + target, + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)), + ) + .unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + + let hit = app.tabs[i] + .snap_result + .expect("no snap point on the box corner"); + assert_eq!( + hit.snap_type, + SnapType::Vertex, + "expected a box-corner vertex, got {:?} at {:?}", + hit.snap_type, + hit.world + ); + assert!( + (hit.world - target).length() < 1e-6, + "snap missed the corner: {:?} vs {target:?}", + hit.world + ); + let preview = app.tabs[i].last_cursor_world; + assert!( + (preview - target).length() < 1e-6, + "command point left the corner: {preview:?} vs {target:?}" + ); + } + + /// With the 3D master off, solid corners are invisible even when the + /// Vertex mode is configured — and the 2D system must not catch them. + #[test] + fn box_corner_ignores_3d_master_off() { + use acadrust::{entities::Solid3D, EntityType}; + use crate::snap::SnapType; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = true; + app.snapper.snap3d_enabled = false; + app.snapper.enabled3d.insert(SnapType::Vertex); + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + + let base = + crate::scene::model::solid_model::box_solid([0.0, 0.0, 2.5], 10.0, 10.0, 5.0) + .expect("kernel box"); + let placed = crate::scene::model::solid_model::placed( + &base, + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + ) + .expect("place box"); + let sat = crate::scene::convert::acis_export::solid_to_sat(&placed).expect("sat"); + let mut entity = Solid3D::new(); + entity.set_sat_document(&sat); + entity.wires = crate::scene::model::solid_model::edge_wires(&placed); + let h = app.tabs[i].scene.add_entity(EntityType::Solid3D(entity)); + + let target = { + let set = app.tabs[i].scene.meshes.get(&h).expect("box mesh"); + let (edges, lows) = set.geometry_edges(); + let hi = edges[0]; + let lo = lows.first().copied().unwrap_or([0.0; 3]); + glam::DVec3::new( + hi[0] as f64 + lo[0] as f64, + hi[1] as f64 + lo[1] as f64, + hi[2] as f64 + lo[2] as f64, + ) + }; + + let _ = app.run_command_line("ZOOM EXTENTS"); + let _ = app.run_command_line("LINE"); + let cursor = app.tabs[i] + .scene + .camera + .borrow() + .project( + target, + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)), + ) + .unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + + assert!( + app.tabs[i].snap_result.is_none(), + "3D master off must hide solid corners, got {:?}", + app.tabs[i].snap_result.map(|s| (s.snap_type, s.world)) + ); + } + + /// Face centres snap as their own 3D type at the loop average (exact + /// for the box's planar faces). + #[test] + fn line_snaps_to_box_face_center() { + use acadrust::{entities::Solid3D, EntityType}; + use crate::snap::SnapType; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = false; + app.snapper.snap3d_enabled = true; + app.snapper.enabled3d.insert(SnapType::FaceCenter); + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + + let base = + crate::scene::model::solid_model::box_solid([0.0, 0.0, 2.5], 10.0, 10.0, 5.0) + .expect("kernel box"); + let placed = crate::scene::model::solid_model::placed( + &base, + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + ) + .expect("place box"); + let sat = crate::scene::convert::acis_export::solid_to_sat(&placed).expect("sat"); + let mut entity = Solid3D::new(); + entity.set_sat_document(&sat); + entity.wires = crate::scene::model::solid_model::edge_wires(&placed); + app.tabs[i].scene.add_entity(EntityType::Solid3D(entity)); + + // Top-face centre of the 10×10×5 box. + let target = glam::DVec3::new(0.0, 0.0, 5.0); + let _ = app.run_command_line("ZOOM EXTENTS"); + let _ = app.run_command_line("LINE"); + let cursor = app.tabs[i] + .scene + .camera + .borrow() + .project( + target, + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)), + ) + .unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + + let hit = app.tabs[i] + .snap_result + .expect("no snap point on the box face"); + assert_eq!( + hit.snap_type, + SnapType::FaceCenter, + "expected a face centre, got {:?} at {:?}", + hit.snap_type, + hit.world + ); + assert!( + (hit.world - target).length() < 1e-6, + "snap missed the face centre: {:?} vs {target:?}", + hit.world + ); + } + + /// Nearest-to-face catches an interior face point with no base point. + #[test] + fn line_snaps_to_box_face_nearest() { + use acadrust::{entities::Solid3D, EntityType}; + use crate::snap::SnapType; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = false; + app.snapper.snap3d_enabled = true; + app.snapper.enabled3d.insert(SnapType::NearestFace); + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + + let base = + crate::scene::model::solid_model::box_solid([0.0, 0.0, 2.5], 10.0, 10.0, 5.0) + .expect("kernel box"); + let placed = crate::scene::model::solid_model::placed( + &base, + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + ) + .expect("place box"); + let sat = crate::scene::convert::acis_export::solid_to_sat(&placed).expect("sat"); + let mut entity = Solid3D::new(); + entity.set_sat_document(&sat); + entity.wires = crate::scene::model::solid_model::edge_wires(&placed); + app.tabs[i].scene.add_entity(EntityType::Solid3D(entity)); + + // Interior point of the top face (off the diagonal so exactly one + // mesh triangle owns it). + let target = glam::DVec3::new(2.0, 3.0, 5.0); + let _ = app.run_command_line("ZOOM EXTENTS"); + let _ = app.run_command_line("LINE"); + let cursor = app.tabs[i] + .scene + .camera + .borrow() + .project( + target, + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)), + ) + .unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + + let hit = app.tabs[i] + .snap_result + .expect("no snap point on the box face"); + assert_eq!( + hit.snap_type, + SnapType::NearestFace, + "expected nearest-to-face, got {:?} at {:?}", + hit.snap_type, + hit.world + ); + assert!( + (hit.world - target).length() < 1e-3, + "snap missed the face point: {:?} vs {target:?}", + hit.world + ); + } + + /// Perpendicular-to-face drops from an explicit base point: exercised + /// grey-box (real mesh, real helper) since it needs no event plumbing. + #[test] + fn face_perpendicular_needs_base_and_interior_foot() { + use acadrust::{entities::Solid3D, EntityType}; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + + let base = + crate::scene::model::solid_model::box_solid([0.0, 0.0, 2.5], 10.0, 10.0, 5.0) + .expect("kernel box"); + let placed = crate::scene::model::solid_model::placed( + &base, + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + ) + .expect("place box"); + let sat = crate::scene::convert::acis_export::solid_to_sat(&placed).expect("sat"); + let mut entity = Solid3D::new(); + entity.set_sat_document(&sat); + entity.wires = crate::scene::model::solid_model::edge_wires(&placed); + app.tabs[i].scene.add_entity(EntityType::Solid3D(entity)); + let _ = app.run_command_line("ZOOM EXTENTS"); + + let scene = &app.tabs[i].scene; + let bounds = iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)); + let cam = scene.camera.borrow(); + let view_rot = cam.view_proj_rte(bounds); + let eye = cam.eye(); + let foot = glam::DVec3::new(2.0, 3.0, 5.0); + let cursor = cam.project(foot, bounds).unwrap(); + drop(cam); + let cursor = Point::new(cursor.x, cursor.y); + + // Base below the face interior → foot straight above it. + let hit = app.tabs[i].scene.solid_face_snaps( + cursor, + view_rot, + eye, + bounds, + 15.0, + Some(glam::DVec3::new(2.0, 3.0, 0.0)), + true, + true, + ); + let hit = hit.expect("perpendicular foot missed"); + assert_eq!(hit.snap_type, crate::snap::SnapType::FacePerpendicular); + assert!( + (hit.world - foot).length() < 1e-3, + "foot off target: {:?} vs {foot:?}", + hit.world + ); + + // No base → perpendicular cannot fire (nearest still can). + let hit = app.tabs[i].scene.solid_face_snaps( + cursor, view_rot, eye, bounds, 15.0, None, true, false, + ); + assert!(hit.is_none(), "perp without a base must stay silent"); + + // Neither mode wanted → silence. + let hit = app.tabs[i].scene.solid_face_snaps( + cursor, + view_rot, + eye, + bounds, + 15.0, + Some(glam::DVec3::new(2.0, 3.0, 0.0)), + false, + false, + ); + assert!(hit.is_none(), "unwanted modes must stay silent"); + } + + /// Edge midpoints snap as their own 3D type at the segment centre. + #[test] + fn line_snaps_to_box_edge_midpoint() { + use acadrust::{entities::Solid3D, EntityType}; + use crate::snap::SnapType; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.snapper.snap_enabled = false; + app.snapper.snap3d_enabled = true; + app.snapper.enabled3d.insert(SnapType::EdgeMidpoint); + app.snapper.grid_snap_on = false; + app.snapper.otrack_enabled = false; + app.ortho_mode = false; + app.polar_mode = false; + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + + let base = + crate::scene::model::solid_model::box_solid([0.0, 0.0, 2.5], 10.0, 10.0, 5.0) + .expect("kernel box"); + let placed = crate::scene::model::solid_model::placed( + &base, + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + ) + .expect("place box"); + let sat = crate::scene::convert::acis_export::solid_to_sat(&placed).expect("sat"); + let mut entity = Solid3D::new(); + entity.set_sat_document(&sat); + entity.wires = crate::scene::model::solid_model::edge_wires(&placed); + let h = app.tabs[i].scene.add_entity(EntityType::Solid3D(entity)); + + // Midpoint of a top edge (both ends at max z): bottom edges project + // onto the same pixels, and coincident points resolve to the eye. + let target = { + let set = app.tabs[i].scene.meshes.get(&h).expect("box mesh"); + let (edges, lows) = set.geometry_edges(); + assert!(edges.len() >= 2, "box mesh has no B-rep edges"); + let at = |index: usize| { + let hi = edges[index]; + let lo = lows.get(index).copied().unwrap_or([0.0; 3]); + glam::DVec3::new( + hi[0] as f64 + lo[0] as f64, + hi[1] as f64 + lo[1] as f64, + hi[2] as f64 + lo[2] as f64, + ) + }; + let top = (0..edges.len()).map(at).map(|p| p.z).fold(f64::NEG_INFINITY, f64::max); + (0..edges.len() / 2) + .map(|k| (at(2 * k), at(2 * k + 1))) + .find(|(a, b)| { + (a.z - top).abs() < 1e-9 && (b.z - top).abs() < 1e-9 + }) + .map(|(a, b)| (a + b) * 0.5) + .expect("box mesh has no top edge") + }; + + let _ = app.run_command_line("ZOOM EXTENTS"); + let _ = app.run_command_line("LINE"); + let cursor = app.tabs[i] + .scene + .camera + .borrow() + .project( + target, + iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)), + ) + .unwrap(); + let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y)); + + let hit = app.tabs[i] + .snap_result + .expect("no snap point on the box edge"); + assert_eq!( + hit.snap_type, + SnapType::EdgeMidpoint, + "expected an edge midpoint, got {:?} at {:?}", + hit.snap_type, + hit.world + ); + assert!( + (hit.world - target).length() < 1e-6, + "snap missed the edge midpoint: {:?} vs {target:?}", + hit.world + ); + } } diff --git a/src/app/update/viewport_snap.rs b/src/app/update/viewport_snap.rs new file mode 100644 index 000000000..927fd22e1 --- /dev/null +++ b/src/app/update/viewport_snap.rs @@ -0,0 +1,299 @@ +//! Snap to displayed model geometry while a paper-space command is active. +//! Viewport queries use the renderer's resident wires and interaction index, +//! then project the accepted model point onto the sheet. Ordinary selection +//! continues to use paper-space hit testing. + +use iced::Point; + +use acadrust::types::Handle; + +use crate::app::OpenCADStudio; +use crate::scene::viewport_ref::{AcceptedSnap, ViewportFrame}; +use crate::snap::{SnapResult, SnapType}; + +/// Bound on the retained accepted-snap history. A command's point steps are a +/// handful at most; the cap only stops a long polyline from growing forever. +const MAX_ACCEPTED_SNAPS: usize = 64; + +impl OpenCADStudio { + /// Snaps accepted by the active command's point steps, oldest first, in + /// step order. + pub(crate) fn accepted_snaps(&self) -> &[AcceptedSnap] { + &self.accepted_snaps + } + + /// Infer direct sources only when the measurement uses the active space. + /// Projected model points must retain their acquired source identities; + /// proximity on the sheet could bind them to unrelated paper geometry. + pub(crate) fn infer_dimension_sources_guarded( + &self, + tab: usize, + dimension: Handle, + ) -> Vec> { + if !self.dimension_association_allowed(tab) { + return Vec::new(); + } + self.tabs[tab].scene.infer_dimension_sources(dimension) + } + + /// Whether the dimension can use ordinary active-space associations. + /// Placement clicks are excluded by the measurement classification. + pub(crate) fn dimension_association_allowed(&self, tab: usize) -> bool { + matches!( + self.dimension_measure_space(tab), + crate::app::dim_viewport::DimensionMeasureSpace::Direct + ) + } + + /// Forget the accepted snaps of a finished / abandoned command. + pub(crate) fn clear_accepted_snaps(&mut self) { + self.accepted_snaps.clear(); + } + + /// Record the snap a point step just accepted. `snap` is the displayed snap + /// result (already in the current space's coordinates) or `None` for a free + /// / typed point; `committed` is the point the command actually received + /// after ortho / polar / axis-lock / dynamic-input resolution. + pub(crate) fn record_accepted_snap( + &mut self, + tab: usize, + snap: Option, + frame: Option, + committed: glam::DVec3, + ) -> bool { + let mut accepted = match snap { + // A viewport snap keeps its frame only when the hit really came + // through it, so a paper-sheet snap never claims a model point. + Some(hit) => { + let frame = hit.viewport.and(frame); + AcceptedSnap::from_snap(&hit, frame).with_paper_point(committed) + } + None => AcceptedSnap::free(committed), + }; + if self.tabs[tab] + .active_cmd + .as_ref() + .is_some_and(|cmd| cmd.measures_through_viewports()) + { + if self.tabs[tab] + .active_cmd + .as_ref() + .is_some_and(|cmd| cmd.dimension_placement_pending()) + { + return true; + } + if !self.dimension_acquisition_allowed(tab, accepted.viewport) { + return false; + } + } + self.resolve_source_block_path(tab, &mut accepted); + self.push_accepted_snap(accepted); + true + } + + /// Resolve the instance path once, when a feature is accepted. Failure + /// drops source identity so a failed descent cannot attach to the insert's + /// origin. Ambiguous intersections within one batched block remain unlinked. + fn resolve_source_block_path(&self, tab: usize, accepted: &mut AcceptedSnap) { + let resolve = |source: &mut crate::scene::viewport_ref::SnapSourceRef| { + if !source.block_path.is_empty() { + return true; + } + if !matches!( + self.tabs[tab] + .scene + .document + .get_entity(source.source.handle), + Some(acadrust::EntityType::Insert(_)) + ) { + return true; + } + if source.snap_type == SnapType::Insertion { + let Some(acadrust::EntityType::Insert(insert)) = self.tabs[tab] + .scene + .document + .get_entity(source.source.handle) + else { + return false; + }; + let origin = glam::DVec3::new( + insert.insert_point.x, + insert.insert_point.y, + insert.insert_point.z, + ); + return origin.distance(accepted.model_point) < 1e-5; + } + let Some((entity, path)) = self.tabs[tab].scene.resolve_measurable_feature( + source.source.handle, + accepted.model_point, + accepted.viewport, + Some(source.snap_type), + ) else { + return false; + }; + let distance = crate::scene::viewport_dimension_pick::feature_pick_distance( + &entity, + accepted.model_point, + Some(source.snap_type), + ); + if !distance.is_some_and(|distance| distance < 1e-5) { + return false; + } + source.source = + crate::command::DimensionAssociationSource::inferred(entity.common().handle); + source.block_path = path; + true + }; + let Some(source) = accepted.source.as_mut() else { + return; + }; + if !resolve(source) { + accepted.source = None; + return; + } + if let Some(other) = source.intersection.as_mut() { + if !resolve(other) + || (other.source.handle == source.source.handle + && other.block_path == source.block_path) + { + source.intersection = None; + } + } + } + + /// Retain a bounded history for point and object picks. + pub(crate) fn push_accepted_snap(&mut self, accepted: AcceptedSnap) { + if self.accepted_snaps.len() >= MAX_ACCEPTED_SNAPS { + self.accepted_snaps.remove(0); + } + self.accepted_snaps.push(accepted); + } + /// Query visible viewport geometry with a canvas-pixel cursor and sheet point. + /// Returns paper coordinates and canvas pixels, plus the source viewport frame. + pub(in crate::app) fn paper_viewport_snap( + &mut self, + i: usize, + cursor_canvas: Point, + canvas: (f32, f32), + cursor_paper: glam::DVec3, + ) -> Option<(SnapResult, ViewportFrame)> { + if !self.snapper.snap_enabled { + return None; + } + { + let scene = &self.tabs[i].scene; + if scene.current_layout == "Model" || scene.active_viewport.is_some() { + return None; + } + } + if canvas.0 < 1.0 || canvas.1 < 1.0 { + return None; + } + + for frame in self.tabs[i] + .scene + .viewport_frames_at_paper_point(cursor_paper) + { + let handle = frame.viewport; + let Some((cam, rect)) = self.tabs[i].scene.viewport_edit_frame_for(handle, canvas) + else { + continue; + }; + if rect.width < 1.0 || rect.height < 1.0 { + continue; + } + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: rect.width, + height: rect.height, + }; + let local = Point::new(cursor_canvas.x - rect.x, cursor_canvas.y - rect.y); + let view_rot = cam.view_proj_rte(bounds); + let eye = cam.eye(); + // The cursor in MODEL space, through the frame — exactly the point + // the viewport camera projects back onto `local`. + let model_cursor = frame.paper_to_model(cursor_paper); + + let wires = self.tabs[i] + .scene + .model_wires_for_viewport_arc(handle, bounds.height); + let candidates = self.tabs[i].scene.interaction_candidates_near( + wires, + model_cursor, + view_rot, + eye, + bounds, + self.snapper.osnap_radius_px, + ); + + // The grid belongs to the sheet. Translate the construction + // anchor for perpendicular/tangent snaps into this viewport's + // model coordinates, then restore the shared snapper state. + let saved_grid = self.snapper.grid_snap_on; + let saved_from = self.snapper.from_point; + self.snapper.grid_snap_on = false; + self.snapper.from_point = + saved_from.map(|p| frame.paper_to_model(p.as_dvec3()).as_vec3()); + let hit = self.snapper.snap( + model_cursor, + local, + &candidates, + view_rot, + eye, + bounds, + glam::Vec3::ZERO, + (glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z), + None, + ); + self.snapper.grid_snap_on = saved_grid; + self.snapper.from_point = saved_from; + + let Some(hit) = hit.filter(|h| h.snap_type != SnapType::Grid) else { + continue; + }; + // The snap aperture can reach outside the visible clip boundary. + let paper = frame.model_to_paper(hit.world); + if !self.tabs[i] + .scene + .viewport_displays_paper_point(handle, paper.truncate()) + { + continue; + } + return Some((project_hit_to_paper(hit, &frame, rect, handle), frame)); + } + None + } +} + +/// Re-express a snap taken in a viewport's model space as a paper-space result: +/// the point projects onto the sheet through `frame`, and the pane-local pixel +/// positions shift by the viewport's screen origin so they share the canvas +/// frame with the ordinary paper snap. +fn project_hit_to_paper( + hit: SnapResult, + frame: &ViewportFrame, + rect: iced::Rectangle, + viewport: Handle, +) -> SnapResult { + let to_canvas = |p: Point| Point::new(p.x + rect.x, p.y + rect.y); + SnapResult { + world: frame.model_to_paper(hit.world), + screen: to_canvas(hit.screen), + snap_type: hit.snap_type, + // Tangent geometry is model-space and would be consumed as paper-space + // by TTR/TTT; drop it rather than hand over a wrong circle. + tangent_obj: None, + extension_base: hit.extension_base.map(to_canvas), + extension_base2: hit.extension_base2.map(to_canvas), + extension_origin: hit.extension_origin.map(|p| frame.model_to_paper(p)), + extension_dir: hit.extension_dir.map(|d| { + let mapped = frame.model_to_paper_dir(d.truncate()); + glam::DVec3::new(mapped.x, mapped.y, d.z) + }), + viewport: Some(viewport), + source: hit.source, + secondary_source: hit.secondary_source, + model_point: Some(hit.world), + } +} diff --git a/src/app/view/controls.rs b/src/app/view/controls.rs index 2f333e099..f2127339f 100644 --- a/src/app/view/controls.rs +++ b/src/app/view/controls.rs @@ -346,26 +346,24 @@ pub(super) fn dyn_component_value( ) && relative && !comma_cartesian; match f.component { - DynComponent::X if relative => format!("{:.4}", if wh { dx.abs() } else { dx }), - DynComponent::Y if relative => format!("{:.4}", if wh { dy.abs() } else { dy }), - DynComponent::Z if relative => "0.0000".to_string(), - DynComponent::X => format!("{:.4}", p.x), - DynComponent::Y => format!("{:.4}", p.y), - DynComponent::Z => format!("{:.4}", p.z), + DynComponent::X if relative => crate::entities::common::format_length(if wh { dx.abs() } else { dx }), + DynComponent::Y if relative => crate::entities::common::format_length(if wh { dy.abs() } else { dy }), + DynComponent::Z if relative => crate::entities::common::format_length(0.0), + DynComponent::X => crate::entities::common::format_length(p.x), + DynComponent::Y => crate::entities::common::format_length(p.y), + DynComponent::Z => crate::entities::common::format_length(p.z), // Scaled by the role so a diameter box reads twice the radius. DynComponent::Distance => { - format!( - "{:.4}", - (dx * dx + dy * dy).sqrt() * f.role.value_scale() as f64 + crate::entities::common::format_length( + (dx * dx + dy * dy).sqrt() * f.role.value_scale() as f64, ) } // Shared rule: unsigned magnitude of the short angle, so CW (below the // reference axis) reads positive (e.g. 30°, not -30°/330°). The // committed value stays signed (see dyn_resolve_point). DynComponent::Angle => { - format!( - "{:.1}", - crate::command::dyn_display_angle_deg(dy.atan2(dx) as f32) + crate::entities::common::format_angle( + (crate::command::dyn_display_angle_deg(dy.atan2(dx) as f32) as f64).to_radians(), ) } // Typed-only scalar — no geometric value to track when empty. diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 0e3c7fdae..52d4e18cb 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -213,6 +213,12 @@ impl OpenCADStudio { let i = self.active_tab; let tab = &self.tabs[i]; + // Dynamic input is built before the status bar and properties panel, + // so seed its shared formatter here rather than relying on either of + // those later views to have done it for this drawing/thread. + crate::entities::common::set_unit_context( + crate::entities::common::UnitContext::from_header(&tab.scene.document.header), + ); 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, theme_text.g, theme_text.b, theme_text.a]; @@ -437,18 +443,32 @@ impl OpenCADStudio { ); axes = (ux.as_vec3(), uy.as_vec3(), uz.as_vec3()); } + // BUG FIX: the display step used to ignore GRIDUNIT entirely + // (hardcoded 1.0 base). It now resizes from the DSettings + // grid spacing, and honors "Display grid beyond Limits". + let (step_x, step_y) = crate::ui::overlay::compute_grid_steps( + self.grid_spacing_x, + self.grid_spacing_y, + cam.distance, + cam.fov_y, + bounds, + self.grid_adaptive, + ); + let limits = if self.grid_beyond_limits { + None + } else { + tab.scene.grid_limits_for_viewport(handle) + }; crate::ui::overlay::GridParams { view_rot: cam.view_proj_rte(bounds), eye: cam.eye(), bounds, - step: crate::ui::overlay::compute_grid_step( - cam.distance, - cam.fov_y, - bounds, - ), + step_x, + step_y, + major_every: self.grid_major_every, origin, axes, - limits: tab.scene.grid_limits_for_viewport(handle), + limits, } }) .collect(); @@ -591,7 +611,8 @@ bg={bg_ms:.1}ms n={view_count}" }; let control_polygon = tab.selected_handle.and_then(|handle| { let spline = match tab.scene.document.get_entity(handle) { - Some(acadrust::EntityType::Spline(spline)) if spline.cv_frame_visible => spline, + Some(acadrust::EntityType::Spline(spline)) + if crate::entities::spline::shows_control_vertices(spline) => spline, _ => return None, }; if tab @@ -813,6 +834,23 @@ bg={bg_ms:.1}ms n={view_count}" .active_cmd .as_ref() .is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering()); + let constraint_cursor_badge = if is_paper { + None + } else { + tab.scene.hover_highlight.and_then(|handle| { + tab.scene + .parametric_constraint_set(tab.current_parametric_scope()) + .and_then(|set| { + set.constraints_touching(handle) + .find(|constraint| { + constraint.kind + == crate::scene::parametric_constraints::ConstraintKind::Concentric + }) + .or_else(|| set.constraints_touching(handle).next()) + }) + .map(|constraint| constraint.kind.glyph_symbol().to_string()) + }) + }; let constraint_glyphs: Vec<( iced::Point, [f32; 2], @@ -830,6 +868,7 @@ bg={bg_ms:.1}ms n={view_count}" sel_ref.vp_size, self.show_constraint_values, self.constraint_bar_display, + self.constraint_bar_mode, ) .into_iter() .map(|(id, point, direction, label, is_conflicting, hover_points)| { @@ -888,6 +927,9 @@ bg={bg_ms:.1}ms n={view_count}" grip_hover: self.model_space.grip_hover, }, constraint_glyphs, + self.constraint_glyph_tooltip + .map(|kind| crate::t!(kind.label()).into_owned()), + constraint_cursor_badge, ) }; @@ -1066,15 +1108,15 @@ bg={bg_ms:.1}ms n={view_count}" (None, _) if rectangle_values.is_some() => { let (width, height) = rectangle_values.unwrap(); match f.role { - crate::command::DynRole::Width => format!("{width:.4}"), - crate::command::DynRole::Height => format!("{height:.4}"), + crate::command::DynRole::Width => crate::entities::common::format_length(width), + crate::command::DynRole::Height => crate::entities::common::format_length(height), _ => String::new(), } } // An angle step with a command-supplied live value // (ARC span / direction) shows it in degrees. (None, Some(lv)) if f.component == DynComponent::Angle => { - format!("{lv:.1}") + crate::entities::common::format_angle(lv.to_radians()) } (None, Some(lv)) if matches!( @@ -1082,7 +1124,11 @@ bg={bg_ms:.1}ms n={view_count}" DynComponent::Scalar | DynComponent::Distance ) => { - format!("{lv:.4}") + if f.component == DynComponent::Distance { + crate::entities::common::format_length(lv) + } else { + format!("{lv:.4}") + } } _ => dyn_component_value( f, @@ -1200,6 +1246,9 @@ bg={bg_ms:.1}ms n={view_count}" }; if !thumbnail_capture_clean && !self.layout_settling { + if let Some(pivot) = self.spacemouse_pivot_overlay() { + viewport_stack = viewport_stack.push(pivot); + } // 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. @@ -1588,7 +1637,7 @@ bg={bg_ms:.1}ms n={view_count}" crate::ui::command_line::history_max_height(self.win_size.1), ) + 72.0 } else { - 34.0 + 34.0 + self.command_line.overlay_lines_height() }; // Quick Properties: stay near the selection cursor, flipping around // it as needed to remain inside the visible drawing area. @@ -1737,37 +1786,17 @@ bg={bg_ms:.1}ms n={view_count}" // the cursor position (canvas-relative) anchors the menu under // the cursor instead of drifting into window-relative space. if !tab.is_start { - let (ctx_pos, draworder_open) = { + let (ctx_pos, highlighted) = { let sel = tab.scene.selection.borrow(); - (sel.context_menu, sel.draworder_submenu) + (sel.context_menu, sel.context_menu_ui.highlighted) }; if let Some(p) = ctx_pos { - let has_cmd = tab.active_cmd.is_some(); - // Same guard as typed MTP/M2P and SnapOverrideMtp. - let has_point_step = tab.active_cmd.as_ref().is_some_and(|c| { - (!c.input_kind().wants_text() || c.point_step_accepts_keywords()) - && !c.needs_entity_pick() - }); - let has_selection = !tab.scene.selected.is_empty(); - let isolation_active = tab.scene.is_isolation_active(); - let last_cmds: Vec = self - .command_line - .recent_commands - .iter() - .rev() - .take(3) - .cloned() - .collect(); + let menu = self.current_context_menu(); viewport_stack = viewport_stack.push(viewport_context_menu_overlay( p, command_line_inset, - has_cmd, - has_selection, - tab.scene.selected_constraint, - isolation_active, - last_cmds, - draworder_open, - has_point_step, + &menu, + highlighted, )); } } @@ -1810,6 +1839,8 @@ bg={bg_ms:.1}ms n={view_count}" match id { crate::ui::dock::PanelId::Properties => self.show_properties, crate::ui::dock::PanelId::BlockPalette => self.show_block_palette, + crate::ui::dock::PanelId::ExternalReferences => self.show_external_references, + crate::ui::dock::PanelId::Browser => self.show_browser, } }; let edge_stack = |side: crate::app::config::DockSide| -> Option> { @@ -1872,7 +1903,8 @@ bg={bg_ms:.1}ms n={view_count}" let workspace: Element<'_, Message> = row(parts).width(Fill).height(Fill).into(); let any_dragging = self.dock_dragging.is_some(); - let any_resizing = self.dock_resizing.is_some(); + let any_resizing = + self.dock_resizing.is_some() || self.xref_col_drag.is_some() || self.xref_split_drag; let workspace = if any_dragging { let id = self.dock_dragging.expect("guarded by any_dragging"); let side = self.dock_drag_target.map(|(s, _)| s).unwrap_or( @@ -2199,6 +2231,18 @@ bg={bg_ms:.1}ms n={view_count}" .map(|s| s.conflicts.len()) .unwrap_or(0), &self.gpu_status, + (self.spacemouse.visible() + || self.spacemouse_preferences.mode + != crate::input::spacemouse::NavigationMode::Auto) + .then(|| { + crate::ui::statusbar::spacemouse::view( + self.spacemouse_preferences, + self.spacemouse.status(), + self.spacemouse_paused, + self.spacemouse_label(), + self.spacemouse_sheet(), + ) + }), ) }) .width(Fill) @@ -2658,6 +2702,21 @@ impl OpenCADStudio { let control = iced::time::every(std::time::Duration::from_millis(50)) .map(|_| Message::PollWebControl); iced::Subscription::batch([ + if self.spacemouse.moving() && self.spacemouse_focused && !self.spacemouse_paused { + window::frames().map(Message::SpaceMouseFrame) + } else { + Subscription::none() + }, + self.spacemouse.subscription().map(|_| Message::SpaceMouseWake), + event::listen_with(|event, _, id| match event { + iced::Event::Window(window::Event::Focused) => { + Some(Message::SpaceMouseFocus(id, true)) + } + iced::Event::Window(window::Event::Unfocused) => { + Some(Message::SpaceMouseFocus(id, false)) + } + _ => None, + }), control, frames, history_tick, @@ -2837,6 +2896,18 @@ impl OpenCADStudio { crate::ui::dock::PanelId::BlockPalette => { crate::ui::window::block_palette::view(&self.block_palette, width, auto_collapse) } + crate::ui::dock::PanelId::ExternalReferences => self.xref_manager.view( + width, + auto_collapse, + tab.xref_missing, + &tab.scene.document, + ), + crate::ui::dock::PanelId::Browser => crate::ui::window::browser::view( + &tab.scene.document, + tab.sketch_session.as_ref().map(|session| session.name.as_str()), + width, + auto_collapse, + ), }; let divider = dock_divider(id); match side { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 2291d8654..0d2f85b65 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -32,6 +32,7 @@ impl OpenCADStudio { Some(K::LayoutManager) => crate::tr!("modal", "layout-manager"), Some(K::ScaleManager) => crate::tr!("modal", "scale-manager"), Some(K::AnnoObjectScale) => crate::tr!("modal", "annotation-object-scale"), + Some(K::Hyperlink) => crate::t!("Hyperlink").into_owned(), Some(K::InsertTable) => crate::t!("Insert Table").into_owned(), Some(K::DataLinkManager) => crate::t!("Data Link Manager").into_owned(), Some(K::DataExtraction) => crate::t!("Data Extraction Wizard").into_owned(), @@ -53,8 +54,10 @@ impl OpenCADStudio { Some(K::AttributeEditor) => crate::tr!("modal", "attribute-editor"), Some(K::SaveDialog) => crate::tr!("modal", "save-drawing-as"), Some(K::Recovery) => crate::tr!("modal", "recovery-report"), + Some(K::MissingFonts) => crate::t!("Missing fonts").into_owned(), Some(K::RecoveryPrompt) => crate::tr!("modal", "recovery-prompt"), Some(K::GpuWarning) => crate::tr!("gpu", "title"), + Some(K::XrefHelp) => crate::t!("Reference Manager Help").into_owned(), None => String::new(), } } @@ -64,8 +67,8 @@ impl OpenCADStudio { ) -> Element<'s, Message> { sized_flow( extra, - 760, - 540, + 940, + 690, |flow| { crate::ui::window::plot::view_window( &self.plot_dialog, @@ -84,6 +87,9 @@ impl OpenCADStudio { super::super::ModalKind::About => { automatic_flow(ex, crate::ui::window::about::view_window) } + super::super::ModalKind::XrefHelp => { + automatic_flow(ex, crate::ui::window::xref_help::view_window) + } super::super::ModalKind::Shortcuts => { // Keys claimed by two rows — the cells turn red and a // persistent warning names the command already using each @@ -167,6 +173,65 @@ impl OpenCADStudio { ) } super::super::ModalKind::Aliases => { + // Aliases claimed by two rows — the cells turn red and a + // persistent warning names the command already using each + // alias; the last row wins in the alias map on Apply. + let mut alias_rows: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for row in &self.alias_editor_rows { + let alias = row.0.trim().to_uppercase(); + if !alias.is_empty() { + alias_rows.entry(alias).or_default().push(row); + } + } + let duplicate_aliases: Vec = alias_rows + .iter() + .filter(|(_, rows)| rows.len() > 1) + .map(|(alias, _)| alias.clone()) + .collect(); + let duplicate_conflicts: Vec<(String, String)> = alias_rows + .iter() + .filter(|(_, rows)| rows.len() > 1) + .map(|(alias, rows)| { + let command = rows + .iter() + .find(|(_, command)| !command.is_empty()) + .map(|(_, command)| command.clone()) + .unwrap_or_default(); + (alias.clone(), command) + }) + .collect(); + let duplicate_set: rustc_hash::FxHashSet = + duplicate_aliases.into_iter().collect(); + // Commands the dispatcher can't run: not a registered + // command, plugin command, or input action. + let valid: rustc_hash::FxHashSet = + crate::command::all_registered_command_names() + .into_iter() + .map(str::to_uppercase) + .chain( + self.command_line + .dynamic_commands + .iter() + .map(|cmd| cmd.to_uppercase()), + ) + .chain( + crate::app::shortcuts::INPUT_ACTIONS + .iter() + .map(|action| action.to_string()), + ) + .collect(); + let unknown_commands: Vec = self + .alias_editor_rows + .iter() + .filter_map(|(_, command)| { + let command = command.trim(); + (!command.is_empty() && !valid.contains(command)) + .then(|| command.to_string()) + }) + .collect(); + let unknown_set: rustc_hash::FxHashSet = + unknown_commands.into_iter().collect(); sized_flow( ex, 480, @@ -174,6 +239,12 @@ impl OpenCADStudio { |flow| { crate::ui::window::alias_editor::view_window( &self.alias_editor_rows, + self.alias_pending_add, + self.alias_reset_confirm, + &duplicate_set, + &duplicate_conflicts, + &unknown_set, + self.alias_close_confirm, flow, ) }, @@ -194,7 +265,10 @@ impl OpenCADStudio { }, ) } - super::super::ModalKind::Options => sized_flow( + super::super::ModalKind::Options => { + let dirty = self.options_dirty(); + let close_confirm = self.options_close_confirm; + sized_flow( ex, 880, 620, @@ -218,6 +292,7 @@ impl OpenCADStudio { crate::ui::window::options::AppPrefs { savetime_min: self.savetime_min, backup_on_save: self.backup_on_save, + page_setup_on_new_layout: self.plot_dialog.page_setup_on_new_layout, textfill: crate::scene::text::sdf_atlas::textfill(), cliprompt_lines: self.cliprompt_lines, commandline_fade_ms: self.commandline_fade_ms, @@ -231,7 +306,13 @@ impl OpenCADStudio { show_viewcube: self.show_viewcube, show_ucs_icon: self.show_ucs_icon, ucs_icon_at_origin: self.ucs_icon_at_origin, + right_click_mode: self.right_click_mode, + right_click_hold_ms: self.right_click_hold_ms, }, + crate::ui::window::options::spacemouse::view( + self.spacemouse_preferences, self.spacemouse.status(), + self.spacemouse_paused, self.spacemouse_details, + ), &self.snap_angle_input, { let header = self @@ -282,18 +363,22 @@ impl OpenCADStudio { &self.model_bg_input, &self.paper_bg_input, &self.desk_bg_input, + self.bg_picker, + dirty, + close_confirm, flow, ) }, - ), + ) + } super::super::ModalKind::DraftingSettings => { let state = self.drafting_settings_state.as_ref(); let dirty = self.drafting_settings_dirty(); let confirm = self.drafting_settings_close_confirm; sized_flow( ex, - 700, - 480, + 780, + 500, |flow| { if let Some(state) = state { crate::ui::window::drafting_settings::view_window( @@ -1525,6 +1610,14 @@ impl OpenCADStudio { ) }, ), + super::super::ModalKind::Hyperlink => sized_flow(ex, 560, 260, |flow| { + hyperlink_dialog_window( + &self.hyperlink_editor_url, + &self.hyperlink_editor_description, + self.hyperlink_editor_mixed, + flow, + ) + }), super::super::ModalKind::AttributeEditor => { let doc = &self.tabs[self.active_tab].scene.document; let layers: Vec = doc.layers.iter().map(|l| l.name.clone()).collect(); @@ -1568,6 +1661,17 @@ impl OpenCADStudio { ) }) } + super::super::ModalKind::MissingFonts => { + let fonts = self.missing_fonts.as_ref()?; + let font_source = &self.font_source_input; + automatic_flow(ex, |flow| { + crate::ui::window::missing_fonts::view_window( + fonts, + &font_source, + flow, + ) + }) + } super::super::ModalKind::Recovery => { let report = self.recovery_report.as_ref()?; sized_flow(ex, 680, 460, |flow| { @@ -1646,6 +1750,80 @@ fn dialog_muted_text_style(theme: &Theme) -> iced::widget::text::Style { } } +fn hyperlink_dialog_window<'a>( + url: &'a str, + description: &'a str, + mixed: bool, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let label = |value: Cow<'static, str>| { + text(value) + .size(11) + .style(dialog_muted_text_style) + .width(90) + }; + let mut items: Vec> = Vec::new(); + if mixed { + items.push( + text(t!("Selected objects have different hyperlink values.")) + .size(11) + .style(dialog_muted_text_style) + .into(), + ); + items.push(Space::new().height(8).into()); + } + items.push( + row![ + label(t!("URL:")), + iced::widget::text_input("https://", url) + .on_input(Message::HyperlinkUrlChanged) + .size(13) + .padding([5, 8]) + .width(Fill), + ] + .spacing(8) + .align_y(iced::Alignment::Center) + .width(sizing.width) + .into(), + ); + items.push(Space::new().height(8).into()); + items.push( + row![ + label(t!("Description:")), + iced::widget::text_input("", description) + .on_input(Message::HyperlinkDescriptionChanged) + .size(13) + .padding([5, 8]) + .width(Fill), + ] + .spacing(8) + .align_y(iced::Alignment::Center) + .width(sizing.width) + .into(), + ); + items.push(Space::new().height(Fill).into()); + items.push( + row![ + dialog_button(t!("Remove"), Message::HyperlinkRemove, button::danger), + Space::new().width(Fill), + dialog_button(t!("Cancel"), Message::HyperlinkCancel, button::secondary), + Space::new().width(8), + dialog_button(t!("OK"), Message::HyperlinkApply, button::primary), + ] + .align_y(iced::Alignment::Center) + .into(), + ); + container( + column(items) + .spacing(0) + .width(sizing.width) + .height(sizing.height), + ) + .style(dialog_body_style) + .padding([14, 16]) + .into() +} + /// Compact Save-As options dialog: pick the format/version and a default file /// name. The destination folder and overwrite confirmation come from the /// native OS save dialog (native) or the browser download (web) that follows. diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index 77c57b46c..413dfd4e9 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -11,6 +11,7 @@ use iced::widget::{ }; use iced::{Background, Border, Color, Element, Event, Fill, Length, Rectangle, Size, Theme, Vector}; use crate::t; +use crate::ui::popup::context_menu::{ContextMenu, MenuIcon, MenuItem, MenuRow, MENU_PAD_TOP, MENU_ROW_H}; pub(super) fn position_canvas_overlay<'a>( anchor: iced::Point, @@ -912,9 +913,15 @@ fn mtext_editor_content<'a>( /// panel is laid out first, so edge flipping uses its actual translated size /// instead of estimates. `bottom_inset` reserves overlaid controls such as the /// command line. +/// +/// `offset` shifts the preferred placement from the anchor before the fit / +/// flip logic runs (e.g. to put a specific row of a menu under the pointer). +/// When the shifted panel does not fit it flips to the other side of the +/// *original* anchor, so the pointer never ends up over an unintended row. fn position_canvas_overlay_clamped<'a>( anchor: iced::Point, bottom_inset: f32, + offset: Vector, panel: Element<'a, Message>, ) -> Element<'a, Message> { Element::new(ClampedPin { @@ -922,6 +929,7 @@ fn position_canvas_overlay_clamped<'a>( anchor, bottom_inset: bottom_inset.max(0.0), gap: 0.0, + offset, }) } @@ -937,6 +945,7 @@ pub(super) fn position_canvas_overlay_near_cursor<'a>( anchor: cursor, bottom_inset: bottom_inset.max(0.0), gap: 12.0, + offset: Vector::ZERO, }) } @@ -945,6 +954,8 @@ struct ClampedPin<'a> { anchor: iced::Point, bottom_inset: f32, gap: f32, + /// Preferred placement shift from the anchor (applied before fitting). + offset: Vector, } impl Widget for ClampedPin<'_> { @@ -983,9 +994,10 @@ impl Widget for ClampedPin<'_> { &layout::Limits::new(Size::ZERO, safe), ); let content = node.size(); + let bottom_inset = self.bottom_inset; let max_x = (max.width - MARGIN - content.width).max(MARGIN); - let right = self.anchor.x + self.gap; + let right = self.anchor.x + self.gap + self.offset.x; let left = self.anchor.x - self.gap - content.width; let x = if right <= max_x { right.max(MARGIN) @@ -995,9 +1007,8 @@ impl Widget for ClampedPin<'_> { max_x }; - let max_y = - (max.height - self.bottom_inset - MARGIN - content.height).max(MARGIN); - let below = self.anchor.y + self.gap; + let max_y = (max.height - bottom_inset - MARGIN - content.height).max(MARGIN); + let below = self.anchor.y + self.gap + self.offset.y; let above = self.anchor.y - self.gap - content.height; let y = if below <= max_y { below.max(MARGIN) @@ -1109,26 +1120,44 @@ impl Widget for ClampedPin<'_> { // ── Viewport right-click context menu ────────────────────────────────────── +/// Horizontal inset so the pointer rests inside the default row, not on the +/// panel's border, when the menu opens. +const MENU_CURSOR_INSET_X: f32 = 24.0; +/// Width of the icon gutter every row reserves so labels line up whether or +/// not the row carries a glyph (object snaps, Pan / Zoom). +const MENU_GUTTER_W: f32 = 18.0; +const MENU_ICON_SIZE: f32 = 14.0; + +/// The gutter cell: the row's glyph, or empty space of the same width. +fn context_menu_gutter(icon: Option) -> Element<'static, Message> { + let bytes = icon.map(|icon| match icon { + MenuIcon::Snap(t) => crate::ui::icons::osnap(t), + MenuIcon::Mtp => crate::ui::icons::mtp_icon(), + MenuIcon::Pan => crate::ui::icons::pan_icon(), + MenuIcon::Zoom => crate::ui::icons::zoom_icon(), + }); + let cell: Element<'static, Message> = match bytes { + Some(bytes) => crate::ui::icons::themed::(bytes, MENU_ICON_SIZE), + None => iced::widget::Space::new().width(MENU_ICON_SIZE).height(MENU_ICON_SIZE).into(), + }; + container(cell) + .width(Length::Fixed(MENU_GUTTER_W)) + .align_x(iced::Center) + .align_y(iced::Center) + .into() +} + +/// Render the right-click context menu (rows from +/// `ui::popup::context_menu::build_context_menu`). The panel is placed so the +/// default row sits under the pointer: right-click then left-click in place +/// completes the step without moving the mouse (commercial solutions anchor their shortcut +/// menu the same way). `highlighted` is the keyboard highlight, if any. pub(super) fn viewport_context_menu_overlay( pos: iced::Point, bottom_inset: f32, - has_cmd: bool, - has_selection: bool, - selected_constraint: Option, - isolation_active: bool, - last_cmds: Vec, - draworder_open: bool, - has_point_step: bool, + menu: &ContextMenu, + highlighted: Option, ) -> Element<'static, Message> { - let item = |label: String, msg: Message| -> Element<'static, Message> { - button(text(label).size(12)) - .on_press(msg) - .style(button::subtle) - .padding([4, 12]) - .width(Fill) - .into() - }; - let sep = || -> Element<'static, Message> { container(iced::widget::Space::new().width(Fill).height(1)) .style(|theme: &Theme| container::Style { @@ -1143,142 +1172,67 @@ pub(super) fn viewport_context_menu_overlay( .into() }; - // Indented variant for sub-menu rows (e.g. Draw Order children). - let subitem = |label: String, msg: Message| -> Element<'static, Message> { - button(text(label).size(12)) - .on_press(msg) - .style(button::subtle) - .padding(iced::Padding { - top: 4.0, - right: 12.0, - bottom: 4.0, - left: 26.0, - }) - .width(Fill) - .into() - }; - + // Index into `menu.selectable()` of the row being rendered, so the + // keyboard highlight lands on the same row the model counts. + let mut sel_idx = 0usize; let mut items: Vec> = Vec::new(); - if has_cmd { - items.push(item(t!("Cancel").into_owned(), Message::CommandEscape)); - items.push(item(t!("Enter").into_owned(), Message::CommandFinalize)); - // MTP (`_M2P`) goes last, after Parallel: two picks, so not a - // `SnapType`, but same icon-only cell with hover tooltip. - if has_point_step { - items.push(sep()); - items.push(item( - t!("Mid Between 2 Points (M2P)").into_owned(), - Message::SnapOverrideMtp, - )); - } - } else { - if !last_cmds.is_empty() { - let last = last_cmds[0].clone(); - items.push(item( - t!("Repeat %{last}", last = last).into_owned(), - Message::Command(last.to_uppercase()), - )); - if last_cmds.len() > 1 { - for cmd in last_cmds.iter().skip(1) { - let c = cmd.clone(); - items.push(item(c.clone(), Message::Command(c.to_uppercase()))); + for row in &menu.rows { + match row { + MenuRow::Item(item) => { + let is_hl = highlighted == Some(sel_idx); + items.push(context_menu_row(item, 0.0, is_hl)); + sel_idx += 1; + } + MenuRow::Separator => items.push(sep()), + MenuRow::Submenu { + id, + label, + items: children, + open, + } => { + let is_hl = highlighted == Some(sel_idx); + let caret = if *open { + crate::ui::icons::themed_arrow_down(9.0) + } else { + crate::ui::icons::themed_arrow_right(9.0) + }; + let content = row![ + context_menu_gutter(None), + text(label.clone()).size(12), + iced::widget::Space::new().width(Fill), + caret, + ] + .spacing(4) + .align_y(iced::Center); + let enabled = !children.is_empty(); + let mut btn = button(content) + .padding(iced::Padding { + top: 3.0, + right: 12.0, + bottom: 3.0, + left: 6.0, + }) + .width(Fill) + .height(Length::Fixed(MENU_ROW_H)) + .style(move |theme: &Theme, status| context_menu_row_style(theme, status, is_hl)); + if enabled { + btn = btn.on_press(Message::ContextMenuSubmenuToggle(*id)); + } + items.push(btn.into()); + sel_idx += 1; + if *open { + for child in children { + let is_hl = highlighted == Some(sel_idx); + items.push(context_menu_row(child, 14.0, is_hl)); + sel_idx += 1; + } } } - items.push(sep()); } - if has_selection { - items.push(item(t!("Delete").into_owned(), Message::DeleteSelected)); - items.push(item( - t!("Move").into_owned(), - Message::Command("MOVE".to_string()), - )); - items.push(item( - t!("Copy").into_owned(), - Message::Command("COPY".to_string()), - )); - items.push(sep()); - let do_caret = if draworder_open { - crate::ui::icons::themed_arrow_down(9.0) - } else { - crate::ui::icons::themed_arrow_right(9.0) - }; - items.push( - button( - row![ - text(t!("Draw Order").into_owned()).size(12), - iced::widget::Space::new().width(Fill), - do_caret, - ] - .align_y(iced::Center), - ) - .on_press(Message::DrawOrderSubmenuToggle) - .style(button::subtle) - .padding([4, 12]) - .width(Fill) - .into(), - ); - if draworder_open { - items.push(subitem( - t!("Bring to Front").into_owned(), - Message::Command("DRAWORDER F".to_string()), - )); - items.push(subitem( - t!("Send to Back").into_owned(), - Message::Command("DRAWORDER B".to_string()), - )); - items.push(subitem( - t!("Bring Above Object").into_owned(), - Message::DrawOrderPickRef(true), - )); - items.push(subitem( - t!("Send Under Object").into_owned(), - Message::DrawOrderPickRef(false), - )); - } - items.push(sep()); - items.push(item( - t!("Isolate Objects").into_owned(), - Message::Command("ISOLATEOBJECTS".to_string()), - )); - items.push(item( - t!("Hide Objects").into_owned(), - Message::Command("HIDEOBJECTS".to_string()), - )); - items.push(sep()); - items.push(item(t!("Select Similar").into_owned(), Message::SelectSimilar)); - items.push(item( - t!("Invert Selection").into_owned(), - Message::InvertSelection, - )); - } else if let Some(id) = selected_constraint { - // A selected constraint-glyph pill gets its own minimal menu — - // none of the entity-selection actions above (Move/Copy/Isolate/ - // Select Similar) apply to it. - items.push(item( - t!("Delete").into_owned(), - Message::PropConstraintDelete(id), - )); - items.push(sep()); - } - if isolation_active { - items.push(item( - t!("End Object Isolation").into_owned(), - Message::Command("UNISOLATEOBJECTS".to_string()), - )); - } - items.push(item( - t!("Select All").into_owned(), - Message::Command("SELECTALL".to_string()), - )); - items.push(item(t!("Quick Select...").into_owned(), Message::QSelectOpen)); - items.push(item( - t!("Zoom Extents").into_owned(), - Message::Command("ZOOM EXTENTS".to_string()), - )); } - let menu_col = column(items).spacing(0).width(Length::Fixed(180.0)); + let menu_col = column(items).spacing(0).width(Length::Fixed(menu.width)); let menu_col = scrollable(menu_col) .height(Length::Shrink) .direction(scrollable::Direction::Vertical( @@ -1287,12 +1241,103 @@ pub(super) fn viewport_context_menu_overlay( .scroller_width(6), )); - let menu = container(menu_col) + let panel = container(menu_col) .style(container::bordered_box) - .padding([4, 0]) - .width(Length::Fixed(180.0)); + .padding([MENU_PAD_TOP as u16, 0]) + .width(Length::Fixed(menu.width)); - position_canvas_overlay_clamped(pos, bottom_inset, menu.into()) + // Shift the panel so the default row's centre line is under the pointer. + let offset = Vector::new( + -MENU_CURSOR_INSET_X, + -(menu.default_row_y() + MENU_ROW_H * 0.5), + ); + position_canvas_overlay_clamped(pos, bottom_inset, offset, panel.into()) +} + +/// One menu row: label (bold for the default row, "✓ "-prefixed when +/// checked), and the keyword hint right-aligned and dimmed. Disabled rows +/// render without a press handler and with faded text. +fn context_menu_row(item: &MenuItem, indent: f32, highlighted: bool) -> Element<'static, Message> { + let label = if item.checked { + format!("✓ {}", item.label) + } else { + item.label.clone() + }; + let mut label_text = text(label).size(12); + if item.default { + label_text = label_text.font(iced::Font { + weight: iced::font::Weight::Bold, + ..iced::Font::DEFAULT + }); + } + let enabled = item.enabled; + let mut content = row![context_menu_gutter(item.icon), label_text] + .spacing(4) + .align_y(iced::Center); + if let Some(hint) = item.hint.as_ref() { + content = content + .push(iced::widget::Space::new().width(Fill)) + .push( + text(hint.clone()) + .size(11) + .style(move |theme: &Theme| { + let palette = theme.palette(); + let base = if highlighted { + palette.primary.strong.text + } else { + palette.background.base.text + }; + iced::widget::text::Style { + color: Some(base.scale_alpha(if enabled { 0.6 } else { 0.35 })), + } + }), + ); + } + let mut btn = button(content) + .padding(iced::Padding { + top: 3.0, + right: 12.0, + bottom: 3.0, + left: 6.0 + indent, + }) + .width(Fill) + .height(Length::Fixed(MENU_ROW_H)) + .style(move |theme: &Theme, status| context_menu_row_style(theme, status, highlighted)); + if enabled { + btn = btn.on_press(Message::ContextMenuPick(item.action.clone())); + } + btn.into() +} + +/// Row colours: keyboard highlight uses the accent (as the grip popup does), +/// hover the subtle background tint, disabled rows faded text. +fn context_menu_row_style(theme: &Theme, status: button::Status, highlighted: bool) -> button::Style { + let palette = theme.palette(); + let (background, text_color) = if highlighted { + ( + Some(Background::Color(palette.primary.strong.color)), + palette.primary.strong.text, + ) + } else { + match status { + button::Status::Hovered | button::Status::Pressed => ( + Some(Background::Color(palette.background.weak.color)), + palette.background.weak.text, + ), + button::Status::Disabled => (None, palette.background.base.text.scale_alpha(0.42)), + button::Status::Active => (None, palette.background.base.text), + } + }; + button::Style { + background, + text_color, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 0.0.into(), + }, + ..Default::default() + } } /// One-shot snap override menu (Shift+RMB, #337): a cursor-anchored grid of diff --git a/src/app/viewport_dimension_tests.rs b/src/app/viewport_dimension_tests.rs new file mode 100644 index 000000000..a10b4fa45 --- /dev/null +++ b/src/app/viewport_dimension_tests.rs @@ -0,0 +1,1475 @@ +use super::OpenCADStudio; +use crate::command::{DimensionAssociationSource, StepInput}; +use crate::entities::dim_override; +use crate::scene::viewport_ref::{AcceptedSnap, MeasurementScale, ViewportFrame}; +use crate::scene::Scene; +use crate::snap::{SnapResult, SnapType}; +use acadrust::entities::{Circle, Dimension, Line, Viewport}; +use acadrust::types::{Handle, Vector3}; +use acadrust::{CadDocument, EntityType}; +use glam::DVec3; + +fn fixture() -> (OpenCADStudio, Handle, ViewportFrame) { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let mut scene = Scene::new(); + let line = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(100.0, 0.0, 0.0), + ))); + scene.document.add_layout("Dimensions").unwrap(); + scene.set_current_layout("Dimensions".into()); + let mut viewport = Viewport::new(); + viewport.id = 2; + viewport.center = Vector3::new(50.0, 50.0, 0.0); + viewport.width = 100.0; + viewport.height = 100.0; + viewport.view_height = 1000.0; + viewport.custom_scale = 0.1; + viewport.view_direction = Vector3::UNIT_Z; + viewport.status.is_on = true; + let viewport = scene.add_entity(EntityType::Viewport(viewport)); + let frame = scene.viewport_frame(viewport).unwrap(); + scene.document.header.dimension_associativity = 2; + let i = app.active_tab; + app.tabs[i].scene = scene; + (app, line, frame) +} + +fn hit(frame: ViewportFrame, source: Handle, model: DVec3) -> SnapResult { + SnapResult { + world: frame.model_to_paper(model), + model_point: Some(model), + screen: iced::Point::ORIGIN, + snap_type: SnapType::Endpoint, + tangent_obj: None, + extension_base: None, + extension_base2: None, + extension_origin: None, + extension_dir: None, + viewport: Some(frame.viewport), + source: Some(DimensionAssociationSource::inferred(source)), + secondary_source: None, + } +} + +fn point(app: &mut OpenCADStudio, frame: ViewportFrame, source: Handle, model: DVec3) { + let i = app.active_tab; + let hit = hit(frame, source, model); + let paper = hit.world.with_z(0.0); + assert!(app.record_accepted_snap(i, Some(hit), Some(frame), paper)); + let result = app.tabs[i].active_cmd.as_mut().unwrap().on_point(paper); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); +} + +fn dimension(doc: &CadDocument) -> &Dimension { + doc.entities() + .find_map(|e| match e { + EntityType::Dimension(d) => Some(d), + _ => None, + }) + .unwrap() +} + +fn displayed(doc: &CadDocument) -> f64 { + let dim = dimension(doc); + if matches!(dim, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) { + return dim.measurement(); + } + let factor = dim_override::real(&dim.base().common.extended_data, dim_override::DIMLFAC) + .or_else(|| { + doc.dim_styles + .get(&dim.base().style_name) + .map(|s| s.dimlfac) + }) + .unwrap_or(1.0); + dim.measurement() * MeasurementScale::user_lfac_for_space(factor, true) +} + +pub(super) fn preview_text(app: &OpenCADStudio, entity: &EntityType) -> String { + let EntityType::Dimension(dimension) = entity else { panic!("expected dimension") }; + let doc = &app.tabs[app.active_tab].scene.document; + let source = doc.dim_styles.get(&dimension.base().style_name).unwrap(); + let style = crate::entities::dimension::resolved_dimension_style(source, dimension, doc); + crate::entities::dimension::dimension_text_value(dimension, Some(&style)).unwrap() +} + +fn finish_aligned(app: &mut OpenCADStudio, line: Handle, frame: ViewportFrame) { + let _ = app.dispatch_command("DIMALIGNED"); + point(app, frame, line, DVec3::ZERO); + point(app, frame, line, DVec3::X * 100.0); + let _ = app.feed_command(StepInput::Point(DVec3::new(250.0, 200.0, 0.0))); +} + +#[test] +fn viewport_dimension_navigation_refreshes_after_the_complete_camera_update() { + let (mut app, _, _, frame) = + start_centerline_dimension("DIMALIGNED", true, SnapType::Midpoint, 1.0); + let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 80.0, 0.0))); + let scene = &mut app.tabs[app.active_tab].scene; + assert!((displayed(&scene.document) - 50.0).abs() < 1e-4); + scene.active_viewport = Some(frame.viewport); + let mut camera = scene.navigation_camera(); + camera.target.x += 20.0; + camera.distance *= 0.5; + assert!(scene.apply_navigation_camera(camera)); + assert!( + (displayed(&scene.document) - 55.0).abs() < 1e-4, + "{}", + displayed(&scene.document) + ); +} + +#[test] +fn viewport_dimension_imported_paper_owner_does_not_require_layout_objects() { + let (mut app, line, frame) = fixture(); + finish_aligned(&mut app, line, frame); + let doc = &mut app.tabs[app.active_tab].scene.document; + doc.objects + .retain(|_, object| !matches!(object, acadrust::objects::ObjectType::Layout(_))); + assert!(crate::entities::dimension::dimension_in_paper_space( + dimension(doc), + doc + )); +} + +#[test] +fn viewport_dimension_curved_clip_includes_the_exact_boundary() { + let (mut app, _, frame) = fixture(); + let scene = &mut app.tabs[app.active_tab].scene; + let mut circle = Circle::new(); + circle.center = Vector3::new(50.0, 50.0, 0.0); + circle.radius = 10.0; + let boundary = circle.point_at_angle_wcs(0.123456); + let clip = scene.add_entity(EntityType::Circle(circle)); + let EntityType::Viewport(vp) = scene.document.get_entity_mut(frame.viewport).unwrap() else { + panic!() + }; + vp.clip_boundary_handle = clip; + assert!(scene + .viewport_displays_paper_point(frame.viewport, glam::DVec2::new(boundary.x, boundary.y))); + assert!(!scene.viewport_displays_paper_point(frame.viewport, glam::DVec2::new(60.01, 50.0))); + assert!(!scene.viewport_displays_paper_point(frame.viewport, glam::DVec2::new(35.0, 60.0))); +} + +#[test] +fn viewport_dimension_apparent_intersection_extends_only_the_lines() { + use crate::scene::dimension_assoc_chain::{feature_point, osnap, FeatureContext}; + use acadrust::objects::AssocDimensionReference; + let mut scene = Scene::new(); + let first = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::UNIT_X, + ))); + let second = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(2.0, -1.0, 0.0), + Vector3::new(2.0, -0.5, 0.0), + ))); + let mut reference = AssocDimensionReference { + osnap_type: osnap::APPARENT_INT, + xrefs: vec![first], + intersection_objects: vec![second], + ..Default::default() + }; + let expected = Vector3::new(2.0, 0.0, 0.0); + let context = FeatureContext { + hint: Some(expected), + from: None, + }; + let entity = scene.document.get_entity(first).unwrap(); + assert_eq!( + feature_point(&scene.document, entity, &reference, context), + Some(expected) + ); + reference.osnap_type = osnap::INTERSEC; + assert!(feature_point(&scene.document, entity, &reference, context).is_none()); +} + +#[test] +fn viewport_dimension_projected_snap_keeps_identity_and_elevation() { + let (_, line, frame) = fixture(); + let model = DVec3::new(100.0, 0.0, 35.0); + let hit = hit(frame, line, model); + let accepted = + AcceptedSnap::from_snap(&hit, Some(frame)).with_paper_point(hit.world.with_z(0.0)); + assert_eq!(accepted.model_point, model); + assert_eq!(accepted.source.as_ref().unwrap().source.handle, line); + let moved = accepted.with_paper_point(hit.world + DVec3::X); + assert!(moved.source.is_none()); + assert!((moved.model_point - frame.paper_to_model(moved.paper_point)).length() < 1e-9); +} + +#[test] +fn viewport_dimension_creation_preserves_active_units_and_roundtrips() { + for factor in [25.4, -25.4] { + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + let mut style = acadrust::tables::DimStyle::new("Millimetres"); + style.dimlfac = factor; + app.tabs[i].scene.document.dim_styles.add(style).unwrap(); + app.tabs[i].scene.document.header.current_dimstyle_name = "Millimetres".into(); + finish_aligned(&mut app, line, frame); + let doc = &app.tabs[i].scene.document; + assert_eq!(dimension(doc).base().style_name, "Millimetres"); + assert!((displayed(doc) - 2540.0).abs() < 1e-5, "{}", displayed(doc)); + let data = MeasurementScale::read(&dimension(doc).base().common.extended_data).unwrap(); + assert_eq!(data.user_lfac, 25.4); + assert_eq!(doc.header.dimension_associativity, 2); + for ext in ["dxf", "dwg"] { + let bytes = crate::io::save_to_bytes(doc, ext, doc.version).unwrap(); + let loaded = crate::io::load_bytes(&format!("viewport.{ext}"), bytes).unwrap(); + assert!( + (displayed(&loaded) - 2540.0).abs() < 1e-4, + "{ext}: {}", + displayed(&loaded) + ); + assert_eq!( + MeasurementScale::read(&dimension(&loaded).base().common.extended_data), + Some(data) + ); + } + app.undo_steps(1); + assert!(!app.tabs[i] + .scene + .document + .entities() + .any(|e| matches!(e, EntityType::Dimension(_)))); + app.redo_steps(1); + assert!((displayed(&app.tabs[i].scene.document) - 2540.0).abs() < 1e-5); + } +} + +#[test] +fn viewport_dimension_conflicting_viewports_and_degenerate_picks_allow_retry() { + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + let _ = app.dispatch_command("DIMALIGNED"); + point(&mut app, frame, line, DVec3::ZERO); + point(&mut app, frame, line, DVec3::ZERO); + assert_eq!(app.accepted_snaps().len(), 1); + let other = ViewportFrame { + viewport: Handle::new(0xFFFF), + ..frame + }; + assert!(!app.record_accepted_snap( + i, + Some(hit(other, line, DVec3::X * 100.0)), + Some(other), + DVec3::new(60.0, 50.0, 0.0) + )); + point(&mut app, frame, line, DVec3::X * 100.0); + let _ = app.feed_command(StepInput::Point(DVec3::new(250.0, 200.0, 0.0))); + assert!((displayed(&app.tabs[i].scene.document) - 100.0).abs() < 1e-5); +} + +#[test] +fn viewport_dimension_typed_sheet_points_keep_paper_units() { + let (mut app, _, _) = fixture(); + let result = app.automation_op(r#"{"op":"run","cmd":"DIMALIGNED 50,50 60,50 60,55"}"#); + assert_eq!(result["ok"], true); + let doc = &app.tabs[app.active_tab].scene.document; + assert!((displayed(doc) - 10.0).abs() < 1e-9); + assert!(MeasurementScale::read(&dimension(doc).base().common.extended_data).is_none()); +} + +#[test] +fn viewport_dimension_linear_aligned_and_radial_object_picks() { + for (name, expected) in [ + ("DIMLINEAR", 100.0), + ("DIMALIGNED", 100.0), + ("DIMRADIUS", 100.0), + ("DIMDIAMETER", 200.0), + ] { + let (mut app, _, frame) = fixture(); + let i = app.active_tab; + let radial = name == "DIMRADIUS" || name == "DIMDIAMETER"; + let model = if radial { + let mut circle = Circle::new(); + circle.center = Vector3::new(0.0, 200.0, 0.0); + circle.radius = 100.0; + app.tabs[i].scene.set_current_layout("Model".into()); + app.tabs[i].scene.add_entity(EntityType::Circle(circle)); + app.tabs[i].scene.set_current_layout("Dimensions".into()); + DVec3::new(100.0, 200.0, 0.0) + } else { + DVec3::new(50.0, 0.0, 0.0) + }; + let _ = app.dispatch_command(name); + if !radial { + let _ = app.feed_command(StepInput::Enter); + } + let result = app + .try_dimension_viewport_entity_pick(i, frame.model_to_paper(model), 0.5) + .unwrap(); + let _ = app.apply_cmd_result(result); + assert_eq!( + app.accepted_snaps().len(), + if radial { 1 } else { 2 }, + "{name}" + ); + let place = DVec3::new(55.0, 80.0, 0.0); + let preview = app.dimension_preview_entities(i, place).unwrap(); + assert_eq!(preview.len(), 1, "{name}"); + let value = preview_text(&app, &preview[0]); + assert!(!app.dimension_preview_wires(i, place).unwrap().is_empty(), "{name}"); + let _ = app.feed_command(StepInput::Point(place)); + assert_eq!(preview_text(&app, &EntityType::Dimension(dimension(&app.tabs[i].scene.document).clone())), value, "{name}"); + assert!( + (displayed(&app.tabs[i].scene.document) - expected).abs() < 1e-4, + "{name}: {}", + displayed(&app.tabs[i].scene.document) + ); + } +} + +#[test] +fn viewport_dimension_angular_preserves_angle_without_length_factor() { + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.set_current_layout("Model".into()); + let vertical = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(0.0, 100.0, 0.0), + ))); + app.tabs[i].scene.set_current_layout("Dimensions".into()); + let _ = app.dispatch_command("DIMANGULAR"); + let _ = app.feed_command(StepInput::Enter); + point(&mut app, frame, line, DVec3::ZERO); + point(&mut app, frame, line, DVec3::X * 100.0); + point(&mut app, frame, vertical, DVec3::Y * 100.0); + let place = DVec3::new(70.0, 70.0, 0.0); + let preview = app.dimension_preview_entities(i, place).unwrap(); + assert_eq!(preview.len(), 1); + assert!(MeasurementScale::read(&preview[0].common().extended_data).is_none()); + let value = preview_text(&app, &preview[0]); + let _ = app.feed_command(StepInput::Point(place)); + let doc = &app.tabs[app.active_tab].scene.document; + assert_eq!(preview_text(&app, &EntityType::Dimension(dimension(doc).clone())), value); + assert!((dimension(doc).measurement() - 90.0).abs() < 1e-5); + assert!(MeasurementScale::read(&dimension(doc).base().common.extended_data).is_none()); +} + +#[test] +fn viewport_dimension_preview_uses_candidate_scale_only_before_second_pick() { + for command in ["DIMLINEAR", "DIMALIGNED"] { + let (mut app, model, frame) = fixture(); + let i = app.active_tab; + let mut style = acadrust::tables::DimStyle::new("PreviewUnits"); + style.dimlfac = 25.4; + app.tabs[i].scene.document.dim_styles.add(style).unwrap(); + app.tabs[i].scene.document.header.current_dimstyle_name = "PreviewUnits".into(); + let _ = app.dispatch_command(command); + let _ = app.feed_command(StepInput::Point(DVec3::new(55.0, 50.0, 0.0))); + let target = hit(frame, model, DVec3::X * 100.0); + let sheet = app.dimension_preview_entities(i, target.world).unwrap(); + assert_eq!(preview_text(&app, &sheet[0]), "127"); + app.vp_snap_frame = Some(frame); + app.tabs[i].snap_result = Some(target); + let model_preview = app.dimension_preview_entities(i, target.world).unwrap(); + assert_eq!(preview_text(&app, &model_preview[0]), "1270"); + point(&mut app, frame, model, DVec3::X * 100.0); + let other = ViewportFrame { viewport: Handle::new(0xFFFF), scale: frame.scale * 2.0, ..frame }; + app.vp_snap_frame = Some(other); + app.tabs[i].snap_result = Some(hit(other, model, DVec3::X * 100.0)); + let placement = app.dimension_preview_entities(i, DVec3::new(60.0, 80.0, 0.0)).unwrap(); + assert_eq!(preview_text(&app, &placement[0]), "1270"); + } +} + +#[test] +fn viewport_dimension_snap_query_matches_pan_scale_and_twist() { + for (height, twist, target) in [ + (1000.0, 0.0, Vector3::ZERO), + (500.0, 0.45, Vector3::new(80.0, 20.0, 0.0)), + ] { + let (mut app, line, initial) = fixture(); + let i = app.active_tab; + let Some(EntityType::Viewport(vp)) = + app.tabs[i].scene.document.get_entity_mut(initial.viewport) + else { + panic!() + }; + vp.view_height = height; + vp.custom_scale = vp.height / height; + vp.twist_angle = twist; + vp.view_target = target; + let frame = app.tabs[i].scene.viewport_frame(initial.viewport).unwrap(); + let model = DVec3::X * 100.0; + let paper = frame.model_to_paper(model); + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1000.0, + height: 1000.0, + }; + let screen = { + let cam = app.tabs[i].scene.camera.borrow(); + let ndc = cam + .view_proj_rte(bounds) + .project_point3((paper - cam.eye()).as_vec3()); + iced::Point::new((ndc.x + 1.0) * 500.0, (1.0 - ndc.y) * 500.0) + }; + app.snapper.snap_enabled = true; + let (hit, acquired_frame) = app + .paper_viewport_snap(i, screen, (1000.0, 1000.0), paper) + .unwrap(); + assert_eq!(hit.source.unwrap().handle, line); + assert!((hit.world - paper).length() < 1e-5, "{hit:?}"); + let accepted = AcceptedSnap::from_snap(&hit, Some(acquired_frame)); + assert!((accepted.model_point - model).length() < 1e-6); + } +} + +#[test] +fn viewport_dimension_eligibility_is_shared_and_uses_clipping() { + use acadrust::entities::LwPolyline; + use acadrust::types::Vector2; + let (mut app, _, first) = fixture(); + let scene = &mut app.tabs[app.active_tab].scene; + let mut overlay = scene.document.get_entity(first.viewport).unwrap().clone(); + if let EntityType::Viewport(vp) = &mut overlay { + vp.id = 3; + vp.width = 200.0; + } + overlay.common_mut().handle = Handle::NULL; + let top = scene.add_entity(overlay); + let paper = DVec3::new(55.0, 50.0, 0.0); + assert_eq!( + scene + .viewport_frames_at_paper_point(paper) + .into_iter() + .next() + .unwrap() + .viewport, + top + ); + assert_eq!( + scene + .dimension_pick_through_viewport(paper, 0.2) + .unwrap() + .frame + .viewport, + top + ); + let mut clip = LwPolyline::from_points(vec![ + Vector2::new(0.0, 0.0), + Vector2::new(100.0, 0.0), + Vector2::new(0.0, 100.0), + ]); + clip.is_closed = true; + let clip = scene.add_entity(EntityType::LwPolyline(clip)); + if let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(top) { + vp.clip_boundary_handle = clip; + } + assert_eq!( + scene + .viewport_frames_at_paper_point(paper) + .into_iter() + .next() + .unwrap() + .viewport, + first.viewport + ); + assert_eq!( + scene + .dimension_pick_through_viewport(paper, 0.2) + .unwrap() + .frame + .viewport, + first.viewport + ); + if let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(first.viewport) { + vp.status.is_on = false; + } + assert!(scene.viewport_frames_at_paper_point(paper).is_empty()); + assert!(scene.dimension_pick_through_viewport(paper, 0.2).is_none()); +} + +#[test] +fn viewport_dimension_exploded_commit_uses_compensated_text_and_undo() { + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.document.header.dimension_associativity = 0; + finish_aligned(&mut app, line, frame); + let doc = &app.tabs[i].scene.document; + assert!(!doc + .entities() + .any(|e| matches!(e, EntityType::Dimension(_)))); + let texts: Vec<_> = doc + .entities() + .filter_map(|e| match e { + EntityType::Text(t) => Some(t.value.clone()), + EntityType::MText(t) => Some(t.value.clone()), + _ => None, + }) + .collect(); + assert!(texts.iter().any(|t| t.contains("100")), "{texts:?}"); + app.undo_steps(1); + assert_eq!(app.tabs[i].scene.document.header.dimension_associativity, 0); + app.redo_steps(1); + assert!(app.tabs[i] + .scene + .document + .entities() + .any(|e| matches!(e, EntityType::Text(_) | EntityType::MText(_)))); +} + +#[test] +fn viewport_dimension_association_scale_edit_picture_and_history() { + use crate::scene::ChangeKind; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + finish_aligned(&mut app, line, frame); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + assert_eq!( + app.tabs[i].scene.dimension_association_status(handle).len(), + 2 + ); + let Some(EntityType::Dimension(d)) = app.tabs[i].scene.document.get_entity_mut(handle) else { + panic!() + }; + d.base_mut().text_user_positioned = true; + d.base_mut().text_middle_point = Vector3::new(250.0, 200.0, 0.0); + d.base_mut().block_name = "*SavedPicture".into(); + let pending = app.begin_undo(i, "Viewport scale", 1, true).unwrap(); + let before = app.tabs[i].scene.document.get_entity_arc(frame.viewport); + app.tabs[i].scene.record_undo_before(frame.viewport, before); + let Some(EntityType::Viewport(vp)) = app.tabs[i].scene.document.get_entity_mut(frame.viewport) + else { + panic!() + }; + vp.view_height = 500.0; + vp.custom_scale = 0.2; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + app.commit_undo_delta(i, pending); + assert!((displayed(&app.tabs[i].scene.document) - 100.0).abs() < 1e-5); + assert!((dimension(&app.tabs[i].scene.document).measurement() - 20.0).abs() < 1e-5); + assert!(dimension(&app.tabs[i].scene.document) + .base() + .block_name + .is_empty()); + assert_eq!( + dimension(&app.tabs[i].scene.document) + .base() + .text_middle_point, + Vector3::new(250.0, 200.0, 0.0) + ); + app.undo_steps(1); + assert!((dimension(&app.tabs[i].scene.document).measurement() - 10.0).abs() < 1e-5); + assert_eq!( + dimension(&app.tabs[i].scene.document).base().block_name, + "*SavedPicture" + ); + app.redo_steps(1); + assert!((displayed(&app.tabs[i].scene.document) - 100.0).abs() < 1e-5); + let pending = app.begin_undo(i, "Edit source", 1, true).unwrap(); + let before = app.tabs[i].scene.document.get_entity_arc(line); + app.tabs[i].scene.record_undo_before(line, before); + let Some(EntityType::Line(source)) = app.tabs[i].scene.document.get_entity_mut(line) else { + panic!() + }; + source.end.x = 175.0; + app.tabs[i] + .scene + .bump_entities(&[(line, ChangeKind::Modified)]); + app.commit_undo_delta(i, pending); + assert!((displayed(&app.tabs[i].scene.document) - 175.0).abs() < 1e-5); + let pieces = crate::modules::draw::modify::explode::explode_entity( + app.tabs[i].scene.document.get_entity(handle).unwrap(), + &app.tabs[i].scene.document, + ); + assert!( + pieces.iter().any( + |e| matches!(e,EntityType::Text(t) if t.value.contains("175")) + || matches!(e,EntityType::MText(t) if t.value.contains("175")) + ), + "{pieces:?}" + ); + for ext in ["dxf", "dwg"] { + let doc = &app.tabs[i].scene.document; + let bytes = crate::io::save_to_bytes(doc, ext, doc.version).unwrap(); + let mut loaded = Scene::new(); + loaded.document = crate::io::load_bytes(&format!("assoc.{ext}"), bytes).unwrap(); + loaded.notify_viewport_changed(frame.viewport); + assert!((displayed(&loaded.document) - 175.0).abs() < 1e-5, "{ext}"); + assert_eq!(loaded.dimension_association_status(handle).len(), 2); + } + app.undo_steps(1); + assert!((displayed(&app.tabs[i].scene.document) - 100.0).abs() < 1e-5); +} + +#[test] +fn viewport_dimension_unsupported_and_erased_sources_keep_last_picture() { + use crate::scene::dimension_assoc::{resolve_reference_chain, ReferenceStatus}; + use acadrust::objects::AssocDimensionReference; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + finish_aligned(&mut app, line, frame); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + let reference = AssocDimensionReference { + xrefs: vec![frame.viewport, line], + osnap_type: 255, + ..Default::default() + }; + assert!(matches!( + resolve_reference_chain(&app.tabs[i].scene, &reference, None), + Err(ReferenceStatus::Unresolved) + )); + let previous = app.tabs[i] + .scene + .document + .get_entity(handle) + .unwrap() + .clone(); + let Some(EntityType::Viewport(vp)) = app.tabs[i].scene.document.get_entity_mut(frame.viewport) + else { + panic!() + }; + vp.status.perspective = true; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + assert_eq!( + app.tabs[i].scene.document.get_entity(handle).unwrap(), + &previous + ); + assert!(app.tabs[i] + .scene + .dimension_association_status(handle) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Unresolved)); + let Some(EntityType::Viewport(vp)) = app.tabs[i].scene.document.get_entity_mut(frame.viewport) + else { + panic!() + }; + vp.status.perspective = false; + let pending = app.begin_undo(i, "Erase source", 1, false).unwrap(); + app.tabs[i].scene.erase_entities(&[line]); + app.commit_undo_delta(i, pending); + assert_eq!( + app.tabs[i].scene.document.get_entity(handle).unwrap(), + &previous + ); + assert!(app.tabs[i] + .scene + .dimension_association_status(handle) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Broken(line))); + app.undo_steps(1); + assert!(app.tabs[i] + .scene + .dimension_association_status(handle) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Resolved)); +} + +#[test] +fn viewport_dimension_intersection_tracks_both_entities() { + use crate::scene::ChangeKind; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.set_current_layout("Model".into()); + let other = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(60.0, -50.0, 0.0), + Vector3::new(60.0, 50.0, 0.0), + ))); + app.tabs[i].scene.set_current_layout("Dimensions".into()); + let _ = app.dispatch_command("DIMALIGNED"); + point(&mut app, frame, line, DVec3::ZERO); + let mut intersection = hit(frame, line, DVec3::X * 60.0); + intersection.snap_type = SnapType::Intersection; + intersection.secondary_source = Some(DimensionAssociationSource::inferred(other)); + assert!(app.record_accepted_snap(i, Some(intersection), Some(frame), intersection.world)); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(intersection.world); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); + let _ = app.feed_command(StepInput::Point(DVec3::new(80.0, 80.0, 0.0))); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + assert!(app.tabs[i] + .scene + .dimension_association_sources(handle) + .contains(&other)); + assert!((displayed(&app.tabs[i].scene.document) - 60.0).abs() < 1e-5); + let Some(EntityType::Line(source)) = app.tabs[i].scene.document.get_entity_mut(other) else { + panic!() + }; + source.start.x = 80.0; + source.end.x = 80.0; + app.tabs[i] + .scene + .bump_entities(&[(other, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 80.0).abs() < 1e-5); +} + +#[test] +fn viewport_dimension_spline_tangency_is_parallel_and_validated() { + use crate::scene::dimension_assoc_chain::{feature_point, osnap, FeatureContext}; + use acadrust::objects::AssocDimensionReference; + let mut spline = acadrust::entities::Spline::new(); + spline.degree = 2; + spline.control_points = vec![ + Vector3::ZERO, + Vector3::new(0.5, 0.0, 0.0), + Vector3::new(1.0, 1.0, 0.0), + ]; + spline.knots = vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0]; + let entity = EntityType::Spline(spline); + let reference = AssocDimensionReference { + osnap_type: osnap::TAN, + osnap_distance: 0.8, + ..Default::default() + }; + let result = feature_point( + &CadDocument::new(), + &entity, + &reference, + FeatureContext { + hint: Some(Vector3::new(1.0, 1.0, 0.0)), + from: Some(Vector3::new(0.0, -1.0, 0.0)), + }, + ) + .unwrap(); + assert!( + (result.x - 1.0).abs() < 1e-6 && (result.y - 1.0).abs() < 1e-6, + "{result:?}" + ); + assert!(feature_point( + &CadDocument::new(), + &entity, + &reference, + FeatureContext { + hint: None, + from: Some(Vector3::new(0.0, 1.0, 0.0)) + } + ) + .is_none()); +} + +#[test] +#[ignore = "Set OPENCAD_VIEWPORT_REGRESSION_DXF to an external regression fixture"] +fn viewport_dimension_external_fixture_keeps_unchanged_measurements() { + use crate::scene::ChangeKind; + let path = std::env::var("OPENCAD_VIEWPORT_REGRESSION_DXF").expect("regression drawing path"); + let doc = acadrust::DxfReader::from_file(std::path::Path::new(&path)) + .unwrap() + .read() + .unwrap(); + let mut scene = Scene::new(); + scene.document = doc; + let dimensions: Vec<_> = scene + .document + .entities() + .filter_map(|entity| match entity { + EntityType::Dimension(dim) => { + Some((dim.base().common.handle, dim.base().actual_measurement)) + } + _ => None, + }) + .collect(); + let mut sources: Vec<_> = dimensions + .iter() + .flat_map(|(h, _)| scene.dimension_association_sources(*h)) + .collect(); + sources.sort(); + sources.dedup(); + scene.bump_entities( + &sources + .into_iter() + .map(|h| (h, ChangeKind::Modified)) + .collect::>(), + ); + for (handle, saved) in dimensions { + if scene.dimension_association_status(handle).is_empty() { + continue; + } + let EntityType::Dimension(dim) = scene.document.get_entity(handle).unwrap() else { + panic!() + }; + let factor = dim_override::real(&dim.base().common.extended_data, dim_override::DIMLFAC) + .unwrap_or(1.0) + .abs(); + let result = dim.measurement() * factor; + println!( + "{:X}: saved={saved:.9}, current={result:.9}, status={:?}", + handle.value(), + scene.dimension_association_status(handle) + ); + assert!( + (result - saved).abs() < 1e-3, + "{:X}: {saved} -> {result}", + handle.value() + ); + } +} + +#[test] +fn viewport_dimension_fixture_regenerates_and_tracks_an_intersection() { + use crate::scene::ChangeKind; + let bytes = include_bytes!("../../tests/fixtures/dim_assoc/viewport_associations.dxf").to_vec(); + let mut scene = Scene::new(); + scene.document = crate::io::load_bytes("viewport_associations.dxf", bytes).unwrap(); + scene.notify_viewport_changed(Handle::new(0x200)); + // The cubic's tangent from (0,300) touches at u=.75, C=(90,283.125). + let EntityType::Spline(spline) = scene.document.get_entity(Handle::new(0x104)).unwrap() else { + panic!() + }; + let curve = crate::entities::spline::nurbs3(spline).unwrap(); + let c = DVec3::from_array(curve.point_at_knot(0.75)); + let tangent = DVec3::from_array(curve.derivative_at_knot(0.75)); + assert!((c - DVec3::new(0.0, 300.0, 0.0)).cross(tangent).length() < 1e-8); + let measured = |scene: &Scene, handle| { + let EntityType::Dimension(d) = scene.document.get_entity(Handle::new(handle)).unwrap() + else { + panic!() + }; + d.measurement() + * dim_override::real(&d.base().common.extended_data, dim_override::DIMLFAC) + .unwrap_or(1.0) + .abs() + }; + for (handle, expected) in [ + (0x201, 100.0), + (0x202, 50.0), + (0x203, 35.355339), + (0x204, 141.421356), + (0x205, 90.0_f64.hypot(16.875)), + (0x206, 80.0), + (0x207, 60.0), + ] { + assert!( + (measured(&scene, handle) - expected).abs() < 1e-4, + "{handle:X}: {}", + measured(&scene, handle) + ); + } + let Some(EntityType::Line(line)) = scene.document.get_entity_mut(Handle::new(0x107)) else { + panic!() + }; + line.start.x = 80.0; + line.end.x = 80.0; + scene.bump_entities(&[(Handle::new(0x107), ChangeKind::Modified)]); + assert!((measured(&scene, 0x207) - 80.0).abs() < 1e-5); + let before = scene + .document + .get_entity(Handle::new(0x205)) + .unwrap() + .clone(); + for types in [(1, 255), (8, 9)] { + let acadrust::objects::ObjectType::Associative(object) = + scene.document.objects.get_mut(&Handle::new(0x304)).unwrap() + else { + panic!() + }; + let acadrust::objects::AssociativeData::DimensionAssociation(assoc) = &mut object.data + else { + panic!() + }; + assoc.references[0][0].osnap_type = types.0; + assoc.references[1][0].osnap_type = types.1; + scene.notify_viewport_changed(Handle::new(0x200)); + assert_eq!( + scene.document.get_entity(Handle::new(0x205)).unwrap(), + &before + ); + assert!(scene + .dimension_association_status(Handle::new(0x205)) + .iter() + .any(|(_, s)| *s == crate::scene::ReferenceStatus::Unresolved)); + } +} + +#[test] +fn viewport_dimension_nearest_parameter_survives_source_stretch() { + use crate::scene::ChangeKind; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + let _ = app.dispatch_command("DIMALIGNED"); + point(&mut app, frame, line, DVec3::ZERO); + let mut near = hit(frame, line, DVec3::X * 75.0); + near.snap_type = SnapType::Nearest; + assert!(app.record_accepted_snap(i, Some(near), Some(frame), near.world)); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(near.world); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); + let _ = app.feed_command(StepInput::Point(DVec3::new(55.0, 80.0, 0.0))); + assert!((displayed(&app.tabs[i].scene.document) - 75.0).abs() < 1e-5); + let Some(EntityType::Line(source)) = app.tabs[i].scene.document.get_entity_mut(line) else { + panic!() + }; + source.end.x = 200.0; + app.tabs[i] + .scene + .bump_entities(&[(line, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 150.0).abs() < 1e-5); +} + +#[test] +fn viewport_dimension_nested_block_path_survives_edit_and_copy() { + use crate::scene::ChangeKind; + use acadrust::entities::Insert; + use acadrust::tables::BlockRecord; + let (mut app, _, frame) = fixture(); + let i = app.active_tab; + let scene = &mut app.tabs[i].scene; + let mut inner = BlockRecord::new("Inner"); + inner.handle = scene.document.allocate_handle(); + let inner_h = inner.handle; + scene.document.block_records.add(inner).unwrap(); + let mut leaf = Line::from_points(Vector3::ZERO, Vector3::new(20.0, 0.0, 0.0)); + leaf.common.owner_handle = inner_h; + let leaf = scene.document.add_entity(EntityType::Line(leaf)).unwrap(); + let mut outer = BlockRecord::new("Outer"); + outer.handle = scene.document.allocate_handle(); + let outer_h = outer.handle; + scene.document.block_records.add(outer).unwrap(); + let mut nested = Insert::new("Inner", Vector3::new(30.0, 0.0, 0.0)); + nested.common.owner_handle = outer_h; + nested.set_x_scale(3.0); + nested.set_y_scale(3.0); + nested.rotation = 0.4; + let nested = scene + .document + .add_entity(EntityType::Insert(nested)) + .unwrap(); + scene.set_current_layout("Model".into()); + let mut root = Insert::new("Outer", Vector3::new(20.0, 50.0, 0.0)); + root.set_x_scale(2.0); + root.set_y_scale(2.0); + root.rotation = 0.3; + let root = scene.add_entity(EntityType::Insert(root)); + scene.set_current_layout("Dimensions".into()); + let transform = + crate::scene::dimension_assoc_chain::walk_chain(&scene.document, &[root, nested, leaf]) + .unwrap() + .transform; + let model = |x| { + let v = transform.apply(Vector3::new(x, 0.0, 0.0)); + DVec3::new(v.x, v.y, v.z) + }; + let _ = app.dispatch_command("DIMALIGNED"); + point(&mut app, frame, root, model(0.0)); + let accepted = app.accepted_snaps()[0].source.as_ref().unwrap(); + assert_eq!(accepted.source.handle, leaf); + assert_eq!(accepted.block_path, vec![root, nested]); + point(&mut app, frame, root, model(20.0)); + let _ = app.feed_command(StepInput::Point(DVec3::new(90.0, 90.0, 0.0))); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + assert!((displayed(&app.tabs[i].scene.document) - 120.0).abs() < 1e-5); + let Some(EntityType::Line(source)) = app.tabs[i].scene.document.get_entity_mut(leaf) else { + panic!() + }; + source.end.x = 40.0; + app.tabs[i] + .scene + .bump_entities(&[(leaf, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 240.0).abs() < 1e-5); + let copied = app.tabs[i].scene.copy_entities( + &[handle], + &crate::command::EntityTransform::Translate(DVec3::new(10.0, 10.0, 0.0)), + ); + assert_eq!(copied.len(), 1); + assert!(app.tabs[i].scene.dimension_association(copied[0]).is_none()); + assert!(app.tabs[i].scene.dimension_association(handle).is_some()); +} + +#[test] +fn viewport_dimension_hidden_border_and_lock_keep_content_available() { + let (mut app, _, frame) = fixture(); + let scene = &mut app.tabs[app.active_tab].scene; + let mut layer = acadrust::tables::Layer::new("Viewport frames"); + layer.flags.off = true; + scene.document.layers.add(layer).unwrap(); + let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(frame.viewport) else { + panic!() + }; + vp.common.layer = "Viewport frames".into(); + vp.status.locked = true; + let pick = scene + .dimension_pick_through_viewport(DVec3::new(55.0, 50.0, 0.0), 0.2) + .unwrap(); + assert!(pick.frame.locked); + assert_eq!(pick.frame.viewport, frame.viewport); +} + +#[test] +fn viewport_dimension_perpendicular_uses_transformed_geometry() { + use crate::scene::dimension_assoc::{resolve_reference_chain, ReferenceStatus}; + use acadrust::entities::Insert; + use acadrust::objects::AssocDimensionReference; + use acadrust::tables::BlockRecord; + let mut scene = Scene::new(); + let mut block = BlockRecord::new("Stretched"); + block.handle = scene.document.allocate_handle(); + let owner = block.handle; + scene.document.block_records.add(block).unwrap(); + let mut source = Line::from_points(Vector3::ZERO, Vector3::new(10.0, 10.0, 0.0)); + source.common.owner_handle = owner; + let source = scene.document.add_entity(EntityType::Line(source)).unwrap(); + let mut insert = Insert::new("Stretched", Vector3::ZERO); + insert.set_x_scale(2.0); + let root = scene.add_entity(EntityType::Insert(insert)); + let reference = AssocDimensionReference { + xrefs: vec![root, source], + osnap_type: 8, + osnap_point: Vector3::new(4.0, 2.0, 0.0), + ..Default::default() + }; + let result = + resolve_reference_chain(&scene, &reference, Some(Vector3::new(0.0, 10.0, 0.0))).unwrap(); + assert!( + (result.model.x - 4.0).abs() < 1e-8 && (result.model.y - 2.0).abs() < 1e-8, + "{result:?}" + ); + let unrelated = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::UNIT_X, + ))); + let invalid = AssocDimensionReference { + xrefs: vec![root, unrelated], + ..reference + }; + assert!(matches!( + resolve_reference_chain(&scene, &invalid, None), + Err(ReferenceStatus::Unresolved) + )); +} + +#[test] +fn viewport_dimension_refresh_preserves_explicit_overrides() { + use acadrust::xdata::XDataValue; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + finish_aligned(&mut app, line, frame); + let handle = dimension(&app.tabs[i].scene.document).base().common.handle; + let entity = app.tabs[i].scene.document.get_entity_mut(handle).unwrap(); + dim_override::set_on_entity( + entity, + dim_override::DIMLFAC, + Some(XDataValue::Real(-254.0)), + ); + dim_override::set_on_entity(entity, dim_override::DIMTOL, Some(XDataValue::Integer16(1))); + dim_override::set_on_entity(entity, dim_override::DIMTP, Some(XDataValue::Real(0.05))); + dim_override::set_on_entity(entity, dim_override::DIMTM, Some(XDataValue::Real(0.02))); + if let EntityType::Dimension(d) = entity { + crate::entities::dimension::set_dimension_text_override(d.base_mut(), Some("L=<>".into())); + } + let expected = displayed(&app.tabs[i].scene.document); + let Some(EntityType::Viewport(vp)) = app.tabs[i].scene.document.get_entity_mut(frame.viewport) + else { + panic!() + }; + vp.view_height = 500.0; + vp.custom_scale = 0.2; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + let d = dimension(&app.tabs[i].scene.document); + assert!( + (displayed(&app.tabs[i].scene.document) - expected).abs() < 1e-5, + "{} vs {expected}", + displayed(&app.tabs[i].scene.document) + ); + assert_eq!( + crate::entities::dimension::dimension_text_override(d.base()), + Some("L=<>") + ); + assert_eq!( + dim_override::real(&d.base().common.extended_data, dim_override::DIMTP), + Some(0.05) + ); + assert_eq!( + dim_override::real(&d.base().common.extended_data, dim_override::DIMTM), + Some(0.02) + ); +} + +#[test] +fn viewport_dimension_block_center_keeps_the_circle_source() { + use acadrust::entities::Insert; + use acadrust::tables::BlockRecord; + let (mut app, _, frame) = fixture(); + let i = app.active_tab; + let scene = &mut app.tabs[i].scene; + let mut block = BlockRecord::new("Circle block"); + block.handle = scene.document.allocate_handle(); + let owner = block.handle; + scene.document.block_records.add(block).unwrap(); + let mut circle = Circle::new(); + circle.radius = 10.0; + circle.common.owner_handle = owner; + let circle = scene + .document + .add_entity(EntityType::Circle(circle)) + .unwrap(); + // This line is closer to the circle center than its circumference is. + let mut line = Line::from_points(Vector3::new(-2.0, 1.0, 0.0), Vector3::new(2.0, 1.0, 0.0)); + line.common.owner_handle = owner; + scene.document.add_entity(EntityType::Line(line)).unwrap(); + scene.set_current_layout("Model".into()); + let root = scene.add_entity(EntityType::Insert(Insert::new( + "Circle block", + Vector3::new(40.0, 20.0, 0.0), + ))); + scene.set_current_layout("Dimensions".into()); + let mut center = hit(frame, root, DVec3::new(40.0, 20.0, 0.0)); + center.snap_type = SnapType::Center; + assert!(app.record_accepted_snap(i, Some(center), Some(frame), center.world)); + let source = app + .accepted_snaps() + .last() + .unwrap() + .source + .as_ref() + .unwrap(); + assert_eq!(source.source.handle, circle); + assert_eq!(source.block_path, vec![root]); +} + +#[test] +fn viewport_dimension_angular_object_picks_follow_arc_features() { + use crate::scene::{ChangeKind, ReferenceStatus}; + use acadrust::entities::{Arc, LwPolyline}; + use acadrust::types::Vector2; + for bulged in [false, true] { + let (mut app, _, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.set_current_layout("Model".into()); + let entity = if bulged { + let mut poly = + LwPolyline::from_points(vec![Vector2::new(100.0, 200.0), Vector2::new(0.0, 300.0)]); + poly.vertices[0].bulge = (std::f64::consts::PI / 8.0).tan(); + EntityType::LwPolyline(poly) + } else { + let mut arc = Arc::new(); + arc.center = Vector3::new(0.0, 200.0, 0.0); + arc.radius = 100.0; + arc.start_angle = 0.0; + arc.end_angle = std::f64::consts::FRAC_PI_2; + EntityType::Arc(arc) + }; + let source = app.tabs[i].scene.add_entity(entity); + app.tabs[i].scene.set_current_layout("Dimensions".into()); + let _ = app.dispatch_command("DIMANGULAR"); + let model = DVec3::new(100.0 / 2.0_f64.sqrt(), 200.0 + 100.0 / 2.0_f64.sqrt(), 0.0); + let result = app + .try_dimension_viewport_entity_pick(i, frame.model_to_paper(model), 0.5) + .unwrap(); + let _ = app.apply_cmd_result(result); + assert_eq!(app.accepted_snaps().len(), 3); + let _ = app.feed_command(StepInput::Point(DVec3::new(65.0, 85.0, 0.0))); + assert!((displayed(&app.tabs[i].scene.document) - 90.0).abs() < 1e-5); + let dim = dimension(&app.tabs[i].scene.document).base().common.handle; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + assert!((displayed(&app.tabs[i].scene.document) - 90.0).abs() < 1e-5); + let status = app.tabs[i].scene.dimension_association_status(dim); + assert_eq!(status.len(), 3); + assert!( + status.iter().all(|(_, s)| *s == ReferenceStatus::Resolved), + "{bulged}: {status:?}" + ); + match app.tabs[i].scene.document.get_entity_mut(source).unwrap() { + EntityType::Arc(arc) => arc.end_angle = std::f64::consts::PI / 3.0, + EntityType::LwPolyline(poly) => { + poly.vertices[0].bulge = (std::f64::consts::PI / 12.0).tan() + } + _ => unreachable!(), + } + app.tabs[i] + .scene + .bump_entities(&[(source, ChangeKind::Modified)]); + assert!( + (displayed(&app.tabs[i].scene.document) - 60.0).abs() < 1e-5, + "{bulged}: {}", + displayed(&app.tabs[i].scene.document) + ); + } +} + +#[test] +fn viewport_dimension_quadrant_keeps_its_feature_after_translation() { + use crate::scene::ChangeKind; + let (mut app, line, frame) = fixture(); + let i = app.active_tab; + app.tabs[i].scene.set_current_layout("Model".into()); + let mut circle = Circle::new(); + circle.radius = 100.0; + let source = app.tabs[i].scene.add_entity(EntityType::Circle(circle)); + app.tabs[i].scene.set_current_layout("Dimensions".into()); + let _ = app.dispatch_command("DIMALIGNED"); + point(&mut app, frame, line, DVec3::ZERO); + let mut quadrant = hit(frame, source, DVec3::X * 100.0); + quadrant.snap_type = SnapType::Quadrant; + assert!(app.record_accepted_snap(i, Some(quadrant), Some(frame), quadrant.world)); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(quadrant.world); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); + let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 80.0, 0.0))); + let EntityType::Circle(circle) = app.tabs[i].scene.document.get_entity_mut(source).unwrap() + else { + panic!() + }; + circle.center.x = 300.0; + app.tabs[i] + .scene + .bump_entities(&[(source, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 400.0).abs() < 1e-5); +} + +fn start_centerline_dimension( + command: &str, + paper_first: bool, + paper_kind: SnapType, + factor: f64, +) -> (OpenCADStudio, Handle, Handle, ViewportFrame) { + let (mut app, model, frame) = fixture(); + let i = app.active_tab; + let paper = app.tabs[i] + .scene + .add_entity(EntityType::Line(Line::from_points( + Vector3::new(55.0, 40.0, 0.0), + Vector3::new(55.0, 60.0, 0.0), + ))); + let mut style = acadrust::tables::DimStyle::new("CenterlineUnits"); + style.dimlfac = factor; + app.tabs[i].scene.document.dim_styles.add(style).unwrap(); + app.tabs[i].scene.document.header.current_dimstyle_name = "CenterlineUnits".into(); + let _ = app.dispatch_command(command); + let mut paper_hit = hit(frame, paper, DVec3::new(55.0, 50.0, 0.0)); + paper_hit.world = DVec3::new(55.0, 50.0, 0.0); + paper_hit.model_point = None; + paper_hit.viewport = None; + paper_hit.snap_type = paper_kind; + let mut picks = [ + (paper_hit, None), + (hit(frame, model, DVec3::X * 100.0), Some(frame)), + ]; + if !paper_first { + picks.reverse(); + } + for (snap, context) in picks { + assert!(app.record_accepted_snap(i, Some(snap), context, snap.world)); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_point(snap.world); + app.sync_dimension_snaps(i); + let _ = app.apply_cmd_result(result); + } + (app, model, paper, frame) +} + +#[test] +fn viewport_dimension_paper_centerline_uses_viewport_units_in_either_order() { + for command in ["DIMLINEAR", "DIMALIGNED"] { + for paper_first in [true, false] { + let (mut app, _, paper, _) = + start_centerline_dimension(command, paper_first, SnapType::Midpoint, 25.4); + let paper_snap = app + .accepted_snaps() + .iter() + .find(|s| s.viewport.is_none()) + .unwrap(); + assert_eq!(paper_snap.source.as_ref().unwrap().source.handle, paper); + assert_eq!(paper_snap.paper_point, paper_snap.model_point); + let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 80.0, 0.0))); + let doc = &app.tabs[app.active_tab].scene.document; + assert!( + (displayed(doc) - 1270.0).abs() < 1e-3, + "{command}: {}", + displayed(doc) + ); + app.undo_steps(1); + app.redo_steps(1); + assert!((displayed(&app.tabs[app.active_tab].scene.document) - 1270.0).abs() < 1e-3); + } + } +} + +#[test] +fn viewport_dimension_paper_centerline_tracks_each_owning_space() { + use crate::scene::{ChangeKind, ReferenceStatus}; + for (paper_first, kind) in [(true, SnapType::Midpoint), (false, SnapType::Perpendicular)] { + let (mut app, model, paper, frame) = + start_centerline_dimension("DIMALIGNED", paper_first, kind, 1.0); + let i = app.active_tab; + let _ = app.feed_command(StepInput::Point(DVec3::new(60.0, 80.0, 0.0))); + let dim = dimension(&app.tabs[i].scene.document).base().common.handle; + assert!((displayed(&app.tabs[i].scene.document) - 50.0).abs() < 1e-4); + let assoc = app.tabs[i].scene.dimension_association(dim).unwrap(); + assert!(assoc.trans_space); + assert!(assoc + .references + .iter() + .flatten() + .any(|r| r.xrefs == vec![paper])); + assert!(assoc + .references + .iter() + .flatten() + .any(|r| r.xrefs == vec![frame.viewport, model])); + assert!(app.tabs[i] + .scene + .dimension_association_status(dim) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Resolved)); + let pending = app.begin_undo(i, "Move paper centerline", 1, true).unwrap(); + let before = app.tabs[i].scene.document.get_entity_arc(paper); + app.tabs[i].scene.record_undo_before(paper, before); + let EntityType::Line(line) = app.tabs[i].scene.document.get_entity_mut(paper).unwrap() + else { + panic!() + }; + line.start.x = 56.0; + line.end.x = 56.0; + app.tabs[i] + .scene + .bump_entities(&[(paper, ChangeKind::Modified)]); + app.commit_undo_delta(i, pending); + assert!((displayed(&app.tabs[i].scene.document) - 40.0).abs() < 1e-4); + app.undo_steps(1); + assert!((displayed(&app.tabs[i].scene.document) - 50.0).abs() < 1e-4); + app.redo_steps(1); + let EntityType::Viewport(vp) = app.tabs[i] + .scene + .document + .get_entity_mut(frame.viewport) + .unwrap() + else { + panic!() + }; + vp.view_height = 500.0; + vp.custom_scale = 0.2; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + // Model endpoint now appears at paper x=70; paper centerline stays x=56. + assert!((displayed(&app.tabs[i].scene.document) - 70.0).abs() < 1e-4); + let EntityType::Viewport(vp) = app.tabs[i] + .scene + .document + .get_entity_mut(frame.viewport) + .unwrap() + else { + panic!() + }; + vp.view_target.x = 20.0; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + assert!((displayed(&app.tabs[i].scene.document) - 50.0).abs() < 1e-4); + let EntityType::Line(line) = app.tabs[i].scene.document.get_entity_mut(model).unwrap() + else { + panic!() + }; + line.end.x = 160.0; + app.tabs[i] + .scene + .bump_entities(&[(model, ChangeKind::Modified)]); + assert!((displayed(&app.tabs[i].scene.document) - 110.0).abs() < 1e-4); + for ext in ["dxf", "dwg"] { + let doc = &app.tabs[i].scene.document; + let bytes = crate::io::save_to_bytes(doc, ext, doc.version).unwrap(); + let mut scene = Scene::new(); + scene.document = crate::io::load_bytes(&format!("centerline.{ext}"), bytes).unwrap(); + scene.notify_viewport_changed(frame.viewport); + assert!((displayed(&scene.document) - 110.0).abs() < 1e-4, "{ext}"); + assert!(scene + .dimension_association_status(dim) + .iter() + .all(|(_, s)| *s == ReferenceStatus::Resolved)); + } + let before = app.tabs[i].scene.document.get_entity(dim).unwrap().clone(); + let model_owner = app.tabs[i] + .scene + .document + .get_entity(model) + .unwrap() + .common() + .owner_handle; + app.tabs[i] + .scene + .document + .get_entity_mut(paper) + .unwrap() + .common_mut() + .owner_handle = model_owner; + app.tabs[i].scene.notify_viewport_changed(frame.viewport); + assert_eq!(app.tabs[i].scene.document.get_entity(dim).unwrap(), &before); + assert!(app.tabs[i] + .scene + .dimension_association_status(dim) + .iter() + .any(|(_, s)| *s == ReferenceStatus::Unresolved)); + } +} + +#[test] +fn viewport_dimension_invalid_paths_leave_no_partial_association() { + use crate::scene::viewport_ref::SnapSourceRef; + use acadrust::entities::{DimensionAligned, Insert}; + use acadrust::tables::BlockRecord; + + let (mut app, model, frame) = fixture(); + let scene = &mut app.tabs[app.active_tab].scene; + let mut block = BlockRecord::new("Collapsed"); + block.handle = scene.document.allocate_handle(); + let owner = block.handle; + scene.document.block_records.add(block).unwrap(); + let mut leaf = Line::from_points(Vector3::ZERO, Vector3::UNIT_X); + leaf.common.owner_handle = owner; + let leaf = scene.document.add_entity(EntityType::Line(leaf)).unwrap(); + scene.set_current_layout("Model".into()); + let mut insert = Insert::new("Collapsed", Vector3::ZERO); + insert.set_x_scale(0.0); + insert.set_y_scale(0.0); + let root = scene.add_entity(EntityType::Insert(insert)); + scene.set_current_layout("Dimensions".into()); + let paper = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(55.0, 50.0, 0.0), + Vector3::new(55.0, 60.0, 0.0), + ))); + for (source, path) in [(model, vec![Handle::new(u64::MAX)]), (leaf, vec![root])] { + let dim = scene.add_entity(EntityType::Dimension(Dimension::Aligned( + DimensionAligned::new(Vector3::new(50.0, 50.0, 0.0), Vector3::new(55.0, 50.0, 0.0)), + ))); + let invalid = AcceptedSnap { + paper_point: DVec3::new(50.0, 50.0, 0.0), + model_point: DVec3::ZERO, + viewport: Some(frame.viewport), + frame: Some(frame), + source: Some(SnapSourceRef { + source: DimensionAssociationSource::inferred(source), + block_path: path, + snap_type: SnapType::Endpoint, + intersection: None, + }), + }; + let mut valid = AcceptedSnap::free(DVec3::new(55.0, 50.0, 0.0)); + valid.source = Some(SnapSourceRef { + source: DimensionAssociationSource::inferred(paper), + block_path: Vec::new(), + snap_type: SnapType::Endpoint, + intersection: None, + }); + scene.attach_viewport_dimension_association(dim, &[Some(invalid), Some(valid)]); + let association = scene.dimension_association(dim).unwrap(); + assert!(!association.trans_space); + assert!(association.references[0].is_empty()); + assert_eq!(association.references[1][0].xrefs, vec![paper]); + assert_eq!(scene.dimension_association_sources(dim), vec![paper]); + assert_eq!( + scene.dimension_association_status(dim), + vec![(1, crate::scene::ReferenceStatus::Resolved)] + ); + } +} diff --git a/src/cli.rs b/src/cli.rs index 881932bfe..1e2d5153c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -100,6 +100,13 @@ pub struct Cli { #[arg(help = crate::t!("Log level (error|warn|info|debug|trace). Also reads RUST_LOG.").into_owned(), long_help = None)] pub log: Option, + /// Internal: probe one GPU backend offscreen (`dx12|vulkan|gl|sw`), print + /// one JSON line on success, then exit 0/1. Spawned by the GPU backend + /// resolver so a legacy-driver abort kills only the probe child, never + /// the editor itself. + #[arg(long, value_name = "BACKEND", hide = true)] + pub gpu_probe: Option, + /// Internal: run as the plugin runner child process. #[arg(long, value_names = ["SOCKET", "CDYLIB"], num_args = 2, hide = true)] pub ocs_plugin_runner: Option>, @@ -124,6 +131,12 @@ pub struct GuiConfig { pub compat_renderer: bool, /// Command lines to run once the editor is up. pub script_lines: Vec, + /// Why this launch switched graphics backend, if it did. Shown once + /// as a command-line warning so the switch is never silent. + pub gpu_fallback_notice: Option, + /// The GPU probe found no shader storage buffers: the packed + /// compatibility renderer was enabled automatically for this session. + pub gpu_compat_auto: bool, } /// Set once by `main` before the GUI boots; read by `app::boot`. diff --git a/src/command.rs b/src/command.rs index 25d321e8f..6655c5351 100644 --- a/src/command.rs +++ b/src/command.rs @@ -183,7 +183,7 @@ pub struct SelectionEntity { } /// Association source with an optional sub-entity marker. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq)] pub struct DimensionAssociationSource { pub handle: Handle, pub marker: Option, @@ -347,6 +347,72 @@ impl CadCommand for ValuePromptCommand { } } +/// Interactive pick for `UCS FACE` and `UCS OBJECT`. +/// +/// Hands the picked entity to the inline `UCS FACE ` / +/// `UCS OBJECT ` handler via [`CmdResult::Dispatch`], so the plane +/// construction lives in one place whether the user clicked or typed the +/// arguments. For a face the click lands *on the solid's surface* +/// (`entity_pick_uses_surface_point`) and that point becomes the plane +/// origin; for an object only the handle matters. +pub struct UcsPickCommand { + /// `true` picks a solid face; `false` picks a planar entity. + pub face: bool, +} + +impl CadCommand for UcsPickCommand { + fn name(&self) -> &'static str { + "UCS" + } + + fn prompt(&self) -> String { + if self.face { + crate::t!("UCS FACE Select a face to draw on:").into_owned() + } else { + crate::t!("UCS OBJECT Select object to align UCS:").into_owned() + } + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn entity_pick_uses_surface_point(&self) -> bool { + self.face + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + if !self.face { + return CmdResult::Dispatch(format!("UCS OBJECT {:X}", handle.value())); + } + // Hex handle and comma-separated coordinates are what the inline + // parser reads back. Full `{}` precision, not a rounded format: the + // point has to stay on the face for the planar-face lookup. + CmdResult::Dispatch(format!( + "UCS FACE {:X} {},{},{}", + handle.value(), + pt.x, + pt.y, + pt.z + )) + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + /// Interactive front-end for RENAME. Prompts for the object type (as clickable /// buttons), then the current name, then the new name, and delegates to the /// inline `RENAME ` handler via [`CmdResult::Dispatch`] — the @@ -1248,6 +1314,28 @@ pub struct CoincidentPick { pub whole_curve: bool, } +/// The two input forms accepted by the Horizontal geometric constraint. +/// Object picks are resolved while the entity snapshot is available; point +/// picks are resolved by the host against the live document so two points on +/// the same entity remain distinguishable. +#[derive(Clone, Copy, Debug)] +pub enum HorizontalConstraintSelection { + Reference(crate::scene::parametric_constraints::ParametricRef), + Points(CoincidentPick, CoincidentPick), +} + +/// The two input forms accepted by the Symmetric geometric constraint. +/// Object picks retain their curve or segment references; point picks are +/// resolved by the host against the live document. +#[derive(Clone, Copy, Debug)] +pub enum SymmetricConstraintSelection { + Objects( + crate::scene::parametric_constraints::ParametricRef, + crate::scene::parametric_constraints::ParametricRef, + ), + Points(CoincidentPick, CoincidentPick), +} + /// Construction options shared by SWEEP creation and its live preview. #[derive(Clone, Copy, Debug, PartialEq)] pub struct SweepOptions { @@ -1345,6 +1433,23 @@ pub enum DimensionBreakOperation { Remove, } +/// A transient dimension built for the placement preview. +pub struct DimensionPreview { + pub entity: EntityType, + /// Keep the base dimension's style instead of the current one + /// (`DIMCONTINUE` / `DIMBASELINE` with `DIMCONTINUEMODE=1`). + pub preserve_base_style: bool, +} + +impl DimensionPreview { + pub fn current_style(entity: EntityType) -> Self { + Self { + entity, + preserve_base_style: false, + } + } +} + /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { @@ -1477,6 +1582,66 @@ pub enum CmdResult { /// Undo-history label, e.g. `"Horizontal constraint"`. label: &'static str, }, + /// Adds a Horizontal or Vertical relation (`kind`) against the UCS X or + /// Y direction captured when the command starts. The direction is + /// persisted with the constraint so later edits and save/reopen do not + /// silently fall back to the world axis. + AddHorizontalConstraint { + kind: crate::scene::parametric_constraints::ConstraintKind, + selection: HorizontalConstraintSelection, + direction: acadrust::types::Vector3, + label: &'static str, + }, + /// Resolves the first 2Points pick of a Horizontal or Vertical constraint + /// against the live document so a miss is reported at once, before the + /// second point is asked for. + CheckHorizontalPoint { + kind: crate::scene::parametric_constraints::ConstraintKind, + pick: CoincidentPick, + }, + /// Adds an Equal relation from `first` to each of `others` (the second + /// object, or a Multiple set). The host resizes every follower to the + /// first object's length or radius before the relation holds it there. + AddEqualConstraint { + first: crate::scene::parametric_constraints::ParametricRef, + others: Vec, + /// A Multiple flow: the command stays for more picks; an empty + /// `others` is its Enter and prints the summary line. + multiple: bool, + label: &'static str, + }, + /// Adds a point or object symmetry relation around a picked line. The + /// first reference and axis remain fixed during initial placement. + AddSymmetricConstraint { + selection: SymmetricConstraintSelection, + axis: crate::scene::parametric_constraints::ParametricRef, + label: &'static str, + }, + /// Adds an ordered perpendicular relation. The first picked direction and + /// the second direction's start point stay fixed during the initial solve; + /// those temporary anchors are not persisted as geometric constraints. + AddPerpendicularConstraint { + first: crate::scene::parametric_constraints::ParametricRef, + second: crate::scene::parametric_constraints::ParametricRef, + first_fixed: crate::scene::parametric_constraints::ParametricRef, + second_start: crate::scene::parametric_constraints::ParametricRef, + label: &'static str, + }, + /// Adds an ordered tangent relation. The first picked reference stays + /// fixed during the initial solve and the second keeps its intrinsic + /// shape while it moves into tangency. + AddTangentConstraint { + first: crate::scene::parametric_constraints::ParametricRef, + second: crate::scene::parametric_constraints::ParametricRef, + label: &'static str, + }, + /// Adds an ordered concentric relation. The first picked center remains + /// fixed during the initial solve while the second curve moves rigidly. + AddConcentricConstraint { + first: crate::scene::parametric_constraints::ParametricRef, + second: crate::scene::parametric_constraints::ParametricRef, + label: &'static str, + }, /// Opens the Auto Constrain settings dialog from the selection prompt. OpenAutoConstrainSettings, /// Adds an ordered Coincident relation. Point/point selections create a @@ -1510,6 +1675,11 @@ pub enum CmdResult { kind: crate::scene::parametric_constraints::ConstraintKind, label: &'static str, }, + /// Add a persistent `Fixed` constraint (`crate::modules::parametric::fixed`) + /// at one picked constraint point (`whole_curve == false`, resolved by the + /// host like `AddPointOnEntityConstraint`'s point) or on the whole curve / + /// polyline segment under the pick (`whole_curve == true`). + AddFixedConstraint(CoincidentPick), /// Add a persistent `EqualDistance` constraint /// (`crate::modules::parametric::equal_distance`): the distance /// between `points[0]`/`points[1]` equals the distance between @@ -2245,7 +2415,30 @@ pub trait CadCommand: Send { CmdResult::Cancel } - /// Returns `true` when the command needs entity picking (hit-test) instead of point picking. + /// Supports acquiring dimension geometry through a paper-space viewport. + fn measures_through_viewports(&self) -> bool { + false + } + + /// Definition points acquired so far, in the command's working space. + /// Used to retain references only for accepted measuring inputs. + fn dimension_acquired_points(&self) -> Vec { + Vec::new() + } + + /// The next point places annotation rather than acquiring geometry. + fn dimension_placement_pending(&self) -> bool { + false + } + + /// Dimensions the command would commit if the cursor were clicked now. + /// `None` means this stage has no committable dimension; an empty list + /// means the geometry is degenerate and nothing styled should be shown. + fn dimension_preview(&self, _cursor: DVec3) -> Option> { + None + } + + /// Needs entity hit-testing instead of point input. fn needs_entity_pick(&self) -> bool { false } @@ -2255,6 +2448,11 @@ pub trait CadCommand: Send { false } + /// Take a typed coordinate at an object prompt as a pick at that point. + fn typed_point_picks_entity(&self) -> bool { + false + } + /// Include filled hatch / DXF SOLID regions in the entity hit-test. /// /// Most entity-pick commands operate on curve geometry and intentionally @@ -2609,6 +2807,12 @@ pub trait CadCommand: Send { /// Default: no-op. fn inject_picked_entity(&mut self, _entity: acadrust::EntityType) {} + /// The host undid one document step on the command's behalf + /// (`CmdResult::UndoDocument`) and the command stays active. Commands + /// that cache document entities (FILLET, CHAMFER) refresh them here so + /// the next pick sees the restored geometry. Default: no-op. + fn on_document_undone(&mut self, _document: &acadrust::CadDocument) {} + /// Supply the tessellated surface area associated with the picked entity. /// Commands that measure mesh-backed objects can opt in without owning the /// scene's render cache. @@ -2706,16 +2910,20 @@ mod constraint_registry_tests { "CPCONSTRAINT", "MPCONSTRAINT", "OCCONSTRAINT", - "HCONSTRAINT", + "GCHORIZONTAL", "VCONSTRAINT", + "GCVERTICAL", "PCONSTRAINT", "QCONSTRAINT", + "GCPERPENDICULAR", "ECONSTRAINT", + "GCEQUAL", "TCONSTRAINT", - "NCONSTRAINT", + "GCCONCENTRIC", "NRCONSTRAINT", "LCONSTRAINT", "FXCONSTRAINT", + "GCFIX", "SYCONSTRAINT", "DCONSTRAINT", "ACONSTRAINT", diff --git a/src/entities/arc.rs b/src/entities/arc.rs index e4f35e9cb..c8315524a 100644 --- a/src/entities/arc.rs +++ b/src/entities/arc.rs @@ -3,8 +3,8 @@ use crate::t; 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, format_angle, format_area, + format_length, parse_f64, ro_prop as ro, square_grip, }; use crate::entities::traits::RenderConvertible; use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject}; @@ -197,24 +197,24 @@ fn properties(arc: &Arc) -> Vec { vec![PropSection { title: t!("Geometry").into_owned(), props: vec![ - ro(t!("Start X").as_ref(), "start_x", format!("{sx:.4}")), - ro(t!("Start Y").as_ref(), "start_y", format!("{sy:.4}")), - ro(t!("Start Z").as_ref(), "start_z", format!("{sz:.4}")), + ro(t!("Start X").as_ref(), "start_x", format_length(sx)), + ro(t!("Start Y").as_ref(), "start_y", format_length(sy)), + ro(t!("Start Z").as_ref(), "start_z", format_length(sz)), edit(t!("Center X").as_ref(), "center_x", cwx), edit(t!("Center Y").as_ref(), "center_y", cwy), edit(t!("Center Z").as_ref(), "center_z", cwz), - ro(t!("End X").as_ref(), "end_x", format!("{ex:.4}")), - ro(t!("End Y").as_ref(), "end_y", format!("{ey:.4}")), - ro(t!("End Z").as_ref(), "end_z", format!("{ez:.4}")), + ro(t!("End X").as_ref(), "end_x", format_length(ex)), + ro(t!("End Y").as_ref(), "end_y", format_length(ey)), + ro(t!("End Z").as_ref(), "end_z", format_length(ez)), edit(t!("Radius").as_ref(), "radius", arc.radius), edit_angle(t!("Start angle").as_ref(), "start_angle", sa.to_degrees()), edit_angle(t!("End angle").as_ref(), "end_angle", ea.to_degrees()), - ro(t!("Total angle").as_ref(), "total_angle", format!("{total_angle:.2}")), - ro(t!("Arc length").as_ref(), "arc_length", format!("{arc_length:.4}")), - ro(t!("Area").as_ref(), "area", format!("{area:.4}")), - ro(t!("Normal X").as_ref(), "normal_x", format!("{:.4}", arc.normal.x)), - ro(t!("Normal Y").as_ref(), "normal_y", format!("{:.4}", arc.normal.y)), - ro(t!("Normal Z").as_ref(), "normal_z", format!("{:.4}", arc.normal.z)), + ro(t!("Total angle").as_ref(), "total_angle", format_angle(total_angle.to_radians())), + ro(t!("Arc length").as_ref(), "arc_length", format_length(arc_length)), + ro(t!("Area").as_ref(), "area", format_area(area)), + edit(t!("Normal X").as_ref(), "normal_x", arc.normal.x), + edit(t!("Normal Y").as_ref(), "normal_y", arc.normal.y), + edit(t!("Normal Z").as_ref(), "normal_z", arc.normal.z), ], }] } @@ -244,6 +244,24 @@ fn apply_geom_prop(arc: &mut Arc, field: &str, value: &str) { "radius" if v > 0.0 => arc.radius = v, "start_angle" => arc.start_angle = v.to_radians(), "end_angle" => arc.end_angle = v.to_radians(), + "normal_x" | "normal_y" | "normal_z" => { + let center = arc.center_wcs(); + let mut normal = cadkernel::space::Vec3::new(arc.normal.x, arc.normal.y, arc.normal.z); + match field { + "normal_x" => normal.x = v, + "normal_y" => normal.y = v, + "normal_z" => normal.z = v, + _ => {} + } + if let Some(normal) = normal.normalize() { + arc.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z); + let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs( + (center.x, center.y, center.z), + (normal.x, normal.y, normal.z), + ); + arc.center = acadrust::types::Vector3::new(x, y, z); + } + } _ => {} } } diff --git a/src/entities/circle.rs b/src/entities/circle.rs index 7d596506e..5cef35386 100644 --- a/src/entities/circle.rs +++ b/src/entities/circle.rs @@ -3,7 +3,7 @@ use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ - center_grip, edit_prop as edit, parse_f64, ro_prop as ro, square_grip, + center_grip, edit_prop as edit, parse_f64, square_grip, }; use crate::entities::traits::RenderConvertible; use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject}; @@ -169,9 +169,9 @@ fn properties(circle: &Circle) -> Vec { edit(t!("Diameter").as_ref(), "diameter", r * 2.0), edit(t!("Circumference").as_ref(), "circumference", 2.0 * PI * r), edit(t!("Area").as_ref(), "area", PI * r * r), - ro(t!("Normal X").as_ref(), "normal_x", format!("{:.4}", circle.normal.x)), - ro(t!("Normal Y").as_ref(), "normal_y", format!("{:.4}", circle.normal.y)), - ro(t!("Normal Z").as_ref(), "normal_z", format!("{:.4}", circle.normal.z)), + edit(t!("Normal X").as_ref(), "normal_x", circle.normal.x), + edit(t!("Normal Y").as_ref(), "normal_y", circle.normal.y), + edit(t!("Normal Z").as_ref(), "normal_z", circle.normal.z), ], }] } @@ -202,6 +202,28 @@ fn apply_geom_prop(circle: &mut Circle, field: &str, value: &str) { "diameter" if v > 0.0 => circle.radius = v / 2.0, "circumference" if v > 0.0 => circle.radius = v / (2.0 * PI), "area" if v > 0.0 => circle.radius = (v / PI).sqrt(), + "normal_x" | "normal_y" | "normal_z" => { + let center = circle.center_wcs(); + let mut normal = cadkernel::space::Vec3::new( + circle.normal.x, + circle.normal.y, + circle.normal.z, + ); + match field { + "normal_x" => normal.x = v, + "normal_y" => normal.y = v, + "normal_z" => normal.z = v, + _ => {} + } + if let Some(normal) = normal.normalize() { + circle.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z); + let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs( + (center.x, center.y, center.z), + (normal.x, normal.y, normal.z), + ); + circle.center = acadrust::types::Vector3::new(x, y, z); + } + } _ => {} } } @@ -358,6 +380,19 @@ impl crate::entities::traits::MassPropsCalc for Circle { mod tests { use super::*; + #[test] + fn editing_normal_preserves_world_center() { + let mut circle = Circle::from_coords(2.0, 3.0, 4.0, 5.0); + let center = circle.center_wcs(); + + apply_geom_prop(&mut circle, "normal_x", "1"); + + assert!((circle.center_wcs() - center).length() < 1.0e-9); + let normal_length = + (circle.normal.x.powi(2) + circle.normal.y.powi(2) + circle.normal.z.powi(2)).sqrt(); + assert!((normal_length - 1.0).abs() < 1.0e-12); + } + #[test] fn quadrant_grips_offer_interactive_radius() { use crate::entities::traits::Grippable; diff --git a/src/entities/common.rs b/src/entities/common.rs index 6ab4aa857..2c012d226 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -283,6 +283,20 @@ fn surveyor(value_rad: f64, prec: usize) -> String { /// A leading `-` is a sign, but the `-` inside `5'-9"` is a separator — after /// feet there is nothing left to subtract from. pub fn parse_length(text: &str) -> Option { + parse_length_unbounded(text).filter(|value| typed_value_in_range(*value)) +} + +/// Largest magnitude a typed length or angle may have. Rust's float parser +/// accepts `inf`, `nan` and overflowing exponents; geometry built from those, +/// or from values whose differences overflow, breaks downstream tolerance and +/// intersection math. +pub const MAX_TYPED_MAGNITUDE: f64 = 1e15; + +pub fn typed_value_in_range(value: f64) -> bool { + value.is_finite() && value.abs() <= MAX_TYPED_MAGNITUDE +} + +fn parse_length_unbounded(text: &str) -> Option { let text = text.trim(); let (sign, rest) = match text.strip_prefix('-') { Some(rest) => (-1.0, rest.trim_start()), @@ -339,6 +353,10 @@ fn parse_inches(text: &str) -> Option { /// number is read in whatever convention the drawing is set to, so what the /// readout shows can be typed straight back. pub fn parse_angle(text: &str) -> Option { + parse_angle_unbounded(text).filter(|value| typed_value_in_range(*value)) +} + +fn parse_angle_unbounded(text: &str) -> Option { let text = text.trim(); if text.is_empty() { return None; @@ -679,11 +697,16 @@ pub fn parse_f64(value: &str) -> Option { .ok() .or_else(|| parse_length(t)) .or_else(|| parse_angle_deg(t)) + .filter(|value| typed_value_in_range(*value)) } /// Parse an angle string the panel displayed via AUNITS back to DEGREES: /// "30", "30°"/"30d", DMS "30°15'20.5\"", grads "33.33g", radians "0.52r". pub fn parse_angle_deg(value: &str) -> Option { + parse_angle_deg_unbounded(value).filter(|value| typed_value_in_range(*value)) +} + +fn parse_angle_deg_unbounded(value: &str) -> Option { let s = value.trim(); if s.is_empty() { return None; @@ -1161,6 +1184,24 @@ pub(crate) fn polyline_segment_fill( } } +#[cfg(test)] +mod typed_range_tests { + use super::*; + + #[test] + fn non_finite_and_huge_typed_values_are_rejected() { + for text in ["inf", "-inf", "nan", "1e400", "1e308"] { + assert_eq!(parse_length(text), None, "length {text}"); + assert_eq!(parse_angle(text), None, "angle {text}"); + assert_eq!(parse_f64(text), None, "property {text}"); + assert_eq!(parse_angle_deg(text), None, "angle degrees {text}"); + } + assert_eq!(parse_length("1e16"), None); + assert_eq!(parse_length("1e15"), Some(1e15)); + assert_eq!(parse_length("-5'-6\""), Some(-66.0)); + } +} + #[cfg(test)] mod length_format_tests { use super::*; diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index 9c9214041..d9688ea5b 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -235,6 +235,65 @@ pub fn set(doc: &mut CadDocument, handle: Handle, code: i16, value: Option) { + let common = entity.common_mut(); + let mut kept: Vec<(i16, XDataValue)> = pairs(&common.extended_data) + .into_iter() + .filter(|(c, _)| *c != code) + .collect(); + if let Some(v) = value { + kept.push((code, v)); + } + + let legacy = common.extended_data.get_record("ACAD_DSTYLE").is_some() + && common.extended_data.get_record("ACAD").is_some_and( + |rec| !matches!(rec.values.first(), Some(XDataValue::String(n)) if n == "DSTYLE"), + ); + let app = if legacy { "ACAD_DSTYLE" } else { "ACAD" }; + + let values = if kept.is_empty() { + None + } else { + let mut vals = Vec::new(); + if !legacy { + vals.push(XDataValue::String("DSTYLE".to_string())); + } + vals.push(XDataValue::ControlString("{".to_string())); + for (code, value) in kept { + vals.push(XDataValue::Integer16(code)); + vals.push(value); + } + vals.push(XDataValue::ControlString("}".to_string())); + Some(vals) + }; + + let mut rebuilt = ExtendedData::new(); + for record in common.extended_data.records() { + if record.application_name != app && !(!legacy && record.application_name == "ACAD_DSTYLE") + { + rebuilt.add_record(record.clone()); + } + } + if let Some(vals) = values { + let mut record = acadrust::xdata::ExtendedDataRecord::new(app); + for v in vals { + record.add_value(v); + } + rebuilt.add_record(record); + } + rebuilt.raw_dwg_eed = common.extended_data.raw_dwg_eed.clone(); + common.extended_data = rebuilt; +} + pub fn property_real_code(field: &str) -> Option { Some(match field { "dim_arrow_size" => DIMASZ, diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 470022408..6cf37e0a5 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -930,6 +930,23 @@ fn capture_dim_text_relative_position(dim: &Dimension) -> Option { + 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(); + + if len <= 1e-12 { + return None; + } + + ( + d.angle_vertex, + d.definition_point, + d.angle_vertex, + dx / len, + dy / len, + ) + } _ => return None, }; @@ -989,6 +1006,23 @@ fn restore_dim_text_relative_position( dy / len, ) } + 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(); + + if len <= 1e-12 { + return; + } + + ( + d.angle_vertex, + d.definition_point, + d.angle_vertex, + dx / len, + dy / len, + ) + } _ => return, }; @@ -1211,6 +1245,26 @@ impl Grippable for Dimension { }; if grip_id == text_grip { apply_to_v3(&mut self.base_mut().text_middle_point, &apply); + + // A diametric dimension must remain collinear when its text/leader + // grip is moved. Rotate both diameter endpoints around the fixed + // center so the leader and diameter share the same radial axis. + if let Dimension::Diameter(d) = self { + let center = d.center(); + let radius = d.measurement() * 0.5; + let offset = d.base.text_middle_point - center; + + if offset.length_squared() > 1e-24 && radius > 1e-12 { + let radial = offset.normalize() * radius; + + // Keep the endpoint nearest the text on the text side. + d.angle_vertex = center + radial; + d.definition_point = center - radial; + d.base.definition_point = d.definition_point; + d.base.actual_measurement = d.measurement(); + } + } + // Dragging the text grip pins it to a user-defined location, so it // no longer follows the style (DIMTAD). See #94. self.base_mut().text_user_positioned = true; @@ -1613,9 +1667,78 @@ pub(crate) fn resolved_dimension_style( style.dimtxsty = record.name.clone(); } } + + // Negative DIMLFAC applies only in paper space, and there only to a + // dimension that measures model space through a viewport. The associative + // record stores the applied factor; zero marks paper-space geometry whose + // dimension reads its sheet distance. + if style.dimlfac < 0.0 { + style.dimlfac = match calculated_dimlfac(dimension) { + Some(calculated) if calculated.abs() < 1e-12 => 1.0, + Some(calculated) => calculated.abs(), + None => crate::scene::viewport_ref::MeasurementScale::user_lfac_for_space( + style.dimlfac, + dimension_in_paper_space(dimension, document), + ), + }; + } style } +/// The linear factor recorded when the dimension was last regenerated. +fn calculated_dimlfac(dimension: &Dimension) -> Option { + dimension + .base() + .common + .extended_data + .get_record("ACAD_DIMASSOC_CALC_DIMLFAC") + .and_then(|record| { + record.values.iter().find_map(|value| match value { + acadrust::xdata::XDataValue::Real(factor) if factor.is_finite() => Some(*factor), + _ => None, + }) + }) +} + +/// Whether the dimension's owner is paper space, even without a Layout object. +pub(crate) fn dimension_in_paper_space(dimension: &Dimension, document: &CadDocument) -> bool { + let owner = dimension.base().common.owner_handle; + if !owner.is_valid() { + return false; + } + document.objects.values().any(|object| { + matches!(object, + acadrust::objects::ObjectType::Layout(layout) + if layout.name != "Model" && layout.block_record == owner + ) + }) || document.block_records.iter().any(|block| { + block.handle == owner && block.name.to_ascii_uppercase().starts_with("*PAPER_SPACE") + }) +} + +/// The value a dimension's text reports for its measurement: the raw +/// geometric measurement times the resolved linear factor. Angular dimensions +/// never scale. +/// +/// `actual_measurement` (DXF group 42) stays **raw** — that is what the format +/// specifies and what export writes — so this is the one helper UI surfaces +/// use when they want the number the user reads. +pub(crate) fn displayed_measurement(dimension: &Dimension, style: Option<&DimStyle>) -> f64 { + let raw = dimension.measurement(); + if matches!( + dimension, + Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) + ) { + return raw; + } + let lfac = style.map(|s| s.dimlfac).unwrap_or(1.0); + if lfac.abs() < 1e-12 { + raw + } else { + raw * lfac + } +} + /// 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. @@ -2097,7 +2220,13 @@ pub fn style_sections( property( t!("Measurement").as_ref(), "measurement", - PropValue::ReadOnly(format!("{:.4}", dimension.measurement())), + // The value the dimension text reports (DIMLFAC applied, + // including viewport compensation) — not the raw paper + // distance, which would disagree with what is drawn. + PropValue::ReadOnly(format!( + "{:.4}", + displayed_measurement(dimension, Some(s)) + )), ), property( t!("Text override").as_ref(), @@ -2196,7 +2325,12 @@ pub fn style_sections( number( t!("Dim scale linear").as_ref(), "dim_scale_linear", - real(ov::DIMLFAC, s.dimlfac), + // The *raw* signed DIMLFAC: a negative value is the + // paper-space / viewport-compensation convention, and + // hiding its sign would make the row uneditable in + // practice. `s.dimlfac` is sign-resolved, so fall back to + // the unresolved source style instead. + real(ov::DIMLFAC, style.dimlfac), true, ), number( @@ -3635,6 +3769,10 @@ fn tessellate_dimension_inner( horizontal_text: text_layout.horizontal, text_movement: style.map(|s| s.dimtmove).unwrap_or(0), text_break: text_layout.break_box, + leader_anchor: style + .filter(|s| s.dimtmove == 1) + .and_then(|_| stored_text_point(dim)) + .map(vec3_local), }, SuppressFlags { ext1: dimse1, @@ -3687,7 +3825,7 @@ fn tessellate_dimension_inner( &mut geom.dim_lines, point, vec3_local(dim.base().normal), - dim_txt as f32 * 0.6, + dim_txt as f32 * CELL_WIDTH as f32, jog_angle, ); } @@ -4299,10 +4437,11 @@ fn text_fill_rect( } let pos = dimension_text_pos_f64(dim, style, text_height, 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; - let approx_h = text_height + dimgap * 2.0; + // CELL_WIDTH × text_height per character approximates the average glyph + // aspect of the bundled stick fonts. Inflate by 1 DIMGAP on each side. + let stack_scale = style.map(dimtfac_or_one).unwrap_or(1.0); + let approx_w = text_cells(&value, stack_scale) * text_height * CELL_WIDTH + dimgap * 2.0; + let approx_h = dimension_text_extent_height(dim, style, text_height) + dimgap * 2.0; let rot = if dim.base().text_rotation.abs() > 1e-9 { dim.base().text_rotation } else { @@ -4344,7 +4483,8 @@ fn arc_length_symbol_points( 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 stack_scale = style.map(dimtfac_or_one).unwrap_or(1.0); + let text_width = text_cells(&value, stack_scale) * text_height * CELL_WIDTH; let symbol_width = text_height * 0.62; let symbol_height = text_height * 0.20; let (center_x, center_y) = if symbol_position == 1 { @@ -4409,8 +4549,8 @@ fn dimension_text_layout( let gap = style .map(|style| (style.dimgap.abs() * dim_scale) as f32) .unwrap_or(0.09); - let width = dimension_text_value(dimension, style) - .map(|text| text.chars().count() as f32 * text_height as f32 * 0.6 + gap * 2.0) + let width = dimension_text_cells(dimension, style) + .map(|cells| cells as f32 * text_height as f32 * CELL_WIDTH as f32 + gap * 2.0) .unwrap_or(0.0); let position = vec3_local(dimension_text_pos_f64( dimension, @@ -4422,7 +4562,9 @@ fn dimension_text_layout( let break_box = (bare_width > 0.0).then_some(TextBreak { center: position, half_width: bare_width * 0.5, - half_height: text_height as f32 * 0.5, + // A limits stack is two lines tall; the break and the arrow fit both + // have to clear the whole block. + half_height: dimension_text_extent_height(dimension, style, text_height) as f32 * 0.5, padding: gap, rotation: dimension_text_rotation(dimension, style), }); @@ -4458,6 +4600,9 @@ struct DimLineParams { horizontal_text: bool, text_movement: i16, text_break: Option, + /// DIMTMOVE 1: the point the file stores is where the leader's hook starts, + /// so the leader is drawn to it exactly and only the text is estimated. + leader_anchor: Option, } fn dimension_geometry( dim: &Dimension, @@ -4530,43 +4675,22 @@ fn dimension_geometry( add_segment(&mut g.dim_lines, a, b); add_segment(&mut g.dim_lines, b, point); } - } else if !suppress.dim2 { - add_segment(&mut g.dim_lines, center, point); } let radius = (point - center).length(); let text_is_outside = text.distance(center) > radius + 1e-5; + // Text inside the arc: the dimension line runs from the arc point + // to the centre. Text outside: only a leader from the arc point to + // the text, unless DIMTOFL asks for the inside line as well. + if !jogged && !suppress.dim2 && (!text_is_outside || params.dimtofl) { + add_segment(&mut g.dim_lines, center, point); + } if text_is_outside && !suppress.dim2 { - 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_radial_leader(&mut g, point, text, ¶ms); } if !suppress.dim2 { - append_arrow( - &mut g, - point, - normalized_or(center - point, Vec3::X), - arrow1, - ); + // The arrowhead sits on the arc with its body toward the text. + let body = if text_is_outside { point - center } else { center - point }; + append_arrow(&mut g, point, normalized_or(body, Vec3::X), arrow1); } if text_is_outside { append_center_mark(&mut g, center, params.dimcen, radius); @@ -4579,9 +4703,9 @@ fn dimension_geometry( &mut g, chord, far_chord, + lv(d.base.text_middle_point), arrow1, arrow2, - d.leader_length as f32, params, suppress, ); @@ -4912,17 +5036,42 @@ fn append_linear_dimension( let d2_out = d2 + dir_d1_to_d2 * dle; // When text plus arrows do not fit, DIMATFIT decides which component moves - // first: 0=both, 1=arrows, 2=text, 3=best fit. + // first: 0=both, 1=arrows, 2=text, 3=best fit. The text is measured along + // the dimension line where it is drawn: horizontal text on a vertical + // dimension takes up its height, and text dragged toward one end runs into + // that arrow long before the sum of widths says the gap is full. let gap = (d2 - d1).length(); + let text_span = params.text_break.map(|text_break| { + let axis_angle = dir_d1_to_d2.y.atan2(dir_d1_to_d2.x) as f64; + let (sin, cos) = (text_break.rotation - axis_angle).sin_cos(); + let half = (text_break.half_width as f64 * cos).abs() + + (text_break.half_height as f64 * sin).abs() + + text_break.padding as f64; + let along = (text_break.center - d1).dot(dir_d1_to_d2) as f64; + (along - half, along + half) + }); + let text_extent = text_span + .map(|(lo, hi)| (hi - lo) as f32) + .unwrap_or(params.text_width); + // Horizontal text beside a non-horizontal dimension is reached by a leg + // and a hook instead of the dimension line itself. + let hooks_out_horizontally = + params.horizontal_text && !params.ticks && dir_d1_to_d2.y.abs() > 1e-3; + let text_hits_an_arrow = text_span.is_some_and(|(lo, hi)| { + let center = (lo + hi) * 0.5; + center > 0.0 + && center < gap as f64 + && (lo < params.arrow_len as f64 || hi > (gap - params.arrow_len) as f64) + }); let arrows_outside = if params.ticks || params.arrow_len <= 1e-6 { false - } else if gap < 2.0 * params.arrow_len { + } else if gap < 2.0 * params.arrow_len || text_hits_an_arrow { true - } else if gap < params.text_width + 2.0 * params.arrow_len { + } else if gap < text_extent + 2.0 * params.arrow_len { match params.dimatfit { 0 | 1 => true, 2 => false, - _ => params.text_width <= gap, + _ => text_extent <= gap, } } else { false @@ -4943,13 +5092,22 @@ fn append_linear_dimension( if draw_inside_line && !suppress.dim2 && line_len - split > 1e-6 { add_segment_with_text_break(&mut g.dim_lines, split_point, d2_out, params.text_break); } - if arrows_outside && !params.dimsoxd { - let stub = params.arrow_len * 2.0; - if !suppress.dim1 { - add_segment(&mut g.dim_lines, d1 - dir_d1_to_d2 * stub, d1); + // Outside the extension lines the dimension line runs two arrow lengths, + // or on to the text when the text sits further out along the line. + let reach = |default: f32, toward_text: Option| toward_text.map_or(default, |t| t.max(default)); + let stub = params.arrow_len * 2.0; + let text_beyond_d1 = text_span + .filter(|(_, hi)| *hi < 0.0 && !hooks_out_horizontally) + .map(|(_, hi)| -hi as f32); + let text_beyond_d2 = text_span + .filter(|(lo, _)| *lo > gap as f64 && !hooks_out_horizontally) + .map(|(lo, _)| (lo - gap as f64) as f32); + if (arrows_outside && !params.dimsoxd) || text_beyond_d1.is_some() || text_beyond_d2.is_some() { + if !suppress.dim1 && (arrows_outside || text_beyond_d1.is_some()) { + add_segment(&mut g.dim_lines, d1 - dir_d1_to_d2 * reach(stub, text_beyond_d1), d1); } - if !suppress.dim2 { - add_segment(&mut g.dim_lines, d2, d2 + dir_d1_to_d2 * stub); + if !suppress.dim2 && (arrows_outside || text_beyond_d2.is_some()) { + add_segment(&mut g.dim_lines, d2, d2 + dir_d1_to_d2 * reach(stub, text_beyond_d2)); } } @@ -4961,15 +5119,70 @@ fn append_linear_dimension( append_arrow(g, d1, normalized_or(d2 - d1, axis), arrow1); append_arrow(g, d2, normalized_or(d1 - d2, -axis), arrow2); } + + // Horizontal text outside the extension lines of a dimension that is not + // horizontal itself: the dimension line runs on to the text's height and + // turns a hook one arrow long toward the text, which sits against it. + if params.horizontal_text && !params.ticks && dir_d1_to_d2.y.abs() > 1e-3 { + if let Some(text_break) = params.text_break { + let along = (text_break.center - d1).dot(dir_d1_to_d2); + let outside = if along < 0.0 { + Some((d1, -dir_d1_to_d2, suppress.dim1)) + } else if along > gap { + Some((d2, dir_d1_to_d2, suppress.dim2)) + } else { + None + }; + if let Some((end, outward, suppressed)) = outside { + let travel = (text_break.center.y - end.y) / outward.y; + if travel > 0.0 && !suppressed { + let elbow = end + outward * travel; + let side = if text_break.center.x >= elbow.x { 1.0 } else { -1.0 }; + let hook_end = Vec3::new(elbow.x + side * params.arrow_len, elbow.y, elbow.z); + add_segment(&mut g.dim_lines, end, elbow); + add_segment(&mut g.dim_lines, elbow, hook_end); + } + } + } + } +} + +/// Leader from a point on the circle to text outside it. Horizontal text gets a +/// hook one arrow long that the text sits against; aligned text is reached by a +/// straight leader that stops a gap short of it. `text_width` already carries +/// a gap on each side. +fn append_radial_leader(g: &mut DimGeom, tip: Vec3, text: Vec3, params: &DimLineParams) { + if params.horizontal_text { + let side = if text.x >= tip.x { 1.0 } else { -1.0 }; + let (hook_start, text_edge) = match params.leader_anchor { + Some(anchor) => (anchor, anchor + Vec3::X * (side * params.arrow_len)), + None => { + let edge = Vec3::new(text.x - side * params.text_width * 0.5, text.y, text.z); + (edge - Vec3::X * (side * params.arrow_len), edge) + } + }; + let slope = (hook_start - tip).y.abs().atan2((hook_start - tip).x.abs()); + // A leader already within 15° of horizontal runs straight to the text. + if slope > 15.0_f32.to_radians() { + add_segment(&mut g.dim_lines, tip, hook_start); + add_segment(&mut g.dim_lines, hook_start, text_edge); + } else { + add_segment(&mut g.dim_lines, tip, text_edge); + } + } else { + let toward = normalized_or(text - tip, Vec3::X); + let reach = ((text - tip).length() - params.text_width * 0.5).max(0.0); + add_segment(&mut g.dim_lines, tip, tip + toward * reach); + } } fn append_diameter_dimension( g: &mut DimGeom, chord: Vec3, far_chord: Vec3, + text_anchor: Vec3, arrow1: &ArrowKind, arrow2: &ArrowKind, - leader_length: f32, params: DimLineParams, suppress: SuppressFlags, ) { @@ -4978,9 +5191,36 @@ fn append_diameter_dimension( if diameter <= 1e-6 { return; } + let center = (chord + far_chord) * 0.5; + let text_is_outside = params.text_position.distance(center) > diameter * 0.5 + 1e-5; + if text_is_outside && !params.dimtofl { + // Outside text without DIMTOFL: no line across the circle, just a + // leader from the near side with the arrowhead on the circle and its + // body toward the text. DIMTMOVE 2 keeps the arrowhead and drops the + // leader. + 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 { + if params.text_movement != 2 { + append_radial_leader(g, tip, params.text_position, ¶ms); + } + append_arrow(g, tip, normalized_or(tip - center, axis), arrow1); + } + return; + } + // Text projected outside the diameter requires inward-pointing arrowheads. + let text_along = (params.text_position - chord).dot(axis); + let text_outside = text_along < 0.0 || text_along > diameter; let arrows_outside = if params.ticks || params.arrow_len <= 1e-6 { false + } else if text_outside { + true } else if diameter < 2.0 * params.arrow_len { true } else if diameter < params.text_width + 2.0 * params.arrow_len { @@ -5004,7 +5244,7 @@ fn append_diameter_dimension( .unwrap_or(line_length * 0.5); let split_point = first + axis * split; - let draw_inside_line = !arrows_outside || params.dimtofl; + let draw_inside_line = text_outside || !arrows_outside || params.dimtofl; if draw_inside_line && !suppress.dim1 && split > 1e-6 { add_segment_with_text_break(&mut g.dim_lines, first, split_point, params.text_break); } @@ -5029,18 +5269,25 @@ fn append_diameter_dimension( 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) + let (tip, direction, suppressed) = if params.text_position.distance_squared(chord) <= params.text_position.distance_squared(far_chord) { - (chord, suppress.dim1) + (chord, -axis, suppress.dim1) } else { - (far_chord, suppress.dim2) + (far_chord, axis, 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()); + if text_outside && !suppressed { + let leader_length = (text_anchor - tip).dot(direction); + + if leader_length > 1e-6 { + let text_extension = params.text_width * 0.5; + add_segment( + &mut g.dim_lines, + tip, + tip + direction * (leader_length + text_extension), + ); + } } 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 { @@ -5772,13 +6019,18 @@ fn dimension_text_entity( document: &CadDocument, dim_scale: f64, ) -> Option { - // Tolerances are emitted by `dimension_tolerance_entity` at their own + // Deviations 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. let pos_f64 = dimension_text_pos_f64(dim, style, text_height, dim_scale); + // A limits stack draws each half at DIMTFAC × DIMTXT, like the source application. + let text_height = match style { + Some(s) if s.dimlim => text_height * dimtfac_or_one(s), + _ => text_height, + }; let base = dim.base(); let rotation = dimension_text_rotation(dim, style); @@ -5917,8 +6169,8 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool 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); + if let Some(point) = stored_text_point(dim) { + let text = vec3_local(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; @@ -5929,8 +6181,8 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool 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) + let text_width = dimension_text_cells(dim, Some(style)) + .map(|cells| cells * height * CELL_WIDTH + gap * 2.0) .unwrap_or(0.0); let arrow = style.dimasz * scale; let span = radius as f64 * (end - start).abs() as f64; @@ -5944,11 +6196,8 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool } if let Dimension::LargeRadial(radial) = dim { let radius = radial.definition_point.distance(&radial.chord_point); - if dim.base().text_user_positioned { - return radial - .definition_point - .distance(&dim.base().text_middle_point) - > radius + 1.0e-9; + if let Some(point) = stored_text_point(dim) { + return radial.definition_point.distance(&point) > radius + 1.0e-9; } if style.dimtix { return false; @@ -5961,8 +6210,8 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool }; 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) + let text_width = dimension_text_cells(dim, Some(style)) + .map(|cells| cells * height * CELL_WIDTH + gap * 2.0) .unwrap_or(0.0); let arrow = style.dimasz * scale; let insufficient = text_width + arrow > available; @@ -5977,8 +6226,7 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool 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; + if let Some(text) = stored_text_point(dim) { return (text.x - radius.angle_vertex.x) .hypot(text.y - radius.angle_vertex.y) > available + 1e-9; @@ -5989,8 +6237,8 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool 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) + let text_width = dimension_text_cells(dim, Some(style)) + .map(|cells| cells * height * CELL_WIDTH + gap * 2.0) .unwrap_or(0.0); let arrow = style.dimasz * scale; let insufficient = text_width + arrow > available; @@ -6023,8 +6271,7 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool 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; + if let Some(point) = stored_text_point(dim) { let position = point.x * axis.x + point.y * axis.y; return position < lo || position > hi; } @@ -6034,8 +6281,8 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool 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) + let text_width = dimension_text_cells(dim, Some(style)) + .map(|cells| cells * height * CELL_WIDTH + gap * 2.0) .unwrap_or(0.0); let arrow = style.dimasz * scale; let span = hi - lo; @@ -6109,26 +6356,30 @@ fn dimension_text_parts( let is_angular = matches!(dim, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)); // Auto-generated body used when the user did not override it. Built first - // so user_text "<>" substitution can re-use it. - let primary_raw = if is_angular { - format_angular_value(dim.measurement(), style) - } else { - let v = format_linear_value(dim.measurement(), style); - match dim { - Dimension::Radius(_) | Dimension::LargeRadial(_) => format!("R{}", v), - Dimension::Diameter(_) => format!("Ø{}", v), - _ => v, - } + // so user_text "<>" substitution can re-use it. With DIMLIM the limits + // stack is the value itself: the source application prints the upper limit over the + // lower one and no nominal, so the tolerance slot stays empty. + let value = match limits_text(dim, style, is_angular) { + Some(limits) => limits, + None if is_angular => format_angular_value(display_measurement(dim), style), + None => format_linear_value(display_measurement(dim), style), + }; + let primary_raw = match dim { + Dimension::Radius(_) | Dimension::LargeRadial(_) => format!("R{}", value), + // U+2205 is the native diameter sign and what `%%c` resolves to; the + // bundled dimension fonts carry it where Latin Ø is missing. + Dimension::Diameter(_) => format!("∅{}", value), + _ => value, }; - // Build tolerance / limits suffix separately so the caller can render - // it as its own Text entity at DIMTFAC × DIMTXT height. + // Build the deviation suffix separately so the caller can render it as + // its own Text entity at DIMTFAC × DIMTXT height. 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). let primary = if !is_angular { - match alternate_units_text(dim.measurement(), style) { + match alternate_units_text(display_measurement(dim) * effective_dimlfac(style), style) { Some(alt) => format!("{} [{}]", primary, alt), None => primary, } @@ -6150,24 +6401,93 @@ fn dimension_text_parts( Some((primary, tolerance_suffix)) } +/// The value the text reports. An angular dimension whose extension point +/// sits on its vertex has no measurable angle; the source application then keeps showing the +/// angle it stored (group 42, radians) rather than 0°, and so do we. +fn display_measurement(dim: &Dimension) -> f64 { + let measured = dim.measurement(); + let stored = dim.base().actual_measurement; + match dim { + Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) + if measured.abs() < 1e-9 && stored.abs() > 1e-9 => + { + stored.to_degrees() + } + _ => measured, + } +} + +/// DIMLIM: the upper limit stacked over the lower one, replacing the nominal. +/// The offsets apply to the displayed number, so DIMLFAC and DIMRND come first. +fn limits_text(dim: &Dimension, style: Option<&DimStyle>, is_angular: bool) -> Option { + // the source application stacks the two limits even when both offsets are zero and the + // halves read the same. + let s = style.filter(|s| s.dimlim)?; + let measurement = display_measurement(dim); + if is_angular { + return Some(format!( + "\\S{}^{};", + format_angular_value(measurement + s.dimtp, style), + format_angular_value(measurement - s.dimtm, style) + )); + } + let mut nominal = measurement * effective_dimlfac(style); + if s.dimrnd > 1e-12 { + nominal = (nominal / s.dimrnd).round() * s.dimrnd; + } + let (high, low) = limit_pair(nominal + s.dimtp, nominal - s.dimtm, s); + Some(format!("\\S{}^{};", high, low)) +} + +/// The two limits keep the same number of decimals so the stack lines up: +/// DIMTZIN drops trailing zeros only as far as both values allow. the source application +/// writes `13.950` over `13.926` with DIMTZIN 8, not `13.95` over `13.926`. +fn limit_pair(high: f64, low: f64, s: &DimStyle) -> (String, String) { + let dimtdec = s.dimtdec.max(0) as usize; + let mut high = decimal_text(high, dimtdec); + let mut low = decimal_text(low, dimtdec); + if s.dimtzin & 8 != 0 { + let droppable = |value: &str| match value.find('.') { + Some(_) => value.len() - value.trim_end_matches('0').len(), + None => 0, + }; + let drop = droppable(&high).min(droppable(&low)); + for value in [&mut high, &mut low] { + value.truncate(value.len() - drop); + if value.ends_with('.') { + value.pop(); + } + } + } + let finish = |value: &str| { + let value = if s.dimtzin & 4 != 0 { + strip_leading_zero(value) + } else { + value.to_string() + }; + swap_decimal_sep(&value, s.dimdsep) + }; + (finish(&high), finish(&low)) +} + +/// Formats a tolerance number with DIMTDEC, DIMTZIN and DIMDSEP. +fn tolerance_formatter(s: &DimStyle) -> impl Fn(f64) -> String + '_ { + let dimtdec = s.dimtdec.max(0) as usize; + move |v: f64| { + let raw = decimal_text(v, dimtdec); + swap_decimal_sep(&apply_linear_zero_suppression(&raw, s.dimtzin), s.dimdsep) + } +} + +/// DIMTOL deviation suffix drawn beside the nominal. DIMLIM wins over DIMTOL, +/// as in the source application, and is handled by `limits_text`. fn build_tolerance_suffix( 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); - 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!("\\S{}^{};", fmt(high), fmt(low))); - } + let s = style.filter(|s| !s.dimlim)?; + let fmt = tolerance_formatter(s); if s.dimtol { let unit = if is_angular { "°" } else { "" }; if (s.dimtp - s.dimtm).abs() < 1e-12 && s.dimtp.abs() > 1e-12 { @@ -6251,7 +6571,7 @@ fn alternate_units_text(measurement: f64, style: Option<&DimStyle>) -> Option String { - let raw = format!("{:.*}", alttdec, x * tolerance_factor); + let raw = decimal_text(x * tolerance_factor, alttdec); swap_decimal_sep(&apply_linear_zero_suppression(&raw, alttzin), s.dimdsep) }; if (s.dimtp - s.dimtm).abs() < 1e-12 && s.dimtp.abs() > 1e-12 { @@ -6265,11 +6585,12 @@ fn alternate_units_text(measurement: f64, style: Option<&DimStyle>) -> Option String { - let raw = format!("{:.*}", alttdec, x * tolerance_factor); + let raw = decimal_text(x * tolerance_factor, alttdec); swap_decimal_sep(&apply_linear_zero_suppression(&raw, alttzin), s.dimdsep) }; + // Alternate limits stack like the primary ones. format!( - "{}/{}", + "\\S{}^{};", fmt(measurement + s.dimtp), fmt(measurement - s.dimtm) ) @@ -6300,12 +6621,7 @@ fn dimension_tolerance_entity( let s = style?; let is_angular = matches!(dim, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)); let tol = build_tolerance_suffix(dim, style, is_angular)?; - let dimtfac = if s.dimtfac.abs() < 1e-12 { - 1.0 - } else { - s.dimtfac - }; - let tol_height = primary_height * dimtfac; + let tol_height = primary_height * dimtfac_or_one(s); // Pull the geometry we need from the synthetic primary entity (Text or // MText — `dimension_text_entity` routes to MText when the dim value @@ -6330,13 +6646,13 @@ 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 primary_w = primary_value_len as f64 * primary_height * CELL_WIDTH; 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 tol_w = tol_visible_chars as f64 * tol_height * CELL_WIDTH; let gap = primary_height * 0.2; let dx_local = primary_w * 0.5 + tol_w * 0.5 + gap; let dy_local = match s.dimtolj { @@ -6385,14 +6701,125 @@ fn apply_dimpost(value: &str, style: Option<&DimStyle>) -> String { } } +/// `resolved_dimension_style` has already applied the DXF sign convention, +/// so a negative value here means the caller passed an unresolved style; +/// fall back to 1.0 rather than reporting a negative length. +fn effective_dimlfac(style: Option<&DimStyle>) -> f64 { + let lfac = style.map(|s| s.dimlfac).unwrap_or(1.0); + if lfac.abs() < 1e-12 || lfac < 0.0 { + 1.0 + } else { + lfac + } +} + +/// Average advance of one character cell as a fraction of the text height. +/// Measured on the bundled stroke fonts by rendering the text of 106 saved +/// dimension pictures: median 0.66, where the old guess of 0.60 left every +/// fit and hook estimate ten percent narrow. +const CELL_WIDTH: f64 = 0.66; + +/// DIMTFAC with the unset value 0 read as 1. +fn dimtfac_or_one(s: &DimStyle) -> f64 { + if s.dimtfac.abs() < 1e-12 { + 1.0 + } else { + s.dimtfac + } +} + +/// Vertical extent of a dimension's whole value: the first line (or limits +/// stack) plus any `\P` continuation lines at MText's default spacing. +fn dimension_text_extent_height(dim: &Dimension, style: Option<&DimStyle>, text_height: f64) -> f64 { + let lines = dimension_text_value(dim, style) + .map(|value| text_lines(&value)) + .unwrap_or(1); + dimension_text_block_height(style, text_height) + (lines - 1) as f64 * text_height * 5.0 / 3.0 +} + +/// Vertical extent of the rendered value: one line, or a DIMLIM stack whose +/// halves each sit at DIMTFAC × DIMTXT. +fn dimension_text_block_height(style: Option<&DimStyle>, text_height: f64) -> f64 { + match style { + Some(s) if s.dimlim => { + // From the lower half's baseline to the upper half's cap, as the + // MText stack lays them out. + text_height + * dimtfac_or_one(s) + * f64::from( + crate::entities::text_support::STACK_RAISE + + crate::entities::text_support::STACK_HALF_SCALE, + ) + } + _ => text_height, + } +} + +/// Approximate width of the rendered value in character cells of the nominal +/// text height. A `\S` stack is as wide as its wider half, drawn at DIMTFAC. +pub(crate) fn dimension_text_cells(dim: &Dimension, style: Option<&DimStyle>) -> Option { + let value = dimension_text_value(dim, style)?; + Some(text_cells(&value, style.map(dimtfac_or_one).unwrap_or(1.0))) +} + +/// Width in cells of the widest line; `\P` starts a new line. +fn text_cells(value: &str, stack_scale: f64) -> f64 { + value + .split(r"\P") + .map(|line| line_cells(line, stack_scale)) + .fold(0.0, f64::max) +} + +/// Number of lines the value renders as. +fn text_lines(value: &str) -> usize { + value.split(r"\P").count().max(1) +} + +/// Visible cells of one line: MText format codes (`\\f...;`, `\\H...;`, `\\C...;`, +/// braces, underline toggles) take no room, a stack is as wide as its wider half. +fn line_cells(value: &str, stack_scale: f64) -> f64 { + let mut cells = 0.0; + let mut chars = value.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '{' | '}' => {} + '\\' => match chars.next() { + Some('S') => { + let body: String = chars.by_ref().take_while(|c| *c != ';').collect(); + let widest = body + .split(['^', '/', '#']) + .map(|half| half.chars().count()) + .max() + .unwrap_or(0); + cells += widest as f64 * stack_scale; + } + Some('~') => cells += 1.0, + // Underline, overline and strike toggles take no room; an + // escaped backslash or brace is one visible character. + Some('L' | 'l' | 'O' | 'o' | 'K' | 'k') => {} + Some('\\' | '{' | '}') => cells += 1.0, + Some(_) => { + for c in chars.by_ref() { + if c == ';' { + break; + } + } + } + None => {} + }, + _ => cells += 1.0, + } + } + cells +} + /// 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, sub_factor, sub_suffix) = style + let (dec, zin, rnd, dsep, lunit, frac, sub_factor, sub_suffix) = style .map(|s| { ( s.dimdec, s.dimzin, - s.dimlfac, s.dimrnd, s.dimdsep, s.dimlunit, @@ -6401,9 +6828,9 @@ fn format_linear_value(measurement: f64, style: Option<&DimStyle>) -> String { s.dimmzs.as_str(), ) }) - .unwrap_or((4, 8, 1.0, 0.0, 46, 2, 0, 1.0, "")); + .unwrap_or((4, 8, 0.0, 46, 2, 0, 1.0, "")); - let lfac = if lfac.abs() < 1e-12 { 1.0 } else { lfac }; + let lfac = effective_dimlfac(style); 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 @@ -6449,10 +6876,23 @@ fn format_with_unit( 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), + _ => decimal_text(value, dec), } } +/// Fixed-point text rounded half away from zero. A few scaled ULPs settle +/// decimal ties without changing values at higher requested precision. +fn decimal_text(value: f64, decimals: usize) -> String { + let scale = 10f64.powi(decimals.min(15) as i32); + let scaled = value * scale; + let tolerance = (scaled.abs().max(1.0) * f64::EPSILON * 4.0).min(0.25); + format!( + "{:.*}", + decimals, + (scaled + scaled.signum() * tolerance).round() / scale + ) +} + fn format_engineering(inches: f64, dec: usize) -> String { // Round to the printed precision *first*, then split, so inches that round // up to a full foot carry into the feet instead of printing an @@ -6748,6 +7188,7 @@ fn text_on_dim_line( dimtix: bool, dimatfit: i16, tad: i16, + horizontal_outside: bool, ) -> Vector3 { // The perpendicular "up" side: the perpendicular of the dimension line // normalised so the text reads left-to-right (or bottom-to-top when @@ -6800,7 +7241,24 @@ fn text_on_dim_line( _ => text_w > span, }; if text_outside { - along = hi + arrow + text_w * 0.5; + // Past the two-arrow stub the dimension line grows outside the + // extension line, then the gap that `text_w` already carries. + along = hi + 2.0 * arrow + text_w * 0.5; + if horizontal_outside && ay.abs() > 1e-3 { + // Horizontal text beside a hook: the line runs out two arrow + // lengths, the hook turns one arrow toward the text-up side + // and the text sits against it, centred on the hook. + let elbow_along = hi + 2.0 * arrow; + let side = if px >= 0.0 { 1.0 } else { -1.0 }; + let elbow_x = defpt.x + ax * elbow_along; + let elbow_y = defpt.y + ay * elbow_along; + let vertical = if tad == 0 { 0.0 } else { perp_off * perp_sign }; + return Vector3::new( + elbow_x + side * (arrow + text_w * 0.5), + elbow_y + vertical, + defpt.z, + ); + } } } let bx = defpt.x + ax * along; @@ -6854,7 +7312,7 @@ fn text_on_single_arrow_dim_line( _ => text_width > available, }; let along = if text_outside { - high + arrow + text_width * 0.5 + high + 2.0 * arrow + text_width * 0.5 } else { (first_along + second_along) * 0.5 }; @@ -6865,6 +7323,30 @@ fn text_on_single_arrow_dim_line( ) } +/// The stored text middle point (group 11) applies to automatic and hand-placed +/// text alike. A regenerated dimension keeps any non-zero point; only a cleared +/// point is placed from the style. +fn stored_text_point(dim: &Dimension) -> Option { + let p = dim.base().text_middle_point; + (p.x * p.x + p.y * p.y + p.z * p.z > 1e-16).then_some(p) +} + +/// The point on the circle a radial leader leaves from: the arc point of a +/// radius, or whichever end of a diameter's chord is nearer the text. +fn radial_leader_tip(dim: &Dimension, text: Vector3) -> Vector3 { + match dim { + Dimension::Radius(d) => d.definition_point, + Dimension::Diameter(d) => { + if d.angle_vertex.distance(&text) <= d.definition_point.distance(&text) { + d.angle_vertex + } else { + d.definition_point + } + } + _ => dim.base().text_middle_point, + } +} + fn dimension_text_pos_f64( dim: &Dimension, style: Option<&DimStyle>, @@ -6890,28 +7372,54 @@ fn dimension_text_pos_f64( let perp_off = if dimtad == 0 { dimtvp * text_height } else { - text_height * 0.5 + dimgap + dimension_text_extent_height(dim, style, text_height) * 0.5 + dimgap }; // Rough text width + arrow allowance, used to decide text-outside fit. - let text_w = dimension_text_value(dim, style) - .map(|t| t.chars().count() as f64 * text_height * 0.6 + 2.0 * dimgap) + let text_w = dimension_text_cells(dim, style) + .map(|cells| cells * text_height * CELL_WIDTH + 2.0 * dimgap) .unwrap_or(0.0); - let arrow = if matches!(dim, Dimension::LargeRadial(_)) { - style - .map(|style| style.dimasz * dim_scale) - .unwrap_or(text_height) - } else { - text_height - }; + let arrow = style + .map(|style| style.dimasz * dim_scale) + .filter(|arrow| *arrow > 1e-9) + .unwrap_or(text_height); + // DIMTOH: text that lands outside reads horizontally. + let horizontal_outside = style.is_some_and(|style| style.dimtoh); - // Explicit per-entity override (text dragged to a custom location): the - // saved point wins. Otherwise the dimension style governs placement. - let use_saved = base.text_user_positioned && { - let p = base.text_middle_point; - p.x * p.x + p.y * p.y + p.z * p.z > 1e-16 - }; - if use_saved { - return base.text_middle_point; + if let Some(point) = stored_text_point(dim) { + return match dim { + // DIMTMOVE 1 stores where the leader's hook starts: one arrow of + // hook, a gap, then the text, all running away from the arc. + // Measured on 39 such dimensions the stored point sits 2.4 text + // heights before the text, which is DIMASZ plus DIMGAP. + Dimension::Radius(_) | Dimension::Diameter(_) + if style.is_some_and(|s| s.dimtmove == 1) => + { + let tip = radial_leader_tip(dim, point); + let side = if point.x >= tip.x { 1.0 } else { -1.0 }; + Vector3::new(point.x + side * (arrow + text_w * 0.5), point.y, point.z) + } + // A diameter whose text the user dragged keeps the grip point on + // the leader and lifts the text by the DIMTAD offset (#1323). + Dimension::Diameter(d) if base.text_user_positioned && dimtad != 0 => { + 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); + // Match the readable orientation used by text_on_dim_line(). + let (mut nx, mut ny) = (dx / len, dy / len); + if nx < 0.0 || (nx == 0.0 && ny < 0.0) { + nx = -nx; + ny = -ny; + } + let (px, py) = (-ny, nx); + let perp_sign = if dimtad == 4 { -1.0 } else { 1.0 }; + Vector3::new( + point.x + px * perp_off * perp_sign, + point.y + py * perp_off * perp_sign, + point.z, + ) + } + _ => point, + }; } match dim { @@ -6931,6 +7439,7 @@ fn dimension_text_pos_f64( dimtix, dimatfit, dimtad, + horizontal_outside, ) } Dimension::Aligned(d) => { @@ -6950,6 +7459,7 @@ fn dimension_text_pos_f64( dimtix, dimatfit, dimtad, + horizontal_outside, ) } Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => { @@ -7039,6 +7549,7 @@ fn dimension_text_pos_f64( dimtix, dimatfit, dimtad, + horizontal_outside, ) } Dimension::Ordinate(d) => { @@ -7139,6 +7650,7 @@ pub(crate) fn baked_large_radial_geometry( horizontal_text: text.horizontal, text_movement: style.map(|style| style.dimtmove).unwrap_or(0), text_break: text.break_box, + leader_anchor: None, }, SuppressFlags { ext1: style.is_some_and(|style| style.dimse1), @@ -7259,13 +7771,9 @@ pub(crate) fn dimension_text_grip_position( document: &CadDocument, anno_scale: f64, ) -> Option { - // Once the user has moved the text, its saved point is authoritative. - let base = dim.base(); - if base.text_user_positioned { - let p = base.text_middle_point; - if p.x * p.x + p.y * p.y + p.z * p.z > 1e-16 { - return Some(p); - } + // A stored text point is where the text is drawn; the grip goes there. + if let Some(p) = stored_text_point(dim) { + return Some(p); } // For automatic text, use the same text-building path used by the @@ -7293,11 +7801,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, 3, 1); - let rev = text_on_dim_line(first, second, defpt, -1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 1); + let fwd = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 1, false); + let rev = text_on_dim_line(first, second, defpt, -1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 1, false); 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, 3, 4); + let below = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 4, false); assert!(below.y < 0.0, "below must be -Y, got {}", below.y); } @@ -7320,7 +7828,8 @@ mod dimtad_tests { false, 3, 2, - ); + false, + ); assert!( above.y > 10.0, "outside must clear the object side, got {}", @@ -7340,7 +7849,8 @@ mod dimtad_tests { false, 3, 2, - ); + false, + ); assert!( below.y < -10.0, "outside must clear the object side, got {}", @@ -7827,3 +8337,472 @@ mod linear_transform_tests { assert_draws_turned_with_its_plane(Dimension::Aligned(d)); } } + + +#[cfg(test)] +mod limits_format_tests { + use super::*; + use acadrust::entities::DimensionLinear; + + /// A horizontal linear dimension measuring 10 units along X. + fn horizontal() -> Dimension { + let mut d = + DimensionLinear::horizontal(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); + d.definition_point = Vector3::new(0.0, 5.0, 0.0); + Dimension::Linear(d) + } + + fn style() -> DimStyle { + let mut s = DimStyle::standard(); + s.dimdec = 3; + s.dimtdec = 3; + // Keep trailing zeros so the expectations read like the drawing. + s.dimzin = 0; + s.dimtzin = 0; + s.dimtp = 0.02; + s.dimtm = 0.05; + s + } + + // the source application's DIMLIM shows the upper limit over the lower one and nothing + // else. The renderer used to keep the nominal and hang the stack beside + // it, so a drawing with limits-only styles grew an extra number on every + // regenerated dimension. + #[test] + fn limits_replace_the_nominal() { + let mut s = style(); + s.dimlim = true; + assert_eq!( + dimension_text_parts(&horizontal(), Some(&s)), + Some((r"\S10.020^9.950;".to_string(), None)) + ); + } + + // Stacked limits keep a common number of decimals: with DIMTZIN 8 the + // zero in 10.020 stays because 9.965 has nothing to drop, while a pair + // that both end in zero shortens together. + #[test] + fn limits_drop_trailing_zeros_only_together() { + let mut s = style(); + s.dimlim = true; + s.dimtzin = 8; + s.dimtm = 0.035; + assert_eq!( + dimension_text_parts(&horizontal(), Some(&s)), + Some((r"\S10.020^9.965;".to_string(), None)) + ); + s.dimtm = 0.08; + assert_eq!( + dimension_text_parts(&horizontal(), Some(&s)), + Some((r"\S10.02^9.92;".to_string(), None)) + ); + } + + // The offsets apply to the number the reader sees, which for a paper + // space dimension is the DIMLFAC-scaled distance (and DIMRND-rounded). + #[test] + fn limits_are_offsets_from_the_scaled_measurement() { + let mut s = style(); + s.dimlim = true; + s.dimlfac = 7.5; + assert_eq!( + dimension_text_parts(&horizontal(), Some(&s)), + Some((r"\S75.020^74.950;".to_string(), None)) + ); + s.dimrnd = 0.5; + s.dimtm = 0.0; + assert_eq!( + dimension_text_parts(&horizontal(), Some(&s)), + Some((r"\S75.020^75.000;".to_string(), None)) + ); + } + + // the source application keeps the stack when both offsets are zero: production + // drawings show `\S11.469^11.469;` for such dimensions. + #[test] + fn limits_without_offsets_still_stack() { + let mut s = style(); + s.dimlim = true; + s.dimtp = 0.0; + s.dimtm = 0.0; + assert_eq!( + dimension_text_parts(&horizontal(), Some(&s)), + Some((r"\S10.000^10.000;".to_string(), None)) + ); + } + + #[test] + fn limits_take_precedence_over_deviation() { + let mut s = style(); + s.dimlim = true; + s.dimtol = true; + let (_, suffix) = dimension_text_parts(&horizontal(), Some(&s)).unwrap(); + assert_eq!(suffix, None); + } + + #[test] + fn deviation_keeps_the_nominal_and_a_separate_suffix() { + let mut s = style(); + s.dimtol = true; + assert_eq!( + dimension_text_parts(&horizontal(), Some(&s)), + Some(("10.000".to_string(), Some(r"\S+0.020^-0.050;".to_string()))) + ); + } + + // A per-entity DSTYLE override turning limits off (group 72 = 0) is how + // the reference callouts in a limits-style drawing show a plain value. + #[test] + fn limits_off_shows_the_plain_value() { + let s = style(); + assert_eq!( + dimension_text_parts(&horizontal(), Some(&s)), + Some(("10.000".to_string(), None)) + ); + } + + #[test] + fn user_text_substitutes_the_stack_for_the_measurement() { + let mut s = style(); + s.dimlim = true; + let mut dim = horizontal(); + dim.base_mut().text = "<> REF".to_string(); + let (value, _) = dimension_text_parts(&dim, Some(&s)).unwrap(); + assert_eq!(value, r"\S10.020^9.950; REF"); + } + + // Fit decisions size the text by its visible cells: a stack is as wide as + // its wider half, drawn at DIMTFAC, not as wide as its control codes. + #[test] + fn text_cells_measure_the_wider_stack_half() { + assert_eq!(text_cells("10.000", 0.5), 6.0); + assert_eq!(text_cells(r"R\S1.020^0.98;", 0.5), 1.0 + 5.0 * 0.5); + assert_eq!(text_cells(r"\S10.020^9.950; REF", 1.0), 6.0 + 4.0); + assert_eq!(text_cells(r"\S1.020^0.980;\PCutouts", 1.0), 7.0, "widest line, not the sum"); + assert_eq!(text_lines(r"4X <>\PNominal"), 2); + assert_eq!( + text_cells(r"{\fArial|b0|i1|c0|p34;Cutouts}", 1.0), + 7.0, + "format codes and braces take no room" + ); + assert_eq!(text_cells(r"\H0.7x;10\L Ref\l", 1.0), 6.0); + } + + // A three-point angular dimension whose first extension point coincides + // with the vertex measures nothing; the stored angle keeps the text. + #[test] + fn degenerate_angular_dimension_reports_its_stored_angle() { + use acadrust::entities::DimensionAngular3Pt; + let mut d = DimensionAngular3Pt::default(); + d.angle_vertex = Vector3::new(6.0, 4.85, 0.0); + d.first_point = d.angle_vertex; + d.second_point = Vector3::new(7.0, 4.85, 0.0); + d.definition_point = Vector3::new(8.0, 5.4, 0.0); + d.base.actual_measurement = std::f64::consts::FRAC_PI_4; + let dim = Dimension::Angular3Pt(d); + let mut s = style(); + s.dimadec = 0; + assert_eq!( + dimension_text_parts(&dim, Some(&s)), + Some(("45°".to_string(), None)) + ); + s.dimlim = true; + s.dimtp = 0.1; + s.dimtm = 0.1; + let (value, _) = dimension_text_parts(&dim, Some(&s)).unwrap(); + assert!(value.starts_with(r"\S45"), "{value}"); + } + + // the source application rounds a printed value half away from zero; Rust's formatter + // rounds the binary value, which sits a hair below the decimal tie. + #[test] + fn decimal_text_rounds_half_away_from_zero() { + assert_eq!(decimal_text(0.4255, 3), "0.426"); + assert_eq!(decimal_text(0.4085, 3), "0.409"); + assert_eq!(decimal_text(-0.4255, 3), "-0.426"); + assert_eq!(decimal_text(2.5, 0), "3"); + assert_eq!(decimal_text(0.1234, 3), "0.123"); + assert_eq!(decimal_text(0.0000000000004, 12), "0.000000000000"); + } + + // Alternate units multiply the displayed (DIMLFAC-scaled) value and stack + // their limits like the primary ones. + #[test] + fn alternate_limits_stack_from_the_scaled_value() { + let mut s = style(); + s.dimlim = true; + s.dimlfac = 2.0; + s.dimalt = true; + s.dimaltf = 25.4; + s.dimaltd = 2; + s.dimalttd = 2; + s.dimaltz = 0; + s.dimalttz = 0; + s.dimtp = 0.02; + s.dimtm = 0.05; + let (value, _) = dimension_text_parts(&horizontal(), Some(&s)).unwrap(); + assert_eq!(value, r"\S20.020^19.950; [\S508.51^506.73;]"); + } + + #[test] + fn a_limits_stack_is_two_halves_tall() { + let mut s = style(); + assert_eq!(dimension_text_block_height(Some(&s), 2.0), 2.0); + s.dimlim = true; + s.dimtfac = 0.5; + let expected = 2.0 + * 0.5 + * f64::from( + crate::entities::text_support::STACK_RAISE + + crate::entities::text_support::STACK_HALF_SCALE, + ); + assert_eq!(dimension_text_block_height(Some(&s), 2.0), expected); + } +} + +#[cfg(test)] +mod layout_parity_tests { + use super::*; + use acadrust::entities::{DimensionDiameter, DimensionLinear, DimensionRadius}; + use glam::DVec3; + + /// A document whose Standard style has imperial sizes and horizontal text. + fn document() -> CadDocument { + let mut document = CadDocument::new(); + let style = document + .dim_styles + .get_mut("Standard") + .expect("Standard style"); + style.dimasz = 0.18; + style.dimtxt = 0.18; + style.dimgap = 0.09; + style.dimtih = true; + style.dimtoh = true; + style.dimtad = 0; + style.dimdec = 2; + document + } + + fn style(document: &CadDocument) -> DimStyle { + document.dim_styles.get("Standard").expect("Standard style").clone() + } + + /// Stroke endpoints and arrowhead triangle count of the drawn dimension. + fn drawn(document: &CadDocument, dim: &Dimension) -> (Vec, usize) { + let wires = dim.tessellate( + document, + Handle::new(0x40), + false, + [1.0; 4], + 1.0, + 1.0, + &rustc_hash::FxHashSet::default(), + None, + [0.0, 0.0, 0.0, 1.0], + None, + None, + ); + let mut points = Vec::new(); + let mut fills = 0; + for wire in &wires { + for p in wire.points.iter().filter(|p| p[0].is_finite()) { + points.push(DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64)); + } + fills += wire.fill_tris.len() / 3; + } + (points, fills) + } + + fn vertical(top: f64) -> DimensionLinear { + let mut d = DimensionLinear::default(); + d.first_point = Vector3::new(0.0, 0.0, 0.0); + d.second_point = Vector3::new(0.0, top, 0.0); + d.definition_point = Vector3::new(-0.5, top, 0.0); + d.rotation = std::f64::consts::FRAC_PI_2; + d + } + + // Group 11 is authoritative whenever present, whether or not the user moved + // the text. + #[test] + fn stored_text_point_places_automatic_text() { + let document = document(); + let mut d = DimensionLinear::horizontal(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); + d.definition_point = Vector3::new(0.0, 5.0, 0.0); + d.base.text_middle_point = Vector3::new(7.0, 5.2, 0.0); + d.base.text_user_positioned = false; + let dim = Dimension::Linear(d); + let position = dimension_text_pos_f64(&dim, Some(&style(&document)), 0.18, 1.0); + assert_eq!((position.x, position.y), (7.0, 5.2)); + } + + // Text pushed outside by a narrow gap sits past the two-arrow stub and a + // gap, not directly against the arrowhead. + #[test] + fn outside_text_clears_the_stub_and_a_gap() { + let document = document(); + let mut d = DimensionLinear::horizontal(Vector3::new(0.0, 0.0, 0.0), Vector3::new(0.2, 0.0, 0.0)); + d.definition_point = Vector3::new(0.0, 1.0, 0.0); + let dim = Dimension::Linear(d); + let s = style(&document); + let position = dimension_text_pos_f64(&dim, Some(&s), 0.18, 1.0); + let half_width = dimension_text_cells(&dim, Some(&s)).unwrap() * 0.18 * CELL_WIDTH * 0.5; + let text_start = position.x - half_width; + let stub_end = 0.2 + 2.0 * 0.18; + assert!( + (text_start - (stub_end + 0.09)).abs() < 1e-6, + "text starts at {text_start}, expected {}", + stub_end + 0.09 + ); + } + + // Horizontal text near the top of a vertical dimension runs into the top + // arrowhead, so both arrowheads flip outside and stubs grow past the + // extension lines. Text in the middle leaves them inside. + #[test] + fn text_that_hits_an_arrow_moves_the_arrows_outside() { + let document = document(); + let top = 1.12; + let mut near_top = vertical(top); + near_top.base.text_middle_point = Vector3::new(-0.5, 0.98, 0.0); + let (points, _) = drawn(&document, &Dimension::Linear(near_top)); + assert!( + points.iter().any(|p| p.y > top + 0.1) && points.iter().any(|p| p.y < -0.1), + "arrows should be outside with stubs past both extension lines" + ); + + let mut centred = vertical(top); + centred.base.text_middle_point = Vector3::new(-0.5, top * 0.5, 0.0); + let (points, _) = drawn(&document, &Dimension::Linear(centred)); + assert!( + points.iter().all(|p| p.y <= top + 0.01 && p.y >= -0.01), + "centred text leaves the arrows inside" + ); + } + + // A negative DIMLFAC scales only a paper-space dimension that measures + // model space through a viewport. The association record carries the + // applied factor; zero means the dimension reads its sheet distance. + #[test] + fn negative_dimlfac_follows_the_recorded_association_factor() { + use acadrust::xdata::{ExtendedDataRecord, XDataValue}; + let document = CadDocument::new(); + let mut source = style(&document); + source.dimlfac = -4.0; + let mut d = DimensionLinear::horizontal(Vector3::new(0.0, 0.0, 0.0), Vector3::new(4.457, 0.0, 0.0)); + d.definition_point = Vector3::new(0.0, 1.0, 0.0); + let with_factor = |factor: f64| { + let mut d = d.clone(); + let mut record = ExtendedDataRecord::new("ACAD_DIMASSOC_CALC_DIMLFAC"); + record.add_value(XDataValue::Real(factor)); + d.base.common.extended_data.add_record(record); + Dimension::Linear(d) + }; + let sheet = resolved_dimension_style(&source, &with_factor(0.0), &document); + assert_eq!(sheet.dimlfac, 1.0, "no factor applied: sheet distance"); + let through_viewport = resolved_dimension_style(&source, &with_factor(-4.0), &document); + assert_eq!(through_viewport.dimlfac, 4.0, "the recorded factor, made positive"); + let unknown = resolved_dimension_style(&source, &Dimension::Linear(d.clone()), &document); + assert_eq!(unknown.dimlfac, 1.0, "model space without a record: unscaled"); + let invalid = resolved_dimension_style(&source, &with_factor(f64::NAN), &document); + assert_eq!(invalid.dimlfac, 1.0, "invalid recorded factor: unscaled"); + } + + // DIMTMOVE 1: the stored point is where the hook starts. The hook runs + // one arrow toward the text, and the text begins a gap beyond it. + #[test] + fn dimtmove_one_anchors_the_hook_start_at_the_stored_point() { + let mut document = document(); + document.dim_styles.get_mut("Standard").unwrap().dimtmove = 1; + let mut d = DimensionRadius::default(); + d.angle_vertex = Vector3::new(0.0, 0.0, 0.0); + d.definition_point = Vector3::new(1.0, 0.0, 0.0); + d.base.text_middle_point = Vector3::new(3.0, 1.5, 0.0); + let dim = Dimension::Radius(d); + let (points, _) = drawn(&document, &dim); + let hook: Vec = points.iter().filter(|p| (p.y - 1.5).abs() < 1e-6).map(|p| p.x).collect(); + let (lo, hi) = (hook.iter().cloned().fold(f64::MAX, f64::min), hook.iter().cloned().fold(f64::MIN, f64::max)); + assert!((lo - 3.0).abs() < 1e-6 && (hi - 3.18).abs() < 1e-6, "hook from the stored point one arrow toward the text: {lo}..{hi}"); + let s = style(&document); + let position = dimension_text_pos_f64(&dim, Some(&s), 0.18, 1.0); + let half = dimension_text_cells(&dim, Some(&s)).unwrap() * 0.18 * CELL_WIDTH * 0.5; + assert!((position.x - half - 0.09 - 3.18).abs() < 1e-6, "text starts a gap past the hook end, got {}", position.x - half); + } + + // Text stored well past the second extension line of a horizontal + // dimension: the dimension line runs out to the text, not just two arrow + // lengths. + #[test] + fn outside_text_on_the_axis_pulls_the_line_out_to_it() { + let document = document(); + let mut d = DimensionLinear::horizontal(Vector3::new(0.0, 0.0, 0.0), Vector3::new(0.8, 0.0, 0.0)); + d.definition_point = Vector3::new(0.0, 2.0, 0.0); + d.base.text_middle_point = Vector3::new(2.5, 2.0, 0.0); + let (points, _) = drawn(&document, &Dimension::Linear(d)); + let furthest = points.iter().map(|p| p.x).fold(f64::MIN, f64::max); + assert!( + furthest > 2.0 && furthest < 2.5, + "line reaches the text's near edge, got {furthest}" + ); + } + + // Horizontal text stored above a vertical dimension: the dimension line + // runs up to the text's height and hooks one arrow toward it. + #[test] + fn horizontal_outside_text_gets_a_leg_and_a_hook() { + let document = document(); + let top = 0.3; + let mut d = vertical(top); + d.base.text_middle_point = Vector3::new(-0.5 + 0.9, 1.0, 0.0); + let (points, _) = drawn(&document, &Dimension::Linear(d)); + assert!( + points.iter().any(|p| (p.y - 1.0).abs() < 1e-6 && (p.x + 0.5).abs() < 1e-6), + "the dimension line reaches the text height at the extension line" + ); + assert!( + points.iter().any(|p| (p.y - 1.0).abs() < 1e-6 && (p.x - (-0.5 + 0.18)).abs() < 1e-6), + "a hook one arrow long turns toward the text" + ); + } + + // Radius text outside the arc: no line to the centre, a leader from the arc + // point to a hook the text sits against, arrowhead body pointing outward. + #[test] + fn radius_text_outside_draws_a_hooked_leader_only() { + let document = document(); + let mut d = DimensionRadius::default(); + d.angle_vertex = Vector3::new(0.0, 0.0, 0.0); + d.definition_point = Vector3::new(1.0, 0.0, 0.0); + d.base.text_middle_point = Vector3::new(3.0, 0.8, 0.0); + let (points, fills) = drawn(&document, &Dimension::Radius(d)); + assert!( + !points.iter().any(|p| p.length() < 1e-6), + "no dimension line reaches the centre" + ); + let hook: Vec<_> = points.iter().filter(|p| (p.y - 0.8).abs() < 1e-6).collect(); + assert!(hook.len() >= 2, "a horizontal hook at the text height: {hook:?}"); + assert!( + points.iter().any(|p| p.x > 1.05 && p.x < 1.3 && p.y > 0.0), + "arrowhead body lies outside the arc along the leader" + ); + assert_eq!(fills, 1); + } + + // Diameter text outside the circle without DIMTOFL: a single leader from + // the near side, one arrowhead, and no line across the circle. + #[test] + fn diameter_text_outside_draws_one_leader_and_no_chord() { + let document = document(); + let mut d = DimensionDiameter::default(); + d.angle_vertex = Vector3::new(-1.0, 0.0, 0.0); + d.definition_point = Vector3::new(1.0, 0.0, 0.0); + d.base.text_middle_point = Vector3::new(3.0, 0.8, 0.0); + let (points, fills) = drawn(&document, &Dimension::Diameter(d)); + assert!( + !points.iter().any(|p| p.x < -0.5), + "nothing is drawn on the far side of the circle" + ); + assert_eq!(fills, 1, "one arrowhead"); + assert!(points.iter().any(|p| (p.y - 0.8).abs() < 1e-6), "hook at the text height"); + } +} diff --git a/src/entities/ellipse.rs b/src/entities/ellipse.rs index 6530f9e38..09560fd5c 100644 --- a/src/entities/ellipse.rs +++ b/src/entities/ellipse.rs @@ -9,8 +9,8 @@ use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ - center_grip, edit_prop as edit, oriented_triangle_grip, parse_f64, ro_prop as ro, - square_grip, + center_grip, edit_prop as edit, format_area, format_length, oriented_triangle_grip, parse_f64, + ro_prop as ro, square_grip, }; use crate::entities::traits::RenderConvertible; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; @@ -163,33 +163,33 @@ fn properties(ell: &Ellipse) -> Vec { vec![PropSection { title: t!("Geometry").into_owned(), props: vec![ - ro(t!("Start X").as_ref(), "start_x", format!("{:.4}", start.x)), - ro(t!("Start Y").as_ref(), "start_y", format!("{:.4}", start.y)), - ro(t!("Start Z").as_ref(), "start_z", format!("{:.4}", start.z)), + ro(t!("Start X").as_ref(), "start_x", format_length(start.x)), + ro(t!("Start Y").as_ref(), "start_y", format_length(start.y)), + ro(t!("Start Z").as_ref(), "start_z", format_length(start.z)), edit(t!("Center X").as_ref(), "center_x", ell.center.x), edit(t!("Center Y").as_ref(), "center_y", ell.center.y), edit(t!("Center Z").as_ref(), "center_z", ell.center.z), - ro(t!("End X").as_ref(), "end_x", format!("{:.4}", end.x)), - ro(t!("End Y").as_ref(), "end_y", format!("{:.4}", end.y)), - ro(t!("End Z").as_ref(), "end_z", format!("{:.4}", end.z)), + ro(t!("End X").as_ref(), "end_x", format_length(end.x)), + ro(t!("End Y").as_ref(), "end_y", format_length(end.y)), + ro(t!("End Z").as_ref(), "end_z", format_length(end.z)), edit(t!("Major radius").as_ref(), "major_r", r_major), edit(t!("Minor radius").as_ref(), "minor_r", r_minor), edit(t!("Radius ratio").as_ref(), "ratio", ell.minor_axis_ratio), edit(t!("Start angle").as_ref(), "start_angle", start_angle), edit(t!("End angle").as_ref(), "end_angle", end_angle), - ro(t!("Major axis vector X").as_ref(), "major_x", format!("{:.4}", major_vec.x)), - ro(t!("Major axis vector Y").as_ref(), "major_y", format!("{:.4}", major_vec.y)), - ro(t!("Major axis vector Z").as_ref(), "major_z", format!("{:.4}", major_vec.z)), - ro(t!("Minor axis vector X").as_ref(), "minor_x", format!("{:.4}", minor_vec.x)), - ro(t!("Minor axis vector Y").as_ref(), "minor_y", format!("{:.4}", minor_vec.y)), - ro(t!("Minor axis vector Z").as_ref(), "minor_z", format!("{:.4}", minor_vec.z)), - ro(t!("Area").as_ref(), "area", format!("{:.4}", props.area)), + ro(t!("Major axis vector X").as_ref(), "major_x", format_length(major_vec.x)), + ro(t!("Major axis vector Y").as_ref(), "major_y", format_length(major_vec.y)), + ro(t!("Major axis vector Z").as_ref(), "major_z", format_length(major_vec.z)), + ro(t!("Minor axis vector X").as_ref(), "minor_x", format_length(minor_vec.x)), + ro(t!("Minor axis vector Y").as_ref(), "minor_y", format_length(minor_vec.y)), + ro(t!("Minor axis vector Z").as_ref(), "minor_z", format_length(minor_vec.z)), + ro(t!("Area").as_ref(), "area", format_area(props.area)), ro(t!("Start parameter").as_ref(), "start_param", format!("{:.4}", ell.start_parameter)), ro(t!("End parameter").as_ref(), "end_param", format!("{:.4}", ell.end_parameter)), - ro(t!("Length").as_ref(), "length", format!("{:.4}", props.perimeter)), - ro(t!("Normal X").as_ref(), "normal_x", format!("{:.4}", ell.normal.x)), - ro(t!("Normal Y").as_ref(), "normal_y", format!("{:.4}", ell.normal.y)), - ro(t!("Normal Z").as_ref(), "normal_z", format!("{:.4}", ell.normal.z)), + ro(t!("Length").as_ref(), "length", format_length(props.perimeter)), + edit(t!("Normal X").as_ref(), "normal_x", ell.normal.x), + edit(t!("Normal Y").as_ref(), "normal_y", ell.normal.y), + edit(t!("Normal Z").as_ref(), "normal_z", ell.normal.z), ], }] } @@ -220,6 +220,26 @@ fn apply_geom_prop(ell: &mut Ellipse, field: &str, value: &str) { "ratio" if v > 0.0 => ell.minor_axis_ratio = v, "start_angle" => ell.start_parameter = v.to_radians(), "end_angle" => ell.end_parameter = v.to_radians(), + "normal_x" | "normal_y" | "normal_z" => { + let mut normal = [ell.normal.x, ell.normal.y, ell.normal.z]; + match field { + "normal_x" => normal[0] = v, + "normal_y" => normal[1] = v, + "normal_z" => normal[2] = v, + _ => {} + } + let Some(normal) = cadkernel::space::Vec3::from(normal).normalize() else { + return; + }; + let Some(major) = cadkernel::space::reorient_axis_to_plane( + [ell.major_axis.x, ell.major_axis.y, ell.major_axis.z], + normal.to_array(), + ) else { + return; + }; + ell.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z); + ell.major_axis = acadrust::types::Vector3::new(major[0], major[1], major[2]); + } _ => {} } } @@ -424,6 +444,19 @@ mod grip_tests { (v.major_axis.x * v.major_axis.x + v.major_axis.y * v.major_axis.y).sqrt() } + #[test] + fn editing_normal_reorients_major_axis_in_kernel() { + let mut e = ell(10.0, 0.5); + + apply_geom_prop(&mut e, "normal_x", "1"); + + let normal = DVec3::new(e.normal.x, e.normal.y, e.normal.z); + let major = DVec3::new(e.major_axis.x, e.major_axis.y, e.major_axis.z); + assert!((normal.length() - 1.0).abs() < 1.0e-12); + assert!((major.length() - 10.0).abs() < 1.0e-9); + assert!(normal.dot(major).abs() < 1.0e-9); + } + #[test] fn minor_grip_below_major_updates_ratio_only() { let mut e = ell(10.0, 0.5); diff --git a/src/entities/hatch.rs b/src/entities/hatch.rs index 0acf8ebde..3be4da77d 100644 --- a/src/entities/hatch.rs +++ b/src/entities/hatch.rs @@ -428,8 +428,8 @@ fn properties(h: &Hatch) -> Vec { title: t!("Geometry").into_owned(), props: vec![ edit(t!("Elevation").as_ref(), "elevation", h.elevation), - ro(t!("Area").as_ref(), "area", format!("{:.4}", area)), - ro(t!("Cumulative area").as_ref(), "cumulative_area", format!("{:.4}", area)), + ro(t!("Area").as_ref(), "area", crate::entities::common::format_area(area)), + ro(t!("Cumulative area").as_ref(), "cumulative_area", crate::entities::common::format_area(area)), ], }, PropSection { @@ -537,8 +537,8 @@ fn properties(h: &Hatch) -> Vec { title: t!("Geometry").into_owned(), props: vec![ edit(t!("Elevation").as_ref(), "elevation", h.elevation), - ro(t!("Area").as_ref(), "area", format!("{:.4}", area)), - ro(t!("Cumulative area").as_ref(), "cumulative_area", format!("{:.4}", area)), + ro(t!("Area").as_ref(), "area", crate::entities::common::format_area(area)), + ro(t!("Cumulative area").as_ref(), "cumulative_area", crate::entities::common::format_area(area)), ], }, ]; diff --git a/src/entities/line.rs b/src/entities/line.rs index 0f4e9fdfb..d434a58d3 100644 --- a/src/entities/line.rs +++ b/src/entities/line.rs @@ -3,8 +3,8 @@ use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ - center_grip, edit_prop as edit, oriented_triangle_grip, parse_f64, ro_prop as ro, - square_grip, + center_grip, edit_prop as edit, format_direction, format_length, oriented_triangle_grip, + parse_f64, ro_prop as ro, square_grip, }; use crate::entities::traits::RenderConvertible; use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject}; @@ -187,7 +187,7 @@ fn properties(line: &Line) -> Vec { "centerline_end_extension", association.end_extension, ), - ro(t!("Length").as_ref(), "length", format!("{:.4}", line.length())), + ro(t!("Length").as_ref(), "length", format_length(line.length())), ro( "Associative", "centerline_associative", @@ -199,7 +199,7 @@ fn properties(line: &Line) -> Vec { let dx = line.end.x - line.start.x; let dy = line.end.y - line.start.y; let dz = line.end.z - line.start.z; - let angle = dy.atan2(dx).to_degrees().rem_euclid(360.0); + let angle = dy.atan2(dx); vec![PropSection { title: t!("Geometry").into_owned(), props: vec![ @@ -209,11 +209,11 @@ fn properties(line: &Line) -> Vec { edit(t!("End X").as_ref(), "end_x", line.end.x), edit(t!("End Y").as_ref(), "end_y", line.end.y), edit(t!("End Z").as_ref(), "end_z", line.end.z), - ro(t!("Delta X").as_ref(), "delta_x", format!("{dx:.4}")), - ro(t!("Delta Y").as_ref(), "delta_y", format!("{dy:.4}")), - ro(t!("Delta Z").as_ref(), "delta_z", format!("{dz:.4}")), - ro(t!("Length").as_ref(), "length", format!("{:.4}", line.length())), - ro(t!("Angle").as_ref(), "angle", format!("{angle:.2}")), + ro(t!("Delta X").as_ref(), "delta_x", format_length(dx)), + ro(t!("Delta Y").as_ref(), "delta_y", format_length(dy)), + ro(t!("Delta Z").as_ref(), "delta_z", format_length(dz)), + ro(t!("Length").as_ref(), "length", format_length(line.length())), + ro(t!("Angle").as_ref(), "angle", format_direction(angle)), edit(t!("Normal X").as_ref(), "normal_x", line.normal.x), edit(t!("Normal Y").as_ref(), "normal_y", line.normal.y), edit(t!("Normal Z").as_ref(), "normal_z", line.normal.z), diff --git a/src/entities/names.rs b/src/entities/names.rs index c26b252f2..1b4bfb8d1 100644 --- a/src/entities/names.rs +++ b/src/entities/names.rs @@ -143,6 +143,7 @@ pub fn dxf_name(e: &EntityType) -> &'static str { EntityType::MLine(_) => "MLINE", EntityType::RasterImage(_) => "RASTERIMAGE", EntityType::Wipeout(_) => "WIPEOUT", + EntityType::Ole2Frame(_) => "OLE2FRAME", EntityType::Underlay(_) => "UNDERLAY", EntityType::AttributeDefinition(_) => "ATTDEF", EntityType::AttributeEntity(_) => "ATTRIB", diff --git a/src/entities/polyline.rs b/src/entities/polyline.rs index fcbdedfa6..86038dde8 100644 --- a/src/entities/polyline.rs +++ b/src/entities/polyline.rs @@ -3,7 +3,7 @@ use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ - edit_prop as edit, format_length, parse_f64, ro_prop as ro, square_grip, + edit_prop as edit, format_area, format_length, parse_f64, ro_prop as ro, square_grip, stepper_prop as stepper, VARIES_LABEL, }; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; @@ -705,8 +705,8 @@ impl PropertyEditable for Polyline2D { edit(t!("End segment width").as_ref(), "pl2_seg_end_w", seg_end_w), global_width_prop, edit(t!("Elevation").as_ref(), "pl2_elevation", self.elevation), - ro(t!("Area").as_ref(), "pl2_area", format!("{area:.4}")), - ro(t!("Length").as_ref(), "pl2_length", format!("{length:.4}")), + ro(t!("Area").as_ref(), "pl2_area", format_area(area)), + ro(t!("Length").as_ref(), "pl2_length", format_length(length)), ], }, PropSection { diff --git a/src/entities/raster_image.rs b/src/entities/raster_image.rs index b3ceab826..028c31d80 100644 --- a/src/entities/raster_image.rs +++ b/src/entities/raster_image.rs @@ -301,9 +301,9 @@ impl PropertyEditable for RasterImage { edit(t!("Position X").as_ref(), "ri_ox", self.insertion_point.x), edit(t!("Position Y").as_ref(), "ri_oy", self.insertion_point.y), edit(t!("Position Z").as_ref(), "ri_oz", self.insertion_point.z), - ro(t!("Rotation").as_ref(), "ri_rotation", format!("{:.4}", rotation_deg)), - ro(t!("Width").as_ref(), "ri_width", format!("{:.4}", self.width())), - ro(t!("Height").as_ref(), "ri_height", format!("{:.4}", self.height())), + ro(t!("Rotation").as_ref(), "ri_rotation", crate::entities::common::format_angle(rotation_deg.to_radians())), + ro(t!("Width").as_ref(), "ri_width", crate::entities::common::format_length(self.width())), + ro(t!("Height").as_ref(), "ri_height", crate::entities::common::format_length(self.height())), ro(t!("Scale").as_ref(), "ri_scale", format!("{:.4}", scale)), ], }, diff --git a/src/entities/solid.rs b/src/entities/solid.rs index bb4972f51..c41bc381a 100644 --- a/src/entities/solid.rs +++ b/src/entities/solid.rs @@ -80,7 +80,14 @@ fn boundary_edges(points: &[[f64; 3]]) -> Vec { } fn kernel_tolerance(points: &[[f64; 3]]) -> f64 { - cadkernel::space::coplanarity_tolerance(points).max(1.0e-12) + let tolerance = cadkernel::space::coplanarity_tolerance(points); + // Corners whose extent overflows f64 give an infinite tolerance, which + // `Tolerance::new` rejects by panicking. + if tolerance.is_finite() { + tolerance.max(1.0e-12) + } else { + 1.0e-12 + } } fn extrusion_body( @@ -366,3 +373,23 @@ impl Transformable for Solid { ); } } + +#[cfg(test)] +mod overflow_tests { + use super::*; + use acadrust::types::Vector3; + + /// Corners 1e308 apart overflow the coplanarity tolerance to infinity, + /// which the kernel's `Tolerance::new` rejects by panicking. + #[test] + fn corners_with_overflowing_extent_do_not_panic() { + let solid = Solid::new( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1e308, 0.0, 0.0), + Vector3::new(0.0, 1e308, 0.0), + Vector3::new(1e308, 1e308, 0.0), + ); + let _ = wcs_bounds(&solid); + let _ = solid_geometry(&solid); + } +} diff --git a/src/entities/spline.rs b/src/entities/spline.rs index e68c3b38a..efbbc7e35 100644 --- a/src/entities/spline.rs +++ b/src/entities/spline.rs @@ -18,7 +18,13 @@ pub(crate) fn uses_fit_method(spline: &Spline) -> bool { } pub(crate) fn shows_fit_points(spline: &Spline) -> bool { - uses_fit_method(spline) && (!spline.cv_frame_visible || spline.flags.periodic) + !spline.fit_points.is_empty() && (!spline.cv_frame_visible || spline.flags.periodic) +} + +pub(crate) fn shows_control_vertices(spline: &Spline) -> bool { + !shows_fit_points(spline) + && !(uses_fit_method(spline) && spline.flags.periodic) + && control_vertices(spline).len() >= 2 } pub(crate) fn fit_nurbs3(spline: &Spline) -> Option { @@ -67,13 +73,59 @@ pub(crate) fn nurbs3(spline: &Spline) -> Option { Some(curve.with_periodicity(spline.flags.closed || spline.flags.periodic)) } +/// NURBS knot locations on a control-point spline, evaluated on its own +/// curve: distinct interior knot values (the clamped ends already snap as +/// endpoints). Fit-method splines invent their knots at the fit nodes, which +/// already snap as endpoints, so they contribute nothing — re-offering them +/// would double every marker. +pub(crate) fn spline_knot_points(spline: &Spline) -> Vec { + const MAX_KNOT_SNAPS: usize = 1024; + if uses_fit_method(spline) { + return Vec::new(); + } + let Some(curve) = nurbs3(spline) else { + return Vec::new(); + }; + let (from, to) = curve.domain(); + if !(from.is_finite() && to.is_finite()) || !(to > from) { + return Vec::new(); + } + let span = to - from; + let mut knots: Vec = curve + .knots() + .iter() + .copied() + .filter(|k| k.is_finite() && *k > from + span * 1e-9 && *k < to - span * 1e-9) + .collect(); + knots.sort_by(f64::total_cmp); + knots.dedup(); + if knots.len() > MAX_KNOT_SNAPS { + let step = knots.len().div_ceil(MAX_KNOT_SNAPS); + knots = knots.into_iter().step_by(step).collect(); + } + knots + .into_iter() + .map(|k| glam::DVec3::from_array(curve.point_at_knot(k))) + .filter(|p| p.is_finite()) + .collect() +} + pub(crate) fn replace_with_nurbs(spline: &mut Spline, curve: &NurbsCurve3) { + let mut fit_points = spline.fit_points.clone(); spline.degree = curve.degree() as i32; spline.knots = curve.knots().to_vec(); spline.control_points = curve.control_points().iter() .map(|point| acadrust::types::Vector3::new(point[0], point[1], point[2])).collect(); spline.weights = curve.weights().to_vec(); - spline.fit_points.clear(); + if !fit_points.is_empty() { + if let (Some(first), Some(point)) = (fit_points.first_mut(), spline.control_points.first()) { + *first = *point; + } + if let (Some(last), Some(point)) = (fit_points.last_mut(), spline.control_points.last()) { + *last = *point; + } + } + spline.fit_points = fit_points; spline.begin_tangent = acadrust::types::Vector3::ZERO; spline.end_tangent = acadrust::types::Vector3::ZERO; spline.flags.rational = spline.weights.windows(2) @@ -134,6 +186,11 @@ fn to_render(spl: &Spline) -> RenderEntity { crate::scene::model::wire_model::SnapHint::Endpoint, ) })); + snap_pts.extend( + spline_knot_points(spl) + .into_iter() + .map(|p| (p, crate::scene::model::wire_model::SnapHint::Knot)), + ); let key_vertices = if planar.is_some() { Vec::new() } else { @@ -179,6 +236,11 @@ fn to_render(spl: &Spline) -> RenderEntity { ) })); } + points.extend( + spline_knot_points(spl) + .into_iter() + .map(|p| (p, crate::scene::model::wire_model::SnapHint::Knot)), + ); (points, Vec::new()) } // A spline through points in space is not a planar curve, so the @@ -568,7 +630,15 @@ fn index_prop(label: &str, field: &'static str, index: usize, count: usize) -> P fn convert_to_control_method(spline: &mut Spline) -> bool { if !uses_fit_method(spline) { - return true; + let valid = spline.control_points.len() >= 2; + if valid { + spline.fit_points.clear(); + spline.begin_tangent = acadrust::types::Vector3::ZERO; + spline.end_tangent = acadrust::types::Vector3::ZERO; + spline.dwg_flags1 &= !1; + spline.dxf_flags &= !(32 | 1024); + } + return valid; } let Some(planar) = crate::entities::curve::spline_curve(spline) else { return false; @@ -600,18 +670,74 @@ fn convert_to_control_method(spline: &mut Spline) -> bool { } fn convert_to_fit_method(spline: &mut Spline) -> bool { - uses_fit_method(spline) + if uses_fit_method(spline) { + return true; + } + if spline.fit_points.len() < 2 { + return false; + } + spline.control_points.clear(); + spline.knots.clear(); + spline.weights.clear(); + spline.flags.rational = false; + spline.dwg_flags1 |= 1; + spline.dxf_flags |= 32 | 1024; + true +} + +fn derived_fit_points(spline: &Spline) -> Vec { + if spline.flags.closed || spline.flags.periodic { + return Vec::new(); + } + let Some(curve) = nurbs3(spline) else { + return Vec::new(); + }; + let (start, end) = curve.domain(); + if !(start.is_finite() && end.is_finite() && end > start) { + return Vec::new(); + } + let tolerance = (end - start).abs().max(1.0) * 1.0e-12; + let mut parameters = vec![start]; + for knot in curve.knots().iter().copied() { + if knot > start + tolerance + && knot < end - tolerance + && parameters + .last() + .is_none_or(|previous| (knot - *previous).abs() > tolerance) + { + parameters.push(knot); + } + } + parameters.push(end); + parameters + .into_iter() + .map(|parameter| { + let point = curve.point_at_knot(parameter); + acadrust::types::Vector3::new(point[0], point[1], point[2]) + }) + .collect() +} + +fn can_show_fit_points(spline: &Spline) -> bool { + spline.fit_points.len() >= 2 || derived_fit_points(spline).len() >= 2 +} + +fn prepare_fit_point_view(spline: &mut Spline) -> bool { + if spline.fit_points.len() < 2 { + spline.fit_points = derived_fit_points(spline); + } + spline.fit_points.len() >= 2 } fn grips(spline: &Spline) -> Vec { - let fit_method = uses_fit_method(spline); let derived_control = control_vertices(spline); - let show_control_vertices = (spline.cv_frame_visible || !fit_method) - && !(fit_method && spline.flags.periodic) + let show_fit = shows_fit_points(spline); + let show_control_vertices = !show_fit + && !(uses_fit_method(spline) && spline.flags.periodic) && derived_control.len() >= 2; let source = if show_control_vertices { derived_control - } else if fit_method { + } else if show_fit { spline.fit_points.clone() } else { spline.control_points.clone() @@ -838,7 +964,7 @@ fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) { match field { "spline_method" => { if value == "Fit" { - if convert_to_fit_method(spline) { + if prepare_fit_point_view(spline) { spline.cv_frame_visible = false; } } else if value == "Control Vertices" { @@ -849,6 +975,12 @@ fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) { return; } "knot_param" => { + if shows_fit_points(spline) + && !uses_fit_method(spline) + && !convert_to_fit_method(spline) + { + return; + } spline.knot_parameterization = match value { "Chord" => 0, "Square Root" => 1, @@ -883,9 +1015,30 @@ fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) { { return; } - if matches!(field, "ctrl_pt_x" | "ctrl_pt_y" | "ctrl_pt_z" | "weight") - && uses_fit_method(spline) - && !convert_to_control_method(spline) + if matches!(field, "ctrl_pt_x" | "ctrl_pt_y" | "ctrl_pt_z" | "weight") { + if !shows_fit_points(spline) && !spline.fit_points.is_empty() { + if !convert_to_control_method(spline) { + return; + } + } else if uses_fit_method(spline) && !convert_to_control_method(spline) { + return; + } + } + if shows_fit_points(spline) + && !uses_fit_method(spline) + && matches!( + field, + "fit_pt_x" + | "fit_pt_y" + | "fit_pt_z" + | "start_tan_x" + | "start_tan_y" + | "start_tan_z" + | "end_tan_x" + | "end_tan_y" + | "end_tan_z" + ) + && !convert_to_fit_method(spline) { return; } @@ -968,16 +1121,23 @@ fn apply_grip(spline: &mut Spline, grip_id: usize, apply: GripApply) { return; } // First CV edit makes the displayed kernel controls authoritative. - let fit_method = uses_fit_method(spline); - if spline.cv_frame_visible && fit_method && !spline.flags.periodic { + let editing_fit = shows_fit_points(spline); + if editing_fit && !uses_fit_method(spline) { + if !convert_to_fit_method(spline) { + return; + } + } else if !editing_fit + && (uses_fit_method(spline) || !spline.fit_points.is_empty()) + && !spline.flags.periodic + { if !convert_to_control_method(spline) { return; } } - let target = if (spline.cv_frame_visible && !spline.flags.periodic) || !fit_method { - spline.control_points.get_mut(grip_id) - } else { + let target = if editing_fit { spline.fit_points.get_mut(grip_id) + } else { + spline.control_points.get_mut(grip_id) }; if let Some(cp) = target { match apply { @@ -1041,9 +1201,10 @@ impl crate::entities::traits::Grippable for Spline { let fit_method = uses_fit_method(self); let can_show_control = control_vertices(self).len() >= 2 && !(fit_method && self.flags.periodic); + let can_show_fit = can_show_fit_points(self); return if !shows_fit_points(self) { vec![ - (can_show_control && fit_method).then_some(GripMenuItem { + can_show_fit.then_some(GripMenuItem { label: "Fit", action: GripMenuAction::ShowFit, }), @@ -1084,7 +1245,7 @@ impl crate::entities::traits::Grippable for Spline { use crate::scene::model::object::GripMenuAction as A; match action { A::ShowFit => { - if convert_to_fit_method(self) { + if prepare_fit_point_view(self) { self.cv_frame_visible = false; return; } @@ -1120,6 +1281,45 @@ impl crate::entities::traits::Transformable for Spline { mod tests { use super::*; + #[test] + fn knot_points_evaluate_distinct_interior_knots() { + let mut spline = Spline::default(); + spline.degree = 3; + spline.control_points = vec![ + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(1.0, 2.0, 0.0), + acadrust::types::Vector3::new(3.0, 3.0, 1.0), + acadrust::types::Vector3::new(5.0, 2.0, 0.0), + acadrust::types::Vector3::new(6.0, 0.0, 1.0), + ]; + spline.knots = vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0]; + let pts = spline_knot_points(&spline); + assert_eq!(pts.len(), 1, "one interior knot value: {pts:?}"); + let curve = nurbs3(&spline).expect("control-point spline builds"); + let expected = curve.point_at_knot(0.5); + let got = [pts[0].x, pts[0].y, pts[0].z]; + let err = ((got[0] - expected[0]).powi(2) + + (got[1] - expected[1]).powi(2) + + (got[2] - expected[2]).powi(2)) + .sqrt(); + assert!(err < 1e-9, "knot point off the curve: {err}"); + } + + #[test] + fn fit_method_splines_emit_no_knot_snaps() { + // Interpolated curves invent their own knots at the fit nodes, which + // already snap as endpoints — re-offering them as knots would double + // every marker. + let mut spline = Spline::default(); + spline.degree = 3; + spline.fit_points = vec![ + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(4.0, 0.0, 1.0), + acadrust::types::Vector3::new(4.0, 3.0, 2.0), + ]; + assert!(spline_knot_points(&spline).is_empty()); + } + #[test] fn fit_bounds_accept_closed_curves_with_unset_tangents() { let mut spline = Spline::default(); @@ -1190,4 +1390,30 @@ mod tests { acadrust::types::Vector3::new(0.0, 2.5, 0.0) ); } + + #[test] + fn showing_derived_fit_points_does_not_change_the_curve() { + let mut spline = Spline::default(); + spline.degree = 3; + spline.control_points = vec![ + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(1.0, 3.0, 0.0), + acadrust::types::Vector3::new(4.0, 3.0, 0.0), + acadrust::types::Vector3::new(5.0, 0.0, 0.0), + ]; + spline.knots = cadkernel::space::clamped_uniform_knots(3, 4); + let before = nurbs3(&spline).unwrap().point_at(0.37); + + assert!(prepare_fit_point_view(&mut spline)); + spline.cv_frame_visible = false; + + assert!(shows_fit_points(&spline)); + assert!(!uses_fit_method(&spline)); + let after = nurbs3(&spline).unwrap().point_at(0.37); + assert!( + cadkernel::space::Vec3::from(before) + .distance(cadkernel::space::Vec3::from(after)) + < 1.0e-12 + ); + } } diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 8227ec73a..743fe85ef 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -39,6 +39,12 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT .map(std::path::Path::new) .and_then(|p| p.parent()); crate::io::resolve_image_file(file, base) + // Fonts fetched from the community repository live in + // the per-user fonts folder — search it last. + .or_else(|| { + crate::io::font_repo::local_font_file(file) + .map(|path| path.to_string_lossy().into_owned()) + }) }) .flatten(); if let Some(p) = shx_path { @@ -191,7 +197,7 @@ pub fn text_local_bounds( /// Expand DXF `%%x` special-character sequences that appear in both TEXT and MTEXT values: /// - `%%d` / `%%D` → `°` /// - `%%p` / `%%P` → `±` -/// - `%%c` / `%%C` → `⌀` +/// - `%%c` / `%%C` → `∅` (U+2205, the native diameter sign) /// - `%%u` / `%%U` → underline toggle (stripped — not renderable with stroke fonts) /// - `%%o` / `%%O` → overline toggle (stripped) /// - `%%%%` → `%` @@ -218,7 +224,7 @@ pub fn resolve_dxf_special_chars(s: &str) -> String { } Some('c') => { chars.next(); - out.push('⌀'); + out.push('∅'); } Some('u') | Some('o') => { chars.next(); @@ -792,9 +798,11 @@ pub fn reorder_line_atoms(atoms: Vec, is_rtl: bool) -> Vec` +//! child process: device request, staging-style mapped upload, render +//! pipeline creation (catches shader-compile failures), offscreen render +//! and readback. The first fully-passing backend wins; +//! - backends implicated in the previous run's crash (crash sentinel) are +//! skipped; +//! - if no hardware backend passes, one software-adapter probe (`--gpu-probe +//! sw`, e.g. WARP / llvmpipe / SwiftShader) is tried: slow but alive; +//! - the winning probe also reports adapter limits, so the packed +//! compatibility renderer is enabled automatically on GPUs without +//! shader storage buffers. +//! +//! Probes run out-of-process because the failure modes are `panic = "abort"` +//! or driver hangs: an in-process probe would die or stall with the app +//! instead of reporting failure. + +use std::path::PathBuf; +use std::time::Duration; + +/// Hidden child-process flag: `--gpu-probe `. +pub const PROBE_ARG: &str = "--gpu-probe"; +/// Per-backend probe timeout: broken drivers can hang enumeration, so never +/// block boot long on a single backend. +pub const PROBE_TIMEOUT: Duration = Duration::from_secs(10); +/// Test hook: every probe reports failure without spawning a child. +pub const FORCE_FAIL_ENV: &str = "OCS_GPU_PROBE_FORCE_FAIL"; +/// Test hook: the first candidate probe reports success without spawning. +pub const FORCE_OK_ENV: &str = "OCS_GPU_PROBE_FORCE_OK"; +/// Test hook: `max_storage_buffers_per_shader_stage` of the synthetic success +/// (drives compat-renderer auto-enable in tests). +pub const FORCE_CAPS_ENV: &str = "OCS_GPU_PROBE_FORCE_CAPS"; +/// Test hook: point the sentinel at a temp dir instead of the user config dir. +pub const SENTINEL_DIR_ENV: &str = "OCS_GPU_SENTINEL_DIR"; + +/// Wire shader path needs one storage buffer; batched hatch needs four (see +/// `DeviceCapabilities`). Below one storage buffer the packed compatibility +/// renderer is required, so the probe enables it automatically. +pub const WIRE_STORAGE_NEEDED: u32 = 1; + +/// Candidate backends in preference order. Windows keeps DX12/Vulkan first so +/// the AMD OpenGL ICD is never touched unless both fail; Linux prefers Vulkan +/// because old Mesa GLES is a known crash source. macOS stays on its Metal +/// default (guard inactive there). +pub fn candidate_backends() -> Vec { + if cfg!(target_os = "windows") { + vec!["dx12".to_string(), "vulkan".to_string(), "gl".to_string()] + } else if cfg!(target_os = "linux") { + vec!["vulkan".to_string(), "gl".to_string()] + } else { + Vec::new() + } +} + +/// Whether the resolver is active on this OS (Windows + Linux; macOS keeps +/// iced's Metal default, wasm has no native GPU choice to make). +pub fn gpu_guard_active() -> bool { + !candidate_backends().is_empty() +} + +/// Capabilities reported by a passing probe child (one JSON line on stdout). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProbeCaps { + /// Backend that passed (`dx12`, `vulkan` or `gl`). + pub backend: String, + /// Human-readable adapter name for logs and notices. + pub adapter: String, + /// `max_storage_buffers_per_shader_stage` of the adapter. + pub max_storage: u32, + /// True when this is a software rasterizer (WARP / llvmpipe / SwiftShader). + pub software: bool, +} + +impl ProbeCaps { + fn synthetic(backend: &str) -> Self { + let max_storage = std::env::var(FORCE_CAPS_ENV) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(8); + Self { + backend: backend.to_string(), + adapter: "synthetic-test-adapter".to_string(), + max_storage, + software: false, + } + } + + /// Parse the JSON line a probe child prints on success. + fn from_json_line(line: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(line.trim()).ok()?; + Some(Self { + backend: v.get("backend")?.as_str()?.to_string(), + adapter: v.get("adapter")?.as_str().unwrap_or("unknown").to_string(), + max_storage: v.get("max_storage")?.as_u64()? as u32, + software: v.get("software")?.as_bool()?, + }) + } + + fn to_json_line(&self) -> String { + serde_json::json!({ + "backend": self.backend, + "adapter": self.adapter, + "max_storage": self.max_storage, + "software": self.software, + }) + .to_string() + } +} + +/// Whether the packed compatibility renderer is needed for these caps. +/// Software rasterizers always take it: the packed path works everywhere and +/// these GPUs are slow enough already. +pub fn needs_compat_renderer(caps: &ProbeCaps) -> bool { + caps.software || caps.max_storage < WIRE_STORAGE_NEEDED +} + +/// What the resolver decided, and why (for the startup notice). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendDecision { + /// Value to set `WGPU_BACKEND` to, or `None` to leave the env alone. + pub backend_value: Option, + /// Human-readable reason when this is not the silent fast path. + pub reason: Option, + /// Probe says the packed compatibility renderer is needed. + pub compat_renderer: bool, + /// Adapter name of the winning probe, for logs. + pub adapter_name: Option, +} + +/// Pure decision function — all IO (env reads, probes, sentinel) happens in +/// the caller so this is unit-testable. +/// * `candidates`: platform-ordered backends to consider. +/// * `excluded`: backends implicated in the previous run's crash. +/// * `results`: probe outcome per tried backend, in any order. +/// * `software`: software-adapter probe outcome. +#[allow(clippy::too_many_arguments)] +pub fn decide_backend( + explicit_cli: Option<&str>, + env_backend: Option<&str>, + safe_mode: bool, + candidates: &[String], + excluded: &[String], + results: &[(String, Option)], + software: Option, + guard_active: bool, +) -> BackendDecision { + if let Some(b) = explicit_cli { + return BackendDecision { + backend_value: Some(b.to_string()), + reason: None, + compat_renderer: false, + adapter_name: None, + }; + } + if let Some(b) = env_backend { + return BackendDecision { + backend_value: Some(b.to_string()), + reason: None, + compat_renderer: false, + adapter_name: None, + }; + } + if safe_mode { + return BackendDecision { + backend_value: Some("gl".to_string()), + reason: Some("safe mode requested (--safe-mode)".to_string()), + compat_renderer: false, + adapter_name: None, + }; + } + if !guard_active { + return BackendDecision { + backend_value: None, + reason: None, + compat_renderer: false, + adapter_name: None, + }; + } + let failed: Vec<&String> = results + .iter() + .filter(|(_, r)| r.is_none()) + .map(|(b, _)| b) + .collect(); + // First passing backend in preference order, skipping backends that died + // with the previous run. + for backend in candidates { + if excluded.iter().any(|e| e == backend) { + continue; + } + if let Some((_, Some(caps))) = results.iter().find(|(b, _)| b == backend) { + let fell_back = backend != &candidates[0] || !excluded.is_empty(); + let reason = if fell_back { + Some(failover_reason(&failed, excluded, backend)) + } else { + None + }; + return BackendDecision { + backend_value: Some(backend.clone()), + reason, + compat_renderer: needs_compat_renderer(caps), + adapter_name: Some(caps.adapter.clone()), + }; + } + } + if let Some(caps) = software { + return BackendDecision { + backend_value: Some(caps.backend.clone()), + reason: Some( + "no hardware-accelerated backend survived probing, \ + so this launch uses software rendering (slow, but stable)" + .to_string(), + ), + compat_renderer: true, + adapter_name: Some(caps.adapter.clone()), + }; + } + // Best effort: GL is the most widely available backend on old hardware. + BackendDecision { + backend_value: Some("gl".to_string()), + reason: Some( + "every graphics probe failed, so this launch tries the GL \ + compatibility backend as a last resort" + .to_string(), + ), + compat_renderer: true, + adapter_name: None, + } +} + +fn failover_reason(failed: &[&String], excluded: &[String], chosen: &str) -> String { + let mut parts: Vec = failed.iter().map(|b| b.to_string()).collect(); + parts.extend(excluded.iter().cloned()); + parts.sort(); + parts.dedup(); + let what_failed = if parts.is_empty() { + "the preferred backend was skipped after the previous crash".to_string() + } else { + format!("{} {}", parts.join(", "), "is not usable on this GPU") + }; + let chosen_text = match chosen { + "gl" => "the GL compatibility backend", + "vulkan" => "Vulkan", + "dx12" => "DirectX 12", + other => other, + }; + format!("{what_failed}, so this launch uses {chosen_text}") +} + +/// Notice text shown in the command line when the backend was switched. +pub fn fallback_notice(reason: &str) -> String { + format!( + "Graphics compatibility mode: {reason}. \ + If drawings render slowly, update the GPU driver; \ + override with --backend ." + ) +} + +/// Notice text when the packed compatibility renderer was auto-enabled. +pub fn compat_notice() -> String { + "Older GPU detected: using the packed compatibility renderer \ + (no shader storage buffers). Override with --compat-renderer / --backend." + .to_string() +} + +/// Sentinel file: armed before `app::run`, disarmed on clean exit. A stale +/// file at startup means the last run died while the GPU was active; its +/// content names the attempted backend(s) so they are skipped next launch. +pub fn sentinel_path() -> Option { + if let Ok(dir) = std::env::var(SENTINEL_DIR_ENV) { + return Some(PathBuf::from(dir).join("gpu_crash_sentinel")); + } + crate::config::config_dir().map(|d| d.join("gpu_crash_sentinel")) +} + +pub fn previous_run_crashed() -> bool { + sentinel_path().is_some_and(|p| p.exists()) +} + +/// Backends implicated in the previous run's crash, from the stale sentinel. +pub fn crashed_backends() -> Vec { + let Some(p) = sentinel_path() else { + return Vec::new(); + }; + std::fs::read_to_string(p) + .unwrap_or_default() + .lines() + .find_map(|l| l.strip_prefix("backend=")) + .unwrap_or("") + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +pub fn arm_sentinel(backend: &str) { + if let Some(p) = sentinel_path() { + if let Some(parent) = p.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write( + &p, + format!( + "backend={backend}\npid={}\nt={}\n", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + ), + ); + } +} + +pub fn disarm_sentinel() { + if let Some(p) = sentinel_path() { + let _ = std::fs::remove_file(&p); + } +} + +/// Outcome of the whole resolution: what to set, what to tell the user, and +/// whether the packed renderer was auto-enabled. +pub struct GpuResolution { + /// Value written to `WGPU_BACKEND` (`None` = env untouched). + pub backend_value: Option, + /// Fallback reason for the startup notice, if any. + pub reason: Option, + /// Probe says the packed compatibility renderer is needed. + pub compat_renderer: bool, +} + +/// Spawn one `--gpu-probe ` child and return its reported capabilities +/// on success. `None` = crashed / failed / timed out. +fn run_probe_for(arg: &str) -> Option { + if std::env::var_os(FORCE_FAIL_ENV).is_some() { + return None; + } + if std::env::var_os(FORCE_OK_ENV).is_some() { + return Some(ProbeCaps::synthetic(arg)); + } + let exe = std::env::current_exe().ok()?; + let mut child = std::process::Command::new(exe) + .arg(PROBE_ARG) + .arg(arg) + .env("RUST_LOG", "error") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + let start = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return None; + } + let out = child.wait_with_output().ok()?; + let line = String::from_utf8_lossy(&out.stdout); + return line.lines().rev().find_map(ProbeCaps::from_json_line); + } + Ok(None) => { + if start.elapsed() > PROBE_TIMEOUT { + let _ = child.kill(); + let _ = child.wait(); + eprintln!("[gpu] {arg} probe timed out; trying the next backend"); + return None; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(_) => return None, + } + } +} + +/// Resolve and apply `WGPU_BACKEND` for this launch. +/// +/// Precedence: `--backend` > existing `WGPU_BACKEND` > `--safe-mode` > +/// per-backend probes (minus crash-implicated backends) > software adapter > +/// last-resort `gl`. Inactive on macOS/wasm (env untouched). +pub fn resolve_gpu(cli_backend: Option<&str>, safe_mode: bool) -> GpuResolution { + let env_backend = std::env::var("WGPU_BACKEND").ok(); + if cli_backend.is_some() || env_backend.is_some() || safe_mode || !gpu_guard_active() { + let d = decide_backend( + cli_backend, + env_backend.as_deref(), + safe_mode, + &[], + &[], + &[], + None, + gpu_guard_active(), + ); + if let Some(v) = &d.backend_value { + std::env::set_var("WGPU_BACKEND", v); + } + return GpuResolution { + backend_value: d.backend_value, + reason: d.reason, + compat_renderer: false, + }; + } + let candidates = candidate_backends(); + let excluded = if previous_run_crashed() { + crashed_backends() + } else { + Vec::new() + }; + let mut results: Vec<(String, Option)> = Vec::new(); + for backend in &candidates { + if excluded.iter().any(|e| e == backend) { + continue; + } + let caps = run_probe_for(backend); + eprintln!( + "[gpu] probe {backend}: {}", + caps.as_ref() + .map(|c| c.adapter.as_str()) + .unwrap_or("FAILED") + ); + results.push((backend.clone(), caps)); + if results.iter().any(|(_, c)| c.is_some()) { + break; + } + } + let sw = if results.iter().all(|(_, c)| c.is_none()) { + run_probe_for("sw") + } else { + None + }; + let d = decide_backend( + None, + None, + false, + &candidates, + &excluded, + &results, + sw, + true, + ); + if let Some(v) = &d.backend_value { + std::env::set_var("WGPU_BACKEND", v); + if let Some(adapter) = &d.adapter_name { + eprintln!("[gpu] backend: {v} ({adapter})"); + } + if let Some(reason) = &d.reason { + eprintln!("[gpu] {}", fallback_notice(reason)); + } + if d.compat_renderer { + eprintln!("[gpu] {}", compat_notice()); + } + } + GpuResolution { + backend_value: d.backend_value, + reason: d.reason, + compat_renderer: d.compat_renderer, + } +} + +/// Child-process entry point for `--gpu-probe `: exercise one +/// backend the way the canvas does (device + staging-style mapped upload + +/// render pipeline creation + offscreen render + readback). Prints one JSON +/// line on success and exits 0; any failure (including a driver abort panics) +/// exits non-zero with no output. +pub fn run_probe_child(which: &str) -> ! { + let code = match which { + "dx12" | "vulkan" | "gl" => probe_single_backend(which).map_or(1, |caps| { + println!("{}", caps.to_json_line()); + 0 + }), + "sw" => probe_software_adapters().map_or(1, |caps| { + println!("{}", caps.to_json_line()); + 0 + }), + _ => { + eprintln!("[gpu-probe] unknown backend '{which}' (want dx12|vulkan|gl|sw)"); + 2 + } + }; + std::process::exit(code); +} + +fn wgpu_backends_for(name: &str) -> iced::wgpu::Backends { + use iced::wgpu::Backends; + match name { + "dx12" => Backends::DX12, + "vulkan" => Backends::VULKAN, + "gl" => Backends::GL, + _ => Backends::empty(), + } +} + +fn backend_name(backend: iced::wgpu::Backend) -> &'static str { + use iced::wgpu::Backend; + match backend { + Backend::Dx12 => "dx12", + Backend::Vulkan => "vulkan", + Backend::Gl => "gl", + Backend::Metal => "metal", + _ => "other", + } +} + +/// Adapter names that mean "no GPU" (same family as the app's software +/// detection): these never count as a passing *hardware* probe. +const SOFTWARE_RASTERIZER_NAMES: [&str; 6] = [ + "llvmpipe", + "lavapipe", + "softpipe", + "swiftshader", + "warp", + "microsoft basic render", +]; + +fn is_software_adapter(info: &iced::wgpu::AdapterInfo) -> bool { + if info.device_type == iced::wgpu::DeviceType::Cpu { + return true; + } + let name = info.name.to_ascii_lowercase(); + SOFTWARE_RASTERIZER_NAMES + .iter() + .any(|marker| name.contains(marker)) +} + +fn probe_single_backend(name: &str) -> Option { + use iced::wgpu; + let backends = wgpu_backends_for(name); + let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); + desc.backends = backends; + let instance = wgpu::Instance::new(desc); + let adapters: Vec = pollster::block_on(instance.enumerate_adapters(backends)); + // Hardware only: a software rasterizer proving "usable" would hide a + // broken hardware path (software gets its own dedicated `sw` probe). + let mut candidates: Vec<_> = adapters + .into_iter() + .filter(|a| !is_software_adapter(&a.get_info())) + .collect(); + if candidates.is_empty() { + eprintln!("[gpu-probe] {name}: no hardware adapters enumerated"); + return None; + } + candidates.sort_by_key(|a| match a.get_info().device_type { + wgpu::DeviceType::DiscreteGpu => 0, + wgpu::DeviceType::IntegratedGpu => 1, + wgpu::DeviceType::Other => 2, + _ => 3, + }); + for adapter in &candidates { + if let Some(caps) = exercise_adapter(adapter) { + return Some(caps); + } + } + eprintln!("[gpu-probe] {name}: no adapter survived the exercise"); + None +} + +fn probe_software_adapters() -> Option { + use iced::wgpu; + let backends = wgpu::Backends::DX12 | wgpu::Backends::VULKAN | wgpu::Backends::GL; + let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); + desc.backends = backends; + let instance = wgpu::Instance::new(desc); + let adapters: Vec = pollster::block_on(instance.enumerate_adapters(backends)); + let mut software: Vec<_> = adapters + .into_iter() + .filter(|a| is_software_adapter(&a.get_info())) + .collect(); + if software.is_empty() { + // No software adapter enumerated: ask wgpu for its fallback adapter. + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::None, + force_fallback_adapter: true, + compatible_surface: None, + })) + .ok()?; + software.push(adapter); + } + for adapter in &software { + if let Some(caps) = exercise_adapter(adapter) { + return Some(caps); + } + } + eprintln!("[gpu-probe] sw: no software adapter survived the exercise"); + None +} + +/// Full exercise for one adapter: device request (iced-style limits), staging +/// upload, render pipeline creation, offscreen render, readback. Any driver +/// abort or validation failure fails the probe. +fn exercise_adapter(adapter: &iced::wgpu::Adapter) -> Option { + use iced::wgpu; + let info = adapter.get_info(); + eprintln!( + "[gpu-probe] trying {} ({:?}, {:?})", + info.name, info.backend, info.device_type + ); + // Mirror iced's compositor: full limits first, then the downlevel set. + let limits = [ + wgpu::Limits::default().using_resolution(adapter.limits()), + wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()), + ]; + for required_limits in limits { + let Ok((device, queue)) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("ocs-gpu-probe"), + required_features: wgpu::Features::empty(), + required_limits: required_limits.clone(), + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + })) + else { + continue; + }; + let errors = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = errors.clone(); + device.on_uncaptured_error(std::sync::Arc::new(move |e: wgpu::Error| { + eprintln!("[gpu-probe] uncaptured error: {e}"); + flag.store(true, std::sync::atomic::Ordering::SeqCst); + })); + if !staging_upload(&device) + || !pipeline_render_readback(&device, &queue) + || errors.load(std::sync::atomic::Ordering::SeqCst) + { + continue; + } + eprintln!("[gpu-probe] OK via {}", info.name); + return Some(ProbeCaps { + backend: backend_name(info.backend).to_string(), + adapter: info.name.clone(), + max_storage: adapter.limits().max_storage_buffers_per_shader_stage, + software: is_software_adapter(&info), + }); + } + None +} + +/// Mapped-memory upload through the queue (never `mapped_at_creation`): the +/// same host-mapping path `StagingBelt` and glyph uploads take. +fn staging_upload(device: &iced::wgpu::Device) -> bool { + use iced::wgpu; + let staging = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("ocs-gpu-probe-staging"), + size: 4096, + usage: wgpu::BufferUsages::MAP_WRITE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let slice = staging.slice(..); + let mapped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = mapped.clone(); + slice.map_async(wgpu::MapMode::Write, move |r| { + flag.store(r.is_ok(), std::sync::atomic::Ordering::SeqCst); + }); + device.poll(wgpu::PollType::wait_indefinitely()).ok(); + if !mapped.load(std::sync::atomic::Ordering::SeqCst) { + return false; + } + { + let mut view = slice.get_mapped_range_mut(); + view.slice(0..1).copy_from_slice(&[0xABu8]); + } + staging.unmap(); + true +} + +/// Render pipeline creation + offscreen render + readback. +/// +/// The fragment shader deliberately reads a `private array, 8>`, +/// the same pattern iced's gradient pipeline compiles: old GLES drivers +/// (Mesa on legacy Intel iGPUs) reject naga's `vec4[8]` GLSL with `no +/// default precision`, so pipeline creation fails there — exactly what this +/// probe must catch before the editor boots. +fn pipeline_render_readback(device: &iced::wgpu::Device, queue: &iced::wgpu::Queue) -> bool { + use iced::wgpu; + const SHADER: &str = r#" +struct VsOut { + @builtin(position) pos: vec4, + @location(0) colour: vec4, +}; +var palette: array, 8>; +@vertex +fn vs(@builtin(vertex_index) idx: u32) -> VsOut { + var out: VsOut; + let corners = array, 3>( + vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); + out.pos = vec4(corners[idx], 0.0, 1.0); + out.colour = palette[idx % 8u]; + return out; +} +@fragment +fn fs(in: VsOut) -> @location(0) vec4 { + return in.colour + palette[0]; +} +"#; + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("ocs-gpu-probe-shader"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(SHADER)), + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("ocs-gpu-probe-pipeline"), + layout: None, + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs"), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Bgra8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + multiview_mask: None, + cache: None, + }); + // 64px offscreen render. 64*4 = 256 bytes per row satisfies + // COPY_BYTES_PER_ROW_ALIGNMENT. + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("ocs-gpu-probe-target"), + size: wgpu::Extent3d { + width: 64, + height: 64, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("ocs-gpu-probe-encoder"), + }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("ocs-gpu-probe-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_pipeline(&pipeline); + pass.draw(0..3, 0..1); + } + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("ocs-gpu-probe-readback"), + size: 256 * 64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(64 * 4), + rows_per_image: Some(64), + }, + }, + wgpu::Extent3d { + width: 64, + height: 64, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + let slice = readback.slice(..); + let ok = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = ok.clone(); + slice.map_async(wgpu::MapMode::Read, move |r| { + flag.store(r.is_ok(), std::sync::atomic::Ordering::SeqCst); + }); + device.poll(wgpu::PollType::wait_indefinitely()).ok(); + if !ok.load(std::sync::atomic::Ordering::SeqCst) { + return false; + } + let _ = slice.get_mapped_range(); + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cands(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + fn ok_caps(backend: &str, storage: u32) -> ProbeCaps { + ProbeCaps { + backend: backend.to_string(), + adapter: format!("test {backend}"), + max_storage: storage, + software: false, + } + } + + #[test] + fn explicit_backend_and_env_beat_everything() { + let d = decide_backend( + Some("vulkan"), + None, + true, + &cands(&["dx12", "vulkan", "gl"]), + &cands(&["dx12"]), + &[], + None, + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("vulkan")); + assert!(d.reason.is_none()); + let d = decide_backend(None, Some("gl"), true, &[], &[], &[], None, true); + assert_eq!(d.backend_value.as_deref(), Some("gl")); + assert!(d.reason.is_none()); + } + + #[test] + fn safe_mode_forces_gl() { + let d = decide_backend( + None, + None, + true, + &cands(&["vulkan", "gl"]), + &[], + &[], + None, + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("gl")); + assert!(d.reason.is_some()); + } + + #[test] + fn first_passing_backend_wins_silently() { + let results = vec![("vulkan".to_string(), Some(ok_caps("vulkan", 8)))]; + let d = decide_backend( + None, + None, + false, + &cands(&["vulkan", "gl"]), + &[], + &results, + None, + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("vulkan")); + assert!(d.reason.is_none()); + assert!(!d.compat_renderer); + assert_eq!(d.adapter_name.as_deref(), Some("test vulkan")); + } + + #[test] + fn failover_picks_next_backend_with_reason() { + let results = vec![ + ("dx12".to_string(), None), + ("vulkan".to_string(), Some(ok_caps("vulkan", 8))), + ]; + let d = decide_backend( + None, + None, + false, + &cands(&["dx12", "vulkan", "gl"]), + &[], + &results, + None, + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("vulkan")); + let reason = d.reason.unwrap(); + assert!(reason.contains("dx12"), "{reason}"); + let notice = fallback_notice(&reason); + assert!(notice.contains("--backend"), "{notice}"); + } + + #[test] + fn crash_implicated_backends_are_skipped() { + // dx12 died last run: even a passing dx12 probe must not win. + let results = vec![ + ("dx12".to_string(), Some(ok_caps("dx12", 8))), + ("gl".to_string(), Some(ok_caps("gl", 0))), + ]; + let d = decide_backend( + None, + None, + false, + &cands(&["dx12", "vulkan", "gl"]), + &cands(&["dx12"]), + &results, + None, + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("gl")); + assert!(d.reason.is_some()); + // No storage buffers on the GL probe: packed renderer auto-enabled. + assert!(d.compat_renderer); + } + + #[test] + fn software_adapter_is_last_resort_before_gl() { + let results = vec![("vulkan".to_string(), None), ("gl".to_string(), None)]; + let sw = ProbeCaps { + backend: "vulkan".to_string(), + adapter: "llvmpipe".to_string(), + max_storage: 8, + software: true, + }; + let d = decide_backend( + None, + None, + false, + &cands(&["vulkan", "gl"]), + &[], + &results, + Some(sw), + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("vulkan")); + assert!(d.compat_renderer); + assert!(d.reason.unwrap().contains("software"),); + } + + #[test] + fn total_failure_falls_back_to_gl_with_compat() { + let d = decide_backend( + None, + None, + false, + &cands(&["vulkan", "gl"]), + &[], + &[("vulkan".to_string(), None), ("gl".to_string(), None)], + None, + true, + ); + assert_eq!(d.backend_value.as_deref(), Some("gl")); + assert!(d.compat_renderer); + assert!(d.reason.is_some()); + } + + #[test] + fn inactive_guard_leaves_the_env_alone() { + let results = vec![("vulkan".to_string(), None)]; + let d = decide_backend(None, None, false, &[], &[], &results, None, false); + assert!(d.backend_value.is_none()); + } + + #[test] + fn probe_caps_json_round_trip() { + let caps = ProbeCaps { + backend: "vulkan".to_string(), + adapter: "Intel HD 405 (Braswell)".to_string(), + max_storage: 8, + software: false, + }; + let line = caps.to_json_line(); + assert_eq!(ProbeCaps::from_json_line(&line), Some(caps)); + assert!(ProbeCaps::from_json_line("not json").is_none()); + assert!(ProbeCaps::from_json_line("{\"backend\":\"gl\"}").is_none()); + } + + #[test] + fn sentinel_round_trip_records_backends() { + let dir = std::env::temp_dir().join(format!("ocs-gpu-test-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + // SAFETY: single-threaded test mutating a process-local test hook. + unsafe { std::env::set_var(SENTINEL_DIR_ENV, &dir) }; + assert!(!previous_run_crashed()); + arm_sentinel("dx12"); + assert!(previous_run_crashed()); + assert_eq!(crashed_backends(), vec!["dx12".to_string()]); + disarm_sentinel(); + assert!(!previous_run_crashed()); + assert!(crashed_backends().is_empty()); + unsafe { std::env::remove_var(SENTINEL_DIR_ENV) }; + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn force_hooks_drive_probes_without_spawning() { + // SAFETY: single-threaded test mutating process-local test hooks. + unsafe { std::env::set_var(FORCE_FAIL_ENV, "1") }; + assert!(run_probe_for("vulkan").is_none()); + unsafe { std::env::remove_var(FORCE_FAIL_ENV) }; + unsafe { std::env::set_var(FORCE_OK_ENV, "1") }; + let caps = run_probe_for("vulkan").expect("forced success"); + assert_eq!(caps.backend, "vulkan"); + assert!(!needs_compat_renderer(&caps)); + unsafe { std::env::set_var(FORCE_CAPS_ENV, "0") }; + let bare = run_probe_for("gl").expect("forced success"); + assert!(needs_compat_renderer(&bare)); + unsafe { std::env::remove_var(FORCE_OK_ENV) }; + unsafe { std::env::remove_var(FORCE_CAPS_ENV) }; + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs new file mode 100644 index 000000000..c1131bdeb --- /dev/null +++ b/src/input/mod.rs @@ -0,0 +1,2 @@ +//! Device integrations. Camera ownership and CAD commands stay in the app. +pub(crate) mod spacemouse; diff --git a/src/input/spacemouse/mod.rs b/src/input/spacemouse/mod.rs new file mode 100644 index 000000000..16a7725eb --- /dev/null +++ b/src/input/spacemouse/mod.rs @@ -0,0 +1,593 @@ +//! SpaceMouse navigation data and the thread-safe bridge to the platform driver. +//! +//! No SDK types escape this module. Driver callbacks operate on a snapshot; +//! only the app thread applies a proposed view to a drawing. + +// Snapshot/ABI support is intentionally dormant until another platform has an adapter. +#![cfg_attr(not(windows), allow(dead_code))] + +use glam::{DMat4, DQuat, DVec3}; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::hash::{Hash, Hasher}; +use std::sync::{Arc, Mutex}; + +#[cfg(windows)] +mod navlib; +mod pan; +#[cfg(windows)] +mod raw_input; +#[cfg(windows)] +mod worker; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum NavigationMode { + #[default] + Auto, + PanOnly, + PanZoom, + Full3D, +} + +impl NavigationMode { + pub const ALL: [Self; 4] = [Self::Auto, Self::PanOnly, Self::PanZoom, Self::Full3D]; + pub fn label(self) -> &'static str { + match self { + Self::Auto => "Follow context", + Self::PanOnly => "Pan only", + Self::PanZoom => "Pan and zoom", + Self::Full3D => "3D navigation", + } + } + pub fn description(self) -> &'static str { + match self { + Self::Auto => "Pan and zoom on paper sheets; full navigation in model views.", + Self::PanOnly => { + "Push or tilt to pan. Twist and vertical pressure are ignored; zoom and rotation stay fixed." + } + Self::PanZoom => { + "Push or tilt to pan; lift or press to zoom. Twist is ignored and rotation stays fixed." + } + Self::Full3D => "Pan, zoom, and orbit model views. Paper sheets keep rotation locked.", + } + } + pub fn allows_rotation(self, sheet: bool) -> bool { + !sheet && matches!(self, Self::Auto | Self::Full3D) + } + pub fn allows_zoom(self) -> bool { + self != Self::PanOnly + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct Preferences { + pub enabled: bool, + pub mode: NavigationMode, + pub pan_speed: u16, + pub pan_reversed: bool, +} +impl Default for Preferences { + fn default() -> Self { + Self { + enabled: true, + mode: NavigationMode::Auto, + pan_speed: 100, + pan_reversed: false, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Status { + Starting, + Ready, + Disconnected, + Unavailable(String), + Unsupported, +} +impl Status { + pub fn label(&self) -> &str { + match self { + Self::Starting => "Connecting…", + Self::Ready => "Ready", + Self::Disconnected => "No SpaceMouse connected", + Self::Unavailable(_) => "3DxWare unavailable", + Self::Unsupported => "Available in the Windows desktop app", + } + } +} + +/// Stable target plus camera revision. Late callbacks must never move another +/// document, pane, layout, or a camera already changed by another input source. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Target { + pub tab: u64, + pub layout: String, + pub viewport: Option, + pub tile: usize, + pub generation: u64, +} +impl Target { + pub fn same_context(&self, other: &Self) -> bool { + self.tab == other.tab + && self.layout == other.layout + && self.viewport == other.viewport + && self.tile == other.tile + } +} + +#[derive(Clone, Debug)] +pub struct View { + pub target: Target, + pub eye: DVec3, + pub rotation: DQuat, + pub distance: f64, + pub fov: f64, + pub perspective: bool, + pub aspect: f64, + pub model_bounds: [f64; 6], + pub selection_bounds: Option<[f64; 6]>, + pub pointer: DVec3, + pub pivot: DVec3, + pub pivot_visible: bool, + pub rotate: bool, + pub zoom: bool, + pub motion_enabled: bool, + pub units_to_meters: f64, + pub pan_speed: f64, +} +impl View { + fn planar(&self) -> bool { + !self.rotate + } + pub fn affine(&self) -> [f64; 16] { + DMat4::from_rotation_translation(self.rotation, self.eye).to_cols_array() + } + pub fn half_height(&self) -> f64 { + self.distance * (self.fov * 0.5).tan() + } + pub fn extents(&self) -> [f64; 6] { + let h = self.half_height(); + let w = h * self.aspect; + [ + -w, + -h, + -self.distance * 1000.0, + w, + h, + self.distance * 1000.0, + ] + } + /// Validate before accepting driver data. A pan-only view projects the + /// translation onto its original screen plane, including in perspective. + pub fn set_affine(&mut self, values: [f64; 16]) -> bool { + if !values.iter().all(|v| v.is_finite()) { + return false; + } + let matrix = DMat4::from_cols_array(&values); + let x = matrix.x_axis.truncate(); + let y = matrix.y_axis.truncate(); + let z = matrix.z_axis.truncate(); + if (x.length_squared() - 1.0).abs() > 0.01 + || (y.length_squared() - 1.0).abs() > 0.01 + || (z.length_squared() - 1.0).abs() > 0.01 + || x.dot(y).abs() > 0.01 + || x.cross(y).dot(z) < 0.99 + || matrix.w_axis.w != 1.0 + || matrix.x_axis.w != 0.0 + || matrix.y_axis.w != 0.0 + || matrix.z_axis.w != 0.0 + { + return false; + } + let proposed_eye = matrix.w_axis.truncate(); + if self.zoom { + self.eye = proposed_eye; + } else { + let normal = self.rotation * DVec3::Z; + let delta = proposed_eye - self.eye; + self.eye += delta - normal * delta.dot(normal); + } + if self.rotate { + self.rotation = DQuat::from_mat4(&matrix).normalize(); + } + true + } + pub fn set_extents(&mut self, extents: [f64; 6]) -> bool { + if !extents.iter().all(|v| v.is_finite()) { + return false; + } + let distance = (extents[4] - extents[1]) * 0.5 / (self.fov * 0.5).tan(); + if !(1e-6..=1e15).contains(&distance) { + return false; + } + if self.zoom { + // Orthographic zoom changes scale while retaining the view center. + self.eye += self.rotation * DVec3::Z * (distance - self.distance); + self.distance = distance; + } + true + } + + /// Honor the driver's Lock Horizon option in our Z-up world. Keep the + /// proposed eye and viewing direction away from the poles. A pitch through + /// a pole must stop there: rebuilding world-up on the far side would reverse + /// screen-right and turn the view through 180 degrees. + fn lock_horizon(&mut self, previous: DQuat, orbit_about_pivot: bool) { + if !self.rotate { + return; + } + // Leave enough room for the app camera's f32 quaternion round-trip. + const POLE_MARGIN: f64 = 1e-4; + let proposed_normal = self.rotation * DVec3::Z; + let mut normal = proposed_normal; + let previous_right = (previous * DVec3::X) + .truncate() + .extend(0.) + .try_normalize() + .unwrap_or(DVec3::X); + let right = DVec3::Z.cross(normal); + let crossed_pole = + right.dot(previous_right) < 0. && (self.rotation * DVec3::X).dot(previous_right) > 0.; + let right = if crossed_pole || right.length_squared() < POLE_MARGIN.powi(2) { + let horizontal_normal = previous_right.cross(DVec3::Z); + normal = horizontal_normal * POLE_MARGIN + + DVec3::Z * normal.z.signum() * (1. - POLE_MARGIN.powi(2)).sqrt(); + // Apply the rejected pitch to the eye around the SDK's orbit pivot + // too. Correcting orientation alone lets a held orbit carry the eye + // over the pole and shifts the object across the screen. + if orbit_about_pivot { + let correction = DQuat::from_rotation_arc(proposed_normal, normal); + self.eye = self.pivot + correction * (self.eye - self.pivot); + } + previous_right + } else { + right.normalize() + }; + self.rotation = + DQuat::from_mat3(&glam::DMat3::from_cols(right, normal.cross(right), normal)) + .normalize(); + } +} + +#[derive(Clone, Debug)] +pub struct Action { + pub id: String, + pub label: String, + pub description: String, + pub icon: Option<&'static [u8]>, +} + +#[derive(Default)] +struct Bridge { + view: Option, + pending_view: Option, + actions: Vec, + commands: VecDeque<(Target, String)>, + focus: bool, + moving: bool, + in_transaction: bool, + frame_time: Option, + reset_motion: bool, + driver_settings_changed: bool, + lock_horizon: bool, + orbit_about_pivot: bool, + pan: pan::Pan, + status: Option, + ever_connected: bool, + wake: Option>, + #[cfg(windows)] + control: Option, +} +impl Bridge { + fn wake(&mut self) { + if let Some(wake) = &mut self.wake { + let _ = wake.try_send(()); + } + } +} + +#[derive(Clone, Default)] +pub struct Service(Arc>); +impl Hash for Service { + fn hash(&self, state: &mut H) { + Arc::as_ptr(&self.0).hash(state); + } +} +impl Service { + pub fn status(&self) -> Status { + if !cfg!(any(windows, test)) { + return Status::Unsupported; + } + self.0 + .lock() + .unwrap() + .status + .clone() + .unwrap_or(Status::Starting) + } + pub fn visible(&self) -> bool { + self.0.lock().unwrap().ever_connected + } + pub fn moving(&self) -> bool { + let bridge = self.0.lock().unwrap(); + if let Some(view) = bridge.view.as_ref().filter(|v| v.planar()) { + bridge.pan.moving(view.zoom) + } else { + bridge.moving + } + } + pub fn frame(&self, milliseconds: f64) { + let mut bridge = self.0.lock().unwrap(); + if bridge.focus + && bridge + .view + .as_ref() + .is_some_and(|v| v.planar() && v.motion_enabled) + { + let zoom = bridge.view.as_ref().unwrap().zoom; + let delta = bridge.pan.frame(milliseconds, zoom); + if delta != DVec3::ZERO { + let view = bridge.view.as_mut().unwrap(); + view.eye -= view.rotation + * delta.truncate().extend(0.) + * view.half_height() + * 2. + * view.pan_speed; + // Lift/pressure owns zoom in both projections. Keep the center + // fixed and leave push/tilt exclusively responsible for pan. + let distance = (view.distance * (delta.z * 2.).exp()).clamp(1e-6, 1e15); + view.eye += view.rotation * DVec3::Z * (distance - view.distance); + view.distance = distance; + bridge.pending_view = bridge.view.clone(); + } + // Also wake after a stale report stops motion, so rendering can idle. + bridge.wake(); + return; + } + if bridge.focus && bridge.moving { + bridge.frame_time = Some(milliseconds); + #[cfg(windows)] + if let Some(wake) = &bridge.control { + wake.signal(); + } + } + } + #[cfg(test)] + pub fn test_connect(&self) { + self.0.lock().unwrap().status = Some(Status::Ready); + } + #[cfg(test)] + pub fn test_view(&self) -> Option { + self.0.lock().unwrap().view.clone() + } + #[cfg(test)] + pub fn test_pan_report(&self, axes: [i16; 6]) { + let report: Vec<_> = std::iter::once(1) + .chain(axes.into_iter().flat_map(i16::to_le_bytes)) + .collect(); + self.0.lock().unwrap().pan.report(&report); + } + #[cfg(test)] + pub fn test_propose(&self, change: impl FnOnce(&mut View)) { + let mut bridge = self.0.lock().unwrap(); + let mut view = bridge.view.clone().expect("focused drawing snapshot"); + change(&mut view); + bridge.pending_view = Some(view); + bridge.moving = true; + } + pub fn set_actions(&self, actions: Vec) { + self.0.lock().unwrap().actions = actions; + } + /// Rebase callbacks that arrived while the UI applied the previous frame. + /// They still belong to the same gesture, despite the scene revision bump. + pub fn acknowledge(&self, previous: &Target, applied: &Target) { + let mut bridge = self.0.lock().unwrap(); + if previous.same_context(applied) { + let Bridge { + view, pending_view, .. + } = &mut *bridge; + for view in [view, pending_view].into_iter().flatten() { + if &view.target == previous { + view.target = applied.clone(); + } + } + } + } + pub fn sync(&self, view: Option, focus: bool) { + let mut bridge = self.0.lock().unwrap(); + let context_changed = match (&bridge.view, &view) { + (Some(old), Some(new)) => !old.target.same_context(&new.target), + (None, None) => false, + _ => true, + }; + let changed = bridge.view.as_ref().map(|v| &v.target) != view.as_ref().map(|v| &v.target) + || bridge.focus != focus + || bridge + .view + .as_ref() + .map(|v| (v.rotate, v.zoom, v.motion_enabled)) + != view.as_ref().map(|v| (v.rotate, v.zoom, v.motion_enabled)); + if changed { + bridge.reset_motion = true; + bridge.frame_time = None; + bridge.pan.clear(); + bridge.pending_view = None; + if context_changed || bridge.focus != focus { + bridge.commands.clear(); + bridge.moving = false; + } + bridge.in_transaction = false; + bridge.view = view; + } else if bridge.pending_view.is_none() { + bridge.view = view; + } + bridge.focus = focus; + if changed { + #[cfg(windows)] + if let Some(wake) = &bridge.control { + wake.signal(); + } + } + } + pub fn drain(&self) -> (Option, Vec<(Target, String)>) { + let mut bridge = self.0.lock().unwrap(); + let view = if bridge.in_transaction { + None + } else { + bridge.pending_view.take() + }; + (view, bridge.commands.drain(..).collect()) + } + pub fn subscription(&self) -> iced::Subscription<()> { + #[cfg(windows)] + { + iced::Subscription::run_with(self.clone(), navlib::subscription) + } + #[cfg(not(windows))] + { + iced::Subscription::none() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + pub(super) fn view() -> View { + View { + target: Target { + tab: 1, + layout: "Model".into(), + viewport: None, + tile: 0, + generation: 0, + }, + eye: DVec3::new(0., 0., 10.), + rotation: DQuat::IDENTITY, + distance: 10., + fov: 1., + perspective: false, + aspect: 1., + model_bounds: [-1., -1., -1., 1., 1., 1.], + selection_bounds: None, + pointer: DVec3::ZERO, + pivot: DVec3::ZERO, + pivot_visible: false, + rotate: false, + zoom: false, + motion_enabled: true, + units_to_meters: 1., + pan_speed: 1., + } + } + #[test] + fn pan_only_preserves_depth_rotation_and_zoom() { + let mut v = view(); + v.rotation = DQuat::from_rotation_x(0.7); + let initial = v.clone(); + let normal = initial.rotation * DVec3::Z; + let proposed = DMat4::from_rotation_translation( + DQuat::from_rotation_y(0.4), + v.eye + DVec3::new(4., 5., 6.), + ); + assert!(v.set_affine(proposed.to_cols_array())); + assert!((v.eye - initial.eye).dot(normal).abs() < 1e-10); + assert_eq!(v.rotation, initial.rotation); + assert!(v.set_extents([-20., -20., -20., 20., 20., 20.])); + assert_eq!(v.distance, initial.distance); + } + #[test] + fn invalid_driver_matrices_do_not_corrupt_the_camera() { + let mut v = view(); + let eye = v.eye; + assert!(!v.set_affine([f64::NAN; 16])); + assert!(!v.set_affine([0.; 16])); + assert!(!v.set_extents([0.; 6])); + assert_eq!(v.eye, eye); + } + #[test] + fn context_and_focus_changes_discard_queued_input() { + let service = Service::default(); + let v = view(); + service.sync(Some(v.clone()), true); + service.0.lock().unwrap().pending_view = Some(v.clone()); + service.sync(Some(v.clone()), false); + assert!(service.drain().0.is_none()); + service.0.lock().unwrap().pending_view = Some(v.clone()); + let mut other = v; + other.target.tab = 2; + service.sync(Some(other), true); + assert!(service.drain().0.is_none()); + } + #[test] + fn paper_cannot_rotate_in_any_mode() { + for mode in NavigationMode::ALL { + assert!(!mode.allows_rotation(true)); + } + assert!(NavigationMode::Auto.allows_rotation(false)); + assert!(!NavigationMode::PanOnly.allows_rotation(false)); + } + #[test] + fn navigation_frames_are_drained_atomically() { + let service = Service::default(); + service.sync(Some(view()), true); + service.test_propose(|v| v.eye.x += 2.); + service.0.lock().unwrap().in_transaction = true; + assert!(service.drain().0.is_none()); + service.0.lock().unwrap().in_transaction = false; + assert_eq!(service.drain().0.unwrap().eye.x, 2.); + assert!(service.drain().0.is_none()); + } + #[test] + fn camera_updates_retain_buttons_but_document_changes_drop_them() { + let service = Service::default(); + let mut v = view(); + service.sync(Some(v.clone()), true); + service + .0 + .lock() + .unwrap() + .commands + .push_back((v.target.clone(), "UNDO".into())); + v.target.generation += 1; + service.sync(Some(v.clone()), true); + assert_eq!(service.drain().1.len(), 1); + service + .0 + .lock() + .unwrap() + .commands + .push_back((v.target.clone(), "UNDO".into())); + v.target.viewport = Some(123); + service.sync(Some(v), true); + assert!(service.drain().1.is_empty()); + } + #[test] + fn applying_a_frame_retains_newer_callbacks_in_the_same_gesture() { + let service = Service::default(); + let mut applied = view(); + service.sync(Some(applied.clone()), true); + service.0.lock().unwrap().reset_motion = false; + service.test_propose(|v| v.eye.x = 1.); + let first = service.drain().0.unwrap(); + service.test_propose(|v| v.eye.x = 2.); + service.0.lock().unwrap().in_transaction = true; + applied.eye = first.eye; + applied.target.generation += 1; + service.acknowledge(&first.target, &applied.target); + service.sync(Some(applied.clone()), true); + assert!(service.drain().0.is_none()); + assert!(!service.0.lock().unwrap().reset_motion); + service.0.lock().unwrap().in_transaction = false; + let second = service.drain().0.unwrap(); + assert_eq!(second.eye.x, 2.); + assert_eq!(second.target, applied.target); + // A subsequent wheel/mouse camera revision must reset the SDK model. + applied.target.generation += 1; + service.sync(Some(applied), true); + assert!(service.0.lock().unwrap().reset_motion); + } +} diff --git a/src/input/spacemouse/navlib.rs b/src/input/spacemouse/navlib.rs new file mode 100644 index 000000000..2f57623db --- /dev/null +++ b/src/input/spacemouse/navlib.rs @@ -0,0 +1,1121 @@ +//! Windows NavLib adapter. Loads the user's installed 3DxWare runtime; neither +//! SDK binaries nor SDK source files are bundled with Open CAD Studio. +//! +//! ABI reference: 3DxWare SDK v4 navlib.h, navlib_types.h, siappcmd_types.h. +//! All callbacks own only a mutex-protected snapshot. NlClose runs before their +//! storage is released. No mutex is held while calling into the SDK. + +use super::*; +use iced::futures::{channel::mpsc::Sender, Stream}; +use std::ffi::{c_char, c_long, CStr, CString}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +type Get = unsafe extern "C" fn(u64, *const c_char, *mut Value) -> c_long; +type Set = unsafe extern "C" fn(u64, *const c_char, *const Value) -> c_long; +#[repr(C)] +struct Accessor { + name: *const c_char, + get: Option, + set: Option, + param: u64, +} +#[repr(C)] +struct Options { + size: u32, + multithreaded: u8, + flags: u32, +} +#[repr(C)] +#[derive(Clone, Copy)] +struct Array { + data: *const u8, + len: usize, +} +#[repr(C)] +#[derive(Clone, Copy)] +union Data { + boolean: u32, + long: c_long, + number: f64, + point: [f64; 3], + bounds: [f64; 6], + matrix: [f64; 16], + array: Array, + ptr: *const Node, +} +#[repr(C)] +#[derive(Clone, Copy)] +struct Value { + kind: i32, + data: Data, +} +impl Value { + fn boolean(v: bool) -> Self { + Self { + kind: 1, + data: Data { boolean: v.into() }, + } + } + fn number(v: f64) -> Self { + Self { + kind: 4, + data: Data { number: v }, + } + } + fn values(kind: i32, v: &[f64]) -> Self { + let mut matrix = [0.; 16]; + matrix[..v.len()].copy_from_slice(v); + Self { + kind, + data: Data { matrix }, + } + } + fn string(v: &CStr) -> Self { + Self { + kind: 13, + data: Data { + array: Array { + data: v.as_ptr().cast(), + len: v.to_bytes().len(), + }, + }, + } + } +} + +type Create = + unsafe extern "C" fn(*mut u64, *const c_char, *const Accessor, usize, *const Options) -> c_long; +type Close = unsafe extern "C" fn(u64) -> c_long; +type Read = unsafe extern "C" fn(u64, *const c_char, *mut Value) -> c_long; +type Write = unsafe extern "C" fn(u64, *const c_char, *const Value) -> c_long; +struct Api { + _library: libloading::Library, + create: Create, + close: Close, + read: Read, + write: Write, +} +impl Api { + fn load() -> Result { + // Search only the Windows system directory, never a drawing's folder. + let library: libloading::Library = unsafe { + libloading::os::windows::Library::load_with_flags("TDxNavLib.dll", 0x00000800) + } + .map_err(|_| "Install or start 3DxWare, then reconnect your SpaceMouse.".to_owned())? + .into(); + unsafe { + Ok(Self { + create: *library.get(b"NlCreate\0").map_err(|e| e.to_string())?, + close: *library.get(b"NlClose\0").map_err(|e| e.to_string())?, + read: *library.get(b"NlReadValue\0").map_err(|e| e.to_string())?, + write: *library.get(b"NlWriteValue\0").map_err(|e| e.to_string())?, + _library: library, + }) + } + } + fn write(&self, handle: u64, name: &CStr, value: Value) -> c_long { + unsafe { (self.write)(handle, name.as_ptr(), &value) } + } +} + +const NO_DATA: c_long = 0x80040078_u32 as c_long; +const INVALID: c_long = 0x80040016_u32 as c_long; + +unsafe extern "C" fn get(param: u64, name: *const c_char, out: *mut Value) -> c_long { + if name.is_null() || out.is_null() { + return INVALID; + } + let name = unsafe { CStr::from_ptr(name) }.to_bytes(); + // NavLib asks for the coordinate system during NlCreate, before any + // document snapshot or window-focus event is available. + let constant = match name { + b"coordinateSystem" => Some(DMat4::from_rotation_x(-std::f64::consts::FRAC_PI_2)), + b"views.front" => Some(DMat4::from_rotation_x(std::f64::consts::FRAC_PI_2)), + _ => None, + }; + if let Some(matrix) = constant { + unsafe { + *out = Value::values(7, &matrix.to_cols_array()); + } + return 0; + } + // This Arc's allocation is retained by Connection until NlClose completes. + let shared = unsafe { &*(param as *const Mutex) }; + let Ok(bridge) = shared.lock() else { + return NO_DATA; + }; + let Some(v) = &bridge.view else { + return NO_DATA; + }; + let value = match name { + b"view.affine" => Value::values(7, &v.affine()), + b"view.extents" => Value::values(11, &v.extents()), + b"view.perspective" => Value::boolean(v.perspective), + b"view.rotatable" => Value::boolean(v.rotate), + b"view.fov" => Value::number(v.fov), + b"view.focusDistance" => Value::number(v.distance), + // This SDK property enables its separate target-camera model. OCS's + // camera.target is only a representation of a free camera's center; + // advertising it here prevents normal continuous camera navigation. + // https://forum.3dconnexion.com/viewtopic.php?t=53430 + b"view.target" => return NO_DATA, + b"view.frustum" => { + let near = v.distance * 0.001; + let h = near * (v.fov * 0.5).tan(); + Value::values( + 12, + &[-h * v.aspect, h * v.aspect, -h, h, near, v.distance * 1000.], + ) + } + b"model.extents" => Value::values(11, &v.model_bounds), + b"model.unitsToMeters" => Value::number(v.units_to_meters), + b"selection.empty" => Value::boolean(v.selection_bounds.is_none()), + b"selection.extents" => match v.selection_bounds { + Some(b) => Value::values(11, &b), + None => return NO_DATA, + }, + b"pivot.position" => Value::values(5, &v.pivot.to_array()), + b"pivot.user" => Value::boolean(v.selection_bounds.is_some()), + b"pivot.visible" => Value::boolean(v.pivot_visible), + b"pointer.position" => Value::values(5, &v.pointer.to_array()), + _ => return NO_DATA, + }; + unsafe { + *out = value; + } + 0 +} + +unsafe extern "C" fn set(param: u64, name: *const c_char, value: *const Value) -> c_long { + if name.is_null() || value.is_null() { + return INVALID; + } + let shared = unsafe { &*(param as *const Mutex) }; + let Ok(mut bridge) = shared.lock() else { + return NO_DATA; + }; + let name = unsafe { CStr::from_ptr(name) }.to_bytes(); + let value = unsafe { &*value }; + // Profile changes can arrive while the settings window owns focus and + // there is no drawing snapshot. Query the SDK later, outside this mutex. + if name == b"settings.changed" && value.kind == 2 { + bridge.driver_settings_changed = true; + log::debug!("SpaceMouse: driver settings revision {}", unsafe { + value.data.long + }); + if let Some(wake) = &bridge.control { + wake.signal(); + } + return 0; + } + if name == b"motion" && value.kind == 1 { + if bridge.view.as_ref().is_some_and(View::planar) { + return 0; + } + bridge.moving = unsafe { value.data.boolean } != 0; + bridge.wake(); + return 0; + } + if name == b"transaction" && value.kind == 2 { + if bridge.view.as_ref().is_some_and(View::planar) { + return 0; + } + bridge.in_transaction = unsafe { value.data.long } != 0; + if !bridge.in_transaction { + bridge.wake(); + } + return 0; + } + if !bridge.focus { + return NO_DATA; + } + if name == b"commands.activeCommand" { + if !matches!(value.kind, 8 | 13) { + return INVALID; + } + let array = unsafe { value.data.array }; + if array.data.is_null() || array.len > 1024 { + return INVALID; + } + let bytes = unsafe { std::slice::from_raw_parts(array.data, array.len) }; + let Ok(id) = std::str::from_utf8(bytes) else { + return INVALID; + }; + let id = id.trim_end_matches('\0'); + if bridge.commands.len() < 32 && bridge.actions.iter().any(|a| a.id == id) { + if let Some(target) = bridge.view.as_ref().map(|v| v.target.clone()) { + bridge.commands.push_back((target, id.to_owned())); + bridge.wake(); + } + } + return 0; + } + let lock_horizon = bridge.lock_horizon; + let orbit_about_pivot = bridge.orbit_about_pivot; + let Some(view) = &mut bridge.view else { + return NO_DATA; + }; + if !view.motion_enabled { + return NO_DATA; + } + // Planar navigation combines physical pushes and tilts before NavLib turns them + // into orbit/zoom camera changes. Buttons still use the SDK connection. + if view.planar() { + return 0; + } + let accepted = match (name, value.kind) { + (b"view.affine", 7) => { + let previous = view.rotation; + let accepted = view.set_affine(unsafe { value.data.matrix }); + if accepted && lock_horizon { + let proposed = view.rotation; + view.lock_horizon(previous, orbit_about_pivot); + if log::log_enabled!(log::Level::Trace) && (proposed * DVec3::Z).z.abs() > 0.99 { + log::trace!( + "SpaceMouse: pole previous={previous:?} driver={proposed:?} accepted={:?}", + view.rotation + ); + } + } + accepted + } + (b"view.extents", 11) => view.set_extents(unsafe { value.data.bounds }), + (b"pivot.position", 5) => { + let p = unsafe { value.data.point }; + let point = DVec3::new(p[0], p[1], p[2]); + if !point.is_finite() { + false + } else { + view.pivot = point; + true + } + } + (b"pivot.visible", 1) => { + view.pivot_visible = unsafe { value.data.boolean } != 0; + true + } + _ => return NO_DATA, + }; + if !accepted { + return INVALID; + } + bridge.pending_view = bridge.view.clone(); + if !bridge.in_transaction { + bridge.wake(); + } + 0 +} + +#[repr(C)] +struct Node { + size: u32, + kind: i32, + next: *const Node, + children: *const Node, + id: *const c_char, + label: *const c_char, + description: *const c_char, +} +#[repr(C)] +#[derive(Clone, Copy)] +struct ImageData { + data: *const u8, + size: usize, + index: u32, +} +#[repr(C)] +union ImageUnion { + image: ImageData, + padding: [usize; 4], +} +#[repr(C)] +struct Image { + size: u32, + kind: i32, + id: *const c_char, + data: ImageUnion, +} + +/// Heap storage survives the entire connection, including deferred SDK reads. +struct Commands { + _strings: Vec, + nodes: Box<[Node]>, + _pngs: Vec>, + images: Vec, +} +impl Commands { + fn new(actions: &[Action]) -> Self { + let mut strings = vec![ + CString::new("Default").unwrap(), + CString::new("Open CAD Studio").unwrap(), + CString::new("").unwrap(), + ]; + let mut nodes = vec![Node { + size: std::mem::size_of::() as u32, + kind: 0, + next: std::ptr::null(), + children: std::ptr::null(), + id: strings[0].as_ptr(), + label: strings[1].as_ptr(), + description: strings[2].as_ptr(), + }]; + let mut pngs = Vec::new(); + let mut images = Vec::new(); + for action in actions { + let start = strings.len(); + for text in [&action.id, &action.label, &action.description] { + strings.push(CString::new(text.replace('\0', "")).unwrap()); + } + nodes.push(Node { + size: std::mem::size_of::() as u32, + kind: 2, + next: std::ptr::null(), + children: std::ptr::null(), + id: strings[start].as_ptr(), + label: strings[start + 1].as_ptr(), + description: strings[start + 2].as_ptr(), + }); + if let Some(svg) = action.icon { + if let Some(png) = icon_png(svg) { + pngs.push(png); + let png = pngs.last().unwrap(); + images.push(Image { + size: std::mem::size_of::() as u32, + kind: 3, + id: strings[start].as_ptr(), + data: ImageUnion { + image: ImageData { + data: png.as_ptr(), + size: png.len(), + index: 0, + }, + }, + }); + } + } + } + let mut nodes = nodes.into_boxed_slice(); + for i in 1..nodes.len().saturating_sub(1) { + nodes[i].next = &nodes[i + 1]; + } + if nodes.len() > 1 { + nodes[0].children = &nodes[1]; + } + Self { + _strings: strings, + nodes, + _pngs: pngs, + images, + } + } +} +fn icon_png(svg: &[u8]) -> Option> { + let tree = resvg::usvg::Tree::from_data(svg, &resvg::usvg::Options::default()).ok()?; + let mut pixmap = resvg::tiny_skia::Pixmap::new(32, 32)?; + let scale = 32.0 / tree.size().width().max(tree.size().height()); + resvg::render( + &tree, + resvg::tiny_skia::Transform::from_scale(scale, scale), + &mut pixmap.as_mut(), + ); + pixmap.encode_png().ok() +} + +struct Connection { + api: Api, + handle: u64, + _accessors: Vec, + _commands: Commands, + _shared: Arc>, + policy: Option<(bool, bool, bool, bool)>, + target: Option, +} +impl Connection { + fn open(shared: Arc>) -> Result { + let api = Api::load()?; + let param = Arc::as_ptr(&shared) as u64; + let reads: &[&CStr] = &[ + c"coordinateSystem", + c"view.affine", + c"view.extents", + c"view.perspective", + c"view.rotatable", + c"view.fov", + c"view.focusDistance", + c"view.target", + c"view.frustum", + c"views.front", + c"model.extents", + c"model.unitsToMeters", + c"selection.empty", + c"selection.extents", + c"pivot.position", + c"pivot.user", + c"pivot.visible", + c"pointer.position", + ]; + let writes: &[&CStr] = &[ + c"view.affine", + c"view.extents", + c"pivot.position", + c"pivot.visible", + ]; + let mut accessors: Vec = reads + .iter() + .map(|name| Accessor { + name: name.as_ptr(), + get: Some(get), + set: writes.contains(name).then_some(set as Set), + param, + }) + .collect(); + for name in [ + c"commands.activeCommand", + c"transaction", + c"motion", + c"settings.changed", + ] { + accessors.push(Accessor { + name: name.as_ptr(), + get: None, + set: Some(set), + param, + }); + } + let options = Options { + size: std::mem::size_of::() as u32, + multithreaded: 1, + flags: 4, + }; + let commands = Commands::new(&shared.lock().unwrap().actions); + let mut handle = 0; + let result = unsafe { + (api.create)( + &mut handle, + c"Open CAD Studio".as_ptr(), + accessors.as_ptr(), + accessors.len(), + &options, + ) + }; + if result != 0 { + return Err(format!( + "3DxWare connection failed ({result:#x}). Start or update 3DxWare." + )); + } + let connection = Self { + api, + handle, + _accessors: accessors, + _commands: commands, + _shared: shared, + policy: None, + target: None, + }; + connection.refresh_settings(); + // Iced requests animation frames only while motion is active and + // supplies their monotonic timestamps. A held puck must keep moving + // even when it sends no new axis values. + connection.write_checked( + c"frame.timingSource", + Value { + kind: 2, + data: Data { long: 1 }, + }, + )?; + connection.write_checked( + c"commands.tree", + Value { + kind: 9, + data: Data { + ptr: connection._commands.nodes.as_ptr(), + }, + }, + )?; + connection.write_checked(c"commands.activeSet", Value::string(c"Default"))?; + if !connection._commands.images.is_empty() { + connection.write_checked( + c"images", + Value { + kind: 14, + data: Data { + array: Array { + data: connection._commands.images.as_ptr().cast(), + len: connection._commands.images.len(), + }, + }, + }, + )?; + } + Ok(connection) + } + fn write_checked(&self, name: &CStr, value: Value) -> Result<(), String> { + let code = self.api.write(self.handle, name, value); + if code == 0 { + Ok(()) + } else { + Err(format!( + "3DxWare rejected {} ({code:#x}). Update 3DxWare.", + name.to_string_lossy() + )) + } + } + fn present(&self) -> Result { + let mut value = Value::boolean(false); + let result = + unsafe { (self.api.read)(self.handle, c"device.present".as_ptr(), &mut value) }; + if result == 0 && value.kind == 1 { + Ok(unsafe { value.data.boolean } != 0) + } else { + Err(result) + } + } + fn string_setting(&self, name: &CStr) -> Option { + // settings.* properties use caller-owned UTF-8 string storage. + let mut buffer = [0_u8; 32]; + let mut value = Value { + kind: 8, + data: Data { + array: Array { + data: buffer.as_mut_ptr(), + len: buffer.len(), + }, + }, + }; + let result = unsafe { (self.api.read)(self.handle, name.as_ptr(), &mut value) }; + if result != 0 { + return None; + } + let end = buffer.iter().position(|b| *b == 0).unwrap_or(buffer.len()); + Some(std::str::from_utf8(&buffer[..end]).ok()?.trim().to_owned()) + } + fn bool_setting(&self, name: &CStr) -> Option { + match self.string_setting(name)?.to_ascii_lowercase().as_str() { + "true" | "1" => Some(true), + "false" | "0" => Some(false), + _ => None, + } + } + fn refresh_settings(&self) { + let value = self.bool_setting(c"settings.LockHorizon"); + let model = self.string_setting(c"settings.MotionModel"); + log::debug!("SpaceMouse: driver Lock Horizon = {value:?}, motion model = {model:?}"); + let mut bridge = self._shared.lock().unwrap(); + if let Some(enabled) = value { + bridge.lock_horizon = enabled; + } + if let Some(model) = model { + // Object mode rotates about the SDK pivot; Camera/Helicopter modes + // keep their proposed eye when pitch is constrained. + bridge.orbit_about_pivot = model == "Examine"; + } + } + fn sync(&mut self) { + let (focus, frame_time, reset_motion, settings_changed, view) = { + let mut bridge = self._shared.lock().unwrap(); + ( + bridge.focus, + bridge.frame_time.take(), + std::mem::take(&mut bridge.reset_motion), + std::mem::take(&mut bridge.driver_settings_changed), + bridge.view.clone(), + ) + }; + let policy = ( + focus, + view.as_ref().is_some_and(|v| v.rotate), + view.as_ref().is_some_and(|v| v.zoom), + view.as_ref().is_some_and(|v| v.motion_enabled), + ); + let context_changed = match (&self.target, &view) { + (Some(old), Some(view)) => !old.same_context(&view.target), + (None, None) => false, + _ => true, + }; + if settings_changed || (focus && self.policy.is_none_or(|p| !p.0)) { + self.refresh_settings(); + } + if self.policy != Some(policy) || context_changed || reset_motion { + self.api + .write(self.handle, c"motion", Value::boolean(false)); + // Native 3DxWare selects the foreground process itself. `focus` + // is for clients hosted through NLServer; releasing it when the + // settings UI takes focus can select another application's profile. + self.api + .write(self.handle, c"active", Value::boolean(view.is_some())); + self.api + .write(self.handle, c"view.rotatable", Value::boolean(policy.1)); + self.policy = Some(policy); + } + if let Some(view) = view { + self.target = Some(view.target); + } else { + self.target = None; + } + // Do not echo driver-originated camera changes back through Write: + // those notifications reset NavLib's current navigation model. + if focus { + if let Some(time) = frame_time { + self.api + .write(self.handle, c"frame.time", Value::number(time)); + } + } + } +} +impl Drop for Connection { + fn drop(&mut self) { + self.api + .write(self.handle, c"active", Value::boolean(false)); + unsafe { + (self.api.close)(self.handle); + } + log::debug!("SpaceMouse: navigation connection closed"); + } +} + +struct Stop( + Arc, + super::worker::Wake, + Option>, +); +impl Drop for Stop { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + self.1.signal(); + // NlClose persists the driver's per-application settings. Do not leave + // a detached worker racing process exit after the subscription drops. + if let Some(thread) = self.2.take() { + let _ = thread.join(); + } + } +} + +pub(super) fn subscription(service: &Service) -> impl Stream + use<> { + let shared = service.0.clone(); + iced::stream::channel(1, move |output: Sender<()>| async move { + let wake = match super::worker::Wake::new() { + Ok(wake) => wake, + Err(error) => { + publish_status( + &shared, + Status::Unavailable(format!("SpaceMouse worker: {error}")), + ); + return; + } + }; + let stop = Arc::new(AtomicBool::new(false)); + let mut stop_guard = Stop(stop.clone(), wake.clone(), None); + let _raw_input = super::raw_input::Listener::start(shared.clone()); + { + let mut bridge = shared.lock().unwrap(); + bridge.wake = Some(output); + bridge.control = Some(wake.clone()); + } + stop_guard.2 = Some( + std::thread::Builder::new() + .name("spacemouse".into()) + .spawn(move || { + while !stop.load(Ordering::Acquire) { + match Connection::open(shared.clone()) { + Ok(mut connection) => { + let mut last_check = None; + loop { + if stop.load(Ordering::Acquire) { + break; + } + connection.sync(); + if last_check.is_none_or(|t: std::time::Instant| { + t.elapsed() >= Duration::from_secs(1) + }) { + last_check = Some(std::time::Instant::now()); + let status = match connection.present() { + Ok(true) => match super::raw_input::connected() { + Ok(true) => Status::Ready, + Ok(false) => Status::Disconnected, + // Device enumeration can race hotplug. Keep the SDK's + // answer until the next hardware poll in that case. + Err(_) => Status::Ready, + }, + Ok(false) => Status::Disconnected, + Err(code) => Status::Unavailable(format!( + "3DxWare stopped responding ({code:#x})." + )), + }; + let failed = matches!(status, Status::Unavailable(_)); + publish_status(&shared, status); + if failed { + break; + } + } + wake.wait(Duration::from_secs(1)); + } + } + Err(reason) => publish_status(&shared, Status::Unavailable(reason)), + } + if !stop.load(Ordering::Acquire) { + wake.wait(Duration::from_secs(5)); + } + } + }) + .expect("SpaceMouse worker thread"), + ); + std::future::pending::<()>().await; + }) +} +fn publish_status(shared: &Mutex, status: Status) { + let mut bridge = shared.lock().unwrap(); + bridge.ever_connected |= status == Status::Ready; + if bridge.status.as_ref() != Some(&status) { + if status != Status::Ready { + bridge.pending_view = None; + bridge.commands.clear(); + bridge.moving = false; + bridge.in_transaction = false; + bridge.pan.clear(); + } + bridge.status = Some(status); + bridge.wake(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn sdk_abi_layout() { + assert_eq!(std::mem::size_of::(), 136); + assert_eq!(std::mem::offset_of!(Value, data), 8); + assert_eq!(std::mem::size_of::(), 12); + assert_eq!(std::mem::offset_of!(Options, flags), 8); + if cfg!(target_pointer_width = "64") { + assert_eq!(std::mem::size_of::(), 32); + assert_eq!(std::mem::size_of::(), 48); + assert_eq!(std::mem::size_of::(), 48); + } + } + #[test] + #[ignore = "requires an installed, running 3DxWare driver"] + fn installed_driver_connects_and_reports_device_presence() { + let shared = Arc::new(Mutex::new(Bridge { + actions: vec![Action { + id: "UNDO".into(), + label: "Undo".into(), + description: "Undo last step".into(), + icon: Some(crate::ui::window::options::spacemouse::ICON), + }], + ..Bridge::default() + })); + let connection = Connection::open(shared).unwrap(); + eprintln!( + "3DxWare connected; device present: {}", + connection.present().unwrap() + ); + let horizon = connection.bool_setting(c"settings.LockHorizon"); + eprintln!("3DxWare Lock Horizon: {horizon:?}"); + assert!( + horizon.is_some(), + "read Lock Horizon through the installed SDK" + ); + assert!(connection.string_setting(c"settings.MotionModel").is_some()); + } + #[test] + fn settings_changes_are_received_while_the_driver_dialog_has_focus() { + let service = Service::default(); + let revision = Value { + kind: 2, + data: Data { long: 1 }, + }; + assert_eq!( + unsafe { + set( + Arc::as_ptr(&service.0) as u64, + c"settings.changed".as_ptr(), + &revision, + ) + }, + 0 + ); + assert!(service.0.lock().unwrap().driver_settings_changed); + } + #[test] + fn orbit_respects_the_driver_horizon_setting_and_unlock_restores_roll() { + let service = Service::default(); + let mut view = super::super::tests::view(); + view.rotate = true; + view.zoom = true; + service.sync(Some(view.clone()), true); + let param = Arc::as_ptr(&service.0) as u64; + for locked in [false, true, false] { + service.0.lock().unwrap().lock_horizon = locked; + for pitch in [0.4, 1.1, 0., std::f64::consts::PI] { + let rotation = DQuat::from_rotation_z(0.6) + * DQuat::from_rotation_x(pitch) + * DQuat::from_rotation_z(0.3); + let matrix = DMat4::from_rotation_translation(rotation, view.eye); + assert_eq!( + unsafe { + set( + param, + c"view.affine".as_ptr(), + &Value::values(7, &matrix.to_cols_array()), + ) + }, + 0 + ); + let result = service.drain().0.unwrap(); + assert!(result.rotation.is_finite()); + let at_pole = pitch == 0. || pitch == std::f64::consts::PI; + if locked && at_pole { + assert!((result.rotation * DVec3::Z).abs_diff_eq(rotation * DVec3::Z, 1.1e-4)); + assert!( + (result.eye.distance(view.pivot) - view.eye.distance(view.pivot)).abs() + < 1e-10 + ); + } else { + assert_eq!(result.eye, view.eye); + assert!((result.rotation * DVec3::Z).abs_diff_eq(rotation * DVec3::Z, 1e-10)); + } + if locked { + assert!((result.rotation * DVec3::X).z.abs() < 1e-10); + } else { + assert!(result.rotation.abs_diff_eq(rotation, 1e-10)); + } + } + } + } + #[test] + fn horizon_locked_orbit_stops_at_both_poles_and_reverses_without_reflection() { + for perspective in [false, true] { + for bottom in [false, true] { + for yaw in [0., 0.7, -2.4] { + let service = Service::default(); + let mut view = super::super::tests::view(); + view.rotate = true; + view.zoom = true; + view.perspective = perspective; + view.pivot = DVec3::new(13., -27., 5.); + let pole = if bottom { std::f64::consts::PI } else { 0. }; + let inward = if bottom { -1. } else { 1. }; + let heading = DQuat::from_rotation_z(yaw); + view.rotation = heading * DQuat::from_rotation_x(pole + inward * 0.03); + view.eye = view.pivot + view.rotation * DVec3::Z * view.distance; + service.sync(Some(view.clone()), true); + service.0.lock().unwrap().lock_horizon = true; + service.0.lock().unwrap().orbit_about_pivot = true; + let param = Arc::as_ptr(&service.0) as u64; + // Approach, cross, hold beyond, then reverse away from each + // pole, including the app camera's f32 quaternion precision. + for offset in [0.02, 0.01, 0., -0.01, -0.03, -0.03, -0.03, 0.01, 0.02] { + let proposed = heading * DQuat::from_rotation_x(pole + inward * offset); + let eye = view.pivot + proposed * DVec3::Z * view.distance; + let matrix = DMat4::from_rotation_translation(proposed, eye); + assert_eq!( + unsafe { + set( + param, + c"view.affine".as_ptr(), + &Value::values(7, &matrix.to_cols_array()), + ) + }, + 0 + ); + let mut result = service.drain().0.unwrap(); + result.rotation = result.rotation.as_quat().as_dquat().normalize(); + let right = result.rotation * DVec3::X; + let up = result.rotation * DVec3::Y; + let normal = result.rotation * DVec3::Z; + assert!( + right.dot(heading * DVec3::X) > 0.99999, + "screen-right flipped" + ); + assert!(right.z.abs() < 1e-7, "horizon rolled"); + assert!(up.z >= 0., "camera passed the pole"); + assert!( + (result.eye - normal * result.distance).distance(view.pivot) < 1e-5, + "orbit pivot moved on screen" + ); + if offset > 0. { + assert!( + normal.abs_diff_eq(proposed * DVec3::Z, 1e-7), + "cannot reverse away from pole" + ); + } else { + assert!(normal.z.abs() > 0.9999999, "pitch should stop at the pole"); + } + service.sync(Some(result), true); + } + } + } + } + } + #[test] + fn horizon_locked_camera_mode_preserves_the_eye_at_a_pole() { + let service = Service::default(); + let mut view = super::super::tests::view(); + view.rotate = true; + view.zoom = true; + view.perspective = true; + view.rotation = DQuat::from_rotation_x(0.01); + service.sync(Some(view.clone()), true); + service.0.lock().unwrap().lock_horizon = true; + let param = Arc::as_ptr(&service.0) as u64; + for tilt in [-0.01, -0.02, 0.01] { + let rotation = DQuat::from_rotation_x(tilt); + let eye = view.eye + DVec3::new(1., 2., 3.); + let matrix = DMat4::from_rotation_translation(rotation, eye); + assert_eq!( + unsafe { + set( + param, + c"view.affine".as_ptr(), + &Value::values(7, &matrix.to_cols_array()), + ) + }, + 0 + ); + let result = service.drain().0.unwrap(); + assert_eq!( + result.eye, eye, + "pitch limits must preserve camera-mode translation" + ); + assert!((result.rotation * DVec3::Y).z > 0.); + } + } + #[test] + fn horizon_lock_allows_yaw_around_the_full_circle() { + let service = Service::default(); + let mut view = super::super::tests::view(); + view.rotate = true; + view.zoom = true; + view.rotation = DQuat::from_rotation_x(0.01); + service.sync(Some(view.clone()), true); + service.0.lock().unwrap().lock_horizon = true; + let param = Arc::as_ptr(&service.0) as u64; + // A large yaw change is not a pitch through the pole either. + for yaw in (0..=72).map(|i| i as f64 * 0.1).chain([10., 13.]) { + let rotation = DQuat::from_rotation_z(yaw) * DQuat::from_rotation_x(0.01); + let matrix = DMat4::from_rotation_translation(rotation, view.eye); + assert_eq!( + unsafe { + set( + param, + c"view.affine".as_ptr(), + &Value::values(7, &matrix.to_cols_array()), + ) + }, + 0 + ); + let result = service.drain().0.unwrap(); + assert!(result.rotation.dot(rotation).abs() > 1. - 1e-10); + assert_eq!(result.eye, view.eye); + } + } + #[test] + fn sdk_motion_cannot_double_apply_planar_push_tilt_or_zoom() { + for zoom in [false, true] { + let service = Service::default(); + let mut view = super::super::tests::view(); + view.zoom = zoom; + service.sync(Some(view), true); + let param = Arc::as_ptr(&service.0) as u64; + for (name, value) in [ + (c"motion", Value::boolean(true)), + ( + c"view.affine", + Value::values(7, &DMat4::IDENTITY.to_cols_array()), + ), + ( + c"view.extents", + Value::values(11, &[-2., -2., -2., 2., 2., 2.]), + ), + ] { + assert_eq!(unsafe { set(param, name.as_ptr(), &value) }, 0); + } + assert!(!service.moving()); + assert!(service.drain().0.is_none()); + } + } + #[test] + fn coordinate_system_is_available_before_a_drawing_exists() { + let bridge = Mutex::new(Bridge::default()); + let mut value = Value::boolean(false); + assert_eq!( + unsafe { + get( + &bridge as *const _ as u64, + c"coordinateSystem".as_ptr(), + &mut value, + ) + }, + 0 + ); + let matrix = DMat4::from_cols_array(&unsafe { value.data.matrix }); + assert!((matrix.transform_vector3(DVec3::Z) - DVec3::Y).length() < 1e-10); + } + #[test] + fn free_camera_does_not_advertise_the_sdk_target_camera_model() { + let service = Service::default(); + service.sync(Some(super::super::tests::view()), true); + let mut value = Value::values(5, &[0., 0., 0.]); + assert_eq!( + unsafe { + get( + Arc::as_ptr(&service.0) as u64, + c"view.target".as_ptr(), + &mut value, + ) + }, + NO_DATA + ); + } + #[test] + fn paused_motion_is_rejected_but_resume_action_is_queued() { + let service = Service::default(); + let mut view = super::super::tests::view(); + view.motion_enabled = false; + service.sync(Some(view), true); + service.set_actions(vec![Action { + id: "SPACEMOUSEPAUSE".into(), + label: "Resume".into(), + description: String::new(), + icon: None, + }]); + let param = Arc::as_ptr(&service.0) as u64; + assert_eq!( + unsafe { + set( + param, + c"view.affine".as_ptr(), + &Value::values(7, &DMat4::IDENTITY.to_cols_array()), + ) + }, + NO_DATA + ); + assert_eq!( + unsafe { + set( + param, + c"commands.activeCommand".as_ptr(), + &Value::string(c"SPACEMOUSEPAUSE"), + ) + }, + 0 + ); + let (view, commands) = service.drain(); + assert!(view.is_none()); + assert_eq!(commands[0].1, "SPACEMOUSEPAUSE"); + service.sync(None, false); + assert_eq!( + unsafe { + set( + param, + c"commands.activeCommand".as_ptr(), + &Value::string(c"SPACEMOUSEPAUSE"), + ) + }, + NO_DATA + ); + assert!(service.drain().1.is_empty()); + } +} diff --git a/src/input/spacemouse/pan.rs b/src/input/spacemouse/pan.rs new file mode 100644 index 000000000..9fc435aaa --- /dev/null +++ b/src/input/spacemouse/pan.rs @@ -0,0 +1,138 @@ +//! Planar input in device space: pushes and tilts have the same meaning. +//! The SDK's camera matrices have already combined orbit, pan, and zoom, so +//! they cannot reliably recover these independent physical inputs. +use glam::{DVec2, DVec3}; +use std::time::{Duration, Instant}; + +#[derive(Default)] +pub(super) struct Pan { + axes: [i16; 6], + received: Option, + frame_time: Option, +} +impl Pan { + pub fn clear(&mut self) { + *self = Self::default(); + } + + /// SpaceMouse USB uses split translation/rotation reports; Wireless also + /// sends a combined report. Ignore buttons, battery, and truncated reports. + pub fn report(&mut self, report: &[u8]) -> bool { + let (start, count) = match (report.first(), report.len()) { + (Some(1), 13..) => (0, 6), + (Some(1), 7) => (0, 3), + (Some(2), 7..) => (3, 3), + _ => return false, + }; + for i in 0..count { + self.axes[start + i] = i16::from_le_bytes([report[1 + i * 2], report[2 + i * 2]]); + } + self.received = Some(Instant::now()); + if !self.moving(true) { + self.frame_time = None; + } + true + } + pub fn moving(&self, zoom: bool) -> bool { + direction(self.axes) != DVec2::ZERO || (zoom && response(self.axes[2] as f64) != 0.) + } + + pub fn frame(&mut self, milliseconds: f64, zoom: bool) -> DVec3 { + if !milliseconds.is_finite() + || self + .received + .is_none_or(|t| t.elapsed() > Duration::from_millis(350)) + { + self.clear(); + return DVec3::ZERO; + } + let dt = self.frame_time.map_or(1. / 60., |old| { + ((milliseconds - old) / 1000.).clamp(0., 0.05) + }); + self.frame_time = Some(milliseconds); + direction(self.axes).extend(if zoom { + response(self.axes[2] as f64) + } else { + 0. + }) * dt + } +} + +fn response(v: f64) -> f64 { + let n = (v.abs() / 350.).min(1.); + if n <= 0.08 { + 0. + } else { + v.signum() * ((n - 0.08) / 0.92).powf(1.5) + } +} + +fn direction(axes: [i16; 6]) -> DVec2 { + fn strongest(push: f64, tilt: f64) -> f64 { + // A hand often pushes and tilts together. Do not double the speed. + if push.abs() >= tilt.abs() { + push + } else { + tilt + } + } + let right = strongest(response(axes[0] as f64), response(-(axes[4] as f64))); + let up = strongest(response(-(axes[1] as f64)), response(-(axes[3] as f64))); + DVec2::new(right, up).clamp_length_max(1.) +} + +#[cfg(test)] +mod tests { + use super::*; + fn report(axes: [i16; 6]) -> Vec { + std::iter::once(1) + .chain(axes.into_iter().flat_map(i16::to_le_bytes)) + .collect() + } + #[test] + fn pushes_and_tilts_pan_in_the_same_screen_directions() { + for (push, tilt, expected) in [ + ([350, 0, 0, 0, 0, 0], [0, 0, 0, 0, -350, 0], DVec2::X), + ([-350, 0, 0, 0, 0, 0], [0, 0, 0, 0, 350, 0], -DVec2::X), + ([0, -350, 0, 0, 0, 0], [0, 0, 0, -350, 0, 0], DVec2::Y), + ([0, 350, 0, 0, 0, 0], [0, 0, 0, 350, 0, 0], -DVec2::Y), + ] { + assert_eq!(direction(push), expected); + assert_eq!(direction(tilt), expected); + } + assert_eq!(direction([350, 0, 0, 0, -350, 0]), DVec2::X); + } + #[test] + fn twist_vertical_pressure_and_sensor_noise_do_not_pan() { + assert_eq!(direction([0, 0, 350, 0, 0, -350]), DVec2::ZERO); + assert_eq!(direction([10, -10, 350, 20, -20, 350]), DVec2::ZERO); + } + #[test] + fn held_input_continues_and_release_or_disconnect_stops_it() { + let mut pan = Pan::default(); + assert!(pan.report(&report([350, 0, 0, 0, 0, 0]))); + for frame in 0..20 { + assert!(pan.frame(frame as f64 * 16., false).x > 0.); + } + assert!(pan.report(&report([0; 6]))); + assert!(!pan.moving(false)); + assert_eq!(pan.frame(400., false), DVec3::ZERO); + pan.report(&report([350, 0, 0, 0, 0, 0])); + pan.received = Some(Instant::now() - Duration::from_secs(1)); + assert_eq!(pan.frame(500., false), DVec3::ZERO); + assert!(!pan.moving(false)); + } + #[test] + fn split_and_combined_reports_agree_and_other_reports_are_ignored() { + let mut pan = Pan::default(); + let full = report([1, 2, 3, 4, 5, 6]); + assert!(pan.report(&full[..7])); + let mut rotation = vec![2]; + rotation.extend_from_slice(&full[7..]); + assert!(pan.report(&rotation)); + assert_eq!(pan.axes, [1, 2, 3, 4, 5, 6]); + assert!(!pan.report(&[1, 2, 3])); + assert!(!pan.report(&[23; 13])); + assert_eq!(pan.axes, [1, 2, 3, 4, 5, 6]); + } +} diff --git a/src/input/spacemouse/raw_input.rs b/src/input/spacemouse/raw_input.rs new file mode 100644 index 000000000..0947e52ed --- /dev/null +++ b/src/input/spacemouse/raw_input.rs @@ -0,0 +1,235 @@ +//! Windows multi-axis HID receiver for push-or-tilt planar navigation. +//! Registers only Generic Desktop / Multi-axis Controller, never a keyboard +//! or ordinary mouse. The driver continues to own buttons and 3D navigation. +use super::*; +use std::sync::atomic::{AtomicIsize, Ordering}; +use windows_sys::Win32::{ + Foundation::{HANDLE, HWND, LPARAM, LRESULT, WPARAM}, + System::LibraryLoader::GetModuleHandleW, + UI::{Input::*, WindowsAndMessaging::*}, +}; + +pub(super) struct Listener { + window: Arc, + thread: Option>, +} +impl Listener { + pub fn start(shared: Arc>) -> Self { + let window = Arc::new(AtomicIsize::new(0)); + let worker_window = window.clone(); + let thread = std::thread::Builder::new() + .name("spacemouse-pan".into()) + .spawn(move || { + if let Err(error) = run(shared, &worker_window) { + eprintln!("SpaceMouse pan input: {error}"); + } + }) + .ok(); + Self { window, thread } + } +} +impl Drop for Listener { + fn drop(&mut self) { + let hwnd = self.window.swap(-1, Ordering::AcqRel); + if hwnd > 0 { + unsafe { + PostMessageW(hwnd as HWND, WM_CLOSE, 0, 0); + } + } + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn run(shared: Arc>, window: &AtomicIsize) -> Result<(), String> { + let class: Vec = "OpenCADStudio.SpaceMouse.Pan\0".encode_utf16().collect(); + unsafe { + let instance = GetModuleHandleW(std::ptr::null()); + let definition = WNDCLASSW { + lpfnWndProc: Some(procedure), + hInstance: instance, + lpszClassName: class.as_ptr(), + ..Default::default() + }; + // Another subscription may have registered the class earlier. + if RegisterClassW(&definition) == 0 { + let mut existing = WNDCLASSW::default(); + if GetClassInfoW(instance, class.as_ptr(), &mut existing) == 0 { + return Err(std::io::Error::last_os_error().to_string()); + } + } + let hwnd = CreateWindowExW( + 0, + class.as_ptr(), + std::ptr::null(), + 0, + 0, + 0, + 0, + 0, + HWND_MESSAGE, + std::ptr::null_mut(), + instance, + std::ptr::null(), + ); + if hwnd.is_null() { + return Err(std::io::Error::last_os_error().to_string()); + } + SetWindowLongPtrW(hwnd, GWLP_USERDATA, Arc::as_ptr(&shared) as isize); + if window + .compare_exchange(0, hwnd as isize, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + DestroyWindow(hwnd); + return Ok(()); + } + let device = RAWINPUTDEVICE { + usUsagePage: 1, + usUsage: 8, + dwFlags: RIDEV_INPUTSINK | RIDEV_DEVNOTIFY, + hwndTarget: hwnd, + }; + if RegisterRawInputDevices(&device, 1, std::mem::size_of::() as u32) == 0 { + window.store(0, Ordering::Release); + DestroyWindow(hwnd); + return Err(std::io::Error::last_os_error().to_string()); + } + let mut message = MSG::default(); + while GetMessageW(&mut message, std::ptr::null_mut(), 0, 0) > 0 { + TranslateMessage(&message); + DispatchMessageW(&message); + } + let remove = RAWINPUTDEVICE { + dwFlags: RIDEV_REMOVE, + hwndTarget: std::ptr::null_mut(), + ..device + }; + RegisterRawInputDevices(&remove, 1, std::mem::size_of::() as u32); + window.store(0, Ordering::Release); + } + Ok(()) +} + +unsafe extern "system" fn procedure(hwnd: HWND, message: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { + if message == WM_DESTROY { + unsafe { + PostQuitMessage(0); + } + return 0; + } + let pointer = unsafe { GetWindowLongPtrW(hwnd, GWLP_USERDATA) } as *const Mutex; + if !pointer.is_null() { + let shared = unsafe { &*pointer }; + if message == WM_INPUT { + input(shared, lp as HRAWINPUT); + } else if message == WM_INPUT_DEVICE_CHANGE && wp == GIDC_REMOVAL as usize { + if let Ok(mut bridge) = shared.lock() { + bridge.pan.clear(); + bridge.wake(); + } + } + } + unsafe { DefWindowProcW(hwnd, message, wp, lp) } +} + +fn is_spacemouse(device: HANDLE) -> bool { + let mut info = RID_DEVICE_INFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut size = info.cbSize; + unsafe { + if GetRawInputDeviceInfoW( + device, + RIDI_DEVICEINFO, + (&mut info as *mut RID_DEVICE_INFO).cast(), + &mut size, + ) == u32::MAX + || info.dwType != RIM_TYPEHID + { + return false; + } + let hid = info.Anonymous.hid; + // Logitech's legacy SpaceMouse IDs and current 3Dconnexion vendor ID. + hid.usUsagePage == 1 + && hid.usUsage == 8 + && (hid.dwVendorId == 0x256f + || (hid.dwVendorId == 0x046d && (0xc603..=0xc631).contains(&hid.dwProductId))) + } +} + +/// Older NavLib versions report device.present as always true. Enumerate the +/// registered multi-axis HID devices as well, so availability reflects hardware. +pub(super) fn connected() -> Result { + let element_size = std::mem::size_of::() as u32; + let mut count = 0; + unsafe { + if GetRawInputDeviceList(std::ptr::null_mut(), &mut count, element_size) == u32::MAX { + return Err(std::io::Error::last_os_error().to_string()); + } + let mut devices = vec![RAWINPUTDEVICELIST::default(); count as usize]; + let read = GetRawInputDeviceList(devices.as_mut_ptr(), &mut count, element_size); + if read == u32::MAX { + return Err(std::io::Error::last_os_error().to_string()); + } + Ok(devices + .iter() + .take(read as usize) + .any(|device| device.dwType == RIM_TYPEHID && is_spacemouse(device.hDevice))) + } +} + +fn input(shared: &Mutex, handle: HRAWINPUT) { + // u64 storage provides the alignment required by RAWINPUTHEADER. + let mut buffer = [0_u64; 128]; + let mut size = std::mem::size_of_val(&buffer) as u32; + let header_size = std::mem::size_of::(); + let read = unsafe { + GetRawInputData( + handle, + RID_INPUT, + buffer.as_mut_ptr().cast(), + &mut size, + header_size as u32, + ) + }; + if read == u32::MAX + || read < (header_size + 8) as u32 + || read as usize > std::mem::size_of_val(&buffer) + { + return; + } + let header = unsafe { &*buffer.as_ptr().cast::() }; + if header.dwType != RIM_TYPEHID || !is_spacemouse(header.hDevice) { + return; + } + let bytes = unsafe { std::slice::from_raw_parts(buffer.as_ptr().cast::(), read as usize) }; + let width = + u32::from_le_bytes(bytes[header_size..header_size + 4].try_into().unwrap()) as usize; + let count = + u32::from_le_bytes(bytes[header_size + 4..header_size + 8].try_into().unwrap()) as usize; + if width == 0 || count > (bytes.len() - header_size - 8) / width { + return; + } + let Ok(mut bridge) = shared.lock() else { + return; + }; + if !bridge.focus + || !bridge + .view + .as_ref() + .is_some_and(|v| v.planar() && v.motion_enabled) + { + return; + } + let zoom = bridge.view.as_ref().unwrap().zoom; + let was_moving = bridge.pan.moving(zoom); + let mut accepted = false; + for report in bytes[header_size + 8..].chunks_exact(width).take(count) { + accepted |= bridge.pan.report(report); + } + if accepted && (was_moving || bridge.pan.moving(zoom)) { + bridge.wake(); + } +} diff --git a/src/input/spacemouse/worker.rs b/src/input/spacemouse/worker.rs new file mode 100644 index 000000000..653b0a0fc --- /dev/null +++ b/src/input/spacemouse/worker.rs @@ -0,0 +1,122 @@ +//! Wait for app work and dispatch messages to windows owned by the SDK thread. +//! A channel-only wait leaves Windows-delivered driver settings unprocessed. +use std::{sync::Arc, time::Duration}; +use windows_sys::Win32::{ + Foundation::{CloseHandle, HANDLE}, + System::Threading::{CreateEventW, SetEvent}, + UI::WindowsAndMessaging::*, +}; + +struct Event(isize); +impl Drop for Event { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0 as HANDLE); + } + } +} + +#[derive(Clone)] +pub(super) struct Wake(Arc); +impl Wake { + pub fn new() -> Result { + let event = unsafe { CreateEventW(std::ptr::null(), 0, 0, std::ptr::null()) }; + if event.is_null() { + Err(std::io::Error::last_os_error()) + } else { + Ok(Self(Arc::new(Event(event as isize)))) + } + } + pub fn signal(&self) { + unsafe { + SetEvent(self.0 .0 as HANDLE); + } + } + pub fn wait(&self, timeout: Duration) { + let handle = self.0 .0 as HANDLE; + unsafe { + MsgWaitForMultipleObjectsEx( + 1, + &handle, + timeout.as_millis().min(u32::MAX as u128) as u32, + QS_ALLINPUT, + MWMO_INPUTAVAILABLE, + ); + let mut message = MSG::default(); + // Bound each batch so an SDK message burst cannot starve shutdown + // or app frames. The next wait returns immediately if more remain. + for _ in 0..256 { + if PeekMessageW(&mut message, std::ptr::null_mut(), 0, 0, PM_REMOVE) == 0 { + break; + } + TranslateMessage(&message); + DispatchMessageW(&message); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use windows_sys::Win32::{Foundation::*, System::LibraryLoader::GetModuleHandleW}; + + static DELIVERED: AtomicUsize = AtomicUsize::new(0); + unsafe extern "system" fn procedure( + window: HWND, + message: u32, + wp: WPARAM, + lp: LPARAM, + ) -> LRESULT { + if message == WM_APP { + DELIVERED.fetch_add(1, Ordering::SeqCst); + return 0; + } + unsafe { DefWindowProcW(window, message, wp, lp) } + } + #[test] + fn sdk_worker_dispatches_window_messages_and_app_wakes() { + std::thread::spawn(|| unsafe { + let wake = Wake::new().unwrap(); + let class: Vec = "OpenCADStudio.SpaceMouse.Worker.Test\0" + .encode_utf16() + .collect(); + let instance = GetModuleHandleW(std::ptr::null()); + let definition = WNDCLASSW { + lpfnWndProc: Some(procedure), + hInstance: instance, + lpszClassName: class.as_ptr(), + ..Default::default() + }; + assert_ne!(RegisterClassW(&definition), 0); + let window = CreateWindowExW( + 0, + class.as_ptr(), + std::ptr::null(), + 0, + 0, + 0, + 0, + 0, + HWND_MESSAGE, + std::ptr::null_mut(), + instance, + std::ptr::null(), + ); + assert!(!window.is_null()); + assert_ne!(PostMessageW(window, WM_APP, 0, 0), 0); + wake.wait(Duration::from_secs(1)); + assert_eq!(DELIVERED.load(Ordering::SeqCst), 1); + let other = wake.clone(); + std::thread::spawn(move || other.signal()).join().unwrap(); + let start = std::time::Instant::now(); + wake.wait(Duration::from_secs(5)); + assert!(start.elapsed() < Duration::from_secs(1)); + DestroyWindow(window); + UnregisterClassW(class.as_ptr(), instance); + }) + .join() + .unwrap(); + } +} diff --git a/src/io/font_repo.rs b/src/io/font_repo.rs new file mode 100644 index 000000000..8df774c66 --- /dev/null +++ b/src/io/font_repo.rs @@ -0,0 +1,291 @@ +// Community font repository — detect missing SHX fonts and fetch them from +// the project's `fonts/` folder on GitHub. +// +// DWG files reference SHX fonts by bare name (`vnarial.shx`, `romans.shx`, …). +// When the file doesn't resolve on disk the renderer substitutes an embedded +// LFF stroke font, which mangles drawings authored with fonts it cannot stand +// in for. This module detects those references and downloads the real files +// from the community folder, where anyone can contribute (see +// `fonts/README.md` in the repository root). + +use std::path::{Path, PathBuf}; + +use acadrust::CadDocument; + +/// The community folder's GitHub contents API (lists name + download URL). +#[cfg(not(target_arch = "wasm32"))] +const REPO_API_URL: &str = + "https://api.github.com/repos/HakanSeven12/OpenCADStudio/contents/fonts"; +#[cfg(not(target_arch = "wasm32"))] +const MAX_FONT_BYTES: u64 = 16 * 1024 * 1024; + +/// Local store for fonts downloaded from the community repository. Text +/// resolution searches here after the drawing folder. +#[cfg(not(target_arch = "wasm32"))] +pub fn fonts_dir() -> Option { + crate::config::config_dir().map(|dir| dir.join("fonts")) +} + +/// Web builds have no filesystem to cache fonts into. +#[cfg(target_arch = "wasm32")] +pub fn fonts_dir() -> Option { + None +} + +/// Where missing fonts are fetched from. The community repository is the +/// default; a custom source is any HTTP folder - a company file server, a +/// private GitHub repo's raw folder - where each font lives as +/// `{base}/{file_name}`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FontSource { + Community, + Custom(String), +} + +impl FontSource { + /// Empty / whitespace input selects the community repository. + pub fn from_url(url: &str) -> Self { + match url.trim() { + "" => FontSource::Community, + trimmed => FontSource::Custom(trimmed.trim_end_matches('/').to_string()), + } + } +} + +/// One file listed in the community folder. +#[derive(Debug, Clone, PartialEq)] +pub struct RepoFont { + pub name: String, + pub download_url: String, +} + +/// Referenced-but-unresolvable `.shx` font files across the document's text +/// styles. Names are deduplicated case-insensitively and reduced to the bare +/// file name (`C:\Fonts\VNArial.shx` → `VNArial.shx`). +pub fn missing_shx_fonts(doc: &CadDocument) -> Vec { + let mut missing: Vec = Vec::new(); + let base = doc + .source_path + .as_deref() + .map(Path::new) + .and_then(|p| p.parent()); + for style in doc.text_styles.iter() { + let file = style.font_file.trim(); + if file.is_empty() || !file.to_ascii_lowercase().ends_with(".shx") { + continue; + } + let resolved = crate::io::resolve_image_file(file, base).is_some() + || local_font_file(file).is_some(); + if resolved { + continue; + } + let name = file.rsplit(['/', '\\']).next().unwrap_or(file).to_string(); + if !missing.iter().any(|m| m.eq_ignore_ascii_case(&name)) { + missing.push(name); + } + } + missing.sort_by_key(|name| name.to_ascii_lowercase()); + missing +} + +/// A font file previously downloaded into the local fonts directory, matched +/// case-insensitively by file name. +pub fn local_font_file(name: &str) -> Option { + let file = name.rsplit(['/', '\\']).next()?; + let dir = fonts_dir()?; + let direct = dir.join(file); + if direct.is_file() { + return Some(direct); + } + let lower = file.to_ascii_lowercase(); + std::fs::read_dir(&dir) + .ok()? + .filter_map(|entry| entry.ok()) + .find(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase() == lower) + .map(|entry| entry.path()) +} + +/// List the community folder through the GitHub contents API. +#[cfg(not(target_arch = "wasm32"))] +fn fetch_repo_fonts() -> Result, String> { + let agent = crate::network::agent(std::time::Duration::from_secs(15)); + let mut response = agent + .get(REPO_API_URL) + .header("User-Agent", concat!("OpenCADStudio/", env!("OCS_APP_VERSION"))) + .header("Accept", "application/vnd.github+json") + .call() + .map_err(|e| format!("Could not reach the font repository: {e}"))?; + let body = response + .body_mut() + .read_to_string() + .map_err(|e| format!("Could not read the font repository listing: {e}"))?; + parse_contents(&body) +} + +/// Parse the GitHub contents-API response: +/// `[{"name":"romans.shx","download_url":"https://…"}, …]`. +#[cfg(not(target_arch = "wasm32"))] +fn parse_contents(body: &str) -> Result, String> { + let value: serde_json::Value = + serde_json::from_str(body).map_err(|e| format!("Unexpected font listing: {e}"))?; + let items = value + .as_array() + .ok_or_else(|| "Unexpected font-repository response.".to_string())?; + Ok(items + .iter() + .filter_map(|item| { + let name = item.get("name")?.as_str()?.to_string(); + let url = item.get("download_url")?.as_str()?.to_string(); + (!name.is_empty() && !url.is_empty()).then_some(RepoFont { + name, + download_url: url, + }) + }) + .collect()) +} + +/// Download every `missing` font that exists in the community folder into the +/// local fonts directory. Returns the (requested, saved-path) pairs that were +/// actually fetched; names absent from the repository are skipped. +#[cfg(not(target_arch = "wasm32"))] +pub fn download_fonts( + missing: &[String], + source: &FontSource, +) -> Result, String> { + if missing.is_empty() { + return Ok(Vec::new()); + } + let repo = match source { + FontSource::Community => Some(fetch_repo_fonts()?), + FontSource::Custom(_) => None, + }; + let dir = fonts_dir().ok_or_else(|| "Could not resolve the fonts folder.".to_string())?; + std::fs::create_dir_all(&dir).map_err(|e| format!("Could not create {}: {e}", dir.display()))?; + let mut downloaded = Vec::new(); + for want in missing { + let file = want.rsplit(['/', '\\']).next().unwrap_or(want); + let url = match (source, repo.as_ref()) { + // Community: download URLs come from the listing, matched by name. + (FontSource::Community, Some(repo)) => { + let lower = file.to_ascii_lowercase(); + repo.iter() + .find(|f| f.name.to_ascii_lowercase() == lower) + .map(|f| f.download_url.clone()) + } + // Custom folder: the font is simply `{base}/{file_name}`; a miss + // (404) is skipped without failing the batch. + (FontSource::Custom(base), _) => { + Some(format!("{base}/{}", percent_encode_path(file))) + } + (FontSource::Community, None) => None, + }; + let Some(url) = url else { continue }; + let agent = crate::network::agent(std::time::Duration::from_secs(30)); + let mut response = agent + .get(&url) + .header("User-Agent", concat!("OpenCADStudio/", env!("OCS_APP_VERSION"))) + .call() + .map_err(|e| format!("Could not download {file}: {e}"))?; + if response.status() != 200 { + continue; + } + let bytes = response + .body_mut() + .with_config() + .limit(MAX_FONT_BYTES) + .read_to_vec() + .map_err(|e| format!("Could not download {file}: {e}"))?; + if bytes.is_empty() { + continue; + } + let dest = dir.join(file); + std::fs::write(&dest, &bytes) + .map_err(|e| format!("Could not save {}: {e}", dest.display()))?; + downloaded.push((file.to_string(), dest)); + } + Ok(downloaded) +} + +/// The web build has no network client, and no fonts folder to save into. +#[cfg(target_arch = "wasm32")] +pub fn download_fonts( + missing: &[String], + _source: &FontSource, +) -> Result, String> { + if missing.is_empty() { + return Ok(Vec::new()); + } + Err("Downloading fonts is not available in the web app.".to_string()) +} + +/// Escape path characters that would break a URL (spaces and non-ASCII file +/// names are common in font sets). +#[cfg(not(target_arch = "wasm32"))] +fn percent_encode_path(file: &str) -> String { + let mut out = String::with_capacity(file.len()); + for byte in file.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char) + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_the_github_contents_listing() { + let body = r#"[ + {"name":"romans.shx","path":"fonts/romans.shx", + "download_url":"https://raw.githubusercontent.com/x/y/main/fonts/romans.shx","type":"file"}, + {"name":"subset","path":"fonts/subset","type":"dir"}, + {"name":"no-url.shx","path":"fonts/no-url.shx","type":"file"} + ]"#; + let fonts = parse_contents(body).unwrap(); + assert_eq!(fonts.len(), 1); + assert_eq!(fonts[0].name, "romans.shx"); + assert!(fonts[0].download_url.ends_with("/fonts/romans.shx")); + } + + #[test] + fn custom_source_urls_are_trimmed_and_encoded() { + assert_eq!(FontSource::from_url(""), FontSource::Community); + assert_eq!( + FontSource::from_url(" https://intra/fonts/ "), + FontSource::Custom("https://intra/fonts".into()) + ); + assert_eq!(percent_encode_path("vn 30f.shx"), "vn%2030f.shx"); + } + + #[test] + fn rejects_a_non_listing_response() { + assert!(parse_contents("{\"message\":\"Not Found\"}").is_err()); + } + + #[test] + fn missing_fonts_deduplicate_by_name_case_insensitively() { + // Two styles referencing the same missing font with different casing + // and one whose file resolves next to the drawing must yield one hit. + let dir = std::env::temp_dir().join("ocs_font_repo_tests"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("exists.shx"), b"stub").unwrap(); + let mut doc = CadDocument::new(); + let mk = |name: &str, file: &str| { + let mut style = acadrust::TextStyle::new(name); + style.font_file = file.into(); + style + }; + let _ = doc.text_styles.add(mk("A", "MISSING1.shx")); + let _ = doc.text_styles.add(mk("B", "missing1.SHX")); + let _ = doc.text_styles.add(mk("C", "exists.shx")); + doc.source_path = Some(dir.join("drawing.dwg").to_string_lossy().into_owned()); + let missing = missing_shx_fonts(&doc); + assert_eq!(missing, vec!["MISSING1.shx".to_string()]); + let _ = std::fs::remove_file(dir.join("exists.shx")); + } +} diff --git a/src/io/mod.rs b/src/io/mod.rs index 8fab9dda2..8d0ac888e 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -4,9 +4,11 @@ // Default save format: DWG (AC1032 / R2018+). pub mod file_association; +pub mod font_repo; #[cfg(not(target_arch = "wasm32"))] pub mod edit_lock; pub mod obj; +pub mod ole_embed; #[cfg(not(target_arch = "wasm32"))] pub mod single_instance; pub mod pdf_export; @@ -16,10 +18,13 @@ pub mod recovery; pub mod step; pub mod stl; pub mod xref; +pub mod xref_model; pub mod linetypes; pub mod patterns; pub mod update_check; -pub mod paper_sizes; +pub mod paper_catalog; +pub mod plot_device; +pub mod windows_media; pub mod thumbnail; #[cfg(target_arch = "wasm32")] mod web_worker; @@ -314,10 +319,24 @@ async fn open_path_with_phase_attempt( }); callback }; - std::fs::File::open(&path2).map_err(|error| OpenAttemptFailure { - message: format!("failed to open drawing: {error}"), - read_stats: None, - recoverable: false, + std::fs::File::open(&path2).map_err(|error| { + // 32 = ERROR_SHARING_VIOLATION, 33 = ERROR_LOCK_VIOLATION: + // another program holds the drawing without read sharing, + // so nothing on the machine can read the bytes until it + // closes the file — not even read-only. + let message = match error.raw_os_error() { + Some(32) | Some(33) => format!( + "\"{}\" is in use by another program. Close the file there and \ + reopen it here, or open a copy of the file.", + path2.display() + ), + _ => format!("failed to open drawing: {error}"), + }; + OpenAttemptFailure { + message, + read_stats: None, + recoverable: false, + } })?; let outcome = load_file_for_open(&path2, Some(parser_progress), &attempt)?; let read_stats = outcome.stats; @@ -522,6 +541,18 @@ pub async fn pick_and_load_web( let name = handle.file_name(); progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2); let bytes: Arc<[u8]> = Arc::from(handle.read().await); + open_bytes_web(name, bytes, progress).await +} + +/// Web file open from bytes the caller already holds (the control channel's +/// `open` with `data_base64`). Same parse, caching and recovery handling as a +/// file chosen in the browser picker. +#[cfg(target_arch = "wasm32")] +pub async fn open_bytes_web( + name: String, + bytes: Arc<[u8]>, + progress: Arc, +) -> WebOpenOutcome { let size_bytes = bytes.len() as u64; let result = load_web_bytes(&name, &bytes, progress.clone(), false, "", None).await; let keep_for_recovery = result @@ -996,6 +1027,7 @@ fn finalize_loaded_outcome( ) -> Result { let doc = &mut outcome.document; normalize_block_origins(doc); + normalize_knotless_splines(doc); if outcome.stats.source_format == Some(acadrust::SourceFormat::Dxf) { fix_dxf_dimension_rotations(doc); fix_dxf_layout_plot_settings(doc); @@ -1018,20 +1050,117 @@ fn read_dwg_path( DwgReadOptions::default() }; #[cfg(not(target_arch = "wasm32"))] - let mut reader = { - let mut reader = DwgReader::from_mmap(path).map_err(ReaderFailure::from_reader)?; - reader.options = options; + { + // The memory-mapped read is the fast path, but a mapped file is read + // through the page-fault handler: a file that shrinks while mapped + // (a writer saving over it) or a cloud placeholder that cannot page + // in raises an in-page exception the process cannot catch. Both are + // ordinary I/O errors through plain reads, so anything that can + // fault — or refuses to open under a third-party lock — falls back + // to one in-memory snapshot read below. + if !cloud_placeholder(path) { + match DwgReader::from_mmap(path) { + Ok(mut reader) => { + reader.options = options.clone(); + if let Some(progress) = &progress { + reader.set_progress_callback(progress.clone()); + } + match reader.read_with_stats() { + Ok(outcome) => return Ok(outcome), + // The file shrank or became unreadable mid-parse; + // retry from a snapshot instead of reporting a bare + // I/O error. + Err(acadrust::DxfError::Io(_)) => {} + Err(error) => return Err(ReaderFailure::from_reader(error)), + } + } + // Locked or unmappable: the snapshot read surfaces the real + // error when the file genuinely cannot be read. + Err(_) => {} + } + } + let bytes = read_drawing_snapshot(path).map_err(|error| { + ReaderFailure::terminal(format!( + "failed to open drawing (is it in use by another program?): {error}" + )) + })?; + let mut reader = DwgReader::from_stream_with_options(std::io::Cursor::new(bytes), options); + if let Some(progress) = progress { + reader.set_progress_callback(progress); + } reader - }; + .read_with_stats() + .map_err(ReaderFailure::from_reader) + } #[cfg(target_arch = "wasm32")] - let mut reader = DwgReader::from_file_with_options(path, options) - .map_err(ReaderFailure::from_reader)?; - if let Some(progress) = progress { - reader.set_progress_callback(progress); + { + let mut reader = DwgReader::from_file_with_options(path, options) + .map_err(ReaderFailure::from_reader)?; + if let Some(progress) = progress { + reader.set_progress_callback(progress); + } + reader + .read_with_stats() + .map_err(ReaderFailure::from_reader) + } +} + +/// Windows cloud-placeholder files (OneDrive "Files On Demand" and similar) +/// page their bytes in on demand; faulting a placeholder through a memory map +/// can raise an in-page exception that kills the process. Plain reads hydrate +/// the file instead. Checks `FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS`, +/// `FILE_ATTRIBUTE_RECALL_ON_OPEN` and `FILE_ATTRIBUTE_OFFLINE`. +#[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))] +fn cloud_placeholder(path: &Path) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_OFFLINE: u32 = 0x1000; + const FILE_ATTRIBUTE_RECALL_ON_OPEN: u32 = 0x0004_0000; + const FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS: u32 = 0x0040_0000; + std::fs::metadata(path) + .map(|meta| { + let attrs = meta.file_attributes(); + attrs + & (FILE_ATTRIBUTE_OFFLINE + | FILE_ATTRIBUTE_RECALL_ON_OPEN + | FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS) + != 0 + }) + .unwrap_or(false) +} + +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn cloud_placeholder(_path: &Path) -> bool { + false +} + +/// Read the whole drawing through the most permissive share mode the platform +/// offers. On Windows `std::fs::read` opens without `FILE_SHARE_DELETE`, so a +/// concurrent atomic-save replace makes the read fail even though every byte +/// was readable. A snapshot can never fault: a writer shrinking the file +/// mid-read just ends the read early. +#[cfg(not(target_arch = "wasm32"))] +fn read_drawing_snapshot(path: &Path) -> std::io::Result> { + #[cfg(target_os = "windows")] + { + use std::io::Read; + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + + let mut file = std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .open(path)?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + Ok(bytes) + } + #[cfg(not(target_os = "windows"))] + { + std::fs::read(path) } - reader - .read_with_stats() - .map_err(ReaderFailure::from_reader) } fn read_dxf_path(path: &Path, failsafe: bool) -> Result { @@ -1518,7 +1647,12 @@ mod save_failure_tests { // ── Plot Style Table ────────────────────────────────────────────────────── /// Show a file-open dialog and load the selected CTB or STB file. -pub async fn pick_plot_style() -> Option { +/// Pick a plot style table file and load it. `Ok(None)` when the picker was +/// cancelled; `Err` says why the chosen file could not be read. A table +/// picked from outside the plot styles folder is copied into it (unless a +/// file of that name is already there), so the dialog lists it from now on +/// and page setups naming it resolve after a restart. +pub async fn pick_plot_style() -> Result, String> { let dialog = crate::sys::file_dialog() .set_title(crate::t!("Load Plot Style Table").as_ref()) .add_filter(crate::t!("Plot Style Tables").as_ref(), &["ctb", "CTB"]) @@ -1529,8 +1663,26 @@ pub async fn pick_plot_style() -> Option { Ok(dir) => dialog.set_directory(dir), Err(_) => dialog, }; - let handle = dialog.pick_file().await?; - plot_style::PlotStyleTable::load(&crate::sys::handle_path(&handle)).ok() + let Some(handle) = dialog.pick_file().await else { + return Ok(None); + }; + let path = crate::sys::handle_path(&handle); + let table = plot_style::PlotStyleTable::load(&path)?; + #[cfg(not(target_arch = "wasm32"))] + if let (Ok(dir), Some(file_name)) = (plot_style::ensure_plot_styles_dir(), path.file_name()) { + let destination = dir.join(file_name); + let same_place = path.parent().is_some_and(|parent| { + parent + .canonicalize() + .ok() + .zip(dir.canonicalize().ok()) + .is_some_and(|(a, b)| a == b) + }); + if !same_place && !destination.exists() { + let _ = std::fs::copy(&path, &destination); + } + } + Ok(Some(table)) } // ── Image file picker ───────────────────────────────────────────────────── @@ -1553,6 +1705,23 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> { Ok((path, w, h)) } +/// Pick an image file and prepare it for embedding (IMAGEEMBED). The bytes +/// are read before returning so the OLE2FRAME payload is ready to place. +pub async fn pick_embedded_image_file() -> Result { + let handle = crate::sys::file_dialog() + .set_title(crate::t!("Select Image File").as_ref()) + .add_filter(crate::t!("Images").as_ref(), &["png", "jpg", "jpeg", "bmp", "tiff", "tif"]) + .add_filter(crate::t!("PNG").as_ref(), &["png"]) + .add_filter(crate::t!("JPEG").as_ref(), &["jpg", "jpeg"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .pick_file() + .await + .ok_or_else(|| "Cancelled".to_string())?; + let name = handle.file_name(); + let bytes = handle.read().await; + ole_embed::EmbeddedImage::from_bytes(name, bytes) +} + /// Save `doc` to `path` with the given DXF version, overriding `doc.version`. /// Format is auto-detected from the extension (dwg / dxf). pub fn save_as_version( @@ -2022,6 +2191,8 @@ fn sync_current_styles_on_save(doc: &mut CadDocument) { // `purge_corrupt_entities` scans the document and removes any entity that // fails a cheap sanity check, returning the number dropped so the caller can // surface it to the UI / log. +// +// Keep valid degenerate geometry: dropping it would trigger strict-open recovery. fn finite_unit_normal(n: &acadrust::types::Vector3) -> bool { let (x, y, z) = (n.x, n.y, n.z); @@ -2079,20 +2250,15 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool { || p.vertices.iter().any(|v| !finite_vec3(&v.location)) } E::Line(l) => !finite_vec3(&l.start) || !finite_vec3(&l.end), + // Zero-radius curves are valid and have bounded kernel tessellation. E::Circle(c) => { - !finite_vec3(&c.center) - || !finite_coord(c.radius) - // Reject zero- or near-zero circles: they tessellate into a - // degenerate curve the tessellator cannot sample. - || c.radius.abs() < 1.0e-10 - || c.radius.abs() > 1.0e10 + !finite_vec3(&c.center) || !finite_coord(c.radius) || c.radius.abs() > 1.0e10 } E::Arc(a) => { !finite_vec3(&a.center) || !finite_coord(a.radius) || !a.start_angle.is_finite() || !a.end_angle.is_finite() - || a.radius.abs() < 1.0e-10 || a.radius.abs() > 1.0e10 || !finite_unit_normal(&a.normal) } @@ -2128,8 +2294,12 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool { && (s.knots.iter().any(|k| !k.is_finite()) || s.knots.windows(2).any(|w| w[1] < w[0]) || (!compact_periodic_knots && s.knots.len() != n + deg + 1)); + // A single control point with no knots and no fit points cannot + // form a curve, and no knot vector exists for it. + let lone_point = n == 1 && s.knots.is_empty() && s.fit_points.is_empty(); n >= MAX_VERTS || degree_bad + || lone_point || s.control_points.iter().any(|p| !finite_vec3(p)) || knots_bad } @@ -2137,8 +2307,32 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool { } } +/// Give control-point splines stored without knots a degree their control +/// points can carry, and the matching clamped knot vector. +/// +/// A degree-p curve needs at least p + 1 control points. Knot generation +/// (here, and in the DWG writer for knot-less splines) subtracts in `usize` +/// and underflows when there are fewer; in release builds the wrapped count +/// drives an unbounded allocation. Lowering the degree keeps the geometry the +/// points describe: two points are a straight segment at any degree. +fn normalize_knotless_splines(doc: &mut CadDocument) { + for entity in doc.entities_mut() { + let EntityType::Spline(spline) = entity else { + continue; + }; + let n = spline.control_points.len(); + if !spline.knots.is_empty() || n < 2 || spline.degree < 1 { + continue; + } + let degree = (spline.degree as usize).min(n - 1); + spline.degree = degree as i32; + spline.knots = acadrust::entities::Spline::generate_clamped_knots(degree, n); + } +} + pub fn purge_corrupt_entities(doc: &mut CadDocument) -> usize { use crate::par::prelude::*; + normalize_knotless_splines(doc); // Detection is pure and read-only; the per-vertex finite/extent checks on // large polylines dominate, so fan the scan out across cores. Gather // entity references in one pass, test in parallel, then remove serially @@ -2337,9 +2531,70 @@ mod layer_roundtrip_tests { #[cfg(test)] mod corrupt_guard_tests { use super::*; - use acadrust::entities::{Arc, EntityType, Spline}; + use acadrust::entities::{Arc, Circle, EntityType, Spline}; use acadrust::types::Vector3; + fn knotless_spline(degree: i32, points: usize) -> Spline { + let mut spline = Spline::new(); + spline.degree = degree; + spline.control_points = (0..points) + .map(|index| Vector3::new(index as f64, index as f64, 0.0)) + .collect(); + spline + } + + // Degree 3 over two control points with no knot vector: generating knots + // for it underflows, and writing it to DWG used to panic (debug) or + // allocate without bound (release). + #[test] + fn knotless_spline_with_too_few_points_is_lowered_and_saves_as_dwg() { + let mut doc = CadDocument::new(); + doc.add_entity(EntityType::Spline(knotless_spline(3, 2))).unwrap(); + doc.add_entity(EntityType::Spline(knotless_spline(3, 1))).unwrap(); + assert_eq!(purge_corrupt_entities(&mut doc), 1, "the lone point is dropped"); + let spline = doc + .entities() + .find_map(|entity| match entity { + EntityType::Spline(spline) => Some(spline.clone()), + _ => None, + }) + .expect("the two-point spline is kept"); + assert_eq!(spline.degree, 1); + assert_eq!(spline.knots, vec![0.0, 0.0, 1.0, 1.0]); + + let path = std::env::temp_dir().join(format!( + "ocs_knotless_spline_{}.dwg", + std::process::id() + )); + save_as_version(&doc, &path, acadrust::DxfVersion::AC1032).expect("save"); + let _ = std::fs::remove_file(&path); + } + + // `load_file` (headless export, xrefs, block import) does not purge, so + // the spline repair has to happen when any load is finalized. + #[test] + fn knotless_spline_is_repaired_by_every_load_path() { + const DXF: &str = "0\nSECTION\n2\nENTITIES\n0\nSPLINE\n8\n0\n70\n0\n71\n3\n72\n0\n73\n2\n74\n0\n\ +10\n0\n20\n0\n30\n0\n10\n1\n20\n1\n30\n0\n0\nENDSEC\n0\nEOF\n"; + let dir = std::env::temp_dir(); + let source = dir.join(format!("ocs_knotless_load_{}.dxf", std::process::id())); + let target = dir.join(format!("ocs_knotless_load_{}.dwg", std::process::id())); + std::fs::write(&source, DXF).unwrap(); + let doc = load_file(&source).expect("load"); + let _ = std::fs::remove_file(&source); + let spline = doc + .entities() + .find_map(|entity| match entity { + EntityType::Spline(spline) => Some(spline), + _ => None, + }) + .expect("spline"); + assert_eq!(spline.degree, 1); + assert_eq!(spline.knots.len(), 4); + save_as_version(&doc, &target, acadrust::DxfVersion::AC1032).expect("save"); + let _ = std::fs::remove_file(&target); + } + // Small but finite arcs are valid records. Kernel tessellation is bounded, // so opening must retain them instead of treating their size as corruption. #[test] @@ -2377,6 +2632,32 @@ mod corrupt_guard_tests { assert!(!is_entity_corrupt(&EntityType::Arc(a))); } + #[test] + fn keeps_zero_radius_circle_and_arc() { + let mut c = Circle::new(); + c.center = Vector3::new(206.2, 150.7, 0.0); + c.radius = 0.0; + assert!(!is_entity_corrupt(&EntityType::Circle(c))); + + let mut a = Arc::new(); + a.center = Vector3::new(223.5, 174.5, 0.0); + a.radius = 0.0; + a.start_angle = 0.0; + a.end_angle = 0.0; + a.normal = Vector3::new(0.0, 0.0, 1.0); + assert!(!is_entity_corrupt(&EntityType::Arc(a))); + } + + #[test] + fn drops_absurd_radius_circle() { + let mut c = Circle::new(); + c.radius = 1.0e11; + assert!(is_entity_corrupt(&EntityType::Circle(c))); + let mut c = Circle::new(); + c.radius = f64::NAN; + assert!(is_entity_corrupt(&EntityType::Circle(c))); + } + // Parser desync emits 100_000-control-point splines; building a kernel // NURBS from one and tessellating it OOMs. The control-point cap rejects it. #[test] diff --git a/src/io/ole_embed.rs b/src/io/ole_embed.rs new file mode 100644 index 000000000..b30fd9365 --- /dev/null +++ b/src/io/ole_embed.rs @@ -0,0 +1,261 @@ +// Embed a raster image inside the drawing itself as an OLE2FRAME entity. +// +// The IMAGE mechanism (RasterImage + ImageDefinition) stores only a file path, +// so a drawing copied to another machine loses its pictures. An OLE2FRAME +// instead carries the encoded raster inside an OLE compound file embedded in +// the entity data, so +// the .dwg is self-contained. cadcodec both builds the compound storage and +// reads it back, and the renderer already paints OLE2FRAMEs via +// ImageModel::from_ole2frame, so this module only packs and unpacks. + +use std::io::Write as _; +use std::path::Path; + +use acadrust::compound_file::{ + BinaryRecord, CompoundEntry, CompoundFile, CompoundStorage, CompoundStream, + CompoundStreamContent, StructuredStoragePayload, +}; +use acadrust::entities::{Ole2Frame, OleFrameEnvelope, OleObjectType}; +use acadrust::types::Vector3; + +/// OLE compound-file stream holding OLE 1.0 native data. cadcodec's +/// presentation extractor looks here first; the body is the encoded raster +/// behind a 4-byte little-endian length. +const OLE10NATIVE: &str = "\u{1}Ole10Native"; +/// Leading header marker carried verbatim by the geometry envelope; readers +/// use it to recognise the envelope around the compound file. +const GEOMETRY_MARKER: u16 = 1; +/// Encoded formats the extractor recognises by magic and every DWG consumer +/// can decode. Anything else is re-encoded as PNG before embedding. +const PORTABLE_FORMATS: [image::ImageFormat; 3] = [ + image::ImageFormat::Png, + image::ImageFormat::Jpeg, + image::ImageFormat::Bmp, +]; + +/// A raster image prepared for embedding: encoded bytes plus the metadata the +/// placement prompts need. +#[derive(Clone, Debug, PartialEq)] +pub struct EmbeddedImage { + /// Encoded raster bytes (PNG / JPEG / BMP as stored or re-encoded). + pub bytes: Vec, + pub pixel_width: u32, + pub pixel_height: u32, + /// File name the picture came from — surfaces in prompts and properties. + pub name: String, +} + +impl EmbeddedImage { + /// Prepare an image file for embedding. Recognised raster formats are + /// stored as-is; anything else is decoded and re-encoded as PNG so the + /// payload always stays renderable. + pub fn from_file(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("image") + .to_string(); + Self::from_bytes(name, bytes) + } + + /// Prepare already-read image bytes for embedding (web file handles). + pub fn from_bytes(name: String, bytes: Vec) -> Result { + let format = image::guess_format(&bytes).map_err(|e| e.to_string())?; + let encoded = if PORTABLE_FORMATS.contains(&format) { + bytes + } else { + encode_png(&bytes)? + }; + let img = image::load_from_memory(&encoded).map_err(|e| e.to_string())?; + let (pixel_width, pixel_height) = image::GenericImageView::dimensions(&img); + Ok(Self { + bytes: encoded, + pixel_width, + pixel_height, + name, + }) + } + + /// Width : height aspect ratio; a degenerate height falls back to square. + pub fn aspect(&self) -> f64 { + if self.pixel_height == 0 { + 1.0 + } else { + self.pixel_width as f64 / self.pixel_height as f64 + } + } +} + +fn encode_png(bytes: &[u8]) -> Result, String> { + let img = image::load_from_memory(bytes).map_err(|e| e.to_string())?; + let mut png = std::io::Cursor::new(Vec::new()); + img.write_to(&mut png, image::ImageFormat::Png) + .map_err(|e| e.to_string())?; + Ok(png.into_inner()) +} + +/// World-space corners for a picture placed with its bottom-left corner at +/// `origin`, extending `width` right and `width / aspect` up — the same +/// rectangle the IMAGE command previews. +pub fn corners_from_placement(origin: Vector3, width: f64, aspect: f64) -> (Vector3, Vector3) { + let width = width.max(1e-6); + let height = width / aspect.max(1e-6); + ( + Vector3::new(origin.x, origin.y + height, origin.z), + Vector3::new(origin.x + width, origin.y, origin.z), + ) +} + +/// Pack `image` into an OLE2FRAME spanning `upper_left` → `lower_right` in +/// world space. The picture rides in an `\x01Ole10Native` stream as OLE 1.0 +/// native data behind the geometry envelope cadcodec writes around it. +pub fn build_embedded_ole( + image: &EmbeddedImage, + upper_left: Vector3, + lower_right: Vector3, +) -> Ole2Frame { + // OLE 1.0 native: 4-byte little-endian payload length, then the raster. + let mut native = Vec::with_capacity(4 + image.bytes.len()); + let _ = native.write(&(image.bytes.len() as u32).to_le_bytes()); + native.extend_from_slice(&image.bytes); + + let file = CompoundFile { + minor_version: 0x003E, + major_version: 3, + transaction_signature: 0, + root: CompoundStorage { + name: "Root Entry".to_string(), + class_id: [0; 16], + state_bits: 0, + creation_time: 0, + modified_time: 0, + entries: vec![CompoundEntry::Stream(CompoundStream { + name: OLE10NATIVE.to_string(), + class_id: [0; 16], + state_bits: 0, + creation_time: 0, + modified_time: 0, + content: CompoundStreamContent::Binary(BinaryRecord::split(&native, 4096)), + })], + }, + }; + + let mut ole = Ole2Frame::new(); + ole.ole_object_type = OleObjectType::Embedded; + ole.source_application = "OpenCADStudio".to_string(); + ole.upper_left_corner = upper_left; + ole.lower_right_corner = lower_right; + ole.envelope = OleFrameEnvelope::Geometry { + marker: GEOMETRY_MARKER, + extension_records: Vec::new(), + }; + ole.storage = StructuredStoragePayload { + leading_records: Vec::new(), + compound_file: Some(file), + trailing_records: Vec::new(), + }; + ole +} + +/// Non-interactive embed used by the control API: add the entity to `doc` and +/// return its handle. The undo snapshot is the caller's responsibility. +pub fn add_embedded_image( + doc: &mut acadrust::CadDocument, + image: &EmbeddedImage, + origin: Vector3, + width: f64, +) -> Result { + let (upper_left, lower_right) = corners_from_placement(origin, width, image.aspect()); + let ole = build_embedded_ole(image, upper_left, lower_right); + doc.add_entity(acadrust::EntityType::Ole2Frame(ole)) + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal valid PNG: a solid-colour 4×3 image through the `image` crate. + fn png_bytes(width: u32, height: u32) -> Vec { + let img = image::RgbaImage::from_pixel(width, height, image::Rgba([10, 20, 30, 255])); + let mut png = std::io::Cursor::new(Vec::new()); + img.write_to(&mut png, image::ImageFormat::Png).unwrap(); + png.into_inner() + } + + #[test] + fn from_bytes_keeps_png_and_measures_pixels() { + let embedded = EmbeddedImage::from_bytes("t.png".into(), png_bytes(4, 3)).unwrap(); + assert_eq!(embedded.pixel_width, 4); + assert_eq!(embedded.pixel_height, 3); + assert_eq!(embedded.bytes, png_bytes(4, 3)); + } + + #[test] + fn from_bytes_reencodes_foreign_formats_as_png() { + // TIFF starts with II*/MM* and is not in the portable set, so it must + // come back re-encoded as PNG. + let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 3, 255])); + let mut tiff = std::io::Cursor::new(Vec::new()); + img.write_to(&mut tiff, image::ImageFormat::Tiff).unwrap(); + let embedded = EmbeddedImage::from_bytes("t.tif".into(), tiff.into_inner()).unwrap(); + assert!(embedded.bytes.starts_with(&[0x89, b'P', b'N', b'G'])); + } + + #[test] + fn packed_payload_roundtrips_through_the_extractor() { + let png = png_bytes(4, 3); + let image = EmbeddedImage { + bytes: png.clone(), + pixel_width: 4, + pixel_height: 3, + name: "t.png".into(), + }; + let ole = build_embedded_ole( + &image, + Vector3::new(0.0, 3.0, 0.0), + Vector3::new(4.0, 0.0, 0.0), + ); + let payload = ole.encoded_payload(); + match acadrust::entities::extract_presentation(&payload) { + Some(acadrust::entities::OlePresentation::Raster(bytes)) => assert_eq!(bytes, png), + other => panic!("expected the embedded raster back, got {other:?}"), + } + } + + #[test] + fn dwg_roundtrip_preserves_the_embedded_picture() { + let png = png_bytes(4, 3); + let image = EmbeddedImage { + bytes: png.clone(), + pixel_width: 4, + pixel_height: 3, + name: "t.png".into(), + }; + let mut doc = acadrust::CadDocument::new(); + let handle = + add_embedded_image(&mut doc, &image, Vector3::new(10.0, 10.0, 0.0), 20.0).unwrap(); + + let dir = std::env::temp_dir().join("ocs_ole_embed_tests"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("embedded_roundtrip.dwg"); + acadrust::DwgWriter::write_to_file(&path, &doc).unwrap(); + + let mut reader = acadrust::io::dwg::DwgReader::from_file(&path).unwrap(); + let reopened = reader.read().unwrap(); + std::fs::remove_file(&path).ok(); + + let ole = match reopened.get_entity(handle) { + Some(acadrust::EntityType::Ole2Frame(ole)) => ole, + other => panic!("expected the Ole2Frame back, got {other:?}"), + }; + // Corners survive: 20 wide at aspect 4:3 → 15 tall, base at (10,10). + assert_eq!(ole.lower_right_corner, Vector3::new(30.0, 10.0, 0.0)); + assert_eq!(ole.upper_left_corner, Vector3::new(10.0, 25.0, 0.0)); + match acadrust::entities::extract_presentation(&ole.encoded_payload()) { + Some(acadrust::entities::OlePresentation::Raster(bytes)) => assert_eq!(bytes, png), + other => panic!("expected the embedded raster back, got {other:?}"), + } + } +} diff --git a/src/io/paper_catalog.rs b/src/io/paper_catalog.rs new file mode 100644 index 000000000..1842f4b3d --- /dev/null +++ b/src/io/paper_catalog.rs @@ -0,0 +1,867 @@ +//! Paper catalogue with the source application-compatible media names. +//! +//! the source application stores a layout's paper as the plotter driver's *canonical media +//! name* — `ISO_A4_(210.00_x_297.00_MM)`, `ANSI_B_(11.00_x_17.00_Inches)`, +//! `ARCH_full_bleed_D_(24.00_x_36.00_Inches)` — always with the portrait +//! dimensions; landscape is expressed through the plot rotation. A drawing +//! saved here has to carry those exact names so the source application finds the media in its +//! `DWG To PDF.pc3` list instead of silently falling back to a default sheet, +//! and a name that arrives from the source application (including a custom size this catalogue +//! has never seen) must still resolve to its dimensions and go back out +//! untouched. The name itself encodes the dimensions, which is what makes the +//! second half possible: [`PaperSize::parse_canonical`] never needs a table. + +use std::borrow::Cow; +use std::sync::OnceLock; + +/// Unit the media is defined in. the source application keeps ISO/JIS sheets in millimetres +/// and ANSI/ARCH sheets in inches, and the unit is part of the media name. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, serde::Serialize, serde::Deserialize)] +pub enum PaperUnits { + Millimeters, + Inches, +} + +impl PaperUnits { + pub const MM_PER_INCH: f64 = 25.4; + + pub fn to_mm(self, value: f64) -> f64 { + match self { + PaperUnits::Millimeters => value, + PaperUnits::Inches => value * Self::MM_PER_INCH, + } + } + + pub fn from_mm(self, value: f64) -> f64 { + match self { + PaperUnits::Millimeters => value, + PaperUnits::Inches => value / Self::MM_PER_INCH, + } + } + + /// The unit token the source application writes inside the media name. + fn canonical_suffix(self) -> &'static str { + match self { + PaperUnits::Millimeters => "MM", + PaperUnits::Inches => "Inches", + } + } + + fn from_canonical_suffix(token: &str) -> Option { + if token.eq_ignore_ascii_case("MM") { + Some(PaperUnits::Millimeters) + } else if token.eq_ignore_ascii_case("Inches") || token.eq_ignore_ascii_case("Inch") { + Some(PaperUnits::Inches) + } else { + None + } + } + + /// Short unit label for display (`mm` / `in`). + pub fn short_label(self) -> &'static str { + match self { + PaperUnits::Millimeters => "mm", + PaperUnits::Inches => "in", + } + } +} + +/// The sheet family, used to group the picker and to prefer a standard sheet +/// over its full-bleed twin when matching by dimensions. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)] +pub enum PaperSeries { + IsoA, + IsoB, + Ansi, + Arch, + Jis, + /// Letter / Legal / Tabloid under their US names, and any user-defined sheet. + Other, +} + +/// the source application offers three printable-area variants of most sheets: the standard +/// one with driver margins, *full bleed* with zero margins, and *expand* +/// (the driver's maximum printable area). The sheet dimensions are identical; +/// only the media name and the margins differ. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)] +pub enum PaperVariant { + Standard, + FullBleed, + Expand, +} + +impl PaperVariant { + /// The infix the source application places between the series and the sheet letter. + fn canonical_infix(self) -> &'static str { + match self { + PaperVariant::Standard => "", + PaperVariant::FullBleed => "full_bleed_", + PaperVariant::Expand => "expand_", + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum Orientation { + Portrait, + Landscape, +} + +/// Unprintable margins of a sheet, in the order the source application stores them. The unit +/// is whatever the context says — millimetres in a drawing, the sheet's own +/// unit in a user-defined size. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Margins { + pub left: f64, + pub bottom: f64, + pub right: f64, + pub top: f64, +} + +impl Margins { + pub const ZERO: Margins = Margins { + left: 0.0, + bottom: 0.0, + right: 0.0, + top: 0.0, + }; + + pub const fn uniform(value: f64) -> Self { + Margins { + left: value, + bottom: value, + right: value, + top: value, + } + } + + /// The same margins expressed in millimetres when they were given in + /// `units`. + pub fn to_mm(self, units: PaperUnits) -> Self { + Margins { + left: units.to_mm(self.left), + bottom: units.to_mm(self.bottom), + right: units.to_mm(self.right), + top: units.to_mm(self.top), + } + } +} + +/// A sheet the user defined, kept in the application settings the way +/// the source application keeps custom sizes in a plotter configuration: a name, the +/// dimensions in the sheet's own unit, and the printable margins. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct CustomPaper { + /// Human name, e.g. `Roll 24`; becomes `Roll_24_(…)` in the media name. + pub name: String, + pub width: f64, + pub height: f64, + pub units: PaperUnits, + /// Unprintable margins in `units`. + pub margins: Margins, +} + +impl Default for CustomPaper { + fn default() -> Self { + Self { + name: String::new(), + width: 0.0, + height: 0.0, + units: PaperUnits::Millimeters, + margins: Margins::ZERO, + } + } +} + +impl CustomPaper { + /// The catalogue sheet this definition describes. + pub fn paper(&self) -> PaperSize { + PaperSize::custom_named(&self.name, self.width, self.height, self.units) + } + + pub fn canonical(&self) -> String { + self.paper().canonical.into_owned() + } + + pub fn margins_mm(&self) -> Margins { + self.margins.to_mm(self.units) + } +} + +/// One sheet of the catalogue, or a sheet reconstructed from a media name. +#[derive(Clone, PartialEq, Debug)] +pub struct PaperSize { + /// the source application canonical media name, e.g. `ISO_A4_(210.00_x_297.00_MM)`. This is + /// the value written to `PlotSettings::paper_size` and the key the plot + /// dialog stores. + pub canonical: Cow<'static, str>, + /// Human name without dimensions: `ISO A4`, `ANSI full bleed B`. + pub label: Cow<'static, str>, + pub series: PaperSeries, + pub variant: PaperVariant, + /// Portrait width in `units` (width ≤ height). + pub width: f64, + /// Portrait height in `units`. + pub height: f64, + pub units: PaperUnits, +} + +impl PaperSize { + /// Portrait dimensions in millimetres. + pub fn portrait_mm(&self) -> (f64, f64) { + (self.units.to_mm(self.width), self.units.to_mm(self.height)) + } + + /// Sheet dimensions in millimetres for the given orientation. + pub fn sheet_mm(&self, orientation: Orientation) -> (f64, f64) { + let (w, h) = self.portrait_mm(); + match orientation { + Orientation::Portrait => (w, h), + Orientation::Landscape => (h, w), + } + } + + /// Picker text: the human name followed by the dimensions in the sheet's + /// own unit — `ISO A4 (210 × 297 mm)`, `ANSI B (11.00 × 17.00 in)`. + pub fn display(&self) -> String { + format!( + "{} ({} × {} {})", + self.label, + format_dimension(self.width, self.units), + format_dimension(self.height, self.units), + self.units.short_label() + ) + } + + /// A user-defined sheet, named the way the source application names custom media created + /// in the plot dialog (`UserDefinedMetric_(420.00_x_297.00_MM)`). The + /// dimensions are normalised to portrait so the name stays canonical. + pub fn custom(width: f64, height: f64, units: PaperUnits) -> Self { + let label = match units { + PaperUnits::Millimeters => "UserDefinedMetric", + PaperUnits::Inches => "UserDefinedInches", + }; + Self::custom_named(label, width, height, units) + } + + /// A user-defined sheet under its own name, the way a plotter + /// configuration's custom sizes are named (`Roll_24_(609.60_x_1500.00_MM)`). + /// A blank name falls back to the source application's generic one; spaces become + /// underscores in the media name and parentheses are dropped so the name + /// stays parseable. + pub fn custom_named(name: &str, width: f64, height: f64, units: PaperUnits) -> Self { + let (width, height) = if width <= height { + (width, height) + } else { + (height, width) + }; + let cleaned: String = name + .trim() + .chars() + .filter(|c| !matches!(c, '(' | ')')) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + if cleaned.is_empty() { + return Self::custom(width, height, units); + } + Self { + canonical: Cow::Owned(canonical_name( + &cleaned.replace(' ', "_"), + width, + height, + units, + )), + label: Cow::Owned(cleaned), + series: PaperSeries::Other, + variant: PaperVariant::Standard, + width, + height, + units, + } + } + + /// Rebuild a sheet from any media name that follows the source application's + /// `_(_x__)` convention, whether or not the + /// catalogue knows it. The dialog form with spaces (`ISO A4 (210.00 x + /// 297.00 MM)`) is accepted too. Returns `None` for names without an + /// embedded dimension group (a bare printer paper name such as `A4`, or a + /// raster `Pixels` size). + pub fn parse_canonical(name: &str) -> Option { + let name = name.trim(); + let open = name.rfind('(')?; + let close = name[open..].find(')')? + open; + let dims = name[open + 1..close].trim_matches(['_', ' ']); + let raw_label = name[..open].trim_matches(['_', ' ']); + if raw_label.is_empty() { + return None; + } + // `210.00_x_297.00_MM`, or `210.00 x 297.00 MM` from a dialog string. + let normalized: String = dims.replace(' ', "_"); + let mut parts = normalized.split('_').filter(|part| !part.is_empty()); + let width: f64 = parts.next()?.parse().ok()?; + if !parts.next()?.eq_ignore_ascii_case("x") { + return None; + } + let height: f64 = parts.next()?.parse().ok()?; + let units = PaperUnits::from_canonical_suffix(parts.next()?)?; + if parts.next().is_some() + || width <= 0.0 + || height <= 0.0 + || !width.is_finite() + || !height.is_finite() + { + return None; + } + let label = raw_label.replace('_', " "); + let (series, variant) = classify_label(&label); + let (portrait_width, portrait_height) = if width <= height { + (width, height) + } else { + (height, width) + }; + Some(Self { + canonical: Cow::Owned(canonical_name( + &raw_label.replace(' ', "_"), + width, + height, + units, + )), + label: Cow::Owned(label), + series, + variant, + width: portrait_width, + height: portrait_height, + units, + }) + } +} + +fn format_dimension(value: f64, units: PaperUnits) -> String { + match units { + // Millimetre sheets are whole numbers in every standard; keep the + // fraction only when a custom size needs it. + PaperUnits::Millimeters if (value - value.round()).abs() < 1e-6 => { + format!("{}", value.round() as i64) + } + PaperUnits::Millimeters => format!("{value:.1}"), + PaperUnits::Inches => format!("{value:.2}"), + } +} + +/// `_(_x__)` exactly as the source application spells it: two decimals, +/// portrait order. +fn canonical_name(prefix: &str, width: f64, height: f64, units: PaperUnits) -> String { + format!( + "{prefix}_({width:.2}_x_{height:.2}_{})", + units.canonical_suffix() + ) +} + +/// Series and variant from a human label (`ISO full bleed A4`, `ARCH expand D`). +fn classify_label(label: &str) -> (PaperSeries, PaperVariant) { + let lower = label.to_ascii_lowercase(); + let variant = if lower.contains("full bleed") { + PaperVariant::FullBleed + } else if lower.contains("expand") { + PaperVariant::Expand + } else { + PaperVariant::Standard + }; + let series = if lower.starts_with("iso b") { + PaperSeries::IsoB + } else if lower.starts_with("iso") { + PaperSeries::IsoA + } else if lower.starts_with("ansi") { + PaperSeries::Ansi + } else if lower.starts_with("arch") { + PaperSeries::Arch + } else if lower.starts_with("jis") { + PaperSeries::Jis + } else { + PaperSeries::Other + }; + (series, variant) +} + +// ── Catalogue ───────────────────────────────────────────────────────────── + +/// Compact source row: series prefix as the source application writes it, sheet letter, +/// portrait width/height, units, and whether the source application ships full-bleed / +/// expand twins of it in `DWG To PDF.pc3`. +struct Row { + series: PaperSeries, + prefix: &'static str, + sheet: &'static str, + width: f64, + height: f64, + units: PaperUnits, + twins: bool, +} + +const fn mm( + series: PaperSeries, + prefix: &'static str, + sheet: &'static str, + w: f64, + h: f64, + twins: bool, +) -> Row { + Row { + series, + prefix, + sheet, + width: w, + height: h, + units: PaperUnits::Millimeters, + twins, + } +} + +const fn inch( + series: PaperSeries, + prefix: &'static str, + sheet: &'static str, + w: f64, + h: f64, + twins: bool, +) -> Row { + Row { + series, + prefix, + sheet, + width: w, + height: h, + units: PaperUnits::Inches, + twins, + } +} + +/// The sheets the source application's `DWG To PDF.pc3` offers, in its display order, plus +/// the ISO B / JIS B series that other drivers add and the US names system +/// printers report. +const ROWS: &[Row] = &[ + mm(PaperSeries::IsoA, "ISO", "A0", 841.0, 1189.0, true), + mm(PaperSeries::IsoA, "ISO", "A1", 594.0, 841.0, true), + mm(PaperSeries::IsoA, "ISO", "A2", 420.0, 594.0, true), + mm(PaperSeries::IsoA, "ISO", "A3", 297.0, 420.0, true), + mm(PaperSeries::IsoA, "ISO", "A4", 210.0, 297.0, true), + mm(PaperSeries::IsoA, "ISO", "A5", 148.0, 210.0, false), + mm(PaperSeries::IsoB, "ISO", "B0", 1000.0, 1414.0, false), + mm(PaperSeries::IsoB, "ISO", "B1", 707.0, 1000.0, false), + mm(PaperSeries::IsoB, "ISO", "B2", 500.0, 707.0, false), + mm(PaperSeries::IsoB, "ISO", "B3", 353.0, 500.0, false), + mm(PaperSeries::IsoB, "ISO", "B4", 250.0, 353.0, false), + mm(PaperSeries::IsoB, "ISO", "B5", 176.0, 250.0, false), + inch(PaperSeries::Ansi, "ANSI", "A", 8.5, 11.0, true), + inch(PaperSeries::Ansi, "ANSI", "B", 11.0, 17.0, true), + inch(PaperSeries::Ansi, "ANSI", "C", 17.0, 22.0, true), + inch(PaperSeries::Ansi, "ANSI", "D", 22.0, 34.0, true), + inch(PaperSeries::Ansi, "ANSI", "E", 34.0, 44.0, true), + inch(PaperSeries::Arch, "ARCH", "A", 9.0, 12.0, true), + inch(PaperSeries::Arch, "ARCH", "B", 12.0, 18.0, true), + inch(PaperSeries::Arch, "ARCH", "C", 18.0, 24.0, true), + inch(PaperSeries::Arch, "ARCH", "D", 24.0, 36.0, true), + inch(PaperSeries::Arch, "ARCH", "E", 36.0, 48.0, true), + inch(PaperSeries::Arch, "ARCH", "E1", 30.0, 42.0, true), + mm(PaperSeries::Jis, "JIS", "B0", 1030.0, 1456.0, false), + mm(PaperSeries::Jis, "JIS", "B1", 728.0, 1030.0, false), + mm(PaperSeries::Jis, "JIS", "B2", 515.0, 728.0, false), + mm(PaperSeries::Jis, "JIS", "B3", 364.0, 515.0, false), + mm(PaperSeries::Jis, "JIS", "B4", 257.0, 364.0, false), + mm(PaperSeries::Jis, "JIS", "B5", 182.0, 257.0, false), + inch(PaperSeries::Other, "Letter", "", 8.5, 11.0, false), + inch(PaperSeries::Other, "Legal", "", 8.5, 14.0, false), + inch(PaperSeries::Other, "Tabloid", "", 11.0, 17.0, false), +]; + +fn build_catalog() -> Vec { + let mut sizes = Vec::with_capacity(ROWS.len() * 3); + for row in ROWS { + let variants: &[PaperVariant] = if row.twins { + &[ + PaperVariant::Standard, + PaperVariant::FullBleed, + PaperVariant::Expand, + ] + } else { + &[PaperVariant::Standard] + }; + for &variant in variants { + // `ISO_full_bleed_A4`, `ANSI_expand_B`, `Letter`. + let prefix = if row.sheet.is_empty() { + row.prefix.to_string() + } else { + format!("{}_{}{}", row.prefix, variant.canonical_infix(), row.sheet) + }; + sizes.push(PaperSize { + canonical: Cow::Owned(canonical_name(&prefix, row.width, row.height, row.units)), + label: Cow::Owned(prefix.replace('_', " ")), + series: row.series, + variant, + width: row.width, + height: row.height, + units: row.units, + }); + } + } + sizes +} + +/// Every catalogued sheet in picker order. +pub fn catalog() -> &'static [PaperSize] { + static CATALOG: OnceLock> = OnceLock::new(); + CATALOG.get_or_init(build_catalog) +} + +/// The sheet a fresh drawing or a page setup without one falls back to. +pub fn default_paper() -> &'static PaperSize { + catalog() + .iter() + .find(|paper| paper.canonical == "ISO_A4_(210.00_x_297.00_MM)") + .expect("catalogue always carries ISO A4") +} + +/// Short names a system printer driver, an older Open CAD Studio config or a +/// user at the command line may spell a sheet with, mapped onto the canonical +/// sheet. `Ledger` is the landscape name of Tabloid; the sheet is the same. +const ALIASES: &[(&str, &str)] = &[ + ("A0", "ISO_A0_(841.00_x_1189.00_MM)"), + ("A1", "ISO_A1_(594.00_x_841.00_MM)"), + ("A2", "ISO_A2_(420.00_x_594.00_MM)"), + ("A3", "ISO_A3_(297.00_x_420.00_MM)"), + ("A4", "ISO_A4_(210.00_x_297.00_MM)"), + ("A5", "ISO_A5_(148.00_x_210.00_MM)"), + ("B4", "ISO_B4_(250.00_x_353.00_MM)"), + ("B5", "ISO_B5_(176.00_x_250.00_MM)"), + ("Letter", "Letter_(8.50_x_11.00_Inches)"), + ("US Letter", "Letter_(8.50_x_11.00_Inches)"), + ("Legal", "Legal_(8.50_x_14.00_Inches)"), + ("US Legal", "Legal_(8.50_x_14.00_Inches)"), + ("Tabloid", "Tabloid_(11.00_x_17.00_Inches)"), + ("Ledger", "Tabloid_(11.00_x_17.00_Inches)"), +]; + +/// Resolve any spelling of a sheet: an exact canonical name (case-insensitive, +/// underscores or spaces), a driver/config alias such as `A4` or `Letter`, or +/// an unknown but well-formed the source application name, which is reconstructed from its +/// embedded dimensions. `None` only for names that carry no size at all. +pub fn resolve(name: &str) -> Option { + let name = name.trim(); + if name.is_empty() { + return None; + } + let spaced = name.replace('_', " "); + if let Some(paper) = catalog().iter().find(|paper| { + paper + .canonical + .replace('_', " ") + .eq_ignore_ascii_case(&spaced) + }) { + return Some(paper.clone()); + } + if let Some((_, canonical)) = ALIASES + .iter() + .find(|(alias, _)| alias.eq_ignore_ascii_case(name)) + { + return catalog() + .iter() + .find(|paper| paper.canonical == *canonical) + .cloned(); + } + PaperSize::parse_canonical(name) +} + +/// The catalogued sheet whose portrait size matches `(w, h)` in millimetres +/// (either order) within half a millimetre. Standard variants win over their +/// full-bleed / expand twins so a size read from a drawing without a usable +/// name lands on the plain sheet. +pub fn match_dimensions_mm(w: f64, h: f64) -> Option<&'static PaperSize> { + const TOLERANCE_MM: f64 = 0.5; + let (short, long) = if w <= h { (w, h) } else { (h, w) }; + catalog() + .iter() + .filter(|paper| paper.variant == PaperVariant::Standard) + .filter(|paper| { + let (pw, ph) = paper.portrait_mm(); + (pw - short).abs() <= TOLERANCE_MM && (ph - long).abs() <= TOLERANCE_MM + }) + // ANSI A and Letter share a size; prefer the named series. + .min_by_key(|paper| paper.series) +} + +/// Sheet for a name as read from a drawing: the catalogued or reconstructed +/// sheet when the name carries one, otherwise the catalogued sheet with the +/// given dimensions, otherwise a custom sheet built from the dimensions alone. +pub fn from_drawing(name: &str, width_mm: f64, height_mm: f64) -> PaperSize { + if let Some(paper) = resolve(name) { + return paper; + } + if let Some(paper) = match_dimensions_mm(width_mm, height_mm) { + return paper.clone(); + } + if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 { + return default_paper().clone(); + } + PaperSize::custom(width_mm, height_mm, PaperUnits::Millimeters) +} + +// ── Plot geometry helpers ───────────────────────────────────────────────── + +#[derive(Copy, Clone, PartialEq, Debug)] +pub enum PlotScale { + /// Scale so the window fills the sheet minus a 5% margin. + Fit, + /// Exact scale factor (mm per drawing unit). + Ratio(f64), +} + +/// Map a world-space window onto a sheet. Returns (scale, offset_x, offset_y), +/// where the scaled window is centered on the sheet and (offset_x, offset_y) is +/// the sheet-mm position of the window's min corner. The caller turns that into +/// its own coordinate offset. +pub fn window_to_sheet( + window_wh: (f64, f64), + sheet_mm: (f64, f64), + scale: PlotScale, +) -> (f64, f64, f64) { + let (ww, wh) = (window_wh.0.max(1e-9), window_wh.1.max(1e-9)); + let scale = match scale { + PlotScale::Ratio(r) => r.max(1e-9), + PlotScale::Fit => { + const MARGIN: f64 = 1.05; + let sx = (sheet_mm.0 / MARGIN) / ww; + let sy = (sheet_mm.1 / MARGIN) / wh; + sx.min(sy) + } + }; + let scaled_w = ww * scale; + let scaled_h = wh * scale; + let offset_x = (sheet_mm.0 - scaled_w) / 2.0; + let offset_y = (sheet_mm.1 - scaled_h) / 2.0; + (scale, offset_x, offset_y) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn a4() -> PaperSize { + resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap() + } + + #[test] + fn catalogue_names_follow_source_app_spelling() { + let names: Vec<&str> = catalog().iter().map(|p| p.canonical.as_ref()).collect(); + for expected in [ + "ISO_A4_(210.00_x_297.00_MM)", + "ISO_full_bleed_A4_(210.00_x_297.00_MM)", + "ISO_expand_A0_(841.00_x_1189.00_MM)", + "ISO_B4_(250.00_x_353.00_MM)", + "ANSI_A_(8.50_x_11.00_Inches)", + "ANSI_full_bleed_B_(11.00_x_17.00_Inches)", + "ARCH_D_(24.00_x_36.00_Inches)", + "ARCH_expand_E1_(30.00_x_42.00_Inches)", + "JIS_B4_(257.00_x_364.00_MM)", + "Letter_(8.50_x_11.00_Inches)", + "Legal_(8.50_x_14.00_Inches)", + "Tabloid_(11.00_x_17.00_Inches)", + ] { + assert!(names.contains(&expected), "missing {expected}"); + } + // Every name is portrait and unique. + let mut seen = std::collections::HashSet::new(); + for paper in catalog() { + assert!( + paper.width <= paper.height, + "{} is not portrait", + paper.canonical + ); + assert!( + seen.insert(paper.canonical.clone()), + "duplicate {}", + paper.canonical + ); + } + } + + #[test] + fn every_catalogue_name_round_trips_through_the_parser() { + for paper in catalog() { + let parsed = PaperSize::parse_canonical(&paper.canonical) + .unwrap_or_else(|| panic!("cannot parse {}", paper.canonical)); + assert_eq!(parsed.canonical, paper.canonical); + assert_eq!(parsed.label, paper.label); + assert_eq!(parsed.series, paper.series); + assert_eq!(parsed.variant, paper.variant); + assert_eq!(parsed.units, paper.units); + assert!((parsed.width - paper.width).abs() < 1e-9); + assert!((parsed.height - paper.height).abs() < 1e-9); + } + } + + #[test] + fn unknown_canonical_names_are_reconstructed_and_preserved() { + // A custom size created in a plot dialog. + let custom = resolve("UserDefinedMetric_(420.00_x_297.00_MM)").unwrap(); + assert_eq!(custom.canonical, "UserDefinedMetric_(420.00_x_297.00_MM)"); + assert_eq!(custom.label, "UserDefinedMetric"); + assert_eq!(custom.series, PaperSeries::Other); + assert_eq!(custom.portrait_mm(), (297.0, 420.0)); + // A driver sheet the catalogue does not list, in inches. + let oversize = resolve("Oversize_A1_(625.00_x_880.00_MM)").unwrap(); + assert_eq!(oversize.portrait_mm(), (625.0, 880.0)); + // The dialog spelling with spaces resolves to the same canonical name. + let spaced = resolve("ISO full bleed A3 (297.00 x 420.00 MM)").unwrap(); + assert_eq!(spaced.canonical, "ISO_full_bleed_A3_(297.00_x_420.00_MM)"); + assert_eq!(spaced.variant, PaperVariant::FullBleed); + assert_eq!(spaced.series, PaperSeries::IsoA); + // A raster size or a bare driver name carries no usable dimensions. + assert!(resolve("Sun_Hi-Res_(1600.00_x_1280.00_Pixels)").is_none()); + assert!(resolve("A4 Plain").is_none()); + assert!(resolve("").is_none()); + } + + #[test] + fn aliases_and_case_insensitive_names_resolve() { + assert_eq!(resolve("A4").unwrap().canonical, a4().canonical); + assert_eq!( + resolve("a3").unwrap().canonical, + "ISO_A3_(297.00_x_420.00_MM)" + ); + assert_eq!( + resolve("Letter").unwrap().canonical, + "Letter_(8.50_x_11.00_Inches)" + ); + assert_eq!( + resolve("Ledger").unwrap().canonical, + "Tabloid_(11.00_x_17.00_Inches)" + ); + assert_eq!( + resolve("iso_a4_(210.00_x_297.00_mm)").unwrap().canonical, + a4().canonical + ); + } + + #[test] + fn dimensions_match_the_plain_sheet_in_either_orientation() { + assert_eq!( + match_dimensions_mm(297.0, 210.0).unwrap().canonical, + a4().canonical + ); + assert_eq!( + match_dimensions_mm(210.0, 297.0).unwrap().canonical, + a4().canonical + ); + assert_eq!( + match_dimensions_mm(210.3, 297.4).unwrap().canonical, + a4().canonical + ); + assert!(match_dimensions_mm(215.0, 297.0).is_none()); + // 8.5 × 11 in prefers ANSI A over the Letter alias sheet. + assert_eq!( + match_dimensions_mm(215.9, 279.4).unwrap().canonical, + "ANSI_A_(8.50_x_11.00_Inches)" + ); + assert_eq!( + match_dimensions_mm(609.6, 914.4).unwrap().canonical, + "ARCH_D_(24.00_x_36.00_Inches)" + ); + } + + #[test] + fn from_drawing_falls_back_from_name_to_size_to_custom() { + assert_eq!( + from_drawing("ISO_A3_(297.00_x_420.00_MM)", 0.0, 0.0).label, + "ISO A3" + ); + // A legacy drawing with a bare driver name and A4 dimensions. + assert_eq!( + from_drawing("Plain paper", 297.0, 210.0).canonical, + a4().canonical + ); + let custom = from_drawing("", 300.0, 500.0); + assert_eq!(custom.canonical, "UserDefinedMetric_(300.00_x_500.00_MM)"); + assert_eq!(custom.portrait_mm(), (300.0, 500.0)); + assert_eq!(from_drawing("", 0.0, f64::INFINITY), *default_paper()); + } + + #[test] + fn parser_rejects_non_finite_dimensions() { + assert!(PaperSize::parse_canonical("Custom_(inf_x_297.00_MM)").is_none()); + assert!(PaperSize::parse_canonical("Custom_(210.00_x_NaN_MM)").is_none()); + } + + #[test] + fn sheet_and_display_formatting() { + assert_eq!(a4().sheet_mm(Orientation::Portrait), (210.0, 297.0)); + assert_eq!(a4().sheet_mm(Orientation::Landscape), (297.0, 210.0)); + assert_eq!(a4().display(), "ISO A4 (210 × 297 mm)"); + let ansi_b = resolve("ANSI_B_(11.00_x_17.00_Inches)").unwrap(); + assert_eq!(ansi_b.display(), "ANSI B (11.00 × 17.00 in)"); + let (w, h) = ansi_b.portrait_mm(); + assert!((w - 279.4).abs() < 1e-9 && (h - 431.8).abs() < 1e-9); + assert_eq!( + PaperSize::custom(297.5, 210.0, PaperUnits::Millimeters).display(), + "UserDefinedMetric (210 × 297.5 mm)" + ); + assert_eq!(default_paper().canonical, a4().canonical); + } + + #[test] + fn user_defined_sheets_carry_their_name_and_margins() { + let roll = CustomPaper { + name: "Roll 24 (long)".into(), + width: 1500.0, + height: 609.6, + units: PaperUnits::Millimeters, + margins: Margins { + left: 5.0, + bottom: 17.0, + right: 6.0, + top: 18.0, + }, + }; + assert_eq!(roll.canonical(), "Roll_24_long_(609.60_x_1500.00_MM)"); + assert_eq!(roll.paper().label, "Roll 24 long"); + assert_eq!(roll.paper().display(), "Roll 24 long (609.6 × 1500 mm)"); + // Round-trips through the parser like any the source application name. + assert_eq!( + resolve(&roll.canonical()).unwrap().portrait_mm(), + (609.6, 1500.0) + ); + let arch = CustomPaper { + name: String::new(), + width: 30.0, + height: 42.0, + units: PaperUnits::Inches, + margins: Margins::uniform(0.25), + }; + assert_eq!(arch.canonical(), "UserDefinedInches_(30.00_x_42.00_Inches)"); + assert_eq!(arch.margins_mm(), Margins::uniform(6.35)); + } + + #[test] + fn fit_centers_and_scales_within_margin() { + // 100×100 window onto a 210×297 sheet, Fit. Limiting axis is width: + // usable = 210/1.05 = 200; scale = 200/100 = 2.0. + let (s, ox, oy) = window_to_sheet((100.0, 100.0), (210.0, 297.0), PlotScale::Fit); + assert!((s - 2.0).abs() < 1e-9, "scale {s}"); + // Window is 100*2 = 200 wide/tall; centered on 210×297. + assert!((ox - (210.0 - 200.0) / 2.0).abs() < 1e-9, "ox {ox}"); + assert!((oy - (297.0 - 200.0) / 2.0).abs() < 1e-9, "oy {oy}"); + } + + #[test] + fn ratio_applies_exact_scale_and_centers() { + // Ratio(0.5): scale is exactly 0.5; the scaled window is centered and + // (ox, oy) is the sheet-mm position of its min corner. + let (s, ox, oy) = window_to_sheet((100.0, 80.0), (210.0, 297.0), PlotScale::Ratio(0.5)); + assert!((s - 0.5).abs() < 1e-9); + // scaled window = 50×40; centered box origin = ((210-50)/2,(297-40)/2). + assert!((ox - (210.0 - 50.0) / 2.0).abs() < 1e-9, "ox {ox}"); + assert!((oy - (297.0 - 40.0) / 2.0).abs() < 1e-9, "oy {oy}"); + } +} diff --git a/src/io/paper_sizes.rs b/src/io/paper_sizes.rs deleted file mode 100644 index 916a961e3..000000000 --- a/src/io/paper_sizes.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! Standard paper sizes and sheet orientation for window plotting. - -#[derive(Copy, Clone, PartialEq, Eq, Debug)] -pub enum PaperSize { - A4, - A3, - A2, - A1, - A0, - Letter, - Legal, - Tabloid, -} - -impl PaperSize { - pub const ALL: [PaperSize; 8] = [ - PaperSize::A4, - PaperSize::A3, - PaperSize::A2, - PaperSize::A1, - PaperSize::A0, - PaperSize::Letter, - PaperSize::Legal, - PaperSize::Tabloid, - ]; - - pub fn label(self) -> &'static str { - match self { - PaperSize::A4 => "A4", - PaperSize::A3 => "A3", - PaperSize::A2 => "A2", - PaperSize::A1 => "A1", - PaperSize::A0 => "A0", - PaperSize::Letter => "Letter", - PaperSize::Legal => "Legal", - PaperSize::Tabloid => "Tabloid", - } - } - - /// Portrait dimensions in mm (width, height); width < height. - pub fn dimensions_mm(self) -> (f64, f64) { - match self { - PaperSize::A4 => (210.0, 297.0), - PaperSize::A3 => (297.0, 420.0), - PaperSize::A2 => (420.0, 594.0), - PaperSize::A1 => (594.0, 841.0), - PaperSize::A0 => (841.0, 1189.0), - PaperSize::Letter => (215.9, 279.4), - PaperSize::Legal => (215.9, 355.6), - PaperSize::Tabloid => (279.4, 431.8), - } - } -} - -#[derive(Copy, Clone, PartialEq, Eq, Debug)] -pub enum Orientation { - Portrait, - Landscape, -} - -/// Sheet dimensions in mm for the given size and orientation. -pub fn sheet_mm(size: PaperSize, o: Orientation) -> (f64, f64) { - let (w, h) = size.dimensions_mm(); - match o { - Orientation::Portrait => (w, h), - Orientation::Landscape => (h, w), - } -} - -#[derive(Copy, Clone, PartialEq, Debug)] -pub enum PlotScale { - /// Scale so the window fills the sheet minus a 5% margin. - Fit, - /// Exact scale factor (mm per drawing unit). - Ratio(f64), -} - -/// Map a world-space window onto a sheet. Returns (scale, offset_x, offset_y), -/// where the scaled window is centered on the sheet and (offset_x, offset_y) is -/// the sheet-mm position of the window's min corner. The caller turns that into -/// its own coordinate offset. -pub fn window_to_sheet( - window_wh: (f64, f64), - sheet_mm: (f64, f64), - scale: PlotScale, -) -> (f64, f64, f64) { - let (ww, wh) = (window_wh.0.max(1e-9), window_wh.1.max(1e-9)); - let scale = match scale { - PlotScale::Ratio(r) => r.max(1e-9), - PlotScale::Fit => { - const MARGIN: f64 = 1.05; - let sx = (sheet_mm.0 / MARGIN) / ww; - let sy = (sheet_mm.1 / MARGIN) / wh; - sx.min(sy) - } - }; - let scaled_w = ww * scale; - let scaled_h = wh * scale; - let offset_x = (sheet_mm.0 - scaled_w) / 2.0; - let offset_y = (sheet_mm.1 - scaled_h) / 2.0; - (scale, offset_x, offset_y) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn paper_dimensions_and_orientation() { - assert_eq!(PaperSize::A4.dimensions_mm(), (210.0, 297.0)); - assert_eq!(PaperSize::A0.dimensions_mm(), (841.0, 1189.0)); - assert_eq!(PaperSize::Letter.dimensions_mm(), (215.9, 279.4)); - assert_eq!(PaperSize::Legal.dimensions_mm(), (215.9, 355.6)); - assert_eq!(PaperSize::Tabloid.dimensions_mm(), (279.4, 431.8)); - assert_eq!(PaperSize::ALL.len(), 8); - assert_eq!(PaperSize::A3.label(), "A3"); - // Portrait keeps (w,h); landscape swaps. - assert_eq!(sheet_mm(PaperSize::A4, Orientation::Portrait), (210.0, 297.0)); - assert_eq!(sheet_mm(PaperSize::A4, Orientation::Landscape), (297.0, 210.0)); - } - - #[test] - fn fit_centers_and_scales_within_margin() { - // 100×100 window onto a 210×297 sheet, Fit. Limiting axis is width: - // usable = 210/1.05 = 200; scale = 200/100 = 2.0. - let (s, ox, oy) = window_to_sheet((100.0, 100.0), (210.0, 297.0), PlotScale::Fit); - assert!((s - 2.0).abs() < 1e-9, "scale {s}"); - // Window is 100*2 = 200 wide/tall; centered on 210×297. - assert!((ox - (210.0 - 200.0) / 2.0).abs() < 1e-9, "ox {ox}"); - assert!((oy - (297.0 - 200.0) / 2.0).abs() < 1e-9, "oy {oy}"); - } - - #[test] - fn ratio_applies_exact_scale_and_centers() { - // Ratio(0.5): scale is exactly 0.5; the scaled window is centered and - // (ox, oy) is the sheet-mm position of its min corner. - let (s, ox, oy) = window_to_sheet((100.0, 80.0), (210.0, 297.0), PlotScale::Ratio(0.5)); - assert!((s - 0.5).abs() < 1e-9); - // scaled window = 50×40; centered box origin = ((210-50)/2,(297-40)/2). - assert!((ox - (210.0 - 50.0) / 2.0).abs() < 1e-9, "ox {ox}"); - assert!((oy - (297.0 - 40.0) / 2.0).abs() < 1e-9, "oy {oy}"); - } -} diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 840555a8e..774ec9afa 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -375,7 +375,7 @@ fn image_triangle_matrix(p: [[f32; 2]; 3], uv: [[f32; 2]; 3]) -> Option<[f32; 6] /// The parent comes from `iced::window::run`, keeping the portal request tied /// to the visible app window on Wayland instead of silently resolving to /// `None` on desktops that reject a parentless save dialog (#537). -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] pub fn pick_pdf_path_owned( stem: String, parent: &dyn iced::window::Window, @@ -392,6 +392,25 @@ pub fn pick_pdf_path_owned( Some(path) } +/// Windows: pick the PDF destination with the async dialog on a worker +/// thread. The parented blocking dialog ran `IFileDialog::Show` on the UI +/// thread inside the window callback, and when the target name already +/// existed the overwrite-confirmation popup is a second nested modal that +/// never gets pumped there — the app froze instead of asking. The async +/// backend runs the dialog off-thread; it is the same pattern the DWG +/// Save As flow uses, whose confirm popup works. +#[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))] +pub async fn pick_pdf_path_async(stem: String) -> Option { + let handle = crate::sys::file_dialog() + .set_title(crate::t!("Export as PDF").as_ref()) + .set_file_name(format!("{stem}.pdf")) + .add_filter(crate::t!("PDF Files").as_ref(), &["pdf"]) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .save_file() + .await?; + Some(crate::sys::handle_path(&handle)) +} + // ── PDF builder ─────────────────────────────────────────────────────────── #[cfg(not(target_arch = "wasm32"))] @@ -586,6 +605,7 @@ fn append_pdf_page( emit_wire_fills( &mut ops, std::slice::from_ref(&wire.wire), + wire.draw_depth, ox, oy, plot_style, @@ -1017,6 +1037,7 @@ fn plotted_color( fn emit_wire_fills( ops: &mut Vec, wires: &[WireModel], + wire_depth: f32, ox: f64, oy: f64, plot_style: Option<&PlotStyleTable>, @@ -1071,7 +1092,11 @@ fn emit_wire_fills( 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), + // The host wire's composed draw depth (PlotWire carries + // wire_draw_depth). depth_override alone is a per-block + // child label and would sort the fill outside its block's + // band; keep the pattern fill co-sorted with its wire. + draw_depth: wire_depth, }; emit_hatch( ops, diff --git a/src/io/plot_device.rs b/src/io/plot_device.rs new file mode 100644 index 000000000..08aaaf4e0 --- /dev/null +++ b/src/io/plot_device.rs @@ -0,0 +1,631 @@ +//! Plot devices: what a page setup names as its plotter, and what that device +//! can print. +//! +//! the source application stores the device in `PlotSettings::printer_name` as the plotter +//! configuration's name — `DWG To PDF.pc3` for its PDF driver, the Windows +//! printer's name for a system printer, `None` when nothing is assigned. A +//! drawing saved here has to use the same spelling so the source application attaches the +//! layout to its own PDF driver instead of reporting a missing device; and a +//! device name that arrives from the source application (an HP plotter's `.pc3`, `DWF6 +//! ePlot.pc3`) must go back out untouched even though we cannot drive it. +//! +//! The device also decides the *printable area*: the sheet inset by the +//! driver's unprintable margins, which the source application writes into the layout and +//! draws as the dashed rectangle in paper space. The PDF driver's margins are +//! a fixed table; a CUPS printer reports its media and margins over IPP +//! (`media-col-database`), which is queried once per printer and cached. + +pub use crate::io::paper_catalog::Margins; +use crate::io::paper_catalog::{self, CustomPaper, PaperSize, PaperUnits, PaperVariant}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +/// the source application's plotter configuration name for PDF output. +pub const PDF_DEVICE_NAME: &str = "DWG To PDF.pc3"; +/// the source application's spelling for "no plotter assigned" in the plot dialog… +pub const NO_DEVICE_NAME: &str = "None"; +/// …and the name it actually stores in the drawing for that choice. +const NO_DEVICE_STORED_NAME: &str = "none_device"; +/// The label older Open CAD Studio builds wrote into drawings for PDF output. +/// Recognised on read so those drawings keep plotting to PDF, and always +/// rewritten with [`PDF_DEVICE_NAME`] on save. +const LEGACY_PDF_LABEL: &str = "Save to PDF file…"; +const WINDOWS_PDF_PRINTER: &str = "Microsoft Print to PDF"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PlotDevice { + /// No plotter assigned (the source application `None`). We plot to the system default + /// printer in that case, which is what the plot dialog offers. + None, + /// PDF file output, the source application's `DWG To PDF.pc3`. + Pdf, + /// A system printer by its queue / spooler name. + Printer(String), +} + +impl PlotDevice { + /// The name the source application would store for this device. + pub fn canonical_name(&self) -> String { + match self { + PlotDevice::None => NO_DEVICE_STORED_NAME.to_string(), + PlotDevice::Pdf => PDF_DEVICE_NAME.to_string(), + PlotDevice::Printer(name) => name.clone(), + } + } + + /// Interpret a stored `printer_name`. Returns the device and whether the + /// stored spelling should be preserved on save: an the source application plotter + /// configuration we cannot drive (`DWF6 ePlot.pc3`, an HP `.pc3`) is + /// treated as PDF output but keeps its name, so the drawing still attaches + /// to that plotter in the source application; our own legacy label and an empty name are + /// not worth preserving. + pub fn from_stored_name(name: &str) -> (Self, bool) { + let name = name.trim(); + if name.is_empty() || name.eq_ignore_ascii_case(NO_DEVICE_NAME) { + return (PlotDevice::None, false); + } + if name.eq_ignore_ascii_case(NO_DEVICE_STORED_NAME) { + // the source application's own spelling: keep it rather than rewriting it. + return (PlotDevice::None, true); + } + if name == LEGACY_PDF_LABEL || name.eq_ignore_ascii_case(WINDOWS_PDF_PRINTER) { + return (PlotDevice::Pdf, false); + } + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".pc3") { + // Any the source application plotter configuration: PDF ones are ours to drive, + // the rest plot to PDF here but keep their name for the source application. + return (PlotDevice::Pdf, true); + } + (PlotDevice::Printer(name.to_string()), true) + } + + /// Unprintable margins the device imposes on `paper`, in millimetres. + /// A sheet the user defined carries its own margins for every device, + /// the way a custom size in a plotter configuration does; a printer that + /// reports the same size still wins, since it knows its hardware. + pub fn margins_mm(&self, paper: &PaperSize, custom: &[CustomPaper]) -> Margins { + let user_defined = custom + .iter() + .find(|c| c.canonical() == paper.canonical) + .map(CustomPaper::margins_mm); + match self { + PlotDevice::Pdf => user_defined.unwrap_or_else(|| pdf_margins_mm(paper)), + PlotDevice::Printer(name) => cached_printer_capabilities(name) + .and_then(|caps| caps.margins_for(paper)) + .or(user_defined) + .unwrap_or(Margins::ZERO), + // No device: the source application keeps the margins of the last device or its + // defaults; without one we show the full sheet as printable. + PlotDevice::None => user_defined.unwrap_or(Margins::ZERO), + } + } +} + +// ── PDF driver ──────────────────────────────────────────────────────────── + +/// Unprintable margins of the source application's `DWG To PDF.pc3` sheets, as its Plotter +/// Configuration Editor lists them (Modify Standard Paper Sizes). They depend +/// on the sheet family and variant, not on the individual size: every ISO +/// sheet shares one set in millimetres, every ANSI / ARCH sheet one set in +/// inches. Order inside each set is left, bottom, right, top. +const PDF_MM_STANDARD: Margins = Margins { + left: 5.0, + bottom: 17.0, + right: 6.0, + top: 18.0, +}; +const PDF_MM_EXPAND: Margins = Margins { + left: 5.0, + bottom: 10.0, + right: 6.0, + top: 11.0, +}; +const PDF_MM_FULL_BLEED: Margins = Margins { + left: 0.0, + bottom: 1.0, + right: 0.0, + top: 1.0, +}; +const PDF_IN_STANDARD: Margins = Margins { + left: 0.23, + bottom: 0.70, + right: 0.23, + top: 0.70, +}; +const PDF_IN_EXPAND: Margins = Margins { + left: 0.23, + bottom: 0.42, + right: 0.23, + top: 0.42, +}; +const PDF_IN_FULL_BLEED: Margins = Margins::uniform(0.03); + +fn pdf_margins_mm(paper: &PaperSize) -> Margins { + let set = match (paper.units, paper.variant) { + (PaperUnits::Millimeters, PaperVariant::Standard) => PDF_MM_STANDARD, + (PaperUnits::Millimeters, PaperVariant::Expand) => PDF_MM_EXPAND, + (PaperUnits::Millimeters, PaperVariant::FullBleed) => PDF_MM_FULL_BLEED, + (PaperUnits::Inches, PaperVariant::Standard) => PDF_IN_STANDARD, + (PaperUnits::Inches, PaperVariant::Expand) => PDF_IN_EXPAND, + (PaperUnits::Inches, PaperVariant::FullBleed) => PDF_IN_FULL_BLEED, + }; + set.to_mm(paper.units) +} + +// ── System printers ─────────────────────────────────────────────────────── + +/// One sheet a printer reports it can load, with its unprintable margins. +#[derive(Clone, Debug, PartialEq)] +pub struct PrinterMedia { + /// The sheet as our catalogue knows it (matched by size), or a custom + /// sheet built from the reported dimensions. + pub paper: PaperSize, + pub margins: Margins, + /// `true` when the printer also reports a borderless variant of this size. + pub borderless: bool, +} + +/// What a printer reported about its media, cached for the session. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PrinterCapabilities { + /// Sheets in the printer's own order, one entry per distinct size. + pub media: Vec, + /// The printer's default sheet, when reported. + pub default_paper: Option, +} + +impl PrinterCapabilities { + /// Margins the printer reports for `paper`, matched by size. `None` when + /// the printer never mentioned a sheet of that size. + pub fn margins_for(&self, paper: &PaperSize) -> Option { + let (w, h) = paper.portrait_mm(); + self.media + .iter() + .find(|media| { + let (mw, mh) = media.paper.portrait_mm(); + (mw - w).abs() <= 0.5 && (mh - h).abs() <= 0.5 + }) + .map(|media| media.margins) + } +} + +/// Answers per printer for the session; a printer that could not be asked +/// is remembered as `None` too, so a slow failing query (an offline network +/// queue) runs once, not on every dialog open. +type CapabilityCache = HashMap>>; + +fn capability_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// The cached capabilities of `printer`, if they were queried already. +pub fn cached_printer_capabilities(printer: &str) -> Option> { + capability_cache().lock().ok()?.get(printer).cloned().flatten() +} + +/// Query `printer` for its media (blocking: a process spawn or a driver +/// round trip) and cache the answer. Returns `None` when the platform or +/// the printer cannot answer — the plot dialog then falls back to the paper +/// catalogue with unverified margins. +pub fn printer_capabilities(printer: &str) -> Option> { + if let Ok(cache) = capability_cache().lock() { + if let Some(answer) = cache.get(printer) { + return answer.clone(); + } + } + let caps = query_printer_capabilities(printer).map(Arc::new); + if let Ok(mut cache) = capability_cache().lock() { + cache.insert(printer.to_string(), caps.clone()); + } + caps +} + +/// Name of the system default printer, when the platform reports one. +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +pub fn default_printer_name() -> Option { + // `lpstat -d` → "system default destination: NAME" (the prefix is + // localized, the name follows the last colon). + let output = std::process::Command::new("lpstat") + .arg("-d") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let line = text.lines().find(|line| line.contains(':'))?; + let name = line.rsplit(':').next()?.trim(); + (!name.is_empty()).then(|| name.to_string()) +} + +/// The spooler's default printer, by name — the same lookup every direct +/// print job resolves "Default" through, so the dialog names what will print. +#[cfg(target_os = "windows")] +pub fn default_printer_name() -> Option { + crate::io::print_to_printer::windows_default_printer().ok() +} + +#[cfg(target_arch = "wasm32")] +pub fn default_printer_name() -> Option { + None +} + +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn query_printer_capabilities(printer: &str) -> Option { + // cupsd answers for the queue itself, so this is quick even when the + // printer is off; `ipptool` is part of every CUPS install that has + // `lpstat`. The standard test file asks for every attribute; the two we + // read are `media-col-database` and `media-default`. + let uri = format!("ipp://localhost/printers/{}", printer.replace(' ', "%20")); + let output = std::process::Command::new("ipptool") + .args(["-tv", &uri, "get-printer-attributes.test"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_ipp_media(&String::from_utf8_lossy(&output.stdout)) +} + +/// The driver's sheet tables and a printer DC's margins (see +/// `windows_media`); `None` for a queue that lists no sheet. +#[cfg(target_os = "windows")] +fn query_printer_capabilities(printer: &str) -> Option { + let raw = crate::io::print_to_printer::windows_printer_media(printer)?; + crate::io::windows_media::printer_media(&raw) +} + +#[cfg(target_arch = "wasm32")] +fn query_printer_capabilities(_printer: &str) -> Option { + None +} + +/// Read the media a printer reports from `ipptool -tv` output. +/// +/// The relevant lines look like +/// `media-col-database (1setOf collection) = {media-size={x-dimension=21000 +/// y-dimension=29700} media-bottom-margin=300 media-left-margin=300 …},{…}` +/// with sizes and margins in hundredths of a millimetre, and +/// `media-default (keyword) = iso_a4_210x297mm`. Borderless variants repeat a +/// size with zero margins; they collapse onto the bordered entry. +#[cfg_attr(any(target_arch = "wasm32", target_os = "windows"), allow(dead_code))] +fn parse_ipp_media(text: &str) -> Option { + let database = attribute_value(text, "media-col-database")?; + let mut media: Vec = Vec::new(); + for entry in database.split("},{") { + let Some((w, h)) = collection_size_mm(entry) else { + continue; + }; + let margins = Margins { + left: hundredths_mm(entry, "media-left-margin"), + bottom: hundredths_mm(entry, "media-bottom-margin"), + right: hundredths_mm(entry, "media-right-margin"), + top: hundredths_mm(entry, "media-top-margin"), + }; + let borderless = margins == Margins::ZERO; + if let Some(existing) = media.iter_mut().find(|m| { + let (mw, mh) = m.paper.portrait_mm(); + let (pw, ph) = portrait(w, h); + (mw - pw).abs() <= 0.5 && (mh - ph).abs() <= 0.5 + }) { + // Keep the bordered margins as the sheet's printable area and + // remember that edge-to-edge is possible too. + if borderless { + existing.borderless = true; + } else if existing.margins == Margins::ZERO { + existing.margins = margins; + existing.borderless = true; + } + continue; + } + let (pw, ph) = portrait(w, h); + let paper = paper_catalog::match_dimensions_mm(pw, ph) + .cloned() + .unwrap_or_else(|| PaperSize::custom(pw, ph, PaperUnits::Millimeters)); + media.push(PrinterMedia { + paper, + margins, + borderless, + }); + } + if media.is_empty() { + return None; + } + let default_paper = attribute_value(text, "media-default") + .and_then(|name| pwg_size_mm(name.trim())) + .and_then(|(w, h)| { + let (w, h) = portrait(w, h); + media + .iter() + .find(|m| { + let (mw, mh) = m.paper.portrait_mm(); + (mw - w).abs() <= 0.5 && (mh - h).abs() <= 0.5 + }) + .map(|m| m.paper.clone()) + }); + Some(PrinterCapabilities { + media, + default_paper, + }) +} + +/// The text after `name (…) = ` on the line that declares `name`. +#[cfg_attr(any(target_arch = "wasm32", target_os = "windows"), allow(dead_code))] +fn attribute_value<'a>(text: &'a str, name: &str) -> Option<&'a str> { + text.lines().find_map(|line| { + let line = line.trim_start(); + let rest = line.strip_prefix(name)?; + // The attribute name is followed by its syntax in parentheses. + if !rest.starts_with(' ') && !rest.starts_with('(') { + return None; + } + let (_, value) = rest.split_once('=')?; + Some(value.trim()) + }) +} + +#[cfg_attr(any(target_arch = "wasm32", target_os = "windows"), allow(dead_code))] +fn collection_size_mm(entry: &str) -> Option<(f64, f64)> { + let x = hundredths(entry, "x-dimension")?; + let y = hundredths(entry, "y-dimension")?; + (x > 0.0 && y > 0.0).then_some((x, y)) +} + +#[cfg_attr(any(target_arch = "wasm32", target_os = "windows"), allow(dead_code))] +fn hundredths(entry: &str, key: &str) -> Option { + let start = entry.find(key)? + key.len(); + let rest = entry[start..].trim_start_matches('='); + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse::().ok().map(|v| v / 100.0) +} + +#[cfg_attr(any(target_arch = "wasm32", target_os = "windows"), allow(dead_code))] +fn hundredths_mm(entry: &str, key: &str) -> f64 { + hundredths(entry, key).unwrap_or(0.0) +} + +#[cfg_attr(any(target_arch = "wasm32", target_os = "windows"), allow(dead_code))] +fn portrait(w: f64, h: f64) -> (f64, f64) { + if w <= h { + (w, h) + } else { + (h, w) + } +} + +/// Dimensions from a PWG self-describing media name such as +/// `iso_a4_210x297mm` or `na_letter_8.5x11in`. +#[cfg_attr(any(target_arch = "wasm32", target_os = "windows"), allow(dead_code))] +fn pwg_size_mm(name: &str) -> Option<(f64, f64)> { + let dims = name.rsplit('_').next()?; + let (dims, units) = dims + .strip_suffix("mm") + .map(|d| (d, PaperUnits::Millimeters)) + .or_else(|| dims.strip_suffix("in").map(|d| (d, PaperUnits::Inches)))?; + let (w, h) = dims.split_once('x')?; + let w: f64 = w.parse().ok()?; + let h: f64 = h.parse().ok()?; + Some((units.to_mm(w), units.to_mm(h))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const IPP_SAMPLE: &str = " media-default (keyword) = iso_a4_210x297mm\n\ + media-supported (1setOf keyword) = iso_a4_210x297mm,om_a-4.borderless_210x297mm,na_letter_8.5x11in,custom_215x345mm_215x345mm\n\ + media-col-database (1setOf collection) = {media-size={x-dimension=21000 y-dimension=29700} media-bottom-margin=300 media-left-margin=300 media-right-margin=300 media-top-margin=300},{media-size={x-dimension=21000 y-dimension=29700} media-bottom-margin=0 media-left-margin=0 media-right-margin=0 media-top-margin=0},{media-size={x-dimension=21590 y-dimension=27940} media-bottom-margin=635 media-left-margin=635 media-right-margin=635 media-top-margin=635},{media-size={x-dimension=21500 y-dimension=34500} media-bottom-margin=300 media-left-margin=300 media-right-margin=300 media-top-margin=300}\n"; + + #[test] + fn canonical_names_follow_source_app() { + assert_eq!(PlotDevice::Pdf.canonical_name(), "DWG To PDF.pc3"); + // What the source application writes for "None" in its plot dialog. + assert_eq!(PlotDevice::None.canonical_name(), "none_device"); + assert_eq!( + PlotDevice::Printer("L3270".into()).canonical_name(), + "L3270" + ); + } + + #[test] + fn stored_names_resolve_and_say_whether_to_keep_them() { + assert_eq!(PlotDevice::from_stored_name(""), (PlotDevice::None, false)); + assert_eq!( + PlotDevice::from_stored_name("None"), + (PlotDevice::None, false) + ); + assert_eq!( + PlotDevice::from_stored_name("none_device"), + (PlotDevice::None, true) + ); + assert_eq!( + PlotDevice::from_stored_name("NONE_DEVICE"), + (PlotDevice::None, true) + ); + assert_eq!( + PlotDevice::from_stored_name("Save to PDF file…"), + (PlotDevice::Pdf, false) + ); + assert_eq!( + PlotDevice::from_stored_name("Microsoft Print to PDF"), + (PlotDevice::Pdf, false) + ); + assert_eq!( + PlotDevice::from_stored_name("DWG To PDF.pc3"), + (PlotDevice::Pdf, true) + ); + assert_eq!( + PlotDevice::from_stored_name("the source application PDF (High Quality Print).pc3"), + (PlotDevice::Pdf, true) + ); + // A plotter we cannot drive plots to PDF here but keeps its name. + assert_eq!( + PlotDevice::from_stored_name("DWF6 ePlot.pc3"), + (PlotDevice::Pdf, true) + ); + assert_eq!( + PlotDevice::from_stored_name("HP LaserJet 4"), + (PlotDevice::Printer("HP LaserJet 4".into()), true) + ); + } + + #[test] + fn pdf_margins_follow_source_app_per_family_and_variant() { + let m = + |name: &str| PlotDevice::Pdf.margins_mm(&paper_catalog::resolve(name).unwrap(), &[]); + // ISO sheets share one millimetre set regardless of size. + let iso_standard = Margins { + left: 5.0, + bottom: 17.0, + right: 6.0, + top: 18.0, + }; + assert_eq!(m("ISO_A4_(210.00_x_297.00_MM)"), iso_standard); + assert_eq!(m("ISO_A0_(841.00_x_1189.00_MM)"), iso_standard); + assert_eq!( + m("ISO_expand_A4_(210.00_x_297.00_MM)"), + Margins { + left: 5.0, + bottom: 10.0, + right: 6.0, + top: 11.0 + } + ); + assert_eq!( + m("ISO_full_bleed_A4_(210.00_x_297.00_MM)"), + Margins { + left: 0.0, + bottom: 1.0, + right: 0.0, + top: 1.0 + } + ); + // Inch sheets are listed in inches and stored in millimetres. + let close = |a: Margins, b: Margins| { + (a.left - b.left).abs() < 1e-9 + && (a.bottom - b.bottom).abs() < 1e-9 + && (a.right - b.right).abs() < 1e-9 + && (a.top - b.top).abs() < 1e-9 + }; + let inch = |l: f64, b: f64, r: f64, t: f64| Margins { + left: l * 25.4, + bottom: b * 25.4, + right: r * 25.4, + top: t * 25.4, + }; + assert!(close( + m("ANSI_A_(8.50_x_11.00_Inches)"), + inch(0.23, 0.70, 0.23, 0.70) + )); + assert!(close( + m("ARCH_D_(24.00_x_36.00_Inches)"), + inch(0.23, 0.70, 0.23, 0.70) + )); + assert!(close( + m("ARCH_expand_D_(24.00_x_36.00_Inches)"), + inch(0.23, 0.42, 0.23, 0.42) + )); + assert!(close( + m("ANSI_full_bleed_B_(11.00_x_17.00_Inches)"), + inch(0.03, 0.03, 0.03, 0.03) + )); + assert_eq!( + PlotDevice::None.margins_mm( + &paper_catalog::resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap(), + &[] + ), + Margins::ZERO + ); + } + + #[test] + fn user_defined_sheets_bring_their_own_margins() { + let roll = CustomPaper { + name: "Roll".into(), + width: 900.0, + height: 1200.0, + units: PaperUnits::Millimeters, + margins: Margins::uniform(3.0), + }; + let paper = roll.paper(); + assert_eq!( + PlotDevice::Pdf.margins_mm(&paper, &[roll.clone()]), + Margins::uniform(3.0) + ); + assert_eq!( + PlotDevice::None.margins_mm(&paper, &[roll.clone()]), + Margins::uniform(3.0) + ); + // An unknown printer falls back to the user's margins too. + assert_eq!( + PlotDevice::Printer("Nowhere".into()).margins_mm(&paper, &[roll]), + Margins::uniform(3.0) + ); + // Without a definition a custom-looking sheet gets the family set. + assert_eq!( + PlotDevice::Pdf.margins_mm(&paper, &[]), + Margins { + left: 5.0, + bottom: 17.0, + right: 6.0, + top: 18.0 + } + ); + } + + #[test] + fn ipp_media_parses_sizes_margins_borderless_and_default() { + let caps = parse_ipp_media(IPP_SAMPLE).expect("media parsed"); + assert_eq!( + caps.media.len(), + 3, + "borderless A4 folds into the bordered entry" + ); + let a4 = &caps.media[0]; + assert_eq!(a4.paper.canonical, "ISO_A4_(210.00_x_297.00_MM)"); + assert_eq!(a4.margins, Margins::uniform(3.0)); + assert!(a4.borderless); + let letter = &caps.media[1]; + assert_eq!(letter.paper.canonical, "ANSI_A_(8.50_x_11.00_Inches)"); + assert_eq!(letter.margins, Margins::uniform(6.35)); + assert!(!letter.borderless); + let custom = &caps.media[2]; + assert_eq!( + custom.paper.canonical, + "UserDefinedMetric_(215.00_x_345.00_MM)" + ); + assert_eq!( + caps.default_paper.as_ref().map(|p| p.canonical.as_ref()), + Some("ISO_A4_(210.00_x_297.00_MM)") + ); + // Margin lookup by sheet size, either orientation, tolerant of the + // catalogue's inch → mm rounding. + let ansi_a = paper_catalog::resolve("Letter_(8.50_x_11.00_Inches)").unwrap(); + assert_eq!(caps.margins_for(&ansi_a), Some(Margins::uniform(6.35))); + let a3 = paper_catalog::resolve("ISO_A3_(297.00_x_420.00_MM)").unwrap(); + assert_eq!(caps.margins_for(&a3), None); + } + + #[test] + fn ipp_output_without_media_is_rejected() { + assert!(parse_ipp_media("printer-state (enum) = 3\n").is_none()); + assert!(parse_ipp_media( + "media-col-database (1setOf collection) = {media-size={x-dimension=0 y-dimension=0}}\n" + ) + .is_none()); + } + + #[test] + fn pwg_names_carry_their_size() { + assert_eq!(pwg_size_mm("iso_a4_210x297mm"), Some((210.0, 297.0))); + let (w, h) = pwg_size_mm("na_letter_8.5x11in").unwrap(); + assert!((w - 215.9).abs() < 1e-9 && (h - 279.4).abs() < 1e-9); + assert_eq!( + pwg_size_mm("custom_215x345mm_215x345mm"), + Some((215.0, 345.0)) + ); + assert_eq!(pwg_size_mm("iso_a4"), None); + } +} diff --git a/src/io/plot_style.rs b/src/io/plot_style.rs index 89b2033c6..4e9e3d9e6 100644 --- a/src/io/plot_style.rs +++ b/src/io/plot_style.rs @@ -175,6 +175,10 @@ pub struct PlotStyleTable { pub aci_entries: Vec, // 256 entries, index = ACI /// For STB: named style entries. pub named_entries: HashMap, + /// What had to be tolerated to read the file (a bad checksum, a length + /// that did not add up, descriptions in a Windows code page): the file + /// loaded, but the user deserves to know it was not quite as written. + pub load_warnings: Vec, } impl PlotStyleTable { @@ -190,6 +194,7 @@ impl PlotStyleTable { lineweights: LW_TABLE.to_vec(), aci_entries: (0..=255).map(|_| PlotStyleEntry::default()).collect(), named_entries: HashMap::default(), + load_warnings: Vec::new(), } } @@ -207,8 +212,10 @@ impl PlotStyleTable { pub fn from_bytes(name: impl Into, raw: &[u8]) -> Result { let name = name.into(); let is_stb = name.to_ascii_lowercase().ends_with(".stb"); - let text = decompress_ctb(raw)?; - parse_plot_style_text(&text, name, is_stb) + let (text, warnings) = decompress_ctb(raw)?; + let mut table = parse_plot_style_text(&text, name, is_stb)?; + table.load_warnings = warnings; + Ok(table) } pub fn builtin(name: &str) -> Result { @@ -264,12 +271,18 @@ impl PlotStyleTable { let path = Path::new(name); if path.components().count() != 1 || !matches!(path.components().next(), Some(Component::Normal(_))) - || !path - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("ctb")) { return Err(format!("Invalid plot style name: {name}")); } + if path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("stb")) { + return Err(crate::tf!( + "Named plot style tables (.stb) are not supported yet: {name}" + ) + .into_owned()); + } + if !path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("ctb")) { + return Err(format!("Invalid plot style name: {name}")); + } #[cfg(not(target_arch = "wasm32"))] { @@ -286,7 +299,12 @@ impl PlotStyleTable { .map(|entry| entry.path()); return match matched { Some(path) => Self::load(&path), - None => Self::builtin(name), + None => Self::builtin(name).map_err(|_| { + // Not a built-in either: say where a copy would be found. + let folder = dir.display(); + crate::tf!("{name} was not found in the plot styles folder ({folder}).") + .into_owned() + }), }; } @@ -424,10 +442,18 @@ impl PlotStyleTable { // ── Deflate helpers ─────────────────────────────────────────────────────────── -/// Decompress a CTB/STB file's raw bytes into the text content. +/// Decompress a CTB/STB file's raw bytes into the text content, with the +/// things that had to be tolerated on the way. /// -fn decompress_ctb(data: &[u8]) -> Result { +/// Files written by other applications are read as they are, not as the +/// container says they should be: a checksum or a length that does not add +/// up is reported and read past (the text is either there or the zlib +/// stream fails on its own), a zlib stream with a bad trailer is retried as +/// raw deflate, and text that is not UTF-8 is read as Windows-1252 — the +/// code page those applications write descriptions in. +fn decompress_ctb(data: &[u8]) -> Result<(String, Vec), String> { const PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec"; + let mut warnings = Vec::new(); let mut decoded = Vec::new(); if data.starts_with(PREFIX) { if data.len() < 60 { @@ -436,20 +462,34 @@ fn decompress_ctb(data: &[u8]) -> Result { let checksum = u32::from_le_bytes(data[48..52].try_into().unwrap()); let text_len = u32::from_le_bytes(data[52..56].try_into().unwrap()) as usize; let compressed_len = u32::from_le_bytes(data[56..60].try_into().unwrap()) as usize; - if compressed_len > data.len() - 60 { - return Err("CTB compressed payload is truncated".into()); - } - let payload = &data[60..60 + compressed_len]; + let available = data.len() - 60; + let payload = if compressed_len > available { + warnings.push(format!( + "the header announces {compressed_len} compressed bytes \ + but the file holds {available}" + )); + &data[60..] + } else { + &data[60..60 + compressed_len] + }; if adler32(payload) != checksum { - return Err("CTB compressed payload checksum mismatch".into()); + warnings.push("the compressed payload's checksum does not match".into()); } - use flate2::read::ZlibDecoder; - ZlibDecoder::new(payload) - .read_to_end(&mut decoded) - .map_err(|e| format!("CTB zlib decompress: {e}"))?; - if decoded.len() != text_len { + decoded = inflate_lenient(payload, &mut warnings)?; + // A byte or a few short is a header that counted its trailing NUL + // differently; far short of the announced text is a cut-off file, + // which the inflater does not always notice on its own. + const TOLERATED_SHORTFALL: usize = 16; + if decoded.len() + TOLERATED_SHORTFALL < text_len { return Err(format!( - "CTB content length mismatch: expected {text_len}, got {}", + "CTB text is incomplete: the header announces {text_len} bytes \ + but only {} could be read", + decoded.len() + )); + } + if decoded.len() != text_len { + warnings.push(format!( + "the header announces {text_len} bytes of text but {} were read", decoded.len() )); } @@ -461,10 +501,7 @@ fn decompress_ctb(data: &[u8]) -> Result { .unwrap_or(0); let payload = &data[split_at..]; if payload.starts_with(&[0x78]) { - use flate2::read::ZlibDecoder; - ZlibDecoder::new(payload) - .read_to_end(&mut decoded) - .map_err(|e| format!("legacy CTB zlib decompress: {e}"))?; + decoded = inflate_lenient(payload, &mut warnings)?; } else { use flate2::read::DeflateDecoder; DeflateDecoder::new(payload) @@ -472,10 +509,72 @@ fn decompress_ctb(data: &[u8]) -> Result { .map_err(|e| format!("legacy CTB deflate decompress: {e}"))?; } } - if decoded.last() == Some(&0) { + while decoded.last() == Some(&0) { decoded.pop(); } - String::from_utf8(decoded).map_err(|e| format!("CTB text is not UTF-8: {e}")) + let (text, code_page) = decode_plot_style_text(decoded); + if let Some(note) = code_page { + warnings.push(note); + } + Ok((text, warnings)) +} + +/// Inflate a zlib stream, falling back to the raw deflate data behind its +/// two-byte header when the stream's own trailer is wrong. +fn inflate_lenient(payload: &[u8], warnings: &mut Vec) -> Result, String> { + use flate2::read::{DeflateDecoder, ZlibDecoder}; + let mut decoded = Vec::new(); + match ZlibDecoder::new(payload).read_to_end(&mut decoded) { + Ok(_) => Ok(decoded), + Err(zlib_error) if payload.len() > 2 => { + let mut raw = Vec::new(); + DeflateDecoder::new(&payload[2..]) + .read_to_end(&mut raw) + .map_err(|_| format!("CTB zlib decompress: {zlib_error}"))?; + warnings.push("the zlib stream is damaged; its deflate data was read directly".into()); + Ok(raw) + } + Err(zlib_error) => Err(format!("CTB zlib decompress: {zlib_error}")), + } +} + +/// The table's text out of its bytes: UTF-8 when it is, else Windows-1252 +/// (Latin-1 with the 0x80–0x9F block the code page defines), which is what +/// applications on Western systems write into descriptions. Other code +/// pages come out as readable but wrong characters in descriptions only — +/// pens and colours are numbers and unaffected. The second value says when +/// the fallback was taken. +pub fn decode_plot_style_text(bytes: Vec) -> (String, Option) { + match String::from_utf8(bytes) { + Ok(text) => (text, None), + Err(error) => { + let text = error + .into_bytes() + .into_iter() + .map(windows_1252_char) + .collect(); + ( + text, + Some("descriptions were read as Windows-1252 text (the file is not UTF-8)".into()), + ) + } + } +} + +/// One Windows-1252 byte as a character: Latin-1 except for the 0x80–0x9F +/// block, which the code page fills with punctuation and accented letters. +fn windows_1252_char(byte: u8) -> char { + const HIGH: [char; 32] = [ + '\u{20AC}', '\u{FFFD}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}', + '\u{2021}', '\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{FFFD}', + '\u{017D}', '\u{FFFD}', '\u{FFFD}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}', + '\u{2022}', '\u{2013}', '\u{2014}', '\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}', + '\u{0153}', '\u{FFFD}', '\u{017E}', '\u{0178}', + ]; + match byte { + 0x80..=0x9F => HIGH[usize::from(byte - 0x80)], + _ => char::from(byte), + } } /// Compress plot-style text content as a CTB/STB file. @@ -686,6 +785,7 @@ fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result PlotStyleTable { + let mut table = PlotStyleTable::identity("foreign.ctb"); + table.description = description.to_string(); + table.aci_entries[1].lineweight = 13; + table + } + + fn compressed(table: &PlotStyleTable) -> Vec { + compress_ctb(table.to_text().as_bytes()).expect("compress") + } + + /// The pen data must survive every tolerated defect. + fn assert_pens_kept(table: &PlotStyleTable) { + assert_eq!(table.aci_entries[1].lineweight, 13); + } + + #[test] + fn a_wrong_checksum_is_reported_not_fatal() { + let mut bytes = compressed(&table_with_description("ok")); + bytes[48] ^= 0xFF; + let table = PlotStyleTable::from_bytes("foreign.ctb", &bytes).expect("loads"); + assert_pens_kept(&table); + assert_eq!(table.load_warnings.len(), 1, "{:?}", table.load_warnings); + assert!(table.load_warnings[0].contains("checksum")); + } + + #[test] + fn a_text_length_off_by_one_and_a_short_payload_field_are_tolerated() { + let mut bytes = compressed(&table_with_description("ok")); + let text_len = u32::from_le_bytes(bytes[52..56].try_into().unwrap()); + bytes[52..56].copy_from_slice(&(text_len - 1).to_le_bytes()); + let table = PlotStyleTable::from_bytes("foreign.ctb", &bytes).expect("loads"); + assert_pens_kept(&table); + assert!(table.load_warnings.iter().any(|w| w.contains("bytes of text"))); + + let mut bytes = compressed(&table_with_description("ok")); + let compressed_len = u32::from_le_bytes(bytes[56..60].try_into().unwrap()); + bytes[56..60].copy_from_slice(&(compressed_len + 100).to_le_bytes()); + let table = PlotStyleTable::from_bytes("foreign.ctb", &bytes).expect("loads"); + assert_pens_kept(&table); + assert!(table.load_warnings.iter().any(|w| w.contains("compressed bytes"))); + } + + #[test] + fn windows_1252_descriptions_are_read_with_a_note() { + // "Descripción" and "Ελληνικά" are not what a Western file holds; a + // Western one holds e.g. "Plumas – señal" in Windows-1252. + let text = table_with_description("marker").to_text(); + let mut bytes = text.into_bytes(); + let marker = bytes.windows(6).position(|w| w == b"marker").unwrap(); + let cp1252 = [ + 0x50, 0x6C, 0x75, 0x6D, 0x61, 0x73, 0x20, 0x96, 0x20, 0x73, 0x65, 0xF1, 0x61, 0x6C, + ]; + bytes.splice(marker..marker + 6, cp1252); + let compressed = compress_ctb(&bytes).unwrap(); + let table = PlotStyleTable::from_bytes("foreign.ctb", &compressed).expect("loads"); + assert_pens_kept(&table); + assert_eq!(table.description, "Plumas – señal"); + assert!(table.load_warnings.iter().any(|w| w.contains("Windows-1252"))); + // Clean UTF-8 gets no note. + let (text, note) = decode_plot_style_text("Ελληνικά".as_bytes().to_vec()); + assert_eq!(text, "Ελληνικά"); + assert!(note.is_none()); + } + + #[test] + fn a_truncated_stream_is_still_an_error() { + let bytes = compressed(&table_with_description("ok")); + let cut = &bytes[..bytes.len() / 2]; + let error = PlotStyleTable::from_bytes("foreign.ctb", cut).unwrap_err(); + assert!(error.contains("incomplete"), "{error}"); + } + + #[test] + fn named_tables_and_missing_files_get_a_reason() { + let error = PlotStyleTable::load_named("styles.stb").unwrap_err(); + assert!(error.contains(".stb"), "{error}"); + let error = PlotStyleTable::load_named("nowhere-to-be-found.ctb").unwrap_err(); + assert!(error.contains("nowhere-to-be-found.ctb"), "{error}"); + assert!(PlotStyleTable::load_named("../escape.ctb").is_err()); + } +} diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 35129dd61..ad68fea52 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -47,11 +47,17 @@ pub struct PrintOptions { /// setting wins, so the field is legitimately unread there. #[cfg_attr(target_os = "windows", allow(dead_code))] pub quality: Option, + /// Driver options chosen in the printer-properties editor, as CUPS + /// `key=value` pairs (`ColorModel=Gray`, `MediaType=Photographic`). Read + /// on the CUPS path (`lp -o`); Windows keeps such choices in the + /// printer's own preferences instead. + #[cfg_attr(target_os = "windows", allow(dead_code))] + pub driver_options: Vec<(String, String)>, } #[cfg(target_arch = "wasm32")] -pub fn list_printers() -> Vec { - Vec::new() +pub fn list_printers() -> Result, String> { + Ok(Vec::new()) } #[cfg(target_arch = "wasm32")] @@ -74,40 +80,300 @@ pub fn print_existing_pdf(_path: &std::path::Path, _opts: &PrintOptions) -> Resu /// Enumerate installed printers. Linux/macOS query CUPS via `lpstat -e`; /// Windows queries the spooler's cached local and connected printer list. +/// An error is the system's own explanation (a stopped spooler, a failing +/// RPC); a desktop without CUPS at all is not an error but an empty list. #[cfg(not(target_arch = "wasm32"))] -pub fn list_printers() -> Vec { +pub fn list_printers() -> Result, String> { #[cfg(not(target_os = "windows"))] { - std::process::Command::new("lpstat") - .arg("-e") - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| { - String::from_utf8_lossy(&o.stdout) - .lines() - .map(|l| l.trim().to_string()) - .filter(|l| !l.is_empty()) - .collect() - }) - .unwrap_or_default() + let output = match std::process::Command::new("lpstat").arg("-e").output() { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(error.to_string()), + }; + // `lpstat -e` exits non-zero with "No destinations added" when CUPS + // runs but knows no queue — an empty list, not a failure. + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect()) } #[cfg(target_os = "windows")] { - windows_printers().unwrap_or_else(|error| { - eprintln!("Could not enumerate printers: {error}"); - Vec::new() - }) + windows_printers().map_err(|error| error.to_string()) + } +} + +/// The printing situation as the system reports it, one line per fact: +/// the default printer, the printers listed (or the reason none were), and +/// on Windows the route a plot takes. Shown by the `PRINTERS` command. +#[cfg(not(target_arch = "wasm32"))] +pub fn printer_report() -> Vec { + let mut lines = Vec::new(); + match crate::io::plot_device::default_printer_name() { + Some(name) => lines.push(crate::tf!("Default printer: {name}").into_owned()), + None => lines.push(crate::t!("Default printer: none reported by the system").into_owned()), + } + match list_printers() { + Ok(printers) if printers.is_empty() => { + lines.push(crate::t!("Printers: none listed by the system").into_owned()); + } + Ok(printers) => { + let count = printers.len(); + lines.push(crate::tf!("Printers ({count}):").into_owned()); + lines.extend(printers.into_iter().map(|name| format!(" {name}"))); + } + Err(error) => lines.push(crate::tf!("Could not list printers: {error}").into_owned()), + } + #[cfg(target_os = "windows")] + { + match pdf_printto_command() { + Some(command) => lines.push( + crate::tf!("Plots go through the PDF application: {command}").into_owned(), + ), + None => lines.push( + crate::t!( + "Plots go straight to the printer (no PDF application registers a print verb)." + ) + .into_owned(), + ), + } + } + lines +} + +/// What `printer` reports about its sheets, one line per sheet plus the +/// default and the margins — `PRINTERS `. Asks the driver (and caches +/// the answer like the plot dialog does). +#[cfg(not(target_arch = "wasm32"))] +pub fn printer_media_report(printer: &str) -> Vec { + let Some(caps) = crate::io::plot_device::printer_capabilities(printer) else { + return vec![crate::tf!( + "{printer}: the printer reports no sheets; the paper catalogue is used." + ) + .into_owned()]; + }; + let mut lines = Vec::new(); + let count = caps.media.len(); + lines.push(crate::tf!("{printer}: {count} sheet(s) reported").into_owned()); + for media in &caps.media { + let m = media.margins; + let borderless = if media.borderless { " (borderless available)" } else { "" }; + lines.push(format!( + " {} — margins L {:.1} B {:.1} R {:.1} T {:.1} mm{borderless}", + media.paper.display(), + m.left, + m.bottom, + m.right, + m.top + )); + } + match &caps.default_paper { + Some(paper) => { + let sheet = paper.display(); + lines.push(crate::tf!("Default sheet: {sheet}").into_owned()); + } + None => lines.push(crate::t!("Default sheet: not reported").into_owned()), + } + lines +} + +#[cfg(target_arch = "wasm32")] +pub fn printer_media_report(_printer: &str) -> Vec { + vec![crate::t!("Printing is not available in the web version.").into_owned()] +} + +#[cfg(target_arch = "wasm32")] +pub fn printer_report() -> Vec { + vec![crate::t!("Printing is not available in the web version.").into_owned()] +} + +/// The Windows default printer, as the spooler names it. +#[cfg(target_os = "windows")] +pub(crate) fn windows_default_printer() -> std::io::Result { + use windows_sys::Win32::Foundation::{GetLastError, ERROR_FILE_NOT_FOUND}; + use windows_sys::Win32::Graphics::Printing::GetDefaultPrinterW; + + let mut needed = 0u32; + // SAFETY: the first call only asks for the size; the second writes into + // a buffer of exactly that size. + unsafe { GetDefaultPrinterW(std::ptr::null_mut(), &mut needed) }; + if needed == 0 { + // Windows answers ERROR_FILE_NOT_FOUND when no printer is the default. + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "No default printer is configured on Windows.", + )); + } + let mut buf = vec![0u16; needed as usize]; + if unsafe { GetDefaultPrinterW(buf.as_mut_ptr(), &mut needed) } == 0 { + let code = unsafe { GetLastError() }; + if code == ERROR_FILE_NOT_FOUND { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "No default printer is configured on Windows.", + )); + } + return Err(std::io::Error::from_raw_os_error(code as i32)); + } + let end = buf.iter().position(|&u| u == 0).unwrap_or(0); + Ok(String::from_utf16_lossy(&buf[..end])) +} + +/// The command Windows runs for the `printto` verb on `.pdf` files — the +/// registered PDF application's "print to a named printer" entry — or +/// `None` when no application registered one (Edge and store readers only +/// register `open`), in which case a plot goes straight to the spooler. +#[cfg(target_os = "windows")] +pub(crate) fn pdf_printto_command() -> Option { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::UI::Shell::{AssocQueryStringW, ASSOCF_NOTRUNCATE, ASSOCSTR_COMMAND}; + + let wide = + |s: &str| -> Vec { std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect() }; + let extension = wide(".pdf"); + let verb = wide("printto"); + let mut len = 0u32; + // SAFETY: strings are NUL-terminated; a null buffer asks for the size, + // which the call reports with S_FALSE rather than S_OK. + let sized = unsafe { + AssocQueryStringW( + ASSOCF_NOTRUNCATE, + ASSOCSTR_COMMAND, + extension.as_ptr(), + verb.as_ptr(), + std::ptr::null_mut(), + &mut len, + ) + }; + if sized < 0 || len == 0 { + return None; + } + let mut buf = vec![0u16; len as usize]; + let filled = unsafe { + AssocQueryStringW( + ASSOCF_NOTRUNCATE, + ASSOCSTR_COMMAND, + extension.as_ptr(), + verb.as_ptr(), + buf.as_mut_ptr(), + &mut len, + ) + }; + if filled < 0 { + return None; + } + let end = buf.iter().position(|&u| u == 0).unwrap_or(buf.len()); + let command = String::from_utf16_lossy(&buf[..end]); + (!command.trim().is_empty()).then_some(command) +} + +/// Where a Windows plot goes and how. The route is decided before any +/// system call so the decision itself can be tested anywhere. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) enum PrintRoute { + /// Hand the PDF to the registered PDF application's `printto` verb, + /// naming the printer explicitly. + PdfApplication { printer: String }, + /// Rasterise in-process and draw to the printer through the spooler. + Direct { printer: String }, +} + +impl PrintRoute { + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn printer(&self) -> &str { + match self { + PrintRoute::PdfApplication { printer } | PrintRoute::Direct { printer } => printer, + } + } +} + +/// Resolve the dialog's choice to a route. "Default" (no name) becomes the +/// system default printer *by name*, so the job can never drift to whatever +/// the PDF application considers its default; the PDF application is used +/// only when it registered a `printto` verb. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) fn plan_print_route( + requested: Option<&str>, + default_printer: Option<&str>, + printto_registered: bool, +) -> Result { + let requested = requested.map(str::trim).filter(|name| !name.is_empty()); + let printer = match requested.or(default_printer.map(str::trim).filter(|n| !n.is_empty())) { + Some(name) => name.to_string(), + None => return Err("No default printer is configured on Windows.".into()), + }; + Ok(if printto_registered { + PrintRoute::PdfApplication { printer } + } else { + PrintRoute::Direct { printer } + }) +} + +#[cfg(test)] +mod print_route_tests { + use super::{plan_print_route, PrintRoute}; + + #[test] + fn a_named_printer_is_kept_and_blank_means_the_default() { + assert_eq!( + plan_print_route(Some("Office Laser"), Some("Home Inkjet"), false), + Ok(PrintRoute::Direct { printer: "Office Laser".into() }) + ); + assert_eq!( + plan_print_route(Some(" "), Some("Home Inkjet"), false), + Ok(PrintRoute::Direct { printer: "Home Inkjet".into() }) + ); + assert_eq!( + plan_print_route(None, Some(" Home Inkjet "), true), + Ok(PrintRoute::PdfApplication { printer: "Home Inkjet".into() }) + ); + } + + #[test] + fn no_printer_at_all_is_an_error_not_a_guess() { + assert!(plan_print_route(None, None, true).is_err()); + assert!(plan_print_route(Some(""), Some(""), false).is_err()); + } + + #[test] + fn the_pdf_application_is_used_only_with_a_printto_verb() { + let direct = plan_print_route(Some("X"), None, false).unwrap(); + let via_app = plan_print_route(Some("X"), None, true).unwrap(); + assert!(matches!(direct, PrintRoute::Direct { .. })); + assert!(matches!(via_app, PrintRoute::PdfApplication { .. })); + assert_eq!(direct.printer(), "X"); } } #[cfg(target_os = "windows")] fn windows_printers() -> std::io::Result> { - use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER}; use windows_sys::Win32::Graphics::Printing::{ - EnumPrintersW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, + PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, PRINTER_ENUM_NETWORK, }; + let mut names = enumerate_printers(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS)?; + // LOCAL|CONNECTIONS misses network-discovered printers (spooler-shared and + // per-machine connections), which is what "the plot dialog lost printers" + // reports look like. A second NETWORK-augmented pass can only widen the + // list: when the spooler rejects the extra flag it just yields nothing. + if let Ok(extra) = + enumerate_printers(PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS | PRINTER_ENUM_NETWORK) + { + names.extend(extra); + names.sort(); + names.dedup(); + } + Ok(names) +} + +#[cfg(target_os = "windows")] +fn enumerate_printers(flags: u32) -> std::io::Result> { + use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER}; + use windows_sys::Win32::Graphics::Printing::EnumPrintersW; + let mut bytes_needed = 0; let mut printer_count = 0; let mut buffer = Vec::::new(); @@ -124,7 +390,7 @@ fn windows_printers() -> std::io::Result> { // aligned, writable allocation of buffer_bytes bytes. let success = unsafe { EnumPrintersW( - PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, + flags, std::ptr::null(), 4, data, @@ -238,13 +504,16 @@ fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec ( "rundll32.exe", vec![ "printui.dll,PrintUIEntry".to_string(), - "/p".to_string(), + "/e".to_string(), "/n".to_string(), name.to_string(), ], @@ -263,6 +532,7 @@ fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec) -> (&'static str, Vec) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + return open_desktop_printer_settings(printer); + } + #[allow(unreachable_code)] let (program, args) = printer_properties_command(printer); std::process::Command::new(program) .args(args) @@ -305,6 +580,498 @@ pub fn print_existing_pdf(path: &std::path::Path, opts: &PrintOptions) -> Result dispatch_to_printer_opts(path, opts) } +/// Raster resolution for the GDI fallback print. 300 DPI balances spool +/// memory (a 297mm-wide page is ~3.5k pixels) against readable linework; the +/// on-screen rasteriser runs at 150 DPI, which looks soft on paper. +#[cfg(target_os = "windows")] +const GDI_PRINT_DPI: f32 = 300.0; + +/// Print by rasterising the PDF in-process (hayro) and drawing the bitmap to +/// the printer DC with GDI. Needs no PDF application — the ShellExecute verb +/// path only works when the registered PDF viewer exposes `print`/`printto`, +/// and many (Edge, store readers like ASC.Pdf) register only `open`. +/// Rotate a top-down RGBA raster 90° clockwise (width and height swap). +#[cfg(target_os = "windows")] +fn rotate_rgba90(pixels: &[u8], width: u32, height: u32) -> Vec { + let mut rotated = vec![0u8; pixels.len()]; + for y in 0..height as usize { + for x in 0..width as usize { + let src = (y * width as usize + x) * 4; + // Clockwise: destination column = height-1-y, row = x. + let dst = (x * height as usize + (height as usize - 1 - y)) * 4; + rotated[dst..dst + 4].copy_from_slice(&pixels[src..src + 4]); + } + } + rotated +} + +/// Open `device`, fetch the driver's default DEVMODE (public struct plus the +/// driver's private data behind it) and let `edit` change it while the +/// printer is still open — a change has to be reconciled by the driver +/// through `DocumentPropertiesW` with that handle. `edit` returns `false` +/// to reject the buffer. `None` when the driver could not be queried. +#[cfg(target_os = "windows")] +fn with_printer_devmode( + device_wide: &[u16], + edit: impl FnOnce(windows_sys::Win32::Graphics::Printing::PRINTER_HANDLE, &mut Vec) -> bool, +) -> Option> { + use windows_sys::Win32::Graphics::Gdi::{DEVMODEW, DM_OUT_BUFFER}; + use windows_sys::Win32::Graphics::Printing::{ + ClosePrinter, DocumentPropertiesW, OpenPrinterW, PRINTER_HANDLE, + }; + unsafe { + let mut handle = PRINTER_HANDLE::default(); + if OpenPrinterW(device_wide.as_ptr(), &mut handle, std::ptr::null()) == 0 { + return None; + } + let result = (|| { + let needed = DocumentPropertiesW( + std::ptr::null_mut(), + handle, + device_wide.as_ptr(), + std::ptr::null_mut(), + std::ptr::null(), + DM_OUT_BUFFER, + ); + if needed <= 0 { + return None; + } + let mut buf = vec![0u8; needed as usize]; + let dm = buf.as_mut_ptr() as *mut DEVMODEW; + if DocumentPropertiesW( + std::ptr::null_mut(), + handle, + device_wide.as_ptr(), + dm, + std::ptr::null(), + DM_OUT_BUFFER, + ) <= 0 + { + return None; + } + // The driver's own dmSize locates its private data + // (dmDriverExtra) behind the public struct; only a driver that + // left it zero gets the public size. + if (*dm).dmSize == 0 { + (*dm).dmSize = std::mem::size_of::() as u16; + } + edit(handle, &mut buf).then_some(buf) + })(); + ClosePrinter(handle); + result + } +} + +/// The sheet a print job asks the driver for: one of the driver's own +/// sheets by id, or a user-defined size in tenths of a millimetre. +#[cfg(target_os = "windows")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DevmodeSheet { + Id(u16), + UserMm { width_tenths: i16, length_tenths: i16 }, +} + +/// Fetch the printer driver's DEVMODE, flip its orientation to match the +/// plot's aspect (`landscape` = image wider than tall) and, when a sheet is +/// given, select it. The merged DEVMODE is returned as raw bytes (drivers +/// append private data after the struct). A driver that rejects the sheet +/// is asked again for the orientation alone; `None` when the driver could +/// not be queried — CreateDC then falls back to the driver default. +#[cfg(target_os = "windows")] +fn oriented_printer_devmode( + device_wide: &[u16], + landscape: bool, + sheet: Option, +) -> Option> { + use windows_sys::Win32::Graphics::Gdi::{ + DEVMODEW, DM_IN_BUFFER, DM_ORIENTATION, DM_OUT_BUFFER, DM_PAPERLENGTH, DM_PAPERSIZE, + DM_PAPERWIDTH, DMORIENT_LANDSCAPE, DMORIENT_PORTRAIT, + }; + use windows_sys::Win32::Graphics::Printing::DocumentPropertiesW; + let attempt = |sheet: Option| { + with_printer_devmode(device_wide, |handle, buf| unsafe { + let dm = buf.as_mut_ptr() as *mut DEVMODEW; + (*dm).dmFields |= DM_ORIENTATION; + (*dm).Anonymous1.Anonymous1.dmOrientation = if landscape { + DMORIENT_LANDSCAPE as i16 + } else { + DMORIENT_PORTRAIT as i16 + }; + match sheet { + Some(DevmodeSheet::Id(id)) => { + (*dm).dmFields |= DM_PAPERSIZE; + (*dm).dmFields &= !(DM_PAPERWIDTH | DM_PAPERLENGTH); + (*dm).Anonymous1.Anonymous1.dmPaperSize = id as i16; + } + Some(DevmodeSheet::UserMm { width_tenths, length_tenths }) => { + (*dm).dmFields |= DM_PAPERSIZE | DM_PAPERWIDTH | DM_PAPERLENGTH; + (*dm).Anonymous1.Anonymous1.dmPaperSize = + crate::io::windows_media::DMPAPER_USER as i16; + (*dm).Anonymous1.Anonymous1.dmPaperWidth = width_tenths; + (*dm).Anonymous1.Anonymous1.dmPaperLength = length_tenths; + } + None => {} + } + // Let the driver reconcile fields that depend on orientation and + // sheet (paper dimensions, imageable area) before we print with it. + DocumentPropertiesW( + std::ptr::null_mut(), + handle, + device_wide.as_ptr(), + dm, + dm, + DM_IN_BUFFER | DM_OUT_BUFFER, + ) > 0 + }) + }; + match sheet { + Some(sheet) => attempt(Some(sheet)).or_else(|| attempt(None)), + None => attempt(None), + } +} + +/// The DEVMODE sheet for a plot of `width_mm` × `height_mm`: the driver's +/// own id when it lists that size, else a user-defined size (which the +/// driver may still refuse — the caller then prints on the default sheet). +#[cfg(target_os = "windows")] +fn devmode_sheet_for(printer: &str, width_mm: f64, height_mm: f64) -> Option { + let tenths = |mm: f64| -> Option { i16::try_from((mm * 10.0).round() as i64).ok() }; + match windows_printer_media(printer) { + Some(raw) => { + let listed = crate::io::windows_media::paper_id_for_sheet(&raw, width_mm, height_mm); + if let Some(id) = listed { + return Some(DevmodeSheet::Id(id)); + } + // A driver that lists sheets but not this one gets it as a + // user-defined size, portrait (the orientation flag turns it). + let (w, h) = if width_mm <= height_mm { + (width_mm, height_mm) + } else { + (height_mm, width_mm) + }; + Some(DevmodeSheet::UserMm { + width_tenths: tenths(w)?, + length_tenths: tenths(h)?, + }) + } + None => None, + } +} + +/// What the driver of `printer` says about its sheets: the three parallel +/// `DeviceCapabilities` tables, the default DEVMODE's sheet and the margins +/// of a portrait printer DC. `None` when the driver lists no sheet (a fax or +/// virtual queue) or cannot be asked at all — the dialog keeps the catalogue. +/// Slow for an offline network queue (the spooler may fetch the driver), so +/// it is only ever called from a background task. +#[cfg(target_os = "windows")] +pub(crate) fn windows_printer_media( + printer: &str, +) -> Option { + use crate::io::windows_media::{DeviceCapsSample, RawPrinterMedia, PAPER_NAME_CHARS}; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::POINT; + use windows_sys::Win32::Graphics::Gdi::{ + CreateDCW, DeleteDC, GetDeviceCaps, DEVMODEW, DM_PAPERLENGTH, DM_PAPERSIZE, + DM_PAPERWIDTH, HORZRES, LOGPIXELSX, LOGPIXELSY, PHYSICALHEIGHT, PHYSICALOFFSETX, + PHYSICALOFFSETY, PHYSICALWIDTH, VERTRES, + }; + use windows_sys::Win32::Storage::Xps::{ + DeviceCapabilitiesW, DC_PAPERNAMES, DC_PAPERS, DC_PAPERSIZE, + }; + + let wide = + |s: &str| -> Vec { std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect() }; + let device_wide = wide(printer.trim()); + // SAFETY: the device name is NUL-terminated; a null output buffer asks + // for the count only. + let count = |capability: u16| -> Option { + let n = unsafe { + DeviceCapabilitiesW( + device_wide.as_ptr(), + std::ptr::null(), + capability, + std::ptr::null_mut(), + std::ptr::null(), + ) + }; + (n > 0).then_some(n as usize) + }; + // The three tables are parallel; a driver that disagrees on their + // lengths is read up to the shortest. + let count = count(DC_PAPERS)? + .min(count(DC_PAPERSIZE)?) + .min(count(DC_PAPERNAMES)?); + let mut ids = vec![0u16; count]; + let mut sizes = vec![POINT { x: 0, y: 0 }; count]; + let mut names = vec![0u16; count * PAPER_NAME_CHARS]; + // SAFETY: each buffer holds `count` entries of the type the capability + // writes (WORD, POINT, 64 WCHARs); the calls fill at most that many. + let filled = unsafe { + DeviceCapabilitiesW( + device_wide.as_ptr(), + std::ptr::null(), + DC_PAPERS, + ids.as_mut_ptr(), + std::ptr::null(), + ) > 0 + && DeviceCapabilitiesW( + device_wide.as_ptr(), + std::ptr::null(), + DC_PAPERSIZE, + sizes.as_mut_ptr().cast::(), + std::ptr::null(), + ) > 0 + && DeviceCapabilitiesW( + device_wide.as_ptr(), + std::ptr::null(), + DC_PAPERNAMES, + names.as_mut_ptr(), + std::ptr::null(), + ) > 0 + }; + if !filled { + return None; + } + + // The driver's default sheet, from its default DEVMODE — which also + // makes the margin DC start portrait and on that sheet. + let devmode = with_printer_devmode(&device_wide, |_, _| true); + let (default_id, default_tenths_mm) = devmode + .as_ref() + .map(|buf| { + // SAFETY: the buffer holds at least a public DEVMODEW. + let dm = unsafe { &*(buf.as_ptr() as *const DEVMODEW) }; + let fields = dm.dmFields; + let paper = unsafe { dm.Anonymous1.Anonymous1 }; + let id = (fields & DM_PAPERSIZE != 0).then_some(paper.dmPaperSize as u16); + let size = (fields & DM_PAPERWIDTH != 0 && fields & DM_PAPERLENGTH != 0) + .then_some((i32::from(paper.dmPaperWidth), i32::from(paper.dmPaperLength))); + (id, size) + }) + .unwrap_or((None, None)); + + let winspool = wide("WINSPOOL"); + let init = devmode + .as_ref() + .map(|b| b.as_ptr().cast::()) + .unwrap_or(std::ptr::null()); + // SAFETY: the strings are NUL-terminated and `devmode` outlives the DC. + let hdc = unsafe { CreateDCW(winspool.as_ptr(), device_wide.as_ptr(), std::ptr::null(), init) }; + let margins = if hdc.is_null() { + None + } else { + // SAFETY: hdc is a valid printer DC; each index is documented. + let caps = |index: u32| unsafe { GetDeviceCaps(hdc, index as i32) }; + let sample = DeviceCapsSample { + physical_width: caps(PHYSICALWIDTH), + physical_height: caps(PHYSICALHEIGHT), + offset_x: caps(PHYSICALOFFSETX), + offset_y: caps(PHYSICALOFFSETY), + horz_res: caps(HORZRES), + vert_res: caps(VERTRES), + dpi_x: caps(LOGPIXELSX), + dpi_y: caps(LOGPIXELSY), + }; + unsafe { DeleteDC(hdc) }; + crate::io::windows_media::device_margins_mm(&sample) + }; + + Some(RawPrinterMedia { + ids, + sizes_tenths_mm: sizes.into_iter().map(|p| (p.x, p.y)).collect(), + names_utf16: names, + default_id, + default_tenths_mm, + margins, + }) +} + +#[cfg(target_os = "windows")] +fn gdi_raster_print( + path: &std::path::Path, + printer: &str, + copies: u32, + output_file: Option<&std::path::Path>, +) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Graphics::Gdi::{ + CreateDCW, DeleteDC, GetDeviceCaps, SetStretchBltMode, StretchDIBits, BITMAPINFO, + BITMAPINFOHEADER, BI_RGB, COLORONCOLOR, DIB_RGB_COLORS, DEVMODEW, HORZRES, SRCCOPY, + VERTRES, + }; + // windows-sys 0.61 groups the spooler-document calls (StartDoc/StartPage/ + // EndPage/EndDoc/DOCINFOW) under Storage::Xps, not Graphics::Gdi. + use windows_sys::Win32::Storage::Xps::{EndDoc, EndPage, StartDocW, StartPage, DOCINFOW}; + + let wide = |s: &str| -> Vec { std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect() }; + + // The device is resolved by the caller (`plan_print_route`): a name the + // dialog chose or the system default looked up by name. + let device = printer.trim(); + if device.is_empty() { + return Err("No printer was chosen for direct printing.".into()); + } + let device = device.to_string(); + + // A plot is printed once; the on-screen memo of PDF pages must not keep + // a 300 DPI raster of every job for the rest of the session. + let page = crate::scene::model::pdf_raster::rasterize_page_at_dpi_uncached( + &path.to_string_lossy(), + "1", + GDI_PRINT_DPI, + ) + .ok_or_else(|| "Could not rasterise the plot for direct printing.".to_string())?; + + let winspool = wide("WINSPOOL"); + let device_wide = wide(&device); + // The dialog's sheet and Landscape/Portrait choice ride in through the + // DEVMODE: the raster's size decides both, and the driver's default must + // not override them (a landscape A3 plot into the driver's portrait A4 + // used to shrink and misorient the output). + let sheet_w_mm = f64::from(page.width) / f64::from(GDI_PRINT_DPI) * 25.4; + let sheet_h_mm = f64::from(page.height) / f64::from(GDI_PRINT_DPI) * 25.4; + let sheet = devmode_sheet_for(&device, sheet_w_mm, sheet_h_mm); + let dm_buf = oriented_printer_devmode(&device_wide, page.width > page.height, sheet); + let init = dm_buf + .as_ref() + .map(|b| b.as_ptr().cast::()) + .unwrap_or(std::ptr::null()); + // SAFETY: all pointers are NUL-terminated / valid for the call; the DC is + // released below and dm_buf outlives it. + let hdc = unsafe { + CreateDCW(winspool.as_ptr(), device_wide.as_ptr(), std::ptr::null(), init) + }; + if hdc.is_null() { + return Err(format!("Could not open printer \"{device}\" for printing.")); + } + + let result = (|| -> Result<(), String> { + // SAFETY: hdc is a valid printer DC; each capability is a documented index. + let (page_w, page_h) = + unsafe { (GetDeviceCaps(hdc, HORZRES as i32), GetDeviceCaps(hdc, VERTRES as i32)) }; + if page_w <= 0 || page_h <= 0 { + return Err("Printer reported no printable area.".into()); + } + + // BGRA, top-down — what StretchDIBits expects for BI_RGB 32bpp. + let mut bgra = page.pixels.as_ref().clone(); + for px in bgra.chunks_exact_mut(4) { + px.swap(0, 2); + } + + let mut src_w = page.width; + let mut src_h = page.height; + // Drivers whose DEVMODE could not be set (v4 class drivers may fail + // the DocumentProperties query) keep their default orientation. When + // that mismatches the plot sheet, rotating the raster 90° keeps the + // plot at full size on the page instead of shrinking it to fit. + if (page_w > page_h) != (src_w > src_h) { + bgra = rotate_rgba90(&bgra, src_w, src_h); + std::mem::swap(&mut src_w, &mut src_h); + } + + // Fit inside the printable area, preserving the page's aspect ratio. + let fit_w = f64::from(page_w) / f64::from(src_w); + let fit_h = f64::from(page_h) / f64::from(src_h); + let scale = fit_w.min(fit_h); + let draw_w = (f64::from(src_w) * scale).max(1.0) as i32; + let draw_h = (f64::from(src_h) * scale).max(1.0) as i32; + let x = (page_w - draw_w) / 2; + let y = (page_h - draw_h) / 2; + + let doc_name = wide("OpenCADStudio Plot"); + // An optional redirect writes the spooled output to a file instead of + // the physical tray — how "Microsoft Print to PDF" prints silently, + // and how the fallback proves itself in tests without wasting paper. + let redirect = output_file.map(|p| wide(&p.to_string_lossy())); + let info = DOCINFOW { + cbSize: std::mem::size_of::() as i32, + lpszDocName: doc_name.as_ptr(), + lpszOutput: redirect + .as_ref() + .map(|w| w.as_ptr()) + .unwrap_or(std::ptr::null()), + lpszDatatype: std::ptr::null(), + fwType: 0, + }; + // SAFETY: info and its strings outlive every StartDoc/EndDoc call here. + if unsafe { StartDocW(hdc, &info) } <= 0 { + return Err(format!( + "Print job could not start (code {}).", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + unsafe { SetStretchBltMode(hdc, COLORONCOLOR) }; + let mut bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: src_w as i32, + // Negative height: top-down rows, no vertical flip needed. + biHeight: -(src_h as i32), + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB, + biSizeImage: bgra.len() as u32, + ..Default::default() + }, + bmiColors: [Default::default()], + }; + for _ in 0..copies.max(1) { + if unsafe { StartPage(hdc) } <= 0 { + return Err(format!( + "Could not start a print page (code {}).", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + // StartPage resets DC attributes, so the stretch mode is set per page. + unsafe { SetStretchBltMode(hdc, COLORONCOLOR) }; + let drawn = unsafe { + StretchDIBits( + hdc, + x, + y, + draw_w, + draw_h, + 0, + 0, + src_w as i32, + src_h as i32, + bgra.as_ptr().cast(), + &mut bmi, + DIB_RGB_COLORS, + SRCCOPY, + ) + }; + // Failure returns GDI_ERROR (-1); a successful draw returns the + // source height it copied (>= 0, 0 only for zero-height pages). + if drawn < 0 { + return Err(format!( + "Drawing to the printer failed (code {}).", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + if unsafe { EndPage(hdc) } <= 0 { + return Err(format!( + "Could not finish a print page (code {}).", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + } + if unsafe { EndDoc(hdc) } <= 0 { + return Err(format!( + "Print job could not complete (code {}).", + unsafe { windows_sys::Win32::Foundation::GetLastError() } + )); + } + Ok(()) + })(); + + // SAFETY: the DC was created above and used only on this thread. + unsafe { DeleteDC(hdc) }; + result +} + /// Open a file with the OS default application (used for print preview). #[cfg(not(target_arch = "wasm32"))] pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> { @@ -332,6 +1099,50 @@ pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> { .map_err(|e| format!("Could not open preview: {e}")) } +/// Ask the registered PDF application to print `path` on `printer` through +/// its `printto` verb, once per copy. The printer name is quoted: some +/// `printto` command templates do not quote their `%2`. +#[cfg(target_os = "windows")] +fn shell_print_to(path: &std::path::Path, printer: &str, copies: u32) -> Result<(), String> { + 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 = wide("printto"); + let params = wide(&format!("\"{printer}\"")); + for _ in 0..copies.max(1) { + 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.as_ptr(), + nShow: SW_HIDE, + ..Default::default() + }; + // SAFETY: every pointer in `info` outlives the call. + 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(()) +} + /// Dispatch a PDF to a specific printer with [`PrintOptions`]. #[cfg(not(target_arch = "wasm32"))] fn dispatch_to_printer_opts( @@ -340,48 +1151,43 @@ fn dispatch_to_printer_opts( ) -> Result { #[cfg(target_os = "windows")] { - 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() { - Some(p) if !p.is_empty() => (wide("printto"), Some(wide(p)), p.to_string()), - _ => (wide("print"), None, "default printer".to_string()), - }; - let params_ptr = params.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null()); - for _ in 0..opts.copies.max(1) { - 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 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(), - ); + let default_printer = windows_default_printer().ok(); + let route = plan_print_route( + opts.printer.as_deref(), + default_printer.as_deref(), + pdf_printto_command().is_some(), + )?; + let printer = route.printer().to_string(); + let via_application = match &route { + PrintRoute::PdfApplication { .. } => { + match shell_print_to(path, &printer, opts.copies) { + Ok(()) => true, + // The PDF application refused the job: the spooler route + // still prints it, and the report says so. + Err(_) => { + gdi_raster_print(path, &printer, opts.copies, None)?; + false + } } - return Err(format!("Windows print dispatch failed (code {code})")); } + PrintRoute::Direct { .. } => { + gdi_raster_print(path, &printer, opts.copies, None)?; + false + } + }; + // A direct job is fully spooled when the calls return; the temporary + // PDF behind it has no reader left. The PDF application reads its + // file after ShellExecute returns, so that copy is left to the + // temp folder. + if !via_application && path.starts_with(std::env::temp_dir()) { + let _ = std::fs::remove_file(path); } - Ok(label) + let how = if via_application { + crate::t!("via the PDF application") + } else { + crate::t!("direct print") + }; + Ok(format!("{printer} ({how})")) } #[cfg(not(target_os = "windows"))] @@ -406,6 +1212,10 @@ fn dispatch_to_printer_opts( }; cmd.arg("-o").arg(format!("print-quality={pq}")); } + // The driver's own options, exactly as the PPD names them. + for (key, value) in &opts.driver_options { + cmd.arg("-o").arg(format!("{key}={value}")); + } let lp_result = cmd .arg("--") .arg(path_str.as_ref()) @@ -459,6 +1269,210 @@ fn dispatch_to_printer_opts( mod printer_properties_tests { use super::printer_properties_command; + /// The GDI fallback rasterises the plot PDF in-process; prove the 300 DPI + /// path yields a page sized for print rather than the screen's 150 DPI. + #[cfg(target_os = "windows")] + #[test] + fn gdi_fallback_rasterises_the_plot_pdf_at_print_dpi() { + use printpdf::{Mm, PdfDocument, PdfPage as OutputPage, PdfSaveOptions}; + + let mut document = PdfDocument::new("gdi print test"); + document + .pages + .push(OutputPage::new(Mm(210.0), Mm(148.0), Vec::new())); + let bytes = document.save(&PdfSaveOptions::default(), &mut Vec::new()); + let path = std::env::temp_dir().join("ocs_gdi_print_probe.pdf"); + std::fs::write(&path, &bytes).unwrap(); + + let page = crate::scene::model::pdf_raster::rasterize_page_at_dpi( + &path.to_string_lossy(), + "1", + super::GDI_PRINT_DPI, + ) + .expect("plot PDF should rasterise"); + let _ = std::fs::remove_file(&path); + + assert_eq!(page.dpi, super::GDI_PRINT_DPI); + // 210mm at 300 DPI ≈ 2480 px, aspect 210:148. + assert!((page.width as f64 - 210.0 / 25.4 * 300.0).abs() < 2.0); + assert!((page.height as f64 - 148.0 / 25.4 * 300.0).abs() < 2.0); + assert_eq!(page.pixels.len(), page.width as usize * page.height as usize * 4); + } + + /// Opening the printer DC and reading its printable area must work with + /// the device-name resolution the GDI fallback uses. No job is spooled. + #[cfg(target_os = "windows")] + #[test] + fn gdi_fallback_opens_a_printer_dc() { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Graphics::Gdi::{ + CreateDCW, DeleteDC, GetDeviceCaps, HORZRES, VERTRES, + }; + + let wide = |s: &str| -> Vec { + std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect() + }; + let winspool = wide("WINSPOOL"); + let device = wide("Microsoft Print to PDF"); + // SAFETY: NUL-terminated strings; the DC is released before returning. + let hdc = unsafe { + CreateDCW(winspool.as_ptr(), device.as_ptr(), std::ptr::null(), std::ptr::null()) + }; + assert!(!hdc.is_null(), "printer DC should open"); + // SAFETY: capability indexes on a valid DC. + let (w, h) = unsafe { (GetDeviceCaps(hdc, HORZRES as i32), GetDeviceCaps(hdc, VERTRES as i32)) }; + // SAFETY: releasing the DC opened above. + unsafe { DeleteDC(hdc) }; + assert!(w > 0 && h > 0, "printable area should be reported, got {w}x{h}"); + } + + /// End to end through the real spooler: plot a page with black ink, run + /// the GDI fallback against "Microsoft Print to PDF" redirected to a file + /// (no dialog, no paper), then rasterise the result and require that ink + /// actually landed on the page. Catches off-page draws (the blank-paper + /// regression) without a physical printer. + #[cfg(target_os = "windows")] + #[test] + fn gdi_fallback_lands_ink_on_the_printed_page() { + use printpdf::{Color, Mm, PdfDocument, PdfPage as OutputPage, PdfSaveOptions, Rgb}; + + let mut document = PdfDocument::new("gdi ink test"); + let mut ops = Vec::new(); + // A big black block in the middle of an A4 page. + ops.push(printpdf::Op::SetFillColor { + col: Color::Rgb(Rgb { r: 0.0, g: 0.0, b: 0.0, icc_profile: None }), + }); + // printpdf 0.9's DrawRectangle never paints (its serializer always + // ends the path with `n`), so the ink must be a filled polygon — the + // same op the plot pipeline draws hatches with. + ops.push(printpdf::Op::DrawPolygon { + polygon: printpdf::Polygon { + rings: vec![printpdf::PolygonRing { + points: vec![ + printpdf::LinePoint { p: printpdf::Point { x: Mm(60.0).into(), y: Mm(60.0).into() }, bezier: false }, + printpdf::LinePoint { p: printpdf::Point { x: Mm(150.0).into(), y: Mm(60.0).into() }, bezier: false }, + printpdf::LinePoint { p: printpdf::Point { x: Mm(150.0).into(), y: Mm(100.0).into() }, bezier: false }, + printpdf::LinePoint { p: printpdf::Point { x: Mm(60.0).into(), y: Mm(100.0).into() }, bezier: false }, + ], + }], + mode: printpdf::PaintMode::Fill, + winding_order: printpdf::WindingOrder::NonZero, + }, + }); + document + .pages + .push(OutputPage::new(Mm(210.0), Mm(148.0), ops)); + let bytes = document.save(&PdfSaveOptions::default(), &mut Vec::new()); + let plot = std::env::temp_dir().join("ocs_gdi_ink_source.pdf"); + std::fs::write(&plot, &bytes).unwrap(); + + let printed = std::env::temp_dir().join("ocs_gdi_ink_output.pdf"); + let _ = std::fs::remove_file(&printed); + + let result = super::gdi_raster_print(&plot, "Microsoft Print to PDF", 1, Some(&printed)); + assert!(result.is_ok(), "GDI print failed: {result:?}"); + + // The spooled PDF must contain the black block: rasterise it and + // count clearly-dark pixels. + let page = crate::scene::model::pdf_raster::rasterize_page( + &printed.to_string_lossy(), + "1", + ) + .expect("spooled output should rasterise"); + let dark = page + .pixels + .chunks_exact(4) + .filter(|px| px[0] < 100 && px[1] < 100 && px[2] < 100) + .count(); + std::fs::remove_file(&plot).ok(); + std::fs::remove_file(&printed).ok(); + assert!( + dark > page.width as usize * page.height as usize / 50, + "printed page should carry ink; only {dark} dark pixels" + ); + } + + #[cfg(target_os = "windows")] + /// The plot sheet's orientation must drive the printer's DEVMODE: a + /// landscape sheet spooled through "Microsoft Print to PDF" (whose + /// default is portrait) has to come out as a landscape page. + #[cfg(target_os = "windows")] + #[test] + fn gdi_fallback_prints_landscape_sheets_landscape() { + use printpdf::{Color, Mm, PdfDocument, PdfPage as OutputPage, PdfSaveOptions, Rgb}; + + let mut document = PdfDocument::new("gdi orientation test"); + let mut ops = Vec::new(); + ops.push(printpdf::Op::SetFillColor { + col: Color::Rgb(Rgb { r: 0.0, g: 0.0, b: 0.0, icc_profile: None }), + }); + ops.push(printpdf::Op::DrawPolygon { + polygon: printpdf::Polygon { + rings: vec![printpdf::PolygonRing { + points: vec![ + printpdf::LinePoint { p: printpdf::Point { x: Mm(20.0).into(), y: Mm(20.0).into() }, bezier: false }, + printpdf::LinePoint { p: printpdf::Point { x: Mm(270.0).into(), y: Mm(20.0).into() }, bezier: false }, + printpdf::LinePoint { p: printpdf::Point { x: Mm(270.0).into(), y: Mm(45.0).into() }, bezier: false }, + printpdf::LinePoint { p: printpdf::Point { x: Mm(20.0).into(), y: Mm(45.0).into() }, bezier: false }, + ], + }], + mode: printpdf::PaintMode::Fill, + winding_order: printpdf::WindingOrder::NonZero, + }, + }); + // Landscape A4: 297 × 210 mm. + document.pages.push(OutputPage::new(Mm(297.0), Mm(210.0), ops)); + let bytes = document.save(&PdfSaveOptions::default(), &mut Vec::new()); + let plot = std::env::temp_dir().join("ocs_gdi_orientation_source.pdf"); + std::fs::write(&plot, &bytes).unwrap(); + let printed = std::env::temp_dir().join("ocs_gdi_orientation_output.pdf"); + let _ = std::fs::remove_file(&printed); + + let result = super::gdi_raster_print(&plot, "Microsoft Print to PDF", 1, Some(&printed)); + assert!(result.is_ok(), "GDI print failed: {result:?}"); + + let page = crate::scene::model::pdf_raster::rasterize_page( + &printed.to_string_lossy(), + "1", + ) + .expect("spooled output should rasterise"); + std::fs::remove_file(&plot).ok(); + std::fs::remove_file(&printed).ok(); + + // Whichever mechanism wins — DEVMODE orientation or the raster + // rotation fallback — the ink must land on the page and the plot's + // long side must run along the page's long side. + let mut min_x = page.width; + let mut max_x = 0u32; + let mut min_y = page.height; + let mut max_y = 0u32; + let mut dark = 0usize; + for y in 0..page.height { + for x in 0..page.width { + let px = &page.pixels[((y * page.width + x) * 4) as usize..((y * page.width + x) * 4 + 3) as usize]; + if px.iter().all(|&c| c < 100) { + dark += 1; + min_x = min_x.min(x); + max_x = max_x.max(x); + min_y = min_y.min(y); + max_y = max_y.max(y); + } + } + } + assert!(dark > 1_000, "printed page must carry ink; {dark} dark pixels"); + let ink_w = (max_x - min_x + 1) as f64; + let ink_h = (max_y - min_y + 1) as f64; + assert_eq!( + ink_w > ink_h, + page.width > page.height, + "ink aspect must follow the page: ink {:.0}x{:.0} on page {}x{}", + ink_w, + ink_h, + page.width, + page.height + ); + } + #[cfg(target_os = "windows")] #[test] fn windows_opens_selected_printer_or_printer_list() { @@ -468,7 +1482,7 @@ mod printer_properties_tests { "rundll32.exe", vec![ "printui.dll,PrintUIEntry".to_string(), - "/p".to_string(), + "/e".to_string(), "/n".to_string(), "Office LaserJet".to_string(), ], @@ -523,3 +1537,290 @@ mod printer_properties_tests { ); } } + +// ── Driver options (CUPS PPD) ───────────────────────────────────────────── + +/// One option a CUPS printer's driver exposes, as `lpoptions -l` lists it: +/// the PPD keyword, its description, the choices, and the current default. +#[derive(Clone, Debug, PartialEq)] +pub struct PrinterOption { + /// PPD keyword, e.g. `MediaType`, passed back verbatim as `-o key=value`. + pub key: String, + /// The driver's description, e.g. `Media Type`. + pub label: String, + /// Keyword of the currently selected choice. + pub default: String, + pub choices: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PrinterChoice { + /// PPD choice keyword, e.g. `PhotographicHighGloss`. + pub keyword: String, + /// Readable form of the keyword, e.g. `Photographic High Gloss`. + pub label: String, +} + +impl PrinterOption { + pub fn label_for(&self, keyword: &str) -> String { + self.choices + .iter() + .find(|choice| choice.keyword == keyword) + .map(|choice| choice.label.clone()) + .unwrap_or_else(|| humanize_keyword(keyword)) + } +} + +/// Ask CUPS for the options of `printer` (blocking, one `lpoptions` spawn). +/// The sheet (`PageSize`) is left out: the plot dialog chooses it. +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +pub fn printer_options(printer: &str) -> Result, String> { + let output = std::process::Command::new("lpoptions") + .args(["-p", printer, "-l"]) + .output() + .map_err(|error| format!("lpoptions: {error}"))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + Ok(parse_lpoptions(&String::from_utf8_lossy(&output.stdout))) +} + +/// Windows drivers keep their options behind `DocumentProperties`; there is +/// no list to show in-line. +#[cfg(any(target_arch = "wasm32", target_os = "windows"))] +pub fn printer_options(_printer: &str) -> Result, String> { + Ok(Vec::new()) +} + +/// Parse `lpoptions -l` output: one option per line as +/// `Key/Description: choice *default choice …`, the asterisk marking the +/// current default. Numeric-range options print the same way and are kept. +pub fn parse_lpoptions(text: &str) -> Vec { + let mut options = Vec::new(); + for line in text.lines() { + let Some((head, tail)) = line.split_once(':') else { + continue; + }; + let (key, label) = head + .split_once('/') + .map(|(key, label)| (key.trim(), label.trim())) + .unwrap_or((head.trim(), head.trim())); + if key.is_empty() || key.eq_ignore_ascii_case("PageSize") { + continue; + } + let mut default = String::new(); + let choices: Vec = tail + .split_whitespace() + .map(|token| { + let (keyword, is_default) = match token.strip_prefix('*') { + Some(keyword) => (keyword, true), + None => (token, false), + }; + if is_default { + default = keyword.to_string(); + } + PrinterChoice { + keyword: keyword.to_string(), + label: humanize_keyword(keyword), + } + }) + .collect(); + if choices.is_empty() { + continue; + } + if default.is_empty() { + default = choices[0].keyword.clone(); + } + options.push(PrinterOption { + key: key.to_string(), + label: if label.is_empty() { key.to_string() } else { label.to_string() }, + default, + choices, + }); + } + options +} + +/// `PhotographicHighGloss` → `Photographic High Gloss`, `DuplexNoTumble` → +/// `Duplex No Tumble`; keywords that are already words or numbers pass +/// through. Consecutive capitals (`RGB`, `A4`) stay together. +pub fn humanize_keyword(keyword: &str) -> String { + let mut out = String::with_capacity(keyword.len() + 4); + let chars: Vec = keyword.chars().collect(); + for (index, &ch) in chars.iter().enumerate() { + if index > 0 && ch.is_ascii_uppercase() { + let previous = chars[index - 1]; + let next_lower = chars.get(index + 1).is_some_and(|c| c.is_ascii_lowercase()); + if previous.is_ascii_lowercase() || (previous.is_ascii_uppercase() && next_lower) { + out.push(' '); + } + } + out.push(ch); + } + out +} + +/// The desktop environment's printer settings panel — where the driver +/// options cannot be listed (no CUPS) the user still gets somewhere useful. +/// Tries the panel of the running desktop first, then the +/// distribution-neutral tools, and only then the CUPS web interface. +#[cfg(target_os = "linux")] +pub fn open_desktop_printer_settings(printer: Option<&str>) -> Result<(), String> { + let desktop = std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .to_ascii_lowercase(); + let mut candidates: Vec<(&str, Vec)> = Vec::new(); + if desktop.contains("gnome") || desktop.contains("ubuntu") || desktop.contains("cinnamon") { + candidates.push(("gnome-control-center", vec!["printers".into()])); + } + if desktop.contains("kde") || desktop.contains("plasma") { + candidates.push(("kcmshell6", vec!["kcm_printer_manager".into()])); + candidates.push(("kcmshell5", vec!["kcm_printer_manager".into()])); + } + candidates.push(("system-config-printer", Vec::new())); + candidates.push(("gnome-control-center", vec!["printers".into()])); + for (program, args) in candidates { + if std::process::Command::new(program) + .args(&args) + .spawn() + .is_ok() + { + return Ok(()); + } + } + let target = printer + .map(|name| format!("http://localhost:631/printers/{name}")) + .unwrap_or_else(|| "http://localhost:631/printers".to_string()); + std::process::Command::new("xdg-open") + .arg(target) + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not open printer settings: {error}")) +} + +/// Show the driver's own document-properties sheet for `printer`, owned by +/// the window `owner` (an `HWND`, 0 for none), and on OK store the result as +/// the user's printing preferences for that printer — which is what the +/// print job then honours. Returns `Ok(false)` when the user cancelled. +#[cfg(target_os = "windows")] +pub fn edit_printer_preferences(printer: &str, owner: isize) -> Result { + use windows_sys::Win32::Graphics::Gdi::DEVMODEW; + use windows_sys::Win32::Graphics::Printing::{ + ClosePrinter, DocumentPropertiesW, OpenPrinterW, SetPrinterW, PRINTER_HANDLE, + PRINTER_INFO_9W, + }; + // winspool.h: DM_OUT_BUFFER = DM_COPY, DM_IN_PROMPT = DM_PROMPT, + // DM_IN_BUFFER = DM_MODIFY. + const DM_OUT_BUFFER: u32 = 2; + const DM_IN_PROMPT: u32 = 4; + const DM_IN_BUFFER: u32 = 8; + const IDOK: i32 = 1; + + let name: Vec = printer.encode_utf16().chain(std::iter::once(0)).collect(); + let mut handle = PRINTER_HANDLE { + Value: std::ptr::null_mut(), + }; + // SAFETY: `name` is NUL-terminated and outlives the call; a null default + // asks for PRINTER_ACCESS_USE, enough for per-user preferences. + if unsafe { OpenPrinterW(name.as_ptr(), &mut handle, std::ptr::null()) } == 0 { + return Err(format!("Could not open printer \"{printer}\"")); + } + let owner = owner as windows_sys::Win32::Foundation::HWND; + let outcome = (|| { + // SAFETY: with null buffers the call only reports the DEVMODE size. + let size = unsafe { + DocumentPropertiesW(owner, handle, name.as_ptr(), std::ptr::null_mut(), std::ptr::null(), 0) + }; + if size <= 0 { + return Err(format!("Printer \"{printer}\" reports no document properties")); + } + // DEVMODEW is u16-aligned; a u16 buffer keeps the cast sound. + let words = (size as usize).div_ceil(2); + let mut current = vec![0u16; words]; + let mut edited = vec![0u16; words]; + // SAFETY: both buffers hold `size` bytes as the driver requested. + let fetched = unsafe { + DocumentPropertiesW( + owner, + handle, + name.as_ptr(), + current.as_mut_ptr().cast::(), + std::ptr::null(), + DM_OUT_BUFFER, + ) + }; + if fetched < 0 { + return Err(format!("Could not read the preferences of \"{printer}\"")); + } + edited.copy_from_slice(¤t); + // SAFETY: as above; DM_IN_PROMPT runs the driver's modal sheet. + let response = unsafe { + DocumentPropertiesW( + owner, + handle, + name.as_ptr(), + edited.as_mut_ptr().cast::(), + current.as_ptr().cast::(), + DM_IN_BUFFER | DM_IN_PROMPT | DM_OUT_BUFFER, + ) + }; + if response != IDOK { + return Ok(false); + } + let info = PRINTER_INFO_9W { + pDevMode: edited.as_mut_ptr().cast::(), + }; + // SAFETY: level 9 takes a PRINTER_INFO_9W; the DEVMODE outlives the call. + if unsafe { SetPrinterW(handle, 9, (&info as *const PRINTER_INFO_9W).cast::(), 0) } == 0 { + return Err(format!("Could not save the preferences of \"{printer}\"")); + } + Ok(true) + })(); + // SAFETY: `handle` came from OpenPrinterW above. + unsafe { ClosePrinter(handle) }; + outcome +} + +#[cfg(test)] +mod driver_option_tests { + use super::*; + + const LPOPTIONS: &str = "PageSize/Media Size: 101.6x180.6mm *A4 A4.Borderless A5\n\ +MediaType/Media Type: *Stationery PhotographicHighGloss Photographic Envelope\n\ +ColorModel/Print Color Mode: *RGB Gray AutoGray ProcessGray\n\ +Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\ +cupsPrintQuality/Print Quality: *Normal High\n\ +print-scaling/Print Scaling: auto auto-fit fill *fit none\n\ +Broken line without colon\n"; + + #[test] + fn lpoptions_output_becomes_options_with_defaults() { + let options = parse_lpoptions(LPOPTIONS); + let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect(); + // PageSize is the plot dialog's business; everything else is listed. + assert_eq!( + keys, + ["MediaType", "ColorModel", "Duplex", "cupsPrintQuality", "print-scaling"] + ); + let media = &options[0]; + assert_eq!(media.label, "Media Type"); + assert_eq!(media.default, "Stationery"); + assert_eq!(media.choices[1].keyword, "PhotographicHighGloss"); + assert_eq!(media.choices[1].label, "Photographic High Gloss"); + assert_eq!(media.label_for("Envelope"), "Envelope"); + let scaling = &options[4]; + assert_eq!(scaling.default, "fit"); + assert_eq!(scaling.choices.len(), 5); + } + + #[test] + fn keywords_read_as_words() { + assert_eq!(humanize_keyword("DuplexNoTumble"), "Duplex No Tumble"); + assert_eq!(humanize_keyword("RGB"), "RGB"); + assert_eq!(humanize_keyword("AutoGray"), "Auto Gray"); + assert_eq!(humanize_keyword("ProcessGray"), "Process Gray"); + assert_eq!(humanize_keyword("auto-fit"), "auto-fit"); + assert_eq!(humanize_keyword("PhotographicSemiGloss"), "Photographic Semi Gloss"); + assert_eq!(humanize_keyword("A4"), "A4"); + } +} diff --git a/src/io/windows_media.rs b/src/io/windows_media.rs new file mode 100644 index 000000000..d8544ef9f --- /dev/null +++ b/src/io/windows_media.rs @@ -0,0 +1,312 @@ +//! What a Windows printer driver says about its sheets, turned into the plot +//! dialog's [`PrinterCapabilities`] — the counterpart of the CUPS media +//! parser in `plot_device.rs`. +//! +//! The driver answers three `DeviceCapabilities` questions with parallel +//! arrays (a paper id, a size in tenths of a millimetre and a 64-character +//! name per sheet) and, through its default DEVMODE, which sheet it starts +//! with; the unprintable margins come from a printer DC. Everything here +//! works on those raw arrays so it compiles and is tested on every platform; +//! the calls that fill them live in `print_to_printer.rs`. + +use crate::io::paper_catalog::{self, Margins, PaperSize, PaperUnits}; +use crate::io::plot_device::{PrinterCapabilities, PrinterMedia}; + +/// `DC_PAPERNAMES` returns names in fixed-width slots of this many UTF-16 +/// units, NUL-padded (and not NUL-terminated when exactly full). +pub const PAPER_NAME_CHARS: usize = 64; + +/// `dmPaperSize` of a user-defined sheet: its size is in `dmPaperWidth` / +/// `dmPaperLength` rather than in the driver's table. +pub const DMPAPER_USER: u16 = 256; + +/// Sheets a driver would list but cannot mean: a "user defined" slot is +/// reported as 0 × 0, and some drivers pad with absurd extents. +const MAX_SHEET_MM: f64 = 10_000.0; + +/// The driver's answers, as the Win32 calls hand them over. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RawPrinterMedia { + /// `DC_PAPERS`: one `DMPAPER_*` id per sheet. + pub ids: Vec, + /// `DC_PAPERSIZE`: one `(width, height)` per sheet, tenths of a millimetre. + pub sizes_tenths_mm: Vec<(i32, i32)>, + /// `DC_PAPERNAMES`: `count × PAPER_NAME_CHARS` UTF-16 units, flat. + pub names_utf16: Vec, + /// The default DEVMODE's `dmPaperSize` when `DM_PAPERSIZE` is set. + pub default_id: Option, + /// The default DEVMODE's `dmPaperWidth` / `dmPaperLength` (tenths of a + /// millimetre) for a `DMPAPER_USER` default. + pub default_tenths_mm: Option<(i32, i32)>, + /// Unprintable margins measured on the printer DC, portrait, mm. + pub margins: Option, +} + +/// The `GetDeviceCaps` values a printable-area calculation needs, taken +/// from one printer DC in portrait. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DeviceCapsSample { + /// `PHYSICALWIDTH` / `PHYSICALHEIGHT`: the sheet, in device pixels. + pub physical_width: i32, + pub physical_height: i32, + /// `PHYSICALOFFSETX` / `PHYSICALOFFSETY`: the printable area's corner. + pub offset_x: i32, + pub offset_y: i32, + /// `HORZRES` / `VERTRES`: the printable area, in device pixels. + pub horz_res: i32, + pub vert_res: i32, + /// `LOGPIXELSX` / `LOGPIXELSY`: device pixels per inch. + pub dpi_x: i32, + pub dpi_y: i32, +} + +/// The sheet names out of a `DC_PAPERNAMES` buffer: each slot is trimmed at +/// its first NUL (a name that fills its slot has none) and decoded lossily. +pub fn paper_names(names_utf16: &[u16], count: usize) -> Vec { + names_utf16 + .chunks(PAPER_NAME_CHARS) + .take(count) + .map(|slot| { + let end = slot.iter().position(|&unit| unit == 0).unwrap_or(slot.len()); + String::from_utf16_lossy(&slot[..end]).trim().to_string() + }) + .collect() +} + +/// The unprintable margins a DC sample describes, in millimetres — `None` +/// when the driver reported no usable resolution or extents. +pub fn device_margins_mm(sample: &DeviceCapsSample) -> Option { + if sample.dpi_x <= 0 + || sample.dpi_y <= 0 + || sample.physical_width <= 0 + || sample.physical_height <= 0 + || sample.horz_res <= 0 + || sample.vert_res <= 0 + { + return None; + } + let mm_x = |pixels: i32| f64::from(pixels) / f64::from(sample.dpi_x) * 25.4; + let mm_y = |pixels: i32| f64::from(pixels) / f64::from(sample.dpi_y) * 25.4; + let margins = Margins { + left: mm_x(sample.offset_x), + top: mm_y(sample.offset_y), + right: mm_x(sample.physical_width - sample.offset_x - sample.horz_res), + bottom: mm_y(sample.physical_height - sample.offset_y - sample.vert_res), + }; + let sane = |value: f64| value.is_finite() && value >= 0.0; + (sane(margins.left) && sane(margins.top) && sane(margins.right) && sane(margins.bottom)) + .then_some(margins) +} + +/// The driver's sheets as the plot dialog lists them: one entry per distinct +/// size (a rotated or borderless twin folds onto its sheet), catalogue +/// sheets under their own name, unknown sizes under the driver's name, the +/// device's margins on every entry, and the driver's default sheet when it +/// is one of them. `None` when the driver listed nothing usable, so the +/// dialog falls back to the catalogue. +pub fn printer_media(raw: &RawPrinterMedia) -> Option { + let count = raw.ids.len().min(raw.sizes_tenths_mm.len()); + let names = paper_names(&raw.names_utf16, count); + let margins = raw.margins.unwrap_or(Margins::ZERO); + let mut media: Vec = Vec::new(); + // Which listed sheet each id resolves to, so the default can be found by + // id even when its own entry folded onto an earlier twin. + let mut sheet_of_id: Vec<(u16, usize)> = Vec::new(); + for index in 0..count { + let (w, h) = raw.sizes_tenths_mm[index]; + let (w, h) = (f64::from(w) / 10.0, f64::from(h) / 10.0); + if !(w > 0.0 && h > 0.0 && w <= MAX_SHEET_MM && h <= MAX_SHEET_MM) { + continue; + } + let (pw, ph) = if w <= h { (w, h) } else { (h, w) }; + let name = names.get(index).cloned().unwrap_or_default(); + if let Some(position) = media.iter().position(|m| same_size(&m.paper, pw, ph)) { + if name.to_ascii_lowercase().contains("borderless") { + media[position].borderless = true; + } + sheet_of_id.push((raw.ids[index], position)); + continue; + } + let paper = paper_catalog::match_dimensions_mm(pw, ph).cloned().unwrap_or_else(|| { + if name.is_empty() { + PaperSize::custom(pw, ph, PaperUnits::Millimeters) + } else { + PaperSize::custom_named(&name, pw, ph, PaperUnits::Millimeters) + } + }); + sheet_of_id.push((raw.ids[index], media.len())); + media.push(PrinterMedia { + paper, + margins, + borderless: name.to_ascii_lowercase().contains("borderless"), + }); + } + if media.is_empty() { + return None; + } + let default_paper = match raw.default_id { + Some(DMPAPER_USER) => raw.default_tenths_mm.and_then(|(w, h)| { + let (w, h) = (f64::from(w) / 10.0, f64::from(h) / 10.0); + let (pw, ph) = if w <= h { (w, h) } else { (h, w) }; + media + .iter() + .find(|m| same_size(&m.paper, pw, ph)) + .map(|m| m.paper.clone()) + }), + Some(id) => sheet_of_id + .iter() + .find(|(candidate, _)| *candidate == id) + .map(|(_, position)| media[*position].paper.clone()), + None => None, + }; + Some(PrinterCapabilities { + media, + default_paper, + }) +} + +/// The driver's paper id for a sheet of this size in either orientation — +/// what a print job's DEVMODE needs so the sheet is printed at its own size +/// rather than on the driver's default. +pub fn paper_id_for_sheet(raw: &RawPrinterMedia, width_mm: f64, height_mm: f64) -> Option { + let (pw, ph) = if width_mm <= height_mm { + (width_mm, height_mm) + } else { + (height_mm, width_mm) + }; + raw.ids + .iter() + .zip(&raw.sizes_tenths_mm) + .find(|(_, (w, h))| { + let (w, h) = (f64::from(*w) / 10.0, f64::from(*h) / 10.0); + let (sw, sh) = if w <= h { (w, h) } else { (h, w) }; + (sw - pw).abs() <= 0.5 && (sh - ph).abs() <= 0.5 + }) + .map(|(id, _)| *id) +} + +fn same_size(paper: &PaperSize, portrait_w: f64, portrait_h: f64) -> bool { + let (w, h) = paper.portrait_mm(); + (w - portrait_w).abs() <= 0.5 && (h - portrait_h).abs() <= 0.5 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn slot(name: &str) -> Vec { + let mut units: Vec = name.encode_utf16().collect(); + units.resize(PAPER_NAME_CHARS, 0); + units + } + + fn raw(entries: &[(u16, (i32, i32), &str)]) -> RawPrinterMedia { + RawPrinterMedia { + ids: entries.iter().map(|(id, _, _)| *id).collect(), + sizes_tenths_mm: entries.iter().map(|(_, size, _)| *size).collect(), + names_utf16: entries.iter().flat_map(|(_, _, name)| slot(name)).collect(), + default_id: None, + default_tenths_mm: None, + margins: Some(Margins { + left: 3.0, + bottom: 3.0, + right: 3.0, + top: 3.0, + }), + } + } + + #[test] + fn names_are_trimmed_at_the_first_nul_and_survive_a_full_slot() { + let mut buffer = slot("A4"); + buffer.extend(std::iter::repeat_n(b'x' as u16, PAPER_NAME_CHARS)); + // A lone surrogate decodes lossily instead of failing. + let mut broken = slot("Letter"); + broken[6] = 0xD800; + broken[7] = 0; + buffer.extend(broken); + let names = paper_names(&buffer, 3); + assert_eq!(names[0], "A4"); + assert_eq!(names[1].len(), PAPER_NAME_CHARS); + assert!(names[2].starts_with("Letter")); + assert_eq!(paper_names(&buffer, 1).len(), 1, "count caps the slots read"); + } + + #[test] + fn twins_fold_onto_their_sheet_and_nonsense_sizes_are_dropped() { + let caps = printer_media(&raw(&[ + (9, (2100, 2970), "A4"), + (77, (2970, 2100), "A4 Rotated"), + (1, (2159, 2794), "Letter"), + (256, (0, 0), "User Defined"), + (300, (2100, 2970), "A4 Borderless"), + (301, (1000, 1500), "Photo 10x15"), + ])) + .expect("usable sheets"); + assert_eq!(caps.media.len(), 3); + assert_eq!(caps.media[0].paper.canonical, "ISO_A4_(210.00_x_297.00_MM)"); + assert!(caps.media[0].borderless, "the borderless twin marks the sheet"); + assert_eq!(caps.media[1].paper.canonical, "ANSI_A_(8.50_x_11.00_Inches)"); + assert_eq!(caps.media[2].paper.label, "Photo 10x15", "the driver's name is kept"); + assert_eq!(caps.media[2].margins.left, 3.0, "the device margins go on every sheet"); + assert!(caps.default_paper.is_none()); + } + + #[test] + fn the_default_sheet_is_found_by_id_or_by_user_size() { + let mut media = raw(&[ + (9, (2100, 2970), "A4"), + (77, (2970, 2100), "A4 Rotated"), + (8, (2970, 4200), "A3"), + ]); + media.default_id = Some(77); + let caps = printer_media(&media).unwrap(); + assert_eq!( + caps.default_paper.as_ref().map(|p| p.canonical.as_ref()), + Some("ISO_A4_(210.00_x_297.00_MM)"), + "the rotated twin's id still names A4" + ); + media.default_id = Some(DMPAPER_USER); + media.default_tenths_mm = Some((4200, 2970)); + let caps = printer_media(&media).unwrap(); + assert_eq!( + caps.default_paper.as_ref().map(|p| p.canonical.as_ref()), + Some("ISO_A3_(297.00_x_420.00_MM)") + ); + media.default_id = Some(999); + assert!(printer_media(&media).unwrap().default_paper.is_none()); + assert!(printer_media(&RawPrinterMedia::default()).is_none(), "nothing listed → catalogue"); + } + + #[test] + fn device_margins_come_from_the_dc_geometry() { + let sample = DeviceCapsSample { + physical_width: 4960, + physical_height: 7016, + offset_x: 100, + offset_y: 100, + horz_res: 4760, + vert_res: 6716, + dpi_x: 600, + dpi_y: 600, + }; + let margins = device_margins_mm(&sample).unwrap(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + assert!(close(margins.left, 100.0 / 600.0 * 25.4)); + assert!(close(margins.right, 100.0 / 600.0 * 25.4)); + assert!(close(margins.top, 100.0 / 600.0 * 25.4)); + assert!(close(margins.bottom, 200.0 / 600.0 * 25.4)); + assert!(device_margins_mm(&DeviceCapsSample::default()).is_none()); + let inverted = DeviceCapsSample { horz_res: 9000, ..sample }; + assert!(device_margins_mm(&inverted).is_none(), "printable area wider than the sheet"); + } + + #[test] + fn the_paper_id_matches_either_orientation_within_half_a_millimetre() { + let media = raw(&[(9, (2100, 2970), "A4"), (8, (2970, 4200), "A3")]); + assert_eq!(paper_id_for_sheet(&media, 297.0, 210.0), Some(9)); + assert_eq!(paper_id_for_sheet(&media, 210.3, 297.4), Some(9)); + assert_eq!(paper_id_for_sheet(&media, 420.0, 297.0), Some(8)); + assert_eq!(paper_id_for_sheet(&media, 216.0, 279.0), None); + } +} diff --git a/src/io/xref.rs b/src/io/xref.rs index fd054a784..948965cb6 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -54,7 +54,20 @@ pub struct XrefInfo { /// just like the host doc, so it gets the same corrupt-entity guard. Folding /// it in here avoids a second full-document `entities()` walk after resolve. pub fn resolve_xrefs(doc: &mut CadDocument, base_dir: &Path) -> (Vec, usize) { - resolve_xrefs_with_progress(doc, base_dir, None) + resolve_xrefs_with_filter(doc, base_dir, None, None) +} + +/// Reload only the direct xref block records identified by `keys`. +/// +/// Unlike [`resolve_xrefs`], this never touches unmatched references. This is +/// the operation behind `XREF Reload `: a targeted reload must not +/// silently reload (or alter the stale baseline of) every other reference. +pub fn resolve_xrefs_for_keys( + doc: &mut CadDocument, + base_dir: &Path, + keys: &HashSet, +) -> (Vec, usize) { + resolve_xrefs_with_filter(doc, base_dir, None, Some(keys)) } /// Resolve XREFs while reporting completed work units. @@ -66,12 +79,22 @@ pub fn resolve_xrefs_with_progress( doc: &mut CadDocument, base_dir: &Path, progress: Option>, +) -> (Vec, usize) { + resolve_xrefs_with_filter(doc, base_dir, progress, None) +} + +fn resolve_xrefs_with_filter( + doc: &mut CadDocument, + base_dir: &Path, + progress: Option>, + only: Option<&HashSet>, ) -> (Vec, usize) { // Auto-resolve every xref — frustum + LOD culling keep GPU cost bounded. let xref_entries: Vec<(String, String, Handle)> = doc .block_records .iter() .filter(|br| (br.flags.is_xref || br.flags.is_xref_overlay) && !br.xref_path.is_empty()) + .filter(|br| only.is_none_or(|keys| keys.contains(&br.handle))) .map(|br| (br.name.clone(), br.xref_path.clone(), br.handle)) .collect(); let xref_count = xref_entries.len(); @@ -224,6 +247,24 @@ pub fn resolve_xrefs_with_progress( if recovered || invalid > 0 { (XrefStatus::Failed, diagnostics, Some(outcome.stats)) } else { + // Reload is replacement, never append. Keep the prior merge + // intact until parsing succeeded, then discard its generated + // entities, sortents, and dependent symbols immediately before + // importing the replacement content. + // + // VISRETAIN=1 (`header.retain_xref_visibility`): host-side + // layer overrides survive the purge — snapshot them first and + // re-apply onto the freshly merged layers below. Without this + // every reload silently reset frozen/off/color tweaks the + // user made on `name|*` layers back to file defaults. + let visretain = doc.header.retain_xref_visibility; + let kept_layers = if visretain { + snapshot_dependent_layers(doc, &block_name) + } else { + HashMap::default() + }; + let _ = unload_reference(doc, br_handle.value()); + remove_pipe_symbols(doc, &block_name); ensure_block_entities(doc, &block_name); dropped += merge_xref_into_block( doc, @@ -231,6 +272,9 @@ pub fn resolve_xrefs_with_progress( br_handle, outcome.document, ); + if visretain { + restore_dependent_layers(doc, &block_name, kept_layers); + } (XrefStatus::Loaded, diagnostics, Some(outcome.stats)) } } @@ -312,6 +356,415 @@ fn ensure_block_entities(doc: &mut CadDocument, block_name: &str) { let _ = doc.add_entity(EntityType::BlockEnd(BlockEnd::new())); } +/// Shared symbol-remap core for XREF load-merge and BIND (SPIKE3 REFACTOR +/// verdict: extract, don't duplicate). +/// +/// Both paths import the source doc's symbol tables into the host and rewrite +/// every entity reference to the imported copies. They differ only in naming: +/// the load path prefixes `{xref}|{sym}` (collisions overwrite), BIND renames +/// `{parent}$N${sym}` (N bumped past collisions). +/// +/// `bind_limitations` — symbol references this helper can NOT remap (counted +/// as unremapped; BIND reports the count per bind, the load path ignores it): +/// - MLINE `style_name`/`style_handle`: no host MLineStyle-table import — +/// the handle is nulled, the name kept verbatim. +/// - Material / plotstyle / full-face-edge visual-style / color-book handles +/// (`EntityCommon` + ACIS `material_handle` bindings on `Solid3D`): the +/// target objects are never imported — handles nulled. +/// - Tolerance `dimension_style_handle`, Shape `style_handle`, MultiLeader +/// `style_handle`/`text_style_handle`, Table `table_style_handle` and an +/// unmapped `block_record_handle`: nulled. +/// - Kept verbatim (audited, benign): linetype handles (the name carries the +/// lookup), Leader `annotation_handle`, Hatch `boundary_handles`, +/// `attdef_handle`, reactors, xdata, extension dictionaries, graphic data, +/// Dimension anonymous-block names (`*D…` — geometry regenerates from the +/// style), Hatch pattern names (no pattern table exists), Shape style +/// names, MultiLeader line-type/arrowhead handles, Table cell internals +/// (rows/cells keep xref text-style names), Viewport visual-style handles, +/// SectionSymbol style handles, `Extended` entities. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +enum SymbolKind { + Layer, + Linetype, + TextStyle, + DimStyle, + Block, +} + +/// Per-kind collision sets for BIND naming, preloaded from the host doc +/// (uppercased — table lookup is case-insensitive). +#[derive(Debug, Default)] +struct BindTaken { + layers: HashSet, + linetypes: HashSet, + text_styles: HashSet, + dim_styles: HashSet, + blocks: HashSet, +} + +impl BindTaken { + fn capture(doc: &CadDocument) -> Self { + let upper_names = + |names: Vec| names.into_iter().map(|n| n.to_uppercase()).collect(); + Self { + layers: upper_names(doc.layers.names().map(|s| s.to_string()).collect()), + linetypes: upper_names(doc.line_types.names().map(|s| s.to_string()).collect()), + text_styles: upper_names( + doc.text_styles.names().map(|s| s.to_string()).collect(), + ), + dim_styles: upper_names(doc.dim_styles.names().map(|s| s.to_string()).collect()), + blocks: upper_names( + doc.block_records + .iter() + .map(|b| b.name.clone()) + .collect(), + ), + } + } + + fn set(&mut self, kind: SymbolKind) -> &mut HashSet { + match kind { + SymbolKind::Layer => &mut self.layers, + SymbolKind::Linetype => &mut self.linetypes, + SymbolKind::TextStyle => &mut self.text_styles, + SymbolKind::DimStyle => &mut self.dim_styles, + SymbolKind::Block => &mut self.blocks, + } + } + + /// Claim `{parent}$N${sym}`, bumping N past every taken name, and record + /// the winner so sequential imports compose transitive chains like + /// `PLAN$0$DETAIL$0$WALLS`. + fn claim(&mut self, kind: SymbolKind, parent: &str, sym: &str) -> String { + let mut n = 0u32; + loop { + let candidate = format!("{parent}${n}${sym}"); + if self.set(kind).insert(candidate.to_uppercase()) { + return candidate; + } + n += 1; + } + } +} + +/// How imported symbol names are formed. +enum SymbolNaming { + /// Load path: `{prefix}|{sym}` (collisions overwrite via add_or_replace). + MergePipe { prefix: String }, + /// BIND path: `{parent}$N${sym}` with per-kind collision dedup. + BindDollar { parent: String, taken: BindTaken }, +} + +impl SymbolNaming { + fn name_for(&mut self, kind: SymbolKind, sym: &str) -> String { + match self { + SymbolNaming::MergePipe { prefix } => format!("{prefix}|{sym}"), + SymbolNaming::BindDollar { parent, taken } => taken.claim(kind, parent, sym), + } + } +} + +/// Imported-name maps for one source document (old UPPER name → new name, +/// old handle → new handle). +#[derive(Default)] +struct XrefSymbolMaps { + layers: HashMap, + linetypes: HashMap, + text_styles: HashMap, + dim_styles: HashMap, + blocks: HashMap, + br_handles: HashMap, + image_defs: HashMap, + underlay_defs: HashMap, +} + +/// Import a source doc's symbol tables + IMAGE/UNDERLAY definitions into the +/// host doc under `naming`, returning the old→new maps for entity remap. +/// +/// Covers layers (incl. "0"), linetypes (minus sentinels), text styles, +/// dim styles, nested block records (layout `*` records and nested xrefs +/// skipped — BIND resolves those before merging), and every image/underlay +/// definition (fresh handle, detached owner, verbatim path — BIND retargets +/// image paths via COLLECT before merging). +fn import_xref_symbols( + doc: &mut CadDocument, + xref_doc: &CadDocument, + naming: &mut SymbolNaming, +) -> XrefSymbolMaps { + let mut maps = XrefSymbolMaps::default(); + + for layer in xref_doc.layers.iter() { + let old = layer.name.clone(); + let new = naming.name_for(SymbolKind::Layer, &old); + let mut cloned = layer.clone(); + cloned.name = new.clone(); + cloned.set_handle(doc.allocate_handle()); + doc.layers.add_or_replace(cloned); + maps.layers.insert(old.to_uppercase(), new); + } + + for lt in xref_doc.line_types.iter() { + let old = lt.name.clone(); + if is_sentinel_linetype(&old) { + continue; + } + let new = naming.name_for(SymbolKind::Linetype, &old); + let mut cloned = lt.clone(); + cloned.name = new.clone(); + cloned.set_handle(doc.allocate_handle()); + doc.line_types.add_or_replace(cloned); + maps.linetypes.insert(old.to_uppercase(), new); + } + + for ts in xref_doc.text_styles.iter() { + let old = ts.name.clone(); + let new = naming.name_for(SymbolKind::TextStyle, &old); + let mut cloned = ts.clone(); + cloned.name = new.clone(); + cloned.set_handle(doc.allocate_handle()); + cloned.xref_dependent = false; + doc.text_styles.add_or_replace(cloned); + maps.text_styles.insert(old.to_uppercase(), new); + } + + for ds in xref_doc.dim_styles.iter() { + let old = ds.name.clone(); + let new = naming.name_for(SymbolKind::DimStyle, &old); + let mut cloned = ds.clone(); + cloned.name = new.clone(); + cloned.set_handle(doc.allocate_handle()); + cloned.xref_reference = false; + cloned.xref_resolved = false; + cloned.xref_dependent = false; + cloned.xref_handle = Handle::NULL; + doc.dim_styles.add_or_replace(cloned); + maps.dim_styles.insert(old.to_uppercase(), new); + } + + // Nested block records: prefixed clones with cleared membership, detached + // layout pointer, fresh handles (same shape as the load path always made). + for br in xref_doc.block_records.iter() { + if br.name.starts_with('*') || br.flags.is_xref || br.flags.is_xref_overlay { + continue; + } + let old = br.name.clone(); + let new = naming.name_for(SymbolKind::Block, &old); + let mut cloned = br.clone(); + cloned.name = new.clone(); + cloned.entity_handles.clear(); + cloned.insert_handles.clear(); + cloned.layout = Handle::NULL; + let new_h = doc.allocate_handle(); + cloned.set_handle(new_h); + cloned.block_entity_handle = doc.allocate_handle(); + cloned.block_end_handle = doc.allocate_handle(); + maps.br_handles.insert(br.handle, new_h); + maps.blocks.insert(old.to_uppercase(), new); + doc.block_records.add_or_replace(cloned); + } + + // IMAGE / UNDERLAY definitions: the entities reference these by handle, + // so without an import every bound image loses its file link. Fresh + // handle, detached owner; the path stays verbatim here. + { + use acadrust::objects::ObjectType; + for (handle, obj) in xref_doc.objects.iter() { + match obj { + ObjectType::ImageDefinition(def) => { + let mut cloned = def.clone(); + let new_h = doc.allocate_handle(); + cloned.handle = new_h; + cloned.owner = Handle::NULL; + doc.objects + .insert(new_h, ObjectType::ImageDefinition(cloned)); + maps.image_defs.insert(*handle, new_h); + } + ObjectType::UnderlayDefinition(def) => { + let mut cloned = def.clone(); + let new_h = doc.allocate_handle(); + cloned.handle = new_h; + cloned.owner_handle = Handle::NULL; + cloned.reactors.clear(); + doc.objects + .insert(new_h, ObjectType::UnderlayDefinition(cloned)); + maps.underlay_defs.insert(*handle, new_h); + } + _ => {} + } + } + } + + maps +} + +/// Null one optional foreign handle, counting it as unremapped when set. +/// Returns 1 when a handle was cleared, 0 otherwise. +fn null_handle(handle: &mut Option) -> usize { + if handle.is_some() { + *handle = None; + 1 + } else { + 0 + } +} + +/// Rewrite one entity's symbol references to the imported copies in place. +/// Returns the count of unremappable foreign handles (see +/// `bind_limitations` above) — ignored on the load path, reported per BIND. +fn remap_xref_entity( + host: &CadDocument, + entity: &mut EntityType, + maps: &XrefSymbolMaps, +) -> usize { + let mut unremapped = 0usize; + let rename = |map: &HashMap, name: &mut String| { + if let Some(new) = map.get(&name.to_uppercase()) { + *name = new.clone(); + } + }; + + // ── Common layer / linetype + material-style handles ── + { + let c = entity.common_mut(); + rename(&maps.layers, &mut c.layer); + if !is_sentinel_linetype(&c.linetype) { + rename(&maps.linetypes, &mut c.linetype); + } + unremapped += null_handle(&mut c.material_handle); + unremapped += null_handle(&mut c.plotstyle_handle); + unremapped += null_handle(&mut c.full_visual_style_handle); + unremapped += null_handle(&mut c.face_visual_style_handle); + unremapped += null_handle(&mut c.edge_visual_style_handle); + unremapped += null_handle(&mut c.color_book_handle); + } + + // ── Text-style names (incl. embedded MText in attributes) ── + let remap_mtext_style = |mtext: &mut acadrust::entities::MText, maps: &XrefSymbolMaps| { + rename(&maps.text_styles, &mut mtext.style); + }; + match entity { + EntityType::Text(t) => rename(&maps.text_styles, &mut t.style), + EntityType::MText(m) => remap_mtext_style(m, maps), + EntityType::AttributeDefinition(a) => { + rename(&maps.text_styles, &mut a.text_style); + if let Some(m) = a.embedded_mtext.as_mut() { + remap_mtext_style(m, maps); + } + } + EntityType::AttributeEntity(a) => { + rename(&maps.text_styles, &mut a.text_style); + if let Some(m) = a.embedded_mtext.as_mut() { + remap_mtext_style(m, maps); + } + } + // ── Dim-style names (+ anonymous-block refs stay verbatim) ── + EntityType::Dimension(d) => rename(&maps.dim_styles, &mut d.base_mut().style_name), + EntityType::Leader(l) => rename(&maps.dim_styles, &mut l.dimension_style), + EntityType::Tolerance(t) => { + rename(&maps.dim_styles, &mut t.dimension_style_name); + unremapped += null_handle(&mut t.dimension_style_handle); + } + // ── Block refs by name ── + EntityType::Insert(ins) => rename(&maps.blocks, &mut ins.block_name), + // ── Table: style handles nulled, legacy block refs remapped ── + EntityType::Table(t) => { + unremapped += null_handle(&mut t.table_style_handle); + if let Some(br) = t.block_record_handle { + match maps.br_handles.get(&br) { + Some(&new_br) => t.block_record_handle = Some(new_br), + None => { + t.block_record_handle = None; + unremapped += 1; + } + } + } + rename(&maps.blocks, &mut t.block_name); + } + // ── MultiLeader / MLine / Shape style handles (names verbatim) ── + EntityType::MultiLeader(m) => { + unremapped += null_handle(&mut m.style_handle); + unremapped += null_handle(&mut m.text_style_handle); + } + EntityType::MLine(m) => { + unremapped += null_handle(&mut m.style_handle); + } + EntityType::Shape(s) => { + unremapped += null_handle(&mut s.style_handle); + } + EntityType::Solid3D(s) => { + for m in &mut s.acis_data.materials { + unremapped += null_handle(&mut m.material_handle); + } + } + // ── IMAGE / UNDERLAY definition links ── + EntityType::RasterImage(img) => { + if let Some(old) = img.definition_handle { + if let Some(&new) = maps.image_defs.get(&old) { + img.definition_handle = Some(new); + // Keep the entity path in sync with its definition (bind + // COLLECT retargets the definition before merging). + use acadrust::objects::ObjectType; + if let Some(ObjectType::ImageDefinition(def)) = host.objects.get(&new) { + img.file_path = def.file_name.clone(); + } + } + } + img.definition_reactor_handle = None; + } + EntityType::Underlay(ul) => { + if let Some(&new) = maps.underlay_defs.get(&ul.definition_handle) { + ul.definition_handle = new; + } + } + _ => {} + } + unremapped +} + +/// Re-point a source doc's draw-order tables at the merged blocks/handles, so +/// an explicit DRAWORDER inside the source survives the merge. +fn copy_xref_sortents( + doc: &mut CadDocument, + xref_doc: &CadDocument, + target_owner: Handle, + br_handle_map: &HashMap, + entity_handle_map: &HashMap, +) { + use acadrust::objects::{ObjectType, SortEntitiesTable}; + let xref_ms_handle = xref_doc.header.model_space_block_handle; + for obj in xref_doc.objects.values() { + let ObjectType::SortEntitiesTable(t) = obj else { + continue; + }; + if t.is_empty() { + continue; + } + let new_block = if t.block_owner_handle == xref_ms_handle { + target_owner + } else if let Some(&h) = br_handle_map.get(&t.block_owner_handle) { + h + } else { + continue; + }; + let mut nt = SortEntitiesTable::new(); + nt.handle = doc.allocate_handle(); + nt.block_owner_handle = new_block; + for e in t.entries() { + let Some(&ne) = entity_handle_map.get(&e.entity_handle) else { + continue; + }; + let ns = entity_handle_map + .get(&e.sort_handle) + .copied() + .unwrap_or(e.sort_handle); + nt.add_entry(ne, ns); + } + if !nt.is_empty() { + doc.objects.insert(nt.handle, ObjectType::SortEntitiesTable(nt)); + } + } +} + /// Merge an external-reference document into `doc`'s xref block. /// /// Copies the xref's model-space entities into the host xref block, AND @@ -338,108 +791,69 @@ fn merge_xref_into_block( br.units = src_insunits; } - // ── Layers ────────────────────────────────────────────────────────── - // Prefix every xref layer (including "0"). Entity layer references are - // remapped below so the resolver finds the merged copy. Host layers - // (incl. its own "0") are untouched — no collisions. - let mut layer_map: HashMap = HashMap::default(); - for layer in xref_doc.layers.iter() { - let old = layer.name.clone(); - let new = format!("{}|{}", prefix, old); - let mut cloned = layer.clone(); - xref_doc.normalize_imported_layer_defaults(&mut cloned); - cloned.name = new.clone(); - cloned.set_handle(doc.allocate_handle()); - doc.layers.add_or_replace(cloned); - layer_map.insert(old.to_uppercase(), new); - } + // ── Symbol tables + definitions (shared helper) ───────────────────── + // Same prefixed scheme as before ("{xref}|{sym}"); the helper additionally + // imports text styles, dim styles, and IMAGE/UNDERLAY definitions. + let mut naming = SymbolNaming::MergePipe { + prefix: prefix.to_string(), + }; + let maps = import_xref_symbols(doc, &xref_doc, &mut naming); - // ── Linetypes ─────────────────────────────────────────────────────── - // Skip the three sentinel names — "ByLayer" / "ByBlock" / "Continuous" - // are magic strings the resolver matches verbatim in both docs. - let mut linetype_map: HashMap = HashMap::default(); - for lt in xref_doc.line_types.iter() { - let old = lt.name.clone(); - if is_sentinel_linetype(&old) { - continue; - } - let new = format!("{}|{}", prefix, old); - let mut cloned = lt.clone(); - cloned.name = new.clone(); - cloned.set_handle(doc.allocate_handle()); - doc.line_types.add_or_replace(cloned); - linetype_map.insert(old.to_uppercase(), new); - } + // ── Entities (shared helper) ──────────────────────────────────────── + // The unremapped-handle count is ignored on the load path (BIND reports + // it per bind). + let (dropped, _, entity_handle_map) = + merge_source_entities(doc, &xref_doc, br_handle, &maps); - // ── Block records (nested blocks inside the xref) ─────────────────── - // First pass: create each prefixed BR with its host handle + empty - // entity_handles. Second pass (in the entity loop below) populates - // them by inserting entities with `owner_handle` set. - // - // Tracks (xref-doc BR handle → host BR handle) so entities owned by - // a nested xref block can be routed to the right host block_record. - let mut br_handle_map: HashMap = HashMap::default(); - let mut block_name_map: HashMap = HashMap::default(); + // ── Draw-order overrides (shared helper) ────────────────────────────── + copy_xref_sortents(doc, &xref_doc, br_handle, &maps.br_handles, &entity_handle_map); + dropped +} + +/// Source entities in display order: each block record's `entity_handles` +/// chain is the draw order inside that block, while the flat entity list is +/// stream order — merging in stream order scrambles the source's own stacking +/// (fills over outlines etc.), since the host ranks draw order by the +/// freshly-allocated handles. Entities missing from every chain keep their +/// stream order at the end. BLOCK/BlockEnd markers are excluded. +fn ordered_source_entities(xref_doc: &CadDocument) -> Vec<(Handle, EntityType)> { + let mut ordered: Vec = Vec::new(); + let mut seen: HashSet = HashSet::default(); for br in xref_doc.block_records.iter() { - // Skip layout block records (*Model_Space, *Paper_Space, *Paper_Space0…) - // and any further-nested xrefs (we don't recurse into xref-of-xref). - if br.name.starts_with('*') || br.flags.is_xref || br.flags.is_xref_overlay { - continue; - } - let old = br.name.clone(); - let new = format!("{}|{}", prefix, old); - let mut cloned = br.clone(); - cloned.name = new.clone(); - cloned.entity_handles.clear(); - cloned.insert_handles.clear(); - // Detach foreign layout pointer — it refers to a Layout in xref_doc - // we don't import. - cloned.layout = Handle::NULL; - let new_h = doc.allocate_handle(); - cloned.set_handle(new_h); - cloned.block_entity_handle = doc.allocate_handle(); - cloned.block_end_handle = doc.allocate_handle(); - br_handle_map.insert(br.handle, new_h); - block_name_map.insert(old.to_uppercase(), new); - doc.block_records.add_or_replace(cloned); - } - - // ── Entities ──────────────────────────────────────────────────────── - let xref_ms_handle = xref_doc.header.model_space_block_handle; - // Merge in each block's DISPLAY order: a block record's entity_handles - // chain is the draw order inside that block, while the flat entity list - // is stream order — merging in stream order scrambled the xref's own - // stacking (fills over outlines etc.), since the host ranks draw order - // by the freshly-allocated handles. Entities missing from every chain - // keep their stream order at the end. - let ordered: Vec = { - let mut ordered: Vec = Vec::new(); - let mut seen: HashSet = HashSet::default(); - for br in xref_doc.block_records.iter() { - for &h in &br.entity_handles { - if seen.insert(h) { - ordered.push(h); - } - } - } - for e in xref_doc.entities() { - let h = e.common().handle; + for &h in &br.entity_handles { if seen.insert(h) { ordered.push(h); } } - ordered - }; - let entities: Vec<(Handle, EntityType)> = ordered + } + for e in xref_doc.entities() { + let h = e.common().handle; + if seen.insert(h) { + ordered.push(h); + } + } + ordered .into_iter() .filter_map(|h| xref_doc.get_entity(h).map(|e| (h, e.clone()))) .filter(|(_, e)| !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_))) - .collect(); + .collect() +} - // old xref-doc entity handle → merged host handle, for the sortents copy. +/// Merge one source doc's entities into `target`, routing model-space content +/// into `target_br` and nested-block content into its imported counterpart +/// (`maps.br_handles`). Returns corrupt-drop count, unremapped-handle count, +/// and the old→new entity handle map for the sortents copy. +fn merge_source_entities( + target: &mut CadDocument, + source: &CadDocument, + target_br: Handle, + maps: &XrefSymbolMaps, +) -> (usize, usize, HashMap) { + let source_ms_handle = source.header.model_space_block_handle; let mut entity_handle_map: HashMap = HashMap::default(); let mut dropped = 0usize; - for (old_h, mut entity) in entities { + let mut unremapped = 0usize; + for (old_h, mut entity) in ordered_source_entities(source) { // Drop parser-garbage entities (bad normals / vertex counts / inf // coords) before they enter the host doc — they trigger huge // allocations and unbounded recursion in the wire pipeline. @@ -447,35 +861,18 @@ fn merge_xref_into_block( dropped += 1; continue; } - // Remap layer / linetype names so the host's resolver hits the - // copies we just inserted. - { - let c = entity.common_mut(); - if let Some(new_layer) = layer_map.get(&c.layer.to_uppercase()) { - c.layer = new_layer.clone(); - } - if !is_sentinel_linetype(&c.linetype) { - if let Some(new_lt) = linetype_map.get(&c.linetype.to_uppercase()) { - c.linetype = new_lt.clone(); - } - } - } - - // INSERTs reference blocks by name, not handle — remap. - if let EntityType::Insert(ins) = &mut entity { - if let Some(new_name) = block_name_map.get(&ins.block_name.to_uppercase()) { - ins.block_name = new_name.clone(); - } - } - + // Remap every symbol reference so the host's resolver hits the copies + // just imported (load path ignores the unremapped count; BIND reports + // it per bind). + unremapped += remap_xref_entity(target, &mut entity, maps); // Route entity to the correct host block record. Entities owned by - // the xref's model_space land in the host xref block (br_handle); - // entities owned by one of the nested BRs land in its prefixed - // counterpart. Paper-space / unknown owners are skipped. + // the source model_space land in the target block; entities owned by + // one of the nested BRs land in its imported counterpart. + // Paper-space / unknown owners are skipped. let old_owner = entity.common().owner_handle; - let new_owner = if old_owner == xref_ms_handle { - br_handle - } else if let Some(&h) = br_handle_map.get(&old_owner) { + let new_owner = if old_owner == source_ms_handle { + target_br + } else if let Some(&h) = maps.br_handles.get(&old_owner) { h } else { continue; @@ -483,50 +880,11 @@ fn merge_xref_into_block( entity.common_mut().owner_handle = new_owner; // Clear the foreign handle so acadrust assigns a new one. set_handle(&mut entity, Handle::NULL); - if let Ok(new_h) = doc.add_entity(entity) { + if let Ok(new_h) = target.add_entity(entity) { entity_handle_map.insert(old_h, new_h); } } - - // ── Draw-order overrides ──────────────────────────────────────────── - // Re-point the xref's SortEntitiesTables at the merged blocks with the - // merged entity handles, so an explicit DRAWORDER inside the xref - // survives the merge. - { - use acadrust::objects::{ObjectType, SortEntitiesTable}; - for obj in xref_doc.objects.values() { - let ObjectType::SortEntitiesTable(t) = obj else { - continue; - }; - if t.is_empty() { - continue; - } - let new_block = if t.block_owner_handle == xref_ms_handle { - br_handle - } else if let Some(&h) = br_handle_map.get(&t.block_owner_handle) { - h - } else { - continue; - }; - let mut nt = SortEntitiesTable::new(); - nt.handle = doc.allocate_handle(); - nt.block_owner_handle = new_block; - for e in t.entries() { - let Some(&ne) = entity_handle_map.get(&e.entity_handle) else { - continue; - }; - let ns = entity_handle_map - .get(&e.sort_handle) - .copied() - .unwrap_or(e.sort_handle); - nt.add_entry(ne, ns); - } - if !nt.is_empty() { - doc.objects.insert(nt.handle, ObjectType::SortEntitiesTable(nt)); - } - } - } - dropped + (dropped, unremapped, entity_handle_map) } fn is_sentinel_linetype(name: &str) -> bool { @@ -539,3 +897,1954 @@ fn is_sentinel_linetype(name: &str) -> bool { fn set_handle(entity: &mut EntityType, h: Handle) { entity.common_mut().handle = h; } + +/// Re-exported so CLI + tests share one derivation with the model. +pub use crate::io::xref_model::child_key; + +/// Unified reference list for the XREF manager. +/// +/// Scans `doc` for DWG xref block-records, RasterImage-linked image +/// definitions, and PDF underlay definitions. `saved_path` is always the raw +/// stored string verbatim; `found_at`/`size_bytes`/`modified` come from +/// [`resolve_path`] against `base_dir`. Keys in `unloaded` come back as +/// `Unloaded` (loaded=false) without touching the filesystem. +/// +/// `prev` holds cached load-time mtimes per key (see +/// [`RefStatCache`](crate::io::xref_model::RefStatCache)). Empty on first +/// call → no `Stale` on bootstrap. When both the live mtime and the cached +/// baseline exist and live > cached + 1s slack, a `Loaded` entry reports +/// `Stale`. Nested children whose host entry is `NotFound`/`Failed` report +/// `Orphaned` (overriding `Loaded`). +/// +/// Key namespacing: top-level keys are host handle values (unchanged). +/// Nested child keys are +/// [`child_key`](crate::io::xref_model::child_key)`(parent_key, name, +/// saved_path)` so foreign handles that collide with host handles never +/// alias a host row. Both `collect_entries` and the palette refresh path go +/// through this fn, and the palette keys rows off `(key, saved_path)`, so +/// nested rows keep resolving. +/// +/// Nested enumeration (SPIKE5): each LOADED DwgXref whose file resolved is +/// opened read-only and its *direct* xref block-records are appended as child +/// entries (no geometry merge). Parsed docs live in a per-path +/// cache inside the call; already-seen normalized `saved_path`s are skipped, +/// so a self- or cyclic reference enumerates once and never recurses. A +/// nested file that fails to parse is skipped silently. Synchronous by +/// design; async stat is Task 8. +pub fn collect_entries( + doc: &acadrust::CadDocument, + base_dir: &Path, + unloaded: &std::collections::HashSet, +) -> Vec { + collect_entries_with_prev(doc, base_dir, unloaded, &std::collections::HashMap::new()) +} + +/// [`collect_entries`] with a load-time mtime baseline for `Stale` detection. +/// See [`collect_entries`] for the full contract. +pub fn collect_entries_with_prev( + doc: &acadrust::CadDocument, + base_dir: &Path, + unloaded: &std::collections::HashSet, + prev: &std::collections::HashMap, +) -> Vec { + use acadrust::entities::UnderlayType; + use acadrust::objects::ObjectType; + use crate::io::xref_model::{ + child_key, decide_status, normalize_lexical, RefKind, RefStatus, RefType, + ReferenceEntry, UnloadKey, + }; + + /// Resolve `raw` via `base_dir`; on success record `found_at`, stat + /// size/mtime, and flip the entry to Loaded. Missing/empty stays NotFound. + /// + /// The filesystem stat is desktop-only by `cfg` (not a runtime check): the + /// wasm binary contains no `std::fs::metadata` call at all, so a web + /// session can never block on — or fail from — a host-filesystem probe. + /// Path-based web references resolve through the file-handle loader and + /// simply stay `NotFound` when unresolvable. + fn stat_into(entry: &mut ReferenceEntry, raw: &str, base_dir: &Path) { + if raw.is_empty() { + return; + } + let Some(found) = resolve_path(raw, base_dir) else { + return; + }; + entry.found_at = Some(found.to_string_lossy().into_owned()); + entry.status = RefStatus::Loaded; + #[cfg(not(target_arch = "wasm32"))] + if let Ok(meta) = std::fs::metadata(&found) { + entry.size_bytes = Some(meta.len()); + entry.modified = meta.modified().ok(); + } + } + + /// Resolve `raw` into `found_at` without statting: unloaded rows keep no + /// size/date (spec) but still know where their file lives, so previews + /// and path operations work without loading. + fn resolve_only(entry: &mut ReferenceEntry, raw: &str, base_dir: &Path) { + if raw.is_empty() { + return; + } + if let Some(found) = resolve_path(raw, base_dir) { + entry.found_at = Some(found.to_string_lossy().into_owned()); + } + } + + fn file_name_only(raw: &str) -> &str { + raw.rsplit(['/', '\\']).next().unwrap_or(raw) + } + + let mut entries: Vec = Vec::new(); + + // ── DWG xrefs ── + for br in doc.block_records.iter() { + if !(br.flags.is_xref || br.flags.is_xref_overlay) { + continue; + } + let key = br.handle.value(); + let mut entry = ReferenceEntry::new(key, br.name.clone(), RefKind::DwgXref); + entry.ref_type = if br.flags.is_xref_overlay { + RefType::Overlay + } else { + RefType::Attach + }; + entry.saved_path = br.xref_path.clone(); + if unloaded.contains(&UnloadKey::Direct(key)) { + entry.status = RefStatus::Unloaded; + resolve_only(&mut entry, &br.xref_path, base_dir); + } else { + stat_into(&mut entry, &br.xref_path, base_dir); + entry.status = decide_status( + entry.status, + entry.modified, + prev.get(&key).copied(), + false, + ); + } + entries.push(entry); + } + + // ── Raster images ──────────────────────────────────────────────────── + // Include unreferenced definitions too. They are actionable file + // references (and standard reference managers expose them) rather than + // silently disappearing from the palette. + let mut referenced_images: HashSet = HashSet::default(); + for e in doc.entities() { + if let EntityType::RasterImage(img) = e { + if let Some(h) = img.definition_handle { + referenced_images.insert(h); + } + } + } + for (handle, obj) in doc.objects.iter() { + let ObjectType::ImageDefinition(def) = obj else { + continue; + }; + let is_unreferenced = !referenced_images.contains(handle); + let key = handle.value(); + let mut entry = ReferenceEntry::new(key, file_name_only(&def.file_name), RefKind::Image); + entry.saved_path = def.file_name.clone(); + if unloaded.contains(&UnloadKey::Direct(key)) { + entry.status = RefStatus::Unloaded; + resolve_only(&mut entry, &def.file_name, base_dir); + } else { + stat_into(&mut entry, &def.file_name, base_dir); + entry.status = decide_status( + entry.status, + entry.modified, + prev.get(&key).copied(), + false, + ); + if is_unreferenced { + entry.status = RefStatus::Unreferenced; + } + } + entries.push(entry); + } + + // ── PDF underlays ── + // Same referenced/unreferenced split as raster images: an underlay + // definition no entity points at lists as Unreferenced (manageable, never + // merged) rather than vanishing from the palette. + let mut referenced_underlays: HashSet = HashSet::default(); + for e in doc.entities() { + if let EntityType::Underlay(u) = e { + if u.definition_handle.is_valid() { + referenced_underlays.insert(u.definition_handle); + } + } + } + for (handle, obj) in doc.objects.iter() { + let ObjectType::UnderlayDefinition(def) = obj else { + continue; + }; + if def.underlay_type != UnderlayType::Pdf { + continue; + } + let key = handle.value(); + let name = if def.name.trim().is_empty() { + file_name_only(&def.file_path).to_owned() + } else { + def.name.clone() + }; + let mut entry = ReferenceEntry::new(key, name, RefKind::Pdf); + entry.saved_path = def.file_path.clone(); + if unloaded.contains(&UnloadKey::Direct(key)) { + entry.status = RefStatus::Unloaded; + resolve_only(&mut entry, &def.file_path, base_dir); + } else { + stat_into(&mut entry, &def.file_path, base_dir); + entry.status = decide_status( + entry.status, + entry.modified, + prev.get(&key).copied(), + false, + ); + if !referenced_underlays.contains(handle) { + entry.status = RefStatus::Unreferenced; + } + } + entries.push(entry); + } + + // ── Nested enumeration: full transitive closure, read-only, no merge ── + // Paths stored in a nested drawing are relative to *that drawing*, not to + // the host. The work queue carries each parent's resolved file so every + // level gets the correct base directory. `seen` is keyed by resolved + // absolute identity and breaks A→B→A cycles as well as shared closures. + let mut seen: HashSet = HashSet::default(); + let mut work: Vec<(u64, PathBuf)> = Vec::new(); + for e in &entries { + if e.kind == RefKind::DwgXref && matches!(e.status, RefStatus::Loaded | RefStatus::Stale) { + if let Some(found) = &e.found_at { + let path = PathBuf::from(found); + seen.insert(normalize_lexical(&path.to_string_lossy())); + work.push((e.key, path)); + } + } + } + let mut nested_cache: HashMap = HashMap::default(); + while let Some((parent_key, parent_path)) = work.pop() { + let cache_key = normalize_lexical(&parent_path.to_string_lossy()); + let nested_doc = if let Some(cached) = nested_cache.get(&cache_key) { + cached.clone() + } else { + match super::load_file(&parent_path) { + Ok(nested) => { + nested_cache.insert(cache_key, nested.clone()); + nested + } + Err(_) => continue, + } + }; + let parent_dir = parent_path.parent().unwrap_or(base_dir); + for br in nested_doc.block_records.iter() { + if !(br.flags.is_xref || br.flags.is_xref_overlay) { + continue; + } + let key = child_key(parent_key, &br.name, &br.xref_path); + let mut child = ReferenceEntry::new(key, br.name.clone(), RefKind::DwgXref); + child.parent_key = Some(parent_key); + child.ref_type = if br.flags.is_xref_overlay { RefType::Overlay } else { RefType::Attach }; + child.saved_path = br.xref_path.clone(); + if unloaded.contains(&UnloadKey::Nested(key)) { + child.status = RefStatus::Unloaded; + } else { + stat_into(&mut child, &br.xref_path, parent_dir); + // Nested baselines live in the same stat cache under the + // child's high-bit-tagged key, which can never alias a host + // handle (see `child_key`) — so the lookup below is safe. + child.status = decide_status(child.status, child.modified, prev.get(&key).copied(), false); + } + if let Some(found) = &child.found_at { + let resolved_id = normalize_lexical(found); + if seen.insert(resolved_id) { + work.push((key, PathBuf::from(found))); + } + } + entries.push(child); + } + } + + // Stale propagation (plan §3.1): a Loaded parent with a Stale descendant + // shows Stale. Roots append before their children, so one reverse pass + // carries staleness transitively up any depth: by the time the iteration + // reaches a parent, the parent already reflects its own subtree. + // Unloaded/NotFound/Failed rows are never overridden. + { + let index: HashMap = + entries.iter().enumerate().map(|(i, e)| (e.key, i)).collect(); + for rev in (0..entries.len()).rev() { + if entries[rev].status != RefStatus::Stale { + continue; + } + if let Some(pk) = entries[rev].parent_key { + if let Some(&pi) = index.get(&pk) { + if entries[pi].status == RefStatus::Loaded { + entries[pi].status = RefStatus::Stale; + } + } + } + } + } + + entries +} + +/// Direct (host-owned) reference target for a top-level key. +/// +/// Nested child keys are stable hashes, never handle values, so they resolve +/// to `None` here — callers treat that as "not directly actionable". +enum RefTarget { + DwgXref { handle: Handle, name: String }, + Image { handle: Handle, name: String }, + Pdf { handle: Handle, name: String }, +} + +fn find_target(doc: &CadDocument, key: u64) -> Option { + for br in doc.block_records.iter() { + if (br.flags.is_xref || br.flags.is_xref_overlay) && br.handle.value() == key { + return Some(RefTarget::DwgXref { + handle: br.handle, + name: br.name.clone(), + }); + } + } + for (handle, obj) in doc.objects.iter() { + use acadrust::objects::ObjectType; + match obj { + ObjectType::ImageDefinition(def) if handle.value() == key => { + return Some(RefTarget::Image { + handle: *handle, + name: def.file_name.clone(), + }); + } + ObjectType::UnderlayDefinition(def) if handle.value() == key => { + return Some(RefTarget::Pdf { + handle: *handle, + name: if def.name.trim().is_empty() { + def.file_path.clone() + } else { + def.name.clone() + }, + }); + } + _ => {} + } + } + None +} + +/// Drop merged content for one xref without removing its definition. +/// +/// Deletes every entity whose `owner_handle` is the xref BlockRecord handle +/// plus that block's draw-order tables; the BlockRecord (flags, path) stays. +/// Image/PDF keys need no doc mutation (session-unloaded covers them) and +/// return `Ok`. Unknown (nested-hash) keys error — nested rows are never +/// directly unloadable. +pub fn unload_reference(doc: &mut CadDocument, key: u64) -> Result { + let Some(target) = find_target(doc, key) else { + return Err(crate::t!("XREF: no loaded reference with that key.").to_string()); + }; + match target { + RefTarget::DwgXref { handle, name } => { + let owned: Vec = doc + .entities() + .filter(|e| e.common().owner_handle == handle) + .map(|e| e.common().handle) + .collect(); + let owned_set: HashSet = owned.iter().copied().collect(); + for h in owned { + doc.remove_entity(h); + } + // Prune the membership chain so the block reads back empty. + if let Some(br) = doc.block_records.iter_mut().find(|b| b.handle == handle) { + br.entity_handles.retain(|h| !owned_set.contains(h)); + } + // Drop draw-order overrides owned by the unloaded block. + { + use acadrust::objects::ObjectType; + let dead: Vec = doc + .objects + .iter() + .filter_map(|(h, o)| match o { + ObjectType::SortEntitiesTable(t) + if t.block_owner_handle == handle => + { + Some(*h) + } + _ => None, + }) + .collect(); + for h in dead { + doc.objects.remove(&h); + } + } + Ok(name) + } + RefTarget::Image { name, .. } | RefTarget::Pdf { name, .. } => Ok(name), + } +} + +/// Detach one direct reference: erase all its instances, delete the +/// BlockRecord/definition and this xref's `name|*` dependent symbols. +/// +/// - Nested keys (stable hashes, no direct definition) have no `find_target` +/// hit → `Err`. The CLI maps entries with `parent_key.is_some()` to the +/// "cannot detach nested" message with the entry name. +/// - INSERT erase walks the flat store, which holds block-owned entities too +/// (SPIKE2 item 6), so instances inside other block definitions are covered. +/// - Images/PDFs: remove the definition object plus every entity referencing +/// it by handle. +pub fn detach_reference(doc: &mut CadDocument, key: u64) -> Result { + let Some(target) = find_target(doc, key) else { + return Err(crate::t!("XREF: no loaded reference with that key.").to_string()); + }; + match target { + RefTarget::DwgXref { handle, name } => { + // (b) erase ALL INSERT instances (flat store includes block-owned). + let inserts: Vec = doc + .entities() + .filter_map(|e| match e { + EntityType::Insert(ins) + if ins.block_name.eq_ignore_ascii_case(&name) => + { + Some(e.common().handle) + } + _ => None, + }) + .collect(); + for h in inserts { + doc.remove_entity(h); + } + // Merged content owned by the xref block. + let owned: Vec = doc + .entities() + .filter(|e| e.common().owner_handle == handle) + .map(|e| e.common().handle) + .collect(); + for h in owned { + doc.remove_entity(h); + } + // (c) `name|*` dependents for THIS xref only (case-insensitive). + remove_pipe_symbols(doc, &name); + // Draw-order tables owned by the detached block. + { + use acadrust::objects::ObjectType; + let dead: Vec = doc + .objects + .iter() + .filter_map(|(h, o)| match o { + ObjectType::SortEntitiesTable(t) + if t.block_owner_handle == handle => + { + Some(*h) + } + _ => None, + }) + .collect(); + for h in dead { + doc.objects.remove(&h); + } + } + doc.block_records.remove(&name); + Ok(name) + } + RefTarget::Image { handle, name } => { + let refs: Vec = doc + .entities() + .filter_map(|e| match e { + EntityType::RasterImage(img) + if img.definition_handle == Some(handle) => + { + Some(e.common().handle) + } + _ => None, + }) + .collect(); + for h in refs { + doc.remove_entity(h); + } + doc.objects.remove(&handle); + Ok(name) + } + RefTarget::Pdf { handle, name } => { + let refs: Vec = doc + .entities() + .filter_map(|e| match e { + EntityType::Underlay(ul) if ul.definition_handle == handle => { + Some(e.common().handle) + } + _ => None, + }) + .collect(); + for h in refs { + doc.remove_entity(h); + } + doc.objects.remove(&handle); + Ok(name) + } + } +} + +// ── BIND (Task 9) ───────────────────────────────────────────────────────── + +/// Outcome of binding one reference. +#[derive(Debug)] +pub struct BindOutcome { + /// Bound block / definition name. + pub name: String, + /// Foreign style handles that could not be remapped (see + /// `bind_limitations` on the shared remap helper). + pub unremapped: usize, +} + +/// Bind one direct reference: fold the external file into the host drawing. +/// +/// - DWG xrefs become plain local blocks (same name, so existing INSERTs keep +/// resolving). Dependent symbols `name|sym` become `name$0$sym` (N bumped +/// past collisions); nested children bind first so chains compose +/// transitively (`PLAN$0$DETAIL$0$WALLS`); the whole bind-closure is +/// merged — nested geometry is never dropped. +/// - Images are COLLECTed: the file is copied next to the host drawing and +/// the definition retargeted to the relative filename. A copy failure +/// errors before the host doc is touched (no partial state). +/// - PDFs error explicitly — vector import is unavailable, and silently +/// dropping them would lose data. +/// - Nested keys have no host definition and never reach here (callers map +/// them to the "bind it in its host drawing" message, mirroring Detach). +pub fn bind_reference( + doc: &mut CadDocument, + key: u64, + base_dir: &Path, + host_dir: &Path, +) -> Result { + let Some(target) = find_target(doc, key) else { + return Err(crate::t!("XREF: no loaded reference with that key.").to_string()); + }; + match target { + RefTarget::DwgXref { handle, name } => bind_dwg(doc, handle, &name, base_dir, host_dir), + RefTarget::Image { handle, name } => { + collect_bind_images(doc, &name, base_dir, host_dir, Some(handle))?; + Ok(BindOutcome { name, unremapped: 0 }) + } + RefTarget::Pdf { .. } => Err(crate::t!( + "XREF: PDF bind (vector import) is not available in this version." + ) + .to_string()), + } +} + +fn bind_failed(name: &str, reason: impl std::fmt::Display) -> String { + crate::tf!("XREF: cannot bind \"{}\": {}.", name, reason.to_string()).to_string() +} + +fn bind_dwg( + doc: &mut CadDocument, + br_handle: Handle, + name: &str, + base_dir: &Path, + host_dir: &Path, +) -> Result { + let saved = doc + .block_records + .iter() + .find(|b| b.handle == br_handle) + .map(|b| b.xref_path.clone()) + .unwrap_or_default(); + let Some(found) = resolve_path(&saved, base_dir) else { + return Err(bind_failed(name, "file not found")); + }; + let source_dir = found + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + // Load the closure (this file + every nested xref, children first). All + // file copies happen below, before the host doc is touched — a copy or + // parse failure leaves no partial document state. + let mut source = load_bind_source(name, &found)?; + check_no_pdf_underlays(&source)?; + let mut stack = vec![crate::io::xref_model::normalize_lexical( + &found.to_string_lossy(), + )]; + let mut unremapped = bind_nested_closure(&mut source, &source_dir, host_dir, &mut stack)?; + collect_bind_images(&mut source, name, &source_dir, host_dir, None)?; + // Drop the previous merge + `name|*` symbols. INSERTs of the xref stay — + // they now reference the bound block. + unload_reference(doc, br_handle.value())?; + remove_pipe_symbols(doc, name); + let taken = BindTaken::capture(doc); + let (u, _) = merge_bound_doc(doc, name, br_handle, &source, taken); + unremapped += u; + // Convert the xref BlockRecord into a plain local block (same name, so + // existing INSERTs keep resolving). + if let Some(br) = doc.block_records.iter_mut().find(|b| b.handle == br_handle) { + br.flags.is_xref = false; + br.flags.is_xref_overlay = false; + br.xref_path.clear(); + } + Ok(BindOutcome { + name: name.to_string(), + unremapped, + }) +} + +fn load_bind_source(ref_name: &str, found: &Path) -> Result { + match super::load_file(found) { + Ok(doc) => Ok(doc), + Err(e) => Err(bind_failed(ref_name, e)), + } +} + +/// Reject PDF underlays anywhere in a bind closure: vector import is +/// unavailable, and silently dropping them would lose data. +fn check_no_pdf_underlays(source: &CadDocument) -> Result<(), String> { + use acadrust::entities::UnderlayType; + use acadrust::objects::ObjectType; + let has_pdf = source.objects.values().any(|o| { + matches!(o, ObjectType::UnderlayDefinition(d) if d.underlay_type == UnderlayType::Pdf) + }); + if has_pdf { + return Err(crate::t!( + "XREF: PDF bind (vector import) is not available in this version." + ) + .to_string()); + } + Ok(()) +} + +/// Recursively bind every nested xref inside `source`, children first, so the +/// parent merge composes transitive chains (`PLAN$0$DETAIL$0$WALLS`). +/// Unresolvable nested files error the whole bind; cyclic closures enumerate +/// once (mirroring `collect_entries`). Returns the nested unremapped count. +fn bind_nested_closure( + source: &mut CadDocument, + source_dir: &Path, + host_dir: &Path, + stack: &mut Vec, +) -> Result { + let children: Vec<(String, Handle, String)> = source + .block_records + .iter() + .filter(|br| (br.flags.is_xref || br.flags.is_xref_overlay) && !br.xref_path.is_empty()) + .map(|br| (br.name.clone(), br.handle, br.xref_path.clone())) + .collect(); + let mut taken = BindTaken::capture(source); + let mut unremapped = 0usize; + for (child_name, child_br, child_saved) in children { + let Some(child_found) = resolve_path(&child_saved, source_dir) else { + return Err(bind_failed(&child_name, "file not found")); + }; + let norm = + crate::io::xref_model::normalize_lexical(&child_found.to_string_lossy()); + if stack.contains(&norm) { + continue; + } + stack.push(norm); + let mut child_doc = load_bind_source(&child_name, &child_found)?; + let child_dir = child_found + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + unremapped += bind_nested_closure(&mut child_doc, &child_dir, host_dir, stack)?; + stack.pop(); + check_no_pdf_underlays(&child_doc)?; + collect_bind_images(&mut child_doc, &child_name, &child_dir, host_dir, None)?; + let (u, taken_back) = merge_bound_doc(source, &child_name, child_br, &child_doc, taken); + unremapped += u; + taken = taken_back; + if let Some(br) = source + .block_records + .iter_mut() + .find(|b| b.handle == child_br) + { + br.flags.is_xref = false; + br.flags.is_xref_overlay = false; + br.xref_path.clear(); + } + } + Ok(unremapped) +} + +/// Merge one fully child-bound source doc into `target` under BIND names +/// (`{parent}$N${sym}`), threading `taken` through for collision dedup. +/// Returns the merge's unremapped count plus the updated taken sets. +fn merge_bound_doc( + target: &mut CadDocument, + parent: &str, + target_br: Handle, + source: &CadDocument, + taken: BindTaken, +) -> (usize, BindTaken) { + ensure_block_entities(target, parent); + let mut naming = SymbolNaming::BindDollar { + parent: parent.to_string(), + taken, + }; + let maps = import_xref_symbols(target, source, &mut naming); + let (_, unremapped, entity_handle_map) = + merge_source_entities(target, source, target_br, &maps); + copy_xref_sortents( + target, + source, + target_br, + &maps.br_handles, + &entity_handle_map, + ); + let taken = match naming { + SymbolNaming::BindDollar { taken, .. } => taken, + SymbolNaming::MergePipe { .. } => unreachable!("bind merge always uses BindDollar"), + }; + (unremapped, taken) +} + +/// COLLECT every raster image in `source`: copy the file next to the host +/// drawing (first claimant keeps the filename, later same-name collisions get +/// `stem$N.ext`) and retarget definitions + entity paths to the relative +/// filename. Copies run before any host mutation — a failure errors with no +/// partial document state. `only` restricts the pass to one definition +/// (direct image bind); `None` collects the whole closure. +fn collect_bind_images( + source: &mut CadDocument, + ref_name: &str, + source_dir: &Path, + host_dir: &Path, + only: Option, +) -> Result<(), String> { + use acadrust::objects::ObjectType; + let defs: Vec<(Handle, String)> = source + .objects + .iter() + .filter_map(|(h, o)| match o { + ObjectType::ImageDefinition(d) + if only.is_none_or(|want| want == *h) => + { + Some((*h, d.file_name.clone())) + } + _ => None, + }) + .collect(); + if defs.is_empty() { + return Ok(()); + } + // Plan every copy first (dedupe repeat references to one source file). + let mut planned: HashMap = HashMap::default(); + let mut ops: Vec<(Handle, PathBuf, String)> = Vec::new(); + for (handle, saved) in &defs { + let Some(resolved) = resolve_path(saved, source_dir) else { + return Err(bind_failed(ref_name, format!("image not found: {saved}"))); + }; + let norm = crate::io::xref_model::normalize_lexical(&resolved.to_string_lossy()); + let dest_name = if let Some(prior) = planned.get(&norm) { + prior.clone() + } else { + let base = file_name_only(saved); + let mut candidate = base.clone(); + let mut n = 0u32; + while host_dir.join(&candidate).exists() + && !same_file(&resolved, &host_dir.join(&candidate)) + { + n += 1; + candidate = unique_file_name(&base, n); + } + planned.insert(norm, candidate.clone()); + candidate + }; + ops.push((*handle, resolved, dest_name)); + } + for (_, src, dest_name) in &ops { + let dest = host_dir.join(dest_name); + if same_file(src, &dest) { + continue; + } + if let Err(e) = std::fs::copy(src, &dest) { + return Err(bind_failed(ref_name, format!("cannot collect image: {e}"))); + } + } + // Retarget definitions + entity paths to the collected relative names. + for (handle, _, dest_name) in ops { + if let Some(ObjectType::ImageDefinition(def)) = source.objects.get_mut(&handle) { + def.file_name = dest_name.clone(); + } + let owned: Vec = source + .entities() + .filter_map(|e| match e { + EntityType::RasterImage(img) if img.definition_handle == Some(handle) => { + Some(e.common().handle) + } + _ => None, + }) + .collect(); + for eh in owned { + if let Some(EntityType::RasterImage(img)) = source.get_entity_mut(eh) { + img.file_path = dest_name.clone(); + } + } + } + Ok(()) +} + +fn file_name_only(raw: &str) -> String { + raw.rsplit(['/', '\\']).next().unwrap_or(raw).to_string() +} + +fn unique_file_name(base: &str, n: u32) -> String { + match base.rfind('.') { + Some(i) if i > 0 => format!("{}${n}.{}", &base[..i], &base[i + 1..]), + _ => format!("{base}${n}"), + } +} + +fn same_file(a: &Path, b: &Path) -> bool { + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(x), Ok(y)) => x == y, + _ => a == b, + } +} + +/// Remove one xref's `name|*` dependent symbols (case-insensitive): layers, +/// linetypes, text styles, dim styles, and nested block records — plus every +/// entity and draw-order table owned by those dependent blocks. +/// +/// Shared by Detach (which then also erases the BlockRecord itself) and BIND +/// (which drops the previous merge before re-merging under `$N$` names). The +/// xref BlockRecord itself, its INSERTs, and non-prefixed symbols stay. +fn remove_pipe_symbols(doc: &mut CadDocument, name: &str) { + let prefix = format!("{}|", name.to_uppercase()); + let is_dep = |n: &str| n.to_uppercase().starts_with(&prefix); + // Dependent blocks first — their handles route the owned-entity cleanup. + let dep_brs: HashSet = doc + .block_records + .iter() + .filter(|b| is_dep(&b.name)) + .map(|b| b.handle) + .collect(); + let owned: Vec = doc + .entities() + .filter(|e| dep_brs.contains(&e.common().owner_handle)) + .map(|e| e.common().handle) + .collect(); + for h in owned { + doc.remove_entity(h); + } + { + use acadrust::objects::ObjectType; + let dead: Vec = doc + .objects + .iter() + .filter_map(|(h, o)| match o { + ObjectType::SortEntitiesTable(t) if dep_brs.contains(&t.block_owner_handle) => { + Some(*h) + } + _ => None, + }) + .collect(); + for h in dead { + doc.objects.remove(&h); + } + } + let layers: Vec = doc + .layers + .names() + .filter(|n| is_dep(n)) + .map(|s| s.to_string()) + .collect(); + for n in layers { + doc.layers.remove(&n); + } + let lts: Vec = doc + .line_types + .names() + .filter(|n| is_dep(n)) + .map(|s| s.to_string()) + .collect(); + for n in lts { + doc.line_types.remove(&n); + } + let styles: Vec = doc + .text_styles + .names() + .filter(|n| is_dep(n)) + .map(|s| s.to_string()) + .collect(); + for n in styles { + doc.text_styles.remove(&n); + } + let dims: Vec = doc + .dim_styles + .names() + .filter(|n| is_dep(n)) + .map(|s| s.to_string()) + .collect(); + for n in dims { + doc.dim_styles.remove(&n); + } + let brs: Vec = doc + .block_records + .iter() + .map(|b| b.name.clone()) + .filter(|n| is_dep(n)) + .collect(); + for n in brs { + doc.block_records.remove(&n); + } +} + +/// Snapshot one xref's dependent-layer overrides for VISRETAIN=1 reloads. +/// +/// Returns `name|*` layers keyed by uppercased name. The reload path purges +/// these entries and re-merges file defaults; without the snapshot the +/// user's frozen/off/color tweaks would be silently lost on every reload. +fn snapshot_dependent_layers( + doc: &CadDocument, + name: &str, +) -> HashMap { + let prefix = format!("{}|", name.to_uppercase()); + doc.layers + .names() + .filter(|n| n.to_uppercase().starts_with(&prefix)) + .filter_map(|n| { + doc.layers + .get(n) + .cloned() + .map(|l| (l.name.to_uppercase(), l)) + }) + .collect() +} + +/// Re-apply a [`snapshot_dependent_layers`] snapshot onto freshly merged +/// layers. Only layers present post-merge are touched (deleted-in-file layers +/// stay deleted); handles and file-structural links (material, plotstyle, +/// xref block-record pointer) stay on the fresh entries — everything a user +/// can tweak in a layer UI is restored. +fn restore_dependent_layers( + doc: &mut CadDocument, + name: &str, + kept: HashMap, +) { + if kept.is_empty() { + return; + } + let prefix = format!("{}|", name.to_uppercase()); + let targets: Vec = doc + .layers + .names() + .filter(|n| n.to_uppercase().starts_with(&prefix)) + .map(|s| s.to_string()) + .collect(); + for n in targets { + let (Some(cur), Some(old)) = ( + doc.layers.get_mut(&n), + kept.get(&n.to_uppercase()), + ) else { + continue; + }; + cur.flags = old.flags.clone(); + cur.color = old.color.clone(); + cur.color_name = old.color_name.clone(); + cur.book_name = old.book_name.clone(); + cur.line_type = old.line_type.clone(); + cur.line_weight = old.line_weight.clone(); + cur.plot_style = old.plot_style.clone(); + cur.is_plottable = old.is_plottable; + cur.transparency = old.transparency.clone(); + } +} + +/// Store `new_raw` verbatim on the definition (no synthesis). +pub fn set_ref_path( + doc: &mut CadDocument, + key: u64, + new_raw: &str, +) -> Result { + let Some(target) = find_target(doc, key) else { + return Err(crate::t!("XREF: no loaded reference with that key.").to_string()); + }; + match target { + RefTarget::DwgXref { name, .. } => { + if let Some(br) = doc.block_records.get_mut(&name) { + br.xref_path = new_raw.to_string(); + return Ok(name); + } + Err(crate::t!("XREF: no loaded reference with that key.").to_string()) + } + RefTarget::Image { handle, name } => { + use acadrust::objects::ObjectType; + match doc.objects.get_mut(&handle) { + Some(ObjectType::ImageDefinition(def)) => { + def.file_name = new_raw.to_string(); + Ok(name) + } + _ => Err(crate::t!("XREF: no loaded reference with that key.").to_string()), + } + } + RefTarget::Pdf { handle, name } => { + use acadrust::objects::ObjectType; + match doc.objects.get_mut(&handle) { + Some(ObjectType::UnderlayDefinition(def)) => { + def.file_path = new_raw.to_string(); + Ok(name) + } + _ => Err(crate::t!("XREF: no loaded reference with that key.").to_string()), + } + } + } +} + +/// Convert one reference's stored path to `pathtype` against `host_path`. +/// Verbatim store on success; neutral errors otherwise. +pub fn apply_pathtype( + doc: &mut CadDocument, + key: u64, + pathtype: crate::io::xref_model::Pathtype, + host_path: &Path, +) -> Result { + let saved = match find_target(doc, key) { + Some(RefTarget::DwgXref { name, .. }) => doc + .block_records + .get(&name) + .map(|b| b.xref_path.clone()) + .unwrap_or_default(), + Some(RefTarget::Image { handle, .. }) => { + use acadrust::objects::ObjectType; + match doc.objects.get(&handle) { + Some(ObjectType::ImageDefinition(def)) => def.file_name.clone(), + _ => String::new(), + } + } + Some(RefTarget::Pdf { handle, .. }) => { + use acadrust::objects::ObjectType; + match doc.objects.get(&handle) { + Some(ObjectType::UnderlayDefinition(def)) => def.file_path.clone(), + _ => String::new(), + } + } + None => return Err(crate::t!("XREF: no loaded reference with that key.").to_string()), + }; + // A stored relative path first has to be resolved against its current host + // folder. Passing it straight to `to_pathtype_result` made `Full` a + // no-op (`refs/a.dwg` stayed relative instead of becoming C:/…/refs/a). + let normalized = crate::io::xref_model::normalize_lexical(&saved); + let bytes = normalized.as_bytes(); + let looks_absolute = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' + || normalized.starts_with("//") + || normalized.starts_with('/'); + let stored_relative = !looks_absolute; + let effective = if stored_relative { + host_path + .parent() + .map(|dir| dir.join(&saved).to_string_lossy().into_owned()) + .unwrap_or(saved.clone()) + } else { + saved + }; + match crate::io::xref_model::to_pathtype_result(&effective, host_path, pathtype) { + Ok(next) => { + set_ref_path(doc, key, &next)?; + Ok(next) + } + Err(crate::io::xref_model::PathtypeError::AcrossDrives) => { + Err(crate::t!("XREF: cannot make path relative across drives.").to_string()) + } + Err(crate::io::xref_model::PathtypeError::UnsavedHost) => Err( + crate::t!("XREF Save the drawing first to resolve relative XREF paths.").to_string(), + ), + } +} + +/// Find & Replace: rewrite `old` → `new` as a component-wise prefix replace +/// wherever the normalized saved path has the normalized `old` path as its +/// leading run of `/`-components. Returns rewrites. +/// +/// Component-wise (never a raw byte splice): matching happens on normalized +/// forms and the result is rejoined from normalized components, so `..`-laden +/// or case-folded saved paths rewrite correctly and multibyte paths can never +/// panic on a mid-char cut. +pub fn replace_path_prefix(doc: &mut CadDocument, old: &str, new: &str) -> usize { + use crate::io::xref_model::normalize_lexical; + let old_n = normalize_lexical(old); + if old_n.is_empty() { + return 0; + } + let old_comps: Vec<&str> = old_n.split('/').collect(); + let new_n = normalize_lexical(new); + let splice = |saved: &mut String| -> bool { + let saved_n = normalize_lexical(saved); + let saved_comps: Vec<&str> = saved_n.split('/').collect(); + if saved_comps.len() < old_comps.len() { + return false; + } + if saved_comps[..old_comps.len()] != old_comps[..] { + return false; + } + let rest = &saved_comps[old_comps.len()..]; + let mut out = new_n.clone(); + if !rest.is_empty() { + if !out.is_empty() { + out.push('/'); + } + out.push_str(&rest.join("/")); + } + *saved = out; + true + }; + let mut n = 0usize; + for br in doc.block_records.iter_mut() { + if !(br.flags.is_xref || br.flags.is_xref_overlay) || br.xref_path.is_empty() { + continue; + } + if splice(&mut br.xref_path) { + n += 1; + } + } + { + use acadrust::objects::ObjectType; + for obj in doc.objects.values_mut() { + match obj { + ObjectType::ImageDefinition(def) if !def.file_name.is_empty() => { + if splice(&mut def.file_name) { + n += 1; + } + } + ObjectType::UnderlayDefinition(def) if !def.file_path.is_empty() => { + if splice(&mut def.file_path) { + n += 1; + } + } + _ => {} + } + } + } + n +} + +/// Flip Attach ↔ Overlay for one DWG xref. DWG only; images/PDFs error. +/// +/// The flip affects future nesting propagation (overlays do not re-export +/// through the host); current geometry is untouched. +pub fn set_ref_type( + doc: &mut CadDocument, + key: u64, + ref_type: crate::io::xref_model::RefType, +) -> Result { + let Some(target) = find_target(doc, key) else { + return Err(crate::t!("XREF: no loaded reference with that key.").to_string()); + }; + match target { + RefTarget::DwgXref { name, .. } => { + if let Some(br) = doc.block_records.get_mut(&name) { + match ref_type { + crate::io::xref_model::RefType::Attach => { + br.flags.is_xref = true; + br.flags.is_xref_overlay = false; + } + crate::io::xref_model::RefType::Overlay => { + br.flags.is_xref = false; + br.flags.is_xref_overlay = true; + } + } + return Ok(name); + } + Err(crate::t!("XREF: no loaded reference with that key.").to_string()) + } + RefTarget::Image { .. } | RefTarget::Pdf { .. } => { + Err(crate::t!("XREF: overlays apply to drawing references only.").to_string()) + } + } +} + +/// Save-As rebase helper, wired in `queue_native_save` (Task 8b). +/// +/// Call with the old/new parent dirs before serializing: every relative +/// saved path is made absolute via `old_base` then re-expressed relative to +/// `new_base` (absolute paths and cross-drive targets are left absolute). +/// Returns rewrite count. +/// +/// Both the save snapshot and, on Save-As completion (`on_save_finished`), +/// the live document are rebased so the running session agrees with the file +/// just written. +pub fn rebase_relative_paths_for_save_as( + doc: &mut CadDocument, + old_base: &Path, + new_base: &Path, +) -> usize { + use crate::io::xref_model::{normalize_lexical, to_pathtype_result, Pathtype}; + let is_relative = |s: &str| { + let n = normalize_lexical(s); + if n.is_empty() { + return false; + } + let b = n.as_bytes(); + if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' { + return false; + } + if n.starts_with("//") || n.starts_with('/') { + return false; + } + true + }; + // Fake host file inside the new base so `to_pathtype_result` has a parent. + let new_host = new_base.join("__host__.dwg"); + let mut n = 0usize; + let mut rebase = |saved: &mut String| { + if !is_relative(saved) { + return; + } + let abs = old_base.join(saved.clone()); + let abs_s = abs.to_string_lossy().into_owned(); + if let Ok(rel) = to_pathtype_result(&abs_s, &new_host, Pathtype::Relative) { + *saved = rel; + n += 1; + } + }; + for br in doc.block_records.iter_mut() { + if !(br.flags.is_xref || br.flags.is_xref_overlay) || br.xref_path.is_empty() { + continue; + } + rebase(&mut br.xref_path); + } + { + use acadrust::objects::ObjectType; + for obj in doc.objects.values_mut() { + match obj { + ObjectType::ImageDefinition(def) if !def.file_name.is_empty() => { + rebase(&mut def.file_name) + } + ObjectType::UnderlayDefinition(def) if !def.file_path.is_empty() => { + rebase(&mut def.file_path) + } + _ => {} + } + } + } + n +} + +#[cfg(test)] +mod tests { + use super::collect_entries; + use super::collect_entries_with_prev; + use super::HashSet; + use super::{ + apply_pathtype, child_key, detach_reference, replace_path_prefix, + resolve_xrefs_for_keys, restore_dependent_layers, set_ref_path, + set_ref_type, snapshot_dependent_layers, unload_reference, + }; + use crate::io::xref_model::{Pathtype, RefKind, RefStatus, RefType}; + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn collect_single_xref_loaded_with_verbatim_path() { + let tmp = std::env::temp_dir().join(format!( + "ocs_xref_collect_{}_{}.dwg", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&tmp, b"").unwrap(); + let saved = tmp.to_string_lossy().into_owned(); + + let mut doc = acadrust::CadDocument::new(); + let mut br = acadrust::tables::BlockRecord::new("PLAN"); + br.flags.is_xref = true; + br.xref_path = saved.clone(); + br.handle = doc.allocate_handle(); + doc.block_records.add(br).expect("add xref block record"); + + let base = tmp.parent().unwrap(); + let entries = collect_entries(&doc, base, &std::collections::HashSet::new()); + + std::fs::remove_file(&tmp).ok(); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].status, RefStatus::Loaded); + assert_eq!(entries[0].saved_path, saved); + assert_eq!(entries[0].kind, RefKind::DwgXref); + } + + fn xref_doc_with(name: &str, saved: &str) -> (acadrust::CadDocument, u64) { + let mut doc = acadrust::CadDocument::new(); + let mut br = acadrust::tables::BlockRecord::new(name); + br.flags.is_xref = true; + br.xref_path = saved.to_string(); + br.handle = doc.allocate_handle(); + let key = br.handle.value(); + doc.block_records.add(br).expect("add xref"); + (doc, key) + } + + fn xref_tmpdir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "ocs_xref_{tag}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write_nested_fixture(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) { + // mid.dwgxrefs inner.dwg; both must parse (enumeration reads the + // mid file's block-record table). Returns (mid, inner) abs paths. + std::fs::write(dir.join("inner.dwg"), b"inner-bytes").unwrap(); + let mut mid = acadrust::CadDocument::new(); + let mut br = acadrust::tables::BlockRecord::new("INNER"); + br.flags.is_xref = true; + br.xref_path = dir.join("inner.dwg").to_string_lossy().into_owned(); + mid.block_records.add(br).unwrap(); + let bytes = crate::io::save_to_bytes(&mid, "dwg", mid.version).unwrap(); + std::fs::write(dir.join("mid.dwg"), &bytes).unwrap(); + (dir.join("mid.dwg"), dir.join("inner.dwg")) + } + + #[test] + fn nested_stale_propagates_to_parent() { + // Plan §3.1: a Loaded parent with a Stale nested descendant shows + // Stale. The nested baseline predates the live mtime (epoch), so the + // child is Stale and the reverse pass carries it to the parent — + // without touching Unloaded/Failed rows. + use std::time::UNIX_EPOCH; + let dir = xref_tmpdir("staleprop"); + let (mid_path, inner_path) = write_nested_fixture(&dir); + let (doc, mid_key) = xref_doc_with( + "MID", + &mid_path.to_string_lossy(), + ); + let inner_key = child_key( + mid_key, + "INNER", + &inner_path.to_string_lossy(), + ); + let mut prev = std::collections::HashMap::new(); + prev.insert(inner_key, UNIX_EPOCH); + let entries = collect_entries_with_prev( + &doc, + &dir, + &std::collections::HashSet::new(), + &prev, + ); + let inner = entries.iter().find(|e| e.key == inner_key).expect("nested INNER listed"); + assert_eq!(inner.status, RefStatus::Stale); + let mid = entries.iter().find(|e| e.key == mid_key).expect("MID listed"); + assert_eq!(mid.status, RefStatus::Stale, "parent reflects stale child"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn xref_end_to_end_engine_lifecycle() { + // One true integration pass over a real DWG fixture (nested xref + + // referenced image + unreferenced PDF): attach-equivalent list → + // unload → targeted reload → detach. Catches integration skew that + // unit tests on single ops cannot. + use acadrust::objects::{ImageDefinition, ObjectType, UnderlayDefinition}; + let dir = xref_tmpdir("e2e"); + let (mid_path, _) = write_nested_fixture(&dir); + std::fs::write(dir.join("img.png"), b"fake-png").unwrap(); + let (mut doc, mid_key) = + xref_doc_with("MID", &mid_path.to_string_lossy()); + // Referenced image → Loaded entry. + let img_h = doc.allocate_handle(); + let mut img_def = ImageDefinition::with_dimensions("img.png", 8, 8); + img_def.handle = img_h; + doc.objects.insert(img_h, ObjectType::ImageDefinition(img_def)); + let mut img = acadrust::entities::RasterImage::new( + "img.png", + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + img.definition_handle = Some(img_h); + doc.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + // Unreferenced PDF definition → listed, never merged. + let pdf_h = doc.allocate_handle(); + let mut pdf_def = UnderlayDefinition::pdf("doc.pdf", "1"); + pdf_def.handle = pdf_h; + doc.objects.insert(pdf_h, ObjectType::UnderlayDefinition(pdf_def)); + + let empty = std::collections::HashSet::new(); + let no_prev = std::collections::HashMap::new(); + // 1. List: everything visible with the right statuses. + let listed = collect_entries_with_prev(&doc, &dir, &empty, &no_prev); + assert!(listed.iter().any(|e| e.name == "MID" && e.status == RefStatus::Loaded)); + assert!(listed.iter().any(|e| e.name == "INNER" && e.parent_key.is_some())); + assert!(listed.iter().any(|e| e.name == "img.png" && e.status == RefStatus::Loaded)); + assert!(listed.iter().any(|e| e.name == "doc.pdf" && e.status == RefStatus::Unreferenced)); + // 2. Unload: definition retained, content marked. + unload_reference(&mut doc, mid_key).expect("unload"); + let mut unloaded = std::collections::HashSet::new(); + unloaded.insert(crate::io::xref_model::UnloadKey::Direct(mid_key)); + let listed = collect_entries_with_prev(&doc, &dir, &unloaded, &no_prev); + assert!(listed.iter().any(|e| e.name == "MID" && e.status == RefStatus::Unloaded)); + assert!(doc.block_records.get("MID").is_some(), "definition retained"); + // 3. Targeted reload: back to Loaded. + let mut handles = HashSet::default(); + handles.insert(doc.block_records.get("MID").unwrap().handle); + let (infos, _) = resolve_xrefs_for_keys(&mut doc, &dir, &handles); + assert!(infos.iter().any(|i| i.name == "MID")); + let listed = collect_entries_with_prev(&doc, &dir, &empty, &no_prev); + assert!(listed.iter().any(|e| e.name == "MID" && e.status == RefStatus::Loaded)); + // 4. Detach: instances, definition, and nested rows all gone. + detach_reference(&mut doc, mid_key).expect("detach"); + let listed = collect_entries_with_prev(&doc, &dir, &empty, &no_prev); + assert!(!listed.iter().any(|e| e.name == "MID" || e.name == "INNER")); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn nested_child_key_never_aliases_host() { + let host_key = 42u64; + let nested = child_key(100, "DETAIL", "refs/detail.dwg"); + assert_ne!(nested, host_key); + } + + #[test] + fn set_ref_path_stores_verbatim() { + let (mut doc, key) = xref_doc_with("PLAN", "old/path.dwg"); + set_ref_path(&mut doc, key, "new\\raw path.dwg").expect("set path"); + let br = doc.block_records.get("PLAN").unwrap(); + assert_eq!(br.xref_path, "new\\raw path.dwg"); + } + + #[test] + fn apply_pathtype_across_drives_errors() { + let (mut doc, key) = xref_doc_with("PLAN", "D:/Lib/plan.dwg"); + let host = std::path::Path::new("C:/Drawings/host.dwg"); + let err = apply_pathtype(&mut doc, key, Pathtype::Relative, host).unwrap_err(); + assert_eq!(err, "XREF: cannot make path relative across drives."); + } + + #[test] + #[cfg(windows)] + fn apply_pathtype_full_resolves_existing_relative_path() { + let (mut doc, key) = xref_doc_with("PLAN", "refs/plan.dwg"); + let host = std::path::Path::new("C:/Drawings/host.dwg"); + apply_pathtype(&mut doc, key, Pathtype::Full, host).expect("full path"); + assert_eq!( + doc.block_records.get("PLAN").unwrap().xref_path, + "C:/Drawings/refs/plan.dwg" + ); + } + + #[test] + fn overlay_on_image_errors() { + use acadrust::objects::{ImageDefinition, ObjectType}; + let mut doc = acadrust::CadDocument::new(); + let h = doc.allocate_handle(); + let mut def = ImageDefinition::with_dimensions("img.png", 8, 8); + def.handle = h; + doc.objects.insert(h, ObjectType::ImageDefinition(def)); + let err = set_ref_type(&mut doc, h.value(), RefType::Overlay).unwrap_err(); + assert_eq!(err, "XREF: overlays apply to drawing references only."); + } + + #[test] + fn ref_type_attach_reachable_roundtrip() { + // F10: `set_ref_type(Attach)` (palette Type control, Attach + // direction) flips an overlay back to a full attach. + let (mut doc, key) = xref_doc_with("PLAN", "plan.dwg"); + set_ref_type(&mut doc, key, RefType::Overlay).expect("to overlay"); + assert!(doc.block_records.get("PLAN").unwrap().flags.is_xref_overlay); + let name = set_ref_type(&mut doc, key, RefType::Attach).expect("to attach"); + assert_eq!(name, "PLAN"); + let br = doc.block_records.get("PLAN").unwrap(); + assert!(br.flags.is_xref && !br.flags.is_xref_overlay); + } + + #[test] + fn unload_drops_merged_entities_keeps_definition() { + let (mut doc, key) = xref_doc_with("PLAN", "missing.dwg"); + let br_h = doc.block_records.get("PLAN").unwrap().handle; + let mut line = acadrust::entities::Line::new(); + line.common.handle = acadrust::types::Handle::NULL; + line.common.owner_handle = br_h; + doc.add_entity(acadrust::EntityType::Line(line)).unwrap(); + assert_eq!(doc.block_records.get("PLAN").unwrap().entity_handles.len(), 1); + unload_reference(&mut doc, key).expect("unload"); + assert!(doc.block_records.get("PLAN").is_some()); + let owned: Vec<_> = doc + .entities() + .filter(|e| e.common().owner_handle == br_h) + .collect(); + assert!(owned.is_empty()); + } + + #[test] + fn detach_erases_inserts_and_definition() { + let (mut doc, key) = xref_doc_with("PLAN", "missing.dwg"); + let ins = acadrust::entities::Insert::new("PLAN", acadrust::types::Vector3::ZERO); + doc.add_entity(acadrust::EntityType::Insert(ins)).unwrap(); + detach_reference(&mut doc, key).expect("detach"); + assert!(doc.block_records.get("PLAN").is_none()); + assert!(!doc.entities().any(|e| matches!( + e, + acadrust::EntityType::Insert(i) if i.block_name.eq_ignore_ascii_case("PLAN") + ))); + } + + #[test] + fn visretain_snapshot_restore_roundtrip() { + // VISRETAIN=1 reload path: host-side overrides on `name|*` layers + // must survive purge + re-merge. Exercises the mechanism directly: + // snapshot → mutate to defaults → restore → overrides back. + let (mut doc, _) = xref_doc_with("PLAN", "plan.dwg"); + let mut layer = acadrust::tables::Layer::new("PLAN|WALLS"); + layer.flags.off = true; + layer.flags.frozen = true; + layer.color = acadrust::types::Color::from_index(1); + doc.layers.add_or_replace(layer); + let kept = snapshot_dependent_layers(&doc, "PLAN"); + assert!(kept.contains_key("PLAN|WALLS")); + // Simulate the purge + file-default re-merge. + doc.layers.remove("PLAN|WALLS"); + doc.layers.add_or_replace(acadrust::tables::Layer::new("PLAN|WALLS")); + assert!(!doc.layers.get("PLAN|WALLS").unwrap().flags.off); + restore_dependent_layers(&mut doc, "PLAN", kept); + let back = doc.layers.get("PLAN|WALLS").unwrap(); + assert!(back.flags.off, "off override restored"); + assert!(back.flags.frozen, "frozen override restored"); + assert_eq!(back.color, acadrust::types::Color::from_index(1)); + } + + #[test] + fn visretain_restore_ignores_deleted_layers() { + // Layers gone from the new file version stay gone — restore only + // touches entries present post-merge, never resurrects. + let (mut doc, _) = xref_doc_with("PLAN", "plan.dwg"); + let mut layer = acadrust::tables::Layer::new("PLAN|GONE"); + layer.flags.off = true; + doc.layers.add_or_replace(layer); + let kept = snapshot_dependent_layers(&doc, "PLAN"); + doc.layers.remove("PLAN|GONE"); + restore_dependent_layers(&mut doc, "PLAN", kept); + assert!(doc.layers.get("PLAN|GONE").is_none()); + } + + #[test] + fn replace_prefix_rewrites_all() { + let (mut doc, _) = xref_doc_with("PLAN", "refs/old/plan.dwg"); + let n = replace_path_prefix(&mut doc, "refs/old", "refs/new"); + assert_eq!(n, 1); + assert_eq!( + doc.block_records.get("PLAN").unwrap().xref_path, + "refs/new/plan.dwg" + ); + } + + #[test] + fn replace_prefix_dotdot_saved_path() { + // `..`-laden saved path normalizes to the old prefix; the splice must + // follow normalized components, not raw bytes. + let (mut doc, _) = xref_doc_with("PLAN", "refs/a/../old/plan.dwg"); + let n = replace_path_prefix(&mut doc, "refs/old", "refs/new"); + assert_eq!(n, 1); + assert_eq!( + doc.block_records.get("PLAN").unwrap().xref_path, + "refs/new/plan.dwg" + ); + } + + #[test] + #[cfg(windows)] + fn replace_prefix_case_folded_match() { + let (mut doc, _) = xref_doc_with("PLAN", "REFS/OLD/plan.dwg"); + let n = replace_path_prefix(&mut doc, "refs/old", "refs/new"); + assert_eq!(n, 1); + assert_eq!( + doc.block_records.get("PLAN").unwrap().xref_path, + "refs/new/plan.dwg" + ); + } + + #[test] + fn replace_prefix_multibyte_no_panic() { + // Old code cut the RAW string by `old.len()` bytes after matching on + // normalized forms — a normalized match with a shorter raw prefix + // panics on a mid-char cut. Component-wise rejoin never slices raw. + // Here old `a/` normalizes to `a`, which prefixes normalized + // `aé/x.dwg`; raw cut at 2 lands inside `é` → old code panicked. + let (mut doc, _) = xref_doc_with("PLAN", "aé/x.dwg"); + let n = replace_path_prefix(&mut doc, "a/", "q/"); + assert_eq!(n, 0); + assert_eq!( + doc.block_records.get("PLAN").unwrap().xref_path, + "aé/x.dwg" + ); + let (mut doc2, _) = xref_doc_with("PLAN", "répértoire/old/plan.dwg"); + let n2 = replace_path_prefix(&mut doc2, "répértoire/old", "répértoire/new"); + assert_eq!(n2, 1); + assert_eq!( + doc2.block_records.get("PLAN").unwrap().xref_path, + "répértoire/new/plan.dwg" + ); + } + + #[test] + fn stale_only_after_cached_baseline() { + use std::collections::{HashMap, HashSet}; + use std::time::{Duration, UNIX_EPOCH}; + // No fs: drive decide_status directly via fabricated times is covered + // in xref_model; here assert the collect wrapper threads `prev` + // through without touching the filesystem (missing file → NotFound, + // never Stale even with a prev entry). + let (doc, key) = xref_doc_with("PLAN", "definitely-missing-xyz.dwg"); + let mut prev: HashMap = HashMap::new(); + prev.insert(key, UNIX_EPOCH + Duration::from_secs(1)); + let entries = super::collect_entries_with_prev( + &doc, + std::path::Path::new("."), + &HashSet::new(), + &prev, + ); + assert_eq!(entries[0].status, RefStatus::NotFound); + } + + #[allow(dead_code)] + type SystemTimeAlias = std::time::SystemTime; + + // ── BIND (Task 9) ──────────────────────────────────────────────────── + mod bind_tests { + use super::super::bind_reference; + use super::xref_doc_with; + + fn bind_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "ocs_xref_bind_{tag}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write_xref_file( + dir: &std::path::Path, + name: &str, + build: impl FnOnce(&mut acadrust::CadDocument), + ) -> String { + let mut doc = acadrust::CadDocument::new(); + build(&mut doc); + let bytes = crate::io::save_to_bytes(&doc, "dwg", doc.version).expect("save xref"); + let path = dir.join(name); + std::fs::write(&path, &bytes).expect("write xref"); + path.to_string_lossy().into_owned() + } + + fn line_on(layer: &str) -> acadrust::EntityType { + let mut line = acadrust::entities::Line::new(); + line.common.layer = layer.to_string(); + acadrust::EntityType::Line(line) + } + + fn add_layer(doc: &mut acadrust::CadDocument, name: &str) { + doc.layers + .add(acadrust::tables::Layer::new(name)) + .expect("add layer"); + } + + #[test] + fn bind_rename_basic() { + let dir = bind_dir("basic"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + add_layer(doc, "WALLS"); + doc.add_entity(line_on("WALLS")).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind"); + assert_eq!(outcome.name, "PLAN"); + assert_eq!(outcome.unremapped, 0); + let br = host.block_records.get("PLAN").unwrap(); + assert!(!br.flags.is_xref && !br.flags.is_xref_overlay); + assert!(br.xref_path.is_empty()); + assert!(host.layers.get("PLAN$0$WALLS").is_some()); + let owned: Vec<_> = host + .entities() + .filter(|e| e.common().owner_handle == br.handle) + .collect(); + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].common().layer, "PLAN$0$WALLS"); + assert!(!host.layers.names().any(|n| n.contains('|'))); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_collision_increments() { + let dir = bind_dir("collision"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + add_layer(doc, "WALLS"); + doc.add_entity(line_on("WALLS")).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + add_layer(&mut host, "PLAN$0$WALLS"); + let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind"); + assert_eq!(outcome.name, "PLAN"); + assert!(host.layers.get("PLAN$1$WALLS").is_some()); + let br = host.block_records.get("PLAN").unwrap(); + let owned: Vec<_> = host + .entities() + .filter(|e| e.common().owner_handle == br.handle) + .collect(); + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].common().layer, "PLAN$1$WALLS"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_transitive_chain() { + let dir = bind_dir("chain"); + let detail_path = write_xref_file(&dir, "detail.dwg", |doc| { + add_layer(doc, "WALLS"); + doc.add_entity(line_on("WALLS")).unwrap(); + }); + let plan_path = write_xref_file(&dir, "plan.dwg", |doc| { + add_layer(doc, "PLANLAYER"); + doc.add_entity(line_on("PLANLAYER")).unwrap(); + let mut br = acadrust::tables::BlockRecord::new("DETAIL"); + br.flags.is_xref = true; + br.xref_path = detail_path.clone(); + doc.block_records.add(br).expect("add nested xref"); + }); + let (mut host, key) = xref_doc_with("PLAN", &plan_path); + let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind"); + assert_eq!(outcome.name, "PLAN"); + assert!(host.layers.get("PLAN$0$DETAIL$0$WALLS").is_some()); + assert!(host.layers.get("PLAN$0$PLANLAYER").is_some()); + let child = host.block_records.get("PLAN$0$DETAIL").expect("bound child block"); + assert!(!child.flags.is_xref && !child.flags.is_xref_overlay); + assert!(!host + .block_records + .iter() + .any(|b| b.flags.is_xref || b.flags.is_xref_overlay)); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_remaps_hatch_dim_block_refs() { + let dir = bind_dir("refs"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + add_layer(doc, "WALLS"); + // Nested block with content + a model-space INSERT of it. + let mut door = acadrust::tables::BlockRecord::new("DOOR"); + door.handle = doc.allocate_handle(); + let door_h = door.handle; + doc.block_records.add(door).expect("add door"); + let mut inner = acadrust::entities::Line::new(); + inner.common.layer = "WALLS".to_string(); + inner.common.owner_handle = door_h; + doc.add_entity(acadrust::EntityType::Line(inner)).unwrap(); + let ins = acadrust::entities::Insert::new( + "DOOR", + acadrust::types::Vector3::ZERO, + ); + doc.add_entity(acadrust::EntityType::Insert(ins)).unwrap(); + // Hatch (pattern names have no table — preserved verbatim). + let mut hatch = acadrust::entities::Hatch::new(); + hatch.pattern.name = "ANSI31".to_string(); + doc.add_entity(acadrust::EntityType::Hatch(hatch)).unwrap(); + // Dim-style reference via a tolerance entity. + doc.dim_styles + .add(acadrust::tables::DimStyle::new("ARCH")) + .expect("add dimstyle"); + let mut tol = acadrust::entities::Tolerance::new(); + tol.dimension_style_name = "ARCH".to_string(); + doc.add_entity(acadrust::EntityType::Tolerance(tol)).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + bind_reference(&mut host, key, &dir, &dir).expect("bind"); + let door = host.block_records.get("PLAN$0$DOOR").expect("bound block"); + assert!(!door.flags.is_xref); + let inserts: Vec<_> = host + .entities() + .filter_map(|e| match e { + acadrust::EntityType::Insert(i) => Some(i.block_name.clone()), + _ => None, + }) + .collect(); + assert!(inserts.iter().any(|n| n == "PLAN$0$DOOR"), "{inserts:?}"); + let door_lines: Vec<_> = host + .entities() + .filter(|e| e.common().owner_handle == door.handle) + .collect(); + assert_eq!(door_lines.len(), 1); + assert_eq!(door_lines[0].common().layer, "PLAN$0$WALLS"); + let br = host.block_records.get("PLAN").unwrap(); + let hatches: Vec<_> = host + .entities() + .filter(|e| { + e.common().owner_handle == br.handle + && matches!(e, acadrust::EntityType::Hatch(_)) + }) + .collect(); + assert_eq!(hatches.len(), 1); + assert!(host.dim_styles.get("PLAN$0$ARCH").is_some()); + let tols: Vec<_> = host + .entities() + .filter_map(|e| match e { + acadrust::EntityType::Tolerance(t) => Some(t.dimension_style_name.clone()), + _ => None, + }) + .collect(); + assert_eq!(tols, vec!["PLAN$0$ARCH".to_string()]); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_remaps_text_styles_and_counts_unmapped() { + let dir = bind_dir("styles"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + doc.text_styles + .add(acadrust::tables::TextStyle::new("NOTE")) + .expect("add text style"); + let mut text = acadrust::entities::Text::new(); + text.style = "NOTE".to_string(); + doc.add_entity(acadrust::EntityType::Text(text)).unwrap(); + // Two foreign handles that cannot be remapped (plotstyle_flags + // set as a real writer would — otherwise the round-trip + // drops the handle). + let mut line = acadrust::entities::Line::new(); + line.common.plotstyle_flags = 0b11; + line.common.plotstyle_handle = Some(doc.allocate_handle()); + doc.add_entity(acadrust::EntityType::Line(line)).unwrap(); + let mut mline = acadrust::entities::MLine::new(); + mline.style_name = "STD".to_string(); + mline.style_handle = Some(doc.allocate_handle()); + doc.add_entity(acadrust::EntityType::MLine(mline)).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind"); + assert_eq!(outcome.unremapped, 2); + assert!(host.text_styles.get("PLAN$0$NOTE").is_some()); + let styles: Vec<_> = host + .entities() + .filter_map(|e| match e { + acadrust::EntityType::Text(t) => Some(t.style.clone()), + _ => None, + }) + .collect(); + assert_eq!(styles, vec!["PLAN$0$NOTE".to_string()]); + let br = host.block_records.get("PLAN").unwrap(); + let lines: Vec<_> = host + .entities() + .filter(|e| { + e.common().owner_handle == br.handle + && matches!(e, acadrust::EntityType::Line(_)) + }) + .collect(); + assert_eq!(lines.len(), 1); + assert!(lines[0].common().plotstyle_handle.is_none()); + let mlines: Vec<_> = host + .entities() + .filter_map(|e| match e { + acadrust::EntityType::MLine(m) => Some(m.style_handle), + _ => None, + }) + .collect(); + assert_eq!(mlines, vec![None]); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_image_collect_copies_file() { + use acadrust::objects::{ImageDefinition, ObjectType}; + let src_dir = bind_dir("imgsrc"); + let host_dir = bind_dir("imghost"); + std::fs::write(src_dir.join("pixel.png"), b"fakepng").unwrap(); + let xref_path = write_xref_file(&src_dir, "plan.dwg", |doc| { + let h = doc.allocate_handle(); + let mut def = ImageDefinition::with_dimensions("pixel.png", 8, 8); + def.handle = h; + doc.objects.insert(h, ObjectType::ImageDefinition(def)); + let mut img = acadrust::entities::RasterImage::new( + "pixel.png", + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + img.definition_handle = Some(h); + doc.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + bind_reference(&mut host, key, &src_dir, &host_dir).expect("bind"); + let copied = host_dir.join("pixel.png"); + assert!(copied.exists()); + assert_eq!(std::fs::read(&copied).unwrap(), b"fakepng"); + let defs: Vec<_> = host + .objects + .values() + .filter_map(|o| match o { + ObjectType::ImageDefinition(d) => Some(d.file_name.clone()), + _ => None, + }) + .collect(); + assert_eq!(defs, vec!["pixel.png".to_string()]); + std::fs::remove_dir_all(&src_dir).ok(); + std::fs::remove_dir_all(&host_dir).ok(); + } + + #[test] + fn bind_image_missing_errors_without_partial_state() { + use acadrust::objects::{ImageDefinition, ObjectType}; + let dir = bind_dir("imgmissing"); + let xref_path = write_xref_file(&dir, "plan.dwg", |doc| { + let h = doc.allocate_handle(); + let mut def = ImageDefinition::with_dimensions("ghost.png", 8, 8); + def.handle = h; + doc.objects.insert(h, ObjectType::ImageDefinition(def)); + let mut img = acadrust::entities::RasterImage::new( + "ghost.png", + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + img.definition_handle = Some(h); + doc.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + }); + let (mut host, key) = xref_doc_with("PLAN", &xref_path); + let err = bind_reference(&mut host, key, &dir, &dir).unwrap_err(); + assert!(err.contains("ghost.png"), "got: {err:?}"); + // No partial state: the definition is untouched. + let br = host.block_records.get("PLAN").unwrap(); + assert!(br.flags.is_xref); + assert_eq!(br.xref_path, xref_path); + assert!(!host.layers.names().any(|n| n.contains("PLAN$0"))); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn bind_pdf_errors() { + use acadrust::objects::{ObjectType, UnderlayDefinition}; + let mut host = acadrust::CadDocument::new(); + let h = host.allocate_handle(); + let mut def = UnderlayDefinition::pdf("doc.pdf", "1"); + def.handle = h; + host.objects.insert(h, ObjectType::UnderlayDefinition(def)); + let err = + bind_reference(&mut host, h.value(), std::path::Path::new("."), std::path::Path::new(".")) + .unwrap_err(); + assert_eq!( + err, + "XREF: PDF bind (vector import) is not available in this version." + ); + } + + #[test] + fn bind_unknown_key_errors() { + let (mut host, _) = xref_doc_with("PLAN", "missing.dwg"); + let err = bind_reference( + &mut host, + 0xDEAD_BEEF, + std::path::Path::new("."), + std::path::Path::new("."), + ) + .unwrap_err(); + assert_eq!(err, "XREF: no loaded reference with that key."); + } + } +} diff --git a/src/io/xref_model.rs b/src/io/xref_model.rs new file mode 100644 index 000000000..063f40d25 --- /dev/null +++ b/src/io/xref_model.rs @@ -0,0 +1,878 @@ +// XREF path model — lexical identity for external references (Task 2). + +/// Shared lexical walk for [`normalize_lexical`] (identity: case-folded on +/// Windows) and [`normalize_display`] (display: case-preserving). One +/// implementation so the two can never drift apart. +/// +/// `fold_case` lowercases the drive prefix and the joined remainder. +/// UNC shares (`//server/share/…`) additionally clamp `..` at the share root +/// (`floor == 2`): climbing above the share is deterministic garbage +/// otherwise, and a share root behaves like a filesystem root for this +/// purpose. Drive-absolute paths clamp at `/`; relative paths retain +/// surplus `..` (it still means "up"). +fn normalize_core(raw: &str, fold_case: bool) -> String { + // 1. Separator unification. Pure string op — no I/O, so missing files + // (the common NotFound case) normalize exactly like present ones. + let slashed = raw.replace('\\', "/"); + + // 2. Prefix split: drive (`C:`), UNC (`//`), or none. + let bytes = slashed.as_bytes(); + let (prefix, mut rest, unc) = if bytes.len() >= 2 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + { + let drive = &slashed[..2]; + ( + if fold_case { + drive.to_ascii_lowercase() + } else { + drive.to_string() + }, + slashed[2..].to_string(), + false, + ) + } else if let Some(stripped) = slashed.strip_prefix("//") { + ("//".to_string(), stripped.to_string(), true) + } else { + (String::new(), slashed, false) + }; + if unc { + // Collapse `///share` → `//share` so the rejoin can't triple-slash. + rest = rest.trim_start_matches('/').to_string(); + } + let absolute = rest.starts_with('/'); + // Components below this stack depth survive `..`: 2 for UNC + // (server + share), 0 otherwise (absolute roots clamp via `absolute`). + let floor = if unc { 2 } else { 0 }; + let climb = !(absolute || unc); + + // 3. Lexical component cleanup: drop empties/`.`, resolve `..` by + // popping subject to the floor above. + let mut stack: Vec<&str> = Vec::new(); + for comp in rest.split('/') { + if comp.is_empty() || comp == "." { + continue; + } else if comp == ".." { + if stack.len() > floor { + stack.pop(); + } else if climb { + stack.push(".."); + } + } else { + stack.push(comp); + } + } + + // 4. Case fold: Windows filesystems are case-insensitive, so identity + // must be too. Native non-Windows paths remain case-sensitive. + let mut joined = stack.join("/"); + // A drive/UNC path keeps Windows identity semantics even when the host + // application runs elsewhere and is inspecting a drawing created there. + if fold_case && (cfg!(windows) || !prefix.is_empty()) { + joined = joined.to_lowercase(); + } + + // 5. Rejoin as `{prefix}{joined}`, preserving rootedness. + if absolute { + joined = format!("/{joined}"); + } + format!("{prefix}{joined}") +} + +/// Lexical path normalization for reference identity. Never touches the +/// filesystem (canonicalize fails on missing files; NotFound is common). +/// Backslash→slash, `.`/`..` component cleanup, case-fold on Windows. +pub fn normalize_lexical(raw: &str) -> String { + // `fold_case` is passed as true unconditionally: `normalize_core` still + // gates the actual fold on `cfg!(windows)`, so non-Windows builds keep + // case-sensitive identity (folding there would conflate distinct files). + // `root_key` lowercases its own input separately for root comparison, so + // identity regimes stay aligned across targets without touching this. + normalize_core(raw, true) +} + +/// How an XREF path is stored relative to its host drawing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pathtype { + Full, + Relative, + None, +} + +/// Why a [`Pathtype::Relative`] conversion cannot be expressed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathtypeError { + AcrossDrives, + UnsavedHost, +} + +/// Case-preserving sibling of [`normalize_lexical`] for stored/display paths. +/// Same walk, no case folding. +fn normalize_display(raw: &str) -> String { + normalize_core(raw, false) +} + +fn root_key(normalized: &str) -> String { + let lower = normalized.to_ascii_lowercase(); + let b = lower.as_bytes(); + if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' { + return lower[..2].to_string(); + } + if lower.starts_with("//") { + let mut parts = lower[2..].split('/').filter(|s| !s.is_empty()); + if let (Some(server), Some(share)) = (parts.next(), parts.next()) { + return format!("//{server}/{share}"); + } + return lower; + } + if lower.starts_with('/') { + return "/".to_string(); + } + String::new() +} + +fn is_relative_path(normalized: &str) -> bool { + let b = normalized.as_bytes(); + if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' { + return false; + } + if normalized.starts_with("//") || normalized.starts_with('/') { + return false; + } + true +} + +fn comps_after_root(path_norm: &str) -> Vec<&str> { + let b = path_norm.as_bytes(); + let is_drive = b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':'; + let is_unc = path_norm.starts_with("//"); + if is_drive { + path_norm[2..].split('/').filter(|s| !s.is_empty()).collect() + } else if is_unc { + let all: Vec<&str> = path_norm[2..].split('/').filter(|s| !s.is_empty()).collect(); + if all.len() >= 2 { + all[2..].to_vec() + } else { + Vec::new() + } + } else { + path_norm.split('/').filter(|s| !s.is_empty()).collect() + } +} + +fn file_name_only(path: &str) -> String { + let disp = normalize_display(path); + if disp.is_empty() { + return String::new(); + } + let mut base = disp.rsplit('/').next().unwrap_or(""); + let bb = base.as_bytes(); + if bb.len() >= 2 && bb[0].is_ascii_alphabetic() && bb[1] == b':' { + base = &base[2..]; + } + base.to_string() +} + +/// Fallible pathtype conversion. `Full` and `None` always succeed; +/// `Relative` fails with [`PathtypeError::AcrossDrives`] when drive letters +/// or UNC hosts differ (UNC vs drive-letter counts as different), and with +/// [`PathtypeError::UnsavedHost`] when `host` has no parent directory. +/// Drive comparison is case-insensitive; all comparisons use +/// [`normalize_lexical`]. +pub fn to_pathtype_result( + path: &str, + host: &std::path::Path, + pathtype: Pathtype, +) -> Result { + match pathtype { + // Absolute output: a stored relative path is resolved against the + // host folder first, so `Full` is never a silent no-op on one. + Pathtype::Full => { + if is_relative_path(&normalize_lexical(path)) && !normalize_lexical(path).is_empty() { + if let Some(dir) = host.parent() { + if !dir.as_os_str().is_empty() { + let joined = dir.join(normalize_display(path)).to_string_lossy().into_owned(); + return Ok(normalize_display(&joined)); + } + } + } + Ok(normalize_display(path)) + } + Pathtype::None => Ok(file_name_only(path)), + Pathtype::Relative => { + let parent = host.parent(); + let parent = match parent { + None => return Err(PathtypeError::UnsavedHost), + Some(p) if p.as_os_str().is_empty() => return Err(PathtypeError::UnsavedHost), + Some(p) => p, + }; + let host_dir_norm = normalize_lexical(&parent.to_string_lossy()); + if host_dir_norm.is_empty() { + return Err(PathtypeError::UnsavedHost); + } + let target_norm = normalize_lexical(path); + if target_norm.is_empty() { + return Ok(String::new()); + } + let target_root = root_key(&target_norm); + let host_root = root_key(&host_dir_norm); + if target_root != host_root { + if is_relative_path(&target_norm) { + return Ok(normalize_display(path)); + } + return Err(PathtypeError::AcrossDrives); + } + let target_comps_norm = comps_after_root(&target_norm); + let host_comps_norm = comps_after_root(&host_dir_norm); + let mut k = 0usize; + while k < target_comps_norm.len() + && k < host_comps_norm.len() + && target_comps_norm[k] == host_comps_norm[k] + { + k += 1; + } + // Display components mirror normalized ones 1:1 (case folding + // cannot change component counts), so the display tail is reused + // to preserve the stored casing in the output. + let target_disp = normalize_display(path); + let target_comps_disp = comps_after_root(&target_disp); + debug_assert_eq!(target_comps_disp.len(), target_comps_norm.len()); + let mut parts: Vec = Vec::new(); + for _ in 0..host_comps_norm.len().saturating_sub(k) { + parts.push("..".to_string()); + } + for c in target_comps_disp.iter().skip(k) { + parts.push(c.to_string()); + } + if parts.is_empty() { + return Ok(file_name_only(path)); + } + Ok(parts.join("/")) + } + } +} + +/// Panic-free pathtype conversion. [`PathtypeError::AcrossDrives`] and +/// [`PathtypeError::UnsavedHost`] fall back to [`Pathtype::Full`] +/// (absolute, separator-normalized) so callers always get a usable path. +pub fn to_pathtype(path: &str, host: &std::path::Path, pathtype: Pathtype) -> String { + match to_pathtype_result(path, host, pathtype) { + Ok(s) => s, + Err(_) => normalize_display(path), + } +} + +/// Case-insensitive name match: `*` matches any run (including empty), +/// `?` matches exactly one char. +pub fn wildcard_match(name: &str, pattern: &str) -> bool { + let n: Vec = name.chars().flat_map(|c| c.to_lowercase()).collect(); + let p: Vec = pattern.chars().flat_map(|c| c.to_lowercase()).collect(); + let mut si = 0usize; + let mut pi = 0usize; + let mut star: Option = None; + let mut match_idx = 0usize; + while si < n.len() { + if pi < p.len() && (p[pi] == '?' || p[pi] == n[si]) { + si += 1; + pi += 1; + } else if pi < p.len() && p[pi] == '*' { + star = Some(pi); + match_idx = si; + pi += 1; + } else if let Some(s) = star { + pi = s + 1; + match_idx += 1; + si = match_idx; + } else { + return false; + } + } + while pi < p.len() && p[pi] == '*' { + pi += 1; + } + pi == p.len() +} + +/// What kind of external file a [`ReferenceEntry`] points at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefKind { + DwgXref, + Image, + Pdf, +} + +/// Lifecycle state of a [`ReferenceEntry`]. +/// +/// `Stale` (file changed since load) and `Orphaned` (definition with no +/// referencing entity) are detected in Task 8 — [`collect_entries`] never +/// produces them yet. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefStatus { + Loaded, + Unloaded, + NotFound, + Failed, + Stale, + /// A nested entry whose host failed (distinct from [`RefStatus::Unreferenced`). + Orphaned, + /// A definition with no placed instances (e.g. an image definition no + /// entity references). Listed so it can be managed, never merged. + Unreferenced, +} + +/// How a DWG xref attaches: full re-export (`Attach`) vs local-only (`Overlay`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefType { + Attach, + Overlay, +} + +/// One external reference in the XREF manager's unified list. +/// +/// `key` is the block-record handle (DWG xref) or definition-object handle +/// (image / PDF) for top-level entries — stable across renames, so +/// [`renamed`](Self::renamed) keeps it. Nested child entries (enumerated from +/// a host file, never merged) use [`child_key`] — an FNV-1a namespaced hash +/// of `(parent_key, name, saved_path)` — so a foreign handle that collides +/// with a host handle can never alias a host row. `saved_path` is the raw +/// stored string verbatim, never synthesized; `found_at` is where it actually +/// resolved, if anywhere. +/// +/// `parent_key` is `None` for roots and `Some(host_key)` for nested children. +/// The palette's refresh path keys rows off `(key, saved_path)` — both sides +/// go through `collect_entries`, so the same derivation applies everywhere. +#[derive(Debug, Clone, PartialEq)] +pub struct ReferenceEntry { + pub key: u64, + pub name: String, + pub kind: RefKind, + pub ref_type: RefType, + pub status: RefStatus, + pub size_bytes: Option, + pub modified: Option, + pub saved_path: String, + pub found_at: Option, + pub parent_key: Option, +} + +impl ReferenceEntry { + pub fn new(key: u64, name: impl Into, kind: RefKind) -> Self { + Self { + key, + name: name.into(), + kind, + ref_type: RefType::Attach, + status: RefStatus::NotFound, + size_bytes: None, + modified: None, + saved_path: String::new(), + found_at: None, + parent_key: None, + } + } + + /// Same entry under a new name — the key is untouched. + pub fn renamed(&self, name: impl Into) -> Self { + Self { + key: self.key, + name: name.into(), + ..self.clone() + } + } +} + +/// Bind-style symbol name for a nested xref block: the industry-standard +/// CAD bind scheme inserts `$0$` separators, so `PLAN` → `DETAIL` → `WALLS` +/// reads transitively. +pub fn bind_symbol(parent: &str, child: &str, sym: &str) -> String { + format!("{parent}$0${child}$0${sym}") +} +/// [`bind_symbol`] for the two-level case, bumping the `$N$` counter past +/// every collision in `taken` (`PLAN$0$WALLS` taken → `PLAN$1$WALLS`). +/// +/// Always terminates: `taken` holds N names, so one of the N+1 candidates +/// `0..=N` is necessarily free (pigeonhole) — no counter cap needed. +pub fn bind_symbol_taken(parent: &str, sym: &str, taken: &[impl AsRef]) -> String { + for n in 0..=taken.len() as u32 { + let candidate = format!("{parent}${n}${sym}"); + if !taken.iter().any(|t| t.as_ref() == candidate) { + return candidate; + } + } + // Unreachable by the argument above; kept total instead of panicking. + format!("{parent}$4294967295${sym}") +} + +/// Stable key for a nested child entry. +/// +/// Top-level entries keep the host handle value unchanged. Nested children +/// come from foreign files whose handle values can collide with host handles, +/// so they are namespaced via FNV-1a over `(parent_key, name, saved_path)` +/// with the top bit forced set (`0x8000_0000_0000_0000`). The high bit is the +/// namespace tag: host handles (sequentially allocated, nowhere near 2^63) +/// never have it set, so a nested key can never alias a host key — in the +/// stat cache, the unload set's raw views, or anywhere else a bare `u64` +/// travels. FNV-1a is chosen over `DefaultHasher` deliberately: the algorithm +/// is fixed, so keys are stable across runs and toolchain versions (a +/// `DefaultHasher` key would become a persistence-format hazard the moment +/// unload state is ever persisted). Same derivation is used everywhere +/// `collect_entries` output is consumed, so palette `(key, saved_path)` rows +/// keep resolving. +pub fn child_key(parent_key: u64, name: &str, saved_path: &str) -> u64 { + // FNV-1a/64 (offset 14695981039346656037, prime 1099511628211). + let mut h: u64 = 14695981039346656037; + for b in parent_key + .to_le_bytes() + .iter() + .chain(name.as_bytes()) + .chain([0u8].iter()) + .chain(saved_path.as_bytes()) + { + h ^= *b as u64; + h = h.wrapping_mul(1099511628211); + } + h | 0x8000_0000_0000_0000 +} + +/// Pure status decision for one entry (no filesystem). +/// +/// - `resolved` is the stat outcome (`Loaded`/`NotFound`/`Failed`; `Unloaded` +/// never reaches here — it is decided before stat). +/// - `live` is the current mtime, `cached` the load-time mtime from the +/// previous refresh (`None` on first call → no `Stale` on bootstrap). +/// The comparison is absolute in both directions: a file restored from +/// backup (live < cached) changed content just as much as an edited one. +/// - `parent_failed` is true for nested children whose host entry is +/// `NotFound`/`Failed` → `Loaded` is overridden to `Orphaned`. +/// +/// The 1s slack absorbs filesystem timestamp granularity so a file that did +/// not actually change is not flagged `Stale`. Conversely, changes landing +/// between document open and the first refresh are invisible that session — +/// the baseline only exists from the first stat on (no file watcher in v1). +pub fn decide_status( + resolved: RefStatus, + live: Option, + cached: Option, + parent_failed: bool, +) -> RefStatus { + if parent_failed && resolved == RefStatus::Loaded { + return RefStatus::Orphaned; + } + if resolved == RefStatus::Loaded { + if let (Some(l), Some(c)) = (live, cached) { + let delta = l.duration_since(c).ok().or_else(|| c.duration_since(l).ok()); + if delta.is_some_and(|d| d > std::time::Duration::from_secs(1)) { + return RefStatus::Stale; + } + } + } + resolved +} + +/// Session set of unloaded reference keys. +/// +/// Owns the `HashSet` that `collect_entries` takes as `unloaded`. +/// Task 8b wires session state; the set itself lives here so both sides +/// share one owner. +/// +/// Keys are domain-tagged: [`UnloadKey::Direct`] wraps a top-level handle +/// value (nested children are never unloadable on their own) while +/// [`UnloadKey::Nested`] wraps a nested [`child_key`] hash. The tag keeps a +/// nested hash from ever aliasing a host handle with the same numeric value. +/// NOTE (nested-key invariant): only [`add_key`](UnloadSet::add_key) may +/// insert `Nested` keys and only [`contains_key`](UnloadSet::contains_key) / +/// [`is_unloaded_key`](UnloadSet::is_unloaded_key) may query them — every op +/// site guards nested rows before touching session state, so the untagged +/// `add`/`contains`/`is_unloaded`/`remove` helpers below only ever see +/// direct keys. New code must use the `_key` variants for nested entries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum UnloadKey { + Direct(u64), + Nested(u64), +} + +#[derive(Debug, Clone, Default)] +pub struct UnloadSet(pub std::collections::HashSet); + +impl UnloadSet { + pub fn add(&mut self, key: u64) { + self.0.insert(UnloadKey::Direct(key)); + } + pub fn add_key(&mut self, key: UnloadKey) { + self.0.insert(key); + } + pub fn remove(&mut self, key: &u64) { + self.0.remove(&UnloadKey::Direct(*key)); + } + pub fn remove_key(&mut self, key: &UnloadKey) { + self.0.remove(key); + } + pub fn contains(&self, key: &u64) -> bool { + self.0.contains(&UnloadKey::Direct(*key)) + } + pub fn contains_key(&self, key: &UnloadKey) -> bool { + self.0.contains(key) + } + pub fn is_unloaded(&self, key: u64) -> bool { + self.0.contains(&UnloadKey::Direct(key)) + } + pub fn is_unloaded_key(&self, key: UnloadKey) -> bool { + self.0.contains(&key) + } + pub fn as_set(&self) -> &std::collections::HashSet { + &self.0 + } +} + +/// Load-time mtimes per reference key, populated on first refresh. +/// +/// `Stale` is detectable only thereafter: an empty cache means bootstrap, so +/// `decide_status` never reports `Stale` without a cached baseline. Reload +/// clears the key (fresh baseline on next refresh). +/// +/// KEY NAMESPACE: keys are host-handle values for direct entries and +/// high-bit-tagged [`child_key`] hashes for nested children (see +/// [`child_key`]: the tag bit keeps the two spaces disjoint, so sharing one +/// `u64` map is sound — the same discipline as [`UnloadKey`], encoded in the +/// key itself because every call site here already threads bare `u64`s). +/// Never insert a foreign handle that is neither. +#[derive(Debug, Clone, Default)] +pub struct RefStatCache(pub std::collections::HashMap); + +impl RefStatCache { + pub fn insert(&mut self, key: u64, mtime: std::time::SystemTime) { + self.0.insert(key, mtime); + } + pub fn get(&self, key: &u64) -> Option { + self.0.get(key).copied() + } + pub fn remove(&mut self, key: &u64) { + self.0.remove(key); + } +} + +#[cfg(test)] +mod tests { + use super::{normalize_display, normalize_lexical, to_pathtype, to_pathtype_result, wildcard_match, Pathtype, PathtypeError}; + use super::{bind_symbol, bind_symbol_taken, RefKind, ReferenceEntry}; + use super::{child_key, decide_status, RefStatCache, RefStatus, UnloadSet}; + + #[test] + fn child_key_differs_from_colliding_host_key() { + // Crafted collision: nested foreign handle 42 equals a host handle 42. + // Namespaced derivation must not alias the host row. + let host_key = 42u64; + let nested = child_key(100, "DETAIL", "refs/detail.dwg"); + assert_ne!(nested, host_key); + // High-bit namespace tag: host handles never carry it. + assert_ne!(nested & 0x8000_0000_0000_0000, 0); + // Stable: same inputs → same key; different parent → different key. + assert_eq!(nested, child_key(100, "DETAIL", "refs/detail.dwg")); + assert_ne!(nested, child_key(101, "DETAIL", "refs/detail.dwg")); + } + + #[test] + fn normalize_is_idempotent() { + // normalize(normalize(x)) == normalize(x) across drives, UNC, + // relative, dot-segments, and empty inputs. + let cases = [ + "C:\\Host\\PLAN.dwg", + "c:/host/./plan.dwg", + "//SERVER/Share/../Share/f.dwg", + "//s/sh/../../x.dwg", + "rel/../../x.dwg", + "c:/a/../../b.dwg", + "a//b\\\\c.dwg", + "../up/ref.dwg", + "", + ".", + ]; + for c in cases { + let once = normalize_lexical(c); + assert_eq!(normalize_lexical(&once), once, "idempotence for {c:?}"); + assert_eq!(normalize_display(&once), once, "display-stable for {c:?}"); + } + // UNC climbs clamp at the share root instead of producing garbage. + assert_eq!(normalize_lexical("//s/sh/../../x.dwg"), "//s/sh/x.dwg"); + } + + #[test] + fn relative_roundtrip_invariant() { + // The Relative branch's real contract: converting an absolute path to + // Relative and resolving it against the same host must recover the + // normalized absolute path. + let host = std::path::Path::new("C:/Drawings/host.dwg"); + for target in [ + "C:/Drawings/refs/plan.dwg", + "C:/Drawings/plan.dwg", + "C:/Lib/detail.dwg", + ] { + let rel = to_pathtype(target, host, Pathtype::Relative); + let back = std::path::Path::new("C:/Drawings").join(&rel); + assert_eq!( + normalize_lexical(&back.to_string_lossy()), + normalize_lexical(target), + "roundtrip for {target:?} via {rel:?}" + ); + } + } + + #[test] + fn decide_status_backwards_mtime_is_stale() { + // A file restored from backup (live < cached) changed content just + // as much as an edited one: absolute difference, both directions. + use std::time::{Duration, UNIX_EPOCH}; + let cached = UNIX_EPOCH + Duration::from_secs(1_000); + let older = cached - Duration::from_secs(30); + assert_eq!( + decide_status(RefStatus::Loaded, Some(older), Some(cached), false), + RefStatus::Stale + ); + } + + #[test] + fn decide_status_stale_needs_slack() { + use std::time::{Duration, UNIX_EPOCH}; + let cached = UNIX_EPOCH + Duration::from_secs(1_000); + // +0.5s: within 1s slack → stays Loaded. + let live = cached + Duration::from_millis(500); + assert_eq!( + decide_status(RefStatus::Loaded, Some(live), Some(cached), false), + RefStatus::Loaded + ); + // +2s: beyond slack → Stale. + let live2 = cached + Duration::from_secs(2); + assert_eq!( + decide_status(RefStatus::Loaded, Some(live2), Some(cached), false), + RefStatus::Stale + ); + // No cached baseline (first refresh) → never Stale on bootstrap. + assert_eq!( + decide_status(RefStatus::Loaded, Some(live2), None, false), + RefStatus::Loaded + ); + } + + #[test] + fn decide_status_orphaned_overrides_loaded_only() { + use std::time::{Duration, UNIX_EPOCH}; + let t = Some(UNIX_EPOCH + Duration::from_secs(1_000)); + assert_eq!( + decide_status(RefStatus::Loaded, t, t, true), + RefStatus::Orphaned + ); + // Non-Loaded nested states survive the parent override. + assert_eq!( + decide_status(RefStatus::NotFound, t, t, true), + RefStatus::NotFound + ); + assert_eq!( + decide_status(RefStatus::Failed, t, t, true), + RefStatus::Failed + ); + } + + #[test] + fn unload_set_add_remove_contains() { + let mut s = UnloadSet::default(); + assert!(!s.is_unloaded(7)); + s.add(7); + assert!(s.is_unloaded(7)); + assert!(s.contains(&7)); + s.remove(&7); + assert!(!s.is_unloaded(7)); + } + + #[test] + fn unload_set_namespaces_direct_vs_nested() { + use super::UnloadKey; + // Crafted collision: host handle 42 vs nested child hash 42 must not + // cross-trigger — the domain tag keeps the two spaces apart. + let mut s = UnloadSet::default(); + s.add(42); + assert!(s.is_unloaded(42)); + assert!(!s.contains_key(&UnloadKey::Nested(42))); + assert!(!s.is_unloaded_key(UnloadKey::Nested(42))); + let mut t = UnloadSet::default(); + t.add_key(UnloadKey::Nested(42)); + assert!(!t.is_unloaded(42)); + assert!(!t.contains(&42)); + assert!(t.is_unloaded_key(UnloadKey::Nested(42))); + } + + #[test] + fn ref_stat_cache_roundtrip() { + use std::time::{Duration, UNIX_EPOCH}; + let mut c = RefStatCache::default(); + let t = UNIX_EPOCH + Duration::from_secs(5); + c.insert(9, t); + assert_eq!(c.get(&9), Some(t)); + } + + #[test] + fn lexical_normalize_missing_file_no_fs_touch() { + let a = normalize_lexical("C:\\Host\\PLAN.dwg"); + let b = normalize_lexical("c:/host/./plan.dwg"); + assert_eq!(a, b); + let c = normalize_lexical("C:\\NoSuchDir\\Sub\\..\\REF.DWG"); + let d = normalize_lexical("c:\\nosuchdir\\ref.dwg"); + assert_eq!(c, d); + } + + #[test] + #[cfg(windows)] + fn lexical_normalize_exact_forms() { + assert_eq!(normalize_lexical("C:\\Host\\PLAN.dwg"), "c:/host/plan.dwg"); + assert_eq!( + normalize_lexical("C:\\NoSuchDir\\Sub\\..\\REF.DWG"), + "c:/nosuchdir/ref.dwg" + ); + assert_eq!( + normalize_lexical("//SERVER/Share/./File.DWG"), + "//server/share/file.dwg" + ); + assert_eq!(normalize_lexical("c:/a/../../b.dwg"), "c:/b.dwg"); + assert_eq!(normalize_lexical("rel/../../x.dwg"), "../x.dwg"); + assert_eq!(normalize_lexical("a//b\\\\c.dwg"), "a/b/c.dwg"); + assert_eq!(normalize_lexical("a/b/"), "a/b"); + assert_eq!(normalize_lexical(""), ""); + assert_eq!(normalize_lexical("."), ""); + } + + // Deferred byte-level roundtrip probe (SPIKE1 / Task 1 carryover): + // temp-dir only, nothing added to the repo. Builds an in-memory document + // carrying each reference kind, writes it out, re-reads, and asserts + // every SPIKE1 cell survived the round trip. + #[cfg(not(target_arch = "wasm32"))] + fn roundtrip_probe(ext: &str) { + use acadrust::objects::{ImageDefinition, ObjectType, UnderlayDefinition}; + use acadrust::tables::BlockRecord; + use acadrust::CadDocument; + + let dir = std::env::temp_dir().join(format!( + "ocs_xref_probe_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).expect("probe dir"); + + let mut doc = CadDocument::new(); + + // Cell 1: xref block record — is_xref flag + absolute path. + let xref_abs = dir.join("PROBE_REF.dwg").to_string_lossy().into_owned(); + let mut br = BlockRecord::new("PROBE_REF"); + br.flags.is_xref = true; + br.xref_path = xref_abs.clone(); + doc.block_records.add(br).expect("add xref block record"); + + // Cell 2: raster image definition with an absolute file path. + let img_handle = doc.allocate_handle(); + let mut img_def = + ImageDefinition::with_dimensions(r"C:\Probe\IMG.png", 64u32, 64u32); + img_def.handle = img_handle; + img_def.is_loaded = true; + doc.objects + .insert(img_handle, ObjectType::ImageDefinition(img_def)); + + // Cell 3: PDF underlay definition with an absolute file path. + let und_handle = doc.allocate_handle(); + let mut und_def = UnderlayDefinition::pdf(r"C:\Probe\DOC.pdf", "1"); + und_def.handle = und_handle; + doc.objects + .insert(und_handle, ObjectType::UnderlayDefinition(und_def)); + + // Cell 4: the retain flag itself ($VISRETAIN). + doc.header.retain_xref_visibility = true; + + let bytes = crate::io::save_to_bytes(&doc, ext, doc.version) + .expect("probe save"); + std::fs::write(dir.join(format!("probe.{ext}")), &bytes).expect("probe write"); + + let back = crate::io::load_bytes(&format!("probe.{ext}"), bytes).expect("probe reload"); + + let br2 = back + .block_records + .iter() + .find(|b| b.name.eq_ignore_ascii_case("PROBE_REF")) + .expect("xref block record round-trips"); + assert!(br2.flags.is_xref, "is_xref flag round-trips"); + assert_eq!(br2.xref_path, xref_abs, "abs xref path round-trips"); + + let img_back = back + .objects + .values() + .find_map(|o| match o { + ObjectType::ImageDefinition(d) => Some(d), + _ => None, + }) + .expect("image definition round-trips"); + assert_eq!(img_back.file_name, r"C:\Probe\IMG.png"); + + let und_back = back + .objects + .values() + .find_map(|o| match o { + ObjectType::UnderlayDefinition(d) => Some(d), + _ => None, + }) + .expect("underlay definition round-trips"); + assert_eq!(und_back.file_path, r"C:\Probe\DOC.pdf"); + + assert!( + back.header.retain_xref_visibility, + "retain_xref_visibility round-trips as true" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn probe_roundtrip_dwg() { + roundtrip_probe("dwg"); + } + + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn probe_roundtrip_dxf() { + roundtrip_probe("dxf"); + } + + #[test] + fn pathtype_full_relative_none_roundtrip() { + let host = std::path::Path::new("C:/Drawings/host.dwg"); + let full = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::Full); + assert_eq!(full, "C:/Drawings/refs/plan.dwg"); + let rel = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::Relative); + assert_eq!(rel, "refs/plan.dwg"); + let none = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::None); + assert_eq!(none, "plan.dwg"); + } + #[test] + fn relative_across_drives_errors() { + let host = std::path::Path::new("C:/Drawings/host.dwg"); + let err = to_pathtype_result("D:/Lib/plan.dwg", host, Pathtype::Relative); + assert_eq!(err, Err(PathtypeError::AcrossDrives)); + } + #[test] + fn wildcard_name_match_case_insensitive() { + assert!(wildcard_match("PLAN-East", "plan-*")); + assert!(wildcard_match("A1", "a?")); + assert!(!wildcard_match("A12", "a?")); + } + + #[test] + fn entry_key_stable_across_rename() { + let a = ReferenceEntry::new(101, "PLAN", RefKind::DwgXref); + let b = a.renamed("PLAN-EAST"); + assert_eq!(a.key, b.key); + assert_eq!(b.name, "PLAN-EAST"); + } + #[test] + fn bind_chain_naming_transitive() { + assert_eq!(bind_symbol("PLAN", "DETAIL", "WALLS"), "PLAN$0$DETAIL$0$WALLS"); + assert_eq!(bind_symbol_taken("PLAN", "WALLS", &["PLAN$0$WALLS"]), "PLAN$1$WALLS"); + } +} diff --git a/src/lib.rs b/src/lib.rs index a02cd5bcb..6d0303ee3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,7 +8,10 @@ pub mod command; pub mod config; pub mod discussions; pub mod entities; +#[cfg(not(target_arch = "wasm32"))] +pub mod gpu_backend; pub mod i18n; +pub(crate) mod input; pub mod io; #[cfg(not(target_arch = "wasm32"))] pub mod mcp; diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index fa1dd3a69..db5c7f379 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -1456,6 +1456,19 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Layout Print Configuration" => Some(("properties", "layout-print-configuration")), "Layout name cannot be empty." => Some(("common", "layout-name-cannot-be-empty")), "Layout plots the current sheet using the selected paper size." => Some(("plot", "layout-plots-the-current-sheet-using-the-selec")), + "Sheet sizes and printable areas reported by the printer." => Some(("plot", "sheet-sizes-and-printable-areas-reported-by-th")), + "Custom paper size" => Some(("plot", "custom-paper-size")), + "Printable margins" => Some(("plot", "printable-margins")), + "Enter a positive width and height." => Some(("plot", "enter-a-positive-width-and-height")), + "Paper size" => Some(("plot", "paper-size")), + "{count} driver option(s) set for this printer." => Some(("plot", "count-driver-option-s-set-for-this-printer")), + "Printer properties — {printer}" => Some(("plot", "printer-properties-printer")), + "This printer reports no driver options." => Some(("plot", "this-printer-reports-no-driver-options")), + "Could not read the printer's options: {error}" => Some(("plot", "could-not-read-the-printer-s-options-error")), + "Reading the printer's options…" => Some(("plot", "reading-the-printer-s-options")), + "Printing preferences saved for {printer}." => Some(("plot", "printing-preferences-saved-for-printer")), + "Printing preferences unchanged." => Some(("plot", "printing-preferences-unchanged")), + "Printer properties" => Some(("plot", "printer-properties")), "Layout renamed: '{old_name}' → '{new_name}'" => Some(("common", "layout-renamed-old-name-new-name")), "Layouts" => Some(("layout", "layouts")), "Leader" => Some(("common", "leader")), @@ -2046,6 +2059,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Plot table type" => Some(("properties", "plot-table-type")), "Plot transparency" => Some(("plot", "plot-transparency")), "Plot upside-down" => Some(("plot", "plot-upside-down")), + "Named plot style tables (.stb) are not supported yet: {name}" => Some(("plot", "named-plot-style-tables-stb-are-not-supported-yet-name")), + "{name} was not found in the plot styles folder ({folder})." => Some(("plot", "name-was-not-found-in-the-plot-styles-folder-folder")), "Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}" => Some(("common", "plot-window-x0-2-y0-2-to-x1-2-y1-2")), "Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}" => Some(("common", "plot-window-x1-2-y1-2-to-x2-2-y2-2")), "Plugin '{id}' failed to load: {e}" => Some(("common", "plugin-id-failed-to-load-e")), @@ -3131,6 +3146,34 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "World" => Some(("common", "world")), "Write Block" => Some(("insert", "write-block")), "X (mm)" => Some(("plot", "x-mm")), + "Select Drawing to Import Page Setups From" => Some(("plot", "select-drawing-to-import-page-setups-from")), + "Drawings and templates" => Some(("plot", "drawings-and-templates")), + "PSETUPIN: {count} page setup(s) imported, {replaced} replaced." => Some(("plot", "psetupin-count-page-setup-s-imported-replaced-replaced")), + "PSETUPIN: {error}" => Some(("plot", "psetupin-error")), + "PSETUPIN: no page setup named \"{want}\" in {file}." => Some(("plot", "psetupin-no-page-setup-named-want-in-file")), + "PSETUPIN: nothing to import from {file}." => Some(("plot", "psetupin-nothing-to-import-from-file")), + "No named page setups in this drawing." => Some(("plot", "no-named-page-setups-in-this-drawing")), + "Import…" => Some(("plot", "import-ellipsis")), + "Show the page setup for new layouts" => Some(("plot", "show-the-page-setup-for-new-layouts")), + "X (in)" => Some(("plot", "x-in")), + "Y (in)" => Some(("plot", "y-in")), + "Hide paperspace objects" => Some(("plot", "hide-paperspace-objects")), + "Save changes to layout" => Some(("plot", "save-changes-to-layout")), + "{printer} does not support {sheet}; switched to its default sheet {default} (PAPERUPDATE = 1)." => Some(("plot", "printer-does-not-support-sheet-switched-to-its-default-sheet-default-paperupdate-1")), + "{printer} does not support {sheet}; the sheet is kept (PAPERUPDATE = 0)." => Some(("plot", "printer-does-not-support-sheet-the-sheet-is-kept-paperupdate-0")), + "{printer}: the printer reports no sheets; the paper catalogue is used." => Some(("plot", "printer-the-printer-reports-no-sheets-the-paper-catalogue-is-used")), + "{printer}: {count} sheet(s) reported" => Some(("plot", "printer-count-sheet-s-reported")), + "Default sheet: {sheet}" => Some(("plot", "default-sheet-sheet")), + "Default sheet: not reported" => Some(("plot", "default-sheet-not-reported")), + "Default printer: {name}" => Some(("plot", "default-printer-name")), + "Default printer: none reported by the system" => Some(("plot", "default-printer-none-reported-by-the-system")), + "Printers: none listed by the system" => Some(("plot", "printers-none-listed-by-the-system")), + "Printers ({count}):" => Some(("plot", "printers-count")), + "Could not list printers: {error}" => Some(("plot", "could-not-list-printers-error")), + "Plots go through the PDF application: {command}" => Some(("plot", "plots-go-through-the-pdf-application-command")), + "Plots go straight to the printer (no PDF application registers a print verb)." => Some(("plot", "plots-go-straight-to-the-printer-no-pdf-application-registers-a-print-verb")), + "via the PDF application" => Some(("plot", "via-the-pdf-application")), + "direct print" => Some(("plot", "direct-print")), "X = %{x}, Y = %{y}, Z = %{z}" => Some(("inquiry", "x-x-y-y-z-z")), "X Scale" => Some(("properties", "x-scale")), "X-Ray" => Some(("draw", "x-ray")), @@ -3151,6 +3194,95 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "XREF Reloaded \"{}\"" => Some(("insert", "xref-reloaded")), "XREF Save the drawing first to resolve relative XREF paths." => Some(("insert", "xref-save-the-drawing-first-to-resolve-relativ")), "XREF Unloaded (skipped): \"{}\"" => Some(("common", "xref-unloaded-skipped")), + "XREF: \"{}\" set to Overlay." => Some(("insert", "xref-set-to-overlay")), + "XREF: bound \"{}\"." => Some(("insert", "xref-bound")), + "XREF: bound \"{}\" with {} unremapped style handles (see bind limitations)." => Some(("insert", "xref-bound-with-unremapped-handles")), + "XREF: cannot bind nested reference '{}'. Bind it in its host drawing." => Some(("insert", "xref-cannot-bind-nested-reference")), + "Unreferenced" => Some(("insert", "unreferenced")), + "File References" => Some(("insert", "xref-file-references")), + "Attach DWG" => Some(("insert", "xref-attach-dwg")), + "Attach Image" => Some(("insert", "xref-attach-image")), + "Attach..." => Some(("insert", "xref-attach-menu")), + "Change Path Type" => Some(("insert", "xref-change-path-type")), + "Find and Replace..." => Some(("insert", "xref-find-and-replace-menu")), + "Display Width" => Some(("insert", "xref-display-width")), + "Display Height" => Some(("insert", "xref-display-height")), + "Change Path" => Some(("insert", "xref-change-path")), + "Make Absolute" => Some(("insert", "xref-make-absolute")), + "Make Relative" => Some(("insert", "xref-make-relative")), + "Remove Path" => Some(("insert", "xref-remove-path")), + "Select New Path" => Some(("insert", "xref-select-new-path")), + "Select a single reference first." => Some(("insert", "xref-select-a-single-reference-first")), + "Reload All References" => Some(("insert", "xref-reload-all-references")), + "Opened" => Some(("insert", "xref-opened")), + "Untitled" => Some(("insert", "xref-untitled")), + "Unresolved" => Some(("insert", "xref-unresolved")), + "Preview not available" => Some(("insert", "xref-preview-not-available")), + "Reload All" => Some(("insert", "xref-reload-all")), + "DWG" => Some(("insert", "xref-dwg")), + "Pixel Width" => Some(("insert", "xref-pixel-width")), + "Pixel Height" => Some(("insert", "xref-pixel-height")), + "Resolution Unit" => Some(("insert", "xref-resolution-unit")), + "XREF: cannot unload nested reference '{}'. Unload it in its host drawing." => Some(("insert", "xref-cannot-unload-nested-reference")), + "XREF: cannot overlay nested reference '{}'. Overlay it in its host drawing." => Some(("insert", "xref-cannot-overlay-nested-reference")), + "XREF: cannot reload nested reference '{}'. Reload it in its host drawing." => Some(("insert", "xref-cannot-reload-nested-reference")), + "XREF: cannot attach nested reference '{}'. Attach it in its host drawing." => Some(("insert", "xref-cannot-attach-nested-reference")), + "XREF: cannot set path type for nested reference '{}'. Set it in its host drawing." => Some(("insert", "xref-cannot-set-path-type-nested-reference")), + "{}: reload applies to drawing references only." => Some(("insert", "reload-applies-to-drawing-references-only")), + "XREF: \"{}\" set to Attach." => Some(("insert", "xref-set-to-attach")), + "External References" => Some(("insert", "xref-manager-title")), + "XATTACH: cannot attach the host drawing into itself." => Some(("insert", "xref-self-attach-blocked")), + "XREF: cannot bind \"{}\": {}." => Some(("insert", "xref-cannot-bind")), + "XREF: cannot detach nested reference '{}'. Detach it in its host drawing." => Some(("insert", "xref-cannot-detach-nested-reference")), + "XREF: PDF bind (vector import) is not available in this version." => Some(("insert", "xref-pdf-bind-unavailable")), + "XREF: cannot make path relative across drives." => Some(("insert", "xref-cannot-make-path-relative")), + "XREF: detached \"{}\"." => Some(("insert", "xref-detached")), + "XREF: no loaded reference with that key." => Some(("insert", "xref-no-loaded-reference-with-that-key")), + "XREF: no references match '{}'." => Some(("insert", "xref-no-references-match")), + "XREF: overlays apply to drawing references only." => Some(("insert", "xref-overlays-drawing-references-only")), + "XREF: Path set for \"{}\" — Reload to apply." => Some(("insert", "xref-path-set-for")), + "XREF: unloaded \"{}\"." => Some(("insert", "xref-unloaded")), + "XREF: unknown option '{}'. Options: ? Reload Unload Detach Path Pathtype Bind Overlay Attach" => Some(("insert", "xref-unknown-option")), + "XREF: updated {} reference(s)." => Some(("insert", "xref-updated-references")), + "Usage: XREF Path " => Some(("insert", "usage-xref-path-pattern")), + "Usage: XREF Path Find " => Some(("insert", "usage-xref-path-find")), + "Usage: XREF Pathtype [pattern]" => Some(("insert", "usage-xref-pathtype")), + "{} reference(s) not found — open the reference manager with EXTERNALREFERENCES." => Some(("insert", "xref-missing-open-xrefman")), + " (nested — not rendered)" => Some(("insert", "xref-nested-not-rendered")), + "Change Path:" => Some(("insert", "change-path")), + "Date" => Some(("insert", "date")), + "Details" => Some(("insert", "details")), + "Detach" => Some(("insert", "xref-detach")), + "Failed" => Some(("insert", "failed")), + "File attach is not available on web — the reference list below is read-only." => Some(("insert", "xref-file-attach-is-not-available-on-web")), + "File name" => Some(("insert", "xref-file-name")), + "Found At" => Some(("insert", "found-at")), + "Help" => Some(("insert", "xref-help")), + "Nested references are read-only — edit them in their host drawing." => Some(("insert", "xref-nested-references-are-read-only")), + "Not found" => Some(("insert", "not-found")), + "Orphaned" => Some(("insert", "orphaned")), + "Overlay" => Some(("insert", "xref-overlay")), + "Path:" => Some(("insert", "path")), + "Reference Manager — select rows, then Detach, Unload, Reload, Bind, Overlay, or a Change Path mode." => Some(("insert", "xref-reference-manager-select-rows-bind")), + "Bind" => Some(("insert", "xref-bind")), + "Refresh" => Some(("insert", "xref-refresh")), + "Relative" => Some(("insert", "relative")), + "Reload" => Some(("insert", "xref-reload")), + "Replace:" => Some(("insert", "xref-replace")), + "Saved Path" => Some(("insert", "saved-path")), + "Select a reference first." => Some(("insert", "xref-select-a-reference-first")), + "Select a reference to inspect its details" => Some(("insert", "xref-select-a-reference-to-inspect-its")), + "Set Path" => Some(("insert", "xref-set-path")), + "Stale" => Some(("insert", "stale")), + "Toggle list/tree" => Some(("insert", "toggle-list-tree")), + "Tree" => Some(("insert", "tree")), + "Type a new path first." => Some(("insert", "xref-type-a-new-path-first")), + "Type the path prefix to find first." => Some(("insert", "xref-type-the-path-prefix-to-find-first")), + "Unload" => Some(("insert", "xref-unload")), + "Unloaded" => Some(("insert", "unloaded")), + "new path for selection" => Some(("insert", "xref-new-path-for-selection")), + "new path prefix" => Some(("insert", "xref-new-path-prefix")), + "old path prefix" => Some(("insert", "xref-old-path-prefix")), "Y (mm)" => Some(("plot", "y-mm")), "Yards" => Some(("common", "yards")), "Yellow" => Some(("common", "yellow")), @@ -3540,6 +3672,33 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Backup Files" => Some(("common", "backup-files")), "Save Block As" => Some(("common", "save-block-as")), "Save Data Extraction" => Some(("common", "save-data-extraction")), + "Enter a valid cell range such as A1:D10." => Some(("common", "enter-a-valid-cell-range-such-as-a1-d10")), + "Named ranges require a spreadsheet file." => Some(("common", "named-ranges-require-a-spreadsheet-file")), + "Select a named range." => Some(("common", "select-a-named-range")), + "The named range was not found." => Some(("common", "the-named-range-was-not-found")), + "The named range does not refer to a worksheet cell range." => Some(("common", "the-named-range-does-not-refer-to-a-worksheet-cell-range")), + "The spreadsheet contains no worksheets." => Some(("common", "the-spreadsheet-contains-no-worksheets")), + "The selected data source is empty." => Some(("common", "the-selected-data-source-is-empty")), + "The data link no longer exists." => Some(("common", "the-data-link-no-longer-exists")), + "Writing to this data link is disabled." => Some(("common", "writing-to-this-data-link-is-disabled")), + "Writing linked data is supported for CSV and text sources." => Some(("common", "writing-linked-data-is-supported-for-csv-and-text-sources")), + "Data source not found: %{path}" => Some(("common", "data-source-not-found")), + "Select at least one property." => Some(("common", "select-at-least-one-property")), + "Select a data link." => Some(("common", "select-a-data-link")), + "The selected data link no longer exists." => Some(("common", "the-selected-data-link-no-longer-exists")), + "Columns, rows, width, and height must be positive values." => Some(("common", "columns-rows-width-and-height-must-be-positive-values")), + "Choose a Spreadsheet File" => Some(("common", "choose-a-spreadsheet-file")), + "Spreadsheet Files" => Some(("common", "spreadsheet-files")), + "Enter a data link name." => Some(("common", "enter-a-data-link-name")), + "A data link with that name already exists." => Some(("common", "a-data-link-with-that-name-already-exists")), + "Data link deleted; existing table values were retained." => Some(("common", "data-link-deleted-existing-table-values-were-retained")), + "No objects were selected when the wizard opened." => Some(("common", "no-objects-were-selected-when-the-wizard-opened")), + "Add a drawing or include the current drawing." => Some(("common", "add-a-drawing-or-include-the-current-drawing")), + "No extractable objects were found in the selected source." => Some(("common", "no-extractable-objects-were-found-in-the-selected-source")), + "Select at least one object type." => Some(("common", "select-at-least-one-object-type")), + "Choose at least one output." => Some(("common", "choose-at-least-one-output")), + "Data Extraction Settings" => Some(("common", "data-extraction-settings")), + "Add Drawings" => Some(("common", "add-drawings")), "CSV" => Some(("common", "csv")), "Export STL" => Some(("common", "export-stl")), "STL Files" => Some(("common", "stl-files")), @@ -4020,7 +4179,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "TORUS Specify second end point of tube diameter:" => Some(("model", "torus-specify-second-end-point-of-tube-diameter")), "2Point" => Some(("model", "value-2point")), "Cube" => Some(("model", "cube")), - "UCS option [World/View/3Point/Z/X/Y/Origin/Save/Delete] or name:" => Some(("view", "ucs-option-world-view-3point-z-x-y-origin-save-delete-or-name")), + "UCS option [Face/OBject/World/View/3Point/Z/X/Y/Origin/Save/Delete] or name:" => Some(("view", "ucs-option-world-view-3point-z-x-y-origin-save-delete-or-name")), "UCS specify new origin:" => Some(("view", "ucs-specify-new-origin")), "UCS specify point on positive X axis:" => Some(("view", "ucs-specify-point-on-positive-x-axis")), "UCS specify point in positive XY plane:" => Some(("view", "ucs-specify-point-in-positive-xy-plane")), @@ -4602,6 +4761,423 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Enter a GitHub URL or repository in owner/repo format." => Some(("common", "enter-a-github-url-or-repository-in-owner-repo-format")), "DWGUNITS Convert drawing to:" => Some(("common", "dwgunits-convert-drawing-to")), "Opened plot preview." => Some(("common", "opened-plot-preview")), + "%{cmd} Select objects or [Settings] (%{count} selected, Enter to apply):" => Some(("common", "select-objects-or-settings-selected-enter-to-apply")), + "%{cmd} Select objects or [Settings]:" => Some(("common", "select-objects-or-settings")), + "+ Add parameter" => Some(("common", "add-parameter")), + "3D Object Snap" => Some(("common", "object-snap-3d")), + "3D Object Snap On (F4)" => Some(("common", "object-snap-on-f4-3d")), + "3D Object Snap modes" => Some(("common", "object-snap-modes-3d")), + "A numeric shell offset distance is required." => Some(("common", "a-numeric-shell-offset-distance-is-required")), + "A selected edge no longer belongs to the body." => Some(("common", "a-selected-edge-no-longer-belongs-to-the-body")), + "A selected face could not be resolved." => Some(("common", "a-selected-face-could-not-be-resolved")), + "A selected face does not belong to the solid." => Some(("common", "a-selected-face-does-not-belong-to-the-solid")), + "ALIGN Scale objects based on alignment points? [Yes / No] :" => Some(("common", "align-scale-objects-based-on-alignment-points-yes-no-no")), + "ANNORESET: no selected object has alternate scale representations." => Some(("common", "annoreset-no-selected-object-has-alternate-scale-representations")), + "ANNORESET: synchronized {} object(s) from the current scale." => Some(("common", "annoreset-synchronized-object-s-from-the-current-scale")), + "Adaptive grid" => Some(("common", "adaptive-grid")), + "Add Drawing Folder" => Some(("common", "add-drawing-folder")), + "Add drawings…" => Some(("common", "add-drawings-2")), + "Add folder…" => Some(("common", "add-folder")), + "Add parameter" => Some(("common", "add-parameter-2")), + "All other rows" => Some(("common", "all-other-rows")), + "Allow selection cycling" => Some(("common", "allow-selection-cycling")), + "Allow writing to source file" => Some(("common", "allow-writing-to-source-file")), + "BREAK Specify second break point or [First point]:" => Some(("common", "break-specify-second-break-point-or-first-point")), + "Begin" => Some(("common", "begin")), + "CHAMFEREDGE: select a base face edge." => Some(("common", "chamferedge-select-a-base-face-edge")), + "Cannot delete beyond this." => Some(("common", "cannot-delete-beyond-this")), + "Cell range" => Some(("common", "cell-range")), + "Center of face" => Some(("common", "center-of-face")), + "Choose output" => Some(("common", "choose-output")), + "Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history." => Some(("common", "clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history")), + "Column & row settings" => Some(("common", "column-row-settings")), + "Combine identical rows" => Some(("common", "combine-identical-rows")), + "Constraint Settings" => Some(("common", "constraint-settings")), + "Constraint Type" => Some(("common", "constraint-type")), + "Copied" => Some(("common", "copied")), + "Create a New Data Link" => Some(("common", "create-a-new-data-link")), + "Create a new data extraction" => Some(("common", "create-a-new-data-extraction")), + "Current drawing" => Some(("common", "current-drawing")), + "Cursor-dependent position" => Some(("common", "cursor-dependent-position")), + "DATAEXTRACTION Specify insertion point:" => Some(("common", "dataextraction-specify-insertion-point")), + "DATAEXTRACTION: external file created." => Some(("common", "dataextraction-external-file-created")), + "DIMBREAK Select dimension to add/remove break or [Multiple]:" => Some(("common", "dimbreak-select-dimension-to-add-remove-break-or-multiple")), + "DIMBREAK Select dimensions ({} selected, press Enter when done):" => Some(("common", "dimbreak-select-dimensions-selected-press-enter-when-done")), + "DIMBREAK Select object to break dimension or [Auto/Manual/Remove] :" => Some(("common", "dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto")), + "DIMBREAK Specify first break point:" => Some(("common", "dimbreak-specify-first-break-point")), + "DIMBREAK Specify second break point:" => Some(("common", "dimbreak-specify-second-break-point")), + "DIMBREAK: no editable dimensions were selected." => Some(("common", "dimbreak-no-editable-dimensions-were-selected")), + "DIMBREAK: no intersections were found." => Some(("common", "dimbreak-no-intersections-were-found")), + "DIMBREAK: removed breaks from {} dimension(s)." => Some(("common", "dimbreak-removed-breaks-from-dimension-s")), + "DIMBREAK: select a dimension." => Some(("common", "dimbreak-select-a-dimension")), + "DIMBREAK: updated {} dimension(s)." => Some(("common", "dimbreak-updated-dimension-s")), + "DIMEDIT Enter new dimension text :" => Some(("common", "dimedit-enter-new-dimension-text-measured-value")), + "DIMEDIT Enter obliquing angle (press Enter for none):" => Some(("common", "dimedit-enter-obliquing-angle-press-enter-for-none")), + "DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] :" => Some(("common", "dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home")), + "DIMEDIT Select objects:" => Some(("common", "dimedit-select-objects")), + "DIMEDIT Specify angle for dimension text:" => Some(("common", "dimedit-specify-angle-for-dimension-text")), + "DIMEDIT: no editable dimensions were selected." => Some(("common", "dimedit-no-editable-dimensions-were-selected")), + "DIMEDIT: updated {} dimension(s)." => Some(("common", "dimedit-updated-dimension-s")), + "DIMJOGLINE Select dimension to add jog or [Remove]:" => Some(("common", "dimjogline-select-dimension-to-add-jog-or-remove")), + "DIMJOGLINE Select dimension to remove jog:" => Some(("common", "dimjogline-select-dimension-to-remove-jog")), + "DIMJOGLINE Specify jog location (or press Enter):" => Some(("common", "dimjogline-specify-jog-location-or-press-enter")), + "DIMJOGLINE: jog added." => Some(("common", "dimjogline-jog-added")), + "DIMJOGLINE: jog removed." => Some(("common", "dimjogline-jog-removed")), + "DIMJOGLINE: select a linear or aligned dimension." => Some(("common", "dimjogline-select-a-linear-or-aligned-dimension")), + "DIMSPACE Specify distance between dimensions or [Auto] :" => Some(("common", "dimspace-specify-distance-between-dimensions-or-auto-auto")), + "DIMSPACE: enter a non-negative spacing value or Auto." => Some(("common", "dimspace-enter-a-non-negative-spacing-value-or-auto")), + "DIMSPACE: select a linear or aligned dimension." => Some(("common", "dimspace-select-a-linear-or-aligned-dimension")), + "DIMSPACE: select one or more linear or aligned dimensions." => Some(("common", "dimspace-select-one-or-more-linear-or-aligned-dimensions")), + "DIMSPACE: spacing value cannot be negative." => Some(("common", "dimspace-spacing-value-cannot-be-negative")), + "DIMTEDIT Specify angle for dimension text:" => Some(("common", "dimtedit-specify-angle-for-dimension-text")), + "DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]:" => Some(("common", "dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle")), + "DIMTEDIT: select a dimension." => Some(("common", "dimtedit-select-a-dimension")), + "DIVIDE: {count} marker(s) placed." => Some(("common", "divide-marker-s-placed")), + "DOF: {dof}" => Some(("common", "dof")), + "DRAWORDER: selection reordered relative to reference objects." => Some(("common", "draworder-selection-reordered-relative-to-reference-objects")), + "Data Extraction" => Some(("common", "data-extraction")), + "Data Extraction Wizard" => Some(("common", "data-extraction-wizard")), + "Data Link Manager" => Some(("common", "data-link-manager")), + "Data extraction" => Some(("common", "data-extraction-2")), + "Data link" => Some(("common", "data-link")), + "Data link name" => Some(("common", "data-link-name")), + "Data rows" => Some(("common", "data-rows")), + "Define data source" => Some(("common", "define-data-source")), + "Display Quick Properties palette on selection" => Some(("common", "display-quick-properties-palette-on-selection")), + "Display Selection Cycling List Box" => Some(("common", "display-selection-cycling-list-box")), + "Display coordinate input near crosshairs" => Some(("common", "display-coordinate-input-near-crosshairs")), + "Display grid beyond Limits" => Some(("common", "display-grid-beyond-limits")), + "Display selection candidate list box on click" => Some(("common", "display-selection-candidate-list-box-on-click")), + "Distance and angle tolerances must be non-negative numbers." => Some(("common", "distance-and-angle-tolerances-must-be-non-negative-numbers")), + "Distance1 = {:.4}, Distance2 = {:.4}" => Some(("common", "distance1-distance2")), + "Draw section" => Some(("common", "draw-section")), + "Drawing table" => Some(("common", "drawing-table")), + "Drawings / folders" => Some(("common", "drawings-folders")), + "Dynamic Prompts" => Some(("common", "dynamic-prompts")), + "Edit an existing data extraction" => Some(("common", "edit-an-existing-data-extraction")), + "Editing weighted or tolerance-fitted interpolation data is not supported." => Some(("common", "editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported")), + "Empty table" => Some(("common", "empty-table")), + "Enable Pointer Input (F12)" => Some(("common", "enable-pointer-input-f12")), + "Enable dimension input fields" => Some(("common", "enable-dimension-input-fields")), + "Enter a body editing option [Shell/eXit]:" => Some(("common", "enter-a-body-editing-option-shell-exit")), + "Enter a solid editing option [Body/eXit]:" => Some(("common", "enter-a-solid-editing-option-body-exit")), + "Entire sheet" => Some(("common", "entire-sheet")), + "Equal X and Y spacing" => Some(("common", "equal-x-and-y-spacing")), + "External file" => Some(("common", "external-file")), + "Extraction settings" => Some(("common", "extraction-settings")), + "FILLET Select 2D polyline [R={:.4}]:" => Some(("common", "fillet-select-2d-polyline-r")), + "FILLET Select first object or [Polyline/Radius] [R={:.4}]:" => Some(("common", "fillet-select-first-object-or-polyline-radius-r")), + "FILLET Specify fillet radius or first point <{:.4}>:" => Some(("common", "fillet-specify-fillet-radius-or-first-point")), + "FILLET Specify second point for radius:" => Some(("common", "fillet-specify-second-point-for-radius")), + "File" => Some(("common", "file")), + "File not found — check the saved path." => Some(("common", "file-not-found-check-the-saved-path")), + "First row" => Some(("common", "first-row")), + "Fit data cannot be purged without a stored control curve." => Some(("common", "fit-data-cannot-be-purged-without-a-stored-control-curve")), + "Fit data does not define a valid control curve." => Some(("common", "fit-data-does-not-define-a-valid-control-curve")), + "Fit points do not define a valid spline." => Some(("common", "fit-points-do-not-define-a-valid-spline")), + "Formula" => Some(("common", "formula")), + "Full path" => Some(("common", "full-path")), + "Fully constrained — no remaining degrees of freedom" => Some(("common", "fully-constrained-no-remaining-degrees-of-freedom")), + "Grid On (F7)" => Some(("common", "grid-on-f7")), + "Grid X spacing:" => Some(("common", "grid-x-spacing")), + "Grid Y spacing:" => Some(("common", "grid-y-spacing")), + "Grid behavior" => Some(("common", "grid-behavior")), + "Grid spacing" => Some(("common", "grid-spacing")), + "HATCHEDIT Pattern:%{name} Scale:%{scale} Angle:%{angle} [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] :" => Some(("common", "hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties")), + "INTERSECT: created %{count} result object(s)." => Some(("common", "intersect-created-result-object-s")), + "INTERSECT: no positive-area common result; %{count} original object(s) retained." => Some(("common", "intersect-no-positive-area-common-result-original-object-s-retained")), + "INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type." => Some(("common", "intersect-select-at-least-two-solids-regions-or-coplanar-surfaces-of-a-compatible-type")), + "INTERSECT: the retained object could not be updated." => Some(("common", "intersect-the-retained-object-could-not-be-updated")), + "INTERSECT: the selected coincident geometry is ambiguous." => Some(("common", "intersect-the-selected-coincident-geometry-is-ambiguous")), + "INTERSECT: the selected objects do not have a supported coplanar surface intersection." => Some(("common", "intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection")), + "INTERSECT: the selected topology could not be intersected safely." => Some(("common", "intersect-the-selected-topology-could-not-be-intersected-safely")), + "Include current drawing" => Some(("common", "include-current-drawing")), + "Include subfolders" => Some(("common", "include-subfolders")), + "Increment angle:" => Some(("common", "increment-angle")), + "Indicator Fill Color" => Some(("common", "indicator-fill-color")), + "Indicator Transparency" => Some(("common", "indicator-transparency")), + "Insert Table" => Some(("common", "insert-table")), + "Insert data extraction table into drawing" => Some(("common", "insert-data-extraction-table-into-drawing")), + "Insert options" => Some(("common", "insert-options")), + "Insert table after saving link" => Some(("common", "insert-table-after-saving-link")), + "Insertion behavior" => Some(("common", "insertion-behavior")), + "Keep table updated to source formatting" => Some(("common", "keep-table-updated-to-source-formatting")), + "Knot" => Some(("common", "knot")), + "LEADER Enter an annotation option [None/Mtext]:" => Some(("common", "leader-enter-an-annotation-option-none-mtext")), + "LEADER Enter first line of annotation text or :" => Some(("common", "leader-enter-first-line-of-annotation-text-or-options")), + "LEADER Enter leader formatting option [Spline/Straight/Arrow/None] :" => Some(("common", "leader-enter-leader-formatting-option-spline-straight-arrow-none-exit")), + "LEADER Enter next line of annotation text:" => Some(("common", "leader-enter-next-line-of-annotation-text")), + "LEADER Specify leader start point:" => Some(("common", "leader-specify-leader-start-point")), + "LEADER Specify next point or [Annotation/Format/Undo] :" => Some(("common", "leader-specify-next-point-or-annotation-format-undo-annotation")), + "LEADER Specify next point:" => Some(("common", "leader-specify-next-point")), + "LENGTHEN Select an object to change or [Undo]:" => Some(("common", "lengthen-select-an-object-to-change-or-undo")), + "LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]:" => Some(("common", "lengthen-select-an-object-to-measure-or-delta-percent-total-dynamic")), + "LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] :" => Some(("common", "loft-enter-an-option-guides-path-cross-sections-only-settings-continuity-bulge-magnitude-cross-sections-only")), + "LOFT Enter an option [Guides/Path/Cross sections only/Settings] :" => Some(("common", "loft-enter-an-option-guides-path-cross-sections-only-settings-cross-sections-only")), + "LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish)" => Some(("common", "loft-select-cross-sections-in-order-or-point-join-mode-enter-to-finish")), + "Link option" => Some(("common", "link-option")), + "Link settings" => Some(("common", "link-settings")), + "Live Section Enabled" => Some(("common", "live-section-enabled")), + "Ltype gen" => Some(("common", "ltype-gen")), + "MEASURE: 0 markers placed." => Some(("common", "measure-0-markers-placed")), + "MEASURE: {count} marker(s) placed." => Some(("common", "measure-marker-s-placed")), + "Maintain associativity" => Some(("common", "maintain-associativity")), + "Major line every:" => Some(("common", "major-line-every")), + "Midpoint on edge" => Some(("common", "midpoint-on-edge")), + "Moment of inertia" => Some(("common", "moment-of-inertia")), + "More options" => Some(("common", "more-options")), + "Move Down" => Some(("common", "move-down")), + "Move Up" => Some(("common", "move-up")), + "Named Parameters" => Some(("common", "named-parameters")), + "Named range" => Some(("common", "named-range")), + "Nearest to face" => Some(("common", "nearest-to-face")), + "No path" => Some(("common", "no-path")), + "No preview available." => Some(("common", "no-preview-available")), + "No surfaces selected." => Some(("common", "no-surfaces-selected")), + "OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales." => Some(("common", "objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales")), + "OBJECTSCALE: the selected objects do not have the current scale." => Some(("common", "objectscale-the-selected-objects-do-not-have-the-current-scale")), + "OBJECTSCALE: {} the current scale on {} object(s)." => Some(("common", "objectscale-the-current-scale-on-object-s")), + "Object Snap On (F3)" => Some(("common", "object-snap-on-f3")), + "Object Snap Tracking On (F11)" => Some(("common", "object-snap-tracking-on-f11")), + "Object Snap modes" => Some(("common", "object-snap-modes-2")), + "On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry." => Some(("common", "on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry")), + "One or more parametric constraints conflict or are redundant\nClick to remove one and re-solve" => Some(("common", "one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve")), + "Ortho mode (F8)" => Some(("common", "ortho-mode-f8")), + "Output data to external file" => Some(("common", "output-data-to-external-file")), + "Output file" => Some(("common", "output-file")), + "PEDIT Convert lines and arcs to polylines [Yes/No] :" => Some(("common", "pedit-convert-lines-and-arcs-to-polylines-yes-no-yes")), + "PEDIT Enter polyline linetype generation option [ON/OFF]:" => Some(("common", "pedit-enter-polyline-linetype-generation-option-on-off")), + "PEDIT Select polyline (or a line/arc to convert) or [Multiple]:" => Some(("common", "pedit-select-polyline-or-a-line-arc-to-convert-or-multiple")), + "Palette Location" => Some(("common", "palette-location")), + "Parameter '{}' already exists." => Some(("common", "parameter-already-exists")), + "Parameter '{}': {}" => Some(("common", "parameter")), + "Parameters" => Some(("common", "parameters")), + "Perpendicular objects must share an intersection point" => Some(("common", "perpendicular-objects-must-share-an-intersection-point")), + "Perpendicular to face" => Some(("common", "perpendicular-to-face")), + "Plane" => Some(("common", "plane")), + "Pointer Input" => Some(("common", "pointer-input")), + "Polar Angle Settings" => Some(("common", "polar-angle-settings")), + "Polar Tracking On (F10)" => Some(("common", "polar-tracking-on-f10")), + "Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes." => Some(("common", "polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes")), + "Press Enter to accept the chamfer or [Distance]:" => Some(("common", "press-enter-to-accept-the-chamfer-or-distance")), + "Principal directions" => Some(("common", "principal-directions")), + "Principal moments" => Some(("common", "principal-moments")), + "Priority" => Some(("common", "priority")), + "Product of inertia" => Some(("common", "product-of-inertia")), + "REVERSE Select line, polyline, spline or helix to reverse:" => Some(("common", "reverse-select-line-polyline-spline-or-helix-to-reverse")), + "Radii of gyration" => Some(("common", "radii-of-gyration")), + "Range" => Some(("common", "range")), + "Recent Colors" => Some(("common", "recent-colors")), + "Reference changes are not available on web — the reference list is read-only." => Some(("common", "reference-changes-are-not-available-on-web-the-reference-list-is-read-only")), + "Refine data" => Some(("common", "refine-data")), + "Relative path" => Some(("common", "relative-path")), + "Remaining degrees of freedom in the current parametric constraint scope" => Some(("common", "remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope")), + "Requires an integer between 0 and 99." => Some(("common", "requires-an-integer-between-0-and-99")), + "Requires numeric distance or two points." => Some(("common", "requires-numeric-distance-or-two-points")), + "SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] :" => Some(("common", "sectionplane-align-section-to-front-back-top-bottom-left-right-top")), + "SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] :" => Some(("common", "sectionplane-enter-section-plane-type-plane-slice-boundary-volume-plane")), + "SECTIONPLANE Specify next point or ENTER to complete:" => Some(("common", "sectionplane-specify-next-point-or-enter-to-complete")), + "SECTIONPLANE Specify next point:" => Some(("common", "sectionplane-specify-next-point")), + "SECTIONPLANE Specify point in direction of section view:" => Some(("common", "sectionplane-specify-point-in-direction-of-section-view")), + "SECTIONPLANE Specify start point:" => Some(("common", "sectionplane-specify-start-point")), + "SECTIONPLANE Specify through point:" => Some(("common", "sectionplane-specify-through-point")), + "SETBYLAYER: no properties required changes." => Some(("common", "setbylayer-no-properties-required-changes")), + "SETBYLAYER: reset {} entity/entities to ByLayer." => Some(("common", "setbylayer-reset-entity-entities-to-bylayer")), + "SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)" => Some(("common", "setvar-cetransparency-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-frame-imageframe-pdfframe-wipeoutframe-xclipframe-pointcloudclipframe-zoomwheel-zoomfactor-cursorsize-pickbox-cursortype-snapang-textfill-clipromptlines-commandlinefadetime-attreq-attdia-dimassoc-dimcontinuemode-constraintsolvemode-constraintinfer-constraintbardisplay-angbase-angdir-sketchinc-skpoly-sktolerance-donutid-donutod-centerexe-centerlayer-centerltype-centerltscale-centerltypefile-centercrosssize-centercrossgap-centermarkexe-colortheme-selectionarea-selectionareaopacity-selectioneffect-selectioneffectcolor-windowsareacolor-crossingareacolor-selectionpreview-gripsize-gripcolor-griphot-griphover-gripobjlimit-clayer-celtype-textstyle-read-only")), + "SHELL supports rectangular boxes, circular cylinders, and spheres." => Some(("common", "shell-supports-rectangular-boxes-circular-cylinders-and-spheres")), + "SHELL: solid updated." => Some(("common", "shell-solid-updated")), + "SPLINEDIT Fit data [Add/Delete/Move/Purge/Tangents/eXit] :" => Some(("common", "splinedit-fit-data-add-delete-move-purge-tangents-exit-exit")), + "SPLINEDIT Fit data [Add/Delete/Move/Purge/eXit] :" => Some(("common", "splinedit-fit-data-add-delete-move-purge-exit-exit")), + "SPLINEDIT Select any open curves to join to source:" => Some(("common", "splinedit-select-any-open-curves-to-join-to-source")), + "SPLINEDIT Specify a point on the spline :" => Some(("common", "splinedit-specify-a-point-on-the-spline-exit")), + "SPLINEDIT Specify control vertex to delete:" => Some(("common", "splinedit-specify-control-vertex-to-delete")), + "SPLINEDIT Specify control vertex:" => Some(("common", "splinedit-specify-control-vertex")), + "SPLINEDIT Specify end tangent or [System default]:" => Some(("common", "splinedit-specify-end-tangent-or-system-default")), + "SPLINEDIT Specify existing fit point on spline :" => Some(("common", "splinedit-specify-existing-fit-point-on-spline-exit")), + "SPLINEDIT Specify new fit point to add :" => Some(("common", "splinedit-specify-new-fit-point-to-add-exit")), + "SPLINEDIT Specify new location or [Next/Previous/Select point/eXit] :" => Some(("common", "splinedit-specify-new-location-or-next-previous-select-point-exit-next")), + "SPLINEDIT Specify new point or [After/Before] :" => Some(("common", "splinedit-specify-new-point-or-after-before-exit")), + "SPLINEDIT Specify precision 0-99 <10> (straight segments):" => Some(("common", "splinedit-specify-precision-0-99-10-straight-segments")), + "SPLINEDIT Specify start tangent or [System default]:" => Some(("common", "splinedit-specify-start-tangent-or-system-default")), + "SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] :" => Some(("common", "splinedit-add-delete-elevate-order-move-weight-exit-exit")), + "SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] :" => Some(("common", "splinedit-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit")), + "SPLINEDIT [Fit data/Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] :" => Some(("common", "splinedit-fit-data-close-join-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit")), + "SPLINEDIT [Fit data/Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] :" => Some(("common", "splinedit-fit-data-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit")), + "SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] :" => Some(("common", "splinedit-open-move-vertex-refine-reverse-convert-to-polyline-undo-exit-exit")), + "SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] :" => Some(("common", "subtract-convert-selected-closed-mesh-objects-to-solids-yes-no-yes")), + "SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter:" => Some(("common", "subtract-select-solids-regions-surfaces-or-meshes-to-subtract-then-press-enter")), + "SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter:" => Some(("common", "subtract-select-base-solids-regions-surfaces-or-meshes-then-press-enter")), + "SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed." => Some(("common", "subtract-a-selected-mesh-is-not-a-supported-closed-single-shell-mesh-no-object-was-changed")), + "SUBTRACT: created %{count} result object(s)." => Some(("common", "subtract-created-result-object-s")), + "SUBTRACT: no compatible base and cutter types or planes were selected." => Some(("common", "subtract-no-compatible-base-and-cutter-types-or-planes-were-selected")), + "SUBTRACT: select at least one base and one cutter Solid, Region, or Surface." => Some(("common", "subtract-select-at-least-one-base-and-one-cutter-solid-region-or-surface")), + "SUBTRACT: the retained base object could not be updated." => Some(("common", "subtract-the-retained-base-object-could-not-be-updated")), + "SUBTRACT: the selected coincident geometry is ambiguous." => Some(("common", "subtract-the-selected-coincident-geometry-is-ambiguous")), + "SUBTRACT: the selected geometry includes an unsupported surface intersection." => Some(("common", "subtract-the-selected-geometry-includes-an-unsupported-surface-intersection")), + "SUBTRACT: the selected topology could not be cut safely." => Some(("common", "subtract-the-selected-topology-could-not-be-cut-safely")), + "Second row" => Some(("common", "second-row")), + "Section Plane" => Some(("common", "section-plane")), + "Section Plane Offset" => Some(("common", "section-plane-offset")), + "Select a 3D solid or surface edge." => Some(("common", "select-a-3d-solid-or-surface-edge")), + "Select a 3D solid." => Some(("common", "select-a-3d-solid")), + "Select a 3D solid:" => Some(("common", "select-a-3d-solid-2")), + "Select a data link or create a new one." => Some(("common", "select-a-data-link-or-create-a-new-one")), + "Select an edge or [Loop/Distance]:" => Some(("common", "select-an-edge-or-loop-distance")), + "Select edge of loop or [Edge/Distance]:" => Some(("common", "select-edge-of-loop-or-edge-distance")), + "Select face or any point to locate section line or [Draw section/Orthographic/Type]:" => Some(("common", "select-face-or-any-point-to-locate-section-line-or-draw-section-orthographic-type")), + "Select faces to add [Undo/Remove/ALL] :" => Some(("common", "select-faces-to-add-undo-remove-all-done")), + "Select faces to remove [Undo/Add/ALL] :" => Some(("common", "select-faces-to-remove-undo-add-all-done")), + "Select objects in the current drawing" => Some(("common", "select-objects-in-the-current-drawing")), + "Select properties" => Some(("common", "select-properties")), + "Select spline-fit polylines:" => Some(("common", "select-spline-fit-polylines")), + "Select surfaces to thicken:" => Some(("common", "select-surfaces-to-thicken")), + "Selected objects in current drawing" => Some(("common", "selected-objects-in-current-drawing")), + "Set cell styles" => Some(("common", "set-cell-styles")), + "Sheet" => Some(("common", "sheet")), + "Show associativity" => Some(("common", "show-associativity")), + "Show command prompting and command input near crosshairs" => Some(("common", "show-command-prompting-and-command-input-near-crosshairs")), + "Show count column" => Some(("common", "show-count-column")), + "Show cycling badge when objects overlap" => Some(("common", "show-cycling-badge-when-objects-overlap")), + "Show name column" => Some(("common", "show-name-column")), + "Show values and parameter names on constraint markers" => Some(("common", "show-values-and-parameter-names-on-constraint-markers")), + "Slice" => Some(("common", "slice")), + "Slice Depth" => Some(("common", "slice-depth")), + "Snap On (F9)" => Some(("common", "snap-on-f9")), + "Snap X spacing:" => Some(("common", "snap-x-spacing")), + "Snap Y spacing:" => Some(("common", "snap-y-spacing")), + "Snap spacing" => Some(("common", "snap-spacing")), + "Snap type" => Some(("common", "snap-type")), + "Specify Distance1 or [Expression] <{:.4}>:" => Some(("common", "specify-distance1-or-expression")), + "Specify Distance2 or [Expression] <{:.4}>:" => Some(("common", "specify-distance2-or-expression")), + "Specify insertion point" => Some(("common", "specify-insertion-point")), + "Specify point or enter arc length:" => Some(("common", "specify-point-or-enter-arc-length")), + "Specify point or enter radius:" => Some(("common", "specify-point-or-enter-radius")), + "Specify replacement path: XREF Path Find " => Some(("common", "specify-replacement-path-xref-path-find-old-new")), + "Specify second point:" => Some(("common", "specify-second-point")), + "Specify the shell offset distance <{:.3}>:" => Some(("common", "specify-the-shell-offset-distance")), + "Specify thickness" => Some(("common", "specify-thickness")), + "Specify window" => Some(("common", "specify-window")), + "Spline cannot be converted within the requested precision." => Some(("common", "spline-cannot-be-converted-within-the-requested-precision")), + "Spreadsheet Links" => Some(("common", "spreadsheet-links")), + "Start from data extraction" => Some(("common", "start-from-data-extraction")), + "Start from data link" => Some(("common", "start-from-data-link")), + "Start from empty table" => Some(("common", "start-from-empty-table")), + "State2" => Some(("common", "state2")), + "Static quadrant location" => Some(("common", "static-quadrant-location")), + "Status: Linked" => Some(("common", "status-linked")), + "Status: Source not found" => Some(("common", "status-source-not-found")), + "Surface Associativity" => Some(("common", "surface-associativity")), + "Surface Type" => Some(("common", "surface-type-2")), + "THICKEN: created {} solid(s); {} surface(s) could not be thickened exactly." => Some(("common", "thicken-created-solid-s-surface-s-could-not-be-thickened-exactly")), + "Tab-separated text" => Some(("common", "tab-separated-text")), + "Tangent objects must share an intersection point" => Some(("common", "tangent-objects-must-share-an-intersection-point")), + "Tangent point must differ from the fit endpoint." => Some(("common", "tangent-point-must-differ-from-the-fit-endpoint")), + "The INTERSECT result could not be displayed completely. The original objects were retained." => Some(("common", "the-intersect-result-could-not-be-displayed-completely-the-original-objects-were-retained")), + "The INTERSECT result could not be encoded as ACIS." => Some(("common", "the-intersect-result-could-not-be-encoded-as-acis")), + "The SUBTRACT result could not be displayed completely. The original objects were retained." => Some(("common", "the-subtract-result-could-not-be-displayed-completely-the-original-objects-were-retained")), + "The SUBTRACT result could not be encoded as ACIS." => Some(("common", "the-subtract-result-could-not-be-encoded-as-acis")), + "The UNION result could not be encoded as ACIS." => Some(("common", "the-union-result-could-not-be-encoded-as-acis")), + "The chamfer history could not be recorded." => Some(("common", "the-chamfer-history-could-not-be-recorded")), + "The edge body could not be updated." => Some(("common", "the-edge-body-could-not-be-updated")), + "The edge body history could not be created." => Some(("common", "the-edge-body-history-could-not-be-created")), + "The offset removes all material. Use a smaller distance or add a face back." => Some(("common", "the-offset-removes-all-material-use-a-smaller-distance-or-add-a-face-back")), + "The region could not be encoded as ACIS." => Some(("common", "the-region-could-not-be-encoded-as-acis")), + "The result could not be displayed completely. The original edge body was retained." => Some(("common", "the-result-could-not-be-displayed-completely-the-original-edge-body-was-retained")), + "The selected base face no longer belongs to the body." => Some(("common", "the-selected-base-face-no-longer-belongs-to-the-body")), + "The selected drawing data is ready to be extracted." => Some(("common", "the-selected-drawing-data-is-ready-to-be-extracted")), + "The selected object is no longer available." => Some(("common", "the-selected-object-is-no-longer-available")), + "The shell could not be constructed from the selected solid and distance." => Some(("common", "the-shell-could-not-be-constructed-from-the-selected-solid-and-distance")), + "The shell offset distance must be nonzero." => Some(("common", "the-shell-offset-distance-must-be-nonzero")), + "Trimmed surface" => Some(("common", "trimmed-surface")), + "Trimming edges" => Some(("common", "trimming-edges")), + "Trims" => Some(("common", "trims")), + "Type a name and a formula (e.g. hole_dia = 12, hole_spacing = 2 * hole_dia + 1.5). Apply to save and re-solve; closing discards unapplied edits." => Some(("common", "type-a-name-and-a-formula-e-g-hole-dia-12-hole-spacing-2-hole-dia-1-5-apply-to-save-and-re-solve-closing-discards-unapplied-edits")), + "U isolines" => Some(("common", "u-isolines-2")), + "UNION: created %{count} result object(s)." => Some(("common", "union-created-result-object-s")), + "UNION: select at least two solids, Regions, or planar Surfaces of the same type." => Some(("common", "union-select-at-least-two-solids-regions-or-planar-surfaces-of-the-same-type")), + "UNION: the selected coincident geometry is ambiguous." => Some(("common", "union-the-selected-coincident-geometry-is-ambiguous")), + "UNION: the selected geometry includes an unsupported surface intersection." => Some(("common", "union-the-selected-geometry-includes-an-unsupported-surface-intersection")), + "UNION: the selected topology could not be closed safely." => Some(("common", "union-the-selected-topology-could-not-be-closed-safely")), + "Use a previous extraction as a template" => Some(("common", "use-a-previous-extraction-as-a-template")), + "Use source formatting" => Some(("common", "use-source-formatting")), + "Used by" => Some(("common", "used-by")), + "V isolines" => Some(("common", "v-isolines-2")), + "Value must be nonzero." => Some(("common", "value-must-be-nonzero")), + "Viewing Direction" => Some(("common", "viewing-direction")), + "WIPEOUT Specify next point or [Close/Undo]:" => Some(("common", "wipeout-specify-next-point-or-close-undo")), + "WIPEOUT Specify next point or [Undo]:" => Some(("common", "wipeout-specify-next-point-or-undo")), + "WIPEOUT Specify next point:" => Some(("common", "wipeout-specify-next-point")), + "Wireframe type" => Some(("common", "wireframe-type")), + "XATTACH" => Some(("insert", "xattach")), + "XATTACH Specify rotation angle <0>:" => Some(("insert", "xattach-specify-rotation-angle-0")), + "XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner):" => Some(("insert", "xattach-specify-scale-factor-1-0-x-y-z-or-pick-corner")), + "XOPEN: opening \"{}\"." => Some(("insert", "xopen-opening")), + "XREF: {e}" => Some(("insert", "xref")), + "_mtp Specify first point of mid:" => Some(("common", "mtp-specify-first-point-of-mid")), + "_mtp Specify second point of mid:" => Some(("common", "mtp-specify-second-point-of-mid")), + "formula" => Some(("common", "formula-2")), + "name" => Some(("common", "name")), + "{command}: aligned {} object(s)." => Some(("common", "aligned-object-s")), + "{} Select annotative objects:" => Some(("common", "select-annotative-objects")), + "{} parameter(s) applied." => Some(("common", "parameter-s-applied")), + "{} row(s) skipped: duplicate parameter name." => Some(("common", "row-s-skipped-duplicate-parameter-name")), + "{} surface(s) cannot be thickened because the offset intersects itself." => Some(("common", "surface-s-cannot-be-thickened-because-the-offset-intersects-itself")), + "⚠ {parametric_conflicts} conflicting" => Some(("common", "conflicting")), + "PLINE Specify starting width <%{w}>:" => Some(("draw", "pline-specify-starting-width")), + "PLINE Specify ending width <%{w}>:" => Some(("draw", "pline-specify-ending-width")), + "PLINE Specify starting half-width <%{w}>:" => Some(("draw", "pline-specify-starting-half-width")), + "PLINE Specify ending half-width <%{w}>:" => Some(("draw", "pline-specify-ending-half-width")), + "PLINE (Line) Specify next point:" => Some(("draw", "pline-line-specify-next-point")), + "PLINE (Arc) Specify endpoint of arc:" => Some(("draw", "pline-arc-specify-endpoint-of-arc")), + "PLINE Specify length of line:" => Some(("draw", "pline-specify-length-of-line")), + "PLINE Specify included angle:" => Some(("draw", "pline-specify-included-angle")), + "PLINE Specify endpoint of arc:" => Some(("draw", "pline-specify-endpoint-of-arc")), + "PLINE Specify center point of arc:" => Some(("draw", "pline-specify-center-point-of-arc")), + "PLINE Specify radius of arc:" => Some(("draw", "pline-specify-radius-of-arc")), + "PLINE Specify direction of chord for arc:" => Some(("draw", "pline-specify-direction-of-chord-for-arc")), + "PLINE Specify length of chord:" => Some(("draw", "pline-specify-length-of-chord")), + "PLINE Specify tangent direction for the start point of arc:" => Some(("draw", "pline-specify-tangent-direction-for-the-start-point-of-arc")), + "PLINE Specify second point on arc:" => Some(("draw", "pline-specify-second-point-on-arc")), + "Halfwidth" => Some(("draw", "halfwidth")), + "CEnter" => Some(("draw", "center-keyword")), + "CLose" => Some(("draw", "close-keyword")), + "Second pt" => Some(("draw", "second-pt")), + "ARea" => Some(("modify", "area-keyword")), + "Displacement" => Some(("modify", "displacement")), + "mOde" => Some(("modify", "mode-keyword")), + "Array" => Some(("modify", "array-keyword")), + "Use first point as displacement" => Some(("modify", "use-first-point-as-displacement")), + "OFFSET Erase source object after offsetting? [Yes/No] :" => Some(("modify", "offset-erase-source-object-after-offsetting")), + "OFFSET Enter layer option for offset objects [Current/Source] :" => Some(("modify", "offset-enter-layer-option-for-offset-objects")), + "Recent Input" => Some(("ui", "recent-input")), + "Copy with Base Point" => Some(("ui", "copy-with-base-point")), + "Undo %{label}" => Some(("ui", "undo-label")), + "Redo %{label}" => Some(("ui", "redo-label")), + "Copy Selection" => Some(("ui", "copy-selection")), + "Isolate" => Some(("ui", "isolate")), + "Deselect All" => Some(("ui", "deselect-all")), + "Options..." => Some(("ui", "options-ellipsis")), + "Base Point" => Some(("ui", "base-point")), + "Right-click Customization" => Some(("options", "right-click-customization")), + "Right-click in drawing area" => Some(("options", "right-click-in-drawing-area")), + "Hold duration" => Some(("options", "hold-duration")), + "Shortcut menu: right-click always opens the menu. Time-sensitive: a quick click is Enter, holding longer opens the menu (SHORTCUTMENUDURATION)." => Some(("options", "right-click-mode-help")), + "Shortcut menu" => Some(("options", "right-click-mode-shortcut-menu")), + "Time-sensitive (quick click = Enter)" => Some(("options", "right-click-mode-time-sensitive")), + "Enter first, second click = menu" => Some(("options", "right-click-mode-enter-first")), + "ARC Specify start point of arc:" => Some(("draw", "arc-specify-start-point-of-arc")), + "ARC Specify second point of arc:" => Some(("draw", "arc-specify-second-point-of-arc")), + "ARC Specify end point of arc:" => Some(("draw", "arc-specify-end-point-of-arc")), + "ARC Specify center point of arc:" => Some(("draw", "arc-specify-center-point-of-arc")), + "ARC Specify included angle:" => Some(("draw", "arc-specify-included-angle")), + "ARC Specify length of chord:" => Some(("draw", "arc-specify-length-of-chord")), + "ARC Specify tangent direction for the start point of arc:" => Some(("draw", "arc-specify-tangent-direction-for-the-start-point-of-arc")), + "ARC Specify radius of arc:" => Some(("draw", "arc-specify-radius-of-arc")), + "chord Length" => Some(("draw", "chord-length")), + "Snap Overrides" => Some(("ui", "snap-overrides")), + "Mid Between 2 Points" => Some(("ui", "mid-between-2-points")), + "Osnap Settings..." => Some(("ui", "osnap-settings")), + "Snap override: None (next pick only)." => Some(("ui", "snap-override-none-next-pick-only")), _ => None, } } diff --git a/src/main.rs b/src/main.rs index a691981e2..0975cc38a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,14 @@ fn main() -> iced::Result { use clap::Parser; let args = cli::Cli::parse(); + // GPU probe child: exercise one backend offscreen, print one JSON + // line on success and exit 0/1. This must run before any logging/GUI + // setup; the parent interprets any non-zero exit (including a driver + // abort) as "this backend is unusable". + if let Some(which) = &args.gpu_probe { + OpenCADStudio::gpu_backend::run_probe_child(which); + } + // Plugin runner mode: the host spawns itself with this hidden flag to // load a plugin cdylib in an isolated process. Hand off immediately so // the child never touches GUI state. @@ -77,19 +85,20 @@ fn main() -> iced::Result { } // GPU backend selection. Explicit `--backend` wins; `--safe-mode` - // forces GL for flaky drivers. On Windows, fall back to DX12/Vulkan so - // the AMD OpenGL ICD (atio6axx.dll) is never touched at startup — it - // access-violates on some hybrid-GPU laptops before any window appears - // (#55). An already-set WGPU_BACKEND always wins. - if let Some(backend) = &args.backend { - std::env::set_var("WGPU_BACKEND", backend); - } else if args.safe_mode { - std::env::set_var("WGPU_BACKEND", "gl"); - } - #[cfg(target_os = "windows")] - if std::env::var_os("WGPU_BACKEND").is_none() { - std::env::set_var("WGPU_BACKEND", "dx12,vulkan"); - } + // forces GL for flaky drivers. On Windows the preference order starts + // with DX12/Vulkan so the AMD OpenGL ICD (atio6axx.dll) is never + // touched at startup — it access-violates on some hybrid-GPU laptops + // before any window appears (#55). An already-set WGPU_BACKEND always + // wins. + // + // Older GPUs without usable DirectX 12 Feature Level 12_0 or working + // Vulkan (e.g. legacy Intel iGPUs), and old GLES drivers whose shader + // compiler rejects iced's own shaders, crash at startup or on the + // first drawing viewport instead. `resolve_gpu` probes each candidate + // backend in an isolated child process and selects the first one that + // survives a real offscreen render; a crash sentinel skips backends + // that died with the previous run. + let gpu = OpenCADStudio::gpu_backend::resolve_gpu(args.backend.as_deref(), args.safe_mode); // Headless modes exit without ever creating a window. if args.serve { @@ -145,12 +154,22 @@ fn main() -> iced::Result { } }) .unwrap_or_default(); + let gpu_fallback_notice = gpu + .reason + .map(|reason| OpenCADStudio::gpu_backend::fallback_notice(&reason)); + // The probe enables the packed renderer automatically on GPUs without + // shader storage buffers; an explicit `--compat-renderer` does the + // same without the notice. + let compat_renderer = args.compat_renderer || gpu.compat_renderer; + let gpu_compat_auto = !args.compat_renderer && gpu.compat_renderer; let _ = cli::GUI_CONFIG.set(cli::GuiConfig { files: if args.new { Vec::new() } else { args.files }, new: args.new, read_only: args.read_only, - compat_renderer: args.compat_renderer, + compat_renderer, script_lines, + gpu_fallback_notice, + gpu_compat_auto, }); // Register (or refresh) the freedesktop DWG thumbnailer so file managers @@ -159,6 +178,19 @@ fn main() -> iced::Result { // mode. Silently ignored on failure or non-Linux. io::file_association::install_thumbnailer(); - app::run() + // Crash sentinel: armed while the GPU is live, naming the attempted + // backend. A stale file at the next launch means this run aborted, so + // the resolver skips those backends instead of crashing again. + // Inactive on macOS/wasm, where the resolver leaves the env alone. + if OpenCADStudio::gpu_backend::gpu_guard_active() { + OpenCADStudio::gpu_backend::arm_sentinel( + gpu.backend_value.as_deref().unwrap_or("auto"), + ); + let result = app::run(); + OpenCADStudio::gpu_backend::disarm_sentinel(); + result + } else { + app::run() + } } } diff --git a/src/mcp.rs b/src/mcp.rs index 71866f815..0f7107c0d 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -54,6 +54,7 @@ const EXECUTE_OPS: &[&str] = &[ "property", "set_properties", "action", + "embed_image", "save", "stop", "batch", @@ -72,6 +73,7 @@ const BATCH_STEP_OPS: &[&str] = &[ "property", "set_properties", "action", + "embed_image", "save", "stop", ]; @@ -717,6 +719,12 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { )), None => missing("name", r#"{"op":"action","name":"zoom_extents"}"#), }, + "embed_image" if request["path"].as_str().is_none_or(str::is_empty) => { + missing( + "path", + r#"{"op":"embed_image","path":"/path/logo.png","at":[0,0,0],"width":100}"#, + ) + } _ => Ok(()), } } @@ -902,7 +910,9 @@ fn execute_request_schema() -> Value { "camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."}, "selection":{"type":"array","items":handle.clone(),"description":"Expected selected handles from current state."}, "cmd":{"type":"string","minLength":1,"description":"Command name followed by its prompt answers separated by spaces. Points use x,y or x,y,z; option answers use their token. Read command details first when unsure.","examples":["LINE 0,0 10,10","CIRCLE 5,5 3","PLINE 0,0 10,0 10,10 C"]}, - "path":{"type":"string","minLength":1,"description":"Absolute drawing path for open or save."}, + "path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image."}, + "at":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World [x,y] or [x,y,z] placement corner for embed_image (picture grows up-right)."}, + "width":{"type":"number","exclusiveMinimum":0,"description":"World width for embed_image; height follows the image aspect ratio. Defaults to pixel_width/100."}, "kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"],"description":"Input kind listed in state.command.accepts."}, "text":{"type":"string","description":"Free text or one option/value token."}, "point":point, @@ -943,6 +953,7 @@ fn execute_request_schema() -> Value { {"properties":{"op":{"const":"property"}},"required":["field","value"]}, {"properties":{"op":{"const":"set_properties"}},"required":["collection","updates"]}, {"properties":{"op":{"const":"action"}},"required":["name"]}, + {"properties":{"op":{"const":"embed_image"}},"required":["path"]}, {"properties":{"op":{"const":"save"}}}, {"properties":{"op":{"const":"stop"}}}, {"properties":{"op":{"const":"batch"}},"required":["steps"]} diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 872a00232..36013041b 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -6,7 +6,8 @@ use acadrust::EntityType; use glam::DVec3; use crate::command::{ - CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionPreview, InputKind, + WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -61,6 +62,29 @@ impl AlignedDimensionCommand { mtext_override: false, } } + + fn build_dimension(&self, first: DVec3, second: DVec3, point: DVec3) -> EntityType { + let first = self.plane.to_local(first); + let second = self.plane.to_local(second); + let point = self.plane.to_local(point); + let mut dim = DimensionAligned::new(v3(first), v3(second)); + // The placement point is stored so commit matches the preview. + dim.definition_point = v3(point); + dim.base.definition_point = v3(point); + let (d1, d2) = dim_line_endpoints(first, second, point); + 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(); + 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(angle) = self.text_angle { + dim.base.text_rotation = angle; + } + self.plane.place_entity(EntityType::Dimension(Dimension::Aligned(dim))) + } } impl CadCommand for AlignedDimensionCommand { @@ -115,27 +139,8 @@ impl CadCommand for AlignedDimensionCommand { CmdResult::NeedPoint } Step::DimLine { p1, p2 } => { - let p1 = self.plane.to_local(p1); - let p2 = self.plane.to_local(p2); - let pt = self.plane.to_local(pt); - let mut dim = DimensionAligned::new(v3(p1), v3(p2)); - // 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(); - 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::CommitDimension { - entity: self.plane.place_entity(EntityType::Dimension(Dimension::Aligned(dim))), + entity: self.build_dimension(p1, p2, pt), association: DimensionAssociationInput::Infer(self.source_handle), preserve_base_style: false, continue_command: false, @@ -161,6 +166,24 @@ impl CadCommand for AlignedDimensionCommand { CmdResult::Cancel } + /// Points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + + fn dimension_acquired_points(&self) -> Vec { + match self.step { + Step::First => vec![], + Step::Second(p) => vec![p], + Step::DimLine { p1, p2 } => vec![p1, p2], + } + } + + fn dimension_placement_pending(&self) -> bool { + matches!(self.step, Step::DimLine { .. }) + } + fn input_kind(&self) -> InputKind { if self.awaiting_text { InputKind::FreeText @@ -333,6 +356,35 @@ impl CadCommand for AlignedDimensionCommand { fill_tris_low: Vec::new(), }) } + + fn dyn_spec(&self) -> Option { + if !matches!(self.step, Step::DimLine { .. }) || self.awaiting_text || self.awaiting_angle { + return None; + } + Some(crate::command::DynSpec { + anchor: crate::command::DynAnchor::LastPoint, + fields: Vec::new(), + guide: crate::command::DynGuide::None, + ref_point: None, + }) + } + + fn dimension_preview(&self, cursor: DVec3) -> Option> { + if self.selecting_object { + return None; + } + let (first, second) = match self.step { + Step::First => return None, + Step::Second(first) => (first, cursor), + Step::DimLine { p1, p2 } => (p1, p2), + }; + if first.distance_squared(second) <= 1e-24 { + return Some(Vec::new()); + } + Some(vec![DimensionPreview::current_style( + self.build_dimension(first, second, cursor), + )]) + } } fn v3(p: DVec3) -> Vector3 { diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index b5d3bf417..3df4b2d3d 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -7,8 +7,8 @@ use cadkernel::geom2d::{ }; use crate::command::{ - CadCommand, CmdOption, CmdResult, DimensionAssociationInput, - DimensionAssociationSource, InputKind, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionAssociationSource, + DimensionPreview, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -482,6 +482,42 @@ impl CadCommand for AngularDimensionCommand { CmdResult::Cancel } + /// Points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + + fn dimension_acquired_points(&self) -> Vec { + match self.step { + Step::Vertex => vec![], + Step::FirstRay(p) => vec![p], + Step::SecondRay { vertex, first } | Step::CircleSecondRay { vertex, first, .. } => { + vec![vertex, first] + } + Step::SecondLine { + first_start, + first_end, + .. + } => vec![first_start, first_end], + Step::ArcPoint3 { + vertex, + first, + second, + } => vec![vertex, first, second], + Step::ArcPoint2 { + first_start, + first_end, + second_start, + second_end, + } => vec![first_start, first_end, second_start, second_end], + } + } + + fn dimension_placement_pending(&self) -> bool { + matches!(self.step, Step::ArcPoint3 { .. } | Step::ArcPoint2 { .. }) + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } @@ -771,6 +807,32 @@ impl CadCommand for AngularDimensionCommand { }; Some(preview_wire(points)) } + + fn dimension_preview(&self, cursor: DVec3) -> Option> { + if self.selecting_object { + return None; + } + let result = match self.step { + Step::ArcPoint3 { + vertex, + first, + second, + } => self.finish_three_point(vertex, first, second, cursor), + Step::ArcPoint2 { + first_start, + first_end, + second_start, + second_end, + } => self.finish_two_line(first_start, first_end, second_start, second_end, cursor), + _ => return None, + }; + match result { + CmdResult::CommitDimension { entity, .. } => { + Some(vec![DimensionPreview::current_style(entity)]) + } + _ => Some(Vec::new()), + } + } } fn picked_curve(entity: &EntityType, click: DVec3) -> Option { diff --git a/src/modules/annotate/arc_length_dim.rs b/src/modules/annotate/arc_length_dim.rs index 4797c70c9..74ad33677 100644 --- a/src/modules/annotate/arc_length_dim.rs +++ b/src/modules/annotate/arc_length_dim.rs @@ -5,8 +5,8 @@ use cadkernel::geom2d::tessellate::{arc, DEFAULT_SEGMENTS_PER_RADIAN}; use glam::DVec3; use crate::command::{ - CadCommand, CmdOption, CmdResult, DimensionAssociationInput, - DimensionAssociationSource, InputKind, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionAssociationSource, + DimensionPreview, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::dimension_assoc::{ @@ -516,6 +516,18 @@ impl CadCommand for ArcLengthDimensionCommand { } } } + + fn dimension_preview(&self, cursor: DVec3) -> Option> { + let Step::DimLine(selection) = self.step else { + return None; + }; + match self.commit_dimension(selection, cursor) { + CmdResult::CommitDimension { entity, .. } => { + Some(vec![DimensionPreview::current_style(entity)]) + } + _ => Some(Vec::new()), + } + } } fn positive_sweep(start: f64, end: f64) -> f64 { diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 1f712ff5e..ef9255aae 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -4,7 +4,8 @@ use acadrust::EntityType; use glam::{DVec3, Vec3}; use crate::command::{ - CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionPreview, InputKind, + WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -55,6 +56,35 @@ impl DiameterDimensionCommand { Step::DimLine(source) => dvec(source.point_at_angle(source.start_angle)), } } + + fn build_dimension( + &self, + source: crate::scene::dimension_assoc::RadialSourceGeometry, + world: DVec3, + ) -> EntityType { + let 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 = plane.to_local(dvec(source.chord_at(world.to_array()))); + let far_chord = plane.to_local(dvec(source.opposite_chord_at(world.to_array()))); + let point = plane.to_local(world); + let mut dim = DimensionDiameter::new(v3(chord), v3(far_chord)); + dim.base.text_middle_point = v3(point); + dim.base.insertion_point = v3(point); + dim.base.text_user_positioned = true; + dim.leader_length = chord.distance(point); + dim.base.actual_measurement = dim.measurement(); + 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; + } + plane.place_entity(EntityType::Dimension(Dimension::Diameter(dim))) + } } impl CadCommand for DiameterDimensionCommand { @@ -88,30 +118,8 @@ impl CadCommand for DiameterDimensionCommand { match self.step { 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.base.text_user_positioned = true; - dim.leader_length = chord.distance(pt); - dim.base.actual_measurement = dim.measurement(); - 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::CommitDimension { - entity: source_plane - .place_entity(EntityType::Dimension(Dimension::Diameter(dim))), + entity: self.build_dimension(source, pt), association: DimensionAssociationInput::Infer(self.source_handle), preserve_base_style: false, continue_command: false, @@ -132,6 +140,23 @@ impl CadCommand for DiameterDimensionCommand { CmdResult::Cancel } + /// Points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + + fn dimension_acquired_points(&self) -> Vec { + match self.step { + Step::SelectObject => vec![], + Step::DimLine(source) => vec![dvec(source.point_at_angle(source.start_angle))], + } + } + + fn dimension_placement_pending(&self) -> bool { + matches!(self.step, Step::DimLine(_)) + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } @@ -261,6 +286,13 @@ impl CadCommand for DiameterDimensionCommand { } } } + + fn dimension_preview(&self, cursor: DVec3) -> Option> { + let Step::DimLine(source) = self.step else { + return None; + }; + Some(vec![DimensionPreview::current_style(self.build_dimension(source, cursor))]) + } } fn v3(p: DVec3) -> Vector3 { diff --git a/src/modules/annotate/dim_baseline.rs b/src/modules/annotate/dim_baseline.rs index 82e1c51b6..28be7350a 100644 --- a/src/modules/annotate/dim_baseline.rs +++ b/src/modules/annotate/dim_baseline.rs @@ -13,7 +13,9 @@ use cadkernel::geom2d::{ use cadkernel::space::Plane; use glam::DVec3; -use crate::command::{CadCommand, CmdOption, CmdResult, DimensionAssociationInput}; +use crate::command::{ + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionPreview, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -482,6 +484,16 @@ impl CadCommand for DimBaselineCommand { self.build_dimension(point) .map(|dimension| preview_for_dimension(&dimension)) } + + fn dimension_preview(&self, cursor: DVec3) -> Option> { + self.build_dimension(cursor) + .map(|dimension| { + vec![DimensionPreview { + entity: EntityType::Dimension(dimension), + preserve_base_style: self.preserve_base_style, + }] + }) + } } fn linear_base( diff --git a/src/modules/annotate/dim_continue.rs b/src/modules/annotate/dim_continue.rs index f4651a2af..06bbcb419 100644 --- a/src/modules/annotate/dim_continue.rs +++ b/src/modules/annotate/dim_continue.rs @@ -11,7 +11,9 @@ use cadkernel::geom2d::{ use cadkernel::space::Plane; use glam::DVec3; -use crate::command::{CadCommand, CmdOption, CmdResult, DimensionAssociationInput}; +use crate::command::{ + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionPreview, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -423,6 +425,16 @@ impl CadCommand for DimContinueCommand { self.build_dimension(point) .map(|dimension| preview_for_dimension(&dimension)) } + + fn dimension_preview(&self, cursor: DVec3) -> Option> { + self.build_dimension(cursor) + .map(|dimension| { + vec![DimensionPreview { + entity: EntityType::Dimension(dimension), + preserve_base_style: self.preserve_base_style, + }] + }) + } } fn build_linear( diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs index b88766a2a..405685658 100644 --- a/src/modules/annotate/jogged_radius_dim.rs +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -5,7 +5,7 @@ use glam::{DVec3, Vec3}; use crate::command::{ CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionAssociationSource, - InputKind, WorkingPlane, + DimensionPreview, InputKind, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::creation_style::DimensionCreationDefaults; @@ -390,6 +390,35 @@ impl CadCommand for JoggedRadiusDimensionCommand { } } } + + fn dimension_preview(&self, cursor: DVec3) -> Option> { + let result = match self.step { + Step::DimLine { + source, + override_center, + } => { + // The jog is not chosen yet; preview it midway along the + // dimension line so the styled value is visible while the + // text position is placed. + let chord = dvec(source.chord_at(cursor.to_array())); + let jog = (override_center + chord) * 0.5; + self.commit_dimension(source, override_center, chord, cursor, jog) + } + Step::Jog { + source, + override_center, + chord, + text_position, + } => self.commit_dimension(source, override_center, chord, text_position, cursor), + _ => return None, + }; + match result { + CmdResult::CommitDimension { entity, .. } => { + Some(vec![DimensionPreview::current_style(entity)]) + } + _ => Some(Vec::new()), + } + } } fn source_plane(source: RadialSourceGeometry) -> WorkingPlane { diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 263fc8cac..5e9b00c94 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -6,7 +6,8 @@ use cadkernel::geom2d::{ }; use crate::command::{ - CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionPreview, InputKind, + WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -107,6 +108,29 @@ impl LinearDimensionCommand { mtext_override: false, } } + + fn build_dimension(&self, first: DVec3, second: DVec3, point: DVec3) -> EntityType { + let first = self.plane.to_local(first); + let second = self.plane.to_local(second); + let point = self.plane.to_local(point); + let mut dim = DimensionLinear::new(v3(first), v3(second)); + let axis = self.axis_mode.axis(first, second, point); + dim.rotation = axis.y.atan2(axis.x); + dim.set_offset(dimension_line_offset(second, point, axis)); + dim.base.definition_point = dim.definition_point; + dim.base.text_middle_point = v3(linear_text_pos(first, second, point, axis)); + dim.base.insertion_point = dim.base.text_middle_point; + dim.base.actual_measurement = dim.measurement(); + 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(angle) = self.text_angle { + dim.base.text_rotation = angle; + } + self.plane.place_entity(EntityType::Dimension(Dimension::Linear(dim))) + } } impl CadCommand for LinearDimensionCommand { @@ -164,28 +188,7 @@ impl CadCommand for LinearDimensionCommand { CmdResult::NeedPoint } Step::DimensionLine { first, second } => { - let first = self.plane.to_local(first); - 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 = self.axis_mode.axis(first, second, pt); - dim.rotation = axis.y.atan2(axis.x); - 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(); - 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; - } - let entity = self.plane.place_entity(EntityType::Dimension( - Dimension::Linear(dim), - )); + let entity = self.build_dimension(first, second, pt); CmdResult::CommitDimension { entity, association: DimensionAssociationInput::Infer(self.source_handle), @@ -217,6 +220,24 @@ impl CadCommand for LinearDimensionCommand { CmdResult::Cancel } + /// Points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + + fn dimension_acquired_points(&self) -> Vec { + match self.step { + Step::FirstPoint => vec![], + Step::SecondPoint(p) => vec![p], + Step::DimensionLine { first, second } => vec![first, second], + } + } + + fn dimension_placement_pending(&self) -> bool { + matches!(self.step, Step::DimensionLine { .. }) + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } @@ -368,6 +389,39 @@ impl CadCommand for LinearDimensionCommand { } } } + + fn dyn_spec(&self) -> Option { + if !matches!(self.step, Step::DimensionLine { .. }) + || self.awaiting_text + || self.awaiting_angle + || self.awaiting_rotation + { + return None; + } + Some(crate::command::DynSpec { + anchor: crate::command::DynAnchor::LastPoint, + fields: Vec::new(), + guide: crate::command::DynGuide::None, + ref_point: None, + }) + } + + fn dimension_preview(&self, cursor: DVec3) -> Option> { + if self.selecting_object { + return None; + } + let (first, second) = match self.step { + Step::FirstPoint => return None, + Step::SecondPoint(first) => (first, cursor), + Step::DimensionLine { first, second } => (first, second), + }; + if first.distance_squared(second) <= 1e-24 { + return Some(Vec::new()); + } + Some(vec![DimensionPreview::current_style( + self.build_dimension(first, second, cursor), + )]) + } } fn v3(pt: DVec3) -> Vector3 { diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index 9611e8aa9..febbf62bd 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -4,7 +4,8 @@ use acadrust::EntityType; use glam::{DVec3, Vec3}; use crate::command::{ - CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionPreview, InputKind, + WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -60,6 +61,26 @@ impl OrdinateDimCommand { Step::LeaderEndpoint { feature } => feature, } } + + fn build_dimension(&self, feature: DVec3, point: DVec3) -> EntityType { + let feature = self.plane.to_local(feature); + let point = self.plane.to_local(point); + let is_x = match self.datum_mode { + DatumMode::Automatic => is_x_type(feature, point), + DatumMode::X => true, + DatumMode::Y => false, + }; + let mut dim = DimensionOrdinate::new(v3(feature), v3(point), is_x); + 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(); + self.plane.place_entity(EntityType::Dimension(Dimension::Ordinate(dim))) + } } impl CadCommand for OrdinateDimCommand { @@ -95,26 +116,8 @@ impl CadCommand for OrdinateDimCommand { CmdResult::NeedPoint } Step::LeaderEndpoint { feature } => { - let feature = self.plane.to_local(feature); - let pt = self.plane.to_local(pt); - 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); - 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), - )), + entity: self.build_dimension(feature, pt), association: DimensionAssociationInput::Infer(None), preserve_base_style: false, continue_command: false, @@ -258,6 +261,13 @@ impl CadCommand for OrdinateDimCommand { .collect(), )) } + + fn dimension_preview(&self, cursor: DVec3) -> Option> { + let Step::LeaderEndpoint { feature } = self.step else { + return None; + }; + Some(vec![DimensionPreview::current_style(self.build_dimension(feature, cursor))]) + } } fn v3(p: DVec3) -> Vector3 { diff --git a/src/modules/annotate/qdim.rs b/src/modules/annotate/qdim.rs index ab10ea0b7..8dbae29c9 100644 --- a/src/modules/annotate/qdim.rs +++ b/src/modules/annotate/qdim.rs @@ -8,7 +8,7 @@ use glam::DVec3; use crate::command::{ CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionAssociationSource, - EntityTransform, SelectionEntity, WorkingPlane, + DimensionPreview, EntityTransform, SelectionEntity, WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -364,6 +364,15 @@ impl CadCommand for QdimCommand { } Vec::new() } + + fn dimension_preview(&self, cursor: DVec3) -> Option> { + (self.step == Step::Place).then(|| { + self.build_dimensions(cursor) + .into_iter() + .map(|(entity, _)| DimensionPreview::current_style(entity)) + .collect() + }) + } } fn v3(point: DVec3) -> Vector3 { Vector3::new(point.x, point.y, point.z) } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index ff69fcdd3..e595aef72 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -3,7 +3,8 @@ use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use crate::command::{ - CadCommand, CmdOption, CmdResult, DimensionAssociationInput, InputKind, WorkingPlane, + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionPreview, InputKind, + WorkingPlane, }; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; @@ -55,6 +56,36 @@ impl RadiusDimensionCommand { Step::DimLine(source) => dvec(source.point_at_angle(source.start_angle)), } } + + fn build_dimension( + &self, + source: crate::scene::dimension_assoc::RadialSourceGeometry, + world: DVec3, + ) -> EntityType { + let 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 = plane.to_local(dvec(source.center_world())); + let chord = plane.to_local(dvec(source.chord_at(world.to_array()))); + let point = plane.to_local(world); + let mut dim = DimensionRadius::new(v3(center), v3(chord)); + dim.base.definition_point = v3(chord); + dim.base.text_middle_point = v3(point); + dim.base.insertion_point = v3(point); + dim.base.text_user_positioned = true; + dim.leader_length = chord.distance(point); + dim.base.actual_measurement = dim.measurement(); + 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; + } + plane.place_entity(EntityType::Dimension(Dimension::Radius(dim))) + } } impl CadCommand for RadiusDimensionCommand { @@ -84,32 +115,8 @@ impl CadCommand for RadiusDimensionCommand { match self.step { 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(); - 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::CommitDimension { - entity: source_plane.place_entity(EntityType::Dimension(Dimension::Radius(dim))), + entity: self.build_dimension(source, pt), association: DimensionAssociationInput::Infer(self.source_handle), preserve_base_style: false, continue_command: false, @@ -130,6 +137,23 @@ impl CadCommand for RadiusDimensionCommand { CmdResult::Cancel } + /// Points and object picks may come through a paper-space viewport; + /// the committed dimension then reports the model measurement. + fn measures_through_viewports(&self) -> bool { + true + } + + fn dimension_acquired_points(&self) -> Vec { + match self.step { + Step::SelectObject => vec![], + Step::DimLine(source) => vec![dvec(source.point_at_angle(source.start_angle))], + } + } + + fn dimension_placement_pending(&self) -> bool { + matches!(self.step, Step::DimLine(_)) + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } @@ -267,6 +291,13 @@ impl CadCommand for RadiusDimensionCommand { } } } + + fn dimension_preview(&self, cursor: DVec3) -> Option> { + let Step::DimLine(source) = self.step else { + return None; + }; + Some(vec![DimensionPreview::current_style(self.build_dimension(source, cursor))]) + } } fn v3(pt: DVec3) -> Vector3 { diff --git a/src/modules/draw/defaults.rs b/src/modules/draw/defaults.rs index 5331ac714..5666c0013 100644 --- a/src/modules/draw/defaults.rs +++ b/src/modules/draw/defaults.rs @@ -21,6 +21,7 @@ thread_local! { static RECT_ELEVATION: Cell = Cell::new(0.0); static RECT_THICKNESS: Cell = Cell::new(0.0); static RECT_WIDTH: Cell = Cell::new(0.0); + static PLINE_WIDTH: Cell = Cell::new(0.0); // PLINEWID static RECT_ROTATION: Cell = Cell::new(0.0); // degrees static RECT_CHAMFER1: Cell = Cell::new(0.0); static RECT_CHAMFER2: Cell = Cell::new(0.0); @@ -69,6 +70,7 @@ accessors!(get_polygon_sides, set_polygon_sides, POLYGON_SIDES); accessors!(get_rect_elevation, set_rect_elevation, RECT_ELEVATION); accessors!(get_rect_thickness, set_rect_thickness, RECT_THICKNESS); accessors!(get_rect_width, set_rect_width, RECT_WIDTH); +accessors!(get_pline_width, set_pline_width, PLINE_WIDTH); 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); diff --git a/src/modules/draw/draw/arc.rs b/src/modules/draw/draw/arc.rs index c1851af62..df06aa0e0 100644 --- a/src/modules/draw/draw/arc.rs +++ b/src/modules/draw/draw/arc.rs @@ -274,7 +274,7 @@ fn end_angle_from_chord_len(start_angle: f64, chord: f64, r: f64) -> Option // ── Command 1: Center, Start, End ───────────────────────────────────────── -pub struct ArcCommand { +pub struct ArcCSECommand { step: u8, c: DVec3, r: f64, @@ -283,7 +283,7 @@ pub struct ArcCommand { plane: WorkingPlane, } -impl ArcCommand { +impl ArcCSECommand { pub fn new() -> Self { Self { step: 0, @@ -296,7 +296,7 @@ impl ArcCommand { } } -impl CadCommand for ArcCommand { +impl CadCommand for ArcCSECommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; } @@ -411,6 +411,443 @@ impl CadCommand for ArcCommand { } } +// ── ARC: compatible prompt flow ─────────────────────────────────────────── +// +// The plain ARC command follows the prompts of commercial solutions, so every keyword a user +// knows is there at the same step: +// Specify start point of arc or [Center] +// Specify second point of arc or [Center/End] +// Specify end point of arc +// … after Center: Specify end point of arc or [Angle/chord Length] +// … after End: Specify center point of arc or [Angle/Direction/Radius] +// Enter at the start prompt continues tangent from the last line/arc. +// Holding Ctrl flips the sweep direction where it is not fixed by the picks. + +#[derive(Clone, Copy, Debug, PartialEq)] +enum ArcStep { + Start, + Second { s: DVec3 }, + End3 { s: DVec3, m: DVec3 }, + CenterFirst, + StartAfterCenter { c: DVec3 }, + CenterAfterStart { s: DVec3 }, + /// Start and centre known: end point [Angle / chord Length]. + EndSC { s: DVec3, c: DVec3 }, + AngleSC { s: DVec3, c: DVec3 }, + LengthSC { s: DVec3, c: DVec3 }, + EndAfterStart { s: DVec3 }, + /// Start and end known: centre point [Angle / Direction / Radius]. + CenterSE { s: DVec3, e: DVec3 }, + AngleSE { s: DVec3, e: DVec3 }, + DirectionSE { s: DVec3, e: DVec3 }, + RadiusSE { s: DVec3, e: DVec3 }, +} + +pub struct ArcCommand { + step: ArcStep, + cw: bool, + plane: WorkingPlane, +} + +impl ArcCommand { + pub fn new() -> Self { + Self { + step: ArcStep::Start, + cw: false, + plane: WorkingPlane::default(), + } + } + + /// Arc about `c` from `s` towards the direction of `e` (Ctrl flips). + fn sc_end(&self, s: DVec3, c: DVec3, e: DVec3) -> Option<(DVec3, f64, f64, f64)> { + let r = c.distance(s); + if r < 1e-9 { + return None; + } + let sa = angle_xy(c, s, self.plane); + let ea = angle_xy(c, e, self.plane); + Some(if self.cw { (c, r, ea, sa) } else { (c, r, sa, ea) }) + } + + /// Arc about `c` from `s` sweeping the signed `span` (radians, CCW > 0). + fn sc_angle(&self, s: DVec3, c: DVec3, span: f64) -> Option<(DVec3, f64, f64, f64)> { + let r = c.distance(s); + if r < 1e-9 || span.abs() < 1e-9 { + return None; + } + let sa = angle_xy(c, s, self.plane); + let ea = sa + span; + Some(if span < 0.0 { (c, r, ea, sa) } else { (c, r, sa, ea) }) + } + + /// Arc about `c` from `s` with chord `len` (Ctrl flips). + fn sc_length(&self, s: DVec3, c: DVec3, len: f64) -> Option<(DVec3, f64, f64, f64)> { + let r = c.distance(s); + if r < 1e-9 || len == 0.0 { + return None; + } + let sa = angle_xy(c, s, self.plane); + let ea = end_angle_from_chord_len(sa, len, r)?; + Some(if self.cw { (c, r, ea, sa) } else { (c, r, sa, ea) }) + } + + /// Sweep from `s` to the cursor about `c`, going the way Ctrl says. + fn cursor_span(&self, s: DVec3, c: DVec3, cursor: DVec3) -> f64 { + let span = (angle_xy(c, cursor, self.plane) - angle_xy(c, s, self.plane)).rem_euclid(TAU); + if self.cw { + span - TAU + } else { + span + } + } + + fn typed_number(text: &str) -> Option { + crate::entities::common::parse_typed_length(text.trim()) + } + + fn typed_angle(text: &str) -> Option { + crate::entities::common::parse_typed_angle(text.trim()) + } + + fn commit(&self, arc: Option<(DVec3, f64, f64, f64)>) -> CmdResult { + match arc { + Some((c, r, sa, ea)) => arc_result(c, r, sa, ea, self.plane), + None => CmdResult::NeedPoint, + } + } + + fn preview(&self, arc: Option<(DVec3, f64, f64, f64)>, fallback: WireModel) -> Option { + match arc { + Some((c, r, sa, ea)) => arc_preview(c, r, sa, ea, self.plane).or(Some(fallback)), + None => Some(fallback), + } + } +} + +impl CadCommand for ArcCommand { + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + fn set_ctrl(&mut self, ctrl: bool) { + self.cw = ctrl; + } + + fn name(&self) -> &'static str { + "ARC" + } + + fn prompt(&self) -> String { + match self.step { + ArcStep::Start => t!("ARC Specify start point of arc:"), + ArcStep::Second { .. } => t!("ARC Specify second point of arc:"), + ArcStep::End3 { .. } | ArcStep::EndSC { .. } | ArcStep::EndAfterStart { .. } => { + t!("ARC Specify end point of arc:") + } + ArcStep::CenterFirst | ArcStep::CenterAfterStart { .. } | ArcStep::CenterSE { .. } => { + t!("ARC Specify center point of arc:") + } + ArcStep::StartAfterCenter { .. } => t!("ARC Specify start point of arc:"), + ArcStep::AngleSC { .. } | ArcStep::AngleSE { .. } => t!("ARC Specify included angle:"), + ArcStep::LengthSC { .. } => t!("ARC Specify length of chord:"), + ArcStep::DirectionSE { .. } => { + t!("ARC Specify tangent direction for the start point of arc:") + } + ArcStep::RadiusSE { .. } => t!("ARC Specify radius of arc:"), + } + .into_owned() + } + + fn options(&self) -> Vec { + use crate::command::CmdOption; + match self.step { + ArcStep::Start => vec![CmdOption::new("Center", "C")], + ArcStep::Second { .. } => vec![CmdOption::new("Center", "C"), CmdOption::new("End", "E")], + ArcStep::EndSC { .. } => vec![ + CmdOption::new("Angle", "A"), + CmdOption::new("chord Length", "L"), + ], + ArcStep::CenterSE { .. } => vec![ + CmdOption::new("Angle", "A"), + CmdOption::new("Direction", "D"), + CmdOption::new("Radius", "R"), + ], + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + !matches!( + self.step, + ArcStep::End3 { .. } + | ArcStep::CenterFirst + | ArcStep::StartAfterCenter { .. } + | ArcStep::CenterAfterStart { .. } + | ArcStep::EndAfterStart { .. } + ) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!( + self.step, + ArcStep::Start | ArcStep::Second { .. } | ArcStep::EndSC { .. } | ArcStep::CenterSE { .. } + ) + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match self.step { + ArcStep::Start => { + self.step = ArcStep::Second { s: pt }; + CmdResult::NeedPoint + } + ArcStep::Second { s } => { + self.step = ArcStep::End3 { s, m: pt }; + CmdResult::NeedPoint + } + ArcStep::End3 { s, m } => match arc_through_points(s, m, pt, self.plane) { + Some((c, r, sa, ea)) => arc_result(c, r, sa, ea, self.plane), + None => CmdResult::NeedPoint, + }, + ArcStep::CenterFirst => { + self.step = ArcStep::StartAfterCenter { c: pt }; + CmdResult::NeedPoint + } + ArcStep::StartAfterCenter { c } => { + self.step = ArcStep::EndSC { s: pt, c }; + CmdResult::NeedPoint + } + ArcStep::CenterAfterStart { s } => { + self.step = ArcStep::EndSC { s, c: pt }; + CmdResult::NeedPoint + } + ArcStep::EndSC { s, c } => self.commit(self.sc_end(s, c, pt)), + // At a typed prompt a pick reads as commercial solutions do: the cursor's + // direction from the centre (angle) or its distance (length, + // radius), the cursor itself for a direction / sagitta. + ArcStep::AngleSC { s, c } => { + let span = self.cursor_span(s, c, pt); + self.commit(self.sc_angle(s, c, span)) + } + ArcStep::LengthSC { s, c } => self.commit(self.sc_length(s, c, s.distance(pt))), + ArcStep::EndAfterStart { s } => { + self.step = ArcStep::CenterSE { s, e: pt }; + CmdResult::NeedPoint + } + ArcStep::CenterSE { s, e } => self.commit(self.sc_end(s, pt, e)), + ArcStep::AngleSE { s, e } => { + self.commit(arc_from_sagitta(s, e, pt, self.cw, self.plane)) + } + ArcStep::DirectionSE { s, e } => { + self.commit(arc_continue(s, pt - s, e, self.cw, self.plane)) + } + ArcStep::RadiusSE { s, e } => { + self.commit(arc_from_se_radius(s, e, pt, self.cw, self.plane)) + } + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let upper = text.trim().to_uppercase(); + match self.step { + ArcStep::Start => match upper.as_str() { + "C" | "CE" | "CENTER" | "CENTRE" => { + self.step = ArcStep::CenterFirst; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + ArcStep::Second { s } => match upper.as_str() { + "C" | "CE" | "CENTER" | "CENTRE" => { + self.step = ArcStep::CenterAfterStart { s }; + Some(CmdResult::NeedPoint) + } + "E" | "END" => { + self.step = ArcStep::EndAfterStart { s }; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + ArcStep::EndSC { s, c } => match upper.as_str() { + "A" | "ANGLE" => { + self.step = ArcStep::AngleSC { s, c }; + Some(CmdResult::NeedPoint) + } + "L" | "LENGTH" => { + self.step = ArcStep::LengthSC { s, c }; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + ArcStep::CenterSE { s, e } => match upper.as_str() { + "A" | "ANGLE" => { + self.step = ArcStep::AngleSE { s, e }; + Some(CmdResult::NeedPoint) + } + "D" | "DIRECTION" => { + self.step = ArcStep::DirectionSE { s, e }; + Some(CmdResult::NeedPoint) + } + "R" | "RADIUS" => { + self.step = ArcStep::RadiusSE { s, e }; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + ArcStep::AngleSC { s, c } => { + let mut span = Self::typed_angle(text)?; + if self.cw { + span = -span; + } + Some(self.commit(self.sc_angle(s, c, span))) + } + ArcStep::LengthSC { s, c } => { + let len = Self::typed_number(text)?; + Some(self.commit(self.sc_length(s, c, len))) + } + ArcStep::AngleSE { s, e } => { + let mut included = Self::typed_angle(text)?; + if self.cw { + included = -included; + } + Some(self.commit(arc_from_endpoints_angle(s, e, included, self.plane))) + } + ArcStep::DirectionSE { s, e } => { + let angle = Self::typed_angle(text)?; + let tangent = self.plane.x * angle.cos() + self.plane.y * angle.sin(); + Some(self.commit(arc_continue(s, tangent, e, self.cw, self.plane))) + } + ArcStep::RadiusSE { s, e } => { + let r = Self::typed_number(text)?; + Some(self.commit(arc_from_endpoints_radius(s, e, r, self.cw, self.plane))) + } + _ => None, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + // Enter at the start prompt: continue tangent from the last + // line or arc, as commercial solutions do. + ArcStep::Start => CmdResult::Dispatch("ARC_CONT".into()), + _ => CmdResult::Cancel, + } + } + fn enter_accepts_default_start(&self) -> bool { + false + } + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn dyn_field(&self) -> crate::command::DynField { + use crate::command::DynField; + match self.step { + ArcStep::AngleSC { .. } | ArcStep::AngleSE { .. } | ArcStep::DirectionSE { .. } => { + DynField::Angle + } + ArcStep::LengthSC { .. } | ArcStep::RadiusSE { .. } => DynField::Distance, + _ => DynField::Point, + } + } + + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + match self.step { + ArcStep::AngleSC { s, c } => Some(DynSpec { + anchor: DynAnchor::Point(c), + fields: vec![DynFieldSpec::new(DynRole::Angle)], + guide: DynGuide::Polar, + ref_point: Some(s), + }), + ArcStep::LengthSC { s, .. } => Some(DynSpec { + anchor: DynAnchor::Point(s), + fields: vec![DynFieldSpec::new(DynRole::Distance)], + guide: DynGuide::Radius, + ref_point: None, + }), + ArcStep::AngleSE { s, e } => Some(DynSpec { + anchor: DynAnchor::Point((s + e) * 0.5), + fields: vec![DynFieldSpec::new(DynRole::Angle)], + guide: DynGuide::None, + ref_point: Some(s), + }), + ArcStep::DirectionSE { s, .. } => Some(DynSpec { + anchor: DynAnchor::Point(s), + fields: vec![DynFieldSpec::new(DynRole::Angle)], + guide: DynGuide::Polar, + ref_point: Some(s + self.plane.x), + }), + ArcStep::RadiusSE { s, .. } => Some(DynSpec { + anchor: DynAnchor::Point(s), + fields: vec![DynFieldSpec::new(DynRole::Radius)], + guide: DynGuide::None, + ref_point: None, + }), + _ => None, + } + } + + fn dyn_commit_as_text(&self) -> bool { + matches!( + self.step, + ArcStep::AngleSC { .. } + | ArcStep::LengthSC { .. } + | ArcStep::AngleSE { .. } + | ArcStep::DirectionSE { .. } + | ArcStep::RadiusSE { .. } + ) + } + + fn dyn_live_value(&self, cursor: DVec3) -> Option { + match self.step { + ArcStep::AngleSC { s, c } => Some(crate::command::dyn_display_angle_deg( + (angle_xy(c, cursor, self.plane) - angle_xy(c, s, self.plane)) as f32, + ) as f64), + ArcStep::LengthSC { s, .. } => Some(s.distance(cursor)), + ArcStep::RadiusSE { s, e } => { + arc_from_se_radius(s, e, cursor, self.cw, self.plane).map(|(_, r, _, _)| r) + } + _ => None, + } + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + match self.step { + ArcStep::Start | ArcStep::CenterFirst => None, + ArcStep::Second { s } | ArcStep::EndAfterStart { s } | ArcStep::CenterAfterStart { s } => { + Some(line_wire(s, pt)) + } + ArcStep::StartAfterCenter { c } => Some(line_wire(c, pt)), + ArcStep::End3 { s, m } => { + let fallback = WireModel::solid_f64( + "rubber_band".into(), + vec![[s.x, s.y, s.z], [m.x, m.y, m.z], [pt.x, pt.y, pt.z]], + WireModel::CYAN, + false, + ); + self.preview(arc_through_points(s, m, pt, self.plane), fallback) + } + ArcStep::EndSC { s, c } => self.preview(self.sc_end(s, c, pt), line_wire(c, pt)), + ArcStep::AngleSC { s, c } => { + let span = self.cursor_span(s, c, pt); + self.preview(self.sc_angle(s, c, span), line_wire(c, pt)) + } + ArcStep::LengthSC { s, c } => { + self.preview(self.sc_length(s, c, s.distance(pt)), line_wire(s, pt)) + } + ArcStep::CenterSE { s, e } => self.preview(self.sc_end(s, pt, e), line_wire(s, e)), + ArcStep::AngleSE { s, e } => { + self.preview(arc_from_sagitta(s, e, pt, self.cw, self.plane), line_wire(s, e)) + } + ArcStep::DirectionSE { s, e } => { + self.preview(arc_continue(s, pt - s, e, self.cw, self.plane), line_wire(s, e)) + } + ArcStep::RadiusSE { s, e } => { + self.preview(arc_from_se_radius(s, e, pt, self.cw, self.plane), line_wire(s, e)) + } + } + } +} + // ── Command 2: 3-Point (ARC_3P) ────────────────────────────────────────── pub struct Arc3PCommand { @@ -677,11 +1114,11 @@ impl CadCommand for ArcSCACommand { } fn on_text_input(&mut self, text: &str) -> Option { if self.step == 2 { - let mut span: f64 = text.trim().replace(',', ".").parse().ok()?; + let mut span = crate::entities::common::parse_typed_angle(text)?; if self.cw { span = -span; } - let ea = self.sa + span.to_radians(); + let ea = self.sa + span; return Some(if span < 0.0 { arc_result(self.c, self.r, ea, self.sa, self.plane) } else { @@ -939,7 +1376,7 @@ impl CadCommand for ArcSEACommand { if self.step != 2 { return None; } - let mut included = text.trim().replace(',', ".").parse::().ok()?.to_radians(); + let mut included = crate::entities::common::parse_typed_angle(text)?; if self.ctrl { included = -included; } @@ -1166,7 +1603,7 @@ impl CadCommand for ArcSEDCommand { if self.step != 2 { return None; } - let angle = text.trim().replace(',', ".").parse::().ok()?.to_radians(); + let angle = crate::entities::common::parse_typed_angle(text)?; let tangent = self.plane.x * angle.cos() + self.plane.y * angle.sin(); let (center, radius, sa, ea) = arc_continue(self.s, tangent, self.e, self.ctrl, self.plane)?; @@ -1285,11 +1722,11 @@ impl CadCommand for ArcCSACommand { } fn on_text_input(&mut self, text: &str) -> Option { if self.step == 2 { - let mut span: f64 = text.trim().replace(',', ".").parse().ok()?; + let mut span = crate::entities::common::parse_typed_angle(text)?; if self.cw { span = -span; } - let ea = self.sa + span.to_radians(); + let ea = self.sa + span; return Some(if span < 0.0 { arc_result(self.c, self.r, ea, self.sa, self.plane) } else { @@ -1531,7 +1968,7 @@ impl CadCommand for ArcContCommand { inventory::submit!(crate::command::CommandRegistration { names: &["ARC", "ARC_3P"] }); // Arc3PCommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSA"] }); // ArcCSACommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSL"] }); // ArcCSLCommand -inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSE"] }); // ArcCommand +inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CSE"] }); // ArcCSECommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCA"] }); // ArcSCACommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCE"] }); // ArcSCECommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SCL"] }); // ArcSCLCommand @@ -1539,3 +1976,126 @@ inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SEA"] }); inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SED"] }); // ArcSEDCommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_SER"] }); // ArcSERCommand inventory::submit!(crate::command::CommandRegistration { names: &["ARC_CONT"] }); // ArcContCommand + +#[cfg(test)] +mod acad_flow_tests { + use super::*; + + fn keywords(cmd: &ArcCommand) -> Vec { + cmd.options().into_iter().map(|o| o.keyword).collect() + } + + fn p(x: f64, y: f64) -> DVec3 { + DVec3::new(x, y, 0.0) + } + + fn committed_arc(res: CmdResult) -> CadArc { + match res { + CmdResult::CommitAndExit(EntityType::Arc(a)) => a, + _ => panic!("expected a committed arc"), + } + } + + #[test] + fn prompts_follow_compatible_keywords() { + let mut cmd = ArcCommand::new(); + assert_eq!(keywords(&cmd), ["C"]); + cmd.on_point(p(0.0, 0.0)); + assert_eq!(keywords(&cmd), ["C", "E"]); + assert!(matches!(cmd.on_text_input("C"), Some(CmdResult::NeedPoint))); + assert!(keywords(&cmd).is_empty(), "centre pick has no keywords"); + cmd.on_point(p(5.0, 0.0)); + assert_eq!(keywords(&cmd), ["A", "L"]); + + let mut cmd = ArcCommand::new(); + cmd.on_point(p(0.0, 0.0)); + cmd.on_text_input("E"); + cmd.on_point(p(10.0, 0.0)); + assert_eq!(keywords(&cmd), ["A", "D", "R"]); + } + + #[test] + fn three_points_make_an_arc() { + let mut cmd = ArcCommand::new(); + cmd.on_point(p(0.0, 0.0)); + cmd.on_point(p(5.0, 5.0)); + let arc = committed_arc(cmd.on_point(p(10.0, 0.0))); + assert!((arc.radius - 5.0).abs() < 1e-9); + assert!((arc.center.x - 5.0).abs() < 1e-9 && arc.center.y.abs() < 1e-9); + } + + #[test] + fn center_first_then_start_and_end() { + let mut cmd = ArcCommand::new(); + cmd.on_text_input("C"); + cmd.on_point(p(5.0, 0.0)); + cmd.on_point(p(10.0, 0.0)); + assert_eq!(keywords(&cmd), ["A", "L"]); + let arc = committed_arc(cmd.on_point(p(5.0, 10.0))); + assert!((arc.radius - 5.0).abs() < 1e-9); + assert!(arc.start_angle.abs() < 1e-9); + assert!((arc.end_angle - std::f64::consts::FRAC_PI_2).abs() < 1e-9); + } + + #[test] + fn start_center_angle_and_chord_length_typed() { + let mut cmd = ArcCommand::new(); + cmd.on_point(p(10.0, 0.0)); + cmd.on_text_input("C"); + cmd.on_point(p(5.0, 0.0)); + cmd.on_text_input("A"); + let arc = committed_arc(cmd.on_text_input("90").expect("angle consumed")); + assert!((arc.end_angle - arc.start_angle - std::f64::consts::FRAC_PI_2).abs() < 1e-9); + + let mut cmd = ArcCommand::new(); + cmd.on_point(p(10.0, 0.0)); + cmd.on_text_input("C"); + cmd.on_point(p(5.0, 0.0)); + cmd.on_text_input("L"); + // Chord 10 on radius 5: a semicircle. + let arc = committed_arc(cmd.on_text_input("10").expect("length consumed")); + assert!((arc.end_angle - arc.start_angle - std::f64::consts::PI).abs() < 1e-9); + } + + #[test] + fn start_end_then_radius_direction_and_angle() { + let mut cmd = ArcCommand::new(); + cmd.on_point(p(0.0, 0.0)); + cmd.on_text_input("E"); + cmd.on_point(p(10.0, 0.0)); + cmd.on_text_input("R"); + let arc = committed_arc(cmd.on_text_input("5").expect("radius consumed")); + assert!((arc.radius - 5.0).abs() < 1e-9); + + let mut cmd = ArcCommand::new(); + cmd.on_point(p(0.0, 0.0)); + cmd.on_text_input("E"); + cmd.on_point(p(10.0, 0.0)); + cmd.on_text_input("D"); + // Tangent straight up at the start: a semicircle over the chord. + let arc = committed_arc(cmd.on_text_input("90").expect("direction consumed")); + assert!((arc.radius - 5.0).abs() < 1e-6); + + let mut cmd = ArcCommand::new(); + cmd.on_point(p(0.0, 0.0)); + cmd.on_text_input("E"); + cmd.on_point(p(10.0, 0.0)); + cmd.on_text_input("A"); + let arc = committed_arc(cmd.on_text_input("180").expect("angle consumed")); + assert!((arc.radius - 5.0).abs() < 1e-6); + + // Centre pick after start / end. + let mut cmd = ArcCommand::new(); + cmd.on_point(p(0.0, 0.0)); + cmd.on_text_input("E"); + cmd.on_point(p(10.0, 0.0)); + let arc = committed_arc(cmd.on_point(p(5.0, 0.0))); + assert!((arc.radius - 5.0).abs() < 1e-9); + } + + #[test] + fn enter_at_start_continues_from_last_entity() { + let mut cmd = ArcCommand::new(); + assert!(matches!(cmd.on_enter(), CmdResult::Dispatch(c) if c == "ARC_CONT")); + } +} diff --git a/src/modules/draw/draw/ellipse.rs b/src/modules/draw/draw/ellipse.rs index db8af61c4..dbd2ebeba 100644 --- a/src/modules/draw/draw/ellipse.rs +++ b/src/modules/draw/draw/ellipse.rs @@ -527,7 +527,15 @@ impl CadCommand for EllipseArcCommand { } fn on_text_input(&mut self, text: &str) -> Option { - let val = parse_num(text)?; + let angle_step = matches!( + &self.step, + ArcStep::StartAngle { .. } | ArcStep::EndAngle { .. } + ); + let val = if angle_step { + crate::entities::common::parse_typed_angle(text)? + } else { + parse_num(text)? + }; match &self.step { ArcStep::MinorRatio { center, major } => { if val > 0.0 { @@ -546,7 +554,7 @@ impl CadCommand for EllipseArcCommand { major, ratio, } => { - let t_start = val.to_radians(); + let t_start = val; let (c, m, r) = (*center, *major, *ratio); self.prev_pt = None; self.step = ArcStep::EndAngle { @@ -563,8 +571,8 @@ impl CadCommand for EllipseArcCommand { ratio, t_start, } => { - // Typed degrees: positive = CCW, negative = CW. - let t_end = val.to_radians(); + // Positive is CCW; negative is CW. + let t_end = val; return Some(CmdResult::CommitAndExit(EntityType::Ellipse(make_ellipse( *center, *major, *ratio, *t_start, t_end, self.plane, )))); diff --git a/src/modules/draw/draw/line.rs b/src/modules/draw/draw/line.rs index 09615df52..cc809dc57 100644 --- a/src/modules/draw/draw/line.rs +++ b/src/modules/draw/draw/line.rs @@ -71,6 +71,21 @@ impl CadCommand for LineCommand { } } + fn options(&self) -> Vec { + use crate::command::CmdOption; + // Covers the LINE prompt of commercial solutions: Undo once a point is placed, Close + // once a closing segment is possible, eXit always. + match self.points.len() { + 0 => Vec::new(), + 1 => vec![CmdOption::new("Undo", "U"), CmdOption::new("eXit", "X")], + _ => vec![ + CmdOption::new("Close", "C"), + CmdOption::new("Undo", "U"), + CmdOption::new("eXit", "X"), + ], + } + } + fn on_point(&mut self, pt: DVec3) -> CmdResult { if let Some(&last) = self.points.last() { let line = Self::line_between(last, pt); @@ -185,6 +200,9 @@ impl CadCommand for LineCommand { Some(CmdResult::NeedPoint) } } + // eXit: end the command keeping every segment drawn so far + // (the third LINE keyword of commercial solutions). + "X" | "EXIT" => Some(CmdResult::Cancel), "U" | "UNDO" => { if self.points.len() >= 2 { // Drop the last vertex and revert its committed segment. diff --git a/src/modules/draw/draw/poly3d.rs b/src/modules/draw/draw/poly3d.rs index 773a53ce8..58f5cfe90 100644 --- a/src/modules/draw/draw/poly3d.rs +++ b/src/modules/draw/draw/poly3d.rs @@ -89,6 +89,18 @@ impl CadCommand for Poly3dCommand { !self.points.is_empty() } + fn options(&self) -> Vec { + use crate::command::CmdOption; + let mut opts = Vec::new(); + if self.points.len() >= 3 { + opts.push(CmdOption::new("Close", "C")); + } + if !self.points.is_empty() { + opts.push(CmdOption::new("Undo", "U")); + } + opts + } + fn on_text_input(&mut self, text: &str) -> Option { match text.trim().to_uppercase().as_str() { "C" | "CLOSE" if self.points.len() >= 3 => self diff --git a/src/modules/draw/draw/polyline.rs b/src/modules/draw/draw/polyline.rs index 852c42400..c195fc3a6 100644 --- a/src/modules/draw/draw/polyline.rs +++ b/src/modules/draw/draw/polyline.rs @@ -1,13 +1,17 @@ // Polyline tool — ribbon definition + interactive command. // // Command: PLINE (PL) -// Each click adds a vertex. -// Type A = switch to Arc segment mode. -// Type L = switch back to Line segment mode. -// Enter / C = close and commit. Escape = commit as-is (if ≥2 vertices). +// Each click adds a vertex. Keywords match commercial solutions: +// Line mode: Arc / Close / Halfwidth / Length / Undo / Width +// Arc mode: Angle / CEnter / CLose / Direction / Halfwidth / Line / +// Radius / Second pt / Undo / Width +// Enter = finish, Escape = finish as-is (a keyword sub-prompt is left +// first), C / CL = close and finish. // -// Arc mode: arcs are tangent-continuous with the preceding segment. +// Arc mode: arcs are tangent-continuous with the preceding segment unless a +// keyword (Angle / CEnter / Direction / Radius / Second pt) defines them. // Bulge is stored per vertex (segment i→i+1); positive = CCW, negative = CW. +// Widths are stored per segment as (start, end). use acadrust::entities::LwVertex; use acadrust::types::Vector2; @@ -38,13 +42,82 @@ enum SegMode { Arc, } +/// A keyword sub-step inside PLINE (the option prompts of commercial solutions). `None` is the +/// regular "specify next point" step for the current segment mode. Every +/// sub-step returns to `None` once it has produced a vertex, or when Undo / +/// Enter / Escape backs out of it. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Sub { + None, + /// Width / Halfwidth: starting width, then ending width. `half` scales the + /// prompt and the typed value by two. + WidthStart { half: bool }, + WidthEnd { half: bool, start: f64 }, + /// Length: a line segment of the typed length along the last tangent. + Length, + /// Arc → Angle: typed included angle, then an endpoint [CEnter/Radius]. + ArcAngle, + ArcAngleEnd { angle: f64 }, + ArcAngleCenter { angle: f64 }, + ArcAngleRadius { angle: f64 }, + ArcAngleRadiusDir { angle: f64, r: f64 }, + /// Arc → CEnter: centre pick, then an endpoint [Angle/Length]. + ArcCenter, + ArcCenterEnd { c: DVec2 }, + ArcCenterAngle { c: DVec2 }, + ArcCenterLength { c: DVec2 }, + /// Arc → Direction: a tangent-direction pick, then the endpoint. + ArcDirection, + ArcDirectionEnd { dir: DVec2 }, + /// Arc → Radius: typed radius, then an endpoint [Angle]. + ArcRadius, + ArcRadiusEnd { r: f64 }, + ArcRadiusAngle { r: f64 }, + ArcRadiusAngleDir { r: f64, angle: f64 }, + /// Arc → Second pt: a point on the arc, then the endpoint (3-point arc). + ArcSecond, + ArcSecondEnd { s: DVec2 }, +} + +impl Sub { + /// The step reads a single typed value (width, length, angle, radius). + fn is_scalar(self) -> bool { + matches!( + self, + Sub::WidthStart { .. } + | Sub::WidthEnd { .. } + | Sub::Length + | Sub::ArcAngle + | Sub::ArcAngleRadius { .. } + | Sub::ArcCenterAngle { .. } + | Sub::ArcCenterLength { .. } + | Sub::ArcRadius + | Sub::ArcRadiusAngle { .. } + ) + } + + /// The typed value is an angle (degrees) rather than a length. + fn is_angle(self) -> bool { + matches!( + self, + Sub::ArcAngle | Sub::ArcCenterAngle { .. } | Sub::ArcRadiusAngle { .. } + ) + } +} + // ── Command implementation ───────────────────────────────────────────────── pub struct PlineCommand { vertices: Vec, /// Bulge for segment i → i+1 (one entry per vertex; last entry unused on commit). bulges: Vec, + /// `(start, end)` width for segment i → i+1, parallel to `bulges`. + widths: Vec<(f64, f64)>, + /// Width pair applied to the next segment. After a segment lands its end + /// width becomes the next start width, as in commercial solutions. + cur_width: (f64, f64), mode: SegMode, + sub: Sub, /// Unit direction of the last committed segment (for arc tangent continuity). last_tangent: Option, /// Handle of the live polyline entity once it exists (created at the 2nd @@ -57,10 +130,14 @@ pub struct PlineCommand { impl PlineCommand { pub fn new() -> Self { + let w = super::super::defaults::get_pline_width(); Self { vertices: Vec::new(), bulges: Vec::new(), + widths: Vec::new(), + cur_width: (w, w), mode: SegMode::Line, + sub: Sub::None, last_tangent: None, live_handle: None, plane: WorkingPlane::default(), @@ -76,6 +153,10 @@ impl PlineCommand { } self.vertices.pop(); self.bulges.pop(); + if let Some((start, _)) = self.widths.pop() { + // The undone segment's start width is the current one again. + self.cur_width = (start, self.cur_width.1); + } // Restore the exit tangent of the segment that is now last so a // following Arc segment stays tangent-continuous. let n = self.vertices.len(); @@ -133,6 +214,9 @@ impl PlineCommand { .map(|(i, v)| { let mut lv = LwVertex::new(Vector2::new(v.x, v.y)); lv.bulge = self.bulges.get(i).copied().unwrap_or(0.0); + let (sw, ew) = self.widths.get(i).copied().unwrap_or((0.0, 0.0)); + lv.start_width = sw; + lv.end_width = ew; lv }) .collect(); @@ -144,6 +228,356 @@ impl PlineCommand { }; Some(self.plane.place_entity(EntityType::LwPolyline(pline))) } + + // ── Sub-step geometry (all in working-plane local 2-D) ───────────────── + + /// Last vertex in local 2-D plus its Z (every vertex shares the plane). + fn last_local(&self) -> Option<(DVec2, f64)> { + let last = self.plane.to_local(*self.vertices.last()?); + Some((DVec2::new(last.x, last.y), last.z)) + } + + fn local2(&self, pt: DVec3) -> DVec2 { + let p = self.plane.to_local(pt); + DVec2::new(p.x, p.y) + } + + fn tangent2(&self) -> DVec2 { + self.last_tangent + .map(|t| t.as_dvec2()) + .filter(|t| t.length_squared() > 1e-12) + .unwrap_or(DVec2::X) + } + + /// Sign for an arc whose direction is not fixed by its definition + /// (CEnter / Radius endpoints, chord length): bend the way the previous + /// segment was heading so the polyline flows on, else counter-clockwise. + fn free_arc_sign(&self, a: DVec2, e: DVec2) -> f64 { + let chord = e - a; + let cross = match self.last_tangent { + Some(t) => t.as_dvec2().perp_dot(chord), + None => 1.0, + }; + if cross < 0.0 { + -1.0 + } else { + 1.0 + } + } + + /// Bulge of the arc from `a` to `e` through `s` (3-point arc). `None` when + /// the points are collinear or coincident. + fn bulge_through(a: DVec2, s: DVec2, e: DVec2) -> Option { + let d = 2.0 * (a.x * (s.y - e.y) + s.x * (e.y - a.y) + e.x * (a.y - s.y)); + if d.abs() < 1e-12 { + return None; + } + let a2 = a.length_squared(); + let s2 = s.length_squared(); + let e2 = e.length_squared(); + let cx = (a2 * (s.y - e.y) + s2 * (e.y - a.y) + e2 * (a.y - s.y)) / d; + let cy = (a2 * (e.x - s.x) + s2 * (a.x - e.x) + e2 * (s.x - a.x)) / d; + let c = DVec2::new(cx, cy); + let start = (a - c).y.atan2((a - c).x); + let end = (e - c).y.atan2((e - c).x); + // The arc bends towards `s`: with `s` right of the chord a → e the + // arc runs counter-clockwise, left of it clockwise. + let ccw = (e - a).perp_dot(s - a) < 0.0; + let mut sweep = end - start; + if ccw { + if sweep <= 0.0 { + sweep += std::f64::consts::TAU; + } + } else if sweep >= 0.0 { + sweep -= std::f64::consts::TAU; + } + Some((sweep / 4.0).tan()) + } + + /// Endpoint of the arc about `c` starting at `a` with signed `sweep`. + fn rotate_about(c: DVec2, a: DVec2, sweep: f64) -> DVec2 { + let (sn, cs) = sweep.sin_cos(); + let v = a - c; + c + DVec2::new(v.x * cs - v.y * sn, v.x * sn + v.y * cs) + } + + /// Resolve the arc a sub-step describes for a candidate `cursor`/point: + /// `(endpoint, bulge)`. `None` when the geometry is degenerate for that + /// input (e.g. a chord longer than the diameter). + fn arc_for(&self, sub: Sub, a: DVec2, p: DVec2) -> Option<(DVec2, f64)> { + let sweep_ok = |s: f64| s.abs() > 1e-9 && s.abs() < std::f64::consts::TAU; + match sub { + Sub::ArcAngleEnd { angle } if sweep_ok(angle) => { + if (p - a).length_squared() < 1e-12 { + return None; + } + Some((p, (angle / 4.0).tan())) + } + Sub::ArcAngleCenter { angle } if sweep_ok(angle) => { + if (a - p).length_squared() < 1e-12 { + return None; + } + Some((Self::rotate_about(p, a, angle), (angle / 4.0).tan())) + } + Sub::ArcAngleRadiusDir { angle, r } if sweep_ok(angle) && r > 0.0 => { + let dir = (p - a).try_normalize()?; + let chord = 2.0 * r * (angle.abs() * 0.5).sin(); + Some((a + dir * chord, (angle / 4.0).tan())) + } + Sub::ArcCenterEnd { c } => { + let r = (a - c).length(); + if r < 1e-9 { + return None; + } + let dir = (p - c).try_normalize()?; + let e = c + dir * r; + let start = (a - c).y.atan2((a - c).x); + let end = (e - c).y.atan2((e - c).x); + let sign = self.free_arc_sign(a, e); + let mut sweep = (end - start).rem_euclid(std::f64::consts::TAU); + if sign < 0.0 { + sweep -= std::f64::consts::TAU; + } + if !sweep_ok(sweep) { + return None; + } + Some((e, (sweep / 4.0).tan())) + } + Sub::ArcCenterAngle { c } => { + // `p` carries the typed angle in `x`. + let angle = p.x; + if (a - c).length_squared() < 1e-12 || !sweep_ok(angle) { + return None; + } + Some((Self::rotate_about(c, a, angle), (angle / 4.0).tan())) + } + Sub::ArcCenterLength { c } => { + // `p` carries the typed chord length in `x`. + let len = p.x; + let r = (a - c).length(); + if r < 1e-9 || len <= 0.0 || len > 2.0 * r { + return None; + } + let sweep = 2.0 * (len / (2.0 * r)).asin(); + // Minor arc; bend on the side the previous segment favours. + let e_ccw = Self::rotate_about(c, a, sweep); + let sign = self.free_arc_sign(a, e_ccw); + let sweep = sweep * sign; + Some((Self::rotate_about(c, a, sweep), (sweep / 4.0).tan())) + } + Sub::ArcDirectionEnd { dir } => { + if (p - a).length_squared() < 1e-12 { + return None; + } + Some((p, compute_bulge(a, dir, p))) + } + Sub::ArcRadiusEnd { r } if r > 0.0 => { + let chord = (p - a).length(); + if chord < 1e-9 || chord > 2.0 * r { + return None; + } + let sweep = 2.0 * (chord / (2.0 * r)).asin() * self.free_arc_sign(a, p); + Some((p, (sweep / 4.0).tan())) + } + Sub::ArcRadiusAngleDir { r, angle } if r > 0.0 && sweep_ok(angle) => { + let dir = (p - a).try_normalize()?; + let chord = 2.0 * r * (angle.abs() * 0.5).sin(); + Some((a + dir * chord, (angle / 4.0).tan())) + } + Sub::ArcSecondEnd { s } => { + if (p - a).length_squared() < 1e-12 { + return None; + } + Some((p, Self::bulge_through(a, s, p)?)) + } + _ => None, + } + } + + /// Land a segment ending at local `e` with `bulge`, publish it, and + /// return to the regular next-point step. + fn push_segment(&mut self, e: DVec2, z: f64, bulge: f64) -> CmdResult { + let last_idx = self.vertices.len() - 1; + let a = self.local2(self.vertices[last_idx]); + self.bulges[last_idx] = bulge; + self.last_tangent = seg_exit_tangent( + DVec3::new(a.x, a.y, z), + DVec3::new(e.x, e.y, z), + bulge, + ); + self.sub = Sub::None; + self.push_vertex(self.plane.to_world(DVec3::new(e.x, e.y, z))) + } + + /// Append a vertex (bulge/width of the segment *into* it are already set + /// on the previous entry) and publish. + fn push_vertex(&mut self, pt: DVec3) -> CmdResult { + if !self.vertices.is_empty() { + let last = self.widths.len() - 1; + self.widths[last] = self.cur_width; + // The end width flows into the next segment's start width. + self.cur_width.0 = self.cur_width.1; + } + self.vertices.push(pt); + self.bulges.push(0.0); + self.widths.push(self.cur_width); + // Publish to the document as soon as the polyline has a segment so it + // becomes snappable while the rest is drawn. (#119) + if self.vertices.len() >= 2 { + self.sync_live(false, false) + } else { + CmdResult::NeedPoint + } + } + + /// A typed value for a scalar sub-step. Widths are lengths ≥ 0; the angle + /// steps read degrees (positive = counter-clockwise); Length may be + /// negative (draws backwards). + fn on_scalar(&mut self, value: f64) -> Option { + if !value.is_finite() { + return None; + } + let (a, z) = self.last_local()?; + match self.sub { + Sub::WidthStart { half } => { + if value < 0.0 { + return None; + } + let start = if half { value * 2.0 } else { value }; + self.sub = Sub::WidthEnd { half, start }; + Some(CmdResult::NeedPoint) + } + Sub::WidthEnd { half, start } => { + if value < 0.0 { + return None; + } + let end = if half { value * 2.0 } else { value }; + self.cur_width = (start, end); + super::super::defaults::set_pline_width(end); + self.sub = Sub::None; + Some(CmdResult::NeedPoint) + } + Sub::Length => { + if value.abs() < 1e-9 { + return None; + } + let e = a + self.tangent2() * value; + Some(self.push_segment(e, z, 0.0)) + } + Sub::ArcAngle => { + let angle = value.to_radians(); + if angle.abs() < 1e-9 || angle.abs() >= std::f64::consts::TAU { + return None; + } + self.sub = Sub::ArcAngleEnd { angle }; + Some(CmdResult::NeedPoint) + } + Sub::ArcAngleRadius { angle } => { + if value <= 0.0 { + return None; + } + self.sub = Sub::ArcAngleRadiusDir { angle, r: value }; + Some(CmdResult::NeedPoint) + } + Sub::ArcCenterAngle { c } => { + let (e, bulge) = + self.arc_for(Sub::ArcCenterAngle { c }, a, DVec2::new(value.to_radians(), 0.0))?; + Some(self.push_segment(e, z, bulge)) + } + Sub::ArcCenterLength { c } => { + let (e, bulge) = self.arc_for(Sub::ArcCenterLength { c }, a, DVec2::new(value, 0.0))?; + Some(self.push_segment(e, z, bulge)) + } + Sub::ArcRadius => { + if value <= 0.0 { + return None; + } + self.sub = Sub::ArcRadiusEnd { r: value }; + Some(CmdResult::NeedPoint) + } + Sub::ArcRadiusAngle { r } => { + let angle = value.to_radians(); + if angle.abs() < 1e-9 || angle.abs() >= std::f64::consts::TAU { + return None; + } + self.sub = Sub::ArcRadiusAngleDir { r, angle }; + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + + /// Leave the current sub-step without placing anything. + fn leave_sub(&mut self) -> CmdResult { + self.sub = Sub::None; + CmdResult::NeedPoint + } + + /// Rubber-band wire for the arc a → e with `bulge` (falls back to a line + /// for a degenerate bulge). + fn arc_wire(&self, a: DVec2, e: DVec2, z: f64, bulge: f64) -> WireModel { + let last = Vec3::new(a.x as f32, a.y as f32, z as f32); + let pt = Vec3::new(e.x as f32, e.y as f32, z as f32); + let mut pts: Vec<[f32; 3]> = Vec::new(); + let mut tangent_geom = None; + if bulge.abs() >= 1e-9 && (e - a).length_squared() >= 1e-12 { + if let Some(arc) = + crate::entities::common::BulgeArc::from_bulge([a.x, a.y], [e.x, e.y], bulge) + { + for s in arc.tessellate_angle(std::f64::consts::TAU / 64.0) { + pts.push([s[0] as f32, s[1] as f32, last.z]); + } + let (sa, ea) = if arc.sweep >= 0.0 { + (arc.start_angle, arc.end_angle) + } else { + (arc.end_angle, arc.start_angle) + }; + let world_center = self + .plane + .to_world(DVec3::new(arc.center[0], arc.center[1], z)); + if arc.radius > 0.0 + && arc.radius.is_finite() + && arc.radius <= 1e6 + && sa.is_finite() + && ea.is_finite() + { + tangent_geom = Some(TangentGeom::Arc { + center: world_center.to_array(), + axis_x: self.plane.x.to_array(), + axis_y: self.plane.y.to_array(), + radius: arc.radius, + start_angle: sa, + end_angle: ea, + }); + } + } else { + pts.extend_from_slice(&arc_sample_points(last, bulge, pt, 64)); + } + } else { + pts.push([last.x, last.y, last.z]); + pts.push([pt.x, pt.y, pt.z]); + } + let world_points = pts + .into_iter() + .map(|point| self.plane.to_world(Vec3::from_array(point).as_dvec3()).as_vec3().to_array()) + .collect(); + let mut wire = WireModel::solid("rubber_band".into(), world_points, WireModel::CYAN, false); + if let Some(tg) = tangent_geom { + wire.tangent_geoms.push(tg); + } + wire + } + + fn line_wire(&self, a: DVec2, e: DVec2, z: f64) -> WireModel { + let a = self.plane.to_world(DVec3::new(a.x, a.y, z)).as_vec3(); + let e = self.plane.to_world(DVec3::new(e.x, e.y, z)).as_vec3(); + WireModel::solid( + "rubber_band".into(), + vec![a.to_array(), e.to_array()], + WireModel::CYAN, + false, + ) + } } // ── Arc geometry helpers ─────────────────────────────────────────────────── @@ -260,6 +694,37 @@ pub(crate) fn arc_sample_points(a: Vec3, bulge: f64, b: Vec3, n: usize) -> Vec<[ // ── CadCommand impl ──────────────────────────────────────────────────────── +impl PlineCommand { + /// Close the polyline: in Arc mode the closing segment is an arc tangent + /// to the last segment (CLose in commercial solutions), in Line mode a straight one. + fn close(&mut self) -> CmdResult { + if self.vertices.len() < 2 { + return CmdResult::NeedPoint; + } + let last_idx = self.vertices.len() - 1; + let a = self.local2(self.vertices[last_idx]); + let first = self.local2(self.vertices[0]); + self.bulges[last_idx] = match self.mode { + SegMode::Line => 0.0, + SegMode::Arc => compute_bulge(a, self.tangent2(), first), + }; + self.widths[last_idx] = self.cur_width; + self.sub = Sub::None; + self.sync_live(true, true) + } + + fn width_prompt(&self, half: bool, ending: bool, default: f64) -> String { + let shown = crate::entities::common::format_length(if half { default * 0.5 } else { default }); + match (half, ending) { + (false, false) => t!("PLINE Specify starting width <%{w}>:", w = shown), + (false, true) => t!("PLINE Specify ending width <%{w}>:", w = shown), + (true, false) => t!("PLINE Specify starting half-width <%{w}>:", w = shown), + (true, true) => t!("PLINE Specify ending half-width <%{w}>:", w = shown), + } + .into_owned() + } +} + impl CadCommand for PlineCommand { fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; @@ -270,70 +735,176 @@ impl CadCommand for PlineCommand { } fn prompt(&self) -> String { - let mode_tag = match self.mode { - SegMode::Line => t!("Line"), - SegMode::Arc => t!("Arc"), - }; if self.vertices.is_empty() { - t!("PLINE Specify start point:").into_owned() - } else { - t!( - "PLINE [%{mode}] Next pt [%{count}pts]:", - mode = mode_tag, - count = self.vertices.len() - ) - .into_owned() + return t!("PLINE Specify start point:").into_owned(); + } + match self.sub { + Sub::None => match self.mode { + SegMode::Line => t!("PLINE (Line) Specify next point:").into_owned(), + SegMode::Arc => t!("PLINE (Arc) Specify endpoint of arc:").into_owned(), + }, + Sub::WidthStart { half } => self.width_prompt(half, false, self.cur_width.0), + Sub::WidthEnd { half, start } => self.width_prompt(half, true, start), + Sub::Length => t!("PLINE Specify length of line:").into_owned(), + Sub::ArcAngle | Sub::ArcCenterAngle { .. } | Sub::ArcRadiusAngle { .. } => { + t!("PLINE Specify included angle:").into_owned() + } + Sub::ArcAngleEnd { .. } + | Sub::ArcCenterEnd { .. } + | Sub::ArcDirectionEnd { .. } + | Sub::ArcRadiusEnd { .. } + | Sub::ArcSecondEnd { .. } => t!("PLINE Specify endpoint of arc:").into_owned(), + Sub::ArcAngleCenter { .. } | Sub::ArcCenter => { + t!("PLINE Specify center point of arc:").into_owned() + } + Sub::ArcAngleRadius { .. } | Sub::ArcRadius => { + t!("PLINE Specify radius of arc:").into_owned() + } + Sub::ArcAngleRadiusDir { .. } | Sub::ArcRadiusAngleDir { .. } => { + t!("PLINE Specify direction of chord for arc:").into_owned() + } + Sub::ArcCenterLength { .. } => t!("PLINE Specify length of chord:").into_owned(), + Sub::ArcDirection => { + t!("PLINE Specify tangent direction for the start point of arc:").into_owned() + } + Sub::ArcSecond => t!("PLINE Specify second point on arc:").into_owned(), } } fn options(&self) -> Vec { use crate::command::CmdOption; - // Options appear once at least one vertex is placed: the segment step - // accepts A / L / C, and Enter finishes. if self.vertices.is_empty() { - Vec::new() - } else { - vec![ - CmdOption::new(t!("Arc").as_ref(), "A"), - CmdOption::new(t!("Line").as_ref(), "L"), - CmdOption::new(t!("Close").as_ref(), "C"), - CmdOption::new(t!("Undo").as_ref(), "U"), - CmdOption::enter(t!("Done").as_ref()), - ] + return Vec::new(); + } + let can_close = self.vertices.len() >= 3 + || (self.vertices.len() == 2 && matches!(self.mode, SegMode::Arc)); + match self.sub { + // The keyword sets of commercial solutions, in their order. + Sub::None => match self.mode { + SegMode::Line => { + let mut opts = vec![CmdOption::new("Arc", "A")]; + if can_close { + opts.push(CmdOption::new("Close", "C")); + } + opts.extend([ + CmdOption::new("Halfwidth", "H"), + CmdOption::new("Length", "L"), + CmdOption::new("Undo", "U"), + CmdOption::new("Width", "W"), + CmdOption::enter("Done"), + ]); + opts + } + SegMode::Arc => { + let mut opts = vec![ + CmdOption::new("Angle", "A"), + CmdOption::new("CEnter", "CE"), + ]; + if can_close { + opts.push(CmdOption::new("CLose", "CL")); + } + opts.extend([ + CmdOption::new("Direction", "D"), + CmdOption::new("Halfwidth", "H"), + CmdOption::new("Line", "L"), + CmdOption::new("Radius", "R"), + CmdOption::new("Second pt", "S"), + CmdOption::new("Undo", "U"), + CmdOption::new("Width", "W"), + CmdOption::enter("Done"), + ]); + opts + } + }, + Sub::WidthStart { half } => { + let d = if half { self.cur_width.0 * 0.5 } else { self.cur_width.0 }; + vec![CmdOption::enter(&format!("<{}>", crate::entities::common::format_length(d)))] + } + Sub::WidthEnd { half, start } => { + let d = if half { start * 0.5 } else { start }; + vec![CmdOption::enter(&format!("<{}>", crate::entities::common::format_length(d)))] + } + Sub::ArcAngleEnd { .. } => vec![ + CmdOption::new("CEnter", "CE"), + CmdOption::new("Radius", "R"), + ], + Sub::ArcCenterEnd { .. } => vec![ + CmdOption::new("Angle", "A"), + CmdOption::new("Length", "L"), + ], + Sub::ArcRadiusEnd { .. } => vec![CmdOption::new("Angle", "A")], + _ => Vec::new(), } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - if !self.vertices.is_empty() { - let last = *self.vertices.last().unwrap(); - let last_local = self.plane.to_local(last); - let pt_local = self.plane.to_local(pt); - let last_idx = self.vertices.len() - 1; - - let bulge = match self.mode { - SegMode::Line => { - let d = DVec2::new(pt_local.x - last_local.x, pt_local.y - last_local.y); - if d.length_squared() > 1e-10 { - // Direction only — f32 is sufficient for tangent continuity. - self.last_tangent = Some(d.normalize().as_vec2()); - } - 0.0 - } - SegMode::Arc => { - let a = DVec2::new(last_local.x, last_local.y); - let b = DVec2::new(pt_local.x, pt_local.y); - let tangent = self - .last_tangent - .map(|t| t.as_dvec2()) - // No previous tangent: default to pointing right (arbitrary). - .unwrap_or(DVec2::new(1.0, 0.0)); - let bulge = compute_bulge(a, tangent, b); - update_tangent_after_arc(&mut self.last_tangent, bulge); - bulge - } - }; - self.bulges[last_idx] = bulge; + if self.vertices.is_empty() { + return self.push_vertex(pt); } + let Some((a, z)) = self.last_local() else { + return CmdResult::NeedPoint; + }; + let p = self.local2(pt); + + match self.sub { + Sub::None => {} + // A picked point at a typed-value prompt reads as the distance + // (or angle) from the last vertex, as commercial solutions allow. + sub if sub.is_scalar() => { + let value = if sub.is_angle() { + (p - a).y.atan2((p - a).x).to_degrees() + } else { + (p - a).length() + }; + return self.on_scalar(value).unwrap_or(CmdResult::NeedPoint); + } + Sub::ArcCenter => { + if (p - a).length_squared() < 1e-12 { + return CmdResult::NeedPoint; + } + self.sub = Sub::ArcCenterEnd { c: p }; + return CmdResult::NeedPoint; + } + Sub::ArcDirection => { + let Some(dir) = (p - a).try_normalize() else { + return CmdResult::NeedPoint; + }; + self.sub = Sub::ArcDirectionEnd { dir }; + return CmdResult::NeedPoint; + } + Sub::ArcSecond => { + if (p - a).length_squared() < 1e-12 { + return CmdResult::NeedPoint; + } + self.sub = Sub::ArcSecondEnd { s: p }; + return CmdResult::NeedPoint; + } + sub => { + // Endpoint / centre / chord-direction pick that completes an arc. + return match self.arc_for(sub, a, p) { + Some((e, bulge)) => self.push_segment(e, z, bulge), + None => CmdResult::NeedPoint, + }; + } + } + + let last_idx = self.vertices.len() - 1; + let bulge = match self.mode { + SegMode::Line => { + let d = p - a; + if d.length_squared() > 1e-10 { + // Direction only — f32 is sufficient for tangent continuity. + self.last_tangent = Some(d.normalize().as_vec2()); + } + 0.0 + } + SegMode::Arc => { + let bulge = compute_bulge(a, self.tangent2(), p); + update_tangent_after_arc(&mut self.last_tangent, bulge); + bulge + } + }; + self.bulges[last_idx] = bulge; // A point landing back on the FIRST vertex (endpoint snap) closes the // polyline instead of stacking a duplicate vertex there — the segment @@ -345,19 +916,12 @@ impl CadCommand for PlineCommand { || (self.vertices.len() == 2 && matches!(self.mode, SegMode::Arc)); let d2 = (pt.x - first.x).powi(2) + (pt.y - first.y).powi(2); if enough && d2 < 1e-12 { + self.widths[last_idx] = self.cur_width; return self.sync_live(true, true); } } - self.vertices.push(pt); - self.bulges.push(0.0); - // Publish to the document as soon as the polyline has a segment so it - // becomes snappable while the rest is drawn. (#119) - if self.vertices.len() >= 2 { - self.sync_live(false, false) - } else { - CmdResult::NeedPoint - } + self.push_vertex(pt) } fn set_live_handle(&mut self, handle: Handle) { @@ -365,6 +929,22 @@ impl CadCommand for PlineCommand { } fn on_enter(&mut self) -> CmdResult { + match self.sub { + // Enter keeps the offered default width. + Sub::WidthStart { half } => { + let start = self.cur_width.0; + self.sub = Sub::WidthEnd { half, start }; + return CmdResult::NeedPoint; + } + Sub::WidthEnd { start, .. } => { + self.cur_width = (start, start); + super::super::defaults::set_pline_width(start); + return self.leave_sub(); + } + // Enter at any other sub-prompt backs out of it. + Sub::None => {} + _ => return self.leave_sub(), + } // Every landed vertex is already published into the live document // entity. Finishing must only close its history/command state; replacing // the identical polyline again would advance geometry_epoch and make @@ -380,6 +960,11 @@ impl CadCommand for PlineCommand { } fn on_escape(&mut self) -> CmdResult { + // Escape inside a keyword sub-prompt returns to the next-point step; + // a second Escape ends the command keeping what is drawn. + if self.sub != Sub::None { + return self.leave_sub(); + } match self.live_handle { Some(handle) => CmdResult::FinalizeLiveEntity(handle), None => CmdResult::Cancel, @@ -394,31 +979,148 @@ impl CadCommand for PlineCommand { } fn wants_text_input(&self) -> bool { - // Accept A / L / C once we have at least the first point. + // Keywords once the first point is placed; typed values in the + // scalar sub-steps. !self.vertices.is_empty() } fn point_step_accepts_keywords(&self) -> bool { - // Each segment is a point pick that also accepts A / L / C / U, so the - // polar dynamic-input distance/angle stays visible. - !self.vertices.is_empty() + // Each segment is a point pick that also accepts keywords, so the + // polar dynamic-input distance/angle stays visible. Scalar sub-steps + // are pure typed-value prompts. + !self.vertices.is_empty() && !self.sub.is_scalar() + } + + fn dyn_field(&self) -> crate::command::DynField { + use crate::command::DynField; + match self.sub { + Sub::WidthStart { .. } | Sub::WidthEnd { .. } => DynField::Scalar, + Sub::Length | Sub::ArcRadius | Sub::ArcAngleRadius { .. } | Sub::ArcCenterLength { .. } => { + DynField::Distance + } + sub if sub.is_angle() => DynField::Angle, + _ => DynField::Point, + } + } + + fn dyn_commit_as_text(&self) -> bool { + self.sub.is_scalar() + } + + fn dyn_live_value(&self, cursor: DVec3) -> Option { + let (a, _) = self.last_local()?; + let p = self.local2(cursor); + match self.sub { + // Length: the cursor's projection onto the continuation direction. + Sub::Length => Some((p - a).dot(self.tangent2())), + Sub::ArcRadius | Sub::ArcAngleRadius { .. } | Sub::ArcCenterLength { .. } => { + Some((p - a).length()) + } + _ => None, + } } fn on_text_input(&mut self, text: &str) -> Option { - match text.trim().to_uppercase().as_str() { - "A" | "ARC" => { - self.mode = SegMode::Arc; - Some(CmdResult::NeedPoint) + let upper = text.trim().to_uppercase(); + if self.vertices.is_empty() { + return None; + } + // Keywords valid at any step: Undo backs out of a sub-prompt first, + // then pops vertices. + match upper.as_str() { + "U" | "UNDO" => { + return Some(if self.sub != Sub::None { + self.leave_sub() + } else { + self.undo_last_vertex() + }); } - "L" | "LINE" => { - self.mode = SegMode::Line; - Some(CmdResult::NeedPoint) + _ => {} + } + match self.sub { + Sub::None => match (self.mode, upper.as_str()) { + (_, "W" | "WIDTH") => { + self.sub = Sub::WidthStart { half: false }; + Some(CmdResult::NeedPoint) + } + (_, "H" | "HALFWIDTH") => { + self.sub = Sub::WidthStart { half: true }; + Some(CmdResult::NeedPoint) + } + (SegMode::Line, "A" | "ARC") => { + self.mode = SegMode::Arc; + Some(CmdResult::NeedPoint) + } + (SegMode::Line, "C" | "CLOSE") => Some(self.close()), + (SegMode::Line, "L" | "LENGTH") => { + self.sub = Sub::Length; + Some(CmdResult::NeedPoint) + } + (SegMode::Arc, "L" | "LINE") => { + self.mode = SegMode::Line; + Some(CmdResult::NeedPoint) + } + (SegMode::Arc, "A" | "ANGLE") => { + self.sub = Sub::ArcAngle; + Some(CmdResult::NeedPoint) + } + (SegMode::Arc, "CE" | "CENTER" | "CENTRE") => { + self.sub = Sub::ArcCenter; + Some(CmdResult::NeedPoint) + } + // Commercial solutions want CL in arc mode; a bare C is accepted as well. + (SegMode::Arc, "CL" | "C" | "CLOSE") => Some(self.close()), + (SegMode::Arc, "D" | "DIRECTION") => { + self.sub = Sub::ArcDirection; + Some(CmdResult::NeedPoint) + } + (SegMode::Arc, "R" | "RADIUS") => { + self.sub = Sub::ArcRadius; + Some(CmdResult::NeedPoint) + } + (SegMode::Arc, "S" | "SECOND" | "SECONDPT") => { + self.sub = Sub::ArcSecond; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + Sub::ArcAngleEnd { angle } => match upper.as_str() { + "CE" | "CENTER" | "CENTRE" => { + self.sub = Sub::ArcAngleCenter { angle }; + Some(CmdResult::NeedPoint) + } + "R" | "RADIUS" => { + self.sub = Sub::ArcAngleRadius { angle }; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + Sub::ArcCenterEnd { c } => match upper.as_str() { + "A" | "ANGLE" => { + self.sub = Sub::ArcCenterAngle { c }; + Some(CmdResult::NeedPoint) + } + "L" | "LENGTH" => { + self.sub = Sub::ArcCenterLength { c }; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + Sub::ArcRadiusEnd { r } => match upper.as_str() { + "A" | "ANGLE" => { + self.sub = Sub::ArcRadiusAngle { r }; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + sub if sub.is_scalar() => { + let value = if sub.is_angle() { + crate::entities::common::parse_typed_angle(text.trim()).map(f64::to_degrees) + } else { + crate::entities::common::parse_typed_length(text.trim()) + }?; + self.on_scalar(value) } - "C" | "CLOSE" => Some(self.sync_live(true, true)), - // Undo: drop the last placed vertex but keep drawing (#352). - // Repeatable — each U backs off one more segment, down to (and - // including) the start point. - "U" | "UNDO" => Some(self.undo_last_vertex()), _ => None, } } @@ -428,94 +1130,49 @@ impl CadCommand for PlineCommand { // yet the document undo takes over. if self.vertices.is_empty() { None + } else if self.sub != Sub::None { + Some(self.leave_sub()) } else { Some(self.undo_last_vertex()) } } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let pt_world = pt; // The committed vertices already render as a real document entity, so // the preview is just the pending segment from the last vertex to the - // cursor. (#119) Downcast to f32 for the pixel-space rubber band. - let last_world = *self.vertices.last()?; - let last = self.plane.to_local(last_world).as_vec3(); - let pt = self.plane.to_local(pt_world).as_vec3(); - - let mut pts: Vec<[f32; 3]> = Vec::new(); - let mut tangent_geom = None; - match self.mode { - SegMode::Line => { - pts.push([last.x, last.y, last.z]); - pts.push([pt.x, pt.y, pt.z]); + // cursor. (#119) + let (a, z) = self.last_local()?; + let p = self.local2(pt); + match self.sub { + Sub::None => match self.mode { + SegMode::Line => Some(self.line_wire(a, p, z)), + SegMode::Arc => { + let bulge = compute_bulge(a, self.tangent2(), p); + Some(self.arc_wire(a, p, z, bulge)) + } + }, + // The continuation line, as far as the cursor projects onto it. + Sub::Length => { + let e = a + self.tangent2() * (p - a).dot(self.tangent2()); + Some(self.line_wire(a, e, z)) } - SegMode::Arc => { - let a = DVec2::new(last.x as f64, last.y as f64); - let b = DVec2::new(pt.x as f64, pt.y as f64); - let tangent = self - .last_tangent - .map(|t| t.as_dvec2()) - .unwrap_or(DVec2::new(1.0, 0.0)); - let bulge = compute_bulge(a, tangent, b); - if bulge.abs() >= 1e-9 && (b - a).length_squared() >= 1e-12 { - if let Some(arc) = - crate::entities::common::BulgeArc::from_bulge([a.x, a.y], [b.x, b.y], bulge) - { - for s in arc.tessellate_angle(std::f64::consts::TAU / 64.0) { - pts.push([s[0] as f32, s[1] as f32, last.z]); - } - let (sa, ea) = if arc.sweep >= 0.0 { - (arc.start_angle, arc.end_angle) - } else { - (arc.end_angle, arc.start_angle) - }; - let world_center = self - .plane - .to_world(DVec3::new(arc.center[0], arc.center[1], last.z as f64)); - if arc.radius > 0.0 - && arc.radius.is_finite() - && arc.radius <= 1e6 - && sa.is_finite() - && ea.is_finite() - { - tangent_geom = Some(TangentGeom::Arc { - center: world_center.to_array(), - axis_x: self.plane.x.to_array(), - axis_y: self.plane.y.to_array(), - radius: arc.radius, - start_angle: sa, - end_angle: ea, - }); - } - } else { - let arc_pts = arc_sample_points(last, bulge, pt, 64); - pts.extend_from_slice(&arc_pts); - } - } else { - pts.push([last.x, last.y, last.z]); - pts.push([pt.x, pt.y, pt.z]); + // Picks that only fix a centre / direction / second point show + // the radius / tangent / chord line to the cursor. + Sub::ArcCenter | Sub::ArcDirection | Sub::ArcSecond | Sub::ArcAngleCenter { .. } => { + match self.arc_for(self.sub, a, p) { + Some((e, bulge)) => Some(self.arc_wire(a, e, z, bulge)), + None => Some(self.line_wire(a, p, z)), } } + sub if sub.is_scalar() => None, + sub => match self.arc_for(sub, a, p) { + Some((e, bulge)) => Some(self.arc_wire(a, e, z, bulge)), + None => Some(self.line_wire(a, p, z)), + }, } - - let world_points = pts - .into_iter() - .map(|point| self.plane.to_world(Vec3::from_array(point).as_dvec3()).as_vec3().to_array()) - .collect(); - let mut wire = WireModel::solid( - "rubber_band".into(), - world_points, - WireModel::CYAN, - false, - ); - if let Some(tg) = tangent_geom { - wire.tangent_geoms.push(tg); - } - Some(wire) } } - // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["PLINE"] }); // PlineCommand @@ -590,6 +1247,209 @@ mod tests { )); } + fn pl(points: &[(f64, f64)]) -> PlineCommand { + let mut cmd = PlineCommand::new(); + for &(x, y) in points { + cmd.on_point(DVec3::new(x, y, 0.0)); + } + cmd.set_live_handle(Handle::new(1)); + cmd + } + + fn entity_of(res: CmdResult) -> LwPolyline { + match res { + CmdResult::UpdateLiveEntity { + entity: EntityType::LwPolyline(p), + .. + } + | CmdResult::CommitLiveEntity(EntityType::LwPolyline(p)) => p, + other => panic!("expected a polyline result, got {}", std::any::type_name_of_val(&other)), + } + } + + fn keywords(cmd: &PlineCommand) -> Vec { + cmd.options().into_iter().map(|o| o.keyword).collect() + } + + #[test] + fn options_per_mode_match_compatible_keywords() { + let mut cmd = pl(&[(0.0, 0.0), (10.0, 0.0)]); + assert_eq!(keywords(&cmd), ["A", "H", "L", "U", "W", ""]); + cmd.on_point(DVec3::new(10.0, 5.0, 0.0)); + assert_eq!(keywords(&cmd), ["A", "C", "H", "L", "U", "W", ""]); + cmd.on_text_input("A"); + assert_eq!( + keywords(&cmd), + ["A", "CE", "CL", "D", "H", "L", "R", "S", "U", "W", ""] + ); + cmd.on_text_input("A"); + assert!(keywords(&cmd).is_empty(), "typed-angle prompt has no keywords"); + cmd.on_text_input("90"); + assert_eq!(keywords(&cmd), ["CE", "R"]); + cmd.on_text_input("CE"); + assert!(keywords(&cmd).is_empty()); + } + + #[test] + fn width_option_sets_segment_widths_in_entity() { + let mut cmd = pl(&[(0.0, 0.0)]); + assert!(matches!(cmd.on_text_input("W"), Some(CmdResult::NeedPoint))); + assert!(matches!(cmd.sub, Sub::WidthStart { half: false })); + assert!(matches!(cmd.on_text_input("1"), Some(CmdResult::NeedPoint))); + assert!(matches!(cmd.sub, Sub::WidthEnd { half: false, .. })); + assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::NeedPoint))); + assert!(matches!(cmd.sub, Sub::None)); + let e = entity_of(cmd.on_point(DVec3::new(10.0, 0.0, 0.0))); + assert_eq!((e.vertices[0].start_width, e.vertices[0].end_width), (1.0, 2.0)); + // The end width carries over as the next segment's start width. + let e = entity_of(cmd.on_point(DVec3::new(20.0, 0.0, 0.0))); + assert_eq!((e.vertices[1].start_width, e.vertices[1].end_width), (2.0, 2.0)); + assert_eq!(super::super::super::defaults::get_pline_width(), 2.0); + super::super::super::defaults::set_pline_width(0.0); + } + + #[test] + fn halfwidth_doubles_value_and_enter_keeps_default() { + let mut cmd = pl(&[(0.0, 0.0)]); + cmd.on_text_input("H"); + assert!(matches!(cmd.sub, Sub::WidthStart { half: true })); + cmd.on_text_input("0.5"); + // Enter at the ending prompt keeps the starting value. + assert!(matches!(cmd.on_enter(), CmdResult::NeedPoint)); + assert_eq!(cmd.cur_width, (1.0, 1.0)); + super::super::super::defaults::set_pline_width(0.0); + } + + #[test] + fn length_extends_along_last_tangent() { + let mut cmd = pl(&[(0.0, 0.0), (10.0, 0.0)]); + cmd.on_text_input("L"); + assert!(matches!(cmd.sub, Sub::Length)); + let e = entity_of(cmd.on_text_input("5").expect("length consumed")); + assert_eq!(e.vertices.len(), 3); + assert!((e.vertices[2].location.x - 15.0).abs() < 1e-9); + assert!(e.vertices[2].location.y.abs() < 1e-9); + } + + #[test] + fn length_after_arc_follows_exit_tangent() { + // Line east, then a tangent semicircle up to (10,10): exit heads west. + let mut cmd = pl(&[(0.0, 0.0), (10.0, 0.0)]); + cmd.on_text_input("A"); + cmd.on_point(DVec3::new(10.0, 10.0, 0.0)); + cmd.on_text_input("L"); // back to Line mode + cmd.on_text_input("L"); // Length + let e = entity_of(cmd.on_text_input("5").expect("length consumed")); + assert!((e.vertices[3].location.x - 5.0).abs() < 1e-6, "{:?}", e.vertices[3].location); + assert!((e.vertices[3].location.y - 10.0).abs() < 1e-6); + } + + #[test] + fn arc_close_closes_with_bulge() { + let mut cmd = pl(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]); + cmd.on_text_input("A"); + let res = cmd.on_text_input("CL").expect("CLose consumed"); + match res { + CmdResult::UpdateLiveEntity { entity: EntityType::LwPolyline(p), finish, .. } => { + assert!(finish); + assert!(p.is_closed); + assert!(p.vertices[2].bulge.abs() > 1e-6, "closing segment should be an arc"); + } + _ => panic!("expected a finishing update"), + } + } + + #[test] + fn arc_angle_endpoint_bulge_is_tan_quarter() { + let mut cmd = pl(&[(0.0, 0.0), (10.0, 0.0)]); + cmd.on_text_input("A"); + cmd.on_text_input("A"); + cmd.on_text_input("90"); + let e = entity_of(cmd.on_point(DVec3::new(10.0, 10.0, 0.0))); + let expect = (std::f64::consts::FRAC_PI_2 / 4.0).tan(); + assert!((e.vertices[1].bulge - expect).abs() < 1e-9); + assert_eq!(e.vertices.len(), 3); + } + + #[test] + fn arc_center_endpoint_projects_to_radius() { + let mut cmd = pl(&[(0.0, 0.0), (10.0, 0.0)]); + cmd.on_text_input("A"); + cmd.on_text_input("CE"); + assert!(matches!(cmd.sub, Sub::ArcCenter)); + cmd.on_point(DVec3::new(10.0, 5.0, 0.0)); + assert_eq!(keywords(&cmd), ["A", "L"]); + let e = entity_of(cmd.on_point(DVec3::new(20.0, 5.0, 0.0))); + let v = e.vertices[2].location; + assert!((v.x - 15.0).abs() < 1e-9 && (v.y - 5.0).abs() < 1e-9, "{v:?}"); + let expect = (std::f64::consts::FRAC_PI_2 / 4.0).tan(); + assert!((e.vertices[1].bulge - expect).abs() < 1e-9, "quarter turn CCW"); + } + + #[test] + fn arc_center_length_uses_chord() { + let mut cmd = pl(&[(0.0, 0.0), (10.0, 0.0)]); + cmd.on_text_input("A"); + cmd.on_text_input("CE"); + cmd.on_point(DVec3::new(10.0, 5.0, 0.0)); + cmd.on_text_input("L"); + // Chord 10 on a radius 5 circle: a semicircle ending at (10,10). + let e = entity_of(cmd.on_text_input("10").expect("chord length")); + let v = e.vertices[2].location; + assert!((v.x - 10.0).abs() < 1e-6 && (v.y - 10.0).abs() < 1e-6, "{v:?}"); + } + + #[test] + fn arc_radius_rejects_long_chord() { + let mut cmd = pl(&[(0.0, 0.0), (10.0, 0.0)]); + cmd.on_text_input("A"); + cmd.on_text_input("R"); + cmd.on_text_input("3"); + assert!(matches!(cmd.on_point(DVec3::new(20.0, 0.0, 0.0)), CmdResult::NeedPoint)); + assert_eq!(cmd.vertices.len(), 2, "chord longer than the diameter is refused"); + let e = entity_of(cmd.on_point(DVec3::new(16.0, 0.0, 0.0))); + assert_eq!(e.vertices.len(), 3); + assert!(e.vertices[1].bulge.abs() > 0.0); + } + + #[test] + fn arc_second_point_three_point_arc_passes_through_second() { + let mut cmd = pl(&[(0.0, 0.0), (10.0, 0.0)]); + cmd.on_text_input("A"); + cmd.on_text_input("S"); + cmd.on_point(DVec3::new(15.0, 5.0, 0.0)); + let e = entity_of(cmd.on_point(DVec3::new(20.0, 0.0, 0.0))); + // Semicircle over the chord, running clockwise (bulge -1). + assert!((e.vertices[1].bulge + 1.0).abs() < 1e-9, "{}", e.vertices[1].bulge); + } + + #[test] + fn arc_direction_uses_picked_tangent() { + let mut cmd = pl(&[(0.0, 0.0), (10.0, 0.0)]); + cmd.on_text_input("A"); + cmd.on_text_input("D"); + cmd.on_point(DVec3::new(10.0, 10.0, 0.0)); // tangent +Y + let e = entity_of(cmd.on_point(DVec3::new(20.0, 0.0, 0.0))); + assert!((e.vertices[1].bulge + 1.0).abs() < 1e-9, "{}", e.vertices[1].bulge); + } + + #[test] + fn undo_in_substep_only_leaves_substep() { + let mut cmd = pl(&[(0.0, 0.0), (10.0, 0.0)]); + cmd.on_text_input("W"); + assert!(matches!(cmd.on_text_input("U"), Some(CmdResult::NeedPoint))); + assert!(matches!(cmd.sub, Sub::None)); + assert_eq!(cmd.vertices.len(), 2); + // Escape likewise backs out of a sub-prompt without finishing. + cmd.on_text_input("A"); + cmd.on_text_input("R"); + assert!(matches!(cmd.on_escape(), CmdResult::NeedPoint)); + assert!(matches!(cmd.sub, Sub::None)); + // A second Undo pops the vertex. + cmd.on_text_input("U"); + assert_eq!(cmd.vertices.len(), 1); + } + #[test] fn test_draw_line_then_arc() { let mut cmd = PlineCommand::new(); diff --git a/src/modules/draw/draw/raster_image.rs b/src/modules/draw/draw/raster_image.rs index 8f169fb9c..fb183ac77 100644 --- a/src/modules/draw/draw/raster_image.rs +++ b/src/modules/draw/draw/raster_image.rs @@ -1,6 +1,10 @@ -// IMAGE / IMAGEATTACH command — place a raster image in the drawing. +// IMAGE / IMAGEEMBED commands — place a raster image in the drawing. // -// Workflow: +// IMAGE references the picture by path (RasterImage + ImageDefinition); +// IMAGEEMBED packs the encoded raster inside the +// drawing as an OLE2FRAME so the .dwg travels alone. +// +// Workflow (both commands): // 1. File dialog opens (async, handled in update.rs). // 2. User picks insertion point (first click). // 3. User drags to pick width; height is computed from the image's aspect ratio. @@ -13,10 +17,22 @@ use glam::DVec3; use crate::t; use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::io::ole_embed::{build_embedded_ole, corners_from_placement, EmbeddedImage}; use crate::scene::model::wire_model::WireModel; +/// Where the picture's pixels come from. +#[derive(Clone)] +pub enum ImageSource { + /// Reference by path — the drawing stores the file location. + File(String), + /// Embedded — the encoded raster travels inside the drawing. + Embedded(EmbeddedImage), +} + pub struct ImageCommand { - file_path: String, + source: ImageSource, + /// CLI name: "IMAGE" for a path reference, "IMAGEEMBED" when embedded. + name: &'static str, pixel_width: u32, pixel_height: u32, origin: Option, @@ -26,7 +42,8 @@ pub struct ImageCommand { impl ImageCommand { pub fn new(file_path: String, pixel_width: u32, pixel_height: u32) -> Self { Self { - file_path, + source: ImageSource::File(file_path), + name: "IMAGE", pixel_width, pixel_height, origin: None, @@ -34,6 +51,25 @@ impl ImageCommand { } } + pub fn new_embedded(image: EmbeddedImage) -> Self { + let (pixel_width, pixel_height) = (image.pixel_width, image.pixel_height); + Self { + source: ImageSource::Embedded(image), + name: "IMAGEEMBED", + pixel_width, + pixel_height, + origin: None, + plane: WorkingPlane::default(), + } + } + + fn source_name(&self) -> std::borrow::Cow<'_, str> { + match &self.source { + ImageSource::File(path) => std::borrow::Cow::Borrowed(short_name(path)), + ImageSource::Embedded(image) => std::borrow::Cow::Owned(image.name.clone()), + } + } + fn aspect(&self) -> f64 { if self.pixel_height == 0 { 1.0 @@ -46,21 +82,30 @@ impl ImageCommand { let origin = self.plane.to_local(origin); let width_pt = self.plane.to_local(width_pt); let world_width = (width_pt.x - origin.x).abs().max(0.001); - let world_height = world_width / self.aspect(); - let ins = Vector3::new(origin.x, origin.y, origin.z); - let mut img = RasterImage::with_size( - &self.file_path, - ins, - self.pixel_width as f64, - self.pixel_height as f64, - world_width, - world_height, - ); - img.flags = acadrust::entities::ImageDisplayFlags::SHOW_IMAGE - | acadrust::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY; - self.plane.place_entity(EntityType::RasterImage(img)) + match &self.source { + ImageSource::File(path) => { + let world_height = world_width / self.aspect(); + let mut img = RasterImage::with_size( + path, + ins, + self.pixel_width as f64, + self.pixel_height as f64, + world_width, + world_height, + ); + img.flags = acadrust::entities::ImageDisplayFlags::SHOW_IMAGE + | acadrust::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY; + self.plane.place_entity(EntityType::RasterImage(img)) + } + ImageSource::Embedded(image) => { + let (upper_left, lower_right) = + corners_from_placement(ins, world_width, self.aspect()); + let ole = build_embedded_ole(image, upper_left, lower_right); + self.plane.place_entity(EntityType::Ole2Frame(ole)) + } + } } } @@ -70,14 +115,14 @@ impl CadCommand for ImageCommand { } fn name(&self) -> &'static str { - "IMAGE" + self.name } fn prompt(&self) -> String { if self.origin.is_none() { t!( "IMAGE Specify insertion point (%{name}): ", - name = short_name(&self.file_path) + name = self.source_name() ) .into_owned() } else { diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index e045cfa80..6a931b707 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -338,8 +338,9 @@ impl CadCommand for XLineCommand { if self.value_origin.is_some() { return None; } self.mode = XLineMode::AngleReference; } else { - let angle: f64 = key.parse().ok().filter(|v: &f64| v.is_finite())?; - let (sin, cos) = angle.to_radians().sin_cos(); + let angle = crate::entities::common::parse_typed_angle(&key) + .filter(|angle| angle.is_finite())?; + let (sin, cos) = angle.sin_cos(); let axis = self.reference.map_or(self.plane.x, |(_, dir)| dir); self.mode = XLineMode::Direction(axis * cos + self.plane.z.cross(axis) * sin); } diff --git a/src/modules/draw/draw/trace.rs b/src/modules/draw/draw/trace.rs index 16e3f9116..c0b4c85e3 100644 --- a/src/modules/draw/draw/trace.rs +++ b/src/modules/draw/draw/trace.rs @@ -144,6 +144,15 @@ impl CadCommand for TraceCommand { self.width_set && !self.points.is_empty() } + fn options(&self) -> Vec { + use crate::command::CmdOption; + if self.width_set && !self.points.is_empty() { + vec![CmdOption::new("Undo", "U")] + } else { + Vec::new() + } + } + fn on_text_input(&mut self, text: &str) -> Option { let t = text.trim(); if !self.width_set { diff --git a/src/modules/draw/inquiry/measuregeom.rs b/src/modules/draw/inquiry/measuregeom.rs index fd5844d51..73a355ff7 100644 --- a/src/modules/draw/inquiry/measuregeom.rs +++ b/src/modules/draw/inquiry/measuregeom.rs @@ -227,6 +227,19 @@ impl CadCommand for MeasureGeomCommand { self.mode == Mode::Choose } + fn options(&self) -> Vec { + use crate::command::CmdOption; + if self.mode != Mode::Choose { + return Vec::new(); + } + vec![ + CmdOption::new("Distance", "D"), + CmdOption::new("Radius", "R"), + CmdOption::new("Angle", "A"), + CmdOption::new("ARea", "AR"), + ] + } + fn on_text_input(&mut self, text: &str) -> Option { if self.mode != Mode::Choose { return None; diff --git a/src/modules/draw/modify/array.rs b/src/modules/draw/modify/array.rs index d0d7a83b6..eb4ba3b1b 100644 --- a/src/modules/draw/modify/array.rs +++ b/src/modules/draw/modify/array.rs @@ -45,6 +45,27 @@ pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[ ), ]; +// ── Item limit ───────────────────────────────────────────────────────────── + +/// Most items one array may create, including the source. Every item becomes +/// a transform and then a copy of each selected object, so an unbounded typed +/// count exhausts memory before anything is drawn. +pub(crate) const MAX_ARRAY_ITEMS: u64 = 100_000; + +/// Whether an array of `counts` (rows, columns, levels, ...) stays in bounds. +pub(crate) fn array_items_within_limit(counts: &[u64]) -> bool { + counts + .iter() + .try_fold(1u64, |total, &count| total.checked_mul(count)) + .is_some_and(|total| total <= MAX_ARRAY_ITEMS) +} + +pub(crate) fn array_limit_error() -> CmdResult { + CmdResult::ReportError( + crate::tf!("Array size exceeds the limit of {} items.", MAX_ARRAY_ITEMS).into_owned(), + ) +} + // ── Rectangular Array ────────────────────────────────────────────────────── #[derive(Debug, Clone, Copy)] @@ -72,8 +93,8 @@ impl ArrayRectCommand { handles, wire_models, step: RectStep::Rows, - default_rows: defaults::get_array_rows() as u32, - default_cols: defaults::get_array_cols() as u32, + default_rows: (defaults::get_array_rows() as u32).clamp(1, MAX_ARRAY_ITEMS as u32), + default_cols: (defaults::get_array_cols() as u32).clamp(1, MAX_ARRAY_ITEMS as u32), default_row_sp: defaults::get_array_row_sp(), default_col_sp: defaults::get_array_col_sp(), plane: WorkingPlane::default(), @@ -154,6 +175,9 @@ impl CadCommand for ArrayRectCommand { self.default_rows } else { let v = t.parse::().unwrap_or(self.default_rows).max(1); + if !array_items_within_limit(&[v as u64]) { + return Some(array_limit_error()); + } defaults::set_array_rows(v as f64); self.default_rows = v; v @@ -165,11 +189,15 @@ impl CadCommand for ArrayRectCommand { let cols = if t.is_empty() { self.default_cols } else { - let v = t.parse::().unwrap_or(self.default_cols).max(1); - defaults::set_array_cols(v as f64); - self.default_cols = v; - v + t.parse::().unwrap_or(self.default_cols).max(1) }; + if !array_items_within_limit(&[rows as u64, cols as u64]) { + return Some(array_limit_error()); + } + if !t.is_empty() { + defaults::set_array_cols(cols as f64); + self.default_cols = cols; + } self.step = RectStep::RowSp { rows, cols }; None } @@ -222,6 +250,10 @@ impl CadCommand for ArrayRectCommand { } RectStep::ColSp { rows, cols, row_sp } => (rows, cols, row_sp, self.default_col_sp), }; + // Defaults are only bounded one count at a time. + if !array_items_within_limit(&[rows as u64, cols as u64]) { + return vec![]; + } Self::build_transforms(rows, cols, row_sp, col_sp, self.plane) .iter() .flat_map(|t| { @@ -274,7 +306,7 @@ impl ArrayPolarCommand { handles, wire_models, step: PolarStep::Center, - default_count: defaults::get_array_p_count() as u32, + default_count: (defaults::get_array_p_count() as u32).clamp(2, MAX_ARRAY_ITEMS as u32), default_angle: defaults::get_array_p_angle(), plane: WorkingPlane::default(), } @@ -330,6 +362,9 @@ impl CadCommand for ArrayPolarCommand { self.default_count } else { let v = t.parse::().unwrap_or(self.default_count).max(2); + if !array_items_within_limit(&[v as u64]) { + return Some(array_limit_error()); + } defaults::set_array_p_count(v as f64); self.default_count = v; v @@ -461,7 +496,7 @@ impl ArrayPathCommand { wire_models, all_entities, step: PathStep::SelectPath, - default_count: defaults::get_array_path_count() as u32, + default_count: (defaults::get_array_path_count() as u32).clamp(2, MAX_ARRAY_ITEMS as u32), pick_pt: DVec3::ZERO, } } @@ -686,6 +721,9 @@ impl CadCommand for ArrayPathCommand { self.default_count } else { let v = t.parse::().unwrap_or(self.default_count).max(2); + if !array_items_within_limit(&[v as u64]) { + return Some(array_limit_error()); + } defaults::set_array_path_count(v as f64); self.default_count = v; v @@ -891,6 +929,9 @@ impl CadCommand for Array3DCommand { } else { t.parse::().unwrap_or(2).max(1) }; + if !array_items_within_limit(&[v as u64]) { + return Some(array_limit_error()); + } self.step = Array3DStep::Cols { rows: v }; Some(CmdResult::NeedPoint) } @@ -900,6 +941,9 @@ impl CadCommand for Array3DCommand { } else { t.parse::().unwrap_or(2).max(1) }; + if !array_items_within_limit(&[rows as u64, v as u64]) { + return Some(array_limit_error()); + } self.step = Array3DStep::Levels { rows, cols: v }; Some(CmdResult::NeedPoint) } @@ -909,6 +953,9 @@ impl CadCommand for Array3DCommand { } else { t.parse::().unwrap_or(2).max(1) }; + if !array_items_within_limit(&[rows as u64, cols as u64, v as u64]) { + return Some(array_limit_error()); + } self.step = Array3DStep::RowSp { rows, cols, @@ -987,3 +1034,33 @@ impl CadCommand for Array3DCommand { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["ARRAY3D", "3DARRAY"] }); + +#[cfg(test)] +mod item_limit_tests { + use super::*; + + #[test] + fn item_limit_counts_every_dimension_without_overflow() { + assert!(array_items_within_limit(&[MAX_ARRAY_ITEMS])); + assert!(!array_items_within_limit(&[MAX_ARRAY_ITEMS + 1])); + assert!(!array_items_within_limit(&[1_000, 1_000])); + assert!(!array_items_within_limit(&[u32::MAX as u64, u32::MAX as u64, u32::MAX as u64])); + } + + #[test] + fn oversized_counts_are_rejected_at_the_prompt() { + let mut rect = ArrayRectCommand::new(vec![Handle::new(1)], Vec::new()); + assert!(rect.on_text_input("100000").is_none()); + assert!(matches!(rect.on_text_input("100000"), Some(CmdResult::ReportError(_)))); + assert!(matches!(rect.step, RectStep::Cols { rows: 100_000 })); + + let mut polar = ArrayPolarCommand::new(vec![Handle::new(1)], Vec::new()); + polar.step = PolarStep::Count { center: DVec3::ZERO }; + assert!(matches!(polar.on_text_input("4294967295"), Some(CmdResult::ReportError(_)))); + + let mut cube = Array3DCommand::new(vec![Handle::new(1)]); + assert!(matches!(cube.on_text_input("300"), Some(CmdResult::NeedPoint))); + assert!(matches!(cube.on_text_input("300"), Some(CmdResult::NeedPoint))); + assert!(matches!(cube.on_text_input("2"), Some(CmdResult::ReportError(_)))); + } +} diff --git a/src/modules/draw/modify/copy.rs b/src/modules/draw/modify/copy.rs index 130cf48dc..9b192ece4 100644 --- a/src/modules/draw/modify/copy.rs +++ b/src/modules/draw/modify/copy.rs @@ -28,6 +28,8 @@ pub fn tool() -> ToolDef { enum Step { Base, Placing(DVec3), + /// Displacement option: the next point / typed coordinate is the vector. + Displacement, } pub struct CopyCommand { @@ -39,6 +41,10 @@ pub struct CopyCommand { array_count: Option, /// True while the next typed value is captured as the array item count. awaiting_count: bool, + /// True while the mOde prompt (Single / Multiple) is up. + awaiting_mode: bool, + /// COPYMODE: Single ends the command after the first copy. + single: bool, } impl CopyCommand { @@ -50,6 +56,8 @@ impl CopyCommand { count: 0, array_count: None, awaiting_count: false, + awaiting_mode: false, + single: false, } } } @@ -63,6 +71,9 @@ impl CadCommand for CopyCommand { if self.awaiting_count { return crate::tr!("command-copy", "array-count"); } + if self.awaiting_mode { + return crate::tr!("command-copy", "mode"); + } match &self.step { Step::Base => crate::tr!( "command-copy", "base", @@ -85,6 +96,35 @@ impl CadCommand for CopyCommand { ) } } + Step::Displacement => crate::tr!("command-copy", "displacement"), + } + } + + fn options(&self) -> Vec { + use crate::command::CmdOption; + if self.awaiting_count { + return Vec::new(); + } + if self.awaiting_mode { + return vec![ + CmdOption::new("Single", "S"), + CmdOption::new("Multiple", "M"), + ]; + } + match self.step { + Step::Base => vec![ + CmdOption::new("Displacement", "D"), + CmdOption::new("mOde", "O"), + ], + Step::Placing(_) => { + let mut opts = vec![CmdOption::new("Array", "A")]; + if self.count > 0 { + opts.push(CmdOption::new("Undo", "U")); + } + opts.push(CmdOption::enter("Exit")); + opts + } + Step::Displacement => Vec::new(), } } @@ -106,6 +146,12 @@ impl CadCommand for CopyCommand { .map(|k| EntityTransform::Translate(delta * k as f64)) .collect(); CmdResult::BatchCopy(self.handles.clone(), transforms) + } else if self.single { + // Single mode: one copy, then done. + CmdResult::BatchCopy( + self.handles.clone(), + vec![EntityTransform::Translate(delta)], + ) } else { self.count += 1; CmdResult::CopySelected( @@ -114,15 +160,24 @@ impl CadCommand for CopyCommand { ) } } + // The point (typically typed as `dx,dy`) is the displacement itself. + Step::Displacement => CmdResult::BatchCopy( + self.handles.clone(), + vec![EntityTransform::Translate(pt)], + ), } } fn on_enter(&mut self) -> CmdResult { - // Cancel an in-progress Array count entry; otherwise finish. + // Cancel an in-progress Array count / mode entry; otherwise finish. if self.awaiting_count { self.awaiting_count = false; return CmdResult::NeedPoint; } + if self.awaiting_mode { + self.awaiting_mode = false; + return CmdResult::NeedPoint; + } CmdResult::Cancel } fn on_escape(&mut self) -> CmdResult { @@ -130,18 +185,23 @@ impl CadCommand for CopyCommand { } fn wants_text_input(&self) -> bool { - matches!(self.step, Step::Placing(_)) + matches!(self.step, Step::Placing(_) | Step::Base) } fn point_step_accepts_keywords(&self) -> bool { - // Accept the Array keyword while placing, but route the typed item - // count straight through on_text_input. - matches!(self.step, Step::Placing(_)) && !self.awaiting_count + // Keywords ride along with the point picks, but a typed item count + // or mode answer goes straight through on_text_input. + matches!(self.step, Step::Placing(_) | Step::Base) + && !self.awaiting_count + && !self.awaiting_mode } fn on_text_input(&mut self, text: &str) -> Option { if self.awaiting_count { if let Ok(n) = text.trim().parse::() { + if !super::array::array_items_within_limit(&[n as u64]) { + return Some(super::array::array_limit_error()); + } if n >= 2 { self.array_count = Some(n); } @@ -149,15 +209,46 @@ impl CadCommand for CopyCommand { self.awaiting_count = false; return Some(CmdResult::NeedPoint); } - match text.trim().to_uppercase().as_str() { - "A" | "ARRAY" => { + let upper = text.trim().to_uppercase(); + if self.awaiting_mode { + match upper.as_str() { + "S" | "SINGLE" => self.single = true, + "M" | "MULTIPLE" => self.single = false, + _ => return Some(CmdResult::NeedPoint), + } + self.awaiting_mode = false; + return Some(CmdResult::NeedPoint); + } + match (&self.step, upper.as_str()) { + (Step::Base, "D" | "DISPLACEMENT") => { + self.step = Step::Displacement; + Some(CmdResult::NeedPoint) + } + (Step::Base, "O" | "MODE") => { + self.awaiting_mode = true; + Some(CmdResult::NeedPoint) + } + (Step::Placing(_), "A" | "ARRAY") => { self.awaiting_count = true; Some(CmdResult::NeedPoint) } + // Undo: take back the last copy placed by this command. + (Step::Placing(_), "U" | "UNDO") if self.count > 0 => { + self.count -= 1; + Some(CmdResult::UndoDocument) + } _ => None, } } + fn on_undo_step(&mut self) -> Option { + if matches!(self.step, Step::Placing(_)) && self.count > 0 { + self.on_text_input("U") + } else { + None + } + } + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { let Step::Placing(base) = &self.step else { return vec![]; @@ -180,3 +271,65 @@ impl CadCommand for CopyCommand { out } } + +#[cfg(test)] +mod tests { + use super::*; + + fn keywords(cmd: &CopyCommand) -> Vec { + cmd.options().into_iter().map(|o| o.keyword).collect() + } + + #[test] + fn array_count_above_the_item_limit_is_rejected() { + let mut cmd = CopyCommand::new(vec![Handle::new(1)], vec![]); + cmd.awaiting_count = true; + assert!(matches!( + cmd.on_text_input("18446744073709551615"), + Some(CmdResult::ReportError(_)) + )); + assert!(cmd.awaiting_count, "the count prompt stays up"); + assert_eq!(cmd.array_count, None); + } + + #[test] + fn mode_single_exits_after_one_copy() { + let mut cmd = CopyCommand::new(vec![Handle::new(1)], vec![]); + assert_eq!(keywords(&cmd), ["D", "O"]); + assert!(matches!(cmd.on_text_input("O"), Some(CmdResult::NeedPoint))); + assert_eq!(keywords(&cmd), ["S", "M"]); + assert!(matches!(cmd.on_text_input("S"), Some(CmdResult::NeedPoint))); + cmd.on_point(DVec3::ZERO); + match cmd.on_point(DVec3::new(10.0, 0.0, 0.0)) { + CmdResult::BatchCopy(_, transforms) => assert_eq!(transforms.len(), 1), + _ => panic!("single mode should copy once and end"), + } + } + + #[test] + fn multiple_mode_keeps_copying_and_undo_takes_one_back() { + let mut cmd = CopyCommand::new(vec![Handle::new(1)], vec![]); + cmd.on_point(DVec3::ZERO); + assert_eq!(keywords(&cmd), ["A", ""], "no Undo before a copy is placed"); + assert!(matches!( + cmd.on_point(DVec3::new(10.0, 0.0, 0.0)), + CmdResult::CopySelected(..) + )); + assert_eq!(keywords(&cmd), ["A", "U", ""]); + assert!(matches!(cmd.on_text_input("U"), Some(CmdResult::UndoDocument))); + assert_eq!(keywords(&cmd), ["A", ""]); + assert!(cmd.on_text_input("U").is_none(), "nothing left to undo"); + } + + #[test] + fn displacement_option_copies_by_vector() { + let mut cmd = CopyCommand::new(vec![Handle::new(1)], vec![]); + cmd.on_text_input("D"); + match cmd.on_point(DVec3::new(1.0, 2.0, 0.0)) { + CmdResult::BatchCopy(_, transforms) => { + assert!(matches!(transforms[0], EntityTransform::Translate(d) if d == DVec3::new(1.0, 2.0, 0.0))); + } + _ => panic!("expected one copy by the displacement"), + } + } +} diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index 5243e8af2..6ef505171 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -88,6 +88,16 @@ pub fn explode_entity(entity: &EntityType, document: &CadDocument) -> Vec explode_polyline2d(p), EntityType::Polyline(p) => explode_polyline(p), EntityType::Polyline3D(p) => explode_polyline3d(p), + // A MINSERT expands to one copy of the block per cell; leave arrays + // too large to materialize intact instead of exhausting memory. + EntityType::Insert(ins) + if !super::array::array_items_within_limit(&[ + ins.row_count as u64, + ins.column_count as u64, + ]) => + { + vec![] + } EntityType::Insert(ins) => ins .explode_from_document(document) .into_iter() @@ -1478,6 +1488,18 @@ mod tests { use super::*; use acadrust::entities::DimensionLinear; + #[test] + fn minsert_too_large_to_materialize_is_left_intact() { + let mut doc = CadDocument::new(); + let mut block = acadrust::tables::BlockRecord::new("B1"); + block.handle = doc.allocate_handle(); + doc.block_records.add(block).unwrap(); + let mut insert = acadrust::entities::Insert::new("B1", Vector3::new(0.0, 0.0, 0.0)); + insert.row_count = u16::MAX; + insert.column_count = u16::MAX; + assert!(explode_entity(&EntityType::Insert(insert), &doc).is_empty()); + } + /// A dimension created without a geometry block gets a real `*D` block on /// bake, its `block_name` resolves to that block, and a second bake is a /// no-op (already has a valid block). diff --git a/src/modules/draw/modify/fillet.rs b/src/modules/draw/modify/fillet.rs index ec19c9728..5ebd86941 100644 --- a/src/modules/draw/modify/fillet.rs +++ b/src/modules/draw/modify/fillet.rs @@ -1062,10 +1062,13 @@ fn fillet_line_arc( + (p1[1] + a * u[1] - click_line[1]).powi(2); let db = (p1[0] + b * u[0] - click_line[0]).powi(2) + (p1[1] + b * u[1] - click_line[1]).powi(2); - da.partial_cmp(&db).unwrap() + da.total_cmp(&db) })?; let ix = p1[0] + t_best * u[0]; let iy = p1[1] + t_best * u[1]; + if !(ix.is_finite() && iy.is_finite()) { + return None; + } // Trim line to intersection let new_line = trim_line_to_point(line, [ix, iy], click_line)?; @@ -1154,9 +1157,11 @@ fn fillet_arc_arc( let ip = *pts.iter().min_by(|a, b| { (a[0] - cx) .hypot(a[1] - cy) - .partial_cmp(&(b[0] - cx).hypot(b[1] - cy)) - .unwrap() + .total_cmp(&(b[0] - cx).hypot(b[1] - cy)) })?; + if !(ip[0].is_finite() && ip[1].is_finite()) { + return None; + } let ia1 = arc_angle_at(c1, ip); let ia2 = arc_angle_at(c2, ip); @@ -1320,6 +1325,8 @@ pub struct FilletCommand { /// (i.e. "R" pressed after the first object was already picked), so the /// command resumes at the second pick instead of restarting selection. resume_second: Option<(Handle, FilletEntity, [f64; 2])>, + /// Fillets applied by this command that Undo can still take back. + made: usize, } impl FilletCommand { @@ -1335,6 +1342,7 @@ impl FilletCommand { all_entities, entity_index, resume_second: None, + made: 0, } } @@ -1372,8 +1380,20 @@ impl FilletCommand { self.entity_index = ModifyEntityIndex::build(&self.all_entities); self.step = FilletStep::First; self.resume_second = None; + self.made += 1; CmdResult::ReplaceManyContinue(replacements) } + + /// Undo option: take back the last fillet of this command. The host + /// undoes the document step and then hands the restored entities back + /// through `on_document_undone`. + fn undo_last(&mut self) -> Option { + if self.made == 0 { + return None; + } + self.made -= 1; + Some(CmdResult::UndoDocument) + } } impl CadCommand for FilletCommand { @@ -1433,10 +1453,15 @@ impl CadCommand for FilletCommand { use crate::command::CmdOption; match self.step { - FilletStep::First => vec![ - CmdOption::new("Polyline", "P"), - CmdOption::new("Radius", "R"), - ], + FilletStep::First => { + let mut opts = Vec::new(); + if self.made > 0 { + opts.push(CmdOption::new("Undo", "U")); + } + opts.push(CmdOption::new("Polyline", "P")); + opts.push(CmdOption::new("Radius", "R")); + opts + } FilletStep::Second { .. } => { vec![CmdOption::new("Radius", "R")] } @@ -1485,13 +1510,16 @@ impl CadCommand for FilletCommand { self.step = FilletStep::Polyline; return Some(CmdResult::NeedPoint); } + if upper == "U" || upper == "UNDO" { + return self.undo_last(); + } // "R" alone → enter sub-step to collect radius if upper == "R" { self.enter_radius_substep(); return Some(CmdResult::NeedPoint); } // "R 5.0" inline shorthand - if upper.starts_with('R') { + if t.starts_with(['r', 'R']) { let body = t[1..].trim(); if let Some(v) = crate::entities::common::parse_typed_length(body) { if v >= 0.0 { @@ -1520,6 +1548,20 @@ impl CadCommand for FilletCommand { ) } + fn on_undo_step(&mut self) -> Option { + self.undo_last() + } + + fn on_document_undone(&mut self, document: &acadrust::CadDocument) { + self.all_entities = document + .entities() + .map(crate::entities::curve::entity_with_lwpolyline_world_xy) + .collect(); + self.entity_index = ModifyEntityIndex::build(&self.all_entities); + self.step = FilletStep::First; + self.resume_second = None; + } + fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; @@ -1848,6 +1890,10 @@ pub struct ChamferCommand { /// distance entry made mid-selection, so the command resumes at the /// second pick instead of restarting selection. resume_pick: Option, + /// Multiple option: keep chamfering until Enter / Esc. + multiple: bool, + /// Chamfers applied by this command that Undo can still take back. + made: usize, } impl ChamferCommand { @@ -1864,6 +1910,8 @@ impl ChamferCommand { all_entities, entity_index, resume_pick: None, + multiple: false, + made: 0, } } @@ -1890,6 +1938,37 @@ impl ChamferCommand { fn resume_after_dist(&mut self) { self.step = self.resume_pick.take().unwrap_or(ChamferStep::First); } + + /// Publish a chamfer. Single mode replaces and ends; Multiple mode + /// replaces, refreshes the cached entities and returns to the first pick. + fn commit(&mut self, replacements: Vec<(Handle, Vec)>) -> CmdResult { + if !self.multiple { + return CmdResult::ReplaceMany(replacements, vec![]); + } + self.all_entities.retain(|entity| { + !replacements + .iter() + .any(|(handle, _)| entity.common().handle == *handle) + }); + self.all_entities.extend( + replacements + .iter() + .flat_map(|(_, entities)| entities.iter().cloned()), + ); + self.entity_index = ModifyEntityIndex::build(&self.all_entities); + self.step = ChamferStep::First; + self.resume_pick = None; + self.made += 1; + CmdResult::ReplaceManyContinue(replacements) + } + + fn undo_last(&mut self) -> Option { + if self.made == 0 { + return None; + } + self.made -= 1; + Some(CmdResult::UndoDocument) + } } impl CadCommand for ChamferCommand { @@ -1944,10 +2023,19 @@ impl CadCommand for ChamferCommand { use crate::command::CmdOption; match self.step { - ChamferStep::First - | ChamferStep::Second { .. } - | ChamferStep::SecondPoly { .. } => { - vec![CmdOption::new(t!("Distance").as_ref(), "D")] + ChamferStep::First => { + let mut opts = Vec::new(); + if self.made > 0 { + opts.push(CmdOption::new("Undo", "U")); + } + opts.push(CmdOption::new("Distance", "D")); + if !self.multiple { + opts.push(CmdOption::new("Multiple", "M")); + } + opts + } + ChamferStep::Second { .. } | ChamferStep::SecondPoly { .. } => { + vec![CmdOption::new("Distance", "D")] } ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 => vec![], } @@ -2010,13 +2098,23 @@ impl CadCommand for ChamferCommand { | ChamferStep::SecondPoly { .. } => { let t = text.trim(); let upper = t.to_uppercase(); + if matches!(self.step, ChamferStep::First) { + match upper.as_str() { + "M" | "MULTIPLE" => { + self.multiple = true; + return Some(CmdResult::NeedPoint); + } + "U" | "UNDO" => return self.undo_last(), + _ => {} + } + } // "D" alone → enter sub-step to collect distances if upper == "D" { self.enter_dist_substep(); return Some(CmdResult::NeedPoint); } // "D 5.0" or "D 5.0 3.0" inline shorthand - if upper.starts_with('D') { + if t.starts_with(['d', 'D']) { let body = t[1..].trim(); let parts: Vec = body .split_whitespace() @@ -2045,6 +2143,20 @@ impl CadCommand for ChamferCommand { } } + fn on_undo_step(&mut self) -> Option { + self.undo_last() + } + + fn on_document_undone(&mut self, document: &acadrust::CadDocument) { + self.all_entities = document + .entities() + .map(crate::entities::curve::entity_with_lwpolyline_world_xy) + .collect(); + self.entity_index = ModifyEntityIndex::build(&self.all_entities); + self.step = ChamferStep::First; + self.resume_pick = None; + } + fn needs_entity_pick(&self) -> bool { !matches!( self.step, @@ -2093,7 +2205,7 @@ impl CadCommand for ChamferCommand { return CmdResult::NeedPoint; } match chamfer_lwpoly_corner(&poly, click1, self.dist1, click, self.dist2) { - Some(new_poly) => CmdResult::ReplaceMany(vec![(h1, vec![new_poly])], vec![]), + Some(new_poly) => self.commit(vec![(h1, vec![new_poly])]), None => CmdResult::NeedPoint, } } @@ -2118,10 +2230,22 @@ impl CadCommand for ChamferCommand { if let Some(l2) = l2 { match compute_chamfer(&l1, click1, self.dist1, &l2, click, self.dist2) { - Some((new_l1, new_l2, chamfer_line)) => CmdResult::ReplaceMany( - vec![(h1, vec![new_l1]), (handle, vec![new_l2])], - vec![chamfer_line], - ), + Some((new_l1, new_l2, chamfer_line)) => { + // Single mode adds the chamfer line as a new entity; + // Multiple folds it into the first line's replacement + // so the command can carry on. + if self.multiple { + self.commit(vec![ + (h1, vec![new_l1, chamfer_line]), + (handle, vec![new_l2]), + ]) + } else { + CmdResult::ReplaceMany( + vec![(h1, vec![new_l1]), (handle, vec![new_l2])], + vec![chamfer_line], + ) + } + } None => CmdResult::NeedPoint, } } else { @@ -2245,6 +2369,81 @@ mod tests { EntityType::Line(line) } + fn keywords(cmd: &dyn CadCommand) -> Vec { + cmd.options().into_iter().map(|o| o.keyword).collect() + } + + /// The upper half of a radius-5 circle at (5, 0), scaled about the origin + /// by an infinite factor: the center becomes (inf, NaN). + fn overflowed_arc(sweep_sign: f64) -> ArcEnt { + let mut arc = ArcEnt::new(); + arc.center = acadrust::types::Vector3::new(f64::INFINITY, f64::NAN, 0.0); + arc.radius = f64::INFINITY; + arc.start_angle = 0.0; + arc.end_angle = sweep_sign * std::f64::consts::PI; + arc + } + + #[test] + fn zero_radius_fillet_rejects_non_finite_geometry() { + let line = LineEnt::from_coords(f64::NAN, f64::NAN, 0.0, f64::INFINITY, f64::INFINITY, 0.0); + let arc = overflowed_arc(1.0); + assert!(fillet_line_arc(&line, [5.0, 5.0], &arc, [5.0, 5.0], 0.0, 0.0).is_none()); + let other = overflowed_arc(-1.0); + assert!(fillet_arc_arc(&arc, [5.0, 5.0], &other, [5.0, -5.0], 0.0, 0.0).is_none()); + } + + #[test] + fn fillet_undo_option_appears_after_a_fillet_and_refreshes_from_document() { + let mut command = FilletCommand::new( + 1.0, + vec![line(0.0, 0.0, 10.0, 0.0, 1), line(0.0, 0.0, 0.0, 10.0, 2)], + ); + assert_eq!(keywords(&command), ["P", "R"]); + assert!(command.on_text_input("U").is_none(), "nothing to undo yet"); + command.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0)); + assert!(matches!( + command.on_entity_pick(Handle::new(2), DVec3::new(0.0, 5.0, 0.0)), + CmdResult::ReplaceManyContinue(_) + )); + assert_eq!(keywords(&command), ["U", "P", "R"]); + assert!(matches!(command.on_text_input("U"), Some(CmdResult::UndoDocument))); + assert_eq!(keywords(&command), ["P", "R"]); + // The host hands the restored document back; the cache follows it. + let mut doc = acadrust::CadDocument::new(); + doc.add_entity(line(0.0, 0.0, 20.0, 0.0, 7)); + command.on_document_undone(&doc); + assert_eq!(command.all_entities.len(), 1); + assert_eq!(command.all_entities[0].common().handle, Handle::new(7)); + } + + #[test] + fn chamfer_multiple_keeps_going_and_single_ends() { + let lines = || vec![line(0.0, 0.0, 10.0, 0.0, 1), line(0.0, 0.0, 0.0, 10.0, 2)]; + let mut single = ChamferCommand::new(1.0, lines()); + assert_eq!(keywords(&single), ["D", "M"]); + single.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0)); + assert!(matches!( + single.on_entity_pick(Handle::new(2), DVec3::new(0.0, 5.0, 0.0)), + CmdResult::ReplaceMany(..) + )); + + let mut multi = ChamferCommand::new(1.0, lines()); + assert!(matches!(multi.on_text_input("M"), Some(CmdResult::NeedPoint))); + assert_eq!(keywords(&multi), ["D"], "Multiple is not offered twice"); + multi.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0)); + match multi.on_entity_pick(Handle::new(2), DVec3::new(0.0, 5.0, 0.0)) { + CmdResult::ReplaceManyContinue(replacements) => { + // The chamfer line rides along with the first line's replacement. + assert_eq!(replacements[0].1.len(), 2); + assert_eq!(replacements[1].1.len(), 1); + } + _ => panic!("multiple mode should keep the command active"), + } + assert_eq!(keywords(&multi), ["U", "D"]); + assert!(matches!(multi.on_text_input("U"), Some(CmdResult::UndoDocument))); + } + #[test] fn fillet_continues_with_replacement_entities() { let first = Handle::new(1); diff --git a/src/modules/draw/modify/lengthen.rs b/src/modules/draw/modify/lengthen.rs index 96948cf1c..9f54ecc5e 100644 --- a/src/modules/draw/modify/lengthen.rs +++ b/src/modules/draw/modify/lengthen.rs @@ -61,8 +61,18 @@ impl CadCommand for LengthenCommand { LenState::Value(ValueMode::Delta) => format!("LENGTHEN Enter delta length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().delta), LenState::Value(ValueMode::Total) => format!("LENGTHEN Enter total length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().total), LenState::Value(ValueMode::Percent) => format!("LENGTHEN Enter percentage length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().percent), - LenState::Value(ValueMode::DeltaAngle) => format!("LENGTHEN Enter delta angle <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().delta_angle), - LenState::Value(ValueMode::TotalAngle) => format!("LENGTHEN Enter total angle <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().total_angle), + LenState::Value(ValueMode::DeltaAngle) => format!( + "LENGTHEN Enter delta angle <{}>:", + crate::entities::common::format_angle( + LENGTHEN_DEFAULTS.lock().unwrap().delta_angle.to_radians(), + ) + ), + LenState::Value(ValueMode::TotalAngle) => format!( + "LENGTHEN Enter total angle <{}>:", + crate::entities::common::format_angle( + LENGTHEN_DEFAULTS.lock().unwrap().total_angle.to_radians(), + ) + ), LenState::DynamicPoint { .. } => "LENGTHEN Specify new end point:".into(), LenState::Apply(_) | LenState::DynamicPick | LenState::Value(ValueMode::Dynamic) => t!("LENGTHEN Select an object to change or [Undo]:").into_owned(), } @@ -146,7 +156,13 @@ impl CadCommand for LengthenCommand { }; return Some(CmdResult::NeedPoint); } - let Some(value) = upper.replace(',', ".").parse::().ok().filter(|v| v.is_finite()) else { + let angle_mode = matches!(mode, ValueMode::DeltaAngle | ValueMode::TotalAngle); + let parsed = if angle_mode { + crate::entities::common::parse_typed_angle(&upper).map(f64::to_degrees) + } else { + upper.replace(',', ".").parse::().ok() + }; + let Some(value) = parsed.filter(|v| v.is_finite()) else { return Some(CmdResult::NeedPoint); }; if !matches!(mode, ValueMode::Delta | ValueMode::DeltaAngle) && value <= 0.0 { return Some(CmdResult::NeedPoint); } @@ -201,7 +217,12 @@ impl CadCommand for LengthenCommand { match mode { ValueMode::Delta => defaults.delta, ValueMode::Total => defaults.total, ValueMode::Percent => defaults.percent, ValueMode::DeltaAngle => defaults.delta_angle, ValueMode::TotalAngle => defaults.total_angle, ValueMode::Dynamic => 0.0 } }; - self.on_text_input(&value.to_string()).unwrap_or(CmdResult::NeedPoint) + let text = if matches!(mode, ValueMode::DeltaAngle | ValueMode::TotalAngle) { + format!("{value}d") + } else { + value.to_string() + }; + self.on_text_input(&text).unwrap_or(CmdResult::NeedPoint) } _ => CmdResult::Cancel, } diff --git a/src/modules/draw/modify/mirror.rs b/src/modules/draw/modify/mirror.rs index 4b7621e38..2cb2bc2bd 100644 --- a/src/modules/draw/modify/mirror.rs +++ b/src/modules/draw/modify/mirror.rs @@ -125,6 +125,15 @@ impl CadCommand for MirrorCommand { matches!(self.step, Step::AskErase { .. }) } + fn options(&self) -> Vec { + use crate::command::CmdOption; + if matches!(self.step, Step::AskErase { .. }) { + vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")] + } else { + Vec::new() + } + } + fn on_text_input(&mut self, text: &str) -> Option { let Step::AskErase { p1, p2 } = &self.step else { return None; diff --git a/src/modules/draw/modify/offset.rs b/src/modules/draw/modify/offset.rs index a2118f12b..72bead8cd 100644 --- a/src/modules/draw/modify/offset.rs +++ b/src/modules/draw/modify/offset.rs @@ -443,6 +443,24 @@ pub struct OffsetCommand { /// Pre-selected offsettable objects (pick-first, #422); consumed when the /// distance step resolves. preselected: Vec, + /// A Yes/No or Current/Source answer is being collected for this option. + awaiting: Option, + /// Erase option: replace the source object with its offset. + erase_source: bool, + /// Layer option: put offsets on the current layer instead of the source's. + layer_current: bool, + /// The side step's targets are document objects (not chained offsets), so + /// Erase can replace them. + targets_are_sources: bool, + /// Offsets committed by this command that Undo can still take back. + made: usize, +} + +/// Which option prompt is waiting for an answer. +#[derive(Clone, Copy, PartialEq)] +enum Await { + Erase, + Layer, } /// The entity types `compute_offsets` can offset. @@ -468,6 +486,11 @@ impl OffsetCommand { entity_index, picked: None, preselected: Vec::new(), + awaiting: None, + erase_source: false, + layer_current: false, + targets_are_sources: false, + made: 0, } } @@ -481,6 +504,11 @@ impl OffsetCommand { entity_index, picked: None, preselected: targets, + awaiting: None, + erase_source: false, + layer_current: false, + targets_are_sources: false, + made: 0, } } @@ -496,9 +524,24 @@ impl OffsetCommand { locked, multiple: false, }; + self.targets_are_sources = true; } CmdResult::NeedPoint } + + /// Undo option: take back the last offset and return to the object pick. + fn undo_last(&mut self) -> Option { + if self.made == 0 { + return None; + } + self.made -= 1; + let locked = match &self.step { + Step::SelectObject { locked } | Step::PickSide { locked, .. } => *locked, + _ => return None, + }; + self.step = Step::SelectObject { locked }; + Some(CmdResult::UndoDocument) + } fn accept_distance(&mut self, distance: f64) -> CmdResult { let distance = distance.abs().max(1e-9); @@ -515,13 +558,23 @@ impl OffsetCommand { impl CadCommand for OffsetCommand { fn preserve_commit_layer(&self) -> bool { - true + !self.layer_current } fn name(&self) -> &'static str { "OFFSET" } fn prompt(&self) -> String { + match self.awaiting { + Some(Await::Erase) => { + return t!("OFFSET Erase source object after offsetting? [Yes/No] :").into_owned() + } + Some(Await::Layer) => { + return t!("OFFSET Enter layer option for offset objects [Current/Source] :") + .into_owned() + } + None => {} + } match &self.step { Step::Distance => { let d = format!("{:.4}", defaults::get_offset_dist()); @@ -580,21 +633,50 @@ impl CadCommand for OffsetCommand { } fn options(&self) -> Vec { + use crate::command::CmdOption; + match self.awaiting { + Some(Await::Erase) => { + return vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")] + } + Some(Await::Layer) => { + return vec![CmdOption::new("Current", "C"), CmdOption::new("Source", "S")] + } + None => {} + } match &self.step { Step::Distance => vec![ - crate::command::CmdOption::new(t!("Through").as_ref(), "T"), - crate::command::CmdOption::enter(&format!( - "{:.4}", - defaults::get_offset_dist() - )), + CmdOption::new("Through", "T"), + CmdOption::new("Erase", "E"), + CmdOption::new("Layer", "L"), + CmdOption::enter(&format!("{:.4}", defaults::get_offset_dist())), ], - Step::PickSide { - multiple: false, .. - } => vec![crate::command::CmdOption::new(t!("Multiple").as_ref(), "M")], - _ => Vec::new(), + Step::SelectObject { .. } => { + let mut opts = Vec::new(); + if self.made > 0 { + opts.push(CmdOption::new("Undo", "U")); + } + opts.push(CmdOption::enter("Exit")); + opts + } + Step::PickSide { multiple, .. } => { + let mut opts = Vec::new(); + if !multiple { + opts.push(CmdOption::new("Multiple", "M")); + } + if self.made > 0 { + opts.push(CmdOption::new("Undo", "U")); + } + opts.push(CmdOption::enter("Exit")); + opts + } + Step::ReferenceSecond { .. } => Vec::new(), } } + fn on_undo_step(&mut self) -> Option { + self.undo_last() + } + fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::SelectObject { .. }) } @@ -631,6 +713,7 @@ impl CadCommand for OffsetCommand { locked, multiple: false, }; + self.targets_are_sources = true; CmdResult::NeedPoint } _ => CmdResult::NeedPoint, @@ -638,7 +721,7 @@ impl CadCommand for OffsetCommand { } // The distance step takes a typed magnitude; the side step accepts one - // too, re-locking the distance mid-command. + // too, re-locking the distance mid-command. The object pick takes Undo. fn wants_text_input(&self) -> bool { matches!(self.step, Step::Distance | Step::PickSide { .. }) } @@ -667,11 +750,46 @@ impl CadCommand for OffsetCommand { fn on_text_input(&mut self, text: &str) -> Option { let t = text.trim().replace(',', "."); + let upper = t.to_uppercase(); + match self.awaiting { + Some(Await::Erase) => { + match upper.as_str() { + "Y" | "YES" => self.erase_source = true, + "N" | "NO" | "" => self.erase_source = false, + _ => return Some(CmdResult::NeedPoint), + } + self.awaiting = None; + return Some(CmdResult::NeedPoint); + } + Some(Await::Layer) => { + match upper.as_str() { + "C" | "CURRENT" => self.layer_current = true, + "S" | "SOURCE" | "" => self.layer_current = false, + _ => return Some(CmdResult::NeedPoint), + } + self.awaiting = None; + return Some(CmdResult::NeedPoint); + } + None => {} + } + if matches!(upper.as_str(), "U" | "UNDO") + && matches!(self.step, Step::SelectObject { .. } | Step::PickSide { .. }) + { + return self.undo_last(); + } match &mut self.step { Step::Distance => { if t.eq_ignore_ascii_case("t") || t.eq_ignore_ascii_case("through") { return Some(self.advance_from_distance(None)); } + if upper == "E" || upper == "ERASE" { + self.awaiting = Some(Await::Erase); + return Some(CmdResult::NeedPoint); + } + if upper == "L" || upper == "LAYER" { + self.awaiting = Some(Await::Layer); + return Some(CmdResult::NeedPoint); + } if let Some(d) = crate::entities::common::parse_typed_length(&t) { return Some(self.accept_distance(d)); } @@ -751,17 +869,30 @@ impl CadCommand for OffsetCommand { }; // Each target offsets by its own through-distance (or the locked // magnitude), toward the clicked side. - let mut news: Vec = Vec::new(); - for entity in &targets { - let mag = locked.unwrap_or_else(|| perp_distance(entity, pt.as_vec3())); - if mag < 1e-9 { - continue; - } - news.extend(compute_offsets(entity, mag, pt.as_vec3())); - } + let per_target: Vec<(Handle, Vec)> = targets + .iter() + .filter_map(|entity| { + let mag = locked.unwrap_or_else(|| perp_distance(entity, pt.as_vec3())); + if mag < 1e-9 { + return None; + } + let offsets = compute_offsets(entity, mag, pt.as_vec3()); + (!offsets.is_empty()).then(|| (entity.common().handle, offsets)) + }) + .collect(); + let mut news: Vec = per_target + .iter() + .flat_map(|(_, offsets)| offsets.iter().cloned()) + .collect(); if news.is_empty() { return CmdResult::NeedPoint; } + self.made += 1; + // Erase option: the offset replaces its source object. Only the + // objects picked from the drawing qualify — a Multiple chain's + // targets are the offsets just made, which are not replaced again. + let erase = self.erase_source && self.targets_are_sources; + self.targets_are_sources = false; if multiple { // Multiple mode chains from the result just created, matching // OFFSET's repeated fixed-distance behavior (parallel/concentric @@ -771,15 +902,14 @@ impl CadCommand for OffsetCommand { locked, multiple: true, }; - return if news.len() == 1 { - CmdResult::CommitEntity(news.pop().unwrap()) - } else { - CmdResult::CommitEntities(news) - }; + } else { + // Classic loop (#418): commit this offset and go back to the object + // pick at the same distance, until Enter / Esc finishes. + self.step = Step::SelectObject { locked }; + } + if erase { + return CmdResult::ReplaceManyContinue(per_target); } - // Classic loop (#418): commit this offset and go back to the object - // pick at the same distance, until Enter / Esc finishes. - self.step = Step::SelectObject { locked }; if news.len() == 1 { CmdResult::CommitEntity(news.pop().unwrap()) } else { @@ -913,3 +1043,55 @@ mod offset_tests { assert!(approx(offset_bbox(&ccw, Vec3::new(50.0, -10.0, 0.0)), out)); } } + +#[cfg(test)] +mod option_tests { + use super::*; + + fn line(x1: f64, y1: f64, x2: f64, y2: f64, handle: u64) -> EntityType { + let mut line = acadrust::entities::Line::from_coords(x1, y1, 0.0, x2, y2, 0.0); + line.common.handle = Handle::new(handle); + EntityType::Line(line) + } + + fn keywords(cmd: &OffsetCommand) -> Vec { + cmd.options().into_iter().map(|o| o.keyword).collect() + } + + #[test] + fn erase_replaces_the_source_object() { + let mut cmd = OffsetCommand::new(vec![line(0.0, 0.0, 10.0, 0.0, 1)]); + assert_eq!(keywords(&cmd), ["T", "E", "L", ""]); + assert!(matches!(cmd.on_text_input("E"), Some(CmdResult::NeedPoint))); + assert_eq!(keywords(&cmd), ["Y", "N"]); + assert!(matches!(cmd.on_text_input("Y"), Some(CmdResult::NeedPoint))); + assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::ReportMeasurement(_)))); + assert!(matches!(cmd.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0)), CmdResult::NeedPoint)); + match cmd.on_point(DVec3::new(5.0, 5.0, 0.0)) { + CmdResult::ReplaceManyContinue(replacements) => { + assert_eq!(replacements.len(), 1); + assert_eq!(replacements[0].0, Handle::new(1)); + assert_eq!(replacements[0].1.len(), 1); + } + _ => panic!("erase should replace the source with its offset"), + } + // Back at the object pick, with Undo now available. + assert_eq!(keywords(&cmd), ["U", ""]); + assert!(matches!(cmd.on_text_input("U"), Some(CmdResult::UndoDocument))); + assert_eq!(keywords(&cmd), [""]); + } + + #[test] + fn layer_current_drops_source_layer_preservation() { + let mut cmd = OffsetCommand::new(vec![line(0.0, 0.0, 10.0, 0.0, 1)]); + assert!(cmd.preserve_commit_layer()); + cmd.on_text_input("L"); + assert_eq!(keywords(&cmd), ["C", "S"]); + cmd.on_text_input("C"); + assert!(!cmd.preserve_commit_layer()); + assert!(matches!(cmd.step, Step::Distance)); + cmd.on_text_input("2"); + cmd.on_entity_pick(Handle::new(1), DVec3::new(5.0, 0.0, 0.0)); + assert!(matches!(cmd.on_point(DVec3::new(5.0, 5.0, 0.0)), CmdResult::CommitEntity(_))); + } +} diff --git a/src/modules/draw/modify/rotate.rs b/src/modules/draw/modify/rotate.rs index 333326e80..ec2ae3b30 100644 --- a/src/modules/draw/modify/rotate.rs +++ b/src/modules/draw/modify/rotate.rs @@ -237,22 +237,22 @@ impl CadCommand for RotateCommand { } // The value already carries the correct sign when it comes // from dynamic input. - let deg: f64 = t.replace(',', ".").parse().ok()?; - Some(self.commit(center, deg.to_radians())) + let angle = crate::entities::common::parse_typed_angle(t)?; + Some(self.commit(center, angle)) } Step::RefFirst { center } => { let center = *center; - let ref_deg: f64 = t.replace(',', ".").parse().ok()?; + let ref_angle = crate::entities::common::parse_typed_angle(t)?; self.step = Step::RefNew { center, - ref_angle: ref_deg.to_radians(), + ref_angle, }; Some(CmdResult::NeedPoint) } Step::RefNew { center, ref_angle } => { let (center, ref_angle) = (*center, *ref_angle); - let new_deg: f64 = t.replace(',', ".").parse().ok()?; - Some(self.commit(center, new_deg.to_radians() - ref_angle)) + let new_angle = crate::entities::common::parse_typed_angle(t)?; + Some(self.commit(center, new_angle - ref_angle)) } Step::Center | Step::RefSecond { .. } => None, } diff --git a/src/modules/draw/modify/scale.rs b/src/modules/draw/modify/scale.rs index 1fc8d242f..a9ce25b20 100644 --- a/src/modules/draw/modify/scale.rs +++ b/src/modules/draw/modify/scale.rs @@ -179,12 +179,12 @@ impl CadCommand for ScaleCommand { return Some(CmdResult::NeedPoint); } let factor: f64 = t.replace(',', ".").parse().ok()?; - (factor > 0.0).then(|| self.commit(base, factor)) + (factor.is_finite() && factor > 0.0).then(|| self.commit(base, factor)) } Step::RefFirst { base } => { let base = *base; let ref_dist: f64 = t.replace(',', ".").parse().ok()?; - if ref_dist > 0.0 { + if ref_dist.is_finite() && ref_dist > 0.0 { self.step = Step::RefNew { base, ref_dist }; return Some(CmdResult::NeedPoint); } @@ -194,7 +194,8 @@ impl CadCommand for ScaleCommand { Step::RefNew { base, ref_dist } => { let (base, ref_dist) = (*base, *ref_dist); let new_len: f64 = t.replace(',', ".").parse().ok()?; - (new_len > 0.0).then(|| self.commit(base, new_len / ref_dist)) + let factor = new_len / ref_dist; + (factor.is_finite() && factor > 0.0).then(|| self.commit(base, factor)) } Step::Base => None, } diff --git a/src/modules/draw/modify/torient.rs b/src/modules/draw/modify/torient.rs index 34808b043..593abaaa5 100644 --- a/src/modules/draw/modify/torient.rs +++ b/src/modules/draw/modify/torient.rs @@ -151,8 +151,8 @@ impl CadCommand for TorientCommand { fn on_text_input(&mut self, text: &str) -> Option { if let Step::AngleOrFirstPoint = self.step { - let deg: f64 = text.trim().replace(',', ".").parse().ok()?; - Some(self.commit_angle(Some(deg.to_radians()))) + let angle = crate::entities::common::parse_typed_angle(text)?; + Some(self.commit_angle(Some(angle))) } else { None } diff --git a/src/modules/draw/modify/translate.rs b/src/modules/draw/modify/translate.rs index 061d2dc82..7c52b7209 100644 --- a/src/modules/draw/modify/translate.rs +++ b/src/modules/draw/modify/translate.rs @@ -2,8 +2,9 @@ // // Command: MOVE (M) // Requires at least one entity selected before starting. -// Step 1: pick base point -// Step 2: pick destination → translates all selected entities by (dest - base) +// Step 1: pick base point, or D = type the displacement vector directly +// Step 2: pick destination → translates all selected entities by (dest - base); +// Enter uses the base point as the displacement (commercial solutions). use acadrust::Handle; use glam::DVec3; @@ -28,6 +29,8 @@ pub fn tool() -> ToolDef { enum Step { Base, Target(DVec3), + /// Displacement option: the next point / typed coordinate is the vector. + Displacement, } pub struct MoveCommand { @@ -62,6 +65,16 @@ impl CadCommand for MoveCommand { x = format!("{:.3}", base.x), y = format!("{:.3}", base.y), ), + Step::Displacement => crate::tr!("command-move", "displacement"), + } + } + + fn options(&self) -> Vec { + use crate::command::CmdOption; + match self.step { + Step::Base => vec![CmdOption::new("Displacement", "D")], + Step::Target(_) => vec![CmdOption::enter("Use first point as displacement")], + Step::Displacement => Vec::new(), } } @@ -78,11 +91,42 @@ impl CadCommand for MoveCommand { EntityTransform::Translate(delta), ) } + // The point (typically typed as `dx,dy`) is the displacement itself. + Step::Displacement => CmdResult::TransformSelected( + self.handles.clone(), + EntityTransform::Translate(pt), + ), + } + } + + fn wants_text_input(&self) -> bool { + matches!(self.step, Step::Base) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::Base) + } + + fn on_text_input(&mut self, text: &str) -> Option { + match (&self.step, text.trim().to_uppercase().as_str()) { + (Step::Base, "D" | "DISPLACEMENT") => { + self.step = Step::Displacement; + Some(CmdResult::NeedPoint) + } + _ => None, } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + // Commercial solutions: Enter at the second point uses the base point as the + // displacement from the origin. + Step::Target(base) => CmdResult::TransformSelected( + self.handles.clone(), + EntityTransform::Translate(base), + ), + _ => CmdResult::Cancel, + } } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel @@ -111,3 +155,40 @@ impl CadCommand for MoveCommand { out } } + +#[cfg(test)] +mod tests { + use super::*; + + fn keywords(cmd: &MoveCommand) -> Vec { + cmd.options().into_iter().map(|o| o.keyword).collect() + } + + #[test] + fn displacement_option_translates_by_vector() { + let mut cmd = MoveCommand::new(vec![Handle::new(1)], vec![]); + assert_eq!(keywords(&cmd), ["D"]); + assert!(matches!(cmd.on_text_input("D"), Some(CmdResult::NeedPoint))); + assert!(keywords(&cmd).is_empty()); + match cmd.on_point(DVec3::new(3.0, 4.0, 0.0)) { + CmdResult::TransformSelected(handles, EntityTransform::Translate(d)) => { + assert_eq!(handles, vec![Handle::new(1)]); + assert_eq!(d, DVec3::new(3.0, 4.0, 0.0)); + } + _ => panic!("expected a translation"), + } + } + + #[test] + fn enter_at_second_point_uses_base_as_displacement() { + let mut cmd = MoveCommand::new(vec![Handle::new(1)], vec![]); + cmd.on_point(DVec3::new(5.0, -2.0, 0.0)); + assert_eq!(keywords(&cmd), [""], "Enter is the only option at the second point"); + match cmd.on_enter() { + CmdResult::TransformSelected(_, EntityTransform::Translate(d)) => { + assert_eq!(d, DVec3::new(5.0, -2.0, 0.0)); + } + _ => panic!("expected a translation by the base point"), + } + } +} diff --git a/src/modules/draw/select.rs b/src/modules/draw/select.rs index d695ce6d7..94f78b3bc 100644 --- a/src/modules/draw/select.rs +++ b/src/modules/draw/select.rs @@ -68,6 +68,21 @@ impl SelectObjectsCommand { } } + /// Standard selection behavior routed to a private apply command while + /// retaining the public command name in the visible prompt. + pub fn routed(prompt_cmd: &str, pending_cmd: &str) -> Self { + Self { + prompt_cmd: prompt_cmd.to_string(), + pending_cmd: pending_cmd.to_string(), + handles: Vec::new(), + commit_on_enter: true, + show_options: true, + pick: None, + pick_crossing: true, + auto_constrain_settings: false, + } + } + /// Single-object variant: the first completed selection action applies /// immediately, with no Enter (used by commands that act on one object). pub fn instant(pending_cmd: &str) -> Self { diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 5e4d5d129..234572e3f 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -472,6 +472,9 @@ impl CadCommand for ExtrudeCommand { fn dyn_live_value(&self, cursor: DVec3) -> Option { Some((cursor - self.anchor).dot(self.profile_direction?)) } + fn dyn_commit_as_text(&self) -> bool { + self.step == ExtrudeStep::Height + } fn is_selection_gathering(&self) -> bool { self.step == ExtrudeStep::Pick } @@ -2839,6 +2842,33 @@ inventory::submit!(crate::command::CommandRegistration { inventory::submit!(crate::command::CommandRegistration { names: &["LOFT"] }); inventory::submit!(crate::command::CommandRegistration { names: &["REVOLVE"] }); +#[cfg(test)] +mod extrude_command_tests { + use super::*; + + #[test] + fn typed_height_commits_through_the_text_path() { + let mut command = ExtrudeCommand::new_named("EXTRUDE", [1.0; 4]); + assert!(!command.dyn_commit_as_text()); + command.set_preselection( + vec![( + Handle::new(1), + EntityType::Circle(acadrust::entities::Circle::new()), + )], + DVec3::ZERO, + Some(DVec3::Z), + ); + assert!(command.dyn_commit_as_text()); + assert!(matches!( + command.on_text_input("5"), + Some(CmdResult::ExtrudeEntities { + extent: ExtrudeExtent::Height(5.0), + .. + }) + )); + } +} + #[cfg(test)] mod revolve_tests { use super::*; diff --git a/src/modules/insert/xattach.rs b/src/modules/insert/xattach.rs index c8c45d5cf..9124421b5 100644 --- a/src/modules/insert/xattach.rs +++ b/src/modules/insert/xattach.rs @@ -2,11 +2,11 @@ // and insert it at a picked point. // // Workflow: -// Step 1 (text input): user types the file path (or the file-picker -// message has already supplied it). -// Step 2 (point pick): user clicks the insertion point. +// Step 1 (point pick): user clicks the insertion point. +// Step 2 (text input): scale factor (default 1.0). +// Step 3 (text input): rotation angle (default 0.0). // Result: BlockRecord + Block entities are created with is_xref=true, -// then an INSERT entity is committed. +// then an INSERT entity is committed with the chosen scale/rotation. use acadrust::entities::{Block, BlockEnd, Insert}; use acadrust::tables::block_record::{BlockFlags, BlockRecord}; @@ -16,7 +16,8 @@ use glam::DVec3; use crate::t; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane}; +use crate::io::xref_model::normalize_lexical; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::scene::Scene; @@ -30,10 +31,20 @@ pub fn tool() -> ToolDef { } } +/// Multi-step placement state: insertion point, then scale, then rotation. +/// Mirrors the INSERT command's scale/rotation value handling (single value +/// typed after the point pick, bare Enter keeps the default). +enum XAttachStep { + Point, + Scale { point: DVec3 }, + Rotate { point: DVec3, sx: f64, sy: f64, sz: f64 }, +} + pub struct XAttachCommand { path: String, block_name: String, plane: WorkingPlane, + step: XAttachStep, } impl XAttachCommand { @@ -44,8 +55,21 @@ impl XAttachCommand { path, block_name, plane: WorkingPlane::default(), + step: XAttachStep::Point, } } + + fn commit_insert(&self, point: DVec3, sx: f64, sy: f64, sz: f64, rotation: f64) -> EntityType { + let mut ins = Insert::new( + self.block_name.clone(), + Vector3::new(point.x, point.y, point.z), + ); + ins.set_x_scale(sx); + ins.set_y_scale(sy); + ins.set_z_scale(sz); + ins.rotation = rotation; + self.plane.place_entity(EntityType::Insert(ins)) + } } impl CadCommand for XAttachCommand { @@ -58,28 +82,102 @@ impl CadCommand for XAttachCommand { } fn prompt(&self) -> String { - t!( - "XATTACH Specify insertion point for \"%{name}\":", - name = self.block_name - ) - .into_owned() + match &self.step { + XAttachStep::Point => t!( + "XATTACH Specify insertion point for \"%{name}\":", + name = self.block_name + ) + .into_owned(), + XAttachStep::Scale { .. } => { + t!("XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner):").into_owned() + } + XAttachStep::Rotate { .. } => { + t!("XATTACH Specify rotation angle <0>:").into_owned() + } + } + } + + fn input_kind(&self) -> InputKind { + match self.step { + XAttachStep::Point => InputKind::Point, + XAttachStep::Scale { .. } | XAttachStep::Rotate { .. } => InputKind::SingleToken, + } } fn on_point(&mut self, pt: DVec3) -> CmdResult { - // We return the INSERT entity; the command handler in commands.rs - // calls `prepare_xref_block` on the scene before committing. - let point = self.plane.to_local(pt); - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Insert(Insert::new( - self.block_name.clone(), - Vector3::new(point.x, point.y, point.z), - )))) + match self.step { + XAttachStep::Point => { + let point = self.plane.to_local(pt); + self.step = XAttachStep::Scale { point }; + CmdResult::NeedPoint + } + XAttachStep::Scale { point } => { + // Corner: derive X/Y scale from the picked opposite corner. + let corner = self.plane.to_local(pt); + let sx = (corner.x - point.x).abs(); + let sy = (corner.y - point.y).abs(); + self.step = XAttachStep::Rotate { + point, + sx: if sx < 1e-9 { 1.0 } else { sx }, + sy: if sy < 1e-9 { 1.0 } else { sy }, + sz: 1.0, + }; + CmdResult::NeedPoint + } + XAttachStep::Rotate { point, sx, sy, sz } => { + // Second point defines the rotation direction from the base. + let base_world = self.plane.to_world(point); + let rotation = self.plane.angle(base_world, pt).unwrap_or(0.0); + CmdResult::CommitAndExit(self.commit_insert(point, sx, sy, sz, rotation)) + } + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + match self.step { + XAttachStep::Point => None, + XAttachStep::Scale { point } => { + // Single value (uniform) or X/Y/Z triplet; invalid input is + // consumed and re-prompts (mirrors INSERT's scale handling). + if let Some((sx, sy, sz)) = parse_scale_spec(text) { + self.step = XAttachStep::Rotate { point, sx, sy, sz }; + } + Some(CmdResult::NeedPoint) + } + XAttachStep::Rotate { point, sx, sy, sz } => { + if let Some(rotation) = crate::entities::common::parse_typed_angle(text) { + Some(CmdResult::CommitAndExit( + self.commit_insert(point, sx, sy, sz, rotation), + )) + } else { + Some(CmdResult::NeedPoint) + } + } + } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + // Bare Enter at the insertion-point step cancels (unchanged). + XAttachStep::Point => CmdResult::Cancel, + // Bare Enter accepts the default scale / rotation. + XAttachStep::Scale { point } => { + self.step = XAttachStep::Rotate { + point, + sx: 1.0, + sy: 1.0, + sz: 1.0, + }; + CmdResult::NeedPoint + } + XAttachStep::Rotate { point, sx, sy, sz } => { + CmdResult::CommitAndExit(self.commit_insert(point, sx, sy, sz, 0.0)) + } + } } fn on_preview_wires(&mut self, _pt: DVec3) -> Vec { + // XREF-Task6: PScale drag preview — no framework hook; revisit vec![] } @@ -96,19 +194,138 @@ pub fn path_to_block_name(path: &str) -> String { .unwrap_or_else(|| "XREF".to_string()) } +/// Collision-free block name: `stem` unless taken (case-insensitive — block +/// names are uppercase by convention), else `stem_1`, `stem_2`, ... +/// (`$0$` numbering belongs to Bind, not Attach.) +pub fn unique_block_name(stem: &str, taken: &[impl AsRef]) -> String { + let upper_taken: Vec = taken.iter().map(|t| t.as_ref().to_uppercase()).collect(); + if !upper_taken.iter().any(|t| t == &stem.to_uppercase()) { + return stem.to_string(); + } + let mut n = 1u32; + loop { + let candidate = format!("{stem}_{n}"); + if !upper_taken + .iter() + .any(|t| *t == candidate.to_uppercase()) + { + return candidate; + } + n += 1; + } +} + +/// Attach-default-Full store path: a relative incoming path is joined onto +/// the host drawing's base dir (yielding an absolute path when the base dir +/// is absolute); absolute paths and missing base dirs pass through verbatim. +/// Separators are normalized to `/` for stable DWG storage. +fn is_absolute_xref_path(path: &str) -> bool { + let bytes = path.as_bytes(); + std::path::Path::new(path).is_absolute() + || path.starts_with("\\\\") + || (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\')) +} + +pub fn resolve_xref_store_path(path: &str, host_base_dir: Option<&std::path::Path>) -> String { + let p = std::path::Path::new(path); + if is_absolute_xref_path(path) { + return path.replace('\\', "/"); + } + match host_base_dir { + Some(base) => base.join(p).to_string_lossy().replace('\\', "/"), + None => path.to_string(), + } +} + +// Host-path note (XREF-Task6): `Scene` exposes no host file path — it owns +// the document plus view state only (verified in scene/mod.rs: no file path +// field). The host drawing path lives one layer up, in `Tab::current_path` +// (app/document.rs). The self-attach guard therefore runs at the command +// driver layer, which owns both the tab path and the pending XATTACH path, +// via this pure, unit-tested helper. `prepare_xref_block` below stays +// side-effect-only and keeps its `-> String` return. + +/// True when attaching `ref_path` would attach the host drawing into itself. +/// Lexical comparison only (no filesystem touch — the target may be missing); +/// a relative ref is resolved against `host_base_dir` (falling back to the +/// host file's parent) before comparing. +pub fn is_self_attach( + host_file: &std::path::Path, + ref_path: &str, + host_base_dir: Option<&std::path::Path>, +) -> bool { + let joined = resolve_xref_store_path(ref_path, host_base_dir); + let candidate = if is_absolute_xref_path(&joined) { + joined + } else if let Some(parent) = host_file.parent() { + parent + .join(&joined) + .to_string_lossy() + .replace('\\', "/") + } else { + joined + }; + normalize_lexical(&candidate) == normalize_lexical(&host_file.to_string_lossy()) +} + +/// Parse a typed scale: a single uniform value, or an X/Y/Z triplet separated +/// by commas, semicolons or whitespace. Zero never scales (rejected, like +/// INSERT); unparseable input returns None and the caller re-prompts. +fn parse_scale_spec(text: &str) -> Option<(f64, f64, f64)> { + let norm = text.trim().replace([',', ';'], " "); + let parts: Vec<&str> = norm.split_whitespace().collect(); + match parts.as_slice() { + [single] => single + .parse::() + .ok() + .filter(|v| *v != 0.0) + .map(|v| (v, v, 1.0)), + [x, y, z] => { + let parsed: Option> = + [x, y, z].iter().map(|s| s.parse::().ok()).collect(); + match parsed { + Some(v) if v.iter().all(|n| *n != 0.0) => Some((v[0], v[1], v[2])), + _ => None, + } + } + _ => None, + } +} + /// Create the XREF BlockRecord + Block/EndBlock entities in the scene document /// for a given file path. Returns the block name. /// /// This must be called before committing the INSERT so that the block /// definition exists when the renderer looks it up. -pub fn prepare_xref_block(scene: &mut Scene, path: &str) -> String { - let block_name = path_to_block_name(path); +/// +/// Name collisions get a `_1`, `_2`... suffix via [`unique_block_name`]; the +/// stored path follows Attach-default-Full via [`resolve_xref_store_path`]. +/// Self-attach blocking is NOT done here — see the note on [`is_self_attach`]; +/// the driver checks it before calling. +pub fn prepare_xref_block( + scene: &mut Scene, + path: &str, + host_base_dir: Option<&std::path::Path>, +) -> String { + let stem = path_to_block_name(path); + let taken: Vec = scene + .document + .block_records + .iter() + .map(|b| b.name.clone()) + .collect(); + let block_name = unique_block_name(&stem, &taken); // If a BlockRecord already exists with this name, skip creation. if scene.document.block_records.get(&block_name).is_some() { return block_name; } + let store_path = resolve_xref_store_path(path, host_base_dir); + // Create the BlockRecord. let mut br = BlockRecord::new(&block_name); br.handle = scene.document.allocate_handle(); @@ -119,21 +336,165 @@ pub fn prepare_xref_block(scene: &mut Scene, path: &str) -> String { has_attributes: false, is_external: false, }; - br.xref_path = path.to_string(); + br.xref_path = store_path.clone(); let _ = scene.document.block_records.add(br); // Create BLOCK entity. - let b = Block::new(&block_name, Vector3::zero()).with_xref_path(path); + let b = Block::new(&block_name, Vector3::zero()).with_xref_path(&store_path); let _ = scene.document.add_entity(EntityType::Block(b)); let _ = scene .document .add_entity(EntityType::BlockEnd(BlockEnd::new())); // Resolve the XREF content immediately. - let path_buf = std::path::PathBuf::from(path); - if let Some(base_dir) = path_buf.parent() { + // Every reference in this host resolves relative to the host drawing, not + // relative to the newly-attached file. Using `store_path.parent()` here + // made an attach unexpectedly redirect existing relative xrefs. + if let Some(base_dir) = host_base_dir { + let _ = crate::io::xref::resolve_xrefs(&mut scene.document, base_dir); + } else if let Some(base_dir) = std::path::Path::new(&store_path).parent() { let _ = crate::io::xref::resolve_xrefs(&mut scene.document, base_dir); } block_name } + +#[cfg(test)] +mod tests { + use super::{ + is_self_attach, parse_scale_spec, path_to_block_name, resolve_xref_store_path, + unique_block_name, XAttachCommand, + }; + use crate::command::{CadCommand, CmdResult}; + use acadrust::EntityType; + use glam::DVec3; + + #[test] + fn block_name_collision_gets_suffix() { + assert_eq!(unique_block_name("PLAN", &["PLAN"]), "PLAN_1"); + assert_eq!(unique_block_name("PLAN", &["PLAN", "PLAN_1"]), "PLAN_2"); + } + + #[test] + fn unique_block_name_is_case_insensitive() { + assert_eq!(unique_block_name("PLAN", &["plan"]), "PLAN_1"); + assert_eq!(unique_block_name("plan", &["PLAN", "plan_1"]), "plan_2"); + assert_eq!(unique_block_name("NEW", &["PLAN"]), "NEW"); + } + + #[test] + fn block_name_derives_from_stem() { + assert_eq!(path_to_block_name("C:/refs/plan.dwg"), "PLAN"); + } + + #[test] + fn relative_ref_resolves_against_host_base() { + let base = std::path::Path::new("C:/Drawings"); + assert_eq!( + resolve_xref_store_path("refs/plan.dwg", Some(base)), + "C:/Drawings/refs/plan.dwg" + ); + assert_eq!( + resolve_xref_store_path("C:/Lib/plan.dwg", Some(base)), + "C:/Lib/plan.dwg" + ); + assert_eq!(resolve_xref_store_path("refs/plan.dwg", None), "refs/plan.dwg"); + } + + #[test] + fn self_attach_guard_matches_normalized_paths() { + let host = std::path::Path::new("C:/Drawings/host.dwg"); + let base = std::path::Path::new("C:/Drawings"); + // Verbatim, drive-letter case, dot segments, and base-relative forms. + assert!(is_self_attach(host, "C:/Drawings/host.dwg", Some(base))); + assert!(is_self_attach(host, "c:/Drawings/host.dwg", Some(base))); + assert!(is_self_attach(host, "C:/Drawings/./host.dwg", Some(base))); + assert!(is_self_attach(host, "host.dwg", Some(base))); + assert!(is_self_attach(host, "C:/Drawings/host.dwg", None)); + // Anything else attaches freely. + assert!(!is_self_attach(host, "C:/Drawings/other.dwg", Some(base))); + assert!(!is_self_attach(host, "other.dwg", Some(base))); + assert!(!is_self_attach(host, "other.dwg", None)); + } + + #[test] + fn scale_spec_parses_single_and_triplet() { + assert_eq!(parse_scale_spec("2"), Some((2.0, 2.0, 1.0))); + assert_eq!(parse_scale_spec("2,3,4"), Some((2.0, 3.0, 4.0))); + assert_eq!(parse_scale_spec("2 3 4"), Some((2.0, 3.0, 4.0))); + assert_eq!(parse_scale_spec("0"), None); + assert_eq!(parse_scale_spec("abc"), None); + assert_eq!(parse_scale_spec("1,2"), None); + } + + #[test] + fn enter_at_insertion_point_cancels() { + let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); + assert!(matches!(cmd.on_enter(), CmdResult::Cancel)); + } + + #[test] + fn placement_flow_scale_then_rotate_commits() { + let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); + assert!(matches!( + cmd.on_point(DVec3::new(10.0, 20.0, 0.0)), + CmdResult::NeedPoint + )); + assert!(cmd.prompt().contains("scale")); + assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::NeedPoint))); + assert!(cmd.prompt().contains("rotation")); + match cmd.on_enter() { + CmdResult::CommitAndExit(EntityType::Insert(ins)) => { + assert_eq!(ins.block_name, "PLAN"); + assert!((ins.x_scale() - 2.0).abs() < 1e-9); + assert!((ins.y_scale() - 2.0).abs() < 1e-9); + assert!(ins.rotation.abs() < 1e-9); + assert!((ins.insert_point.x - 10.0).abs() < 1e-9); + assert!((ins.insert_point.y - 20.0).abs() < 1e-9); + } + _ => panic!("expected CommitAndExit(Insert)"), + } + } + + #[test] + fn placement_flow_triplet_and_typed_rotation() { + let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); + assert!(matches!( + cmd.on_point(DVec3::ZERO), + CmdResult::NeedPoint + )); + assert!(matches!( + cmd.on_text_input("2,3,4"), + Some(CmdResult::NeedPoint) + )); + match cmd.on_text_input("90") { + Some(CmdResult::CommitAndExit(EntityType::Insert(ins))) => { + assert!((ins.x_scale() - 2.0).abs() < 1e-9); + assert!((ins.y_scale() - 3.0).abs() < 1e-9); + assert!((ins.z_scale() - 4.0).abs() < 1e-9); + assert!((ins.rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-6); + } + _ => panic!("expected CommitAndExit(Insert)"), + } + } + + #[test] + fn placement_flow_corner_pick_sets_xy_scale() { + let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string()); + assert!(matches!( + cmd.on_point(DVec3::ZERO), + CmdResult::NeedPoint + )); + assert!(matches!( + cmd.on_point(DVec3::new(3.0, 4.0, 0.0)), + CmdResult::NeedPoint + )); + match cmd.on_enter() { + CmdResult::CommitAndExit(EntityType::Insert(ins)) => { + assert!((ins.x_scale() - 3.0).abs() < 1e-9); + assert!((ins.y_scale() - 4.0).abs() < 1e-9); + } + _ => panic!("expected CommitAndExit(Insert)"), + } + } +} diff --git a/src/modules/parametric/concentric.rs b/src/modules/parametric/concentric.rs new file mode 100644 index 000000000..448c3227c --- /dev/null +++ b/src/modules/parametric/concentric.rs @@ -0,0 +1,185 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::scene::parametric_constraints::ParametricRef; +use crate::t; + +pub struct ConcentricConstraintCommand { + first: Option, + picked_entity: Option, +} + +impl ConcentricConstraintCommand { + pub fn new() -> Self { + Self { + first: None, + picked_entity: None, + } + } + + pub fn with_first(first: ParametricRef) -> Self { + Self { + first: Some(first), + picked_entity: None, + } + } + + fn invalid_selection() -> CmdResult { + CmdResult::ReportError( + t!("Invalid selection for Concentric. Select a circle, arc, ellipse or polyline arc segment.") + .into_owned(), + ) + } + + fn polyline_segment_is_arc(entity: &EntityType, index: usize) -> bool { + match entity { + EntityType::LwPolyline(polyline) => polyline + .vertices + .get(index) + .is_some_and(|vertex| vertex.bulge.abs() > 1.0e-12), + EntityType::Polyline2D(polyline) => polyline + .vertices + .get(index) + .is_some_and(|vertex| vertex.bulge.abs() > 1.0e-12), + _ => false, + } + } + + fn picked_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + match entity { + EntityType::Circle(_) | EntityType::Arc(_) | EntityType::Ellipse(_) => { + Some(ParametricRef::center(handle)) + } + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let planar = crate::entities::curve::entity_curve(entity)?; + let local = planar.plane.project(point.to_array())?; + let segments = planar.curve.segments(); + let (index, _) = cadkernel::geom2d::nearest_of(segments.iter(), local)?; + matches!(segments.get(index), Some(cadkernel::geom2d::Curve::Arc(_))) + .then(|| ParametricRef::segment_center(handle, index)) + } + _ => None, + } + } + + pub fn preselected_reference(entity: &EntityType, handle: Handle) -> Option { + match entity { + EntityType::Circle(_) | EntityType::Arc(_) | EntityType::Ellipse(_) => { + Some(ParametricRef::center(handle)) + } + EntityType::LwPolyline(polyline) => { + let segment_count = if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + (segment_count == 1 && Self::polyline_segment_is_arc(entity, 0)) + .then(|| ParametricRef::segment_center(handle, 0)) + } + EntityType::Polyline2D(polyline) => { + let segment_count = if polyline.is_closed() { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + (segment_count == 1 && Self::polyline_segment_is_arc(entity, 0)) + .then(|| ParametricRef::segment_center(handle, 0)) + } + _ => None, + } + } +} + +impl CadCommand for ConcentricConstraintCommand { + fn name(&self) -> &'static str { + "GCCONCENTRIC" + } + + fn prompt(&self) -> String { + if self.first.is_some() { + t!("GCCONCENTRIC Select second object:").into_owned() + } else { + t!("GCCONCENTRIC Select first object:").into_owned() + } + } + + fn needs_entity_pick(&self) -> bool { + true + } + + 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() { + return CmdResult::NeedPoint; + } + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(reference) = Self::picked_reference(&entity, handle, point) else { + return Self::invalid_selection(); + }; + let Some(first) = self.first else { + self.first = Some(reference); + return CmdResult::NeedPoint; + }; + if first == reference { + return Self::invalid_selection(); + } + CmdResult::AddConcentricConstraint { + first, + second: reference, + label: "Concentric constraint", + } + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn polyline_pick_keeps_the_selected_arc_center() { + let handle = Handle::new(13); + let mut polyline = acadrust::entities::LwPolyline::new(); + polyline.vertices = vec![ + acadrust::entities::LwVertex::from_coords(0.0, 0.0), + acadrust::entities::LwVertex::with_bulge( + acadrust::types::Vector2::new(5.0, 0.0), + 1.0, + ), + acadrust::entities::LwVertex::from_coords(10.0, 0.0), + ]; + + let reference = ConcentricConstraintCommand::picked_reference( + &EntityType::LwPolyline(polyline), + handle, + DVec3::new(8.0, 2.0, 0.0), + ) + .expect("arc segment"); + assert_eq!(reference, ParametricRef::segment_center(handle, 1)); + } +} diff --git a/src/modules/parametric/constraint_bar.rs b/src/modules/parametric/constraint_bar.rs new file mode 100644 index 000000000..76ae1d0a3 --- /dev/null +++ b/src/modules/parametric/constraint_bar.rs @@ -0,0 +1,102 @@ +use acadrust::Handle; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult}; + +pub struct ConstraintBarOptionCommand { + handles: Vec, +} + +impl ConstraintBarOptionCommand { + pub fn new(handles: Vec) -> Self { + Self { handles } + } + + fn finish(&mut self, command: &str) -> CmdResult { + CmdResult::Relaunch(command.to_string(), std::mem::take(&mut self.handles)) + } +} + +impl CadCommand for ConstraintBarOptionCommand { + fn name(&self) -> &'static str { + "CONSTRAINTBAR" + } + + fn prompt(&self) -> String { + "CONSTRAINTBAR Enter an option [Show/Hide/Reset] :".to_string() + } + + fn options(&self) -> Vec { + vec![ + CmdOption::new("Show", "S"), + CmdOption::new("Hide", "H"), + CmdOption::new("Reset", "R"), + ] + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + match keyword.as_str() { + "S" | "SHOW" => Some(self.finish("GCSHOW")), + "H" | "HIDE" => Some(self.finish("GCHIDE")), + "R" | "RESET" => Some(self.finish("CONSTRAINTBAR_RESET")), + _ => None, + } + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + self.finish("GCSHOW") + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["CONSTRAINTBAR"] +}); + +#[cfg(test)] +mod tests { + use super::*; + + fn relaunch(result: CmdResult) -> (String, Vec) { + let CmdResult::Relaunch(command, handles) = result else { + panic!("expected relaunch"); + }; + (command, handles) + } + + #[test] + fn enter_defaults_to_show_and_keeps_the_selection() { + let handles = vec![Handle::new(2), Handle::new(5)]; + let mut command = ConstraintBarOptionCommand::new(handles.clone()); + + assert_eq!(relaunch(command.on_enter()), ("GCSHOW".to_string(), handles)); + } + + #[test] + fn hide_and_reset_route_to_their_apply_commands() { + let handle = Handle::new(7); + let mut hide = ConstraintBarOptionCommand::new(vec![handle]); + assert_eq!( + relaunch(hide.on_text_input("hide").unwrap()), + ("GCHIDE".to_string(), vec![handle]) + ); + + let mut reset = ConstraintBarOptionCommand::new(vec![handle]); + assert_eq!( + relaunch(reset.on_text_input("r").unwrap()), + ("CONSTRAINTBAR_RESET".to_string(), vec![handle]) + ); + } +} diff --git a/src/modules/parametric/equal.rs b/src/modules/parametric/equal.rs new file mode 100644 index 000000000..fde973082 --- /dev/null +++ b/src/modules/parametric/equal.rs @@ -0,0 +1,247 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult}; +use crate::scene::parametric_constraints::ParametricRef; + +use super::HorizontalConstraintCommand; + +#[derive(Clone, Copy)] +enum Step { + /// `Select first object or [Multiple]:`, or `Select first object:` + /// once Multiple was chosen. + First { multiple: bool }, + /// `Select second object:` — the one that takes the first's size. + Second(ParametricRef), + /// `Select object to make equal to first:` — every pick follows the + /// first at once, Enter ends the command. + Others(ParametricRef), +} + +/// Interactive front end for the Equal geometric constraint: the first +/// object sets the size, the second (or, with Multiple, each further pick) +/// takes its length or radius. Objects are picked inside the command; a +/// selection made before it starts is not used. +pub struct EqualConstraintCommand { + step: Step, + picked_entity: Option, +} + +impl EqualConstraintCommand { + pub const NO_OBJECT: &'static str = "No object found."; + pub const INVALID_OBJECT: &'static str = + "Invalid selection for Equal. Select a line, polyline segment, circle or arc."; + pub const SAME_OBJECT: &'static str = + "The object or point is already selected. Select a different object or constraint point."; + + pub fn new() -> Self { + Self { + step: Step::First { multiple: false }, + picked_entity: None, + } + } + + /// The whole line, circle or arc, or the picked straight polyline + /// segment. + pub fn picked_reference( + entity: &EntityType, + handle: Handle, + point: DVec3, + ) -> Option { + match entity { + EntityType::Line(_) | EntityType::Circle(_) | EntityType::Arc(_) => { + Some(ParametricRef::whole(handle)) + } + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let (source, _, _) = + crate::scene::centerline::picked_source(entity, handle, point)?; + let index = usize::try_from(source.segment_index).ok()?; + HorizontalConstraintCommand::segment_is_straight(entity, index) + .then_some(ParametricRef::segment(handle, index)) + } + _ => None, + } + } + + fn report(message: &str) -> CmdResult { + CmdResult::ReportError(message.to_string()) + } + + fn finish(first: ParametricRef, others: Vec, multiple: bool) -> CmdResult { + CmdResult::AddEqualConstraint { + first, + others, + multiple, + label: "Equal constraint", + } + } +} + +impl CadCommand for EqualConstraintCommand { + fn name(&self) -> &'static str { + "GCEQUAL" + } + + fn prompt(&self) -> String { + match self.step { + Step::First { multiple: false } => { + "GCEQUAL Select first object or [Multiple]:".to_string() + } + Step::First { multiple: true } => "GCEQUAL Select first object:".to_string(), + Step::Second(_) => "GCEQUAL Select second object:".to_string(), + Step::Others(_) => "GCEQUAL Select object to make equal to first:".to_string(), + } + } + + fn options(&self) -> Vec { + matches!(self.step, Step::First { multiple: false }) + .then(|| vec![CmdOption::new("Multiple", "M")]) + .unwrap_or_default() + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::First { multiple: false }) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + if matches!(self.step, Step::First { multiple: false }) + && matches!(keyword.as_str(), "M" | "MULTIPLE") + { + self.step = Step::First { multiple: true }; + return Some(CmdResult::NeedPoint); + } + None + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn entity_pick_accepts_points(&self) -> bool { + true + } + + fn typed_point_picks_entity(&self) -> bool { + true + } + + 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() { + return Self::report(Self::NO_OBJECT); + } + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(reference) = Self::picked_reference(&entity, handle, point) else { + return Self::report(Self::INVALID_OBJECT); + }; + match self.step { + Step::First { multiple: false } => { + self.step = Step::Second(reference); + CmdResult::NeedPoint + } + Step::First { multiple: true } => { + self.step = Step::Others(reference); + CmdResult::NeedPoint + } + Step::Second(first) | Step::Others(first) if first == reference => { + Self::report(Self::SAME_OBJECT) + } + Step::Second(first) => Self::finish(first, vec![reference], false), + Step::Others(first) => Self::finish(first, vec![reference], true), + } + } + + // An empty-space click, or a typed coordinate with nothing under it. + fn on_point(&mut self, _point: DVec3) -> CmdResult { + Self::report(Self::NO_OBJECT) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + // Multiple ends with its summary line; the host prints it. + Step::Others(first) => Self::finish(first, Vec::new(), true), + _ => CmdResult::Cancel, + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["ECONSTRAINT", "GCEQUAL"] +}); + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::Line; + use acadrust::types::Vector3; + + fn line() -> EntityType { + EntityType::Line(Line::from_points(Vector3::ZERO, Vector3::new(10.0, 0.0, 0.0))) + } + + #[test] + fn two_picks_make_the_second_follow_the_first() { + let mut command = EqualConstraintCommand::new(); + command.inject_picked_entity(line()); + assert!(matches!( + command.on_entity_pick(Handle::new(7), DVec3::ZERO), + CmdResult::NeedPoint + )); + command.inject_picked_entity(line()); + let CmdResult::AddEqualConstraint { + first, + others, + multiple, + .. + } = command.on_entity_pick(Handle::new(8), DVec3::ZERO) + else { + panic!("the second pick must create the constraint"); + }; + assert_eq!(first, ParametricRef::whole(Handle::new(7))); + assert_eq!(others, vec![ParametricRef::whole(Handle::new(8))]); + assert!(!multiple); + } + + #[test] + fn multiple_applies_each_pick_and_ends_on_enter() { + let mut command = EqualConstraintCommand::new(); + assert!(matches!(command.on_text_input("M"), Some(CmdResult::NeedPoint))); + command.inject_picked_entity(line()); + command.on_entity_pick(Handle::new(7), DVec3::ZERO); + command.inject_picked_entity(line()); + let CmdResult::AddEqualConstraint { + others, multiple, .. + } = command.on_entity_pick(Handle::new(8), DVec3::ZERO) + else { + panic!("a Multiple pick must apply at once"); + }; + assert_eq!(others, vec![ParametricRef::whole(Handle::new(8))]); + assert!(multiple); + let CmdResult::AddEqualConstraint { others, .. } = command.on_enter() else { + panic!("Enter must end the Multiple flow through the host"); + }; + assert!(others.is_empty()); + } +} diff --git a/src/modules/parametric/fixed.rs b/src/modules/parametric/fixed.rs new file mode 100644 index 000000000..c4c641500 --- /dev/null +++ b/src/modules/parametric/fixed.rs @@ -0,0 +1,139 @@ +//! GCFIX / FXCONSTRAINT — locks a constraint point, or a whole curve / +//! polyline segment, at its current position. Picks resolve on the host (a +//! `CadCommand` has no document access): `AddFixedConstraint` carries the +//! click, the driver maps it to a `ParametricRef` and re-prompts on a miss, +//! the way the reference does. + +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, CoincidentPick}; +use crate::scene::parametric_constraints::ParametricRef; + +#[derive(Clone, Copy)] +enum Step { + PointOrObject, + Object, +} + +pub struct FixConstraintCommand { + step: Step, +} + +impl FixConstraintCommand { + pub const INVALID_OBJECT: &'static str = + "Invalid selection for Fixed. Select a line, polyline segment, circle, arc, ellipse or spline."; + pub const NO_POINT: &'static str = "No valid constraint point found."; + pub const NO_OBJECT: &'static str = "No object found."; + + pub fn new() -> Self { + Self { + step: Step::PointOrObject, + } + } + + /// The Object-mode reading of a single preselected entity: a whole + /// curve, or a one-segment polyline's segment. Anything else prompts. + pub fn preselected_reference(entity: &EntityType, handle: Handle) -> Option { + match entity { + EntityType::Line(_) + | EntityType::Circle(_) + | EntityType::Arc(_) + | EntityType::Ellipse(_) + | EntityType::Spline(_) => Some(ParametricRef::whole(handle)), + EntityType::LwPolyline(polyline) => (polyline.vertices.len() == 2 + && !polyline.is_closed) + .then(|| ParametricRef::segment(handle, 0)), + EntityType::Polyline2D(polyline) => (polyline.vertices.len() == 2 + && !polyline.is_closed()) + .then(|| ParametricRef::segment(handle, 0)), + _ => None, + } + } + + fn pick(&self, handle: Option, point: DVec3) -> CmdResult { + CmdResult::AddFixedConstraint(CoincidentPick { + handle, + point, + whole_curve: matches!(self.step, Step::Object), + }) + } +} + +impl CadCommand for FixConstraintCommand { + fn name(&self) -> &'static str { + "FXCONSTRAINT" + } + + fn prompt(&self) -> String { + match self.step { + Step::PointOrObject => "FXCONSTRAINT Select point or [Object] :".to_string(), + Step::Object => "FXCONSTRAINT Select object:".to_string(), + } + } + + fn options(&self) -> Vec { + matches!(self.step, Step::PointOrObject) + .then(|| vec![CmdOption::new("Object", "O")]) + .unwrap_or_default() + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::PointOrObject) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + let to_object = + matches!(self.step, Step::PointOrObject) && matches!(keyword.as_str(), "O" | "OBJECT"); + to_object.then(|| { + self.step = Step::Object; + CmdResult::NeedPoint + }) + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn entity_pick_accepts_points(&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() { + return self.on_point(point); + } + self.pick(Some(handle), point) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::PointOrObject => self.pick(None, point), + // A click on nothing at "Select object:". + Step::Object => CmdResult::ReportError(Self::NO_OBJECT.to_string()), + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::PointOrObject => { + self.step = Step::Object; + CmdResult::NeedPoint + } + Step::Object => CmdResult::Cancel, + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} diff --git a/src/modules/parametric/geom_constraint.rs b/src/modules/parametric/geom_constraint.rs index 9f3189f8c..88a8dc499 100644 --- a/src/modules/parametric/geom_constraint.rs +++ b/src/modules/parametric/geom_constraint.rs @@ -14,14 +14,14 @@ impl GeomConstraintCommand { fn dispatch(keyword: &str) -> Option<&'static str> { Some(match keyword { - "H" | "HORIZONTAL" => "HCONSTRAINT", + "H" | "HORIZONTAL" => "GCHORIZONTAL", "V" | "VERTICAL" => "VCONSTRAINT", - "P" | "PERPENDICULAR" => "QCONSTRAINT", + "P" | "PERPENDICULAR" => "GCPERPENDICULAR", "PA" | "PARALLEL" => "PCONSTRAINT", "T" | "TANGENT" => "TCONSTRAINT", - "SM" | "SMOOTH" => "SMOOTHCONSTRAINT", + "SM" | "SMOOTH" => "GCSMOOTH", "C" | "COINCIDENT" => "GCCOINCIDENT", - "CON" | "CONCENTRIC" => "NCONSTRAINT", + "CON" | "CONCENTRIC" => "GCCONCENTRIC", "COL" | "COLLINEAR" | "COLINEAR" => "LCONSTRAINT", "SY" | "SYMMETRIC" => "SYCONSTRAINT", "E" | "EQUAL" => "ECONSTRAINT", diff --git a/src/modules/parametric/horizontal.rs b/src/modules/parametric/horizontal.rs new file mode 100644 index 000000000..b4b98911b --- /dev/null +++ b/src/modules/parametric/horizontal.rs @@ -0,0 +1,402 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{ + CadCommand, CmdOption, CmdResult, CoincidentPick, HorizontalConstraintSelection, WorkingPlane, +}; +use crate::scene::parametric_constraints::{ + directional_axis_endpoints, is_parametric_point_near, ConstraintKind, ParametricRef, +}; + +#[derive(Clone, Copy)] +enum Step { + ObjectOrTwoPoints, + FirstPoint, + SecondPoint(CoincidentPick), +} + +/// Interactive front end for the Horizontal and Vertical geometric +/// constraints — one flow, mirrored across the working plane's X or Y axis. +pub struct HorizontalConstraintCommand { + kind: ConstraintKind, + step: Step, + picked_entity: Option, + plane: WorkingPlane, +} + +impl HorizontalConstraintCommand { + pub fn new() -> Self { + Self::for_kind(ConstraintKind::Horizontal) + } + + pub fn vertical() -> Self { + Self::for_kind(ConstraintKind::Vertical) + } + + fn for_kind(kind: ConstraintKind) -> Self { + Self { + kind, + step: Step::ObjectOrTwoPoints, + picked_entity: None, + plane: WorkingPlane::default(), + } + } + + /// Re-enter the 2Points flow after the host rejected a pick: back at + /// the first point, or at the second with `first` kept. + pub fn resume(kind: ConstraintKind, first: Option) -> Self { + let mut command = Self::for_kind(kind); + command.step = match first { + Some(first) => Step::SecondPoint(first), + None => Step::FirstPoint, + }; + command + } + + pub fn kind(&self) -> ConstraintKind { + self.kind + } + + fn axis(&self) -> &'static str { + match self.kind { + ConstraintKind::Vertical => "Vertical", + _ => "Horizontal", + } + } + + fn label(&self) -> &'static str { + match self.kind { + ConstraintKind::Vertical => "Vertical constraint", + _ => "Horizontal constraint", + } + } + + fn point_pick(handle: Option, point: DVec3) -> CoincidentPick { + CoincidentPick { + handle, + point, + whole_curve: false, + } + } + + fn direction(&self) -> acadrust::types::Vector3 { + let direction = match self.kind { + ConstraintKind::Vertical => self.plane.y.normalize_or(DVec3::Y), + _ => self.plane.x.normalize_or(DVec3::X), + }; + acadrust::types::Vector3::new(direction.x, direction.y, direction.z) + } + + fn invalid_object(&self) -> CmdResult { + CmdResult::ReportError(format!( + "Invalid selection for {}. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc.", + self.axis() + )) + } + + fn invalid_point() -> CmdResult { + CmdResult::ReportError("No valid constraint point found.".to_string()) + } + + pub(crate) fn segment_is_straight(entity: &EntityType, index: usize) -> bool { + match entity { + EntityType::LwPolyline(polyline) => polyline + .vertices + .get(index) + .is_some_and(|vertex| vertex.bulge.abs() <= 1.0e-12), + EntityType::Polyline2D(polyline) => polyline + .vertices + .get(index) + .is_some_and(|vertex| vertex.bulge.abs() <= 1.0e-12), + _ => false, + } + } + + fn distance_to_axis(point: DVec3, endpoints: [acadrust::types::Vector3; 2]) -> f64 { + cadkernel::space::Vec3::from(point.to_array()) + .distance_to_line( + cadkernel::space::Vec3::new(endpoints[0].x, endpoints[0].y, endpoints[0].z), + cadkernel::space::Vec3::new(endpoints[1].x, endpoints[1].y, endpoints[1].z), + ) + .unwrap_or(f64::INFINITY) + } + + fn ellipse_reference( + entity: &EntityType, + handle: Handle, + point: DVec3, + ) -> Option { + let major = ParametricRef::ellipse_major_axis(handle); + let minor = ParametricRef::ellipse_minor_axis(handle); + let major_distance = + Self::distance_to_axis(point, directional_axis_endpoints(entity, major)?); + let minor_distance = + Self::distance_to_axis(point, directional_axis_endpoints(entity, minor)?); + Some(if minor_distance < major_distance { + minor + } else { + major + }) + } + + fn picked_reference( + entity: &EntityType, + handle: Handle, + point: DVec3, + ) -> Option { + match entity { + EntityType::Line(_) | EntityType::Ray(_) | EntityType::XLine(_) => { + Some(ParametricRef::whole(handle)) + } + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let (source, _, _) = + crate::scene::centerline::picked_source(entity, handle, point)?; + let index = usize::try_from(source.segment_index).ok()?; + Self::segment_is_straight(entity, index) + .then_some(ParametricRef::segment(handle, index)) + } + EntityType::Text(_) | EntityType::MText(_) => { + Some(ParametricRef::text_baseline(handle)) + } + EntityType::Ellipse(_) => Self::ellipse_reference(entity, handle, point), + _ => None, + } + } + + pub fn preselected_reference(entity: &EntityType, handle: Handle) -> Option { + match entity { + EntityType::Line(_) | EntityType::Ray(_) | EntityType::XLine(_) => { + Some(ParametricRef::whole(handle)) + } + EntityType::Text(_) | EntityType::MText(_) => { + Some(ParametricRef::text_baseline(handle)) + } + EntityType::Ellipse(_) => Some(ParametricRef::ellipse_major_axis(handle)), + EntityType::LwPolyline(polyline) => { + let count = if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + (count == 1 && Self::segment_is_straight(entity, 0)) + .then_some(ParametricRef::segment(handle, 0)) + } + EntityType::Polyline2D(polyline) => { + let count = if polyline.is_closed() { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + (count == 1 && Self::segment_is_straight(entity, 0)) + .then_some(ParametricRef::segment(handle, 0)) + } + _ => None, + } + } + + fn finish_points(&mut self, first: CoincidentPick, second: CoincidentPick) -> CmdResult { + // The reference refuses the same pick twice and asks again for the + // second point (the host repeats this check on the resolved refs). + if first.handle == second.handle && (first.point - second.point).length() <= 1.0e-9 { + self.step = Step::SecondPoint(first); + return CmdResult::ReportError( + "The object or point is already selected. Select a different object or constraint point." + .to_string(), + ); + } + CmdResult::AddHorizontalConstraint { + kind: self.kind, + selection: HorizontalConstraintSelection::Points(first, second), + direction: self.direction(), + label: self.label(), + } + } +} + +impl CadCommand for HorizontalConstraintCommand { + fn name(&self) -> &'static str { + match self.kind { + ConstraintKind::Vertical => "GCVERTICAL", + _ => "GCHORIZONTAL", + } + } + + fn prompt(&self) -> String { + match self.step { + Step::ObjectOrTwoPoints => { + format!("{} Select an object or [2Points] <2Points>:", self.name()) + } + Step::FirstPoint => format!("{} Select first point:", self.name()), + Step::SecondPoint(_) => format!("{} Select second point:", self.name()), + } + } + + fn options(&self) -> Vec { + matches!(self.step, Step::ObjectOrTwoPoints) + .then(|| vec![CmdOption::new("2Points", "2P")]) + .unwrap_or_default() + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::ObjectOrTwoPoints) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + if matches!(self.step, Step::ObjectOrTwoPoints) + && matches!(keyword.as_str(), "2" | "2P" | "2POINT" | "2POINTS") + { + self.step = Step::FirstPoint; + Some(CmdResult::NeedPoint) + } else { + None + } + } + + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn entity_pick_accepts_points(&self) -> bool { + true + } + + 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() { + return self.on_point(point); + } + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + match self.step { + Step::ObjectOrTwoPoints => { + let Some(reference) = Self::picked_reference(&entity, handle, point) else { + return self.invalid_object(); + }; + CmdResult::AddHorizontalConstraint { + kind: self.kind, + selection: HorizontalConstraintSelection::Reference(reference), + direction: self.direction(), + label: self.label(), + } + } + Step::FirstPoint | Step::SecondPoint(_) => { + let world = acadrust::types::Vector3::new(point.x, point.y, point.z); + if !is_parametric_point_near(&entity, world) { + return Self::invalid_point(); + } + let pick = Self::point_pick(Some(handle), point); + match self.step { + Step::FirstPoint => { + self.step = Step::SecondPoint(pick); + CmdResult::NeedPoint + } + Step::SecondPoint(first) => self.finish_points(first, pick), + Step::ObjectOrTwoPoints => unreachable!(), + } + } + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let pick = Self::point_pick(None, point); + match self.step { + // The host resolves the pick against the document: a hit resumes + // at the second point, a miss re-asks for the first one at once. + Step::ObjectOrTwoPoints | Step::FirstPoint => CmdResult::CheckHorizontalPoint { + kind: self.kind, + pick, + }, + Step::SecondPoint(first) => self.finish_points(first, pick), + } + } + + fn on_enter(&mut self) -> CmdResult { + if matches!(self.step, Step::ObjectOrTwoPoints) { + self.step = Step::FirstPoint; + CmdResult::NeedPoint + } else { + CmdResult::Cancel + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::Line; + use acadrust::types::Vector3; + + #[test] + fn object_pick_captures_the_working_x_direction() { + let handle = Handle::new(7); + let mut command = HorizontalConstraintCommand::new(); + command.set_working_plane(WorkingPlane::new(DVec3::ZERO, DVec3::Y, DVec3::NEG_X)); + command.inject_picked_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(2.0, 3.0, 0.0), + ))); + + let CmdResult::AddHorizontalConstraint { + selection: HorizontalConstraintSelection::Reference(reference), + direction, + .. + } = command.on_entity_pick(handle, DVec3::ZERO) + else { + panic!("object pick must create the constraint"); + }; + assert_eq!(reference, ParametricRef::whole(handle)); + assert!((direction - Vector3::UNIT_Y).length() < 1.0e-12); + } + + #[test] + fn enter_starts_the_two_point_flow() { + let mut command = HorizontalConstraintCommand::new(); + assert!(matches!(command.on_enter(), CmdResult::NeedPoint)); + let CmdResult::CheckHorizontalPoint { kind, pick } = + command.on_point(DVec3::new(1.0, 2.0, 0.0)) + else { + panic!("the host must check the first point"); + }; + let mut command = HorizontalConstraintCommand::resume(kind, Some(pick)); + let CmdResult::AddHorizontalConstraint { + selection: HorizontalConstraintSelection::Points(first, second), + direction, + .. + } = command.on_point(DVec3::new(4.0, 5.0, 0.0)) + else { + panic!("second point must create the constraint"); + }; + assert_eq!(first.point, DVec3::new(1.0, 2.0, 0.0)); + assert_eq!(second.point, DVec3::new(4.0, 5.0, 0.0)); + assert!((direction - Vector3::UNIT_X).length() < 1.0e-12); + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["GCHORIZONTAL", "GCVERTICAL"] +}); diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index fd82ee55b..ebe13aa90 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -3,20 +3,44 @@ //! resulting constraints through the geometry kernel. mod coincident; +mod concentric; +mod constraint_bar; mod equal_distance; +#[path = "equal.rs"] +mod equal_command; +#[path = "fixed.rs"] +mod fixed_command; mod geom_constraint; +#[path = "horizontal.rs"] +mod horizontal_command; +#[path = "perpendicular.rs"] +mod perpendicular_command; mod point_on_entity; +mod smooth; +#[path = "symmetric.rs"] +mod symmetric_command; +#[path = "tangent.rs"] +mod tangent_command; mod tools; mod value; pub use coincident::{coincident_tool, CoincidentConstraintCommand}; +pub use concentric::ConcentricConstraintCommand; +pub use constraint_bar::ConstraintBarOptionCommand; +pub use equal_command::EqualConstraintCommand; pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand}; +pub use fixed_command::FixConstraintCommand; pub use geom_constraint::GeomConstraintCommand; +pub use horizontal_command::HorizontalConstraintCommand; +pub use perpendicular_command::{PerpendicularConstraintCommand, PerpendicularPick}; pub use point_on_entity::{ center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand, }; +pub use smooth::SmoothConstraintCommand; +pub use symmetric_command::SymmetricConstraintCommand; +pub use tangent_command::TangentConstraintCommand; pub use tools::{ - colinear, concentric, equal, fixed, horizontal, normal, parallel, perpendicular, symmetric, - tangent, vertical, + colinear, concentric as concentric_tool, equal, fixed, horizontal, normal, parallel, + perpendicular, symmetric, tangent, vertical, }; pub use value::{ angle_tool, dimensional_tools, distance_tool, AngleConstraintCommand, @@ -56,10 +80,10 @@ impl CadModule for ParametricModule { RibbonItem::LargeTool(colinear::tool()), RibbonItem::LargeTool(perpendicular::tool()), RibbonItem::LargeTool(command( - "SMOOTHCONSTRAINT", "Smooth", + "GCSMOOTH", "Smooth", include_bytes!("../../../assets/icons/constrain/smooth.svg"), )), - RibbonItem::LargeTool(concentric::tool()), + RibbonItem::LargeTool(concentric_tool::tool()), RibbonItem::LargeTool(horizontal::tool()), RibbonItem::LargeTool(symmetric::tool()), RibbonItem::LargeTool(fixed::tool()), @@ -130,7 +154,7 @@ impl CadModule for ParametricModule { inventory::submit!(crate::command::CommandRegistration { names: &[ - "AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "SMOOTHCONSTRAINT", "GCSHOW", "GCHIDE", "GCRESET", + "AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "GCSMOOTH", "GCSHOW", "GCHIDE", "GCRESET", "GCSHOWALL", "GCHIDEALL", "DCSHOW", "DCHIDE", "DCSHOWALL", "DCHIDEALL", "DCCONVERT", "DELCONSTRAINT", ] @@ -162,8 +186,8 @@ mod tests { groups[0].tools.iter().map(item_id).collect::>(), [ "AUTOCONSTRAIN", "CCONSTRAINT", "PCONSTRAINT", "TCONSTRAINT", - "LCONSTRAINT", "QCONSTRAINT", "SMOOTHCONSTRAINT", "NCONSTRAINT", - "HCONSTRAINT", "SYCONSTRAINT", "FXCONSTRAINT", "VCONSTRAINT", + "LCONSTRAINT", "QCONSTRAINT", "GCSMOOTH", "GCCONCENTRIC", + "GCHORIZONTAL", "SYCONSTRAINT", "FXCONSTRAINT", "VCONSTRAINT", "ECONSTRAINT", "GCVISIBILITY", "GCSHOWALL", "GCHIDEALL", ] ); diff --git a/src/modules/parametric/perpendicular.rs b/src/modules/parametric/perpendicular.rs new file mode 100644 index 000000000..19f4d99ba --- /dev/null +++ b/src/modules/parametric/perpendicular.rs @@ -0,0 +1,270 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::scene::parametric_constraints::{directional_axis_endpoints, ParametricRef}; +use crate::t; + +#[derive(Clone, Copy)] +pub struct PerpendicularPick { + pub reference: ParametricRef, + pub fixed_reference: ParametricRef, + pub start_reference: ParametricRef, +} + +pub struct PerpendicularConstraintCommand { + first: Option, + picked_entity: Option, +} + +impl PerpendicularConstraintCommand { + pub fn new() -> Self { + Self { + first: None, + picked_entity: None, + } + } + + fn invalid_selection() -> CmdResult { + CmdResult::ReportError( + t!("Invalid selection for Perpendicular. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc.") + .into_owned(), + ) + } + + fn distance_to_axis(point: DVec3, endpoints: [acadrust::types::Vector3; 2]) -> f64 { + cadkernel::space::Vec3::from(point.to_array()) + .distance_to_line( + cadkernel::space::Vec3::new(endpoints[0].x, endpoints[0].y, endpoints[0].z), + cadkernel::space::Vec3::new(endpoints[1].x, endpoints[1].y, endpoints[1].z), + ) + .unwrap_or(f64::INFINITY) + } + + fn ellipse_reference( + entity: &EntityType, + handle: Handle, + point: DVec3, + ) -> Option { + let major = ParametricRef::ellipse_major_axis(handle); + let minor = ParametricRef::ellipse_minor_axis(handle); + let major_distance = + Self::distance_to_axis(point, directional_axis_endpoints(entity, major)?); + let minor_distance = + Self::distance_to_axis(point, directional_axis_endpoints(entity, minor)?); + Some(if minor_distance < major_distance { + minor + } else { + major + }) + } + + fn picked_reference( + entity: &EntityType, + handle: Handle, + point: DVec3, + ) -> Option { + let (reference, fixed_reference, start_reference) = match entity { + EntityType::Line(_) => ( + ParametricRef::whole(handle), + ParametricRef::whole(handle), + ParametricRef::point(handle, 0), + ), + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let (source, _, _) = + crate::scene::centerline::picked_source(entity, handle, point)?; + let index = usize::try_from(source.segment_index).ok()?; + ( + ParametricRef::segment(handle, index), + ParametricRef::segment(handle, index), + ParametricRef::point(handle, index as i32), + ) + } + EntityType::Text(_) | EntityType::MText(_) => ( + ParametricRef::text_baseline(handle), + ParametricRef::whole(handle), + ParametricRef::point(handle, 0), + ), + EntityType::Ellipse(_) => ( + Self::ellipse_reference(entity, handle, point)?, + ParametricRef::whole(handle), + ParametricRef::center(handle), + ), + _ => return None, + }; + Some(PerpendicularPick { + reference, + fixed_reference, + start_reference, + }) + } + + pub fn preselected_reference(entity: &EntityType, handle: Handle) -> Option { + match entity { + EntityType::Line(_) | EntityType::Text(_) | EntityType::MText(_) => { + Self::picked_reference(entity, handle, DVec3::ZERO) + } + EntityType::Ellipse(_) => Some(PerpendicularPick { + reference: ParametricRef::ellipse_major_axis(handle), + fixed_reference: ParametricRef::whole(handle), + start_reference: ParametricRef::center(handle), + }), + EntityType::LwPolyline(polyline) => { + let segment_count = if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + let index = (0..segment_count) + .find(|index| polyline.vertices[*index].bulge.abs() <= 1.0e-12)?; + Some(PerpendicularPick { + reference: ParametricRef::segment(handle, index), + fixed_reference: ParametricRef::segment(handle, index), + start_reference: ParametricRef::point(handle, index as i32), + }) + } + EntityType::Polyline2D(polyline) => { + let segment_count = if polyline.is_closed() { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + let index = (0..segment_count) + .find(|index| polyline.vertices[*index].bulge.abs() <= 1.0e-12)?; + Some(PerpendicularPick { + reference: ParametricRef::segment(handle, index), + fixed_reference: ParametricRef::segment(handle, index), + start_reference: ParametricRef::point(handle, index as i32), + }) + } + _ => None, + } + } +} + +impl CadCommand for PerpendicularConstraintCommand { + fn name(&self) -> &'static str { + "GCPERPENDICULAR" + } + + fn prompt(&self) -> String { + if self.first.is_some() { + t!("GCPERPENDICULAR Select second object:").into_owned() + } else { + t!("GCPERPENDICULAR Select first object:").into_owned() + } + } + + fn needs_entity_pick(&self) -> bool { + true + } + + 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() { + return CmdResult::NeedPoint; + } + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(pick) = Self::picked_reference(&entity, handle, point) else { + return Self::invalid_selection(); + }; + let Some(first) = self.first else { + self.first = Some(pick); + return CmdResult::NeedPoint; + }; + if first.reference == pick.reference { + return Self::invalid_selection(); + } + CmdResult::AddPerpendicularConstraint { + first: first.reference, + second: pick.reference, + first_fixed: first.fixed_reference, + second_start: pick.start_reference, + label: "Perpendicular constraint", + } + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::Line; + use acadrust::types::Vector3; + + #[test] + fn command_preserves_pick_order_for_the_solver() { + let first = Handle::new(7); + let second = Handle::new(9); + let mut command = PerpendicularConstraintCommand::new(); + command.inject_picked_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(4.0, 0.0, 0.0), + ))); + assert!(matches!( + command.on_entity_pick(first, DVec3::ZERO), + CmdResult::NeedPoint + )); + command.inject_picked_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(1.0, 2.0, 0.0), + ))); + let CmdResult::AddPerpendicularConstraint { + first: first_ref, + second: second_ref, + first_fixed, + second_start, + .. + } = command.on_entity_pick(second, DVec3::ZERO) + else { + panic!("second pick must create the constraint"); + }; + assert_eq!(first_ref, ParametricRef::whole(first)); + assert_eq!(second_ref, ParametricRef::whole(second)); + assert_eq!(first_fixed, ParametricRef::whole(first)); + assert_eq!(second_start, ParametricRef::point(second, 0)); + } + + #[test] + fn preselection_uses_the_first_straight_polyline_segment() { + let handle = Handle::new(11); + let mut polyline = acadrust::entities::LwPolyline::new(); + polyline.vertices = vec![ + acadrust::entities::LwVertex::with_bulge(acadrust::types::Vector2::new(0.0, 0.0), 1.0), + acadrust::entities::LwVertex::from_coords(4.0, 0.0), + acadrust::entities::LwVertex::from_coords(8.0, 0.0), + ]; + + let pick = PerpendicularConstraintCommand::preselected_reference( + &EntityType::LwPolyline(polyline), + handle, + ) + .expect("straight segment"); + assert_eq!(pick.reference, ParametricRef::segment(handle, 1)); + assert_eq!(pick.start_reference, ParametricRef::point(handle, 1)); + } +} diff --git a/src/modules/parametric/smooth.rs b/src/modules/parametric/smooth.rs new file mode 100644 index 000000000..e5a9c0dd3 --- /dev/null +++ b/src/modules/parametric/smooth.rs @@ -0,0 +1,261 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::scene::parametric_constraints::{ConstraintKind, ParametricRef}; +use crate::t; + +#[derive(Clone, Copy)] +struct SmoothTarget { + endpoint: ParametricRef, + curve: ParametricRef, +} + +pub struct SmoothConstraintCommand { + source: Option, + picked_entity: Option, +} + +impl SmoothConstraintCommand { + pub fn new() -> Self { + Self { + source: None, + picked_entity: None, + } + } + + fn point(point: acadrust::types::Vector3) -> DVec3 { + DVec3::new(point.x, point.y, point.z) + } + + fn nearest_marker(points: &[acadrust::types::Vector3], point: DVec3) -> Option { + points + .iter() + .enumerate() + .min_by(|(_, left), (_, right)| { + (Self::point(**left) - point) + .length_squared() + .total_cmp(&(Self::point(**right) - point).length_squared()) + }) + .map(|(index, _)| index as i32) + } + + fn source_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + let EntityType::Spline(spline) = entity else { + return None; + }; + if spline.flags.closed || spline.flags.periodic { + return None; + } + let points = crate::scene::dimension_assoc::source_points(entity); + let endpoints = [*points.first()?, *points.last()?]; + let marker = Self::nearest_marker(&endpoints, point)?; + Some(ParametricRef::point(handle, marker)) + } + + fn target_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + match entity { + EntityType::Line(_) | EntityType::Arc(_) => { + let points = crate::scene::dimension_assoc::source_points(entity); + let endpoints = [*points.first()?, *points.last()?]; + let marker = Self::nearest_marker(&endpoints, point)?; + Some(SmoothTarget { + endpoint: ParametricRef::point(handle, marker), + curve: ParametricRef::whole(handle), + }) + } + EntityType::Spline(spline) if !spline.flags.closed && !spline.flags.periodic => { + let points = crate::scene::dimension_assoc::source_points(entity); + let endpoints = [*points.first()?, *points.last()?]; + let marker = Self::nearest_marker(&endpoints, point)?; + Some(SmoothTarget { + endpoint: ParametricRef::point(handle, marker), + curve: ParametricRef::whole(handle), + }) + } + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let planar = crate::entities::curve::entity_curve(entity)?; + let local = planar.plane.project(point.to_array())?; + let segments = planar.curve.segments(); + let (segment, _) = cadkernel::geom2d::nearest_of(segments.iter(), local)?; + let points = crate::scene::dimension_assoc::source_points(entity); + let closed = match entity { + EntityType::LwPolyline(polyline) => polyline.is_closed, + EntityType::Polyline2D(polyline) => polyline.is_closed(), + _ => false, + }; + let end = if segment + 1 < points.len() { + segment + 1 + } else if closed { + 0 + } else { + return None; + }; + let candidates = [points.get(segment).copied()?, points.get(end).copied()?]; + let side = Self::nearest_marker(&candidates, point)? as usize; + let vertex = if side == 0 { segment } else { end }; + Some(SmoothTarget { + endpoint: ParametricRef::point(handle, vertex as i32), + curve: ParametricRef::segment(handle, segment), + }) + } + _ => None, + } + } + + pub fn preselected_refs( + first: (&EntityType, Handle), + second: (&EntityType, Handle), + ) -> Option> { + let source_points = crate::scene::dimension_assoc::source_points(first.0); + let source_ends = [*source_points.first()?, *source_points.last()?]; + let source_point = source_ends + .iter() + .copied() + .min_by(|left, right| { + let target_points = crate::scene::dimension_assoc::source_points(second.0); + let distance = |source: acadrust::types::Vector3| { + target_points + .iter() + .map(|target| (source - *target).length_squared()) + .fold(f64::INFINITY, f64::min) + }; + distance(*left).total_cmp(&distance(*right)) + })?; + let source = Self::source_reference(first.0, first.1, Self::point(source_point))?; + + let target_points = crate::scene::dimension_assoc::source_points(second.0); + let target_point = target_points + .iter() + .copied() + .min_by(|left, right| { + (Self::point(*left) - Self::point(source_point)) + .length_squared() + .total_cmp( + &(Self::point(*right) - Self::point(source_point)).length_squared(), + ) + })?; + let target = Self::target_reference(second.0, second.1, Self::point(target_point))?; + let mut refs = vec![source, target.endpoint]; + if target.curve.marker.is_some() { + refs.push(target.curve); + } + Some(refs) + } + + fn invalid_source() -> CmdResult { + CmdResult::ReportError( + t!("Invalid first selection for Smooth. Select an endpoint of an open spline.") + .into_owned(), + ) + } + + fn invalid_target() -> CmdResult { + CmdResult::ReportError( + t!("Invalid second selection for Smooth. Select an endpoint of a line, arc, polyline segment or open spline.") + .into_owned(), + ) + } +} + +impl CadCommand for SmoothConstraintCommand { + fn name(&self) -> &'static str { + "GCSMOOTH" + } + + fn prompt(&self) -> String { + if self.source.is_some() { + t!("GCSMOOTH Select second curve endpoint:").into_owned() + } else { + t!("GCSMOOTH Select first spline curve endpoint:").into_owned() + } + } + + fn needs_entity_pick(&self) -> bool { + true + } + + 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() { + return CmdResult::NeedPoint; + } + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(source) = self.source else { + let Some(source) = Self::source_reference(&entity, handle, point) else { + return Self::invalid_source(); + }; + self.source = Some(source); + return CmdResult::NeedPoint; + }; + let Some(target) = Self::target_reference(&entity, handle, point) else { + return Self::invalid_target(); + }; + if source.entity == target.endpoint.entity { + return Self::invalid_target(); + } + let mut refs = vec![source, target.endpoint]; + if target.curve.marker.is_some() { + refs.push(target.curve); + } + CmdResult::AddParametricConstraint { + kind: ConstraintKind::Smooth, + refs, + driving_param: None, + label: "Smooth constraint", + } + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn polyline_target_keeps_the_picked_arc_segment_and_endpoint() { + let handle = Handle::new(7); + let mut polyline = acadrust::entities::LwPolyline::new(); + polyline.vertices = vec![ + acadrust::entities::LwVertex::from_coords(0.0, 0.0), + acadrust::entities::LwVertex::with_bulge( + acadrust::types::Vector2::new(5.0, 0.0), + 1.0, + ), + acadrust::entities::LwVertex::from_coords(10.0, 0.0), + ]; + + let target = SmoothConstraintCommand::target_reference( + &EntityType::LwPolyline(polyline), + handle, + DVec3::new(10.0, 0.0, 0.0), + ) + .expect("arc endpoint"); + assert_eq!(target.curve, ParametricRef::segment(handle, 1)); + assert_eq!(target.endpoint, ParametricRef::point(handle, 2)); + } +} diff --git a/src/modules/parametric/symmetric.rs b/src/modules/parametric/symmetric.rs new file mode 100644 index 000000000..7557fd04a --- /dev/null +++ b/src/modules/parametric/symmetric.rs @@ -0,0 +1,360 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{ + CadCommand, CmdOption, CmdResult, CoincidentPick, SymmetricConstraintSelection, +}; +use crate::scene::parametric_constraints::{is_parametric_point_near, ParametricRef}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ObjectFamily { + Line, + Circular, + Ellipse, +} + +#[derive(Clone, Copy)] +struct ObjectPick { + reference: ParametricRef, + family: ObjectFamily, +} + +#[derive(Clone, Copy)] +enum Step { + ObjectOrTwoPoints, + SecondObject(ObjectPick), + FirstPoint, + SecondPoint(CoincidentPick), + ObjectAxis(ObjectPick, ObjectPick), + PointAxis(CoincidentPick, CoincidentPick), +} + +pub struct SymmetricConstraintCommand { + step: Step, + picked_entity: Option, +} + +impl SymmetricConstraintCommand { + pub fn new() -> Self { + Self { + step: Step::ObjectOrTwoPoints, + picked_entity: None, + } + } + + fn point_pick(handle: Option, point: DVec3) -> CoincidentPick { + CoincidentPick { + handle, + point, + whole_curve: false, + } + } + + fn segment_count(entity: &EntityType) -> Option { + match entity { + EntityType::LwPolyline(polyline) => Some(if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }), + EntityType::Polyline2D(polyline) => Some(if polyline.is_closed() { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }), + _ => None, + } + } + + fn segment_family(entity: &EntityType, index: usize) -> Option { + let bulge = match entity { + EntityType::LwPolyline(polyline) => polyline.vertices.get(index)?.bulge, + EntityType::Polyline2D(polyline) => polyline.vertices.get(index)?.bulge, + _ => return None, + }; + Some(if bulge.abs() <= 1.0e-12 { + ObjectFamily::Line + } else { + ObjectFamily::Circular + }) + } + + fn picked_segment(entity: &EntityType, point: DVec3) -> Option<(usize, ObjectFamily)> { + let planar = crate::entities::curve::entity_curve(entity)?; + let local = planar.plane.project(point.to_array())?; + let segments = planar.curve.segments(); + let (index, _) = cadkernel::geom2d::nearest_of(segments.iter(), local)?; + Some((index, Self::segment_family(entity, index)?)) + } + + fn picked_object(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + let (reference, family) = match entity { + EntityType::Line(_) => (ParametricRef::whole(handle), ObjectFamily::Line), + EntityType::Circle(_) | EntityType::Arc(_) => { + (ParametricRef::whole(handle), ObjectFamily::Circular) + } + EntityType::Ellipse(_) => (ParametricRef::whole(handle), ObjectFamily::Ellipse), + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let (index, family) = Self::picked_segment(entity, point)?; + (ParametricRef::segment(handle, index), family) + } + _ => return None, + }; + Some(ObjectPick { reference, family }) + } + + fn preselected_object(entity: &EntityType, handle: Handle) -> Option { + match entity { + EntityType::Line(_) => Some(ObjectPick { + reference: ParametricRef::whole(handle), + family: ObjectFamily::Line, + }), + EntityType::Circle(_) | EntityType::Arc(_) => Some(ObjectPick { + reference: ParametricRef::whole(handle), + family: ObjectFamily::Circular, + }), + EntityType::Ellipse(_) => Some(ObjectPick { + reference: ParametricRef::whole(handle), + family: ObjectFamily::Ellipse, + }), + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) + if Self::segment_count(entity) == Some(1) => + { + Some(ObjectPick { + reference: ParametricRef::segment(handle, 0), + family: Self::segment_family(entity, 0)?, + }) + } + _ => None, + } + } + + fn picked_axis(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + match entity { + EntityType::Line(_) => Some(ParametricRef::whole(handle)), + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let (index, family) = Self::picked_segment(entity, point)?; + (family == ObjectFamily::Line).then_some(ParametricRef::segment(handle, index)) + } + _ => None, + } + } + + fn preselected_axis(entity: &EntityType, handle: Handle) -> Option { + match entity { + EntityType::Line(_) => Some(ParametricRef::whole(handle)), + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) + if Self::segment_count(entity) == Some(1) + && Self::segment_family(entity, 0) == Some(ObjectFamily::Line) => + { + Some(ParametricRef::segment(handle, 0)) + } + _ => None, + } + } + + pub fn preselected_refs( + first: (&EntityType, Handle), + second: (&EntityType, Handle), + axis: (&EntityType, Handle), + ) -> Option<[ParametricRef; 3]> { + let first = Self::preselected_object(first.0, first.1)?; + let second = Self::preselected_object(second.0, second.1)?; + let axis = Self::preselected_axis(axis.0, axis.1)?; + (first.family == second.family + && first.reference != second.reference + && axis.entity != first.reference.entity + && axis.entity != second.reference.entity) + .then_some([first.reference, second.reference, axis]) + } + + fn invalid_object() -> CmdResult { + CmdResult::ReportError( + "Symmetric: select two compatible lines, circular curves, polyline segments, or ellipses." + .to_string(), + ) + } + + fn invalid_point() -> CmdResult { + CmdResult::ReportError( + "Symmetric: select an endpoint, center, midpoint, or polyline vertex.".to_string(), + ) + } + + fn invalid_axis() -> CmdResult { + CmdResult::ReportError("Symmetric: select a line as the symmetry axis.".to_string()) + } +} + +impl CadCommand for SymmetricConstraintCommand { + fn name(&self) -> &'static str { + "SYCONSTRAINT" + } + + fn prompt(&self) -> String { + match self.step { + Step::ObjectOrTwoPoints => { + "Select first object or [2Points] <2Points>:".to_string() + } + Step::SecondObject(_) => "Select second object:".to_string(), + Step::FirstPoint => "Select first point:".to_string(), + Step::SecondPoint(_) => "Select second point:".to_string(), + Step::ObjectAxis(_, _) | Step::PointAxis(_, _) => { + "Select symmetry line:".to_string() + } + } + } + + fn options(&self) -> Vec { + matches!(self.step, Step::ObjectOrTwoPoints) + .then(|| vec![CmdOption::new("2Points", "2P")]) + .unwrap_or_default() + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::ObjectOrTwoPoints) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase(); + if matches!(self.step, Step::ObjectOrTwoPoints) + && matches!(keyword.as_str(), "2" | "2P" | "2POINT" | "2POINTS") + { + self.step = Step::FirstPoint; + Some(CmdResult::NeedPoint) + } else { + None + } + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn entity_pick_accepts_points(&self) -> bool { + true + } + + 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() { + return self.on_point(point); + } + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + match self.step { + Step::ObjectOrTwoPoints => { + let Some(first) = Self::picked_object(&entity, handle, point) else { + return Self::invalid_object(); + }; + self.step = Step::SecondObject(first); + CmdResult::NeedPoint + } + Step::SecondObject(first) => { + let Some(second) = Self::picked_object(&entity, handle, point) else { + return Self::invalid_object(); + }; + if first.family != second.family || first.reference == second.reference { + return Self::invalid_object(); + } + self.step = Step::ObjectAxis(first, second); + CmdResult::NeedPoint + } + Step::FirstPoint | Step::SecondPoint(_) => { + let world = acadrust::types::Vector3::new(point.x, point.y, point.z); + if !is_parametric_point_near(&entity, world) { + return Self::invalid_point(); + } + let picked = Self::point_pick(Some(handle), point); + match self.step { + Step::FirstPoint => { + self.step = Step::SecondPoint(picked); + CmdResult::NeedPoint + } + Step::SecondPoint(first) => { + self.step = Step::PointAxis(first, picked); + CmdResult::NeedPoint + } + _ => unreachable!(), + } + } + Step::ObjectAxis(first, second) => { + let Some(axis) = Self::picked_axis(&entity, handle, point) else { + return Self::invalid_axis(); + }; + if axis.entity == first.reference.entity || axis.entity == second.reference.entity { + return Self::invalid_axis(); + } + CmdResult::AddSymmetricConstraint { + selection: SymmetricConstraintSelection::Objects( + first.reference, + second.reference, + ), + axis, + label: "Symmetric constraint", + } + } + Step::PointAxis(first, second) => { + let Some(axis) = Self::picked_axis(&entity, handle, point) else { + return Self::invalid_axis(); + }; + if first.handle == Some(axis.entity) || second.handle == Some(axis.entity) { + return Self::invalid_axis(); + } + CmdResult::AddSymmetricConstraint { + selection: SymmetricConstraintSelection::Points(first, second), + axis, + label: "Symmetric constraint", + } + } + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let picked = Self::point_pick(None, point); + match self.step { + Step::ObjectOrTwoPoints | Step::FirstPoint => { + self.step = Step::SecondPoint(picked); + CmdResult::NeedPoint + } + Step::SecondPoint(first) => { + self.step = Step::PointAxis(first, picked); + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + if matches!(self.step, Step::ObjectOrTwoPoints) { + self.step = Step::FirstPoint; + CmdResult::NeedPoint + } else { + CmdResult::Cancel + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["SYCONSTRAINT"] +}); diff --git a/src/modules/parametric/tangent.rs b/src/modules/parametric/tangent.rs new file mode 100644 index 000000000..0b09f7c09 --- /dev/null +++ b/src/modules/parametric/tangent.rs @@ -0,0 +1,203 @@ +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult}; +use crate::scene::parametric_constraints::ParametricRef; +use crate::t; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum TangentOperand { + Line, + Circle, + Ellipse, +} + +#[derive(Clone, Copy)] +struct TangentPick { + reference: ParametricRef, + operand: TangentOperand, +} + +pub struct TangentConstraintCommand { + first: Option, + picked_entity: Option, +} + +impl TangentConstraintCommand { + pub fn new() -> Self { + Self { + first: None, + picked_entity: None, + } + } + + fn invalid_selection() -> CmdResult { + CmdResult::ReportError( + t!("Invalid selection for Tangent. Select a line, polyline segment, circle, arc or ellipse.") + .into_owned(), + ) + } + + fn picked_reference(entity: &EntityType, handle: Handle, point: DVec3) -> Option { + let (reference, operand) = match entity { + EntityType::Line(_) => (ParametricRef::whole(handle), TangentOperand::Line), + EntityType::Circle(_) | EntityType::Arc(_) => { + (ParametricRef::whole(handle), TangentOperand::Circle) + } + EntityType::Ellipse(_) => (ParametricRef::whole(handle), TangentOperand::Ellipse), + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + let (source, _, _) = crate::scene::centerline::picked_source(entity, handle, point)?; + let index = usize::try_from(source.segment_index).ok()?; + (ParametricRef::segment(handle, index), TangentOperand::Line) + } + _ => return None, + }; + Some(TangentPick { reference, operand }) + } + + fn pair_supported(first: TangentOperand, second: TangentOperand) -> bool { + matches!( + (first, second), + (TangentOperand::Circle, TangentOperand::Circle) + | (TangentOperand::Circle, TangentOperand::Line) + | (TangentOperand::Line, TangentOperand::Circle) + | (TangentOperand::Ellipse, TangentOperand::Line) + | (TangentOperand::Line, TangentOperand::Ellipse) + ) + } + + pub fn preselected_reference( + entity: &EntityType, + handle: Handle, + ) -> Option { + match entity { + EntityType::Line(_) + | EntityType::Circle(_) + | EntityType::Arc(_) + | EntityType::Ellipse(_) => Some(ParametricRef::whole(handle)), + EntityType::LwPolyline(polyline) => { + let segments = if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + (segments == 1 && polyline.vertices.first()?.bulge.abs() <= 1.0e-12) + .then(|| ParametricRef::segment(handle, 0)) + } + EntityType::Polyline2D(polyline) => { + let segments = if polyline.is_closed() { + polyline.vertices.len() + } else { + polyline.vertices.len().saturating_sub(1) + }; + (segments == 1 && polyline.vertices.first()?.bulge.abs() <= 1.0e-12) + .then(|| ParametricRef::segment(handle, 0)) + } + _ => None, + } + } +} + +impl CadCommand for TangentConstraintCommand { + fn name(&self) -> &'static str { + "TCONSTRAINT" + } + + fn prompt(&self) -> String { + if self.first.is_some() { + t!("TCONSTRAINT Select second object:").into_owned() + } else { + t!("TCONSTRAINT Select first object:").into_owned() + } + } + + fn needs_entity_pick(&self) -> bool { + true + } + + 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() { + return CmdResult::NeedPoint; + } + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(pick) = Self::picked_reference(&entity, handle, point) else { + return Self::invalid_selection(); + }; + let Some(first) = self.first else { + self.first = Some(pick); + return CmdResult::NeedPoint; + }; + if first.reference == pick.reference + || !Self::pair_supported(first.operand, pick.operand) + { + return Self::invalid_selection(); + } + CmdResult::AddTangentConstraint { + first: first.reference, + second: pick.reference, + label: "Tangent constraint", + } + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::{Circle, Line}; + use acadrust::types::Vector3; + + #[test] + fn command_preserves_pick_order_for_the_initial_anchor() { + let first = Handle::new(11); + let second = Handle::new(13); + let mut command = TangentConstraintCommand::new(); + command.inject_picked_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(4.0, 0.0, 0.0), + ))); + assert!(matches!( + command.on_entity_pick(first, DVec3::ZERO), + CmdResult::NeedPoint + )); + command.inject_picked_entity(EntityType::Circle(Circle::from_center_radius( + Vector3::new(2.0, 3.0, 0.0), + 1.0, + ))); + let CmdResult::AddTangentConstraint { + first: first_ref, + second: second_ref, + .. + } = command.on_entity_pick(second, DVec3::ZERO) + else { + panic!("second pick must create the constraint"); + }; + assert_eq!(first_ref, ParametricRef::whole(first)); + assert_eq!(second_ref, ParametricRef::whole(second)); + } +} diff --git a/src/modules/parametric/tools.rs b/src/modules/parametric/tools.rs index 9a0a57293..4061cea62 100644 --- a/src/modules/parametric/tools.rs +++ b/src/modules/parametric/tools.rs @@ -6,12 +6,12 @@ pub mod horizontal { use super::*; pub fn tool() -> ToolDef { ToolDef { - id: "HCONSTRAINT", + id: "GCHORIZONTAL", label: "Horizontal", icon: IconKind::Svg(include_bytes!( "../../../assets/icons/constrain/horizontal.svg" )), - event: ModuleEvent::Command("HCONSTRAINT".to_string()), + event: ModuleEvent::Command("GCHORIZONTAL".to_string()), } } } @@ -53,7 +53,7 @@ pub mod perpendicular { icon: IconKind::Svg(include_bytes!( "../../../assets/icons/constrain/perpendicular.svg" )), - event: ModuleEvent::Command("QCONSTRAINT".to_string()), + event: ModuleEvent::Command("GCPERPENDICULAR".to_string()), } } } @@ -88,12 +88,12 @@ pub mod concentric { use super::*; pub fn tool() -> ToolDef { ToolDef { - id: "NCONSTRAINT", + id: "GCCONCENTRIC", label: "Concentric", icon: IconKind::Svg(include_bytes!( "../../../assets/icons/constrain/concentric.svg" )), - event: ModuleEvent::Command("NCONSTRAINT".to_string()), + event: ModuleEvent::Command("GCCONCENTRIC".to_string()), } } } @@ -136,7 +136,7 @@ pub mod fixed { pub fn tool() -> ToolDef { ToolDef { id: "FXCONSTRAINT", - label: "Fixed", + label: "Fix", icon: IconKind::Svg(include_bytes!("../../../assets/icons/constrain/fixed.svg")), event: ModuleEvent::Command("FXCONSTRAINT".to_string()), } @@ -160,16 +160,19 @@ pub mod symmetric { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &[ - "HCONSTRAINT", + "GCHORIZONTAL", "VCONSTRAINT", "PCONSTRAINT", "QCONSTRAINT", + "GCPERPENDICULAR", "ECONSTRAINT", + "GCEQUAL", "TCONSTRAINT", - "NCONSTRAINT", + "GCCONCENTRIC", "NRCONSTRAINT", "LCONSTRAINT", "FXCONSTRAINT", + "GCFIX", "SYCONSTRAINT", ] }); diff --git a/src/modules/view/ucs_cmd.rs b/src/modules/view/ucs_cmd.rs index d7d58ed87..61f3c6b11 100644 --- a/src/modules/view/ucs_cmd.rs +++ b/src/modules/view/ucs_cmd.rs @@ -24,9 +24,13 @@ impl UcsCommand { Self::default() } - /// Options that take no further argument and execute immediately. + /// Options that take no further typed argument: they execute immediately + /// or, for Face / OBject, start their own entity pick. fn is_zero_arg(opt: &str) -> bool { - matches!(opt, "W" | "WORLD" | "VIEW" | "V" | "LIST" | "?") + matches!( + opt, + "W" | "WORLD" | "VIEW" | "V" | "LIST" | "?" | "FACE" | "F" | "OBJECT" | "OB" + ) } /// Options whose argument is a coordinate (so a click is accepted too). @@ -50,7 +54,10 @@ impl CadCommand for UcsCommand { fn prompt(&self) -> String { match self.option.as_deref() { - None => t!("UCS option [World/View/3Point/Z/X/Y/Origin/Save/Delete] or name:").into_owned(), + None => t!( + "UCS option [Face/OBject/World/View/3Point/Z/X/Y/Origin/Save/Delete] or name:" + ) + .into_owned(), Some("Z") => t!("UCS rotation angle about Z (degrees):").into_owned(), Some("X") => t!("UCS rotation angle about X (degrees):").into_owned(), Some("Y") => t!("UCS rotation angle about Y (degrees):").into_owned(), diff --git a/src/plugin/external.rs b/src/plugin/external.rs index 2edf4be6b..d644b907a 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -84,19 +84,11 @@ impl ExternalPlugin { /// 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(), - ), - } + acadrust_source_compatible( + self.api_version, + self.acadrust_declared, + self.acadrust_source.as_deref(), + ) } /// Returns whether the package's rustc matches the host. @@ -123,6 +115,22 @@ impl ExternalPlugin { } } +pub(crate) fn acadrust_source_compatible( + api_version: u32, + declared: bool, + source: Option<&str>, +) -> bool { + !ocs_plugin_api::version_info::uses_acadrust_gate(api_version) + || declared + && source.is_some_and(|source| { + !source.is_empty() + && ocs_plugin_api::version_info::acadrust_sources_compatible( + source, + ocs_plugin_api::version_info::host_acadrust_source(), + ) + }) +} + /// `/OpenCADStudio/plugins`, matching the settings/recent-files store. /// Overridable via `OCS_PLUGINS_DIR` for tests. pub fn plugins_dir() -> Option { @@ -718,7 +726,7 @@ rustc_version = "rustc 0.0.0-fake" } #[test] - fn undeclared_acadrust_falls_back_to_api_gate() { + fn undeclared_acadrust_is_incompatible_for_gated_api() { let toml = r#" [plugin] id = "opencad.test" @@ -729,7 +737,8 @@ 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"); + assert!(!p.acadrust_compatible()); + assert!(!p.loadable()); } #[test] diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs index aabc4322a..a462ae70f 100644 --- a/src/plugin/marketplace.rs +++ b/src/plugin/marketplace.rs @@ -221,21 +221,11 @@ pub fn fetch_release_info(repo: &str) -> Result, String> { .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( + let acadrust_compatible = external::acadrust_source_compatible( 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(), - ), - } - }; + acadrust_declared, + acadrust_source.as_deref(), + ); let rustc_version = manifest.rustc_version.clone(); let rustc_declared = manifest.rustc_declared; let rustc_compatible = if !ocs_plugin_api::version_info::uses_acadrust_gate( @@ -268,17 +258,6 @@ pub fn fetch_release_info(repo: &str) -> Result, String> { } } - // Once a repo declares a fingerprint, require it on later API versions. - let any_acadrust_declared = info.iter().any(|r| r.acadrust_declared); - if any_acadrust_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 { @@ -350,18 +329,12 @@ pub fn install(release: &Release, repository: &str) -> Result { )); } - if ocs_plugin_api::version_info::uses_acadrust_gate(manifest.api_version) - && manifest.acadrust_declared - { + if ocs_plugin_api::version_info::uses_acadrust_gate(manifest.api_version) { 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(), - ); + return Err("Release has no acadrust 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(), - ); + return Err("Release has no acadrust source; cannot verify ABI compatibility".to_string()); } if !ocs_plugin_api::version_info::acadrust_sources_compatible( source, diff --git a/src/scene/boundary.rs b/src/scene/boundary.rs index 780e8b731..af2553fad 100644 --- a/src/scene/boundary.rs +++ b/src/scene/boundary.rs @@ -203,17 +203,44 @@ pub(crate) fn ring_source_handles( } fn curve_forward(curve: &Curve, start: [f64; 2], next: [f64; 2]) -> bool { - let a = curve.parameter_at(start); - let b = curve.parameter_at(next); - let mut delta = b - a; - if curve.is_closed() { - if delta > 0.5 { - delta -= 1.0; - } else if delta < -0.5 { - delta += 1.0; + // For circular geometry, determine direction directly from the + // geometric traversal of the detected boundary. This avoids parameter + // wrap-around choosing the major arc instead of the minor arc. + match curve { + Curve::Circle(circle) => { + let ax = start[0] - circle.centre[0]; + let ay = start[1] - circle.centre[1]; + let bx = next[0] - circle.centre[0]; + let by = next[1] - circle.centre[1]; + + ax * by - ay * bx >= 0.0 + } + + Curve::Arc(arc) => { + let ax = start[0] - arc.centre[0]; + let ay = start[1] - arc.centre[1]; + let bx = next[0] - arc.centre[0]; + let by = next[1] - arc.centre[1]; + + ax * by - ay * bx >= 0.0 + } + + _ => { + let a = curve.parameter_at(start); + let b = curve.parameter_at(next); + let mut delta = b - a; + + if curve.is_closed() { + if delta > 0.5 { + delta -= 1.0; + } else if delta < -0.5 { + delta += 1.0; + } + } + + delta >= 0.0 } } - delta >= 0.0 } fn stored_arc_angles(start: f64, end: f64, counter_clockwise: bool, whole: bool) -> (f64, f64) { @@ -350,7 +377,7 @@ pub(crate) fn exact_hatch_paths( .iter() .enumerate() .filter_map(|(ring_index, ring)| { - let (points, curves) = refined_boundary_ring(ring, sources, tolerance); + let (points, curves) = refined_boundary_ring(ring, sources, tolerance); let count = points.len(); if count < 3 { return None; diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index 125c0dee8..d327d8a92 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -29,6 +29,11 @@ use crate::scene::model::wire_model::{ }; const MAX_NESTING_DEPTH: usize = 32; +/// Small line-only nested definitions cost more as separate instance records +/// than as part of their parent's existing style batches. Folding these tiny +/// subtrees at expansion time keeps exact geometry while avoiding one +/// heavyweight `WireModel` per leaf occurrence. +const INLINE_NESTED_POINT_BUDGET: usize = 128; /// Skip wires whose world-AABB projects to fewer than this many pixels in /// the active view. Picks up tiny detail at zoom-out so the tessellator /// doesn't waste time on geometry that contributes a few sub-pixel marks @@ -150,6 +155,9 @@ pub struct BlockMetrics { pub struct BlockDefn { pub subs: Vec, pub metrics: BlockMetrics, + /// Point cost when this whole subtree is safe and cheap to fold into its + /// parent's batches. `None` keeps the regular instanced expansion path. + inline_point_cost: Option, /// Raw entity count of the source block record (`entity_handles.len()`). /// Divisor for nested depth composition: a nested insert's children get a /// sub-range of `parent_scale / (child_count + 1)`, shared with the scene @@ -317,22 +325,60 @@ impl BlockCache { use crate::par::prelude::*; // Definitions are complete, so each recursive read can run independently. let this: &Self = self; - let resolved: Vec<(&String, BlockMetrics)> = names + let resolved: Vec<(&String, BlockMetrics, Option)> = names .par_iter() .map(|name| { let mut visited: Vec = Vec::new(); - (name, this.defn_metrics_recursive(name, &mut visited)) + let metrics = this.defn_metrics_recursive(name, &mut visited); + let inline_point_cost = + this.defn_inline_point_cost_recursive(name, &mut Vec::new()); + (name, metrics, inline_point_cost) }) .collect(); - for (name, metrics) in resolved { + for (name, metrics, inline_point_cost) in resolved { if let Some(defn_arc) = self.defns.get_mut(name) { let mut defn = (**defn_arc).clone(); defn.metrics = metrics; + defn.inline_point_cost = inline_point_cost; *defn_arc = Arc::new(defn); } } } + fn defn_inline_point_cost_recursive( + &self, + block_name: &str, + visited: &mut Vec, + ) -> Option { + if visited.iter().any(|name| name == block_name) { + return None; + } + let defn = self.defns.get(block_name)?; + visited.push(block_name.to_string()); + let result = (|| { + let mut cost = 0usize; + for sub in &defn.subs { + let sub_cost = match sub { + LocalSub::Wire(wire) => inline_wire_point_cost(wire)?, + LocalSub::Nested(nested) + if nested.clip_poly.is_none() && nested.attachments.is_empty() => + { + self.defn_inline_point_cost_recursive(&nested.block_name, visited)? + .checked_mul(nested.instance_offsets.len())? + } + LocalSub::Nested(_) => return None, + }; + cost = cost.checked_add(sub_cost)?; + if cost > INLINE_NESTED_POINT_BUDGET { + return None; + } + } + Some(cost) + })(); + visited.pop(); + result + } + fn defn_metrics_recursive( &self, block_name: &str, @@ -585,10 +631,27 @@ fn build_defn( BlockDefn { subs, metrics: BlockMetrics::default(), + inline_point_cost: None, child_count: br.entity_handles.len(), } } +fn inline_wire_point_cost(wire: &LocalWire) -> Option { + let line_tangents_only = wire + .tangent_geoms + .iter() + .all(|tangent| matches!(tangent, TangentGeom::Line { .. })); + (line_tangents_only + && !wire.is_point + && wire.point_marker.is_none() + && wire.text_verts.is_empty() + && wire.fill_tris.is_empty() + && wire.pick_tris.is_empty() + && wire.pattern_stations.is_empty() + && wire.world_width == 0.0) + .then_some(wire.points.len()) +} + fn build_nested_ref( nested_ins: &acadrust::entities::Insert, scale_policy: crate::scene::BlockScalePolicy, @@ -1640,6 +1703,14 @@ impl Batches { ); } } + // Glyph quads stay at neutral depth on purpose: the per-wire + // depth_override (b.local_depth) is composed with the insert's + // scene-graph level at upload time (wire_draw_depth: + // depths[insert] + override * half), the same place the + // wipeout/hatch fills get theirs. Baking the raw child rank + // into the vertices here would double-count it in per-block + // label units — for a negative rank that sinks the text below + // its own block's wipes instead of ahead of them. if !b.pattern_stations.is_empty() { b.pattern_stations = encode_pattern_stations( std::mem::take(&mut b.pattern_stations), @@ -1898,7 +1969,20 @@ fn expand_defn( let composed_for = |offset: &[f64; 3]| { nested_instance_transform(nref, *offset).then(accum_xform) }; - if let Some(cp) = &nref.clip_poly { + if nested_defn.inline_point_cost.is_some() && nref.clip_poly.is_none() { + for offset in &nref.instance_offsets { + expand_defn( + nested_defn, + &composed_for(offset), + &inner_ctx, + out, + visited, + depth + 1, + nref.suppress_root_points, + nested_range, + ); + } + } else if let Some(cp) = &nref.clip_poly { let base_composed = nref.xform.then(accum_xform); let base_translation = transform_translation(&base_composed); let base_poly: Vec<[f64; 2]> = cp @@ -2495,11 +2579,17 @@ fn emit_wire( .map(|marker| transformed_point_marker(marker, accum_xform)); let point_marker = marker_transform.map(|(marker, _)| marker); - // Only band wires take a per-child composed depth: their solid area is - // what covers siblings, and their width already splits them into their own - // batches — thin wires keep the shared whole-insert depth so same-style - // batches stay merged. - let local_depth = (lw.world_width > 0.0) + // Band wires take a per-child composed depth: their solid area is what + // covers siblings, and their width already splits them into their own + // batches. Text-bearing wires need it too — but as `depth_override`, not + // baked into vertices: the upload resolves it against the insert's + // scene-graph entry (depths[insert] + override * half), which is what + // keeps text interleaved with its sibling wipeout/hatch fills. Without a + // composed rank the text sits at the bare insert level, an exact tie with + // every sibling fill, and the later wipeout pass erases it (unselected + // block text vanished under its wipeout; selecting won only because the + // xray pass ignores depth). + let local_depth = (lw.world_width > 0.0 || !lw.text_verts.is_empty()) .then(|| d_range.0 + lw.local_rank * d_range.1); let plot_visible = ctx.plot_visible && lw.plot_visible @@ -3036,3 +3126,116 @@ mod bg_resolution_tests { ); } } + +#[cfg(test)] +mod compact_nested_tests { + use super::*; + use crate::scene::view::render::InheritStyle; + use acadrust::entities::{Insert, Line}; + use acadrust::tables::BlockRecord; + + fn add_block(document: &mut CadDocument, name: &str) -> Handle { + let mut block = BlockRecord::new(name); + block.handle = document.allocate_handle(); + let handle = block.handle; + document.block_records.add(block).unwrap(); + handle + } + + fn add_owned(document: &mut CadDocument, owner: Handle, mut entity: EntityType) { + entity.common_mut().owner_handle = owner; + document.add_entity(entity).unwrap(); + } + + #[test] + fn repeated_small_nested_lines_fold_into_parent_batches() { + let mut document = CadDocument::new(); + let leaf = add_block(&mut document, "LEAF"); + for y in 0..6 { + add_owned( + &mut document, + leaf, + EntityType::Line(Line::from_points( + Vector3::new(0.0, y as f64, 0.0), + Vector3::new(1.0, y as f64, 0.0), + )), + ); + } + + let twig = add_block(&mut document, "TWIG"); + for x in 0..5 { + add_owned( + &mut document, + twig, + EntityType::Insert(Insert::new( + "LEAF", + Vector3::new(x as f64 * 2.0, 0.0, 0.0), + )), + ); + } + + let root = add_block(&mut document, "ROOT"); + for x in 0..100 { + add_owned( + &mut document, + root, + EntityType::Insert(Insert::new( + "TWIG", + Vector3::new(x as f64 * 20.0, 0.0, 0.0), + )), + ); + } + + let root_insert = Insert::new("ROOT", Vector3::ZERO); + let root_handle = document + .add_entity(EntityType::Insert(root_insert.clone())) + .unwrap(); + let cache = BlockCache::build( + &document, + 1.0, + None, + true, + [0.0, 0.0, 0.0, 1.0], + None, + &HashMap::default(), + ); + let wires = expand_insert( + &document, + &cache, + &root_insert, + root_handle, + [1.0; 4], + 0, + 0.0, + [0.0; 8], + 1.0, + InheritStyle { + color: [1.0; 4], + pat_len: 0.0, + pat: [0.0; 8], + lw_px: 1.0, + }, + 0, + true, + false, + 1.0, + None, + None, + false, + [0.0, 0.0, 0.0, 1.0], + 1.0, + crate::scene::BlockScalePolicy::FromInsert, + false, + ) + .unwrap(); + + let finite_points = wires + .iter() + .flat_map(|wire| &wire.points) + .filter(|point| point[0].is_finite()) + .count(); + assert_eq!(finite_points, 6_000); + assert_eq!(wires.len(), 1); + assert!(wires[0].render_instance.is_none()); + } +} diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index 4490e24ea..98a1e23d4 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -30,7 +30,10 @@ pub fn general_section(entity: &EntityType) -> PropSection { }, ); - let hyperlink = crate::scene::pe_url_of(entity).unwrap_or_default().to_owned(); + let hyperlink = crate::scene::pe_url_description_of(entity) + .or_else(|| crate::scene::pe_url_of(entity)) + .unwrap_or_default() + .to_owned(); let section = PropSection { title: t!("General").into_owned(), @@ -83,7 +86,7 @@ pub fn general_section(entity: &EntityType) -> PropSection { Property { label: t!("Hyperlink").into_owned(), field: "hyperlink", - value: PropValue::PlainText(hyperlink), + value: PropValue::Hyperlink(hyperlink), }, ], }; diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 4c0118256..a1db36b48 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -567,6 +567,7 @@ impl Scene { viewport.view_target.x = center.x; viewport.view_target.y = center.y; viewport.view_target.z = center.z; + self.notify_viewport_changed(handle); } else { self.camera.borrow_mut().target = center; } diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 829b20cf1..485b192e9 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -3110,6 +3110,75 @@ mod tests { assert_eq!(line_wire.world_width, 10.0); } + #[test] + fn split_mixed_polyline_keeps_exact_line_intersections() { + let tangents = vec![ + TangentGeom::Line { + p1: [-10.0, 0.0, 0.0], + p2: [0.0, 0.0, 0.0], + }, + TangentGeom::Arc { + center: [0.0, 4.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 4.0, + start_angle: -std::f64::consts::FRAC_PI_2, + end_angle: std::f64::consts::FRAC_PI_2, + }, + TangentGeom::Line { + p1: [0.0, 8.0, 0.0], + p2: [10.0, 8.0, 0.0], + }, + ]; + let vertices = vec![ + [-10.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + [0.0, 8.0, 0.0], + [10.0, 8.0, 0.0], + ]; + let wires = split_mixed_polyline( + &tangents, + &vertices, + "mixed", + [1.0; 4], + false, + 0.0, + [0.0; 8], + 1.0, + Vec::new(), + None, + true, + &[(0.0, 0.0); 3], + 0.0, + Vec::new(), + Vec::new(), + ); + let line_wire = wires + .iter() + .find(|wire| { + wire.tangent_geoms + .iter() + .all(|geom| matches!(geom, TangentGeom::Line { .. })) + }) + .expect("straight-segment wire"); + let circle = WireModel { + tangent_geoms: vec![TangentGeom::PlanarCircle { + center: [5.0, 4.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + }], + ..Default::default() + }; + + let mut points = crate::snap::exact_curve_intersections(line_wire, &circle) + .expect("analytical intersections"); + points.sort_by(|left, right| left.x.total_cmp(&right.x)); + assert_eq!(points.len(), 2, "unexpected intersections: {points:?}"); + assert!((points[0] - glam::DVec3::new(2.0, 8.0, 0.0)).length() < 1.0e-9); + assert!((points[1] - glam::DVec3::new(8.0, 8.0, 0.0)).length() < 1.0e-9); + } + #[test] fn test_split_mixed_polyline_zero_width_segments_after_tapered_segment() { let tg_line = TangentGeom::Line { diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index e66fef3b3..0395da05f 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -3,7 +3,7 @@ use acadrust::objects::{ AssocDimensionAssociation, AssocDimensionReference, AssociativeData, AssociativeObject, ObjectType, }; -use acadrust::types::{Handle, Vector3}; +use acadrust::types::{Handle, Transform, Vector3}; use acadrust::EntityType; use cadkernel::geom2d::{ angle_within_arc, arc as tessellate_arc, arc_span, closest_point, Arc as KernelArc, BulgeArc, @@ -14,6 +14,8 @@ use std::f64::consts::TAU; use crate::command::DimensionAssociationSource; +use super::dimension_assoc_chain::{self as chain, ChainError, FeatureContext, ReferenceChain}; +use super::viewport_ref::{AcceptedSnap, MeasurementScale, ViewportFrame}; use super::{ChangeKind, Scene}; pub(crate) const POLYLINE_ARC_CENTER_MARKER: i32 = -4; @@ -99,6 +101,13 @@ impl RadialSourceGeometry { type DPoint = [f64; 3]; +/// The shared-contract vector (`glam`) as the codec's vector. `AcceptedSnap` +/// speaks `glam` because the snap engine does; everything stored in a +/// drawing speaks `acadrust`. +fn dvec3(point: glam::DVec3) -> Vector3 { + Vector3::new(point.x, point.y, point.z) +} + fn vector3(point: DPoint) -> Vector3 { Vector3::new(point[0], point[1], point[2]) } @@ -359,9 +368,261 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option { .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)?; +// ── Chain-aware reference resolution ────────────────────────────────────── + +/// Transform an entity-local feature through its block path and optional viewport. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ChainMap { + pub transform: Transform, + pub frame: Option, +} + +impl ChainMap { + fn identity() -> Self { + Self { + transform: Transform::identity(), + frame: None, + } + } + + /// Entity-local -> model. + pub(crate) fn to_model(&self, point: Vector3) -> Vector3 { + self.transform.apply(point) + } + + /// Model -> the dimension's own space (paper through a viewport). + pub(crate) fn model_to_space(&self, point: Vector3) -> Vector3 { + match &self.frame { + Some(frame) => { + let mapped = frame.model_to_paper(glam::DVec3::new(point.x, point.y, point.z)); + Vector3::new(mapped.x, mapped.y, mapped.z) + } + None => point, + } + } + + /// The dimension's own space -> model. + pub(crate) fn space_to_model(&self, point: Vector3) -> Vector3 { + match &self.frame { + Some(frame) => { + let mapped = frame.paper_to_model(glam::DVec3::new(point.x, point.y, point.z)); + Vector3::new(mapped.x, mapped.y, mapped.z) + } + None => point, + } + } + + /// Entity-local -> the dimension's own space. + pub(crate) fn to_space(&self, point: Vector3) -> Vector3 { + self.model_to_space(self.to_model(point)) + } + + /// Entity-local -> the dimension's own space, for a direction (the linear + /// part only: block rotation/scale, then the viewport's twist and scale). + fn direction_to_space(&self, direction: Vector3) -> Vector3 { + let direction = self.transform.apply_rotation(direction); + match &self.frame { + Some(frame) => { + let xy = frame.model_to_paper_dir(glam::DVec2::new(direction.x, direction.y)); + Vector3::new(xy.x, xy.y, direction.z) + } + None => direction, + } + } + + /// Carry a plane into the dimension's space. + /// + /// A plane is an origin and two axes, and `Plane::point_at` is affine in + /// them, so mapping the origin by the full transform and each axis by its + /// linear part maps every point *on* the plane correctly — including under + /// a non-uniformly scaled block, where the axes simply stop being + /// orthonormal. + fn map_plane(&self, plane: Plane) -> Plane { + Plane::from_axes( + dpoint(self.to_space(vector3(plane.origin))), + dpoint(self.direction_to_space(vector3(plane.x_axis))), + dpoint(self.direction_to_space(vector3(plane.y_axis))), + ) + } + + /// Carry a radial source into the dimension's space. + /// + /// Only the plane moves: the centre, radius and angles are coordinates + /// *within* the plane, so they stay as they are and every point the + /// geometry produces comes out in the dimension's space already. + fn map_radial(&self, radial: RadialSourceGeometry) -> Option { + let plane = self.map_plane(radial.plane); + let x = glam::DVec3::from_array(plane.x_axis); + let y = glam::DVec3::from_array(plane.y_axis); + let magnitude = x.length_squared().max(y.length_squared()); + if magnitude < 1e-24 + || (x.length_squared() - y.length_squared()).abs() > magnitude * 1e-9 + || x.dot(y).abs() > magnitude * 1e-9 + { + return None; + } + Some(RadialSourceGeometry { plane, ..radial }) + } +} + +/// One reference resolved all the way down the chain. +#[derive(Clone, Copy, Debug)] +pub struct ResolvedReference { + /// The feature point in model space, through the block-instance path. + pub model: Vector3, + /// The same point in the dimension's own space — projected onto the sheet + /// when the chain goes through a viewport, otherwise equal to `model`. + pub space: Vector3, + /// The viewport the chain looks through, if any. + pub viewport: Option, +} + +/// Why a reference did not produce a point. The reference's stored data is +/// never modified on a failure — a dimension whose source went away keeps its +/// last valid appearance, and an undo that restores the source restores the +/// link with no extra bookkeeping. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ReferenceStatus { + /// Resolved to a live feature point. + Resolved, + /// A handle in the chain is no longer in the document. + Broken(Handle), + /// The chain is intact but the feature could not be evaluated — an osnap + /// type we do not support, or geometry that no longer has the feature. + Unresolved, +} + +fn chain_map(scene: &Scene, walked: &ReferenceChain) -> ChainMap { + ChainMap { + transform: walked.transform, + frame: walked + .viewport + .and_then(|handle| scene.viewport_frame(handle)), + } +} + +/// How far `point` (entity-local) is from `entity`'s geometry. Used only to +/// choose between two readings of a stored osnap point, so an approximate +/// answer for exotic entities is fine. +fn hint_error(entity: &EntityType, point: Vector3) -> f64 { + if let Some(distance) = source_distance_squared(entity, point) { + if distance.is_finite() { + return distance; + } + } + if let Some(planar) = crate::entities::curve::entity_curve(entity) { + if let Some(uv) = planar.plane.project(dpoint(point)) { + let near = closest_point(&planar.curve, uv); + let world = planar.plane.point_at(near.point); + return point_distance_squared(vector3(world), point); + } + } + f64::INFINITY +} + +/// Interpret a stored point as a source-space or dimension-space hint. +/// Validate both interpretations against the geometry before using the hint +/// to disambiguate a feature; the stored point is never the resolved result. +fn local_hint(map: &ChainMap, entity: &EntityType, stored: Vector3) -> Option { + let inverse = chain::invert(&map.transform)?; + let mut candidates = vec![inverse.apply(stored)]; + if map.frame.is_some() { + candidates.push(inverse.apply(map.space_to_model(stored))); + } + candidates + .into_iter() + .filter(|point| point.x.is_finite() && point.y.is_finite() && point.z.is_finite()) + .min_by(|first, second| hint_error(entity, *first).total_cmp(&hint_error(entity, *second))) +} + +/// Resolve one reference through its full chain. +/// +/// `from_space` is the *other* definition point of the same dimension, in the +/// dimension's own space. PERPENDICULAR and TANGENT have no meaning without +/// it, so the caller resolves the independent references first and comes back +/// for these. +pub fn resolve_reference_chain( + scene: &Scene, + reference: &AssocDimensionReference, + from_space: Option, +) -> Result { + let walked = match chain::walk_chain(&scene.document, &reference.xrefs) { + Ok(walked) => walked, + Err(ChainError::Missing(handle)) => return Err(ReferenceStatus::Broken(handle)), + Err(ChainError::Empty | ChainError::Invalid) => return Err(ReferenceStatus::Unresolved), + }; + let Some(entity) = scene.document.get_entity(walked.entity) else { + return Err(ReferenceStatus::Broken(walked.entity)); + }; + let map = chain_map(scene, &walked); + if walked.viewport.is_some() && map.frame.is_none() { + return Err(ReferenceStatus::Unresolved); + } + let inverse = chain::invert(&map.transform).ok_or(ReferenceStatus::Unresolved)?; + if matches!( + reference.osnap_type, + chain::osnap::INTERSEC | chain::osnap::APPARENT_INT + ) { + match chain::walk_chain(&scene.document, &reference.intersection_objects) { + Err(ChainError::Missing(handle)) => return Err(ReferenceStatus::Broken(handle)), + Err(_) => return Err(ReferenceStatus::Unresolved), + Ok(_) => {} + } + } + let legacy = reference.main_subent_type != 2 + && matches!( + reference.osnap_type, + chain::osnap::NONE | chain::osnap::END | chain::osnap::START_POINT + ); + let local = if reference.osnap_type == chain::osnap::PERP && !walked.block_path.is_empty() { + from_space + .and_then(|from| { + chain::perpendicular_in_model(entity, &map.transform, map.space_to_model(from)) + }) + .map(|point| inverse.apply(point)) + } else if legacy { + resolve_local_by_marker(entity, reference) + } else if chain::evaluates(reference.osnap_type) { + let hint = if reference + .osnap_point + .x + .abs() + .max(reference.osnap_point.y.abs()) + .max(reference.osnap_point.z.abs()) + < 1e40 + { + local_hint(&map, entity, reference.osnap_point) + } else { + None + }; + let from = from_space.map(|point| inverse.apply(map.space_to_model(point))); + chain::feature_point( + &scene.document, + entity, + reference, + FeatureContext { hint, from }, + ) + } else { + None + } + .ok_or(ReferenceStatus::Unresolved)?; + if !local.x.is_finite() || !local.y.is_finite() || !local.z.is_finite() { + return Err(ReferenceStatus::Unresolved); + } + let model = map.to_model(local); + Ok(ResolvedReference { + model, + space: map.model_to_space(model), + viewport: walked.viewport, + }) +} + +/// Resolve a marker/parameter in entity-local coordinates. +/// The caller applies the block and viewport transforms. +fn resolve_local_by_marker( + entity: &EntityType, + reference: &AssocDimensionReference, +) -> Option { 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); @@ -539,6 +800,13 @@ fn remap_plane_offset( ])) } +/// Whether `dimension` still has at least one reference whose whole chain +/// resolves to a live entity. +/// +/// The chain matters: an imported trans-space reference has the VIEWPORT as +/// its first handle, so "some handle in `xrefs` exists" was true even for a +/// reference whose actual source had been erased. Walking the chain asks the +/// question that was meant — is the geometry still there. pub(crate) fn dimension_is_associative( document: &acadrust::CadDocument, dimension: Handle, @@ -552,12 +820,11 @@ pub(crate) fn dimension_is_associative( }; association.dimension == dimension && association.associativity != 0 - && association.references.iter().flatten().any(|reference| { - reference - .xrefs - .iter() - .any(|source| document.get_entity(*source).is_some()) - }) + && association + .references + .iter() + .flatten() + .any(|reference| chain::walk_chain(document, &reference.xrefs).is_ok()) }) } @@ -584,6 +851,15 @@ pub(crate) fn constraint_from_associative_dimension( }; (association.dimension == handle && association.associativity != 0).then_some(association) })?; + if association.trans_space + || association + .references + .iter() + .flatten() + .any(|r| r.xrefs.len() != 1) + { + return None; + } let point = |index: usize| { let reference = association.references.get(index)?.first()?; Some(ParametricRef::point( @@ -858,6 +1134,35 @@ impl Scene { references: [Vec; 4], associativity: i32, sources: Vec>, + ) { + self.store_dimension_association_full( + dimension, + references, + associativity, + false, + sources.into_iter().flatten().map(|s| s.handle).collect(), + ); + } + + /// Create the DIMASSOC object for `dimension` and wire its reactors. + /// + /// `reactor_targets` is every handle the association depends on — + /// including the viewport and each INSERT on a block path, not just the + /// innermost entity — so a change to any link in the chain reaches + /// [`Scene::refresh_associative_dimensions`] through the normal + /// dependency index. + /// + /// The new object and every entity whose reactor list is touched are + /// journalled into the open undo recording, so creating an associative + /// dimension is a single undoable step rather than something that survives + /// its own undo. + fn store_dimension_association_full( + &mut self, + dimension: Handle, + references: [Vec; 4], + associativity: i32, + trans_space: bool, + reactor_targets: Vec, ) { let association_handle = self.document.allocate_handle(); let mut object = AssociativeObject::new("DIMASSOC", "AcDbDimAssoc"); @@ -865,27 +1170,487 @@ impl Scene { object.reactors.push(dimension); object.data = AssociativeData::DimensionAssociation(AssocDimensionAssociation { associativity, + trans_space, dimension, references, ..AssocDimensionAssociation::default() }); + // A brand-new object: its before-image is "absent", so undo erases it. + self.record_undo_object_before(association_handle, None); 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)); + let mut reactor_targets = reactor_targets; + reactor_targets.push(dimension); + reactor_targets.retain(|handle| !handle.is_null()); 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) { + if self + .document + .get_entity(handle) + .is_some_and(|entity| !entity.common().reactors.contains(&association_handle)) + { + if self.is_recording_undo() { + let before = self.document.get_entity_arc(handle); + self.record_undo_before(handle, before); + } + if let Some(entity) = self.document.get_entity_mut(handle) { entity.common_mut().reactors.push(association_handle); } } } } + /// Persist the association for a dimension whose points were picked + /// through [`AcceptedSnap`]s, preserving each source and its owning space. + /// + /// `snaps` is positional: one slot per definition point, in the same order + /// [`dimension_reference_points`] returns them (linear/aligned: first, + /// second; angular: see that function). A `None` slot, or a snap that + /// landed on empty space, leaves that slot non-associative. + /// + /// Each reference records the whole chain — viewport, block-instance path, + /// entity — plus the osnap type and the sub-entity marker / parameter, so + /// the feature survives an edit to the source rather than being + /// re-inferred by proximity. + pub fn attach_viewport_dimension_association( + &mut self, + dimension: Handle, + snaps: &[Option], + ) { + let Some(EntityType::Dimension(dimension_entity)) = self.document.get_entity(dimension) + else { + return; + }; + // Radial references identify a circle/arc and angle, rather than a vertex. + let radial_dimension = matches!( + dimension_entity, + Dimension::Radius(_) | Dimension::Diameter(_) | Dimension::LargeRadial(_) + ); + if radial_dimension { + self.attach_viewport_radial_association(dimension, snaps); + return; + } + let mut references: [Vec; 4] = std::array::from_fn(|_| Vec::new()); + let mut associativity = 0; + let mut trans_space = false; + let mut reactor_targets = Vec::new(); + + for (index, snap) in snaps.iter().enumerate().take(references.len()) { + let Some(snap) = snap else { continue }; + let Some(source) = snap.source.as_ref() else { + continue; + }; + let entity_handle = source.source.handle; + let Some(entity) = self.document.get_entity(entity_handle) else { + continue; + }; + // dimension -> [viewport] -> [insert ...] -> entity + let mut xrefs = Vec::new(); + if let Some(viewport) = snap.viewport { + xrefs.push(viewport); + } + xrefs.extend(source.block_path.iter().copied()); + xrefs.push(entity_handle); + let Ok(walked) = chain::walk_chain(&self.document, &xrefs) else { + continue; + }; + let Some(inverse) = chain::invert(&walked.transform) else { + continue; + }; + let local = inverse.apply(dvec3(snap.model_point)); + let osnap_type = chain::osnap_type_for(source.snap_type); + let marker_parameter = source + .source + .marker + .map(|marker| (marker, source.source.parameter)) + .or_else(|| feature_reference(entity, local, source.snap_type)); + let Some((marker, parameter)) = marker_parameter else { + continue; + }; + let mut intersection_objects = Vec::new(); + if matches!( + source.snap_type, + crate::snap::SnapType::Intersection | crate::snap::SnapType::ApparentIntersection + ) { + let Some(other) = source.intersection.as_ref() else { + continue; + }; + intersection_objects.extend(snap.viewport); + intersection_objects.extend(other.block_path.iter().copied()); + intersection_objects.push(other.source.handle); + if chain::walk_chain(&self.document, &intersection_objects).is_err() { + continue; + } + reactor_targets.extend(intersection_objects.iter().copied()); + } + trans_space |= snap.viewport.is_some(); + reactor_targets.extend(xrefs.iter().copied()); + associativity |= 1 << index; + references[index].push(AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type, + xrefs, + intersection_objects, + main_subent_type: 1, + main_gs_marker: marker, + osnap_distance: parameter, + // Store the source's owning-space point: model coordinates for + // viewport features, paper coordinates for direct sheet features. + osnap_point: dvec3(snap.model_point), + ..AssocDimensionReference::default() + }); + } + if associativity == 0 { + return; + } + self.store_dimension_association_full( + dimension, + references, + associativity, + trans_space, + reactor_targets, + ); + } + + /// The radial half of [`Scene::attach_viewport_dimension_association`]. + /// + /// The first snap that carries a source wins: a radius / diameter + /// dimension measures one circle or one polyline arc segment, and the + /// command only ever acquires one. + fn attach_viewport_radial_association( + &mut self, + dimension: Handle, + snaps: &[Option], + ) { + let Some(snap) = snaps.iter().flatten().find(|snap| snap.source.is_some()) else { + return; + }; + let source = snap.source.as_ref().expect("filtered above"); + let entity_handle = source.source.handle; + let Some(entity) = self.document.get_entity(entity_handle) else { + return; + }; + let mut xrefs = Vec::new(); + xrefs.extend(snap.viewport); + xrefs.extend(source.block_path.iter().copied()); + xrefs.push(entity_handle); + let Ok(walked) = chain::walk_chain(&self.document, &xrefs) else { + return; + }; + let Some(inverse) = chain::invert(&walked.transform) else { + return; + }; + let local = inverse.apply(dvec3(snap.model_point)); + let radial = match source.source.marker { + Some(marker) => radial_source_for_marker(entity, marker), + None => radial_source_at(entity, local), + }; + let Some(radial) = radial else { return }; + let angle = if source.source.marker.is_some() && source.source.parameter.is_finite() { + source.source.parameter + } else { + radial.angle_at(dpoint(local)) + }; + let trans_space = snap.viewport.is_some(); + let reactor_targets = xrefs.clone(); + let reference = AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type: 10, + xrefs, + main_subent_type: 1, + main_gs_marker: radial.marker, + osnap_distance: angle, + osnap_point: dvec3(snap.model_point), + ..AssocDimensionReference::default() + }; + self.store_dimension_association_full( + dimension, + [vec![reference], Vec::new(), Vec::new(), Vec::new()], + 1, + trans_space, + reactor_targets, + ); + } + + /// The definition points an association fills, slot by slot, in the same + /// order [`Scene::attach_viewport_dimension_association`] consumes its + /// `snaps` argument. + /// + /// Callers use it to size and sanity-check the slot array they build from + /// the accepted snaps: slot `k` of that array must describe the feature + /// sitting at `dimension_association_slot_points()[k]`. + pub(crate) fn dimension_association_slot_points(&self, dimension: Handle) -> Vec { + let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) else { + return Vec::new(); + }; + match entity { + // A radial dimension's single reference describes the point where + // the leader meets the circle. + Dimension::Radius(radius) => vec![radius.definition_point], + Dimension::Diameter(diameter) => vec![diameter.angle_vertex], + Dimension::LargeRadial(radial) => vec![radial.chord_point], + other => dimension_reference_points(other), + } + } + + /// Select one measurement viewport while allowing direct references to + /// geometry owned by the dimension's paper layout. A model-space chain + /// without its viewport cannot be interpreted as a paper-space anchor. + fn association_measurement_frame( + &self, + association: &AssocDimensionAssociation, + ) -> Result, ()> { + let mut viewport = None; + for reference in association.references.iter().flatten() { + for handles in [&reference.xrefs, &reference.intersection_objects] { + let Some(root) = handles.first() else { + continue; + }; + if matches!( + self.document.get_entity(*root), + Some(EntityType::Viewport(_)) + ) { + if viewport.is_some_and(|previous| previous != *root) { + return Err(()); + } + viewport = Some(*root); + } + } + } + let Some(viewport) = viewport else { + return if association.trans_space { + Err(()) + } else { + Ok(None) + }; + }; + let owner = self + .document + .get_entity(association.dimension) + .ok_or(())? + .common() + .owner_handle; + for reference in association.references.iter().flatten() { + for handles in [&reference.xrefs, &reference.intersection_objects] { + let Some(root) = handles.first() else { + continue; + }; + if self + .document + .get_entity(*root) + .ok_or(())? + .common() + .owner_handle + != owner + { + return Err(()); + } + } + } + self.viewport_frame(viewport).map(Some).ok_or(()) + } + + /// Resolve each reference's current status without changing its saved data. + pub fn dimension_association_status(&self, dimension: Handle) -> Vec<(usize, ReferenceStatus)> { + let Some(association) = self.dimension_association(dimension) else { + return Vec::new(); + }; + let measurement_supported = !association.trans_space + || self.document.get_entity(dimension).is_some_and(|entity| { + matches!( + entity, + EntityType::Dimension(Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) + ) || persisted_measurement_scale(&self.document, entity).is_some() + }); + let measurement_supported = measurement_supported + && self.document.get_entity(dimension).is_some_and(|entity| { + if !matches!( + entity, + EntityType::Dimension( + Dimension::Radius(_) | Dimension::Diameter(_) | Dimension::LargeRadial(_) + ) + ) { + return true; + } + association.references[0] + .first() + .and_then(|reference| { + let walked = chain::walk_chain(&self.document, &reference.xrefs).ok()?; + let source = self.document.get_entity(walked.entity)?; + chain_map(self, &walked) + .map_radial(radial_source_for_marker(source, reference.main_gs_marker)?) + }) + .is_some() + }); + let measurement_supported = measurement_supported + && association + .references + .iter() + .flatten() + .filter(|r| chain::needs_from_point(r.osnap_type)) + .count() + <= 1; + let measurement_supported = + measurement_supported && self.association_measurement_frame(association).is_ok(); + let points = self.dimension_association_slot_points(dimension); + association + .references + .iter() + .enumerate() + .filter_map(|(index, slot)| { + let reference = slot.first()?; + Some(( + index, + match resolve_reference_chain( + self, + reference, + points + .iter() + .enumerate() + .find_map(|(other, point)| (other != index).then_some(*point)), + ) { + Ok(_) if measurement_supported => ReferenceStatus::Resolved, + Ok(_) => ReferenceStatus::Unresolved, + Err(status) => status, + }, + )) + }) + .collect() + } + + /// Every entity `dimension` depends on: the viewport it looks through, + /// each INSERT on the block path, and the source geometry itself — for + /// each reference chain, plus the second chain of an intersection. + /// + /// Layer visibility and viewport clipping are deliberately not consulted: + /// hiding a layer or clipping a viewport changes what a *new* snap may + /// acquire, never what an existing association points at. + pub fn dimension_association_sources(&self, dimension: Handle) -> Vec { + let Some(association) = self.dimension_association(dimension) else { + return Vec::new(); + }; + let mut sources = Vec::new(); + for reference in association.references.iter().flatten() { + for xrefs in [&reference.xrefs, &reference.intersection_objects] { + let Ok(walked) = chain::walk_chain(&self.document, xrefs) else { + continue; + }; + sources.extend(walked.viewport); + sources.extend(walked.block_path.iter().copied()); + sources.push(walked.entity); + } + } + sources.sort_by_key(|handle| handle.value()); + sources.dedup(); + sources + } + + /// The DIMASSOC payload attached to `dimension`, if there is one. + pub fn dimension_association(&self, dimension: Handle) -> Option<&AssocDimensionAssociation> { + self.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) + }) + } + + /// Preserve an association only when the dimension and every source-chain + /// handle were copied. Remap the complete chain to the new entities. + pub(crate) fn copy_dimension_associations( + &mut self, + handle_map: &rustc_hash::FxHashMap, + ) { + if handle_map.is_empty() { + return; + } + let copies: 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; + }; + let dimension = *handle_map.get(&association.dimension)?; + let complete = association.references.iter().flatten().all(|reference| { + reference + .xrefs + .iter() + .chain(reference.intersection_objects.iter()) + .all(|handle| handle.is_null() || handle_map.contains_key(handle)) + }); + if !complete { + return None; + } + let remap = |handles: &[Handle]| -> Vec { + handles + .iter() + .map(|handle| handle_map.get(handle).copied().unwrap_or(*handle)) + .collect() + }; + let references = std::array::from_fn(|index| { + association.references[index] + .iter() + .map(|reference| AssocDimensionReference { + xrefs: remap(&reference.xrefs), + intersection_objects: remap(&reference.intersection_objects), + ..reference.clone() + }) + .collect::>() + }); + Some(( + dimension, + references, + association.associativity, + association.trans_space, + )) + }) + .collect(); + for (dimension, references, associativity, trans_space) in copies { + let reactor_targets: Vec = { + let references: &[Vec; 4] = &references; + references + .iter() + .flatten() + .flat_map(|reference| { + reference + .xrefs + .iter() + .chain(reference.intersection_objects.iter()) + .copied() + }) + .collect() + }; + self.store_dimension_association_full( + dimension, + references, + associativity, + trans_space, + reactor_targets, + ); + } + } + + /// Refresh dimensions when their viewport moves, pans, zooms, or twists. + pub fn notify_viewport_changed(&mut self, viewport: Handle) { + if matches!( + self.document.get_entity(viewport), + Some(EntityType::Viewport(_)) + ) { + self.bump_entities(&[(viewport, ChangeKind::Modified)]); + } + } + pub(crate) fn infer_dimension_sources(&self, dimension: Handle) -> Vec> { let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) else { return Vec::new(); @@ -963,6 +1728,10 @@ impl Scene { let AssociativeData::DimensionAssociation(association) = &object.data else { return None; }; + // Any link in a chain counts: the source entity, an INSERT on + // the block path, or the viewport the reference looks through + // (that last one is how a pan / zoom / twist of a layout + // viewport reaches the dimensions drawn through it). association .references .iter() @@ -971,38 +1740,138 @@ impl Scene { reference .xrefs .iter() + .chain(reference.intersection_objects.iter()) .any(|handle| changed.contains(handle)) }) - .then_some(association.clone()) + .then(|| 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 Ok(frame) = self.association_measurement_frame(&association) else { + continue; + }; + // Dependent snaps need a resolved independent definition point. + // Two such features require a coupled solve; old points are not + // evidence of a solution after the geometry changes. + if association + .references + .iter() + .flatten() + .filter(|r| chain::needs_from_point(r.osnap_type)) + .count() + > 1 + { + continue; + } + // Resolve independent references first, then perpendicular/tangent. + let mut resolved: [Option; 4] = std::array::from_fn(|index| { + let reference = association.references[index].first()?; + if chain::needs_from_point(reference.osnap_type) { + return None; + } + resolve_reference_chain(self, reference, None).ok() }); + let previous = self + .document + .get_entity(association.dimension) + .and_then(|entity| match entity { + EntityType::Dimension(dimension) => Some(dimension_reference_points(dimension)), + _ => None, + }) + .unwrap_or_default(); + for index in 0..resolved.len() { + let Some(reference) = association.references[index].first() else { + continue; + }; + if !chain::needs_from_point(reference.osnap_type) { + continue; + } + // The other point: freshly resolved where possible, else the + // one the dimension is currently drawn with. + let from = (0..resolved.len()) + .filter(|other| *other != index) + .find_map(|other| { + resolved[other] + .map(|value| value.space) + .or_else(|| previous.get(other).copied()) + }); + resolved[index] = resolve_reference_chain(self, reference, from).ok(); + } + if association + .references + .iter() + .enumerate() + .any(|(index, slot)| !slot.is_empty() && resolved[index].is_none()) + { + continue; + } + // Every point is already in paper space: direct sheet references + // stay there, and model references are projected individually. + // Only the measurement factor is shared by the whole dimension. + let map = frame + .map(|frame| ChainMap { + transform: Transform::identity(), + frame: Some(frame), + }) + .unwrap_or_else(ChainMap::identity); + let model_points: [Option; 4] = std::array::from_fn(|index| { + resolved[index] + .filter(|value| value.viewport.is_some()) + .map(|value| value.model) + }); + let resolved: [Option; 4] = + std::array::from_fn(|index| resolved[index].map(|value| value.space)); + let radial_source = association.references[0].first().and_then(|reference| { - let source = *reference.xrefs.first()?; - let entity = self.document.get_entity(source)?; + let walked = chain::walk_chain(&self.document, &reference.xrefs).ok()?; + let entity = self.document.get_entity(walked.entity)?; let radial = radial_source_for_marker(entity, reference.main_gs_marker)?; - Some((radial, reference.osnap_distance)) + // Mapped once, here: everything the match arms below build out + // of it then lands in the dimension's own space. + Some(( + chain_map(self, &walked).map_radial(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 walked = chain::walk_chain(&self.document, &reference.xrefs).ok()?; + let entity = self.document.get_entity(walked.entity)?; 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) + let radial = radial_source_for_marker(entity, segment)?; + chain_map(self, &walked).map_radial(radial) }); if radial_source.is_none() && resolved.iter().all(Option::is_none) { continue; } + let Some(before) = self.document.get_entity(association.dimension).cloned() else { + continue; + }; + let angular = matches!( + &before, + EntityType::Dimension(Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) + ); + let measurement_scale = if let Some(frame) = map.frame.filter(|_| !angular) { + let Some(mut scale) = persisted_measurement_scale(&self.document, &before) else { + continue; + }; + scale.viewport_compensation = frame.paper_to_model_length_factor(); + Some(scale) + } else { + None + }; + if self.is_recording_undo() { + let before = self.document.get_entity_arc(association.dimension); + self.record_undo_before(association.dimension, before); + } + if measurement_scale.is_some() { + self.ensure_app_id(MeasurementScale::APP_ID); + } let Some(EntityType::Dimension(dimension)) = self.document.get_entity_mut(association.dimension) else { @@ -1010,22 +1879,45 @@ impl Scene { }; match dimension { Dimension::Linear(linear) => { + // The sheet annotation the user placed — the dimension + // line's standoff and, when they dragged it, the text — is + // held relative to the measured points. Carry it by the + // centroid shift so an edit to the source keeps the + // drafting the user did instead of collapsing it. + let carry = centroid_shift( + [Some(linear.first_point), Some(linear.second_point)], + [resolved[0], resolved[1]], + ); if let Some(point) = resolved[0] { linear.first_point = point; } if let Some(point) = resolved[1] { linear.second_point = point; } + linear.definition_point = linear.definition_point + carry; linear.base.definition_point = linear.definition_point; + if linear.base.text_user_positioned { + linear.base.text_middle_point = linear.base.text_middle_point + carry; + linear.base.insertion_point = linear.base.insertion_point + carry; + } } Dimension::Aligned(aligned) => { + let carry = centroid_shift( + [Some(aligned.first_point), Some(aligned.second_point)], + [resolved[0], resolved[1]], + ); if let Some(point) = resolved[0] { aligned.first_point = point; } if let Some(point) = resolved[1] { aligned.second_point = point; } + aligned.definition_point = aligned.definition_point + carry; aligned.base.definition_point = aligned.definition_point; + if aligned.base.text_user_positioned { + aligned.base.text_middle_point = aligned.base.text_middle_point + carry; + aligned.base.insertion_point = aligned.base.insertion_point + carry; + } } Dimension::Angular3Pt(angular) => { if let Some(point) = resolved[0] { @@ -1219,11 +2111,200 @@ impl Scene { Dimension::Linear(linear) => linear_measurement(linear), _ => dimension.measurement(), }; + // Store the raw sheet measurement; the formatter applies DIMLFAC once. + debug_assert!( + map.frame.is_none() + || model_points + .iter() + .zip(resolved.iter()) + .all(|(model, space)| match (model, space, map.frame.as_ref()) { + (Some(model), Some(space), Some(frame)) => { + let projected = frame + .model_to_paper(glam::DVec3::new(model.x, model.y, model.z)); + (projected.x - space.x).abs() < 1e-6 + && (projected.y - space.y).abs() < 1e-6 + } + _ => true, + }), + "resolved paper points must be the model points through the frame", + ); dimension.base_mut().actual_measurement = measurement; + if map.frame.is_some() { + if let EntityType::Dimension(old) = &before { + if old.base().text_user_positioned { + dimension.base_mut().text_middle_point = old.base().text_middle_point; + dimension.base_mut().insertion_point = old.base().insertion_point; + } + } + } + let entity = self + .document + .get_entity_mut(association.dimension) + .expect("dimension exists"); + if let Some(scale) = measurement_scale { + scale.write_to_entity(entity); + } + if *entity == before { + continue; + } + // Saved anonymous blocks bypass generated dimension rendering. + // Invalidate only after a completely resolved, committed update; + // the undo snapshot above retains the previous picture and data. + if let EntityType::Dimension(dimension) = entity { + dimension.base_mut().block_name.clear(); + } refreshed.push((association.dimension, ChangeKind::Modified)); } refreshed } + pub(crate) fn sync_diameter_association_angle(&mut self, dimension: Handle) { + let chord = match self.document.get_entity(dimension) { + Some(EntityType::Dimension(Dimension::Diameter(diameter))) => { + diameter.angle_vertex + } + _ => return, + }; + + let association_handle = self.document.objects.iter().find_map(|(handle, object)| { + let ObjectType::Associative(object) = object else { + return None; + }; + + let AssociativeData::DimensionAssociation(association) = &object.data else { + return None; + }; + + (association.dimension == dimension).then_some(*handle) + }); + + let Some(association_handle) = association_handle else { + return; + }; + + // Resolve the radial source while the association is borrowed immutably. + let angle = { + let Some(ObjectType::Associative(object)) = + self.document.objects.get(&association_handle) + else { + return; + }; + + let AssociativeData::DimensionAssociation(association) = &object.data else { + return; + }; + + let Some(reference) = association.references[0].first() else { + return; + }; + + let Ok(walked) = chain::walk_chain(&self.document, &reference.xrefs) else { + return; + }; + + let Some(entity) = self.document.get_entity(walked.entity) else { + return; + }; + + let Some(radial) = radial_source_for_marker(entity, reference.main_gs_marker) else { + return; + }; + + let Some(radial) = chain_map(self, &walked).map_radial(radial) else { + return; + }; + + radial.angle_at(dpoint(chord)) + }; + + if self.is_recording_undo() { + let before = self.document.objects.get(&association_handle).cloned(); + self.record_undo_object_before(association_handle, before); + } + + let Some(ObjectType::Associative(object)) = + self.document.objects.get_mut(&association_handle) + else { + return; + }; + + let AssociativeData::DimensionAssociation(association) = &mut object.data else { + return; + }; + + let Some(reference) = association.references[0].first_mut() else { + return; + }; + + reference.osnap_distance = angle; + reference.osnap_point = chord; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::{Circle, DimensionDiameter}; + + #[test] + fn diameter_angle_sync_records_the_association_for_undo() { + let mut scene = Scene::new(); + let circle = scene.add_entity(EntityType::Circle(Circle::from_center_radius( + Vector3::ZERO, + 5.0, + ))); + let dimension = scene.add_entity(EntityType::Dimension(Dimension::Diameter( + DimensionDiameter::new( + Vector3::new(5.0, 0.0, 0.0), + Vector3::new(-5.0, 0.0, 0.0), + ), + ))); + scene.attach_dimension_association(dimension, vec![Some(circle)]); + let association = scene + .document + .objects + .iter() + .find_map(|(handle, object)| match object { + ObjectType::Associative(object) + if matches!( + object.data, + AssociativeData::DimensionAssociation(ref association) + if association.dimension == dimension + ) => Some(*handle), + _ => None, + }) + .expect("diameter association"); + + scene.begin_undo_recording(); + let Some(EntityType::Dimension(Dimension::Diameter(diameter))) = + scene.document.get_entity_mut(dimension) + else { + panic!("diameter dimension"); + }; + diameter.angle_vertex = Vector3::new(0.0, 5.0, 0.0); + scene.sync_diameter_association_angle(dimension); + + let recording = scene.take_undo_recording().expect("undo recording"); + let (_, objects, _, _) = recording.into_recorded_images(); + assert!(objects + .iter() + .any(|(handle, before)| *handle == association && before.is_some())); + } +} + +/// Average displacement of the dimension's resolved definition points. +fn centroid_shift(before: [Option; 2], after: [Option; 2]) -> Vector3 { + let mut delta = Vector3::new(0.0, 0.0, 0.0); + let mut count = 0.0; + for index in 0..2 { + if let (Some(old), Some(new)) = (before[index], after[index]) { + delta = delta + (new - old); + count += 1.0; + } + } + if count == 0.0 { + return Vector3::new(0.0, 0.0, 0.0); + } + Vector3::new(delta.x / count, delta.y / count, delta.z / count) } fn linear_measurement(linear: &acadrust::entities::DimensionLinear) -> f64 { @@ -1235,3 +2316,121 @@ fn linear_measurement(linear: &acadrust::entities::DimensionLinear) -> f64 { let axis = [linear.rotation.cos(), linear.rotation.sin()]; ((second[0] - first[0]) * axis[0] + (second[1] - first[1]) * axis[1]).abs() } + +/// Encode the feature actually acquired, in source-local coordinates. +fn feature_reference( + entity: &EntityType, + point: Vector3, + kind: crate::snap::SnapType, +) -> Option<(i32, f64)> { + use crate::snap::SnapType as S; + match kind { + S::Node | S::Insertion => Some((0, 0.0)), + S::Center if matches!(entity, EntityType::Ellipse(_)) => Some((-3, 0.0)), + S::Endpoint | S::ObjectPick | S::Center => { + if kind != S::Center { + if let Some(index) = source_points(entity) + .iter() + .position(|candidate| point_distance_squared(*candidate, point) < 1e-12) + { + return Some((index as i32, 0.0)); + } + } + if matches!(entity, EntityType::Circle(_) | EntityType::Arc(_)) { + return source_reference(entity, point); + } + let planar = crate::entities::curve::entity_curve(entity)?; + if let KernelCurve::Polyline(polyline) = &planar.curve { + for segment in 0..polyline.vertices.len() { + if let Some(arc) = polyline.segment_arc(segment) { + let center = vector3(planar.plane.point_at(arc.center)); + if point_distance_squared(center, point) < 1e-12 { + return Some((POLYLINE_ARC_CENTER_MARKER, segment as f64)); + } + } + } + } + None + } + S::Nearest | S::Extension => { + if matches!(entity, EntityType::Circle(_) | EntityType::Arc(_)) { + return source_reference(entity, point); + } + let planar = crate::entities::curve::entity_curve(entity)?; + let near = closest_point(&planar.curve, planar.plane.project(dpoint(point))?); + Some((0, near.t)) + } + S::Midpoint => { + let planar = crate::entities::curve::entity_curve(entity)?; + if let KernelCurve::Polyline(_) = &planar.curve { + let uv = planar.plane.project(dpoint(point))?; + let segments = planar.curve.segments(); + let (index, _) = segments.iter().enumerate().min_by(|(_, a), (_, b)| { + let error = |c: &KernelCurve| { + let p = closest_point(c, uv).point; + (p[0] - uv[0]).powi(2) + (p[1] - uv[1]).powi(2) + }; + error(a).total_cmp(&error(b)) + })?; + Some((index as i32, 0.5)) + } else { + Some((0, 0.5)) + } + } + S::Tangent => { + if let EntityType::Spline(spline) = entity { + return Some(( + 0, + crate::entities::spline::nurbs3(spline)?.parameter_at(dpoint(point)), + )); + } + source_reference(entity, point) + } + S::Intersection | S::ApparentIntersection => Some((0, 0.0)), + _ => source_reference(entity, point), + } +} + +fn persisted_measurement_scale( + doc: &acadrust::CadDocument, + entity: &EntityType, +) -> Option { + use crate::entities::dim_override; + use acadrust::xdata::XDataValue; + let EntityType::Dimension(dimension) = entity else { + return None; + }; + let data = &dimension.base().common.extended_data; + let effective = dim_override::real(data, dim_override::DIMLFAC) + .or_else(|| { + doc.dim_styles + .get(&dimension.base().style_name) + .map(|s| s.dimlfac) + })? + .abs(); + let effective = if effective == 0.0 { 1.0 } else { effective }; + if !effective.is_finite() { + return None; + } + if let Some(mut scale) = MeasurementScale::read(data) { + // A Properties edit writes the persisted total DIMLFAC. Preserve that + // new user choice when the next source/viewport update recomposes it. + scale.user_lfac = effective / scale.viewport_compensation; + return Some(scale); + } + let calculated = data + .get_record("ACAD_DIMASSOC_CALC_DIMLFAC")? + .values + .iter() + .find_map(|v| match v { + XDataValue::Real(v) => Some(v.abs()), + _ => None, + })?; + if !calculated.is_finite() || calculated <= 0.0 { + return None; + } + Some(MeasurementScale { + user_lfac: effective / calculated, + viewport_compensation: calculated, + }) +} diff --git a/src/scene/dimension_assoc_chain.rs b/src/scene/dimension_assoc_chain.rs new file mode 100644 index 000000000..3a9146af0 --- /dev/null +++ b/src/scene/dimension_assoc_chain.rs @@ -0,0 +1,587 @@ +//! Resolve a dimension feature through `[viewport?, insert..., entity]`. +//! `xrefs` stores the primary chain; `intersection_objects` stores the second +//! intersection source. Features are evaluated in entity-local coordinates and +//! transformed through their block instances. The caller projects viewport +//! references onto the sheet. All arithmetic uses `f64`. + +use acadrust::objects::AssocDimensionReference; +use acadrust::types::{Handle, Matrix4, Transform, Vector3}; +use acadrust::{CadDocument, EntityType}; +use cadkernel::geom2d::{closest_point, Curve as KernelCurve}; +use cadkernel::space::Plane; + +use crate::entities::curve::entity_curve; + +/// `AcDb::OsnapMode`, the value stored in +/// [`AssocDimensionReference::osnap_type`]. +/// +/// These are ObjectARX codes, independent of [`crate::snap::SnapType`]. +#[allow(dead_code)] // Include codes preserved from unsupported imported modes. +pub(crate) mod osnap { + pub const NONE: u8 = 0; + pub const END: u8 = 1; + pub const MID: u8 = 2; + pub const CEN: u8 = 3; + pub const NODE: u8 = 4; + pub const QUAD: u8 = 5; + pub const INTERSEC: u8 = 6; + pub const INS: u8 = 7; + pub const PERP: u8 = 8; + pub const TAN: u8 = 9; + pub const NEAR: u8 = 10; + pub const APPARENT_INT: u8 = 11; + pub const PARA: u8 = 12; + pub const START_POINT: u8 = 13; +} + +/// Translate a live snap mode into the `AcDb::OsnapMode` we persist. +/// +/// Modes with no ObjectARX equivalent (grid, object pick, 3D solid snaps) map +/// to [`osnap::NONE`]; a reference carrying `NONE` resolves by +/// marker/parameter alone. +pub(crate) fn osnap_type_for(snap: crate::snap::SnapType) -> u8 { + use crate::snap::SnapType as S; + match snap { + S::Endpoint => osnap::END, + S::Midpoint => osnap::MID, + S::Center => osnap::CEN, + S::Node => osnap::NODE, + S::Quadrant => osnap::QUAD, + S::Intersection => osnap::INTERSEC, + S::Insertion => osnap::INS, + S::Perpendicular => osnap::PERP, + S::Tangent => osnap::TAN, + S::Nearest | S::Extension => osnap::NEAR, + S::ApparentIntersection => osnap::APPARENT_INT, + S::Parallel => osnap::PARA, + S::Grid | S::ObjectPick | S::Vertex | S::EdgeMidpoint | S::FaceCenter | S::Knot | S::FacePerpendicular | S::NearestFace => { + osnap::NONE + } + } +} + +/// A resolved `xrefs` chain. +#[derive(Clone, Debug)] +pub(crate) struct ReferenceChain { + /// The layout viewport the reference is seen through, when the chain + /// starts with one. `None` for a plain model-space reference. + pub viewport: Option, + /// INSERT handles from the outermost instance down to the instance that + /// directly contains [`Self::entity`]. Empty for top-level geometry. + pub block_path: Vec, + /// The innermost entity — the one that owns the feature. + pub entity: Handle, + /// Block-path transform: entity-local coordinates -> model coordinates. + /// Identity when `block_path` is empty. + pub transform: Transform, +} + +/// Why a chain could not be walked. The data is never discarded on a failure: +/// the caller keeps the reference exactly as read and leaves the dimension +/// showing its last valid appearance. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ChainError { + /// `xrefs` was empty — nothing to resolve. + Empty, + Invalid, + /// A handle in the chain names an object that is not in the document + /// (erased source; restored by an undo). + Missing(Handle), +} + +/// Walk `xrefs` into a [`ReferenceChain`]. +/// +/// The chain is read positionally rather than by a stored kind, because that +/// is all the file gives us: a leading VIEWPORT is the trans-space hop, any +/// INSERTs after it are the block-instance path, and the first handle that is +/// neither is the geometry. A chain that is nothing but INSERTs (dimensioning +/// a block reference's insertion point) ends on the innermost INSERT. +pub(crate) fn walk_chain( + document: &CadDocument, + xrefs: &[Handle], +) -> Result { + if xrefs.is_empty() { + return Err(ChainError::Empty); + } + let mut viewport = None; + let mut block_path = Vec::new(); + let mut transform = Transform::identity(); + let mut owner = None; + for (index, &handle) in xrefs.iter().enumerate() { + if handle.is_null() { + return Err(ChainError::Invalid); + } + let node = document + .get_entity(handle) + .ok_or(ChainError::Missing(handle))?; + if owner.is_some_and(|owner| node.common().owner_handle != owner) { + return Err(ChainError::Invalid); + } + if index == 0 && matches!(node, EntityType::Viewport(_)) { + viewport = Some(handle); + owner = document.block_records.get("*Model_Space").map(|b| b.handle); + continue; + } + if index == xrefs.len() - 1 { + return Ok(ReferenceChain { + viewport, + block_path, + entity: handle, + transform, + }); + } + let EntityType::Insert(insert) = node else { + return Err(ChainError::Invalid); + }; + // Arrays need an instance index that this reference format does not carry. + if insert.row_count > 1 + || insert.column_count > 1 + || block_path.len() >= 8 + || block_path.contains(&handle) + { + return Err(ChainError::Invalid); + } + let block = document + .block_records + .get(&insert.block_name) + .ok_or(ChainError::Invalid)?; + owner = Some(block.handle); + block_path.push(handle); + transform = transform.compose(&crate::scene::render_graph::insert_transform( + document, insert, + )); + } + Err(ChainError::Empty) +} + +/// Invert an affine block transform, rejecting singular or nonfinite matrices. +pub(crate) fn invert(transform: &Transform) -> Option { + let m = &transform.matrix.m; + let matrix = glam::DMat4::from_cols_array_2d(&std::array::from_fn(|column| { + std::array::from_fn(|row| m[row][column]) + })); + if !matrix.is_finite() || matrix.determinant().abs() < 1e-18 { + return None; + } + let inverse = matrix.inverse(); + if !inverse.is_finite() { + return None; + } + let columns = inverse.to_cols_array_2d(); + Some(Transform::from_matrix(Matrix4 { + m: std::array::from_fn(|row| std::array::from_fn(|column| columns[column][row])), + })) +} + +// ── Feature evaluation ──────────────────────────────────────────────────── + +fn vector3(point: [f64; 3]) -> Vector3 { + Vector3::new(point[0], point[1], point[2]) +} + +fn dpoint(point: Vector3) -> [f64; 3] { + [point.x, point.y, point.z] +} + +fn distance_squared(a: Vector3, b: Vector3) -> f64 { + let (dx, dy, dz) = (a.x - b.x, a.y - b.y, a.z - b.z); + dx * dx + dy * dy + dz * dz +} + +fn lift(plane: &Plane, uv: [f64; 2]) -> Vector3 { + vector3(plane.point_at(uv)) +} + +/// Everything a feature evaluation may need beyond the reference itself. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct FeatureContext { + /// The stored osnap point, already expressed in *entity-local* + /// coordinates. Used as the disambiguating hint for every feature that has + /// more than one candidate (quadrant, intersection, tangent, nearest). + pub hint: Option, + /// The point the feature is measured *from*, in entity-local coordinates. + /// PERPENDICULAR and TANGENT are two-point features: the foot / touch + /// point only exists relative to an external point, which for a dimension + /// is the other definition point. Tangency requires this point. + pub from: Option, +} + +/// Evaluate the osnap feature `reference` names on `entity`, in the entity's +/// own coordinates. +/// +/// Returns `None` — never a guess — when the osnap type is one we do not +/// evaluate, or when the geometry no longer supports it. The caller keeps the +/// reference data intact and leaves the dimension where it was. +pub(crate) fn feature_point( + document: &CadDocument, + entity: &EntityType, + reference: &AssocDimensionReference, + context: FeatureContext, +) -> Option { + match reference.osnap_type { + osnap::END | osnap::START_POINT if reference.main_subent_type == 2 => { + imported_endpoint(entity, reference, context.hint) + } + osnap::CEN => center_point(entity, reference), + osnap::MID => mid_point(entity, reference), + osnap::QUAD => quadrant_point(entity, reference, context.hint?), + osnap::NODE | osnap::INS => node_point(entity), + osnap::INTERSEC | osnap::APPARENT_INT => intersection_point( + document, + entity, + reference, + context.hint?, + reference.osnap_type == osnap::APPARENT_INT, + ), + osnap::PERP => perpendicular_point(entity, context.from.or(context.hint)?), + osnap::TAN => tangent_point(entity, reference, context), + osnap::NEAR => nearest_point(entity, reference, context.hint), + // END / START_POINT / PARA / NONE all address a named vertex or a + // stored curve parameter, which is what the legacy marker convention + // already encodes. The caller keeps that path. + _ => None, + } +} + +/// Whether [`feature_point`] knows how to evaluate this osnap type. A type we +/// do not handle keeps its stored data and stays unresolved. +pub(crate) fn evaluates(osnap_type: u8) -> bool { + matches!( + osnap_type, + osnap::END + | osnap::START_POINT + | osnap::CEN + | osnap::MID + | osnap::QUAD + | osnap::NODE + | osnap::INS + | osnap::INTERSEC + | osnap::APPARENT_INT + | osnap::PERP + | osnap::TAN + | osnap::NEAR + ) +} + +/// Whether this osnap type needs the *other* definition point to evaluate. +pub(crate) fn needs_from_point(osnap_type: u8) -> bool { + matches!(osnap_type, osnap::PERP | osnap::TAN) +} + +fn center_point(entity: &EntityType, reference: &AssocDimensionReference) -> Option { + match entity { + EntityType::Circle(circle) => Some(circle.center_wcs()), + EntityType::Arc(arc) => Some(arc.center_wcs()), + EntityType::Ellipse(ellipse) => Some(ellipse.center), + _ => { + // A polyline's bulge segment: `main_gs_marker` names the segment. + let planar = entity_curve(entity)?; + let KernelCurve::Polyline(polyline) = &planar.curve else { + return None; + }; + let segment = if reference.main_subent_type == 1 + && reference.main_gs_marker == super::dimension_assoc::POLYLINE_ARC_CENTER_MARKER + { + reference.osnap_distance.round().max(0.0) as usize + } else { + segment_index(reference)? + }; + let arc = polyline.segment_arc(segment)?; + Some(lift(&planar.plane, arc.center)) + } + } +} + +fn mid_point(entity: &EntityType, reference: &AssocDimensionReference) -> Option { + let planar = entity_curve(entity)?; + match &planar.curve { + // For a polyline the marker names which segment's midpoint it is; + // without one, the midpoint of the whole run. + KernelCurve::Polyline(_) if reference.main_gs_marker >= 0 => { + let segments = planar.curve.segments(); + let segment = segments.get(segment_index(reference)?)?; + Some(lift(&planar.plane, segment.point_at(0.5))) + } + _ => Some(lift(&planar.plane, planar.curve.point_at(0.5))), + } +} + +fn quadrant_point( + entity: &EntityType, + reference: &AssocDimensionReference, + hint: Vector3, +) -> Option { + let planar = entity_curve(entity)?; + let (centre, radius) = match &planar.curve { + KernelCurve::Circle(circle) => (circle.centre, circle.radius), + KernelCurve::Arc(arc) => (arc.centre, arc.radius), + _ => return None, + }; + let valid_angle = |angle: f64| match &planar.curve { + KernelCurve::Arc(arc) => { + cadkernel::geom2d::angle_within_arc(angle, arc.start_angle, arc.end_angle) + } + _ => true, + }; + let at_angle = |angle: f64| { + lift( + &planar.plane, + KernelCurve::Circle(cadkernel::geom2d::Circle { centre, radius }) + .point_at(angle.rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU), + ) + }; + // New references retain the chosen source-local angle, so translation + // cannot silently select another quadrant using the old world point. + if reference.main_subent_type == 1 && reference.main_gs_marker == -2 { + let angle = reference.osnap_distance; + return (angle.is_finite() && valid_angle(angle)).then(|| at_angle(angle)); + } + // An imported point can identify an unchanged quadrant, but cannot prove + // which quadrant was intended after it moves away from the stored hint. + (0..4) + .map(|index| std::f64::consts::FRAC_PI_2 * index as f64) + .filter(|angle| valid_angle(*angle)) + .map(at_angle) + .find(|candidate| distance_squared(*candidate, hint) < 1e-12) +} + +fn node_point(entity: &EntityType) -> Option { + match entity { + EntityType::Point(point) => Some(point.location), + EntityType::Insert(insert) => Some(insert.insert_point), + EntityType::Text(text) => Some(text.insertion_point), + EntityType::MText(text) => Some(text.insertion_point), + EntityType::Shape(shape) => Some(shape.insertion_point), + _ => None, + } +} + +fn perpendicular_point(entity: &EntityType, from: Vector3) -> Option { + let planar = entity_curve(entity)?; + let uv = planar.plane.project(dpoint(from))?; + cadkernel::geom2d::snap::perpendicular_from(&planar.curve, uv) + .into_iter() + .map(|candidate| lift(&planar.plane, candidate.point)) + .min_by(|a, b| distance_squared(*a, from).total_cmp(&distance_squared(*b, from))) +} + +/// Perpendicularity must be evaluated after a block's nonuniform scale. +pub(crate) fn perpendicular_in_model( + entity: &EntityType, + transform: &Transform, + from: Vector3, +) -> Option { + use glam::DVec3; + let source = entity_curve(entity)?; + let origin = transform.apply(vector3(source.plane.origin)); + let x = DVec3::from_array(dpoint( + transform.apply_rotation(vector3(source.plane.x_axis)), + )); + let y = DVec3::from_array(dpoint( + transform.apply_rotation(vector3(source.plane.y_axis)), + )); + let axis_x = x.try_normalize()?; + let normal = x.cross(y).try_normalize()?; + let axis_y = normal.cross(axis_x); + let plane = Plane::from_axes(dpoint(origin), axis_x.to_array(), axis_y.to_array()); + let curve = source.curve.transformed(&cadkernel::geom2d::Transform { + origin: [0.0, 0.0].into(), + x_axis: [x.dot(axis_x), x.dot(axis_y)].into(), + y_axis: [y.dot(axis_x), y.dot(axis_y)].into(), + })?; + let uv = plane.project(dpoint(from))?; + cadkernel::geom2d::snap::perpendicular_from(&curve, uv) + .into_iter() + .map(|candidate| lift(&plane, candidate.point)) + .min_by(|a, b| distance_squared(*a, from).total_cmp(&distance_squared(*b, from))) +} + +fn nearest_point( + entity: &EntityType, + reference: &AssocDimensionReference, + hint: Option, +) -> Option { + let planar = entity_curve(entity)?; + // Circles and arcs store an angle; other curves use a normalized parameter. + match &planar.curve { + KernelCurve::Circle(_) | KernelCurve::Arc(_) => 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, + }, + _ => { + let parameter = reference.osnap_distance; + if parameter.is_finite() && (0.0..=1.0).contains(¶meter) { + return Some(lift(&planar.plane, planar.curve.point_at(parameter))); + } + // No usable parameter: fall back to the point on the curve nearest + // the stored osnap point, which is what NEAR means anyway. + let uv = planar.plane.project(dpoint(hint?))?; + Some(lift(&planar.plane, closest_point(&planar.curve, uv).point)) + } + } +} + +// ── Tangent ─────────────────────────────────────────────────────────────── + +/// Tangent point for the line through `context.from`. +fn tangent_point( + entity: &EntityType, + reference: &AssocDimensionReference, + context: FeatureContext, +) -> Option { + if let EntityType::Spline(spline) = entity { + return spline_tangent_point(spline, reference, context); + } + let planar = entity_curve(entity)?; + let from = context.from?; + let uv = planar.plane.project(dpoint(from))?; + cadkernel::geom2d::snap::tangent_from(&planar.curve, uv) + .into_iter() + .map(|candidate| lift(&planar.plane, candidate.point)) + .min_by(|a, b| { + distance_squared(*a, context.hint.unwrap_or(from)) + .total_cmp(&distance_squared(*b, context.hint.unwrap_or(from))) + }) +} + +fn spline_tangent_point( + spline: &acadrust::entities::Spline, + reference: &AssocDimensionReference, + context: FeatureContext, +) -> Option { + let curve = crate::entities::spline::nurbs3(spline)?; + let (start, end) = curve.domain(); + let stored = reference.osnap_distance; + let u = if stored.is_finite() && stored > start - 1e-9 && stored < end + 1e-9 { + stored.clamp(start, end) + } else { + let hint = context.hint.or(context.from)?; + curve.parameter_at(dpoint(hint)).clamp(start, end) + }; + curve + .tangent_from_near(dpoint(context.from?), u) + .map(vector3) +} + +// ── Intersection ────────────────────────────────────────────────────────── + +/// Intersection of `entity` with the reference's second object chain. +/// +/// Transform both curves into the first entity's local plane and use the +/// kernel's exact/converged intersection solver. Apparent intersections may +/// project noncoplanar curves; only straight lines support an extended crossing. +fn intersection_point( + document: &CadDocument, + entity: &EntityType, + reference: &AssocDimensionReference, + hint: Vector3, + apparent: bool, +) -> Option { + let main = walk_chain(document, &reference.xrefs).ok()?; + let other = walk_chain(document, &reference.intersection_objects).ok()?; + if main.viewport != other.viewport { + return None; + } + let inverse = invert(&main.transform)?; + let first = entity_curve(entity)?; + let second = entity_curve(document.get_entity(other.entity)?)?; + let map = |uv| { + let local = inverse.apply(other.transform.apply(lift(&second.plane, uv))); + let projected = first.plane.project(dpoint(local))?; + if !apparent && distance_squared(local, lift(&first.plane, projected)) > 1e-12 { + return None; + } + Some(projected) + }; + let origin = glam::DVec2::from_array(map([0.0, 0.0])?); + let x = glam::DVec2::from_array(map([1.0, 0.0])?) - origin; + let y = glam::DVec2::from_array(map([0.0, 1.0])?) - origin; + let second = second.curve.transformed(&cadkernel::geom2d::Transform { + origin: origin.to_array().into(), + x_axis: x.to_array().into(), + y_axis: y.to_array().into(), + })?; + let hint_uv = first.plane.project(dpoint(hint))?; + let mut points: Vec<_> = cadkernel::geom2d::intersect( + &first.curve, + &second, + cadkernel::geom2d::Tolerance::new(1e-9), + ) + .into_iter() + .map(|hit| hit.point) + .collect(); + if apparent && points.is_empty() { + // Extended apparent intersections are meaningful for straight lines. + // Curved geometry must actually cross after projection. + if let (KernelCurve::Line(a), KernelCurve::Line(b)) = (&first.curve, &second) { + let extend = |line: &cadkernel::geom2d::Line| { + KernelCurve::XLine(cadkernel::geom2d::XLine { + base: line.start, + direction: [line.end[0] - line.start[0], line.end[1] - line.start[1]], + }) + }; + points.extend( + cadkernel::geom2d::intersect( + &extend(a), + &extend(b), + cadkernel::geom2d::Tolerance::new(1e-9), + ) + .into_iter() + .map(|hit| hit.point), + ); + } + } + points + .into_iter() + .min_by(|a, b| { + let error = |p: &[f64; 2]| (p[0] - hint_uv[0]).powi(2) + (p[1] - hint_uv[1]).powi(2); + error(a).total_cmp(&error(b)) + }) + .map(|point| lift(&first.plane, point)) +} + +/// Internal markers are zero-based; imported edge markers are one-based. +fn segment_index(reference: &AssocDimensionReference) -> Option { + let marker = if reference.main_subent_type == 2 { + reference.main_gs_marker.checked_sub(1)? + } else { + reference.main_gs_marker + }; + usize::try_from(marker).ok() +} + +fn imported_endpoint( + entity: &EntityType, + reference: &AssocDimensionReference, + hint: Option, +) -> Option { + let planar = entity_curve(entity)?; + let t = match reference.osnap_distance { + v if v.abs() < 1e-9 => 0.0, + v if (v - 1.0).abs() < 1e-9 => 1.0, + _ => return None, + }; + let point = match &planar.curve { + KernelCurve::Polyline(_) => { + let segment = segment_index(reference)?; + let point = lift( + &planar.plane, + planar.curve.segments().get(segment)?.point_at(t), + ); + // Exporters can leave stale GS markers after editing a polyline. + // Beyond the unambiguous first-edge convention, require a real + // stored snap point to validate this imported endpoint. Zero and + // 2e50 sentinel points do not establish feature identity. + if segment > 0 && !hint.is_some_and(|hint| distance_squared(point, hint) < 1e-10) { + return None; + } + point + } + KernelCurve::Line(_) | KernelCurve::Arc(_) | KernelCurve::Nurbs(_) => { + lift(&planar.plane, planar.curve.point_at(t)) + } + _ => return None, + }; + Some(point) +} diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index 592f709f1..e36ca0c6d 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -43,6 +43,7 @@ struct EntityNodes { geometry_node_id: i32, points: FxHashMap, segments: FxHashMap, + axes: FxHashMap, } /// Builds one scope's `Assoc2dConstraintGroup` graph incrementally, @@ -55,8 +56,6 @@ struct GroupBuilder<'a> { /// Real entity handles that ended up with a geometry node — what /// `AssocGeomDependency` objects get created for, in first-touch order. referenced_entities: Vec, - horizontal_datum: Option, - vertical_datum: Option, } impl<'a> GroupBuilder<'a> { @@ -67,8 +66,6 @@ impl<'a> GroupBuilder<'a> { next_node_id: FIRST_NODE_ID, entities: FxHashMap::default(), referenced_entities: Vec::new(), - horizontal_datum: None, - vertical_datum: None, } } @@ -396,6 +393,52 @@ impl<'a> GroupBuilder<'a> { Some(node_id) } + /// Returns a derived line node for a text baseline or ellipse axis. These + /// are real native curve nodes tied to the source entity dependency, so + /// the selected direction survives save/reopen instead of collapsing to + /// the entity insertion/center point. + fn directional_axis_node(&mut self, reference: ParametricRef) -> Option { + let marker = reference.marker?; + reference.directional_axis()?; + if let Some(node_id) = self + .entities + .get(&reference.entity) + .and_then(|entity| entity.axes.get(&marker)) + { + return Some(*node_id); + } + let entity = self.document.get_entity(reference.entity)?; + let [start, end] = + super::parametric_constraints::directional_axis_endpoints(entity, reference)?; + let delta = end - start; + if delta.length_squared() <= 1.0e-24 { + return None; + } + let node_id = self.alloc_node_id(); + self.push_node( + node_id, + "AcConstrainedBoundedLine", + AssocConstraintNodeData::BoundedLine { + geometry_dependency: Handle::NULL, + geometry_node_id: node_id, + point: start, + direction: delta.normalize(), + is_ray: false, + start_point: start, + end_point: end, + }, + ); + self.entities + .entry(reference.entity) + .or_default() + .axes + .insert(marker, node_id); + if !self.referenced_entities.contains(&reference.entity) { + self.referenced_entities.push(reference.entity); + } + Some(node_id) + } + /// Returns the point-node id for a line endpoint or curve center, /// creating it the first time the marker is referenced. fn point_node(&mut self, handle: Handle, marker: i32) -> Option { @@ -407,11 +450,17 @@ impl<'a> GroupBuilder<'a> { return Some(*existing); } let segment_midpoint = ParametricRef::point(handle, marker).segment_midpoint_index(); + let segment_center = ParametricRef::point(handle, marker).segment_center_index(); let polyline = matches!( self.document.get_entity(handle), Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)) ); - let (curve_id, point_type) = if let Some(segment) = segment_midpoint { + let (curve_id, point_type) = if let Some(segment) = segment_center { + ( + self.segment_node(handle, segment)?, + implicit_point_type::CENTER, + ) + } else if let Some(segment) = segment_midpoint { ( self.segment_node(handle, segment)?, implicit_point_type::MID, @@ -498,9 +547,15 @@ impl<'a> GroupBuilder<'a> { /// The node id `ParametricRef` resolves to: a point node for a marked /// reference, the whole geometry node otherwise. fn ref_node(&mut self, r: ParametricRef) -> Option { + if r.directional_axis().is_some() { + return self.directional_axis_node(r); + } if r.segment_midpoint_index().is_some() { return self.point_node(r.entity, r.marker?); } + if r.segment_center_index().is_some() { + return self.point_node(r.entity, r.marker?); + } if let Some(segment) = r.segment_index() { return self.segment_node(r.entity, segment); } @@ -603,15 +658,7 @@ impl<'a> GroupBuilder<'a> { Some(node_id) } - fn datum_line(&mut self, horizontal: bool) -> i32 { - let cached = if horizontal { - self.horizontal_datum - } else { - self.vertical_datum - }; - if let Some(node_id) = cached { - return node_id; - } + fn datum_line(&mut self, direction: Vector3) -> i32 { let node_id = self.alloc_node_id(); self.push_node( node_id, @@ -620,18 +667,13 @@ impl<'a> GroupBuilder<'a> { geometry_dependency: Handle::NULL, geometry_node_id: node_id, point: Vector3::ZERO, - direction: if horizontal { - Vector3::UNIT_X + direction: if direction.length_squared() > 1.0e-24 { + direction.normalize() } else { - Vector3::UNIT_Y + Vector3::UNIT_X }, }, ); - if horizontal { - self.horizontal_datum = Some(node_id); - } else { - self.vertical_datum = Some(node_id); - } node_id } @@ -721,7 +763,13 @@ fn constraint_node( ) -> Option { let refs: &[ParametricRef] = &constraint.refs; if constraint.kind == ConstraintKind::Smooth { - let [first, second] = refs else { return None }; + let (first, second, second_curve_ref) = match refs { + [first, second] => (*first, *second, ParametricRef::whole(second.entity)), + [first, second, second_curve] if second.entity == second_curve.entity => { + (*first, *second, *second_curve) + } + _ => return None, + }; let endpoint_parameter = |reference: ParametricRef| { let entity = document.get_entity(reference.entity)?; let EntityType::Spline(spline) = entity else { @@ -741,15 +789,15 @@ fn constraint_node( }?; Some(Some(value)) }; - let first_parameter = endpoint_parameter(*first)?; - let second_parameter = endpoint_parameter(*second)?; + let first_parameter = endpoint_parameter(first)?; + let second_parameter = endpoint_parameter(second)?; if first_parameter.is_none() && second_parameter.is_none() { return None; } let first_curve = builder.geometry_node(first.entity)?; - let second_curve = builder.geometry_node(second.entity)?; - let first_point = builder.ref_node(*first)?; - let second_point = builder.ref_node(*second)?; + let second_curve = builder.ref_node(second_curve_ref)?; + let first_point = builder.ref_node(first)?; + let second_point = builder.ref_node(second)?; let coincidence = builder.alloc_node_id(); builder.push_node( @@ -867,7 +915,12 @@ fn constraint_node( .map(|reference| builder.ref_node(*reference)) .collect::>()?; let horizontal = constraint.kind == ConstraintKind::Horizontal; - let datum = builder.datum_line(horizontal); + let fallback = if horizontal { + Vector3::UNIT_X + } else { + Vector3::UNIT_Y + }; + let datum = builder.datum_line(constraint.axis_direction.unwrap_or(fallback)); let node_id = builder.alloc_node_id(); builder.push_node( node_id, @@ -1553,6 +1606,10 @@ fn dependency_entity( ), AssocConstraintNodeData::Point { .. }, ) => true, + ( + Some(EntityType::Text(_) | EntityType::MText(_) | EntityType::Ellipse(_)), + AssocConstraintNodeData::BoundedLine { .. }, + ) => true, (Some(EntityType::Line(_)), AssocConstraintNodeData::BoundedLine { is_ray, .. }) => !is_ray, ( Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_)), @@ -1593,6 +1650,21 @@ fn group_requires_preservation(document: &CadDocument, group: &Assoc2dConstraint }) } +fn work_plane_vector(work_plane: &[Vector3; 3], local: Vector3) -> Option { + let [origin, axis_x, axis_y] = *work_plane; + let plane = cadkernel::space::Plane::from_axes( + [origin.x, origin.y, origin.z], + [axis_x.x, axis_x.y, axis_x.z], + [axis_y.x, axis_y.y, axis_y.z], + ); + let mut world = cadkernel::space::Vec3::from(plane.vector_at([local.x, local.y])); + if local.z != 0.0 { + world = world + cadkernel::space::Vec3::from(plane.normal()?) * local.z; + } + let world = world.normalize()?; + Some(Vector3::new(world.x, world.y, world.z)) +} + fn polyline_segment_reference( document: &CadDocument, entity: Handle, @@ -1643,6 +1715,52 @@ fn polyline_segment_reference( .map(|index| ParametricRef::segment(entity, index)) } +fn directional_axis_reference( + document: &CadDocument, + entity: Handle, + data: &AssocConstraintNodeData, + work_plane: &[Vector3; 3], +) -> Option { + let AssocConstraintNodeData::BoundedLine { + start_point, + end_point, + .. + } = data + else { + return None; + }; + match document.get_entity(entity)? { + EntityType::Text(_) | EntityType::MText(_) => { + Some(ParametricRef::text_baseline(entity)) + } + EntityType::Ellipse(ellipse) => { + let [origin, axis_x, axis_y] = *work_plane; + let normal = Vector3::new( + axis_x.y * axis_y.z - axis_x.z * axis_y.y, + axis_x.z * axis_y.x - axis_x.x * axis_y.z, + axis_x.x * axis_y.y - axis_x.y * axis_y.x, + ); + let to_world = |point: Vector3| { + origin + axis_x * point.x + axis_y * point.y + normal * point.z + }; + let direction = (to_world(*end_point) - to_world(*start_point)).normalize(); + let major = ellipse.major_axis.normalize(); + let ellipse_normal = ellipse.normal.normalize(); + let minor = Vector3::new( + ellipse_normal.y * major.z - ellipse_normal.z * major.y, + ellipse_normal.z * major.x - ellipse_normal.x * major.z, + ellipse_normal.x * major.y - ellipse_normal.y * major.x, + ); + if direction.dot(&minor).abs() > direction.dot(&major).abs() { + Some(ParametricRef::ellipse_minor_axis(entity)) + } else { + Some(ParametricRef::ellipse_major_axis(entity)) + } + } + _ => None, + } +} + fn numeric_value(value: &AssocEvalVariant) -> Option { match value.value { AssocEvalValue::Real(value) => Some(value), @@ -1927,6 +2045,14 @@ pub(super) fn native_constraint_set( if let Some(entity) = dependency_entity(document, dependency, &node.data) { let reference = polyline_segment_reference(document, entity, &node.data, &group.work_plane) + .or_else(|| { + directional_axis_reference( + document, + entity, + &node.data, + &group.work_plane, + ) + }) .unwrap_or_else(|| { if matches!(node.data, AssocConstraintNodeData::Point { .. }) { ParametricRef::point(entity, 0) @@ -1965,6 +2091,9 @@ pub(super) fn native_constraint_set( implicit_point_type::MID => { ParametricRef::segment_midpoint(curve.entity, segment) } + implicit_point_type::CENTER => { + ParametricRef::segment_center(curve.entity, segment) + } _ => continue, } } else { @@ -2022,7 +2151,7 @@ pub(super) fn native_constraint_set( owned_constraint_ids, .. } if kind == ConstraintKind::Smooth => { - let endpoints = owned_constraint_ids + let mut endpoints: Vec = owned_constraint_ids .iter() .filter_map(|id| group.nodes.iter().find(|child| child.node_id == *id)) .find(|child| { @@ -2038,6 +2167,23 @@ pub(super) fn native_constraint_set( .collect() }) .unwrap_or_default(); + if let Some(target) = endpoints.get(1).copied() { + let segment = owned_constraint_ids + .iter() + .filter_map(|id| group.nodes.iter().find(|child| child.node_id == *id)) + .filter(|child| { + child.class_name.eq_ignore_ascii_case("AcTangentConstraint") + }) + .flat_map(|child| child.connections.iter()) + .filter_map(|id| refs.get(id).copied()) + .find(|reference| { + reference.entity == target.entity + && reference.segment_index().is_some() + }); + if let Some(segment) = segment { + endpoints.push(segment); + } + } (endpoints, Vec::new()) } _ => ( @@ -2084,6 +2230,7 @@ pub(super) fn native_constraint_set( ConstraintKind::DistanceDirected => matches!(targets.len(), 2 | 3), ConstraintKind::EqualDistance => targets.len() == 4, ConstraintKind::RigidSet => !targets.is_empty() && rigid_points.len() >= 2, + ConstraintKind::Smooth => matches!(targets.len(), 2 | 3), _ => targets.len() == 2, }; if !valid_target_count { @@ -2107,6 +2254,31 @@ pub(super) fn native_constraint_set( if let Some(constraint) = set.constraints.last_mut() { constraint.enabled = enabled; constraint.rigid_points = rigid_points; + if matches!(kind, ConstraintKind::Horizontal | ConstraintKind::Vertical) { + let datum_id = match &node.data { + AssocConstraintNodeData::Parallel { + datum_line_index, .. + } => *datum_line_index, + _ => None, + }; + let local_direction = datum_id.and_then(|datum_id| { + group.nodes.iter().find_map(|datum| { + (datum.node_id == datum_id).then_some(&datum.data).and_then(|data| { + match data { + AssocConstraintNodeData::Line { direction, .. } => { + Some(*direction) + } + _ => None, + } + }) + }) + }); + if let Some(world) = + local_direction.and_then(|local| work_plane_vector(&group.work_plane, local)) + { + constraint.axis_direction = Some(world); + } + } if let AssocConstraintNodeData::Distance { direction_type, distance, @@ -2337,7 +2509,7 @@ fn materialize_scope( continue; }; if entity_nodes.geometry_node_id == 0 { - if entity_nodes.segments.is_empty() { + if entity_nodes.segments.is_empty() && entity_nodes.axes.is_empty() { continue; } } @@ -2346,6 +2518,7 @@ fn materialize_scope( for node_id in std::iter::once(entity_nodes.geometry_node_id) .filter(|node_id| *node_id != 0) .chain(entity_nodes.segments.values().copied()) + .chain(entity_nodes.axes.values().copied()) { if let Some(node) = nodes.iter_mut().find(|node| node.node_id == node_id) { set_geometry_dependency(&mut node.data, dep_handle); @@ -2736,14 +2909,15 @@ mod tests { fn horizontal_and_vertical_constraints_round_trip_through_dwg_and_dxf() { for ext in ["dwg", "dxf"] { let mut scene = Scene::new(); - let a = line_entity(&mut scene, (0.0, 0.0), (10.0, 0.0)); + let a = line_entity(&mut scene, (0.0, 0.0), (6.0, 8.0)); let b = line_entity(&mut scene, (10.0, 0.0), (10.0, 5.0)); + let horizontal_direction = Vector3::new(0.6, 0.8, 0.0); scene .parametric_constraint_set_mut(ParametricScope::ModelSpace) - .add( + .add_axis_constraint( ConstraintKind::Horizontal, vec![ParametricRef::whole(a)], - None, + horizontal_direction, ); scene .parametric_constraint_set_mut(ParametricScope::ModelSpace) @@ -2809,6 +2983,20 @@ mod tests { assert!(datums.iter().all(|datum| group.nodes.iter().any(|node| { node.node_id == *datum && node.class_name == "AcConstrainedDatumLine" }))); + + let mut restored = Scene::new(); + restored.document = reloaded; + restored.load_parametric_constraints_from_document(); + let horizontal = restored + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints + .iter() + .find(|constraint| constraint.kind == ConstraintKind::Horizontal) + .unwrap(); + assert!( + (horizontal.axis_direction.unwrap() - horizontal_direction).length() < 1.0e-12 + ); } } diff --git a/src/scene/entity.rs b/src/scene/entity.rs index b5a66886c..8b3d2f930 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -134,7 +134,7 @@ impl Scene { let _ = self.document.layers.add(layer); } - fn ensure_app_id(&mut self, name: &str) { + pub(super) fn ensure_app_id(&mut self, name: &str) { if name.trim().is_empty() || self.document.app_ids.contains(name) { return; } @@ -2156,19 +2156,19 @@ impl Scene { fn populate_images_from_document_unbumped(&mut self) { self.images.clear(); - let entries: Vec<(Handle, acadrust::entities::RasterImage)> = self + let entries: Vec<(Handle, acadrust::entities::EntityType)> = self .document .entities() - .filter_map(|e| { - if let EntityType::RasterImage(img) = e { - Some((img.common.handle, img.clone())) - } else { - None - } + .filter(|e| { + matches!( + e, + EntityType::RasterImage(_) | EntityType::Ole2Frame(_) | EntityType::Underlay(_) + ) }) + .map(|e| (e.common().handle, e.clone())) .collect(); - for (handle, img) in entries { - if let Some(model) = ImageModel::from_raster_image(&img) { + for (handle, entity) in entries { + if let Some(model) = self.image_seed_for(&entity) { self.images.insert(handle, model); } } @@ -2712,4 +2712,25 @@ impl Scene { self.named_parameters = crate::scene::named_parameters::ParameterTable::new(); self.bump_geometry(); } + + /// Reset to the drawing File → New produces. Every path that starts a + /// fresh drawing (the New tab, CLEAR, the automation `new` op) goes + /// through here so they all agree on what a blank drawing contains. + pub fn reset_to_new_drawing(&mut self) { + self.clear(); + self.populate_new_drawing_defaults(); + } + + /// What a blank document gets on top of the codec's defaults: the + /// standard linetypes and a compatible page setup on every paper layout. + pub fn populate_new_drawing_defaults(&mut self) { + crate::io::linetypes::populate_document(&mut self.document); + for obj in self.document.objects.values_mut() { + if let acadrust::objects::ObjectType::Layout(l) = obj { + if l.name != "Model" { + crate::scene::apply_default_page_setup(l, ""); + } + } + } + } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 59eb787f3..e2b373127 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -31,6 +31,9 @@ mod camera_ops; pub(crate) mod centerline; pub(crate) mod centermark; pub(crate) mod dimension_assoc; +pub(crate) mod dimension_assoc_chain; +pub use dimension_assoc::{ReferenceStatus, ResolvedReference}; +pub use page_setup::{apply_default_page_setup, document_page_setups, rotated_margins}; mod dwg_native_constraints; mod entity; #[cfg(test)] @@ -40,6 +43,8 @@ mod layout; mod limits; mod modify; mod mspace; +pub mod viewport_ref; +pub mod viewport_dimension_pick; pub mod named_parameters; mod page_setup; mod paper; @@ -47,7 +52,7 @@ mod preview; mod project; mod scene_markers; mod selection; -pub(crate) use selection::pe_url_of; +pub(crate) use selection::{pe_url_description_of, pe_url_of}; pub mod parametric_constraints; mod parametric_solve; @@ -159,6 +164,333 @@ pub(crate) fn tessellate_entity( wires } +impl Scene { + /// True for the modeler solid families whose wire path is an empty shell + /// (their geometry lives in the mesh pipeline): B-rep corners for those + /// come from the mesh edge list instead of wire points. + fn is_solid_shell_entity(entity: &EntityType) -> bool { + matches!( + entity, + EntityType::Solid3D(_) + | EntityType::Region(_) + | EntityType::Body(_) + | EntityType::Surface(_) + ) + } + + /// B-rep snap points for a solid: edge endpoints as `Vertex` hints and + /// edge centres as `EdgeMidpoint` hints (both from the tessellated edge + /// list), plus face centres as `FaceCenter` hints (from the kernel B-rep + /// faces). Empty for non-solids and for solids with no usable data. + pub(crate) fn solid_snap_points( + &self, + handle: Handle, + ) -> Vec<(glam::DVec3, crate::scene::model::wire_model::SnapHint)> { + use crate::scene::model::wire_model::SnapHint; + let Some(entity) = self.document.get_entity(handle) else { + return Vec::new(); + }; + if !Self::is_solid_shell_entity(entity) { + return Vec::new(); + } + let mut out = Vec::new(); + if let Some(set) = self + .meshes + .get(&handle) + .or_else(|| self.block_meshes.get(&handle)) + { + let (edges, lows) = set.geometry_edges(); + let mut seen = HashSet::default(); + for (a, b) in + crate::scene::model::mesh_model::solid_edge_segments(edges, lows) + { + for (point, hint) in [(a, SnapHint::Vertex), (b, SnapHint::Vertex)] { + let key = [point.x.to_bits(), point.y.to_bits(), point.z.to_bits()]; + if seen.insert(key) { + out.push((point, hint)); + } + } + out.push(((a + b) * 0.5, SnapHint::EdgeMidpoint)); + } + } + out.extend( + Self::solid_face_centers(entity, self.solid_models.get(&handle)) + .into_iter() + .map(|p| (p, SnapHint::FaceCenter)), + ); + out + } + + /// Face centres for a solid entity: kernel B-rep faces averaged over + /// their boundary loops. Prefers the cached kernel body (no re-parse); + /// falls back to parsing the entity's ACIS data, which shares the cost + /// profile of the mesh build at the same epoch. No body ⇒ no centres. + fn solid_face_centers( + entity: &EntityType, + cached: Option<&cadkernel::brep::Body>, + ) -> Vec { + use crate::scene::convert::solid3d_tess::{kernel_body, kernel_region_body}; + use crate::scene::model::solid_model::face_centers; + if let Some(body) = cached { + return face_centers(body) + .into_iter() + .map(glam::DVec3::from_array) + .collect(); + } + match entity { + EntityType::Solid3D(s) => kernel_body(s), + EntityType::Region(r) => kernel_region_body(r), + _ => None, + } + .map(|body| face_centers(&body)) + .unwrap_or_default() + .into_iter() + .map(glam::DVec3::from_array) + .collect() + } + + /// Attach solid B-rep snaps to the entity's shell wire (the empty wire + /// carrying only the insertion snap). History/construction wires keep + /// their own points and are left alone; non-solids are untouched. + /// Idempotent: a wire already carrying 3D snaps is skipped, so shared + /// memo entries can pass through every assembly path safely. + pub(crate) fn attach_solid_snaps(&self, handle: Handle, wires: &mut Vec) { + use crate::scene::model::wire_model::SnapHint; + let points = self.solid_snap_points(handle); + if points.is_empty() { + return; + } + for wire in wires.iter_mut() { + if !wire.points.is_empty() { + continue; + } + if wire.snap_pts.iter().any(|(_, hint)| { + matches!( + hint, + SnapHint::Vertex + | SnapHint::EdgeMidpoint + | SnapHint::FaceCenter + | SnapHint::Knot + ) + }) { + continue; + } + wire.snap_pts.extend(points.iter().copied()); + } + } + + /// Live solid-face snaps for a command point: Nearest-to-face (screen + /// nearest point on a mesh triangle) and Perpendicular-to-face (foot from + /// `base` onto a triangle). Mesh triangles carry no B-rep face grouping, + /// so faces are individual triangles here — the pre-baked `FaceCenter` + /// hints (true B-rep faces) are the separate discrete counterpart. + /// + /// Top-level model meshes only: block-local sets live in a different + /// space, and xclipped views are not honored (the wire channel the + /// pre-baked 3D hints ride is clipped; this direct mesh read is not). + /// Worst case in those views is a snap with no geometry behind it — + /// never a corrupt point. + pub(crate) fn solid_face_snaps( + &self, + cursor_screen: iced::Point, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + aperture_px: f32, + base: Option, + want_perp: bool, + want_nearest: bool, + ) -> Option { + use crate::snap::{ + closest_point_on_tri_2d, foot_on_triangle, snap_better, snap_priority, + snap_tier, SnapType, + }; + if !want_perp && !want_nearest { + return None; + } + if !(aperture_px.is_finite() && aperture_px > 0.0) { + return None; + } + let perp_base = want_perp.then(|| base).flatten(); + let project = |world: glam::DVec3| { + let ndc = view_rot.project_point3((world - eye).as_vec3()); + [ + (ndc.x + 1.0) * 0.5 * bounds.width, + (1.0 - ndc.y) * 0.5 * bounds.height, + ] + }; + let finite_screen = |s: [f32; 2]| s[0].is_finite() && s[1].is_finite(); + let in_bounds = |s: [f32; 2]| { + s[0] >= 0.0 && s[0] <= bounds.width && s[1] >= 0.0 && s[1] <= bounds.height + }; + let radius2 = aperture_px * aperture_px; + let cursor = [cursor_screen.x, cursor_screen.y]; + // (tier, d2, sub, eye-depth², handle, hit): engine ordering, then + // nearer-to-eye (coincident faces share pixels — the top face must + // beat the bottom one), then the handle as a final deterministic + // tie-break (mesh iteration order is not stable). + let mut best: Option<(u8, f32, u8, f64, u64, crate::snap::SnapResult)> = None; + let mut consider = |snap_type: SnapType, + world: glam::DVec3, + screen: [f32; 2], + d2: f32, + handle: Handle| { + if !(d2 < radius2) || !in_bounds(screen) { + return; + } + let (tier, sub) = (snap_tier(snap_type), snap_priority(snap_type)); + let depth2 = (world - eye).length_squared(); + let better = match &best { + None => true, + Some((bt, bd2, bs, bdepth2, bh, _)) => { + snap_better(tier, d2, sub, (*bt, *bd2, *bs)) + || (tier == *bt + && (d2 - *bd2).abs() <= 1e-4 + && sub == *bs + && (depth2 < *bdepth2 + || (depth2 == *bdepth2 && handle.value() < *bh))) + } + }; + if better { + best = Some(( + tier, + d2, + sub, + depth2, + handle.value(), + crate::snap::SnapResult { + world, + screen: iced::Point::new(screen[0], screen[1]), + snap_type, + tangent_obj: None, + extension_base: None, + extension_base2: None, + extension_origin: None, + extension_dir: None, + viewport: None, + source: Some( + crate::command::DimensionAssociationSource::inferred(handle), + ), + secondary_source: None, + model_point: None, + }, + )); + } + }; + for (handle, set) in self.meshes.iter() { + let Some(lod) = set.geometry_lods().first() else { + continue; + }; + if lod.indices.len() < 3 { + continue; + } + // Screen-space cull from the mesh bounds before touching triangles. + let (mut x0, mut y0) = (f32::INFINITY, f32::INFINITY); + let (mut x1, mut y1) = (f32::NEG_INFINITY, f32::NEG_INFINITY); + for corner in [ + [set.world_aabb[0], set.world_aabb[1], set.z_aabb[0]], + [set.world_aabb[2], set.world_aabb[1], set.z_aabb[0]], + [set.world_aabb[0], set.world_aabb[3], set.z_aabb[0]], + [set.world_aabb[2], set.world_aabb[3], set.z_aabb[0]], + [set.world_aabb[0], set.world_aabb[1], set.z_aabb[1]], + [set.world_aabb[2], set.world_aabb[1], set.z_aabb[1]], + [set.world_aabb[0], set.world_aabb[3], set.z_aabb[1]], + [set.world_aabb[2], set.world_aabb[3], set.z_aabb[1]], + ] { + let s = project(glam::DVec3::new( + corner[0] as f64, + corner[1] as f64, + corner[2] as f64, + )); + if !finite_screen(s) { + continue; + } + x0 = x0.min(s[0]); + y0 = y0.min(s[1]); + x1 = x1.max(s[0]); + y1 = y1.max(s[1]); + } + if cursor[0] < x0 - aperture_px + || cursor[0] > x1 + aperture_px + || cursor[1] < y0 - aperture_px + || cursor[1] > y1 + aperture_px + { + continue; + } + for tri in lod.indices.chunks_exact(3) { + let mut v = [glam::DVec3::ZERO; 3]; + let mut ok = true; + for (slot, index) in v.iter_mut().zip(tri.iter()) { + let (Some(high), low) = ( + lod.verts.get(*index as usize), + lod.verts_low.get(*index as usize).copied().unwrap_or([0.0; 3]), + ) else { + ok = false; + break; + }; + *slot = glam::DVec3::new( + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ); + if !slot.is_finite() { + ok = false; + break; + } + } + if !ok { + continue; + } + let [a, b, c] = v; + let (sa, sb, sc) = (project(a), project(b), project(c)); + if !(finite_screen(sa) && finite_screen(sb) && finite_screen(sc)) { + continue; + } + // Per-triangle screen reject before any 3D work. + let (tx0, ty0) = ( + sa[0].min(sb[0]).min(sc[0]), + sa[1].min(sb[1]).min(sc[1]), + ); + let (tx1, ty1) = ( + sa[0].max(sb[0]).max(sc[0]), + sa[1].max(sb[1]).max(sc[1]), + ); + if cursor[0] < tx0 - aperture_px + || cursor[0] > tx1 + aperture_px + || cursor[1] < ty0 - aperture_px + || cursor[1] > ty1 + aperture_px + { + continue; + } + if want_nearest { + let (q, d2, w) = closest_point_on_tri_2d(cursor, sa, sb, sc); + if d2 < radius2 { + let world = a * w[0] as f64 + b * w[1] as f64 + c * w[2] as f64; + consider(SnapType::NearestFace, world, q, d2, *handle); + } + } + if let Some(base3d) = perp_base { + if let Some(foot) = foot_on_triangle(base3d, a, b, c) { + let sf = project(foot); + if finite_screen(sf) { + let dx = sf[0] - cursor[0]; + let dy = sf[1] - cursor[1]; + consider( + SnapType::FacePerpendicular, + foot, + sf, + dx * dx + dy * dy, + *handle, + ); + } + } + } + } + } + best.map(|(_, _, _, _, _, hit)| hit) + } +} + /// Result of `Scene::entity_index()`. The wire path queries `tree` for /// view-rect candidates and also always emits `unbounded_handles` /// (entities with no usable bbox — legacy `UNBOUNDED_AABB` sentinel). @@ -1683,6 +2015,10 @@ pub struct Scene { pub hover_highlight: Option, /// All entities related to the constraint indicator currently under the cursor. constraint_hover_highlights: HashSet, + /// Sub-entity references related to that indicator. Polyline segments are + /// drawn separately so hovering one constraint does not light the whole chain. + constraint_hover_refs: Vec, + constraint_hover_wires: Vec, /// Color used to draw the selection highlight overlay wires (SELECTIONEFFECTCOLOR). pub selection_color: [f32; 4], /// Whether selection visual effect (glow/highlight) is enabled (SELECTIONEFFECT). @@ -1868,7 +2204,8 @@ pub struct Scene { RefCell>>>, styled_wire_cache: RefCell>)>>, styled_hatch_cache: RefCell>>>, - styled_wire_fill_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). @@ -2213,6 +2550,8 @@ impl Scene { refedit_keep: None, hover_highlight: None, constraint_hover_highlights: HashSet::default(), + constraint_hover_refs: Vec::new(), + constraint_hover_wires: Vec::new(), selection_color: crate::scene::model::wire_model::WireModel::SELECTED, selection_effect: true, transparency_display: true, @@ -2873,6 +3212,66 @@ impl Scene { driven_refs: &[parametric_constraints::ParametricRef], retain_size: bool, retained_originals: &[(Handle, EntityType)], + ) { + self.bump_entities_with_solve_policy( + changes, + driven_refs, + retain_size, + retained_originals, + &[], + true, + ); + } + + /// Refresh display and associations after a solved grip or exact history restore. + pub(crate) fn bump_entities_after_parametric_solve(&mut self, changes: &[(Handle, ChangeKind)]) { + self.bump_entities_with_solve_policy(changes, &[], false, &[], &[], false); + } + + /// Apply a newly-created ordered relation while temporarily anchoring + /// its reference side. The anchors guide only this first solve and are + /// never added to the persistent constraint graph. + pub fn bump_entities_with_initial_parametric_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + fixed_refs: &[parametric_constraints::ParametricRef], + retain_size: bool, + ) { + self.bump_entities_with_solve_policy( + changes, + &[], + retain_size, + &[], + fixed_refs, + true, + ); + } + + pub fn bump_entities_with_parametric_transform_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + transformed_refs: &[parametric_constraints::ParametricRef], + retained_originals: &[(Handle, EntityType)], + ) { + self.bump_entities_with_solve_policy( + changes, + driven_refs, + true, + retained_originals, + transformed_refs, + true, + ); + } + + fn bump_entities_with_solve_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], + fixed_refs: &[parametric_constraints::ParametricRef], + solve_parametric: bool, ) { if changes.iter().any(|(handle, kind)| { matches!(kind, ChangeKind::Removed) @@ -2906,12 +3305,13 @@ impl Scene { changes.push(change); } } - if !self.parametric_constraints.is_empty() { - for change in self.refresh_parametric_constraints_with_originals( + if solve_parametric && !self.parametric_constraints.is_empty() { + for change in self.refresh_parametric_constraints_with_initial_policy( &changes, driven_refs, retain_size, retained_originals, + fixed_refs, ) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); @@ -3980,13 +4380,8 @@ impl Scene { None })?; // `paper_limits()` already swaps the sheet for a 90°/270° rotation, so the - // margins must rotate to the same edges: a margin on a physical side moves - // to the displayed side that side rotates onto. - let (ml, mb, mr, mt) = match rot { - 1 | 3 => (bottom, left, top, right), - 2 => (right, top, left, bottom), - _ => (left, bottom, right, top), - }; + // margins must rotate to the same edges. + let (ml, mb, mr, mt) = page_setup::rotated_margins((left, bottom, right, top), rot); // Plot margins are millimetres like the paper size; scale them into the // layout's paper-space units so the inset matches the (already scaled) // sheet rect. Without this an inch paper space insets an ~8-inch sheet @@ -4572,6 +4967,78 @@ impl Scene { .then_some(owner) } + /// Millimetres in one unit of the paper side of this drawing's scale + /// family: 25.4 for an imperial drawing (whose `1/8" = 1'-0"` style scales + /// measure paper in inches), 1 for a metric one. Plot-scale factors built + /// from [`Scene::scale_list`] are expressed per this unit; page setups + /// store theirs per the layout's own paper unit, so the two are converted + /// through this value. + pub(crate) fn scale_family_unit_mm(&self) -> f64 { + if self.prefers_imperial_scales() == Some(true) { + 25.4 + } else { + 1.0 + } + } + + /// The `paper : drawing` ratio behind a scale-list name, from the + /// drawing's own `Scale` object when it has one, otherwise from the + /// built-in family. A ratio keeps the plot scale readable in a page setup + /// (`1 : 250` rather than `0.004 : 1`). + pub(crate) fn scale_ratio(&self, name: &str) -> Option<(f64, f64)> { + use acadrust::objects::ObjectType; + let stored = self.document.objects.values().find_map(|o| match o { + ObjectType::Scale(s) if !s.is_temporary && s.name.eq_ignore_ascii_case(name) => { + Some((s.paper_units, s.drawing_units)) + } + _ => None, + }); + stored + .or_else(|| Self::parse_scale_name_ratio(name)) + .filter(|(paper, drawing)| *paper > 0.0 && *drawing > 0.0) + } + + /// The `paper : drawing` ratio spelled by a scale name: `1:250` gives + /// `(1, 250)`, an architectural `1/8" = 1'-0"` gives `(0.125, 12)` in + /// inches. Anything else is not a scale name. + pub(crate) fn parse_scale_name_ratio(name: &str) -> Option<(f64, f64)> { + if let Some((paper, drawing)) = name.split_once(':') { + let paper: f64 = paper.trim().parse().ok()?; + let drawing: f64 = drawing.trim().parse().ok()?; + return (paper > 0.0 && drawing > 0.0).then_some((paper, drawing)); + } + let (paper, drawing) = name.split_once('=')?; + let paper = Self::parse_feet_inches(paper)?; + let drawing = Self::parse_feet_inches(drawing)?; + (paper > 0.0 && drawing > 0.0).then_some((paper, drawing)) + } + + /// Inches in a `F'-I"` / `I"` / `F'` length, where the inch part may be a + /// fraction (`3/32"`). + fn parse_feet_inches(text: &str) -> Option { + let text = text.trim(); + let (feet, inches) = match text.split_once('\'') { + Some((feet, rest)) => ( + feet.trim().parse::().ok()?, + rest.trim_start_matches('-').trim(), + ), + None => (0.0, text), + }; + let inches = inches.trim_end_matches('"').trim(); + let inches = if inches.is_empty() { + 0.0 + } else if let Some((num, den)) = inches.split_once('/') { + let den: f64 = den.trim().parse().ok()?; + if den <= 0.0 { + return None; + } + num.trim().parse::().ok()? / den + } else { + inches.parse::().ok()? + }; + Some(feet * 12.0 + inches) + } + /// Handle of the real (non-temporary) `Scale` object with this display name. /// Scales are matched by their `name` field, not the dictionary key — some /// files key the entries by an internal identifier (`*A1`, `A0`, …) that @@ -5124,12 +5591,48 @@ impl Scene { handles } - pub fn set_constraint_hover_highlights(&mut self, handles: &[Handle]) { - let desired: HashSet<_> = handles.iter().copied().collect(); - if desired != self.constraint_hover_highlights { - self.constraint_hover_highlights = desired; - self.bump_selection(); + pub fn set_constraint_hover_highlights( + &mut self, + references: &[crate::scene::parametric_constraints::ParametricRef], + ) { + let mut references = references.to_vec(); + references.dedup(); + if references == self.constraint_hover_refs { + return; } + + let mut handles = HashSet::default(); + let mut wires = Vec::new(); + for reference in &references { + let Some(segment) = reference.segment_index() else { + handles.insert(reference.entity); + continue; + }; + let Some(entity) = self.document.get_entity(reference.entity) else { + continue; + }; + let Some(mut curve) = crate::entities::curve::entity_curve(entity) else { + handles.insert(reference.entity); + continue; + }; + let Some(segment_curve) = curve.curve.segments().into_iter().nth(segment) else { + continue; + }; + curve.curve = segment_curve; + let mut wire = WireModel::solid_f64( + format!("constraint-segment:{}:{segment}", reference.entity.value()), + curve.tessellate(8.0), + WireModel::HOVER, + false, + ); + wire.set_fixed_screen_width(2.0); + wires.push(wire); + } + + self.constraint_hover_refs = references; + self.constraint_hover_highlights = handles; + self.constraint_hover_wires = wires; + self.bump_selection(); } /// Keep the current selection visible and temporarily filter every other @@ -5380,6 +5883,10 @@ impl Scene { self.document .set_viewport_annotation_scale(viewport, scale_handle); + // Viewport scale changed: the dimensions drawn on the sheet + // through it measure the same model geometry at a new paper + // size and have to be re-placed. + self.notify_viewport_changed(viewport); self.resident_wire_sets.borrow_mut().clear(); self.bump_geometry(); @@ -5426,6 +5933,10 @@ impl Scene { vp.view_height = vp.height / factor; self.document .set_viewport_annotation_scale(viewport, scale_handle); + // Viewport scale changed: the dimensions drawn on the sheet + // through it measure the same model geometry at a new paper + // size and have to be re-placed. + self.notify_viewport_changed(viewport); self.resident_wire_sets.borrow_mut().clear(); self.bump_geometry(); true @@ -5884,7 +6395,7 @@ impl Scene { continue; } visible_changed.insert(*h); - let raw = tessellate_entity( + let mut raw = tessellate_entity( &self.document, &empty_sel, style_viewport.or(self.active_viewport), @@ -5897,6 +6408,10 @@ impl Scene { None, anno_scale_override.is_some(), ); + // Solid B-rep corners snap via the mesh edge list (their wire + // path is an empty shell). Enrich before the memo/assembly split + // so both share the same snap-bearing wires. + self.attach_solid_snaps(*h, &mut raw); memo_updates.push((*h, Arc::new(raw.clone()))); let mut faded = raw; self.apply_refedit_fade(&mut faded, bg); @@ -9835,9 +10350,15 @@ impl Scene { let mut out = hit_wires; { let mut memo = memo_cell.borrow_mut(); - for (h, a) in &miss_pairs { + for (h, a) in miss_pairs { + // Uniquely owned (just built above): enrich solid shells + // with B-rep vertex snaps before the memo/assembly split. + let mut wires = + Arc::try_unwrap(a).unwrap_or_else(|a| a.as_ref().clone()); + self.attach_solid_snaps(h, &mut wires); + let a = Arc::new(wires); out.extend(a.iter().cloned()); - memo.insert(*h, Arc::clone(a)); + memo.insert(h, Arc::clone(&a)); } } materialize_ms = crate::perf::elapsed_ms(t_materialize); @@ -10245,7 +10766,7 @@ vis_index={:.1} visible_probe={:.1}", } fn rebuild_dependency_index(&self) -> SceneDependencyIndex { - let layout_blocks: HashSet = self + let mut layout_blocks: HashSet = self .document .objects .values() @@ -10256,6 +10777,14 @@ vis_index={:.1} visible_probe={:.1}", _ => None, }) .collect(); + // DWG imports may have model/paper block records without matching + // Layout objects. They still own directly rendered entities, so their + // layer dependencies must resolve to those entity handles. + layout_blocks.extend(self.document.block_records.iter().filter_map(|record| { + let name = normalize_name(&record.name); + (name.starts_with("*MODEL_SPACE") || name.starts_with("*PAPER_SPACE")) + .then_some(record.handle) + })); let block_names: HashMap = self .document .block_records @@ -10796,7 +11325,7 @@ vis_index={:.1} visible_probe={:.1}", let (bg, anno, annotation_scale_handle, blk_cache, paper) = self.tessellation_context(); // tessellate_one is used for one-off lookups (hit test, properties). // Skip culling here so the caller always gets the full geometry. - tessellate_entity( + let mut wires = tessellate_entity( &self.document, &self.selected, self.active_viewport, @@ -10808,7 +11337,11 @@ vis_index={:.1} visible_probe={:.1}", None, None, paper, - ) + ); + // Same solid-corner snaps as the resident paths, so one-off consumers + // (overlay changed-wires, edit previews) agree with hit-test/snap. + self.attach_solid_snaps(e.common().handle, &mut wires); + wires } fn model_space_block_handle(&self) -> Handle { @@ -10855,11 +11388,21 @@ vis_index={:.1} visible_probe={:.1}", /// when something is selected. `None` keeps the current camera target as /// the orbit centre. (#229) pub fn orbit_pivot(&self) -> Option { + self.navigation_selection_bounds() + .map(|(min, max)| (min + max) * 0.5) + } + + pub(crate) fn navigation_selection_bounds(&self) -> Option<(glam::DVec3, glam::DVec3)> { if self.selected.is_empty() { return None; } - let block = self.current_layout_block_handle(); - let scale = if self.current_layout == "Model" { + let model_view = self.current_layout == "Model" || self.active_viewport.is_some(); + let block = if self.active_viewport.is_some() { + self.model_space_block_handle() + } else { + self.current_layout_block_handle() + }; + let scale = if model_view { crate::scene::annotative::scale_handle_by_name( &self.document, &self.document.header.current_annotation_scale, @@ -10916,7 +11459,7 @@ vis_index={:.1} visible_probe={:.1}", } } if any { - Some((min + max) * 0.5) + Some((min, max)) } else { None } @@ -11631,6 +12174,33 @@ mod journal_tests { ); } + #[test] + fn imported_model_space_hatch_without_layout_object_is_a_layer_target() { + use acadrust::entities::Hatch; + use acadrust::objects::ObjectType; + use acadrust::tables::layer::Layer; + + let mut scene = Scene::new(); + scene.document.layers.add_or_replace(Layer::new("Shadows")); + let mut hatch = Hatch::new(); + hatch.common.layer = "Shadows".to_string(); + let handle = scene.add_entity(EntityType::Hatch(hatch)); + let model = scene.model_space_block_handle(); + assert_eq!(scene.document.get_entity(handle).unwrap().common().owner_handle, model); + scene.document.objects.retain(|_, object| !matches!(object, ObjectType::Layout(_))); + scene.invalidate_dependency_index(); + + let targets = scene.dependency_targets(DependencyKind::Layer, &["Shadows".to_string()]); + assert!(targets.render_handles.contains(&handle)); + assert!(targets.source_handles.contains(&handle)); + assert!(!targets.touches_block_definition); + + scene.document.layers.get_mut("Shadows").unwrap().flags.off = true; + let epoch = scene.geometry_epoch; + scene.invalidate_layer_dependencies(&["Shadows".to_string()]); + assert_ne!(scene.geometry_epoch, epoch, "the cached hatch needs an entity delta"); + } + /// The loader's handed-over draw depths must equal a local rebuild. #[test] fn the_handed_over_draw_depth_map_matches_a_recompute() { diff --git a/src/scene/model/mesh_model.rs b/src/scene/model/mesh_model.rs index f59f91d77..36ea44f9c 100644 --- a/src/scene/model/mesh_model.rs +++ b/src/scene/model/mesh_model.rs @@ -318,3 +318,128 @@ impl MeshLodSet { }) } } + +/// Max B-rep edge pairs read from one solid for snapping. Curved-edge +/// tessellations fan out into thousands of joints; the cap keeps the +/// interaction index and the per-move snap scan bounded. +pub const MAX_SOLID_SNAP_EDGES: usize = 2048; + +/// Distinct B-rep edge segments from a high/low double-single line list +/// (mesh `edge_verts` pairs + `edge_verts_low`), feeding solid vertex +/// (endpoints) and edge-midpoint (centres) snaps. +/// +/// Degenerate (zero-length) and non-finite pairs are dropped, and identical +/// pairs collapse regardless of endpoint order. Past the cap the output is +/// stride-sampled so coverage degrades gracefully instead of dropping every +/// edge past some cutoff. +pub fn solid_edge_segments( + edge_verts: &[[f32; 3]], + edge_verts_low: &[[f32; 3]], +) -> Vec<(glam::DVec3, glam::DVec3)> { + fn point(high: [f32; 3], low: [f32; 3]) -> Option { + let p = glam::DVec3::new( + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ); + p.is_finite().then_some(p) + } + fn key(p: &glam::DVec3) -> [u64; 3] { + [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()] + } + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for index in 0..edge_verts.len() / 2 { + let high = [edge_verts[2 * index], edge_verts[2 * index + 1]]; + let low = [ + edge_verts_low.get(2 * index).copied().unwrap_or([0.0; 3]), + edge_verts_low + .get(2 * index + 1) + .copied() + .unwrap_or([0.0; 3]), + ]; + let (Some(a), Some(b)) = (point(high[0], low[0]), point(high[1], low[1])) else { + continue; + }; + if a.distance_squared(b) <= 1e-12 { + continue; + } + let (ka, kb) = (key(&a), key(&b)); + let ordered = if ka <= kb { (ka, kb) } else { (kb, ka) }; + if !seen.insert(ordered) { + continue; + } + out.push((a, b)); + } + if out.len() > MAX_SOLID_SNAP_EDGES { + let step = out.len().div_ceil(MAX_SOLID_SNAP_EDGES); + out = out.into_iter().step_by(step).collect(); + } + out +} + +#[cfg(test)] +mod solid_snap_tests { + use super::solid_edge_segments; + + fn pt(x: f32, y: f32, z: f32) -> [f32; 3] { + [x, y, z] + } + + #[test] + fn shared_corners_keep_both_segments() { + // Two edges sharing the corner (1,0,0): both pairs survive, the + // corner itself dedups later at attach time, not here. + let edges = vec![ + pt(0.0, 0.0, 0.0), + pt(1.0, 0.0, 0.0), + pt(1.0, 0.0, 0.0), + pt(1.0, 1.0, 0.0), + ]; + let low = vec![[0.0f32; 3]; 4]; + let segs = solid_edge_segments(&edges, &low); + assert_eq!(segs.len(), 2, "shared corner collapsed segments: {segs:?}"); + } + + #[test] + fn degenerate_and_non_finite_pairs_are_dropped() { + let edges = vec![ + pt(0.0, 0.0, 0.0), + pt(f32::NAN, 0.0, 0.0), + pt(1.0, 0.0, 0.0), + pt(1.0, 0.0, 0.0), // degenerate: zero length + pt(2.0, 0.0, 0.0), + pt(3.0, 0.0, f32::INFINITY), + pt(4.0, 0.0, 0.0), + pt(5.0, 0.0, 0.0), + ]; + let low = vec![[0.0f32; 3]; 8]; + let segs = solid_edge_segments(&edges, &low); + assert_eq!(segs.len(), 1, "expected only the valid pair: {segs:?}"); + assert!(segs[0].0.is_finite() && segs[0].1.is_finite()); + } + + #[test] + fn output_is_capped_with_coverage() { + let mut edges = Vec::new(); + for k in 0..2200 { + let x = 100.0 + k as f32; + edges.push(pt(x, 0.0, 0.0)); + edges.push(pt(x, 1.0, 0.0)); + } + let low = vec![[0.0f32; 3]; 4400]; + let segs = solid_edge_segments(&edges, &low); + assert!( + segs.len() <= super::MAX_SOLID_SNAP_EDGES, + "cap exceeded: {}", + segs.len() + ); + assert!( + segs.len() >= super::MAX_SOLID_SNAP_EDGES / 2, + "stride sampling dropped too much coverage: {}", + segs.len() + ); + // The first pair survives sampling (index 0 is kept). + assert_eq!(segs[0].0, glam::DVec3::new(100.0, 0.0, 0.0)); + } +} diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 6c3f2eb39..df84137e9 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -14,6 +14,9 @@ pub enum PropValue { EditText(String), /// Editable text that must not be expression-evaluated. PlainText(String), + /// Hyperlink collection summary. The value is display-only in the row; + /// the adjacent collection button opens the URL/description editor. + Hyperlink(String), /// Layer name — rendered as a combo_box. LayerChoice(String), /// Generic string choice rendered as a combo_box. diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index d19c22113..3d6ed2146 100644 --- a/src/scene/model/pdf_raster.rs +++ b/src/scene/model/pdf_raster.rs @@ -18,7 +18,7 @@ pub struct PdfPage { const RASTER_DPI: f32 = 150.0; -type PageKey = (String, String); +type PageKey = (String, String, u32); fn page_cache() -> &'static Mutex>>> { static CACHE: OnceLock>>>> = OnceLock::new(); @@ -40,12 +40,18 @@ pub fn register_source(path: &str, bytes: Arc>) { page_cache() .lock() .unwrap_or_else(|error| error.into_inner()) - .retain(|(cached_path, _), _| cached_path != path); + .retain(|(cached_path, _, _), _| cached_path != path); } /// Rasterise a 1-based PDF page, memoised by source path and page name. pub fn rasterize_page(path: &str, page: &str) -> Option> { - let key = (path.to_string(), page.to_string()); + rasterize_page_at_dpi(path, page, RASTER_DPI) +} + +/// Rasterise a 1-based PDF page at a caller-chosen DPI, memoised per source +/// path, page and DPI (print quality differs from the on-screen 150 DPI). +pub fn rasterize_page_at_dpi(path: &str, page: &str, dpi: f32) -> Option> { + let key = (path.to_string(), page.to_string(), dpi.to_bits()); if let Some(hit) = page_cache() .lock() .unwrap_or_else(|error| error.into_inner()) @@ -55,7 +61,7 @@ pub fn rasterize_page(path: &str, page: &str) -> Option> { return hit; } - let built = rasterize_uncached(path, page); + let built = rasterize_uncached(path, page, dpi); page_cache() .lock() .unwrap_or_else(|error| error.into_inner()) @@ -63,12 +69,19 @@ pub fn rasterize_page(path: &str, page: &str) -> Option> { built } -fn rasterize_uncached(path: &str, page: &str) -> Option> { +/// Rasterise without touching the memo: for one-off consumers such as a +/// print job, whose 300 DPI page would otherwise stay cached for the whole +/// session (tens of megabytes per sheet). +pub fn rasterize_page_at_dpi_uncached(path: &str, page: &str, dpi: f32) -> Option> { + rasterize_uncached(path, page, dpi) +} + +fn rasterize_uncached(path: &str, page: &str, dpi: f32) -> Option> { let bytes = source_bytes(path)?; let pdf = Pdf::new(bytes).ok()?; let page_no = page.trim().parse::().unwrap_or(1).max(1); let page = pdf.pages().get(page_no - 1)?; - let scale = RASTER_DPI / 72.0; + let scale = dpi / 72.0; let pixmap = hayro::render( page, &RenderCache::new(), @@ -86,7 +99,7 @@ fn rasterize_uncached(path: &str, page: &str) -> Option> { pixels: Arc::new(pixmap.data_as_u8_slice().to_vec()), width, height, - dpi: RASTER_DPI, + dpi, })) } diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 1fa867915..3c74e2b6b 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -371,6 +371,45 @@ pub fn planar_face_normal(body: &Body, face: FaceKey) -> Option<[f64; 3]> { Some(if face.forward { normal } else { -normal }.to_array()) } +/// Centre of every B-rep face: the average of its boundary-loop vertices. +/// Exact for planar faces; for curved faces the loop average floats slightly +/// inside the surface — a documented approximation the snap path must not pay +/// a surface evaluation for on every wire build. Capped like the edge snaps. +pub fn face_centers(body: &Body) -> Vec<[f64; 3]> { + const MAX_FACE_CENTERS: usize = 2048; + let mut out = Vec::new(); + for face in body.face_keys() { + let mut sum = [0.0; 3]; + let mut count = 0usize; + for coedge in body.face_coedges(face) { + let Some((va, vb)) = body.coedge_vertices(coedge) else { + continue; + }; + for vk in [va, vb] { + let Some(v) = body.vertices.get(vk) else { + continue; + }; + if !v.point.iter().all(|c| c.is_finite()) { + continue; + } + sum[0] += v.point[0]; + sum[1] += v.point[1]; + sum[2] += v.point[2]; + count += 1; + } + } + if count >= 3 { + let n = count as f64; + out.push([sum[0] / n, sum[1] / n, sum[2] / n]); + } + } + if out.len() > MAX_FACE_CENTERS { + let step = out.len().div_ceil(MAX_FACE_CENTERS); + out = out.into_iter().step_by(step).collect(); + } + out +} + // ── Boolean operations ────────────────────────────────────────────────────── /// Which CSG to apply. Mirrors `model::boolean_cmd::BoolOp` but kept local so @@ -601,6 +640,30 @@ mod tests { assert_eq!(display([2, 2], false), boundaries); } + #[test] + fn box_face_centers_are_six() { + let body = box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0).unwrap(); + let centers = face_centers(&body); + assert_eq!(centers.len(), 6, "a box has six faces: {centers:?}"); + for expected in [ + [5.0, 0.0, 0.0], + [-5.0, 0.0, 0.0], + [0.0, 5.0, 0.0], + [0.0, -5.0, 0.0], + [0.0, 0.0, 5.0], + [0.0, 0.0, -5.0], + ] { + assert!( + centers.iter().any(|c| { + (c[0] - expected[0]).abs() < 1e-9 + && (c[1] - expected[1]).abs() < 1e-9 + && (c[2] - expected[2]).abs() < 1e-9 + }), + "missing face center {expected:?} in {centers:?}" + ); + } + } + #[test] fn every_primitive_is_the_size_it_was_asked_for() { // Triangle counts say a mesh exists; the volume says it is the right diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index deec10db2..d1fc50bb8 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -22,6 +22,18 @@ pub enum SnapHint { /// arc's two ends would also produce a midpoint snap out on the chord, /// which is not on the geometry. Endpoint, + /// B-rep corner of a 3D solid. Maps to the separate 3D snap system + /// (`SnapType::Vertex`), never to the 2D modes. + Vertex, + /// Centre of a 3D solid B-rep edge. Maps to the separate 3D snap system + /// (`SnapType::EdgeMidpoint`), never to the 2D modes. + EdgeMidpoint, + /// Centre of a 3D solid B-rep face. Maps to the separate 3D snap system + /// (`SnapType::FaceCenter`), never to the 2D modes. + FaceCenter, + /// NURBS knot location on a spline entity. Maps to the separate 3D snap + /// system (`SnapType::Knot`), never to the 2D modes. + Knot, } /// Geometric primitive used by the tangent-snap engine. diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 47b605db8..e4ae6e69c 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -161,6 +161,80 @@ impl Scene { .filter(|&h| !self.is_layer_locked(h)) .collect(); let handles = &handles[..]; + let retained_originals: Vec<(Handle, EntityType)> = handles + .iter() + .filter_map(|handle| { + self.document + .get_entity(*handle) + .cloned() + .map(|entity| (*handle, entity)) + }) + .collect(); + let transformed_refs: Vec<_> = handles + .iter() + .copied() + .map(parametric_constraints::ParametricRef::whole) + .collect(); + let mut driven_refs = Vec::new(); + if !matches!(t, EntityTransform::Translate(_)) { + for set in &self.parametric_constraints { + for constraint in &set.constraints { + if !constraint.enabled + || !matches!( + constraint.kind, + parametric_constraints::ConstraintKind::Parallel + | parametric_constraints::ConstraintKind::Perpendicular + | parametric_constraints::ConstraintKind::Colinear + ) + { + continue; + } + let selected_refs = constraint + .refs + .iter() + .filter(|reference| handles.contains(&reference.entity)) + .count(); + if selected_refs == 0 || selected_refs == constraint.refs.len() { + continue; + } + for reference in constraint + .refs + .iter() + .copied() + .filter(|reference| !handles.contains(&reference.entity)) + { + let anchor = if let Some(index) = reference.segment_index() { + parametric_constraints::ParametricRef::point( + reference.entity, + index as i32, + ) + } else if reference.directional_axis().is_some() + && matches!( + self.document.get_entity(reference.entity), + Some(EntityType::Ellipse(_)) + ) + { + parametric_constraints::ParametricRef::center(reference.entity) + } else { + parametric_constraints::ParametricRef::point(reference.entity, 0) + }; + let follows_selected_point = set.constraints.iter().any(|link| { + link.enabled + && link.kind + == parametric_constraints::ConstraintKind::Coincident + && link.refs.contains(&anchor) + && link + .refs + .iter() + .any(|item| handles.contains(&item.entity)) + }); + if !follows_selected_point && !driven_refs.contains(&anchor) { + driven_refs.push(anchor); + } + } + } + } + } // Preserve readable text orientation when MIRRTEXT is disabled. let preserve_text_orientation = matches!(t, EntityTransform::Mirror { .. }) && !self.document.header.mirror_text; @@ -290,7 +364,12 @@ impl Scene { // per-handle, keeping the block cache + all other memoized wires. let changes: Vec<(Handle, ChangeKind)> = handles.iter().map(|&h| (h, ChangeKind::Modified)).collect(); - self.bump_entities(&changes); + self.bump_entities_with_parametric_transform_policy( + &changes, + &driven_refs, + &transformed_refs, + &retained_originals, + ); self.refresh_meshes_for_handles(&refresh_solid_handles); } @@ -730,6 +809,7 @@ impl Scene { // Complete group copies record their new Group objects and dictionary // entry as targeted object deltas inside copy_complete_groups. self.copy_complete_groups(&handle_map); + self.copy_dimension_associations(&handle_map); // The copies are new handles (natural memo misses, tessellated fresh) // and reference only already-cached blocks — no block defn changes. // Report them as additions so derived caches patch in exactly the copies. @@ -1398,6 +1478,7 @@ impl Scene { if let Some(entity) = self.document.get_entity_mut(handle) { view::dispatch::apply_grip(entity, grip_id, apply); } + self.sync_diameter_association_angle(handle); if let Some((annotation_handle, old_landing)) = leader_landing_before { let new_landing = self.document.get_entity(handle).and_then(|entity| { let EntityType::Leader(leader) = entity else { diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index 6cad6e5c5..fe6df1b7b 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -80,12 +80,173 @@ impl Scene { &self, canvas_px: (f32, f32), ) -> Option<(view::camera::Camera, iced::Rectangle)> { - let vp_handle = self.active_viewport?; + self.viewport_edit_frame_for(self.active_viewport?, canvas_px) + } + + /// Camera and canvas rectangle for snapping through an inactive viewport. + pub fn viewport_edit_frame_for( + &self, + vp_handle: Handle, + canvas_px: (f32, f32), + ) -> Option<(view::camera::Camera, iced::Rectangle)> { let cam = self.camera_for_viewport(vp_handle)?; let full = self.viewport_screen_rect(vp_handle, canvas_px)?; Some((cam, full)) } + /// Planar paper/model mapping derived from the viewport's display camera. + /// Oblique and perspective views have no supported planar mapping. + pub fn viewport_frame( + &self, + vp_handle: Handle, + ) -> Option { + use crate::scene::viewport_ref::ViewportFrame; + + let (paper_center, vp_height, locked) = match self.document.get_entity(vp_handle) { + Some(EntityType::Viewport(vp)) => ( + glam::DVec2::new(vp.center.x, vp.center.y), + vp.height, + vp.status.locked, + ), + _ => return None, + }; + if !paper_center.is_finite() || !vp_height.is_finite() || vp_height < 1e-9 { + return None; + } + let cam = self.camera_for_viewport(vp_handle)?; + if cam.projection != view::camera::Projection::Orthographic { + return None; + } + // Screen right/up of the viewport camera, in model space. A planar + // frame exists only when both stay in the model XY plane and form a + // right-handed pair (a plan view, possibly twisted). Anything else is + // an oblique 3-D view: unsupported. + let right = (cam.rotation * glam::Vec3::X).as_dvec3(); + let up = (cam.rotation * glam::Vec3::Y).as_dvec3(); + if !right.is_finite() || !up.is_finite() || right.z.abs() > 1e-6 || up.z.abs() > 1e-6 { + return None; + } + let normal = right.cross(up); + if normal.z < 1.0 - 1e-6 { + return None; // mirrored or degenerate + } + // Model units visible across the viewport's height -> paper per model. + let model_height = cam.ortho_size() as f64 * 2.0; + if !model_height.is_finite() || model_height < 1e-12 { + return None; + } + let scale = vp_height / model_height; + if !scale.is_finite() || scale <= 0.0 { + return None; + } + // `ViewportFrame` rotates model geometry by `twist` (CCW) before + // scaling; the row that produces the paper X component is `right`, so + // cos(twist) = right.x and sin(twist) = -right.y. + let twist = (-right.y).atan2(right.x); + Some(ViewportFrame { + viewport: vp_handle, + paper_center, + model_target: glam::DVec2::new(cam.target.x as f64, cam.target.y as f64), + scale, + twist, + locked, + }) + } + + /// Visible, supported content viewports at a sheet point, topmost first. + /// Shared by snapping and explicit dimension object picking. + pub fn viewport_frames_at_paper_point( + &self, + paper: glam::DVec3, + ) -> Vec { + if self.current_layout == "Model" || self.active_viewport.is_some() { + return Vec::new(); + } + self.layout_content_viewports() + .iter() + .rev() + .filter_map(|&handle| { + if !self.viewport_displays_content(handle) + || !self.viewport_displays_paper_point(handle, paper.truncate()) + { + return None; + } + self.viewport_frame(handle) + }) + .collect() + } + + /// Test the viewport rectangle and optional nonrectangular clip boundary. + pub fn viewport_displays_paper_point(&self, vp_handle: Handle, paper: glam::DVec2) -> bool { + let Some(EntityType::Viewport(vp)) = self.document.get_entity(vp_handle) else { + return false; + }; + let hw = (vp.width * 0.5).abs(); + let hh = (vp.height * 0.5).abs(); + if hw < 1e-9 || hh < 1e-9 { + return false; + } + if (paper.x - vp.center.x).abs() > hw || (paper.y - vp.center.y).abs() > hh { + return false; + } + if vp.clip_boundary_handle.is_null() { + return true; + } + let Some(boundary) = self.document.get_entity(vp.clip_boundary_handle) + .and_then(crate::entities::curve::entity_curve) + else { + return false; + }; + let Some(normal) = boundary.plane.normal() else { + return false; + }; + if !boundary.curve.is_closed() || normal[0].abs() > 1e-9 || normal[1].abs() > 1e-9 { + return false; + } + let Some(point) = boundary.plane.project([paper.x, paper.y, vp.center.z]) else { + return false; + }; + cadkernel::geom2d::contains( + &[boundary.curve], + point, + cadkernel::geom2d::Tolerance::new(1e-9), + ) + } +} + +#[cfg(test)] +mod clip_tests { + use super::*; + + #[test] + fn concave_clip_boundary_rejects_the_notch() { + let mut scene = Scene::new(); + let mut poly = acadrust::entities::LwPolyline::from_points( + [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (5.0, 5.0), (5.0, 10.0), (0.0, 10.0)] + .map(|(x, y)| acadrust::types::Vector2::new(x, y)) + .to_vec(), + ); + poly.is_closed = true; + let clip = scene.add_entity(EntityType::LwPolyline(poly)); + let mut viewport = acadrust::entities::Viewport::new(); + viewport.center = acadrust::types::Vector3::new(5.0, 5.0, 0.0); + viewport.width = 20.0; + viewport.height = 20.0; + viewport.clip_boundary_handle = clip; + let viewport = scene.add_entity(EntityType::Viewport(viewport)); + for (x, y, inside) in [ + (2.0, 2.0, true), (8.0, 2.0, true), (8.0, 8.0, false), + (-1.0, 5.0, false), (5.0, 7.0, true), + ] { + assert_eq!( + scene.viewport_displays_paper_point(viewport, glam::DVec2::new(x, y)), inside, + ); + } + } +} + +impl Scene { + /// Fold the active viewport's saved view onto the effective camera (the /// auto-fit centre for stale UTM views) and persist it into `view_target` / /// `view_height`. Called on entering MSPACE so pan/zoom, paper↔model and the @@ -109,6 +270,7 @@ impl Scene { vp.view_height = eff_h; } } + self.notify_viewport_changed(vp_handle); } /// Fit model-space bounds into the active floating viewport without moving @@ -147,6 +309,7 @@ impl Scene { viewport.custom_scale = viewport.height / viewport.view_height; } } + self.notify_viewport_changed(viewport_handle); self.camera_generation += 1; true } @@ -198,6 +361,7 @@ impl Scene { vp.view_target.y += model_delta.y as f64; vp.view_target.z += model_delta.z as f64; } + self.notify_viewport_changed(vp_handle); } /// Zoom the active viewport's model-space view by `steps` notches. @@ -247,6 +411,7 @@ impl Scene { } } } + self.notify_viewport_changed(vp_handle); } /// Orbit the active viewport's view direction by the given screen-pixel delta. @@ -324,6 +489,7 @@ impl Scene { vp.view_direction.z = dir.z as f64; vp.twist_angle = twist; } + self.notify_viewport_changed(vp_handle); } /// Mutate the active viewport's camera through a closure, then re-encode the @@ -352,6 +518,7 @@ impl Scene { .normalize_or(glam::Vec3::Y); let roll = up0.cross(desired_up).dot(dir).atan2(up0.dot(desired_up)); let twist = -roll as f64; + let mut changed = false; if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(vp_handle) { if vp.status.locked { return false; @@ -364,9 +531,12 @@ impl Scene { tmp.projection == view::camera::Projection::Perspective; vp.lens_length = (12.0 / (tmp.fov_y * 0.5).tan().max(1e-6)) as f64; - return true; + changed = true; } - false + if changed { + self.notify_viewport_changed(vp_handle); + } + changed } /// Render mode of the active paper-space viewport, or `None` when no @@ -446,6 +616,46 @@ impl Scene { self.camera.borrow().rotation } + /// Camera owning navigation, including an entered paper-space viewport. + pub(crate) fn navigation_camera(&self) -> view::camera::Camera { + self.active_viewport + .and_then(|h| self.camera_for_viewport(h)) + .unwrap_or_else(|| self.camera.borrow().clone()) + } + + pub(crate) fn navigation_locked(&self) -> bool { + self.active_viewport.is_some_and(|h| { + !matches!(self.document.get_entity(h), Some(acadrust::EntityType::Viewport(v)) if !v.status.locked) + }) + } + + /// Commit an externally navigated camera through the same floating-view + /// encoding as the ViewCube. Camera coordinates already include view_center. + pub(crate) fn apply_navigation_camera(&mut self, camera: view::camera::Camera) -> bool { + if self.navigation_locked() { + return false; + } + if let Some(handle) = self.active_viewport { + if let Some(acadrust::EntityType::Viewport(vp)) = self.document.get_entity_mut(handle) { + vp.view_target.x = camera.target.x; + vp.view_target.y = camera.target.y; + vp.view_target.z = camera.target.z; + vp.view_center.x = 0.; + vp.view_center.y = 0.; + vp.view_height = camera.ortho_size() as f64 * 2.; + vp.custom_scale = vp.height / vp.view_height; + } + // Notify associations only after all camera fields are persisted. + if !self.mutate_active_viewport_camera(|c| *c = camera.clone()) { + return false; + } + } else { + *self.camera.borrow_mut() = camera; + } + self.camera_generation += 1; + true + } + pub fn active_camera_projection(&self) -> view::camera::Projection { if let Some(handle) = self.active_viewport { if let Some(camera) = self.camera_for_viewport(handle) { @@ -524,6 +734,26 @@ impl Scene { .map(|(h, _)| h) } + /// Content (floating) viewports of the current paper layout, in draw order + /// (the last entry draws on top). Empty in the Model layout. + pub fn layout_content_viewports(&self) -> Arc> { + if self.current_layout == "Model" { + return Arc::new(Vec::new()); + } + let (_, _, handles) = self.paper_viewport_handles(); + handles + } + + /// `true` when this viewport's contents are displayed (VP ON). The border + /// layer's visibility deliberately does NOT matter: a viewport whose frame + /// layer is off still shows its model content, so it still snaps. + pub fn viewport_displays_content(&self, vp_handle: Handle) -> bool { + matches!( + self.document.get_entity(vp_handle), + Some(EntityType::Viewport(vp)) if vp.status.is_on + ) + } + /// Return the handle of the first active user viewport in the current layout, /// or `None` if there are none. Used by the MS command. pub fn first_user_viewport(&self) -> Option { diff --git a/src/scene/page_setup.rs b/src/scene/page_setup.rs index a09db2e6c..af3926e75 100644 --- a/src/scene/page_setup.rs +++ b/src/scene/page_setup.rs @@ -5,33 +5,113 @@ //! dictionary + objects consistent so the result round-trips through DWG/DXF. use super::Scene; -use acadrust::objects::{Dictionary, ObjectType, PlotSettings}; -use acadrust::Handle; +use acadrust::objects::{Dictionary, Layout, ObjectType, PlotSettings}; +use acadrust::{CadDocument, Handle}; + +/// Give a freshly created paper layout the page setup a new drawing starts +/// with — ISO A4 landscape on no plotter, plotted 1:1 as a layout — stored the +/// way the file format expects it: the portrait medium under its canonical +/// name with the orientation in the rotation code. `plot_style` names the +/// plot style table to attach, or is empty. +pub fn apply_default_page_setup(layout: &mut Layout, plot_style: &str) { + use acadrust::objects::{PlotPaperUnits, PlotRotation, PlotType, ScaledType}; + let a4 = crate::io::paper_catalog::default_paper(); + let (paper_width, paper_height) = a4.portrait_mm(); + layout.min_limits = (0.0, 0.0); + layout.max_limits = (paper_height, paper_width); + layout.min_extents = (0.0, 0.0, 0.0); + layout.max_extents = (paper_height, paper_width, 0.0); + layout.paper_width = paper_width; + layout.paper_height = paper_height; + layout.plot_rotation = PlotRotation::Degrees90.to_code(); + layout.plot_paper_units = PlotPaperUnits::Millimeters.to_code(); + layout.plot_type = PlotType::Layout.to_code(); + layout.plot_scale_type = ScaledType::OneToOne.to_code(); + layout.plot_scale_numerator = 1.0; + layout.plot_scale_denominator = 1.0; + layout.plot_scale_factor = 1.0; + layout.plot_flags.use_standard_scale = true; + layout.plot_flags.print_lineweights = true; + layout.plot_flags.draw_viewports_first = true; + layout.plot_flags.plot_plot_styles = !plot_style.is_empty(); + layout.plot_flags.show_plot_styles = !plot_style.is_empty(); + layout.plot_style_sheet = plot_style.to_string(); + layout.paper_size = a4.canonical.to_string(); + layout.plot_printer_name = crate::io::plot_device::PlotDevice::None.canonical_name(); +} + +/// The unprintable margins of a sheet as they appear on the rotated layout, +/// `(left, bottom, right, top)`. Stored margins belong to the medium's own +/// edges; a plot rotated by a quarter turn (`rotation` is the file's 0–3 +/// code) shows the medium turned on the layout, so each margin moves to the +/// edge its side lands on — verified against limits written by the +/// commercial application: at 90° the medium's top margin is the layout's +/// left one and its left margin the layout's bottom one. +pub fn rotated_margins( + (left, bottom, right, top): (f64, f64, f64, f64), + rotation: i16, +) -> (f64, f64, f64, f64) { + match rotation { + 1 => (top, left, bottom, right), + 2 => (right, top, left, bottom), + 3 => (bottom, right, top, left), + _ => (left, bottom, right, top), + } +} + +/// Handle of a document's `ACAD_PLOTSETTINGS` dictionary, located robustly. +/// +/// The canonical path is the header's `acad_plotsettings_dict_handle`, but +/// DWGs written by other programs don't always leave that pointer resolvable +/// (the header handle points at no loaded dictionary — see +/// [`crate::scene::annotative::root_named_dict_handle`]). In that case fall +/// back to the dictionary that owns the drawing's `PlotSettings` objects, +/// mirroring [`Scene::scalelist_dict_handle`]. Returns `None` when the +/// drawing genuinely has no named page setups. +fn plotsettings_dict_handle_in(document: &CadDocument) -> Option { + let dh = document.header.acad_plotsettings_dict_handle; + if matches!(document.objects.get(&dh), Some(ObjectType::Dictionary(_))) { + return Some(dh); + } + let owner = document.objects.values().find_map(|o| match o { + ObjectType::PlotSettings(ps) => Some(ps.owner), + _ => None, + })?; + matches!(document.objects.get(&owner), Some(ObjectType::Dictionary(_))).then_some(owner) +} + +/// The named page setups of any document — `(name, settings)` in dictionary +/// order. Reads a drawing that is not open in the editor (the source of a +/// `PSETUPIN` import) the same way the editor reads its own. +pub fn document_page_setups(document: &CadDocument) -> Vec<(String, PlotSettings)> { + let Some(dh) = plotsettings_dict_handle_in(document) else { + return Vec::new(); + }; + let Some(ObjectType::Dictionary(dict)) = document.objects.get(&dh) else { + return Vec::new(); + }; + dict.entries + .iter() + .filter_map(|(name, handle)| match document.objects.get(handle) { + Some(ObjectType::PlotSettings(ps)) => Some((name.clone(), ps.clone())), + _ => None, + }) + .collect() +} + +/// A page setup copied out of another drawing, made safe for this one: the +/// handles it pointed at (a plot view, a visual style) meant something only +/// in its source, so they are dropped while the names, which travel, stay. +pub fn detach_page_setup(ps: &mut PlotSettings) { + ps.plot_view_handle = Handle::NULL; + ps.visual_style_handle = Handle::NULL; + ps.reactors.clear(); + ps.xdictionary_handle = None; +} impl Scene { - /// Handle of the `ACAD_PLOTSETTINGS` dictionary, located robustly. - /// - /// The canonical path is the header's `acad_plotsettings_dict_handle`, but - /// DWGs written by other programs don't always leave that pointer resolvable - /// (the header handle points at no loaded dictionary — see - /// [`crate::scene::annotative::root_named_dict_handle`]). In that case fall - /// back to the dictionary that owns the drawing's `PlotSettings` objects, - /// mirroring [`Scene::scalelist_dict_handle`]. Returns `None` when the - /// drawing genuinely has no named page setups. fn plotsettings_dict_handle(&self) -> Option { - let dh = self.document.header.acad_plotsettings_dict_handle; - if matches!(self.document.objects.get(&dh), Some(ObjectType::Dictionary(_))) { - return Some(dh); - } - let owner = self.document.objects.values().find_map(|o| match o { - ObjectType::PlotSettings(ps) => Some(ps.owner), - _ => None, - })?; - matches!( - self.document.objects.get(&owner), - Some(ObjectType::Dictionary(_)) - ) - .then_some(owner) + plotsettings_dict_handle_in(&self.document) } /// Names of the document's named page setups, in dictionary order. @@ -42,6 +122,13 @@ impl Scene { } } + /// Bring a page setup from another drawing in under `name`, replacing + /// one of that name the way `PSETUPIN` redefines it. + pub fn import_page_setup(&mut self, name: &str, mut ps: PlotSettings) { + detach_page_setup(&mut ps); + self.page_setup_save(name, ps); + } + /// Clone the named page setup's `PlotSettings`, or `None` if absent. pub fn page_setup_get(&self, name: &str) -> Option { let dh = self.plotsettings_dict_handle()?; diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 9c7fe40cb..0dc086c20 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -14,8 +14,8 @@ use acadrust::types::{Handle, Vector3}; /// ordered per-entity-type point list when non-negative (0/1 = a line's /// start/end, ...), or names a special case when negative (-3 = a /// circle/arc's center; -2 = a bounded curve's midpoint). Polyline segment -/// and segment-midpoint references use private negative ranges so they stay -/// distinct from vertex markers. + /// segment-midpoint, and curved-segment-center references use private + /// negative ranges so they stay distinct from vertex markers. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ParametricRef { pub entity: Handle, @@ -28,6 +28,17 @@ pub struct ParametricRef { const POLYLINE_SEGMENT_MARKER_BASE: i32 = -1_000_000; const POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE: i32 = -2_000_000; +const POLYLINE_SEGMENT_CENTER_MARKER_BASE: i32 = -3_000_000; +const ELLIPSE_MAJOR_AXIS_MARKER: i32 = -4; +const ELLIPSE_MINOR_AXIS_MARKER: i32 = -5; +const TEXT_BASELINE_MARKER: i32 = -6; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DirectionalAxis { + TextBaseline, + EllipseMajor, + EllipseMinor, +} impl ParametricRef { pub fn whole(entity: Handle) -> Self { @@ -78,47 +89,316 @@ impl ParametricRef { pub fn segment_midpoint_index(self) -> Option { let marker = self.marker?; - (marker <= POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE) + (marker <= POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE + && marker > POLYLINE_SEGMENT_CENTER_MARKER_BASE) .then(|| (POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - marker) as usize) } + + /// Select the center of one curved polyline segment. + pub fn segment_center(entity: Handle, index: usize) -> Self { + Self { + entity, + marker: Some(POLYLINE_SEGMENT_CENTER_MARKER_BASE - index as i32), + } + } + + pub fn segment_center_index(self) -> Option { + let marker = self.marker?; + (marker <= POLYLINE_SEGMENT_CENTER_MARKER_BASE) + .then(|| (POLYLINE_SEGMENT_CENTER_MARKER_BASE - marker) as usize) + } + + /// Select the displayed baseline of a Text or MText entity. + pub fn text_baseline(entity: Handle) -> Self { + Self { + entity, + marker: Some(TEXT_BASELINE_MARKER), + } + } + + /// Select the major axis of an ellipse or elliptical arc. + pub fn ellipse_major_axis(entity: Handle) -> Self { + Self { + entity, + marker: Some(ELLIPSE_MAJOR_AXIS_MARKER), + } + } + + /// Select the minor axis of an ellipse or elliptical arc. + pub fn ellipse_minor_axis(entity: Handle) -> Self { + Self { + entity, + marker: Some(ELLIPSE_MINOR_AXIS_MARKER), + } + } + + pub(crate) fn directional_axis(self) -> Option { + match self.marker? { + TEXT_BASELINE_MARKER => Some(DirectionalAxis::TextBaseline), + ELLIPSE_MAJOR_AXIS_MARKER => Some(DirectionalAxis::EllipseMajor), + ELLIPSE_MINOR_AXIS_MARKER => Some(DirectionalAxis::EllipseMinor), + _ => None, + } + } } -/// Point reference driven by a native entity grip. Segment midpoint and -/// scalar radius grips deliberately return None because they do not identify -/// one stored point parameter. -pub(crate) fn driven_ref_for_grip( +/// The finite guide used to pick, display and serialize a directional text or +/// ellipse reference. Constraint equations treat the guide as an infinite +/// line; its finite length only makes selection and native persistence stable. +pub(crate) fn directional_axis_endpoints( + entity: &acadrust::EntityType, + reference: ParametricRef, +) -> Option<[Vector3; 2]> { + match (entity, reference.directional_axis()?) { + (acadrust::EntityType::Text(text), DirectionalAxis::TextBaseline) => { + use acadrust::entities::TextHorizontalAlignment as Alignment; + + if matches!( + text.horizontal_alignment, + Alignment::Aligned | Alignment::Fit + ) { + if let Some(end) = text.alignment_point.filter(|end| { + (*end - text.insertion_point).length_squared() > 1.0e-18 + }) { + return Some([text.insertion_point, end]); + } + } + let length = text.height.abs().max(1.0); + Some([ + text.insertion_point, + text.insertion_point + + Vector3::new(text.rotation.cos(), text.rotation.sin(), 0.0) * length, + ]) + } + (acadrust::EntityType::MText(text), DirectionalAxis::TextBaseline) => { + let length = text.height.abs().max(1.0); + Some([ + text.insertion_point, + text.insertion_point + + Vector3::new(text.rotation.cos(), text.rotation.sin(), 0.0) * length, + ]) + } + (acadrust::EntityType::Ellipse(ellipse), axis) => { + let major_length = ellipse.major_axis.length(); + if major_length <= 1.0e-12 { + return None; + } + let vector = match axis { + DirectionalAxis::EllipseMajor => ellipse.major_axis, + DirectionalAxis::EllipseMinor => { + let major = ellipse.major_axis / major_length; + let normal = ellipse.normal.normalize(); + Vector3::new( + normal.y * major.z - normal.z * major.y, + normal.z * major.x - normal.x * major.z, + normal.x * major.y - normal.y * major.x, + ) * (major_length * ellipse.minor_axis_ratio) + } + DirectionalAxis::TextBaseline => return None, + }; + (vector.length_squared() > 1.0e-24).then_some([ellipse.center, ellipse.center + vector]) + } + _ => None, + } +} + +/// The axis a lone Horizontal/Vertical reference — or two points on one +/// entity — turns onto its datum: the entity, the anchor that stays, the +/// axis end that moves, and for a polyline the vertex to move instead of +/// turning the whole entity. +pub(crate) fn axis_alignment_target( + document: &acadrust::CadDocument, + refs: &[ParametricRef], +) -> Option<(Handle, Vector3, Vector3, Option)> { + let (handle, start_marker, end_marker) = match refs { + [reference] => { + let entity = document.get_entity(reference.entity)?; + if reference.directional_axis().is_some() { + let [start, end] = directional_axis_endpoints(entity, *reference)?; + return Some((reference.entity, start, end, None)); + } + let index = reference.segment_index().map_or(0, |index| index as i32); + (reference.entity, index, index + 1) + } + [first, second] if first.entity == second.entity => { + (first.entity, first.marker?, second.marker?) + } + _ => return None, + }; + let entity = document.get_entity(handle)?; + let start = resolve_point(entity, start_marker)?; + let end = resolve_point(entity, end_marker)?; + let vertex = matches!( + entity, + acadrust::EntityType::LwPolyline(_) | acadrust::EntityType::Polyline2D(_) + ) + .then(|| usize::try_from(end_marker).ok()) + .flatten(); + Some((handle, start, end, vertex)) +} + +/// Moves one polyline vertex in its plane; `false` for any other entity. +pub(crate) fn set_polyline_vertex( + entity: &mut acadrust::EntityType, + index: usize, + x: f64, + y: f64, +) -> bool { + match entity { + acadrust::EntityType::LwPolyline(polyline) => polyline + .vertices + .get_mut(index) + .map(|vertex| { + vertex.location.x = x; + vertex.location.y = y; + }) + .is_some(), + acadrust::EntityType::Polyline2D(polyline) => polyline + .vertices + .get_mut(index) + .map(|vertex| { + vertex.location.x = x; + vertex.location.y = y; + }) + .is_some(), + _ => false, + } +} + +/// The size an Equal relation copies. +#[derive(Clone, Copy)] +pub(crate) enum EqualSize { + /// A line's or a straight polyline segment's length. + Length(f64), + /// A circle's or an arc's radius. + Radius(f64), +} + +pub(crate) fn equal_size( + document: &acadrust::CadDocument, + reference: ParametricRef, +) -> Option { + let entity = document.get_entity(reference.entity)?; + match entity { + acadrust::EntityType::Circle(circle) if reference.marker.is_none() => { + Some(EqualSize::Radius(circle.radius)) + } + acadrust::EntityType::Arc(arc) if reference.marker.is_none() => { + Some(EqualSize::Radius(arc.radius)) + } + acadrust::EntityType::Line(_) + | acadrust::EntityType::LwPolyline(_) + | acadrust::EntityType::Polyline2D(_) => { + let index = reference.segment_index().map_or(0, |index| index as i32); + let start = resolve_point(entity, index)?; + let end = resolve_point(entity, index + 1)?; + let length = (end - start).length(); + (length > 1.0e-12).then_some(EqualSize::Length(length)) + } + _ => None, + } +} + +/// `follower` resized to `first`'s size the way the reference does it: a +/// line or segment keeps its start and direction and only its end moves, +/// a circle or arc keeps its center and takes the radius. `None` when the +/// two do not share a size kind. +pub(crate) fn equal_size_follower( + document: &acadrust::CadDocument, + first: ParametricRef, + follower: ParametricRef, +) -> Option { + let size = equal_size(document, first)?; + let original = document.get_entity(follower.entity)?; + let mut entity = original.clone(); + match size { + EqualSize::Radius(radius) if follower.marker.is_none() => match &mut entity { + acadrust::EntityType::Circle(circle) => circle.radius = radius, + acadrust::EntityType::Arc(arc) => arc.radius = radius, + _ => return None, + }, + EqualSize::Length(length) => { + let index = follower.segment_index().map_or(0, |index| index as i32); + let start = resolve_point(original, index)?; + let end = resolve_point(original, index + 1)?; + let current = (end - start).length(); + if current <= 1.0e-12 { + return None; + } + let scale = length / current; + let x = start.x + (end.x - start.x) * scale; + let y = start.y + (end.y - start.y) * scale; + if matches!( + original, + acadrust::EntityType::LwPolyline(_) | acadrust::EntityType::Polyline2D(_) + ) { + if !set_polyline_vertex(&mut entity, index as usize + 1, x, y) { + return None; + } + } else if let (acadrust::EntityType::Line(line), None) = (&mut entity, follower.marker) + { + line.end.x = x; + line.end.y = y; + } else { + return None; + } + } + _ => return None, + } + Some(entity) +} + +/// Grabbed points are exact kernel inputs; the solver anchors the remaining +/// endpoint coordinates according to the line's directional constraints. +pub(crate) fn grip_solve_anchor_refs( entity: &acadrust::EntityType, handle: Handle, grip_id: usize, -) -> Option { +) -> Vec { match entity { acadrust::EntityType::Line(_) if grip_id <= 1 => { - Some(ParametricRef::point(handle, grip_id as i32)) + vec![ParametricRef::point(handle, grip_id as i32)] } acadrust::EntityType::LwPolyline(polyline) if grip_id < polyline.vertices.len() => { - Some(ParametricRef::point(handle, grip_id as i32)) + vec![ParametricRef::point(handle, grip_id as i32)] + } + acadrust::EntityType::LwPolyline(polyline) => { + let segment = grip_id - polyline.vertices.len(); + if polyline.vertices.get(segment).is_some_and(|vertex| vertex.bulge.abs() < 1e-9) + && (segment + 1 < polyline.vertices.len() || polyline.is_closed) + { + vec![ParametricRef::segment(handle, segment)] + } else { + Vec::new() + } } acadrust::EntityType::Polyline2D(polyline) if grip_id < polyline.vertices.len() => { - Some(ParametricRef::point(handle, grip_id as i32)) + vec![ParametricRef::point(handle, grip_id as i32)] } acadrust::EntityType::Arc(_) => match grip_id { - 0 => Some(ParametricRef::center(handle)), - 1 => Some(ParametricRef::point(handle, 0)), - 2 => Some(ParametricRef::point(handle, 1)), - _ => None, + 0 => vec![ParametricRef::center(handle)], + 1..=3 => vec![ParametricRef::whole(handle)], + _ => Vec::new(), + }, + acadrust::EntityType::Circle(_) => match grip_id { + 0 => vec![ParametricRef::center(handle)], + 1..=4 => vec![ParametricRef::whole(handle)], + _ => Vec::new(), + }, + acadrust::EntityType::Ellipse(_) => match grip_id { + 0 => vec![ParametricRef::center(handle)], + 1..=6 => vec![ParametricRef::whole(handle)], + _ => Vec::new(), }, - acadrust::EntityType::Circle(_) if grip_id == 0 => { - Some(ParametricRef::center(handle)) - } acadrust::EntityType::Point(_) | acadrust::EntityType::Insert(_) | acadrust::EntityType::Text(_) | acadrust::EntityType::MText(_) if grip_id == 0 => { - Some(ParametricRef::point(handle, 0)) + vec![ParametricRef::point(handle, 0)] } - _ => None, + _ => Vec::new(), } } @@ -176,11 +456,11 @@ pub enum ConstraintKind { /// For the supported entity types, equal curvature is already covered /// by the circle/circle radius branch of `Equal`. EqualDistance, - /// Two circles/arcs are mirror images of each other across a line — - /// `refs`: `[center(a), center(b), whole(mirror_line)]`. Scoped to the - /// circle-center-pair case (unambiguous with whole-entity selection); - /// point-symmetry about a point, and symmetry between two lines, aren't - /// modeled. + /// Two points or compatible curves are symmetric across a line. + /// Point mode stores two point refs plus the axis. Object mode stores two + /// whole/segment refs plus the axis and constrains the complete relevant + /// geometry: line direction, circular center/radius, or ellipse + /// center/axes. Symmetric, /// A circle/arc's diameter (twice `Radius`'s target) — `refs`: /// `[whole(circle_or_arc)]`. Same DWG class as `Radius` @@ -261,6 +541,10 @@ pub struct ParametricConstraint { /// identifies the direction line for the line-relative modes. pub(crate) distance_direction_type: u8, pub(crate) distance_direction: Option, + /// World-space datum direction captured for Horizontal/Vertical. Native + /// files store the same vector on the connected constrained datum line. + /// `None` keeps legacy world-X/world-Y behavior. + pub(crate) axis_direction: Option, /// Which of the four directed sectors an angular constraint measures. pub(crate) angle_sector: u8, } @@ -348,11 +632,71 @@ impl ParametricConstraintSet { rigid_points: Vec::new(), distance_direction_type: 0, distance_direction: None, + axis_direction: None, angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, }); id } + /// Appends a Horizontal/Vertical constraint tied to an explicit datum + /// direction rather than silently interpreting it in world coordinates. + pub fn add_axis_constraint( + &mut self, + kind: ConstraintKind, + refs: Vec, + direction: Vector3, + ) -> ConstraintId { + let fallback = if kind == ConstraintKind::Vertical { + Vector3::UNIT_Y + } else { + Vector3::UNIT_X + }; + let direction = if direction.length_squared() > 1.0e-24 { + direction.normalize() + } else { + fallback + }; + let id = self.add(kind, refs, None); + if let Some(constraint) = self.constraints.last_mut() { + constraint.axis_direction = Some(direction); + } + id + } + + pub fn contains_axis_constraint( + &self, + kind: ConstraintKind, + refs: &[ParametricRef], + direction: Vector3, + ) -> bool { + let fallback = if kind == ConstraintKind::Vertical { + Vector3::UNIT_Y + } else { + Vector3::UNIT_X + }; + let direction = if direction.length_squared() > 1.0e-24 { + direction.normalize() + } else { + fallback + }; + self.constraints.iter().any(|constraint| { + if !constraint.enabled || constraint.kind != kind { + return false; + } + let same_refs = constraint.refs == refs + || (refs.len() == 2 + && constraint.refs.len() == 2 + && constraint.refs[0] == refs[1] + && constraint.refs[1] == refs[0]); + if !same_refs { + return false; + } + let existing = constraint.axis_direction.unwrap_or(fallback); + existing.length_squared() > 1.0e-24 + && existing.normalize().dot(&direction).abs() >= 1.0 - 1.0e-10 + }) + } + /// Removes a constraint by id. Returns whether one was actually removed. pub fn remove(&mut self, id: ConstraintId) -> bool { let before = self.constraints.len(); @@ -391,11 +735,33 @@ impl ParametricConstraintSet { /// endpoint's equivalent of `dimension_assoc::resolve_reference`, restricted /// to the marker conventions constraint endpoints actually use (whole-entity /// `None`, an ordinary `source_points()` index, the `-3` center case, or a -/// bounded curve/segment midpoint). +/// bounded curve/segment midpoint, or a curved polyline-segment center). /// /// Solver-side registration reads raw entity fields directly. This helper is /// for UI-side consumers that need the current world-space position. +/// The single addressable point (marker `0`) of a point-like entity: the +/// node, insertion, or table origin the solver registers as its +/// `EntityGeom::Point` / text baseline start. `None` for curve entities, +/// whose marker `0` is a `source_points()` endpoint. +pub(crate) fn insertion_point(entity: &acadrust::EntityType) -> Option { + match entity { + acadrust::EntityType::Point(point) => Some(point.location), + acadrust::EntityType::Insert(insert) => Some(insert.insert_point), + acadrust::EntityType::Text(text) => Some(text.insertion_point), + acadrust::EntityType::MText(text) => Some(text.insertion_point), + acadrust::EntityType::AttributeDefinition(attribute) => Some(attribute.insertion_point), + acadrust::EntityType::AttributeEntity(attribute) => Some(attribute.insertion_point), + acadrust::EntityType::Table(table) => Some(table.insertion_point), + _ => None, + } +} + pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Option { + if marker == 0 { + if let Some(point) = insertion_point(entity) { + return Some(point); + } + } if marker == -3 { return match entity { acadrust::EntityType::Circle(circle) => Some(circle.center_wcs()), @@ -412,12 +778,20 @@ pub(crate) fn resolve_point(entity: &acadrust::EntityType, marker: i32) -> Optio let point = curve.point_at(0.5); return Some(Vector3::new(point[0], point[1], point[2])); } - let segment = ParametricRef { + let reference = ParametricRef { entity: Handle::NULL, marker: Some(marker), + }; + if let Some(segment) = reference.segment_center_index() { + let planar = crate::entities::curve::entity_curve(entity)?; + let curve = planar.curve.segments().into_iter().nth(segment)?; + let cadkernel::geom2d::Curve::Arc(arc) = curve else { + return None; + }; + let point = planar.plane.point_at(arc.centre); + return Some(Vector3::new(point[0], point[1], point[2])); } - .segment_midpoint_index(); - if let Some(segment) = segment { + if let Some(segment) = reference.segment_midpoint_index() { let planar = crate::entities::curve::entity_curve(entity)?; let curve = planar.curve.segments().into_iter().nth(segment)?; let point = planar.plane.point_at(curve.point_at(0.5)); @@ -445,6 +819,12 @@ pub(crate) fn parametric_point_candidates( .enumerate() .map(|(marker, point)| (marker as i32, point)) .collect(); + // A node / insertion point is a constraint point too (the reference + // fixes a POINT via NODe and TEXT via INSert), and these entities have + // no `source_points()` to collide with marker `0`. + if let Some(point) = insertion_point(entity) { + points.push((0, point)); + } match entity { acadrust::EntityType::Circle(circle) => points.push((-3, circle.center_wcs())), acadrust::EntityType::Arc(arc) => points.push((-3, arc.center_wcs())), @@ -575,6 +955,58 @@ pub(crate) fn parametric_curve_ref_for_pick( } impl ConstraintKind { + /// Per-kind visibility bit used by CONSTRAINTBARMODE for the standard + /// geometric-constraint family. Helper relations which have no standard + /// bit stay visible under the normal display policy. + pub const fn bar_mode_bit(self) -> Option { + match self { + Self::Horizontal => Some(1), + Self::Vertical => Some(2), + Self::Perpendicular => Some(4), + Self::Parallel => Some(8), + Self::Tangent => Some(16), + Self::Smooth => Some(32), + Self::Coincident => Some(64), + Self::Concentric => Some(128), + Self::Colinear => Some(256), + Self::Symmetric => Some(512), + Self::Equal => Some(1024), + Self::Fixed => Some(2048), + _ => None, + } + } + + pub const fn label(self) -> &'static str { + match self { + Self::Coincident => "Coincident", + Self::Horizontal => "Horizontal", + Self::Vertical => "Vertical", + Self::Parallel => "Parallel", + Self::Perpendicular => "Perpendicular", + Self::Equal => "Equal", + Self::Distance => "Distance", + Self::Angle => "Angle", + Self::Angle3Point => "3-point angle", + Self::Radius => "Radius", + Self::Tangent => "Tangent", + Self::Smooth => "Smooth", + Self::Concentric => "Concentric", + Self::CenterPoint => "Center point", + Self::Colinear => "Collinear", + Self::Midpoint => "Midpoint", + Self::Fixed => "Fixed", + Self::PointOnCurve => "Point on curve", + Self::EqualDistance => "Equal distance", + Self::Symmetric => "Symmetric", + Self::Diameter => "Diameter", + Self::DistanceX => "Horizontal distance", + Self::DistanceY => "Vertical distance", + Self::DistanceDirected => "Directed distance", + Self::Normal => "Normal", + Self::RigidSet => "Rigid set", + } + } + /// The short symbol a constraint glyph shows — matches the existing /// ribbon icons (`crate::modules::parametric::{tools,value}`) for /// the kinds that have a one-click button, so the same glyph means the @@ -611,6 +1043,36 @@ impl ConstraintKind { } } +/// Fixed draws a padlock (`src/ui/overlay.rs`), the way the reference bar +/// does: the plain symbol for a held curve or segment, this variant when one +/// addressable point is held (white lock with the point marker). +pub(crate) const FIXED_POINT_GLYPH: &str = "F·"; + +pub(crate) fn fixed_glyph_label(constraint: &ParametricConstraint) -> &'static str { + let point = constraint + .refs + .first() + .is_some_and(|reference| reference.marker.is_some() && reference.segment_index().is_none()); + if point { + FIXED_POINT_GLYPH + } else { + ConstraintKind::Fixed.glyph_symbol() + } +} + +/// Vertical draws the reference bar's axis mark (`src/ui/overlay.rs`): the +/// plain symbol for an object, this variant — with the point marker — for a +/// two-point relation. +pub(crate) const VERTICAL_POINTS_GLYPH: &str = "│·"; + +pub(crate) fn vertical_glyph_label(constraint: &ParametricConstraint) -> &'static str { + if constraint.refs.len() == 2 { + VERTICAL_POINTS_GLYPH + } else { + ConstraintKind::Vertical.glyph_symbol() + } +} + /// The full glyph text for one constraint: its symbol, plus the driving /// value for a dimensional kind (Distance/Angle/Radius). pub(crate) fn glyph_label(constraint: &ParametricConstraint) -> String { @@ -640,7 +1102,13 @@ pub(crate) fn glyph_placement( document: &acadrust::CadDocument, constraint: &ParametricConstraint, ) -> Option<(Vector3, Vector3)> { - let r = constraint.refs.first()?; + glyph_placements(document, constraint).into_iter().next() +} + +fn glyph_placement_for_reference( + document: &acadrust::CadDocument, + r: ParametricRef, +) -> Option<(Vector3, Vector3)> { let entity = document.get_entity(r.entity)?; let line_midpoint = |line: &acadrust::entities::Line| { Vector3::new( @@ -649,12 +1117,38 @@ pub(crate) fn glyph_placement( (line.start.z + line.end.z) * 0.5, ) }; - let line_normal = |line: &acadrust::entities::Line| { - let direction = Vector3::new(-(line.end.y - line.start.y), line.end.x - line.start.x, 0.0); + let segment_normal = |start: Vector3, end: Vector3| { + let direction = Vector3::new(-(end.y - start.y), end.x - start.x, 0.0); (direction.length_squared() > 1e-24) .then_some(direction) .unwrap_or(Vector3::UNIT_Y) }; + let line_normal = |line: &acadrust::entities::Line| segment_normal(line.start, line.end); + if r.directional_axis().is_some() { + let [start, end] = directional_axis_endpoints(entity, r)?; + let anchor = (start + end) * 0.5; + return Some((anchor, segment_normal(start, end))); + } + if let Some(segment) = r.segment_center_index() { + let planar = crate::entities::curve::entity_curve(entity)?; + let curve = planar.curve.segments().into_iter().nth(segment)?; + let on_arc = planar.plane.point_at(curve.point_at(0.5)); + let cadkernel::geom2d::Curve::Arc(arc) = curve else { + return None; + }; + let center = planar.plane.point_at(arc.centre); + let center = Vector3::new(center[0], center[1], center[2]); + let on_arc = Vector3::new(on_arc[0], on_arc[1], on_arc[2]); + return Some((on_arc, on_arc - center)); + } + if let Some(segment) = r.segment_index() { + let anchor = resolve_point( + entity, + POLYLINE_SEGMENT_MIDPOINT_MARKER_BASE - segment as i32, + )?; + let [start, end] = constraint_segment_endpoints(document, r)?; + return Some((anchor, segment_normal(start, end))); + } match (entity, r.marker) { (acadrust::EntityType::Line(line), None) => Some((line_midpoint(line), line_normal(line))), (acadrust::EntityType::Circle(circle), None | Some(-3)) => { @@ -667,6 +1161,10 @@ pub(crate) fn glyph_placement( let anchor = arc.midpoint_wcs(); Some((anchor, anchor - center)) } + (acadrust::EntityType::Ellipse(ellipse), None | Some(-3)) => { + let anchor = ellipse.center + ellipse.major_axis; + Some((anchor, ellipse.major_axis)) + } (acadrust::EntityType::Line(line), Some(marker)) => { let anchor = resolve_point(entity, marker)?; let direction = anchor - line_midpoint(line); @@ -695,6 +1193,56 @@ pub(crate) fn glyph_placement( } } +fn glyph_placements( + document: &acadrust::CadDocument, + constraint: &ParametricConstraint, +) -> Vec<(Vector3, Vector3)> { + // Relations the reference marks on every object they join. + if matches!( + constraint.kind, + ConstraintKind::Parallel | ConstraintKind::Symmetric | ConstraintKind::Equal + ) { + return constraint + .refs + .iter() + .filter_map(|reference| glyph_placement_for_reference(document, *reference)) + .collect(); + } + + let Some(first) = constraint.refs.first().copied() else { + return Vec::new(); + }; + let fallback = glyph_placement_for_reference(document, first); + if matches!( + constraint.kind, + ConstraintKind::Perpendicular | ConstraintKind::Tangent + ) { + if let [first, second, ..] = constraint.refs.as_slice() { + if let (Some(first_curve), Some(second_curve), Some((anchor, outward))) = ( + constraint_reference_curve_xy(document, *first), + constraint_reference_curve_xy(document, *second), + fallback, + ) { + if let Some(crossing) = cadkernel::geom2d::intersect( + &first_curve, + &second_curve, + cadkernel::geom2d::Tolerance::default(), + ) + .into_iter() + .next() + { + return vec![( + Vector3::new(crossing.point[0], crossing.point[1], anchor.z), + outward, + )]; + } + } + } + } + + fallback.into_iter().collect() +} + /// World-space locations that explain what a hovered constraint acts on. /// Point constraints expose their referenced point directly; curve relations /// expose the contact or intersection that makes the relation visible. @@ -725,7 +1273,15 @@ fn constraint_reference_curve_xy( document: &acadrust::CadDocument, reference: ParametricRef, ) -> Option { - let curve = crate::entities::curve::entity_curve_xy(document.get_entity(reference.entity)?)?; + let entity = document.get_entity(reference.entity)?; + if reference.directional_axis().is_some() { + let [start, end] = directional_axis_endpoints(entity, reference)?; + return Some(cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line { + start: [start.x, start.y], + end: [end.x, end.y], + })); + } + let curve = crate::entities::curve::entity_curve_xy(entity)?; reference .segment_index() .map(|index| curve.segments().into_iter().nth(index)) @@ -761,25 +1317,6 @@ pub(crate) fn constraint_hover_points( } } - if matches!( - constraint.kind, - ConstraintKind::Horizontal | ConstraintKind::Vertical - ) { - for reference in &constraint.refs { - let Some(entity) = document.get_entity(reference.entity) else { - continue; - }; - if let Some(endpoints) = constraint_segment_endpoints(document, *reference) { - for point in endpoints { - push_unique(&mut points, point); - } - } else if matches!(entity, acadrust::EntityType::Line(_)) { - for point in super::dimension_assoc::source_points(entity) { - push_unique(&mut points, point); - } - } - } - } if matches!( constraint.kind, @@ -811,13 +1348,13 @@ pub(crate) fn constraint_hover_points( } impl super::Scene { - /// Expand a set of entities to the full enabled constraint component in - /// one scope. Whole-object translations use this to keep every connected - /// entity's own shape while moving the assembly. + /// Expand through enabled constraints. Whole-object translations follow + /// point connections; grip previews include curve relations as well. pub(crate) fn parametric_connected_handles( &self, scope: ParametricScope, seeds: &[Handle], + include_curve_relations: bool, ) -> Vec { let mut ordered = seeds.to_vec(); let mut found: std::collections::HashSet<_> = seeds.iter().copied().collect(); @@ -828,10 +1365,10 @@ impl super::Scene { let mut added = false; for constraint in set.constraints.iter().filter(|constraint| { constraint.enabled - && matches!( + && (include_curve_relations || matches!( constraint.kind, ConstraintKind::Coincident | ConstraintKind::PointOnCurve - ) + )) }) { if !constraint .refs @@ -866,15 +1403,34 @@ impl super::Scene { &self, scope: ParametricScope, id: ConstraintId, + kind: ConstraintKind, related_entity_selected: bool, display_mode: i16, + bar_mode: i16, ) -> bool { self.is_parametric_constraint_visible(scope, id) + && kind + .bar_mode_bit() + .is_none_or(|bit| bar_mode & bit != 0) && (self.shown_parametric_constraints.contains(&(scope, id)) - || display_mode & 1 != 0 || (display_mode & 2 != 0 && related_entity_selected)) } + /// Apply display bit 1 at creation; bit 2 follows the current selection. + pub fn note_parametric_constraint_applied( + &mut self, + scope: ParametricScope, + id: ConstraintId, + display_mode: i16, + ) { + self.hidden_parametric_constraints.remove(&(scope, id)); + if display_mode & 1 != 0 { + self.shown_parametric_constraints.insert((scope, id)); + } else { + self.shown_parametric_constraints.remove(&(scope, id)); + } + } + pub fn set_parametric_constraint_visibility( &mut self, scope: ParametricScope, @@ -1206,40 +1762,6 @@ impl super::Scene { inferred } - pub fn smooth_constraint_refs(&self, handles: &[Handle]) -> Option> { - let [first, second] = handles else { - return None; - }; - let first_entity = self.document.get_entity(*first)?; - let second_entity = self.document.get_entity(*second)?; - let (spline_handle, spline, target_handle, target) = match (first_entity, second_entity) { - (acadrust::EntityType::Spline(spline), target) => (*first, spline, *second, target), - (target, acadrust::EntityType::Spline(spline)) => (*second, spline, *first, target), - _ => return None, - }; - if spline.flags.closed || spline.flags.periodic { - return None; - } - let spline_points = - super::dimension_assoc::source_points(&acadrust::EntityType::Spline(spline.clone())); - let target_points = super::dimension_assoc::source_points(target); - let spline_ends = [*spline_points.first()?, *spline_points.last()?]; - let target_ends = [*target_points.first()?, *target_points.last()?]; - let mut best = (f64::INFINITY, 0, 0); - for (source_marker, source) in spline_ends.iter().enumerate() { - for (target_marker, target) in target_ends.iter().enumerate() { - let distance = (*source - *target).length_squared(); - if distance < best.0 { - best = (distance, source_marker, target_marker); - } - } - } - Some(vec![ - ParametricRef::point(spline_handle, best.1 as i32), - ParametricRef::point(target_handle, best.2 as i32), - ]) - } - /// The constraint set for `scope`, if one has been created. pub fn parametric_constraint_set( &self, @@ -1290,6 +1812,7 @@ impl super::Scene { vp_size: (f32, f32), show_values: bool, display_mode: i16, + bar_mode: i16, ) -> Vec<( ConstraintId, iced::Point, @@ -1322,36 +1845,29 @@ impl super::Scene { let selected = c .refs .iter() - .any(|reference| self.selected.contains(&reference.entity)); - self.should_display_parametric_constraint(scope, c.id, selected, display_mode) + .any(|reference| self.selected.contains(&reference.entity) + || self.preview_hidden.contains(&reference.entity)); + self.should_display_parametric_constraint( + scope, + c.id, + c.kind, + selected, + display_mode, + bar_mode, + ) }) - .filter_map(|c| { - let (anchor, outward) = glyph_placement(&self.document, c)?; - let screen = crate::scene::pick::grip::project_rte( - glam::DVec3::new(anchor.x, anchor.y, anchor.z), - view_rot, - eye, - bounds, - )?; - let outward_screen = crate::scene::pick::grip::project_rte( - glam::DVec3::new( - anchor.x + outward.x, - anchor.y + outward.y, - anchor.z + outward.z, - ), - view_rot, - eye, - bounds, - )?; - let direction = (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y); - let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); + .flat_map(|c| { let is_conflicting = set.conflicts.iter().any(|(id, _)| *id == c.id); - let label = if show_values { + let base_label = if c.kind == ConstraintKind::Fixed { + fixed_glyph_label(c).to_string() + } else if c.kind == ConstraintKind::Vertical { + vertical_glyph_label(c).to_string() + } else if show_values { glyph_label(c) } else { c.kind.glyph_symbol().to_string() }; - let hover_points = constraint_hover_points(&self.document, c) + let hover_points: Vec = constraint_hover_points(&self.document, c) .into_iter() .filter_map(|hover_point| { let projected = crate::scene::pick::grip::project_rte( @@ -1367,14 +1883,53 @@ impl super::Scene { (point.x.is_finite() && point.y.is_finite()).then_some(point) }) .collect(); - point.x.is_finite().then_some(( - c.id, - point, - direction.to_array(), - label, - is_conflicting, - hover_points, - )) + glyph_placements(&self.document, c) + .into_iter() + .enumerate() + .filter_map(|(index, (anchor, outward))| { + let label = if c.kind == ConstraintKind::Symmetric { + if index == 2 { + "S│".to_string() + } else if c.refs.get(index).is_some_and(|reference| { + reference.marker.is_some() + && reference.segment_index().is_none() + }) { + "S•".to_string() + } else { + "S◇".to_string() + } + } else { + base_label.clone() + }; + let screen = crate::scene::pick::grip::project_rte( + glam::DVec3::new(anchor.x, anchor.y, anchor.z), + view_rot, + eye, + bounds, + )?; + let outward_screen = crate::scene::pick::grip::project_rte( + glam::DVec3::new( + anchor.x + outward.x, + anchor.y + outward.y, + anchor.z + outward.z, + ), + view_rot, + eye, + bounds, + )?; + let direction = + (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y); + let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); + point.x.is_finite().then(|| ( + c.id, + point, + direction.to_array(), + label, + is_conflicting, + hover_points.clone(), + )) + }) + .collect::>() }) .collect() } @@ -1390,6 +1945,7 @@ impl super::Scene { vp_size: (f32, f32), show_values: bool, display_mode: i16, + bar_mode: i16, p: iced::Point, ) -> Option { let placements = self.constraint_glyph_placements_screen( @@ -1397,6 +1953,7 @@ impl super::Scene { vp_size, show_values, display_mode, + bar_mode, ); let glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = placements .iter() @@ -1434,6 +1991,7 @@ impl super::Scene { ConstraintKind, Vec, Option, + Option, )> = Vec::new(); for (scope_index, set) in self.parametric_constraints.iter().enumerate() { for c in &set.constraints { @@ -1448,16 +2006,27 @@ impl super::Scene { marker: r.marker, }) .collect(); - to_add.push((scope_index, c.kind, new_refs, c.driving_param.clone())); + to_add.push(( + scope_index, + c.kind, + new_refs, + c.driving_param.clone(), + c.axis_direction, + )); } } if to_add.is_empty() { return; } let mut touched: Vec = Vec::new(); - for (scope_index, kind, refs, driving_param) in to_add { + for (scope_index, kind, refs, driving_param, axis_direction) in to_add { touched.extend(refs.iter().map(|r| r.entity)); self.parametric_constraints[scope_index].add(kind, refs, driving_param); + self.parametric_constraints[scope_index] + .constraints + .last_mut() + .expect("the copied constraint was just added") + .axis_direction = axis_direction; } touched.sort(); touched.dedup(); @@ -1534,6 +2103,14 @@ mod tests { document .add_entity(acadrust::EntityType::Circle(circle)) .unwrap(); + let mut polyline = acadrust::entities::LwPolyline::from_points(vec![ + acadrust::types::Vector2::new(0.0, 0.0), + acadrust::types::Vector2::new(10.0, 0.0), + ]); + polyline.common.handle = h(3); + document + .add_entity(acadrust::EntityType::LwPolyline(polyline)) + .unwrap(); let constraint = |reference| ParametricConstraint { id: 0, @@ -1545,6 +2122,7 @@ mod tests { rigid_points: Vec::new(), distance_direction_type: 0, distance_direction: None, + axis_direction: None, angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, }; let (anchor, direction) = @@ -1559,6 +2137,93 @@ mod tests { glyph_placement(&document, &constraint(ParametricRef::center(h(2)))).unwrap(); assert_eq!(anchor, Vector3::new(5.0, 0.0, 0.0)); assert_eq!(direction, Vector3::new(5.0, 0.0, 0.0)); + let (anchor, direction) = + glyph_placement(&document, &constraint(ParametricRef::segment(h(3), 0))).unwrap(); + assert_eq!(anchor, Vector3::new(5.0, 0.0, 0.0)); + assert_eq!(direction, Vector3::new(0.0, 10.0, 0.0)); + + let mut tangent_line = acadrust::entities::Line::from_points( + Vector3::new(-10.0, 5.0, 0.0), + Vector3::new(10.0, 5.0, 0.0), + ); + tangent_line.common.handle = h(4); + document.add_entity(acadrust::EntityType::Line(tangent_line)).unwrap(); + let relation = |id, kind, refs| ParametricConstraint { + id, + kind, + refs, + driving_param: None, + enabled: true, + native_origin: None, + rigid_points: Vec::new(), + distance_direction_type: 0, + distance_direction: None, + axis_direction: None, + angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, + }; + let tangent = relation( + 1, + ConstraintKind::Tangent, + vec![ParametricRef::whole(h(4)), ParametricRef::whole(h(2))], + ); + assert_eq!( + glyph_placement(&document, &tangent).unwrap().0, + Vector3::new(0.0, 5.0, 0.0) + ); + + let mut rectangle = acadrust::entities::LwPolyline::from_points(vec![ + acadrust::types::Vector2::new(0.0, 0.0), + acadrust::types::Vector2::new(4.0, 0.0), + acadrust::types::Vector2::new(4.0, 2.0), + acadrust::types::Vector2::new(0.0, 2.0), + ]); + rectangle.common.handle = h(5); + rectangle.is_closed = true; + document.add_entity(acadrust::EntityType::LwPolyline(rectangle)).unwrap(); + let parallel = glyph_placements( + &document, + &relation( + 2, + ConstraintKind::Parallel, + vec![ParametricRef::segment(h(5), 0), ParametricRef::segment(h(5), 2)], + ), + ); + assert_eq!(parallel.len(), 2); + assert_eq!(parallel[0].0, Vector3::new(2.0, 0.0, 0.0)); + assert_eq!(parallel[1].0, Vector3::new(2.0, 2.0, 0.0)); + let perpendicular = relation( + 3, + ConstraintKind::Perpendicular, + vec![ParametricRef::segment(h(5), 3), ParametricRef::segment(h(5), 2)], + ); + assert_eq!( + glyph_placement(&document, &perpendicular).unwrap().0, + Vector3::new(0.0, 2.0, 0.0) + ); + } + + #[test] + fn polyline_constraint_hover_builds_only_the_referenced_segment() { + let mut scene = super::super::Scene::new(); + let handle = scene.add_entity(acadrust::EntityType::LwPolyline( + acadrust::entities::LwPolyline::from_points(vec![ + acadrust::types::Vector2::new(0.0, 0.0), + acadrust::types::Vector2::new(4.0, 0.0), + acadrust::types::Vector2::new(4.0, 2.0), + ]), + )); + + scene.set_constraint_hover_highlights(&[ParametricRef::segment(handle, 1)]); + + assert!(scene.constraint_hover_highlights.is_empty()); + assert_eq!(scene.constraint_hover_wires.len(), 1); + let wire = &scene.constraint_hover_wires[0]; + let points: Vec<_> = wire.points.iter().zip(&wire.points_low).map(|(high, low)| [ + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ]).collect(); + assert_eq!(points, vec![[4.0, 0.0, 0.0], [4.0, 2.0, 0.0]]); } #[test] @@ -1697,18 +2362,18 @@ mod tests { )); assert_eq!( - driven_ref_for_grip(&arc, handle, 0), - Some(ParametricRef::center(handle)) + grip_solve_anchor_refs(&arc, handle, 0), + vec![ParametricRef::center(handle)] ); assert_eq!( - driven_ref_for_grip(&arc, handle, 1), - Some(ParametricRef::point(handle, 0)) + grip_solve_anchor_refs(&arc, handle, 1), + vec![ParametricRef::point(handle, 0)] ); assert_eq!( - driven_ref_for_grip(&arc, handle, 2), - Some(ParametricRef::point(handle, 1)) + grip_solve_anchor_refs(&arc, handle, 2), + vec![ParametricRef::point(handle, 1)] ); - assert_eq!(driven_ref_for_grip(&arc, handle, 3), None); + assert_eq!(grip_solve_anchor_refs(&arc, handle, 3), Vec::new()); } #[test] @@ -1873,6 +2538,45 @@ mod tests { assert!(scene.is_parametric_constraint_visible(ParametricScope::ModelSpace, dimensional)); } + #[test] + fn constraint_display_distinguishes_loaded_created_and_explicit_visibility() { + let mut scene = super::super::Scene::new(); + let scope = ParametricScope::ModelSpace; + let id = scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(h(1))], + None, + ); + assert!(!scene.should_display_parametric_constraint( + scope, id, ConstraintKind::Horizontal, false, 3, 4095 + )); + assert!(scene.should_display_parametric_constraint( + scope, id, ConstraintKind::Horizontal, true, 2, 4095 + )); + assert!(!scene.should_display_parametric_constraint( + scope, id, ConstraintKind::Horizontal, true, 1, 4095 + )); + scene.note_parametric_constraint_applied(scope, id, 1); + assert!(scene.should_display_parametric_constraint( + scope, id, ConstraintKind::Horizontal, false, 0, 4095 + )); + scene.set_parametric_constraint_visibility(scope, None, false, false); + assert!(!scene.should_display_parametric_constraint( + scope, id, ConstraintKind::Horizontal, true, 3, 4095 + )); + scene.set_parametric_constraint_visibility(scope, None, false, true); + assert!(scene.should_display_parametric_constraint( + scope, id, ConstraintKind::Horizontal, false, 0, 4095 + )); + scene.note_parametric_constraint_applied(scope, id, 0); + assert!(!scene.should_display_parametric_constraint( + scope, id, ConstraintKind::Horizontal, false, 3, 4095 + )); + assert!(scene.should_display_parametric_constraint( + scope, id, ConstraintKind::Horizontal, true, 2, 4095 + )); + } + #[test] fn hidden_constraint_stays_hidden_when_related_entity_is_selected() { let mut scene = super::super::Scene::new(); @@ -1884,7 +2588,9 @@ mod tests { ); scene.hidden_parametric_constraints.insert((scope, id)); - assert!(!scene.should_display_parametric_constraint(scope, id, true, 2)); + assert!(!scene.should_display_parametric_constraint( + scope, id, ConstraintKind::Horizontal, true, 2, 4095 + )); } #[test] @@ -1955,8 +2661,41 @@ mod tests { ); assert_eq!( - scene.parametric_connected_handles(ParametricScope::ModelSpace, &[h(1)]), + scene.parametric_connected_handles(ParametricScope::ModelSpace, &[h(1)], false), vec![h(1), h(2), h(3)] ); } + + #[test] + fn duplicating_an_axis_constraint_keeps_its_direction() { + let mut scene = super::super::Scene::new(); + let line = |y| { + acadrust::EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(0.0, y, 0.0), + Vector3::new(4.0, y + 1.0, 0.0), + )) + }; + let source = scene.add_entity(line(0.0)); + let copied = scene.add_entity(line(10.0)); + let direction = Vector3::new(3.0, 4.0, 0.0); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add_axis_constraint( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(source)], + direction, + ); + let mut handle_map = rustc_hash::FxHashMap::default(); + handle_map.insert(source, copied); + + scene.duplicate_parametric_constraints_for(&handle_map); + + let constraints = &scene + .parametric_constraint_set(ParametricScope::ModelSpace) + .unwrap() + .constraints; + assert_eq!(constraints.len(), 2); + assert_eq!(constraints[1].refs, vec![ParametricRef::whole(copied)]); + assert_eq!(constraints[1].axis_direction, Some(direction.normalize())); + } } diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 535faf3b1..a0c7a1792 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -17,7 +17,7 @@ use cadkernel_constraints::constraints::curve_generic::{BoundedArcValue, CurveVa use cadkernel_constraints::constraints::point_line::{ CenterOfGravity, Difference, Equal, EqualLineLength, MidpointOnLine, P2PDistance, Parallel as ParallelConstraint, Perpendicular as PerpendicularConstraint, PointOnLine, - ProjectedDistance, ProjectedDistanceAlongLine, + ProjectedDistance, ProjectedDistanceAlongLine, SymmetricLineDirections, }; use cadkernel_constraints::constraints::Constraint; use cadkernel_constraints::geo::{ @@ -28,6 +28,7 @@ use cadkernel_constraints::solvers::dogleg::solve_dl; use cadkernel_constraints::solvers::lm::solve_lm; use cadkernel_constraints::solvers::SolveStatus; use cadkernel_constraints::system::System; +use cadkernel_constraints::util::ParamId; use super::named_parameters::ParameterTable; use super::parametric_constraints::{ @@ -41,6 +42,10 @@ use super::{ChangeKind, Scene}; #[derive(Clone)] enum EntityGeom { Point(GPoint), + /// A text insertion point plus its displayed baseline direction. The + /// second point is a solver guide only; write-back converts it to the + /// entity's rotation/alignment fields. + TextLine(GLine), Line(GLine), Ray(GLine), XLine(GLine), @@ -78,6 +83,7 @@ impl EntityGeom { fn point_for_marker(&self, marker: i32) -> Option { match (self, marker) { (EntityGeom::Point(point), 0) => Some(*point), + (EntityGeom::TextLine(line), 0) => Some(line.p1), (EntityGeom::Line(l), 0) => Some(l.p1), (EntityGeom::Line(l), 1) => Some(l.p2), (EntityGeom::Ray(l), 0) => Some(l.p1), @@ -142,7 +148,10 @@ fn as_circle_or_line(g: EntityGeom, reference: ParametricRef) -> CircleOrLine { match g { EntityGeom::Circle(c) => CircleOrLine::Circle(c), EntityGeom::Arc(a) => CircleOrLine::Circle(a.circle), - EntityGeom::Line(l) | EntityGeom::Ray(l) | EntityGeom::XLine(l) => CircleOrLine::Line(l), + EntityGeom::TextLine(l) + | EntityGeom::Line(l) + | EntityGeom::Ray(l) + | EntityGeom::XLine(l) => CircleOrLine::Line(l), EntityGeom::Ellipse(ellipse) => CircleOrLine::Ellipse(ellipse), EntityGeom::Point(_) => CircleOrLine::Other, EntityGeom::Spline { .. } => CircleOrLine::Other, @@ -176,6 +185,16 @@ fn register_entity( handle: Handle, ) -> Option { let entity = document.get_entity(handle)?; + register_entity_geom(sys, handle, entity) +} + +/// `register_entity` for an entity value that isn't the document's — the +/// retained pre-edit original a persistent `Fixed` pins to. +fn register_entity_geom( + sys: &mut System, + handle: Handle, + entity: &EntityType, +) -> Option { match entity { EntityType::Point(point) => Some(EntityGeom::Point(GPoint::new( sys.add_param(point.location.x, false), @@ -185,14 +204,20 @@ fn register_entity( sys.add_param(insert.insert_point.x, false), sys.add_param(insert.insert_point.y, false), ))), - EntityType::Text(text) => Some(EntityGeom::Point(GPoint::new( - sys.add_param(text.insertion_point.x, false), - sys.add_param(text.insertion_point.y, false), - ))), - EntityType::MText(text) => Some(EntityGeom::Point(GPoint::new( - sys.add_param(text.insertion_point.x, false), - sys.add_param(text.insertion_point.y, false), - ))), + EntityType::Text(_) | EntityType::MText(_) => { + let reference = ParametricRef::text_baseline(handle); + let [start, end] = super::parametric_constraints::directional_axis_endpoints( + entity, + reference, + )?; + Some(EntityGeom::TextLine(GLine { + p1: GPoint::new( + sys.add_param(start.x, false), + sys.add_param(start.y, false), + ), + p2: GPoint::new(sys.add_param(end.x, false), sys.add_param(end.y, false)), + })) + } EntityType::AttributeDefinition(attribute) => Some(EntityGeom::Point(GPoint::new( sys.add_param(attribute.insertion_point.x, false), sys.add_param(attribute.insertion_point.y, false), @@ -499,6 +524,12 @@ fn resolve_constraint_point( if let Some(point) = geometry.point_for_marker(marker) { return Some(point); } + if let Some(segment) = reference + .segment_center_index() + .and_then(|index| geometry.arc_segment(index)) + { + return Some(segment.arc.circle.center); + } let midpoint_line = reference .segment_midpoint_index() @@ -780,6 +811,175 @@ fn refs_share_supported_plane(document: &acadrust::CadDocument, refs: &[Parametr plane_z.is_some() } +fn concentric_reference_geometry( + document: &acadrust::CadDocument, + reference: ParametricRef, +) -> Option<(Vector3, cadkernel::space::Plane)> { + let marker = reference.marker?; + if marker != -3 && reference.segment_center_index().is_none() { + return None; + } + let entity = document.get_entity(reference.entity)?; + let center = super::parametric_constraints::resolve_point(entity, marker)?; + let plane = crate::entities::curve::entity_curve(entity)?.plane; + Some((center, plane)) +} + +fn concentric_refs_share_plane( + document: &acadrust::CadDocument, + refs: &[ParametricRef], +) -> bool { + let [first, second] = refs else { + return false; + }; + if first == second { + return false; + } + let Some((first_center, first_plane)) = concentric_reference_geometry(document, *first) else { + return false; + }; + let Some((second_center, second_plane)) = concentric_reference_geometry(document, *second) + else { + return false; + }; + let (Some(first_normal), Some(second_normal)) = (first_plane.normal(), second_plane.normal()) + else { + return false; + }; + let parallel = (first_normal[0] * second_normal[0] + + first_normal[1] * second_normal[1] + + first_normal[2] * second_normal[2]) + .abs() + >= 1.0 - 1.0e-9; + let points = [ + first_plane.origin, + second_plane.origin, + [first_center.x, first_center.y, first_center.z], + [second_center.x, second_center.y, second_center.z], + ]; + let tolerance = cadkernel::space::coplanarity_tolerance(&points); + parallel + && first_plane + .distance_to(second_plane.origin) + .is_some_and(|distance| distance.abs() <= tolerance) + && first_plane + .distance_to([second_center.x, second_center.y, second_center.z]) + .is_some_and(|distance| distance.abs() <= tolerance) +} + +fn ref_is_driven(driven: &[ParametricRef], reference: ParametricRef) -> bool { + driven.iter().any(|candidate| { + candidate.entity == reference.entity + && (candidate.marker == reference.marker || candidate.marker.is_none()) + }) +} + +fn apply_spatial_concentric_constraints( + document: &acadrust::CadDocument, + set: &ParametricConstraintSet, + driven_refs: &[ParametricRef], + initial_fixed_refs: &[ParametricRef], + results: &mut Vec<(Handle, EntityType)>, +) { + let current_entity = |handle: Handle, results: &[(Handle, EntityType)]| { + results + .iter() + .rev() + .find(|(candidate, _)| *candidate == handle) + .map(|(_, entity)| entity.clone()) + .or_else(|| document.get_entity(handle).cloned()) + }; + + let mut handles = Vec::::new(); + let mut relations = Vec::new(); + let mut fixed = Vec::new(); + let mut points = Vec::new(); + for constraint in set.constraints.iter().filter(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Concentric + && !refs_share_supported_plane(document, &constraint.refs) + && concentric_refs_share_plane(document, &constraint.refs) + }) { + let [first, second] = constraint.refs.as_slice() else { + continue; + }; + if first.entity == second.entity { + continue; + } + let (Some(first_entity), Some(second_entity)) = ( + current_entity(first.entity, results), + current_entity(second.entity, results), + ) else { + continue; + }; + let (Some(first_marker), Some(second_marker)) = (first.marker, second.marker) else { + continue; + }; + let (Some(first_center), Some(second_center)) = ( + super::parametric_constraints::resolve_point(&first_entity, first_marker), + super::parametric_constraints::resolve_point(&second_entity, second_marker), + ) else { + continue; + }; + let first_index = handles + .iter() + .position(|handle| *handle == first.entity) + .unwrap_or_else(|| { + handles.push(first.entity); + handles.len() - 1 + }); + let second_index = handles + .iter() + .position(|handle| *handle == second.entity) + .unwrap_or_else(|| { + handles.push(second.entity); + handles.len() - 1 + }); + let first_point = [first_center.x, first_center.y, first_center.z]; + let second_point = [second_center.x, second_center.y, second_center.z]; + relations.push((first_index, first_point, second_index, second_point)); + points.extend([first_point, second_point]); + if ref_is_driven(driven_refs, *first) || ref_is_driven(initial_fixed_refs, *first) { + fixed.push(first_index); + } + if ref_is_driven(driven_refs, *second) || ref_is_driven(initial_fixed_refs, *second) { + fixed.push(second_index); + } + } + if relations.is_empty() { + return; + } + fixed.sort_unstable(); + fixed.dedup(); + let tolerance = cadkernel::space::coplanarity_tolerance(&points).max(MOVE_EPS); + let Some(translations) = cadkernel::space::solve_rigid_point_coincidence( + handles.len(), + &relations, + &fixed, + tolerance, + ) else { + return; + }; + for (handle, translation) in handles.into_iter().zip(translations) { + let translation = glam::DVec3::from_array(translation); + if translation.length() <= tolerance { + continue; + } + let Some(mut entity) = current_entity(handle, results) else { + continue; + }; + crate::scene::view::dispatch::apply_transform( + &mut entity, + &crate::command::EntityTransform::Translate(translation), + ); + if let Some((_, current)) = results.iter_mut().find(|(candidate, _)| *candidate == handle) { + *current = entity; + } else { + results.push((handle, entity)); + } + } +} + fn resolved_target(params: &ParameterTable, constraint: &ParametricConstraint) -> Option { let value = constraint.driving_param.as_ref()?.resolve(params).ok()?; if !value.is_finite() { @@ -800,27 +1000,135 @@ fn oriented_distance(target: f64, current_projection: f64) -> f64 { } } -/// One [`ParametricConstraint`]'s `cadkernel_constraints` construction: every system-level -/// constraint it contributes (most kinds contribute exactly one; a few — -/// `Coincident`/`Concentric`/`CenterPoint`'s X+Y halves, `Colinear`'s two -/// endpoints, `Midpoint`'s X+Y, `Symmetric`'s midpoint+perpendicular pair, -/// `Fixed`'s per-coordinate pins — contribute more). Empty means it can't be -/// built (an unsupported/not-yet-mapped kind, a dangling ref, a ref shape -/// the kind doesn't expect — e.g. `Parallel` needs two whole-line refs — or, -/// for a dimensional kind, a `driving_param` that fails to resolve). A constraint -/// that can't be built is simply skipped for this solve, not an error: -/// geometry it would have constrained is left alone, matching how a -/// dangling associative-dimension reference degrades today rather than -/// aborting the whole recompute. (A named-parameter *cycle* should be -/// unreachable here — `ParameterTable::set` already refuses to create one — -/// so it gets the same treatment as any other resolve failure rather than a -/// special case.) +fn retained_tangent_side( + document: &acadrust::CadDocument, + retained_before: &HashMap>, + constraint: &ParametricConstraint, +) -> Option { + let [a, b] = constraint.refs.as_slice() else { + return None; + }; + if constraint.kind != ConstraintKind::Tangent + || !retained_before.contains_key(&a.entity) && !retained_before.contains_key(&b.entity) + { + return None; + } + let curve = |reference: ParametricRef| { + let entity = retained_before + .get(&reference.entity) + .map(|entity| entity.as_ref()) + .or_else(|| document.get_entity(reference.entity))?; + let curve = crate::entities::curve::entity_curve_xy(entity)?; + reference + .segment_index() + .map(|index| curve.segments().into_iter().nth(index)) + .unwrap_or(Some(curve)) + }; + let circle_center = |curve: &cadkernel::geom2d::Curve| match curve { + cadkernel::geom2d::Curve::Circle(circle) => Some(circle.centre), + cadkernel::geom2d::Curve::Arc(arc) => Some(arc.centre), + _ => None, + }; + let (a, b) = (curve(*a)?, curve(*b)?); + let (line, center) = a + .as_ray() + .zip(circle_center(&b)) + .or_else(|| b.as_ray().zip(circle_center(&a)))?; + let ([x1, y1], [dx, dy]) = line; + Some(dx * (center[1] - y1) - dy * (center[0] - x1) >= 0.0) +} + +/// A solve result with no extent left — the only way the solver could +/// honour every constraint after an edit that contradicted them. +fn collapsed_by_solve(entity: &EntityType) -> bool { + const EPS: f64 = 1.0e-9; + match entity { + EntityType::Line(line) => (line.end - line.start).length() <= EPS, + EntityType::Circle(circle) => circle.radius <= EPS, + EntityType::Arc(arc) => arc.radius <= EPS, + _ => false, + } +} + +/// The kernel parameters a `Fixed` constraint on `r` holds in place: a +/// polyline segment's endpoints (plus center/radius for an arc segment), one +/// addressable point, or every intrinsic parameter of a whole entity. Arc +/// pins its five intrinsic params only — `start`/`end` are already tied to +/// those by the arc-rules `CurveValue` constraints `solve_scope` adds; +/// Ellipse likewise pins center + radmin, `focus1` being tied to `center` +/// by the ellipse-rules `Difference` constraints. +fn fixed_pin_params( + document: &acadrust::CadDocument, + sys: &mut System, + cache: &mut HashMap, + r: ParametricRef, +) -> Vec { + if let Some(index) = r.segment_index() { + let Some(geometry) = resolve_ref(document, sys, cache, r) else { + return Vec::new(); + }; + if let Some(line) = geometry.line_segment(index) { + return vec![line.p1.x, line.p1.y, line.p2.x, line.p2.y]; + } + return geometry + .arc_segment(index) + .map(|segment| { + let arc = segment.arc; + vec![ + arc.start.x, + arc.start.y, + arc.end.x, + arc.end.y, + arc.circle.center.x, + arc.circle.center.y, + arc.circle.rad, + ] + }) + .unwrap_or_default(); + } + if r.marker.is_some() { + return resolve_constraint_point(document, sys, cache, r) + .map(|point| vec![point.x, point.y]) + .unwrap_or_default(); + } + match resolve_ref(document, sys, cache, r) { + Some(EntityGeom::Point(point)) => vec![point.x, point.y], + Some(EntityGeom::Polyline { points, .. }) => { + points.iter().flat_map(|point| [point.x, point.y]).collect() + } + Some(EntityGeom::TextLine(line)) + | Some(EntityGeom::Line(line)) + | Some(EntityGeom::Ray(line)) + | Some(EntityGeom::XLine(line)) => vec![line.p1.x, line.p1.y, line.p2.x, line.p2.y], + Some(EntityGeom::Circle(circle)) => vec![circle.center.x, circle.center.y, circle.rad], + Some(EntityGeom::Arc(arc)) => vec![ + arc.circle.center.x, + arc.circle.center.y, + arc.circle.rad, + arc.start_angle, + arc.end_angle, + ], + Some(EntityGeom::Ellipse(ellipse)) => { + vec![ellipse.center.x, ellipse.center.y, ellipse.radmin] + } + Some(EntityGeom::Spline { curve, .. }) => { + curve.poles.iter().flat_map(|point| [point.x, point.y]).collect() + } + None => Vec::new(), + } +} + +/// Builds the kernel equations for one constraint. Unsupported references or +/// invalid dimensional targets produce no equations. fn build_constraint( document: &acadrust::CadDocument, sys: &mut System, cache: &mut HashMap, params: &ParameterTable, c: &ParametricConstraint, + tangent_side: Option, + tangent_point: Option, + originals: &HashMap>, ) -> Vec> { if !c.enabled || !refs_share_supported_plane(document, &c.refs) { return Vec::new(); @@ -849,6 +1157,7 @@ fn build_constraint( EntityGeom::Circle(circ) => Some(circ), EntityGeom::Arc(a) => Some(a.circle), EntityGeom::Point(_) + | EntityGeom::TextLine(_) | EntityGeom::Line(_) | EntityGeom::Ray(_) | EntityGeom::XLine(_) @@ -859,6 +1168,37 @@ fn build_constraint( let point_ref = |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| { resolve_constraint_point(document, sys, cache, r) }; + let directional_line = + |sys: &mut System, cache: &mut HashMap<_, _>, r: ParametricRef| { + let geometry = resolve_ref(document, sys, cache, r)?; + let minor_axis = matches!( + r.directional_axis(), + Some(super::parametric_constraints::DirectionalAxis::EllipseMinor) + ); + let line = match geometry { + EntityGeom::TextLine(line) + if matches!( + r.directional_axis(), + Some(super::parametric_constraints::DirectionalAxis::TextBaseline) + ) => line, + EntityGeom::Line(line) | EntityGeom::Ray(line) | EntityGeom::XLine(line) + if r.marker.is_none() => line, + EntityGeom::Polyline { .. } => geometry.line_segment(r.segment_index()?)?, + EntityGeom::Ellipse(ellipse) + if matches!( + r.directional_axis(), + Some( + super::parametric_constraints::DirectionalAxis::EllipseMajor + | super::parametric_constraints::DirectionalAxis::EllipseMinor + ) + ) => GLine { + p1: ellipse.center, + p2: ellipse.focus1, + }, + _ => return None, + }; + Some((line, minor_axis)) + }; let point_on_bounded_arc = |sys: &mut System, point: GPoint, arc: GArc| { let initial = BoundedArcValue::initial_parameter(sys.store(), point, arc); @@ -891,30 +1231,49 @@ fn build_constraint( ConstraintKind::Coincident | ConstraintKind::Concentric | ConstraintKind::CenterPoint => { point_pair_equal(sys, cache, &c.refs) } - ConstraintKind::Horizontal => match c.refs.as_slice() { - [r] => whole_line(sys, cache, *r) - .map(|line| { - vec![Rc::new(Equal::new(line.p1.y, line.p2.y, 1.0)) as Rc] - }) - .unwrap_or_default(), - [a, b] => match (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) { - (Some(a), Some(b)) => vec![Rc::new(Equal::new(a.y, b.y, 1.0))], + ConstraintKind::Horizontal | ConstraintKind::Vertical => { + let fallback = if c.kind == ConstraintKind::Vertical { + Vector3::UNIT_Y + } else { + Vector3::UNIT_X + }; + let direction = c.axis_direction.unwrap_or(fallback); + let length = direction.x.hypot(direction.y); + let (dx, dy) = if length > 1.0e-12 { + (direction.x / length, direction.y / length) + } else { + (fallback.x, fallback.y) + }; + let datum = GLine { + p1: GPoint::new(sys.add_param(0.0, true), sys.add_param(0.0, true)), + p2: GPoint::new(sys.add_param(dx, true), sys.add_param(dy, true)), + }; + match c.refs.as_slice() { + [reference] => directional_line(sys, cache, *reference) + .map(|(line, minor_axis)| { + if minor_axis { + vec![Rc::new(PerpendicularConstraint::new( + sys.store(), + line, + datum, + )) as Rc] + } else { + vec![Rc::new(ParallelConstraint::new(sys.store(), line, datum)) + as Rc] + } + }) + .unwrap_or_default(), + [a, b] => match (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) { + (Some(a), Some(b)) => vec![Rc::new(ParallelConstraint::new( + sys.store(), + GLine { p1: a, p2: b }, + datum, + ))], + _ => Vec::new(), + }, _ => Vec::new(), - }, - _ => Vec::new(), - }, - ConstraintKind::Vertical => match c.refs.as_slice() { - [r] => whole_line(sys, cache, *r) - .map(|line| { - vec![Rc::new(Equal::new(line.p1.x, line.p2.x, 1.0)) as Rc] - }) - .unwrap_or_default(), - [a, b] => match (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) { - (Some(a), Some(b)) => vec![Rc::new(Equal::new(a.x, b.x, 1.0))], - _ => Vec::new(), - }, - _ => Vec::new(), - }, + } + } ConstraintKind::Parallel => { let [a, b] = c.refs.as_slice() else { return Vec::new(); @@ -930,16 +1289,20 @@ fn build_constraint( let [a, b] = c.refs.as_slice() else { return Vec::new(); }; - let (Some(fixed), Some(moving)) = - (whole_line(sys, cache, *a), whole_line(sys, cache, *b)) + let (Some((fixed, fixed_minor)), Some((moving, moving_minor))) = + (directional_line(sys, cache, *a), directional_line(sys, cache, *b)) else { return Vec::new(); }; - vec![Rc::new(PerpendicularConstraint::new( - sys.store(), - moving, - fixed, - ))] + if fixed_minor ^ moving_minor { + vec![Rc::new(ParallelConstraint::new(sys.store(), moving, fixed))] + } else { + vec![Rc::new(PerpendicularConstraint::new( + sys.store(), + moving, + fixed, + ))] + } } ConstraintKind::Equal => { let [a, b] = c.refs.as_slice() else { @@ -1032,7 +1395,10 @@ fn build_constraint( return Vec::new(); }; match geom { - EntityGeom::Line(l) | EntityGeom::Ray(l) | EntityGeom::XLine(l) => { + EntityGeom::TextLine(l) + | EntityGeom::Line(l) + | EntityGeom::Ray(l) + | EntityGeom::XLine(l) => { vec![Rc::new(PointOnLine::new(p, l))] } EntityGeom::Circle(circ) => { @@ -1089,189 +1455,121 @@ fn build_constraint( let [a, b, m] = c.refs.as_slice() else { return Vec::new(); }; - let (Some(pa), Some(pb), Some(mirror)) = ( - point_ref(sys, cache, *a), - point_ref(sys, cache, *b), - whole_line(sys, cache, *m), + let Some(mirror) = whole_line(sys, cache, *m) else { + return Vec::new(); + }; + if let (Some(pa), Some(pb)) = + (point_ref(sys, cache, *a), point_ref(sys, cache, *b)) + { + let pair = GLine { p1: pa, p2: pb }; + return vec![ + Rc::new(MidpointOnLine::new(pair, mirror)), + Rc::new(PerpendicularConstraint::new(sys.store(), pair, mirror)), + ]; + } + let (Some(first), Some(second)) = ( + resolve_ref(document, sys, cache, *a), + resolve_ref(document, sys, cache, *b), ) else { return Vec::new(); }; - let pair = GLine { p1: pa, p2: pb }; - vec![ - Rc::new(MidpointOnLine::new(pair, mirror)), - Rc::new(PerpendicularConstraint::new(sys.store(), pair, mirror)), - ] + match ( + as_circle_or_line(first, *a), + as_circle_or_line(second, *b), + ) { + (CircleOrLine::Line(first), CircleOrLine::Line(second)) => { + vec![Rc::new(SymmetricLineDirections::new( + sys.store(), + first, + second, + mirror, + ))] + } + (CircleOrLine::Circle(first), CircleOrLine::Circle(second)) => { + let centers = GLine { + p1: first.center, + p2: second.center, + }; + vec![ + Rc::new(MidpointOnLine::new(centers, mirror)), + Rc::new(PerpendicularConstraint::new( + sys.store(), + centers, + mirror, + )), + Rc::new(Equal::new(second.rad, first.rad, 1.0)), + ] + } + (CircleOrLine::Ellipse(first), CircleOrLine::Ellipse(second)) => { + let centers = GLine { + p1: first.center, + p2: second.center, + }; + let first_axis = GLine { + p1: first.center, + p2: first.focus1, + }; + let second_axis = GLine { + p1: second.center, + p2: second.focus1, + }; + vec![ + Rc::new(MidpointOnLine::new(centers, mirror)), + Rc::new(PerpendicularConstraint::new( + sys.store(), + centers, + mirror, + )), + Rc::new(SymmetricLineDirections::new( + sys.store(), + first_axis, + second_axis, + mirror, + )), + Rc::new(EqualMajorAxesConic::new( + GConic::Ellipse(first), + GConic::Ellipse(second), + )), + Rc::new(Equal::new(second.radmin, first.radmin, 1.0)), + ] + } + _ => Vec::new(), + } } ConstraintKind::Fixed => { let Some(r) = c.refs.first() else { return Vec::new(); }; - if r.segment_index().is_some() { - let Some(line) = whole_line(sys, cache, *r) else { - return Vec::new(); - }; - let (x1, y1, x2, y2) = { - let store = sys.store(); - ( - store.get(line.p1.x), - store.get(line.p1.y), - store.get(line.p2.x), - store.get(line.p2.y), - ) - }; - return vec![ - Rc::new(Equal::new(line.p1.x, sys.add_param(x1, true), 1.0)), - Rc::new(Equal::new(line.p1.y, sys.add_param(y1, true), 1.0)), - Rc::new(Equal::new(line.p2.x, sys.add_param(x2, true), 1.0)), - Rc::new(Equal::new(line.p2.y, sys.add_param(y2, true), 1.0)), - ]; - } - if r.marker.is_some() { - let Some(point) = point_ref(sys, cache, *r) else { - return Vec::new(); - }; - let (x, y) = { - let store = sys.store(); - (store.get(point.x), store.get(point.y)) - }; - return vec![ - Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)), - Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)), - ]; - } - let Some(geom) = resolve_ref(document, sys, cache, *r) else { - return Vec::new(); - }; - match geom { - EntityGeom::Point(point) => { - let (x, y) = { + let pinned = fixed_pin_params(document, sys, cache, *r); + // A persistent Fixed holds the *pre-edit* geometry: after MOVE / + // STRETCH / a grip drag the entity in `document` is already + // displaced, so the target comes from the caller's retained + // original when there is one — the entity's own live values + // otherwise (a freshly applied Fixed pins where it stands). + let mut targets: Option> = None; + if let Some(original) = originals.get(&r.entity) { + let mut original_cache = HashMap::new(); + if let Some(geometry) = register_entity_geom(sys, r.entity, original.as_ref()) { + original_cache.insert(r.entity, geometry); + let params = fixed_pin_params(document, sys, &mut original_cache, *r); + if params.len() == pinned.len() { let store = sys.store(); - (store.get(point.x), store.get(point.y)) - }; - vec![ - Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)), - Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)), - ] - } - EntityGeom::Polyline { points, .. } => { - let values: Vec<_> = points - .iter() - .map(|point| { - let store = sys.store(); - (point, store.get(point.x), store.get(point.y)) - }) - .collect(); - values - .into_iter() - .flat_map(|(point, x, y)| { - [ - Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)) - as Rc, - Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)) - as Rc, - ] - }) - .collect() - } - EntityGeom::Line(l) | EntityGeom::Ray(l) | EntityGeom::XLine(l) => { - let (x1, y1, x2, y2) = { - let store = sys.store(); - ( - store.get(l.p1.x), - store.get(l.p1.y), - store.get(l.p2.x), - store.get(l.p2.y), - ) - }; - vec![ - Rc::new(Equal::new(l.p1.x, sys.add_param(x1, true), 1.0)), - Rc::new(Equal::new(l.p1.y, sys.add_param(y1, true), 1.0)), - Rc::new(Equal::new(l.p2.x, sys.add_param(x2, true), 1.0)), - Rc::new(Equal::new(l.p2.y, sys.add_param(y2, true), 1.0)), - ] - } - EntityGeom::Circle(circ) => { - let (cx, cy, r) = { - let store = sys.store(); - ( - store.get(circ.center.x), - store.get(circ.center.y), - store.get(circ.rad), - ) - }; - vec![ - Rc::new(Equal::new(circ.center.x, sys.add_param(cx, true), 1.0)), - Rc::new(Equal::new(circ.center.y, sys.add_param(cy, true), 1.0)), - Rc::new(Equal::new(circ.rad, sys.add_param(r, true), 1.0)), - ] - } - // Pinning the 5 intrinsic params (center, radius, both - // angles) is enough — `start`/`end` are already tied to - // those via the arc-rules `CurveValue` constraints - // `solve_scope` adds for every arc, so pinning them too - // would just be redundant. - EntityGeom::Arc(a) => { - let (cx, cy, r, sa, ea) = { - let store = sys.store(); - ( - store.get(a.circle.center.x), - store.get(a.circle.center.y), - store.get(a.circle.rad), - store.get(a.start_angle), - store.get(a.end_angle), - ) - }; - vec![ - Rc::new(Equal::new(a.circle.center.x, sys.add_param(cx, true), 1.0)), - Rc::new(Equal::new(a.circle.center.y, sys.add_param(cy, true), 1.0)), - Rc::new(Equal::new(a.circle.rad, sys.add_param(r, true), 1.0)), - Rc::new(Equal::new(a.start_angle, sys.add_param(sa, true), 1.0)), - Rc::new(Equal::new(a.end_angle, sys.add_param(ea, true), 1.0)), - ] - } - // Pinning center + radmin is enough — `focus1` is already - // tied to `center` by the ellipse-rules `Difference` - // constraints `solve_scope` adds for every registered - // ellipse (see this module's doc comment), so with `center` - // pinned here too, pinning `focus1` again would just be - // redundant (same reasoning as Arc's `Fixed` arm above). - EntityGeom::Ellipse(el) => { - let (cx, cy, b) = { - let store = sys.store(); - ( - store.get(el.center.x), - store.get(el.center.y), - store.get(el.radmin), - ) - }; - vec![ - Rc::new(Equal::new(el.center.x, sys.add_param(cx, true), 1.0)), - Rc::new(Equal::new(el.center.y, sys.add_param(cy, true), 1.0)), - Rc::new(Equal::new(el.radmin, sys.add_param(b, true), 1.0)), - ] - } - EntityGeom::Spline { curve, .. } => { - let values: Vec<_> = curve - .poles - .iter() - .map(|point| { - let store = sys.store(); - (point, store.get(point.x), store.get(point.y)) - }) - .collect(); - values - .into_iter() - .flat_map(|(point, x, y)| { - [ - Rc::new(Equal::new(point.x, sys.add_param(x, true), 1.0)) - as Rc, - Rc::new(Equal::new(point.y, sys.add_param(y, true), 1.0)) - as Rc, - ] - }) - .collect() + targets = Some(params.iter().map(|param| store.get(*param)).collect()); + } } } + let targets = targets.unwrap_or_else(|| { + let store = sys.store(); + pinned.iter().map(|param| store.get(*param)).collect() + }); + pinned + .into_iter() + .zip(targets) + .map(|(param, value)| { + Rc::new(Equal::new(param, sys.add_param(value, true), 1.0)) + as Rc + }) + .collect() } ConstraintKind::Distance => { let [a, b] = c.refs.as_slice() else { @@ -1517,6 +1815,14 @@ fn build_constraint( } (CircleOrLine::Circle(circ), CircleOrLine::Line(line)) | (CircleOrLine::Line(line), CircleOrLine::Circle(circ)) => { + if let Some(point) = tangent_point.and_then(|reference| + resolve_constraint_point(document, sys, cache, reference)) + { + // At a coincident endpoint, constrain the tangent direction + // directly; a circle-to-line distance has a singular derivative. + let radius = GLine { p1: circ.center, p2: point }; + return vec![Rc::new(PerpendicularConstraint::new(sys.store(), line, radius))]; + } let store = sys.store(); let (cx, cy) = (store.get(circ.center.x), store.get(circ.center.y)); let (x1, y1) = (store.get(line.p1.x), store.get(line.p1.y)); @@ -1525,7 +1831,7 @@ fn build_constraint( // `C2LDistance::signed_value` itself uses — so `ccw`'s // sign matches whichever side the circle already sits on. let area = (x2 - x1) * (cy - y1) - (y2 - y1) * (cx - x1); - let ccw = area >= 0.0; + let ccw = tangent_side.unwrap_or(area >= 0.0); // A driven, fixed zero: with `internal: false` this makes // `C2LDistance`'s target exactly the circle's own radius // (see its `error_grad`), i.e. plain tangency rather than @@ -1604,26 +1910,167 @@ fn build_constraint( } } -fn smooth_target_jet(entity: &EntityType, marker: i32) -> Option { - let parameter = match marker { - 0 => 0.0, - 1 => 1.0, - _ => return None, - }; - let curve = crate::entities::curve::entity_curve(entity)?; - if curve.is_closed() { - return None; +fn smooth_refs(refs: &[ParametricRef]) -> Option<(ParametricRef, ParametricRef, ParametricRef)> { + match refs { + [source, target_endpoint] => Some(( + *source, + *target_endpoint, + ParametricRef::whole(target_endpoint.entity), + )), + [source, target_endpoint, target_curve] + if target_endpoint.entity == target_curve.entity => + { + Some((*source, *target_endpoint, *target_curve)) + } + _ => None, } - let step = if marker == 0 { 1.0e-4 } else { -1.0e-4 }; +} + +fn smooth_target_planar_curve( + entity: &EntityType, + curve_reference: ParametricRef, +) -> Option { + let planar = crate::entities::curve::entity_curve(entity)?; + let Some(segment) = curve_reference.segment_index() else { + return (!planar.is_closed()).then_some(planar); + }; + let curve = planar.curve.segments().into_iter().nth(segment)?; + Some(cadkernel::space::PlanarCurve::new(planar.plane, curve)) +} + +fn smooth_endpoint_parameter( + entity: &EntityType, + endpoint_reference: ParametricRef, + curve: &cadkernel::space::PlanarCurve, +) -> Option { + let marker = endpoint_reference.marker?; + let endpoint = super::parametric_constraints::resolve_point(entity, marker)?; + let endpoint = cadkernel::space::Vec3::new(endpoint.x, endpoint.y, endpoint.z); + let start = cadkernel::space::Vec3::from(curve.point_at(0.0)); + let end = cadkernel::space::Vec3::from(curve.point_at(1.0)); + Some(if endpoint.distance(start) <= endpoint.distance(end) { + 0.0 + } else { + 1.0 + }) +} + +fn smooth_target_jet( + entity: &EntityType, + endpoint_reference: ParametricRef, + curve_reference: ParametricRef, +) -> Option { + if curve_reference.segment_index().is_none() { + if let EntityType::Spline(spline) = entity { + let endpoint = match endpoint_reference.marker { + Some(0) => cadkernel::space::SplineEnd::Start, + Some(1) => cadkernel::space::SplineEnd::End, + _ => return None, + }; + let nurbs = crate::entities::spline::nurbs3(spline)?; + return cadkernel::space::CurveJet::from_nurbs(&nurbs, endpoint); + } + } + + let curve = smooth_target_planar_curve(entity, curve_reference)?; + let parameter = smooth_endpoint_parameter(entity, endpoint_reference, &curve)?; + let point = curve.point_at(parameter); let tangent = curve.tangent_at(parameter); - let next = curve.point_at(parameter + step); - let third = curve.point_at(parameter + 2.0 * step); - Some(cadkernel::space::CurveJet { - point, - tangent, - curvature: cadkernel::space::curve::curvature_through(point, next, third), - }) + match &curve.curve { + cadkernel::geom2d::Curve::Line(_) => { + cadkernel::space::CurveJet::linear(point, tangent) + } + cadkernel::geom2d::Curve::Arc(arc) => cadkernel::space::CurveJet::circular( + point, + tangent, + curve.plane.point_at(arc.centre), + ), + _ => None, + } +} + +fn smooth_spline_plane(spline: &acadrust::entities::Spline) -> Option { + let source = if crate::entities::spline::uses_fit_method(spline) { + &spline.fit_points + } else { + &spline.control_points + }; + let origin = source.first()?; + let origin_vector = cadkernel::space::Vec3::new(origin.x, origin.y, origin.z); + let along = source + .iter() + .skip(1) + .map(|point| cadkernel::space::Vec3::new(point.x, point.y, point.z) - origin_vector) + .find(|vector| vector.length_squared() > 1.0e-24)?; + let normal = source + .iter() + .skip(1) + .map(|point| cadkernel::space::Vec3::new(point.x, point.y, point.z) - origin_vector) + .map(|vector| along.cross(vector)) + .find(|vector| vector.length_squared() > 1.0e-24)?; + let plane = cadkernel::space::Plane::orthonormal( + origin_vector.to_array(), + along.to_array(), + normal.to_array(), + )?; + let points: Vec<_> = source.iter().map(|point| [point.x, point.y, point.z]).collect(); + let tolerance = cadkernel::space::coplanarity_tolerance(&points); + source + .iter() + .all(|point| plane.contains([point.x, point.y, point.z], tolerance)) + .then_some(plane) +} + +fn smooth_entity_plane( + entity: &EntityType, + curve_reference: ParametricRef, +) -> Option { + if let EntityType::Spline(spline) = entity { + return crate::entities::curve::entity_curve(entity) + .map(|curve| curve.plane) + .or_else(|| smooth_spline_plane(spline)); + } + smooth_target_planar_curve(entity, curve_reference).map(|curve| curve.plane) +} + +fn smooth_refs_share_plane( + document: &acadrust::CadDocument, + source_ref: ParametricRef, + target_curve_ref: ParametricRef, +) -> bool { + let Some(source_entity) = document.get_entity(source_ref.entity) else { + return false; + }; + let Some(source_plane) = smooth_entity_plane(source_entity, ParametricRef::whole(source_ref.entity)) + else { + return false; + }; + let Some(target_entity) = document.get_entity(target_curve_ref.entity) else { + return false; + }; + let Some(target_plane) = smooth_entity_plane(target_entity, target_curve_ref) else { + return false; + }; + let points = [source_plane.origin, target_plane.origin]; + let tolerance = cadkernel::space::coplanarity_tolerance(&points); + if let EntityType::Line(line) = target_entity { + return source_plane + .contains([line.start.x, line.start.y, line.start.z], tolerance) + && source_plane + .contains([line.end.x, line.end.y, line.end.z], tolerance); + } + let (Some(source_normal), Some(target_normal)) = + (source_plane.normal(), target_plane.normal()) + else { + return false; + }; + cadkernel::space::Vec3::from(source_normal) + .cross(cadkernel::space::Vec3::from(target_normal)) + .length() + <= 1.0e-9 + && source_plane.contains(target_plane.origin, tolerance) + && target_plane.contains(source_plane.origin, tolerance) } fn apply_smooth_constraints( @@ -1636,7 +2083,9 @@ fn apply_smooth_constraints( .iter() .filter(|constraint| constraint.enabled && constraint.kind == ConstraintKind::Smooth) { - let [source_ref, target_ref] = constraint.refs.as_slice() else { + let Some((source_ref, target_ref, target_curve_ref)) = + smooth_refs(&constraint.refs) + else { continue; }; let source = results @@ -1657,10 +2106,7 @@ fn apply_smooth_constraints( Some(1) => cadkernel::space::SplineEnd::End, _ => continue, }; - let Some(target_marker) = target_ref.marker else { - continue; - }; - let Some(target_jet) = smooth_target_jet(&target, target_marker) else { + let Some(target_jet) = smooth_target_jet(&target, target_ref, target_curve_ref) else { continue; }; let Some(curve) = crate::entities::spline::nurbs3(&spline) else { @@ -1696,6 +2142,12 @@ type SolveResult = ( fn retained_line_length(entity: &EntityType, segment: Option) -> Option { match (entity, segment) { (EntityType::Line(line), None) => Some((line.end - line.start).length()), + (EntityType::Text(_) | EntityType::MText(_), None) => { + let reference = ParametricRef::text_baseline(entity.common().handle); + let [start, end] = + super::parametric_constraints::directional_axis_endpoints(entity, reference)?; + Some((end - start).length()) + } (EntityType::LwPolyline(polyline), Some(index)) => { let world = crate::entities::curve::lwpolyline_world_xy(polyline)?; let a = world.vertices.get(index)?.location; @@ -1731,24 +2183,120 @@ fn retained_radius(entity: &EntityType) -> Option { } } -/// Rebuilds `set`'s entire `cadkernel_constraints::System` from current document -/// geometry, solves it, and returns the resulting entity states for every -/// handle whose registered coordinates actually moved beyond floating-point -/// noise, plus the scope's total remaining degrees of freedom and any -/// redundant/conflicting constraints, resolved -/// back to the `ConstraintId`s a `ConflictResolverPanel` can name and offer -/// to remove. `None` if nothing in the scope could be built (no constraint -/// resolved to anything). DOF/conflicts are still returned even when every -/// subsystem fails to solve (rank is a structural property of the Jacobian, -/// not of whether Dogleg converged) — only the geometry write-back is gated -/// on a successful solve. +/// Hold the distance between two points at `length`. +/// +/// `P2PDistance` divides by the current distance, so a zero-length segment +/// gives the solver a NaN Jacobian. Keep coincident endpoints together with +/// per-axis differences instead. +fn retain_segment_length( + sys: &mut System, + a: GPoint, + b: GPoint, + current_length: f64, + length: f64, +) { + if current_length > f64::EPSILON && length > f64::EPSILON { + let target = sys.add_param(length, true); + sys.add_constraint(Rc::new(P2PDistance::new(a, b, target))); + return; + } + let dx = sys.add_param(0.0, true); + let dy = sys.add_param(0.0, true); + sys.add_constraint(Rc::new(Difference::new(a.x, b.x, dx))); + sys.add_constraint(Rc::new(Difference::new(a.y, b.y, dy))); +} + +/// Visit vertices and segments in stored drawing order, including the closing edge last. +fn polyline_ref_order( + document: &acadrust::CadDocument, + reference: ParametricRef, +) -> Option<(u64, usize)> { + if !matches!(document.get_entity(reference.entity), + Some(EntityType::LwPolyline(_) | EntityType::Polyline2D(_))) + { + return None; + } + let position = if let Some(index) = reference.segment_index() + .or_else(|| reference.segment_midpoint_index()) + .or_else(|| reference.segment_center_index()) + { + 2 * index + 1 + } else { + 2 * usize::try_from(reference.marker?).ok()? + }; + Some((reference.entity.value(), position)) +} + +fn constraints_in_drawing_order<'a>( + document: &acadrust::CadDocument, + set: &'a ParametricConstraintSet, +) -> Vec<&'a ParametricConstraint> { + let mut constraints: Vec<_> = set.constraints.iter().collect(); + // A relation is visited when its last referenced element is reached. + // Keep reference roles and persisted constraint IDs unchanged. + constraints.sort_by_cached_key(|constraint| constraint.refs.iter() + .filter_map(|reference| polyline_ref_order(document, *reference)).max()); + constraints +} + +/// Propagate explicit axis constraints through parallel/perpendicular relations. +fn constrained_line_axes(constraints: &[&ParametricConstraint]) -> HashMap { + let mut axes = HashMap::new(); + for c in constraints.iter().filter(|c| c.enabled) { + if let [reference] = c.refs.as_slice() { + if matches!(c.kind, ConstraintKind::Horizontal | ConstraintKind::Vertical) { + let fallback = if c.kind == ConstraintKind::Vertical { + Vector3::UNIT_Y + } else { + Vector3::UNIT_X + }; + let direction = c.axis_direction.unwrap_or(fallback); + let length = direction.x.hypot(direction.y); + if length > 1.0e-12 { + let (x, y) = (direction.x.abs() / length, direction.y.abs() / length); + if x >= 1.0 - 1.0e-10 { + axes.insert(*reference, false); + } else if y >= 1.0 - 1.0e-10 { + axes.insert(*reference, true); + } + } + } + } + } + loop { + let before = axes.len(); + for c in constraints.iter().filter(|c| c.enabled) { + if !matches!(c.kind, ConstraintKind::Parallel | ConstraintKind::Perpendicular | ConstraintKind::Colinear) { + continue; + } + let [a, b] = c.refs.as_slice() else { continue }; + let perpendicular = c.kind == ConstraintKind::Perpendicular; + if let Some(axis) = axes.get(a).copied() { + axes.entry(*b).or_insert(axis ^ perpendicular); + } + if let Some(axis) = axes.get(b).copied() { + axes.entry(*a).or_insert(axis ^ perpendicular); + } + } + if before == axes.len() { + return axes; + } + } +} + +/// Solve the scope through the kernel and return changed geometry plus diagnostics. +/// Failed solves still return DOF/conflicts, but never write back geometry. +/// Returns `None` when no supported geometry or smooth constraint can be built. fn solve_scope( document: &acadrust::CadDocument, drawing_params: &ParameterTable, set: &ParametricConstraintSet, driven_refs: &[ParametricRef], retain_size: bool, + retain_lengths: bool, retained_before: &HashMap>, + initial_fixed_refs: &[ParametricRef], + originals: &HashMap>, ) -> Option { let params = if set.local_parameters.is_empty() { drawing_params @@ -1757,38 +2305,439 @@ fn solve_scope( }; let mut sys = System::new(); let mut cache: HashMap = HashMap::new(); - // Tracks which system-level `cadkernel_constraints` constraint(s) came from which - // `ParametricConstraint` — a `SubSystem`'s redundant-row indices (from - // `cadkernel_constraints::diagnosis`) are local to that partition's own constraint - // list, not `set.constraints`' indices, and several kinds contribute - // more than one system-level constraint per `ParametricConstraint` (see - // `build_constraint`'s doc comment). `Rc::ptr_eq` against this after - // partitioning resolves a row back to the `ConstraintId` the UI - // actually names. + let constraints = constraints_in_drawing_order(document, set); + // A persistent Fixed outranks any edit: a point it holds is never a + // driven grip / STRETCH anchor, and a moved entity it holds gets no + // temporary whole-entity pin — the Fixed one, targeting the retained + // original, pulls it back instead (a MOVE leaves a fixed object, or the + // fixed points of a partly fixed one, where they were). + let fixed_refs: Vec = constraints + .iter() + .filter(|c| c.enabled && c.kind == ConstraintKind::Fixed) + .filter_map(|c| c.refs.first().copied()) + .collect(); + let held_by_fixed = |r: &ParametricRef| { + fixed_refs.iter().any(|f| { + f.entity == r.entity + && (f.marker.is_none() + || f.marker == r.marker + || f.segment_index().is_some_and(|index| { + matches!(r.marker, Some(marker) + if marker == index as i32 || marker == index as i32 + 1) + })) + }) + }; + // A moved entity a Fixed holds only in part gets no temporary pin and + // (below) no size retention, so its free points simply stay where the + // edit put them while the fixed ones pull their own coordinates back — + // a MOVE on a line with one fixed end stretches the line instead of + // sliding it — and any other constraint on it reconciles the rest. + let driven_refs: Vec = + driven_refs.iter().copied().filter(|r| !held_by_fixed(r)).collect(); + let driven_refs = driven_refs.as_slice(); + let fixed_touches = |handle: Handle| fixed_refs.iter().any(|f| f.entity == handle); + let line_axes = if retain_lengths { HashMap::new() } else { constrained_line_axes(&constraints) }; + // Map partition-local kernel rows back to persistent constraint IDs by Rc identity. let mut owner: Vec<(Rc, ConstraintId)> = Vec::new(); - for c in &set.constraints { - for constraint in build_constraint(document, &mut sys, &mut cache, params, c) { + for c in &constraints { + if c.enabled && matches!(c.kind, ConstraintKind::Parallel | ConstraintKind::Perpendicular) + && c.refs.len() == 2 && c.refs.iter().all(|r| line_axes.contains_key(r)) + && (line_axes[&c.refs[0]] ^ line_axes[&c.refs[1]]) == (c.kind == ConstraintKind::Perpendicular) + && refs_share_supported_plane(document, &c.refs) + && c.refs.iter().all(|reference| { + match resolve_ref(document, &mut sys, &mut cache, *reference) { + Some(EntityGeom::Line(_)) => true, + Some(geometry @ EntityGeom::Polyline { .. }) => reference.segment_index() + .and_then(|index| geometry.line_segment(index)).is_some(), + _ => false, + } + }) + { + // Implied axis equations below replace normalized angle equations, + // which become ill-conditioned when a dragged edge crosses zero. + continue; + } + let tangent_side = retained_tangent_side(document, retained_before, c); + let tangent_point = (c.kind == ConstraintKind::Tangent && c.refs.len() == 2 + && c.refs[0].entity != c.refs[1].entity + && c.refs.iter().all(|r| r.marker.is_none())) + .then(|| constraints.iter().find_map(|connection| { + (connection.enabled && connection.kind == ConstraintKind::Coincident + && connection.refs.len() == 2 + && connection.refs.iter().all(|r| matches!(r.marker, Some(0 | 1))) + && c.refs.iter().all(|r| connection.refs.iter().any(|p| p.entity == r.entity))) + .then(|| connection.refs[0]) + })).flatten(); + for constraint in build_constraint( + document, + &mut sys, + &mut cache, + params, + c, + tangent_side, + tangent_point, + originals, + ) { owner.push((constraint.clone(), c.id)); sys.add_constraint(constraint); } } - let has_smooth = set - .constraints + // Ordered two-object constraints use the first pick as a reference for + // their initial placement. Temporary Fixed equations stay out of `owner` + // and the persistent native graph. Symmetric references are handled as + // exact driven anchors below, so they are excluded from this approximate + // equality-equation path. + let symmetric_constraints = constraints .iter() + .filter(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Symmetric + && constraint.refs.len() == 3 + }) + .collect::>(); + let temporary_fixed_refs = initial_fixed_refs + .iter() + .copied() + .filter(|reference| { + !symmetric_constraints + .iter() + .any(|constraint| constraint.refs.contains(reference)) + }) + .collect::>(); + for reference in &temporary_fixed_refs { + if fixed_touches(reference.entity) { + continue; + } + // A transformed entity with a Horizontal/Vertical relation keeps + // only its end point where the transform put it; the rest re-aligns + // to the axis at the retained length (a rotated vertical line stays + // vertical below its moved end, as in the reference) instead of the + // whole-entity pin contradicting the axis and failing the solve. + let axis_pin = reference.marker.is_none().then(|| { + constraints.iter().find_map(|c| { + (c.enabled && matches!(c.kind, ConstraintKind::Horizontal | ConstraintKind::Vertical)) + .then(|| c.refs.iter().find(|r| r.entity == reference.entity)) + .flatten() + .map(|r| match r.directional_axis() { + Some(super::parametric_constraints::DirectionalAxis::TextBaseline) => { + ParametricRef::point(r.entity, 0) + } + Some(_) => ParametricRef::center(r.entity), + None => ParametricRef::point( + r.entity, + r.segment_index().map_or(1, |index| index as i32 + 1), + ), + }) + }) + }) + .flatten(); + let pinned = axis_pin.unwrap_or(*reference); + // The re-aligned entity keeps the length the transform gave it (a + // scaled vertical line stays scaled), not its pre-edit length. + if axis_pin.is_some() { + let segment = reference + .segment_index() + .or_else(|| { + constraints.iter().find_map(|c| { + c.refs + .iter() + .find(|r| r.entity == reference.entity) + .and_then(|r| r.segment_index()) + }) + }); + let line = resolve_ref(document, &mut sys, &mut cache, *reference).and_then(|geometry| { + match (&geometry, segment) { + (EntityGeom::Polyline { .. }, Some(index)) => geometry.line_segment(index), + (EntityGeom::Line(line) | EntityGeom::Ray(line) | EntityGeom::XLine(line), _) => { + Some(*line) + } + _ => None, + } + }); + if let Some(line) = line { + let length = { + let store = sys.store(); + let dx = store.get(line.p2.x) - store.get(line.p1.x); + let dy = store.get(line.p2.y) - store.get(line.p1.y); + (dx * dx + dy * dy).sqrt() + }; + retain_segment_length(&mut sys, line.p1, line.p2, length, length); + } + } + let temporary = ParametricConstraint { + id: ConstraintId::MAX, + kind: ConstraintKind::Fixed, + refs: vec![pinned], + driving_param: None, + enabled: true, + native_origin: None, + rigid_points: Vec::new(), + distance_direction_type: distance_direction_type::UNDIRECTED, + distance_direction: None, + axis_direction: None, + angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, + }; + for constraint in build_constraint( + document, + &mut sys, + &mut cache, + params, + &temporary, + None, + None, + &HashMap::new(), + ) { + sys.add_constraint(constraint); + } + } + + let has_smooth = constraints.iter() .any(|constraint| constraint.enabled && constraint.kind == ConstraintKind::Smooth); - if cache.is_empty() && !has_smooth { + let has_spatial_concentric = constraints.iter().any(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Concentric + && !refs_share_supported_plane(document, &constraint.refs) + && concentric_refs_share_plane(document, &constraint.refs) + }); + if cache.is_empty() && !has_smooth && !has_spatial_concentric { return None; } - // Preserve current line lengths and radii for this solve when - // CONSTRAINTSOLVEMODE requests it. These temporary equations guide the - // solution without becoming persistent dimensional constraints. + let mut anchors = driven_refs.to_vec(); + for constraint in &symmetric_constraints { + if let Some(axis) = constraint.refs.get(2).copied() { + if !anchors.contains(&axis) { + anchors.push(axis); + } + } + for reference in initial_fixed_refs { + if constraint.refs[..2].contains(reference) && !anchors.contains(reference) { + anchors.push(*reference); + } + } + } + // Equal: the side that is not being edited follows the other's length + // or radius the way the reference does it — its start (a circle its + // center) and direction stay and only its end moves — so its size is + // not retained below, its other points are held and its direction is + // kept while the edit lasts. A chain of Equal relations follows along. + let edited = |handle: Handle| { + driven_refs.iter().any(|reference| reference.entity == handle) + || initial_fixed_refs.iter().any(|reference| reference.entity == handle) + }; + let mut equal_followers: Vec = Vec::new(); + let mut moving: Vec = cache.keys().copied().filter(|handle| edited(*handle)).collect(); + loop { + let before = equal_followers.len(); + for constraint in &constraints { + if !constraint.enabled || constraint.kind != ConstraintKind::Equal { + continue; + } + let [a, b] = constraint.refs.as_slice() else { continue }; + let follower = match (moving.contains(&a.entity), moving.contains(&b.entity)) { + (true, false) => *b, + (false, true) => *a, + _ => continue, + }; + if !equal_followers.contains(&follower) { + equal_followers.push(follower); + moving.push(follower.entity); + } + } + if equal_followers.len() == before { + break; + } + } + for follower in &equal_followers { + let Some(geometry) = resolve_ref(document, &mut sys, &mut cache, *follower) else { + continue; + }; + let mut hold = |anchor: ParametricRef| { + if !anchors.contains(&anchor) { + anchors.push(anchor); + } + }; + let line = match &geometry { + EntityGeom::Line(line) => Some(*line), + EntityGeom::Polyline { .. } => follower + .segment_index() + .and_then(|index| geometry.line_segment(index)), + _ => None, + }; + match &geometry { + EntityGeom::Circle(_) | EntityGeom::Arc(_) => { + hold(ParametricRef::center(follower.entity)); + } + EntityGeom::Line(_) => hold(ParametricRef::point(follower.entity, 0)), + EntityGeom::Polyline { points, .. } => { + let moving_vertex = follower + .segment_index() + .map(|index| (index + 1) % points.len().max(1)); + for index in (0..points.len()).filter(|index| Some(*index) != moving_vertex) { + hold(ParametricRef::point(follower.entity, index as i32)); + } + } + _ => {} + } + if let Some(line) = line { + let (dx, dy) = { + let store = sys.store(); + ( + store.get(line.p2.x) - store.get(line.p1.x), + store.get(line.p2.y) - store.get(line.p1.y), + ) + }; + if dx.hypot(dy) > f64::EPSILON { + let datum = GLine { + p1: GPoint::new(sys.add_param(0.0, true), sys.add_param(0.0, true)), + p2: GPoint::new(sys.add_param(dx, true), sys.add_param(dy, true)), + }; + let parallel = ParallelConstraint::new(sys.store(), line, datum); + sys.add_constraint(Rc::new(parallel)); + } + } + } + if !retain_lengths { + let mut axis_lines: Vec<_> = line_axes.into_iter().filter_map(|(reference, vertical)| { + let geometry = cache.get(&reference.entity)?; + let line = match geometry { + EntityGeom::Line(line) => *line, + EntityGeom::Polyline { .. } => geometry.line_segment(reference.segment_index()?)?, + _ => return None, + }; + Some((reference, line, vertical)) + }).collect(); + axis_lines.sort_by_key(|(reference, _, _)| (reference.entity.value(), reference.segment_index())); + let driven_points: Vec<_> = driven_refs.iter().filter_map(|reference| + resolve_constraint_point(document, &mut sys, &mut cache, *reference)).collect(); + let coincident: Vec<_> = constraints.iter().filter(|c| + c.enabled && c.kind == ConstraintKind::Coincident).filter_map(|c| { + let [a, b] = c.refs.as_slice() else { return None }; + Some((resolve_constraint_point(document, &mut sys, &mut cache, *a)?, + resolve_constraint_point(document, &mut sys, &mut cache, *b)?)) + }).collect(); + let point_group = |point| { + let mut points = vec![point]; + let mut cursor = 0; + while cursor < points.len() { + for (a, b) in &coincident { + let next = if points[cursor] == *a { Some(*b) } + else if points[cursor] == *b { Some(*a) } else { None }; + if let Some(next) = next.filter(|next| !points.contains(next)) { + points.push(next); + } + } + cursor += 1; + } + points + }; + let perpendicular: Vec<_> = constraints.iter().filter(|c| + c.enabled && c.kind == ConstraintKind::Perpendicular && c.refs.len() == 2).collect(); + let mut retained_axes = HashSet::new(); + for point in &driven_points { + let group = point_group(*point); + let incident: Vec<_> = axis_lines.iter().filter(|(_, line, _)| + group.contains(&line.p1) || group.contains(&line.p2)).collect(); + if incident.is_empty() { + continue; + } + let common_corner = perpendicular.iter().any(|c| c.refs.iter() + .all(|reference| incident.iter().any(|(r, _, _)| r == reference))); + if common_corner && retain_size { + // The shared right-angle corner carries its two legs together. + retained_axes.extend([false, true]); + continue; + } + let free_legs: Vec<_> = incident.iter().copied().filter(|(reference, _, _)| + perpendicular.iter().any(|c| c.refs.contains(reference))).collect(); + let editable = if free_legs.is_empty() { &incident } else { &free_legs }; + retained_axes.extend([false, true].into_iter().filter(|axis| + !editable.iter().any(|(_, _, vertical)| vertical == axis))); + for (reference, line, vertical) in editable.iter().copied() { + let opposite = if group.contains(&line.p1) { line.p2 } else { line.p1 }; + if !driven_points.contains(&opposite) { + let parameter = if *vertical { opposite.y } else { opposite.x }; + // Only the along-edge coordinate stays fixed. The normal + // coordinate follows the grabbed point through the kernel. + if let Some(original) = retained_before.get(&reference.entity) { + let points = super::dimension_assoc::source_points(original); + let start = reference.segment_index().unwrap_or(0); + let index = if opposite == line.p1 { start } else { (start + 1) % points.len().max(1) }; + if let Some(point) = points.get(index) { + sys.store_mut().set(parameter, if *vertical { point.y } else { point.x }); + } + } + sys.store_mut().set_driven(parameter, true); + } + } + } + for (reference, line, vertical) in &axis_lines { + // These implied linear equations remain defined even while an + // edge crosses zero length; cross-product equations do not. + let (a, b) = if *vertical { (line.p1.x, line.p2.x) } else { (line.p1.y, line.p2.y) }; + sys.add_constraint(Rc::new(Equal::new(a, b, 1.0))); + let paired_edge_is_dragged = constraints.iter().any(|c| { + c.enabled && c.kind == ConstraintKind::Parallel && c.refs.contains(reference) + && c.refs.iter().any(|other| other != reference && driven_refs.contains(other)) + }); + if paired_edge_is_dragged && !driven_refs.contains(reference) { + // Moving a whole edge changes its separation from the opposite + // parallel edge, whose normal coordinate remains the reference. + if let Some(original) = retained_before.get(&reference.entity) { + let points = super::dimension_assoc::source_points(original); + if let Some(point) = points.get(reference.segment_index().unwrap_or(0)) { + sys.store_mut().set(a, if *vertical { point.x } else { point.y }); + sys.store_mut().set_driven(a, true); + } + } + } + if retain_size && retained_axes.contains(vertical) { + let Some(original) = retained_before.get(&reference.entity) else { continue }; + let points = super::dimension_assoc::source_points(original); + let index = reference.segment_index().unwrap_or(0); + let (Some(a), Some(b)) = (points.get(index), points.get(index + 1).or_else(|| points.first())) + else { continue }; + let (a_param, b_param, delta) = if *vertical { + (line.p1.y, line.p2.y, b.y - a.y) + } else { + (line.p1.x, line.p2.x, b.x - a.x) + }; + let target = sys.add_param(delta, true); + sys.add_constraint(Rc::new(Difference::new(a_param, b_param, target))); + } + } + for reference in driven_refs { + if matches!(document.get_entity(reference.entity), Some(EntityType::Line(_))) + && matches!(reference.marker, Some(0 | 1)) + && !axis_lines.iter().any(|(line_ref, _, _)| line_ref.entity == reference.entity) + { + anchors.push(ParametricRef::point(reference.entity, 1 - reference.marker.unwrap())); + } + } + } + + // Grip edits retain radii while connected lines resize to meet their + // endpoints. Other edits can retain line lengths as well. if retain_size { for (handle, geom) in &cache { + // A Fixed entity's size is whatever its pins say: whole → all + // pinned anyway, partial → the reference lets it resize (MOVE + // on a one-end-fixed line shortens it). + if fixed_touches(*handle) + || initial_fixed_refs + .iter() + .any(|reference| reference.entity == *handle && reference.marker.is_none()) + || symmetric_constraints.iter().any(|constraint| { + constraint.refs.get(2).is_some_and(|axis| axis.entity == *handle) + }) + || equal_followers.iter().any(|follower| follower.entity == *handle) + { + continue; + } match geom { - EntityGeom::Line(line) => { + EntityGeom::Line(line) | EntityGeom::TextLine(line) if retain_lengths => { let current_length = { let store = sys.store(); let dx = store.get(line.p2.x) - store.get(line.p1.x); @@ -1799,14 +2748,13 @@ fn solve_scope( .get(handle) .and_then(|entity| retained_line_length(entity, None)) .unwrap_or(current_length); - let target = sys.add_param(length, true); - sys.add_constraint(Rc::new(P2PDistance::new(line.p1, line.p2, target))); + retain_segment_length(&mut sys, line.p1, line.p2, current_length, length); } EntityGeom::Polyline { points, closed, .. - } => { + } if retain_lengths => { for index in 0..points.len() { let Some(a) = points.get(index).copied() else { continue; @@ -1829,8 +2777,7 @@ fn solve_scope( .get(handle) .and_then(|entity| retained_line_length(entity, Some(index))) .unwrap_or(current_length); - let target = sys.add_param(length, true); - sys.add_constraint(Rc::new(P2PDistance::new(a, b, target))); + retain_segment_length(&mut sys, a, b, current_length, length); } } EntityGeom::Circle(circle) => { @@ -1849,6 +2796,26 @@ fn solve_scope( let target = sys.add_param(radius, true); sys.add_constraint(Rc::new(Equal::new(arc.circle.rad, target, 1.0))); } + EntityGeom::Ellipse(ellipse) => { + let (focus_distance, minor_radius) = { + let store = sys.store(); + let dx = store.get(ellipse.focus1.x) - store.get(ellipse.center.x); + let dy = store.get(ellipse.focus1.y) - store.get(ellipse.center.y); + ((dx * dx + dy * dy).sqrt(), store.get(ellipse.radmin)) + }; + let focus_target = sys.add_param(focus_distance, true); + let minor_target = sys.add_param(minor_radius, true); + sys.add_constraint(Rc::new(P2PDistance::new( + ellipse.center, + ellipse.focus1, + focus_target, + ))); + sys.add_constraint(Rc::new(Equal::new( + ellipse.radmin, + minor_target, + 1.0, + ))); + } _ => {} } } @@ -1917,16 +2884,25 @@ fn solve_scope( // `focus1 - center` (which is what `major_axis` direction/length and // `minor_axis_ratio` are actually computed from on write-back) changes // anyway, making the ellipse appear to rotate and reshape even though - // no shape parameter was itself pulled on. Pinning `focus1 - center` to - // its pre-solve (seed) value forces `focus1` to translate rigidly with - // `center`, keeping orientation/eccentricity invariant unless something - // actually constrains `focus1`/`radmin` directly (only `Fixed` does - // today). `radmin` needs no equivalent rule — as a lone scalar it isn't - // coupled to `center`'s movement, so it already stays put on its own. - for geom in cache.values() { + // no shape parameter was itself pulled on. Most ellipse relations keep + // that vector exact. A directional axis relation instead preserves its + // length while allowing the vector to rotate into the requested angle. + for (handle, geom) in &cache { let EntityGeom::Ellipse(el) = geom else { continue; }; + let symmetric = set.constraints.iter().any(|constraint| { + constraint.enabled + && constraint.kind == ConstraintKind::Symmetric + && constraint.refs.get(..2).is_some_and(|references| { + references.iter().any(|reference| { + reference.entity == *handle && reference.marker.is_none() + }) + }) + }); + if symmetric { + continue; + } let (cx, cy, fx, fy) = { let store = sys.store(); ( @@ -1936,17 +2912,84 @@ fn solve_scope( store.get(el.focus1.y), ) }; - let dx = sys.add_param(fx - cx, true); - let dy = sys.add_param(fy - cy, true); - sys.add_constraint(Rc::new(Difference::new(el.center.x, el.focus1.x, dx))); - sys.add_constraint(Rc::new(Difference::new(el.center.y, el.focus1.y, dy))); + let directional = set.constraints.iter().any(|constraint| { + constraint.enabled + && matches!( + constraint.kind, + ConstraintKind::Horizontal + | ConstraintKind::Vertical + | ConstraintKind::Perpendicular + ) + && constraint.refs.iter().any(|reference| { + reference.entity == *handle && reference.directional_axis().is_some() + }) + }); + if directional && !retain_size { + let distance = sys.add_param((fx - cx).hypot(fy - cy), true); + sys.add_constraint(Rc::new(P2PDistance::new( + el.center, + el.focus1, + distance, + ))); + let minor_value = sys.store().get(el.radmin); + let minor = sys.add_param(minor_value, true); + sys.add_constraint(Rc::new(Equal::new(el.radmin, minor, 1.0))); + } else if !directional { + let dx = sys.add_param(fx - cx, true); + let dy = sys.add_param(fy - cy, true); + sys.add_constraint(Rc::new(Difference::new(el.center.x, el.focus1.x, dx))); + sys.add_constraint(Rc::new(Difference::new(el.center.y, el.focus1.y, dy))); + } } - // The first ordered Coincident selection is the anchor for this solve. - // Pin either its addressed point or every defining parameter of its whole - // curve temporarily; the relation remains persistent, while these pins do - // not enter the drawing's constraint graph. - for reference in driven_refs { + // Text keeps its displayed baseline rigid during ordinary point edits. + // A perpendicular baseline reference keeps only the span length so the + // command can rotate the text about its insertion point. + for (handle, geom) in &cache { + let EntityGeom::TextLine(line) = geom else { + continue; + }; + let (x1, y1, x2, y2) = { + let store = sys.store(); + ( + store.get(line.p1.x), + store.get(line.p1.y), + store.get(line.p2.x), + store.get(line.p2.y), + ) + }; + let directional = set.constraints.iter().any(|constraint| { + constraint.enabled + && matches!( + constraint.kind, + ConstraintKind::Horizontal + | ConstraintKind::Vertical + | ConstraintKind::Perpendicular + ) + && constraint.refs.iter().any(|reference| { + reference.entity == *handle + && matches!( + reference.directional_axis(), + Some(super::parametric_constraints::DirectionalAxis::TextBaseline) + ) + }) + }); + if directional && !retain_size { + let length = (x2 - x1).hypot(y2 - y1); + retain_segment_length(&mut sys, line.p1, line.p2, length, length); + } else if !directional { + let dx = sys.add_param(x2 - x1, true); + let dy = sys.add_param(y2 - y1, true); + sys.add_constraint(Rc::new(Difference::new(line.p1.x, line.p2.x, dx))); + sys.add_constraint(Rc::new(Difference::new(line.p1.y, line.p2.y, dy))); + } + } + + // Temporarily make each requested anchor a fixed kernel input. Marking the + // geometry parameters as driven keeps them exact and out of the solver's + // free-variable list; an approximate equality equation would still allow + // visible drift after repeated grip frames. + for reference in &anchors { if !set .constraints .iter() @@ -1954,14 +2997,20 @@ fn solve_scope( { continue; } - let params_to_pin = if reference.marker.is_some() && reference.segment_index().is_none() { + let params_to_pin = if let Some(index) = reference.segment_index() { + resolve_ref(document, &mut sys, &mut cache, *reference) + .and_then(|geometry| geometry.line_segment(index)) + .map(|line| vec![line.p1.x, line.p1.y, line.p2.x, line.p2.y]) + .unwrap_or_default() + } else if reference.marker.is_some() { resolve_constraint_point(document, &mut sys, &mut cache, *reference) .map(|point| vec![point.x, point.y]) .unwrap_or_default() } else { match resolve_ref(document, &mut sys, &mut cache, *reference) { Some(EntityGeom::Point(point)) => vec![point.x, point.y], - Some(EntityGeom::Line(line)) + Some(EntityGeom::TextLine(line)) + | Some(EntityGeom::Line(line)) | Some(EntityGeom::Ray(line)) | Some(EntityGeom::XLine(line)) => { vec![line.p1.x, line.p1.y, line.p2.x, line.p2.y] @@ -1996,9 +3045,7 @@ fn solve_scope( } }; for parameter in params_to_pin { - let value = sys.store().get(parameter); - let target = sys.add_param(value, true); - sys.add_constraint(Rc::new(Equal::new(parameter, target, 1.0))); + sys.store_mut().set_driven(parameter, true); } } @@ -2019,7 +3066,10 @@ fn solve_scope( .values() .map(|g| match g { EntityGeom::Point(_) => 2, - EntityGeom::Line(_) | EntityGeom::Ray(_) | EntityGeom::XLine(_) => 4, + EntityGeom::TextLine(_) + | EntityGeom::Line(_) + | EntityGeom::Ray(_) + | EntityGeom::XLine(_) => 4, EntityGeom::Polyline { points, arcs, .. } => { points.len() * 2 + arcs.iter().flatten().count() * 5 } @@ -2101,31 +3151,72 @@ fn solve_scope( results.push((handle, EntityType::Insert(updated))); } } - (EntityType::Text(text), EntityGeom::Point(geometry)) => { + (EntityType::Text(text), EntityGeom::TextLine(geometry)) => { let mut updated = text.clone(); let next = Vector3::new( - store.get(geometry.x), - store.get(geometry.y), + store.get(geometry.p1.x), + store.get(geometry.p1.y), + text.insertion_point.z, + ); + let end = Vector3::new( + store.get(geometry.p2.x), + store.get(geometry.p2.y), text.insertion_point.z, ); let delta = next - text.insertion_point; - if delta.length() > MOVE_EPS { + let direction = end - next; + let rotation = direction.y.atan2(direction.x); + let aligned = matches!( + text.horizontal_alignment, + acadrust::entities::TextHorizontalAlignment::Aligned + | acadrust::entities::TextHorizontalAlignment::Fit + ); + let current_rotation = if aligned { + text.alignment_point + .filter(|point| (*point - text.insertion_point).length_squared() > 1.0e-18) + .map(|point| { + (point.y - text.insertion_point.y) + .atan2(point.x - text.insertion_point.x) + }) + .unwrap_or(text.rotation) + } else { + text.rotation + }; + if delta.length() > MOVE_EPS + || (rotation - current_rotation).abs() > MOVE_EPS + { updated.insertion_point = next; - if let Some(alignment) = &mut updated.alignment_point { - *alignment = *alignment + delta; + if aligned { + updated.alignment_point = Some(end); + } else { + if let Some(alignment) = &mut updated.alignment_point { + *alignment = *alignment + delta; + } + updated.rotation = rotation; } results.push((handle, EntityType::Text(updated))); } } - (EntityType::MText(text), EntityGeom::Point(geometry)) => { + (EntityType::MText(text), EntityGeom::TextLine(geometry)) => { let mut updated = text.clone(); let next = Vector3::new( - store.get(geometry.x), - store.get(geometry.y), + store.get(geometry.p1.x), + store.get(geometry.p1.y), text.insertion_point.z, ); - if (next - text.insertion_point).length() > MOVE_EPS { + let dx = store.get(geometry.p2.x) - next.x; + let dy = store.get(geometry.p2.y) - next.y; + let rotation = dy.atan2(dx); + if (next - text.insertion_point).length() > MOVE_EPS + || (rotation - text.rotation).abs() > MOVE_EPS + { updated.insertion_point = next; + updated.rotation = rotation; + updated.dwg_x_direction = Some(Vector3::new( + rotation.cos(), + rotation.sin(), + 0.0, + )); results.push((handle, EntityType::MText(updated))); } } @@ -2359,8 +3450,8 @@ fn solve_scope( } } (EntityType::Spline(spline), EntityGeom::Spline { curve, control_z }) => { - let mut changed = - spline.fit_points.len() > 0 || spline.control_points.len() != curve.poles.len(); + let mut changed = crate::entities::spline::uses_fit_method(spline) + || spline.control_points.len() != curve.poles.len(); let control_points: Vec<_> = curve .poles .iter() @@ -2377,11 +3468,25 @@ fn solve_scope( .collect(); if changed { let mut updated = spline.clone(); + if crate::entities::spline::uses_fit_method(spline) { + updated.cv_frame_visible = true; + } updated.degree = curve.degree as i32; updated.knots = curve.knots.clone(); updated.control_points = control_points; updated.weights = curve.weights.iter().map(|id| store.get(*id)).collect(); - updated.fit_points.clear(); + if !updated.fit_points.is_empty() { + if let (Some(first), Some(point)) = + (updated.fit_points.first_mut(), updated.control_points.first()) + { + *first = *point; + } + if let (Some(last), Some(point)) = + (updated.fit_points.last_mut(), updated.control_points.last()) + { + *last = *point; + } + } updated.begin_tangent = Vector3::ZERO; updated.end_tangent = Vector3::ZERO; updated.flags.rational = updated @@ -2397,6 +3502,13 @@ fn solve_scope( } } apply_smooth_constraints(document, set, &mut results); + apply_spatial_concentric_constraints( + document, + set, + driven_refs, + initial_fixed_refs, + &mut results, + ); Some((results, dof, conflicts)) } @@ -2409,13 +3521,8 @@ impl Scene { refs: &[ParametricRef], driving_param: Option<&super::named_parameters::DrivingValue>, ) -> Result<(), &'static str> { - if !refs_share_supported_plane(&self.document, refs) { - return Err( - "Constraint references must be supported entities on the same world-XY plane.", - ); - } if kind == ConstraintKind::Smooth { - let [source_ref, target_ref] = refs else { + let Some((source_ref, target_ref, target_curve_ref)) = smooth_refs(refs) else { return Err("Smooth requires one spline endpoint and one target endpoint."); }; if source_ref.entity == target_ref.entity { @@ -2433,13 +3540,16 @@ impl Scene { Some(1) => cadkernel::space::SplineEnd::End, _ => return Err("Smooth requires a spline endpoint."), }; - let Some(target_marker) = target_ref.marker else { + let Some(_) = target_ref.marker else { return Err("Smooth requires a target endpoint."); }; let Some(target) = self.document.get_entity(target_ref.entity) else { return Err("Smooth target does not exist."); }; - let Some(target_jet) = smooth_target_jet(target, target_marker) else { + if !smooth_refs_share_plane(&self.document, source_ref, target_curve_ref) { + return Err("Smooth requires both curves to lie on the same plane."); + } + let Some(target_jet) = smooth_target_jet(target, target_ref, target_curve_ref) else { return Err("The selected target does not support endpoint smoothing."); }; let Some(curve) = crate::entities::spline::nurbs3(spline) else { @@ -2450,6 +3560,26 @@ impl Scene { } return Ok(()); } + if kind == ConstraintKind::Concentric { + if !concentric_refs_share_plane(&self.document, refs) { + return Err( + "Concentric requires two supported circular curves on the same plane.", + ); + } + if !refs_share_supported_plane(&self.document, refs) { + if refs[0].entity == refs[1].entity { + return Err( + "Concentric cannot move two curved segments of the same object on this plane.", + ); + } + return Ok(()); + } + } + if !refs_share_supported_plane(&self.document, refs) { + return Err( + "Constraint references must be supported entities on the same world-XY plane.", + ); + } let validation_target = match driving_param { Some(super::named_parameters::DrivingValue::Named(name)) if !self.named_parameters.contains(name) => @@ -2468,6 +3598,7 @@ impl Scene { rigid_points: Vec::new(), distance_direction_type: 0, distance_direction: None, + axis_direction: None, angle_sector: angle_sector::PARALLEL_COUNTERCLOCKWISE, }; let mut system = System::new(); @@ -2478,6 +3609,9 @@ impl Scene { &mut cache, &self.named_parameters, &candidate, + None, + None, + &HashMap::new(), ) .is_empty() { @@ -2527,6 +3661,23 @@ impl Scene { driven_refs: &[ParametricRef], retain_size: bool, retained_originals: &[(Handle, EntityType)], + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_initial_policy( + changes, + driven_refs, + retain_size, + retained_originals, + &[], + ) + } + + pub(crate) fn refresh_parametric_constraints_with_initial_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], + initial_fixed_refs: &[ParametricRef], ) -> Vec<(Handle, ChangeKind)> { // An erased entity takes its constraints with it instead of leaving // dangling references. Done first, @@ -2558,22 +3709,25 @@ impl Scene { } } + // Pre-edit images of everything this transaction touched: what a + // persistent Fixed pins to, and (under CONSTRAINTSOLVEMODE) what + // sizes are retained from. + let mut originals: HashMap> = self + .undo_recording + .as_ref() + .into_iter() + .flat_map(|recording| recording.before.iter()) + .filter_map(|(handle, entity)| { + entity + .as_ref() + .map(|entity| (*handle, std::sync::Arc::clone(entity))) + }) + .collect(); + for (handle, entity) in retained_originals { + originals.insert(*handle, std::sync::Arc::new(entity.clone())); + } let retained_before: HashMap> = if retain_size { - let mut retained: HashMap> = self - .undo_recording - .as_ref() - .into_iter() - .flat_map(|recording| recording.before.iter()) - .filter_map(|(handle, entity)| { - entity - .as_ref() - .map(|entity| (*handle, std::sync::Arc::clone(entity))) - }) - .collect(); - for (handle, entity) in retained_originals { - retained.insert(*handle, std::sync::Arc::new(entity.clone())); - } - retained + originals.clone() } else { HashMap::new() }; @@ -2594,13 +3748,34 @@ impl Scene { &self.parametric_constraints[i], driven_refs, retain_size, + true, &retained_before, + initial_fixed_refs, + &originals, ) else { continue; }; - self.parametric_constraints[i].dof = Some(dof); - self.parametric_constraints[i].conflicts = conflicts; + if initial_fixed_refs.is_empty() { + self.parametric_constraints[i].dof = Some(dof); + self.parametric_constraints[i].conflicts = conflicts; + } else { + self.parametric_constraints[i].dof = None; + self.parametric_constraints[i].conflicts.clear(); + } for (handle, new_entity) in solved { + // An edit the constraints can only satisfy by collapsing the + // entity (a rotated line whose start is fixed and direction + // constrained) is refused: the entity goes back to its + // pre-edit shape, as the reference does. + let new_entity = match originals.get(&handle) { + Some(original) + if collapsed_by_solve(&new_entity) + && !collapsed_by_solve(original.as_ref()) => + { + original.as_ref().clone() + } + _ => new_entity, + }; if let Some(before) = self.document.get_entity_arc(handle) { self.record_undo_before(handle, Some(before)); } @@ -2638,12 +3813,19 @@ impl Scene { if !is_touched { continue; } + let connected = self.parametric_connected_handles(set.scope, touched, true); + let mut component = set.clone(); + component.constraints.retain(|constraint| constraint.refs.iter() + .any(|reference| connected.contains(&reference.entity))); let Some((solved, _dof, _conflicts)) = solve_scope( &self.document, &self.named_parameters, - set, + &component, driven_refs, retain_size, + false, + &retained_before, + &[], &retained_before, ) else { @@ -2665,6 +3847,174 @@ mod tests { use acadrust::entities::EntityType; use acadrust::types::Vector3; + fn spatial_circle(center: Vector3, radius: f64) -> acadrust::entities::Circle { + let normal = Vector3::UNIT_Y; + let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs( + (center.x, center.y, center.z), + (normal.x, normal.y, normal.z), + ); + let mut circle = acadrust::entities::Circle::from_coords(x, y, z, radius); + circle.normal = normal; + circle + } + + #[test] + fn polyline_constraint_order_follows_stored_vertices_for_open_and_closed_polylines() { + use acadrust::entities::{LwPolyline, LwVertex, Polyline2D, Vertex2D}; + for closed in [false, true] { + let mut scene = Scene::new(); + let points = [(8.0, 0.0), (4.0, 0.0), (4.0, 3.0), (0.0, 3.0)]; + let mut light = LwPolyline::new(); + light.vertices = points.iter().map(|&(x, y)| LwVertex::from_coords(x, y)).collect(); + light.is_closed = closed; + let mut heavy = Polyline2D::new(); + heavy.vertices = points.iter().map(|&(x, y)| Vertex2D::new(Vector3::new(x, y, 0.0))).collect(); + heavy.flags.set_closed(closed); + for entity in [EntityType::LwPolyline(light), EntityType::Polyline2D(heavy)] { + let handle = scene.add_entity(entity); + let mut refs = vec![ + ParametricRef::point(handle, 0), + ParametricRef::segment(handle, 0), + ParametricRef::point(handle, 1), + ParametricRef::segment_midpoint(handle, 1), + ParametricRef::point(handle, 2), + ParametricRef::segment(handle, 2), + ParametricRef::point(handle, 3), + ]; + if closed { + refs.push(ParametricRef::segment(handle, 3)); + } + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); + for reference in refs.iter().rev() { + set.add(ConstraintKind::Fixed, vec![*reference], None); + } + let ordered = super::constraints_in_drawing_order(&scene.document, &set); + assert_eq!(ordered.iter().map(|c| c.refs[0]).collect::>(), refs); + assert_eq!(super::polyline_ref_order(&scene.document, ParametricRef::segment(handle, 1)), + super::polyline_ref_order(&scene.document, ParametricRef::segment_midpoint(handle, 1))); + assert_eq!(set.constraints[0].refs[0], *refs.last().unwrap(), "persistent order must not change"); + } + } + } + + #[test] + fn polyline_relations_follow_the_last_edge_without_swapping_reference_roles() { + let mut scene = Scene::new(); + let mut polyline = acadrust::entities::LwPolyline::new(); + polyline.vertices = [(0.0, 0.0), (4.0, 0.0), (4.0, 3.0), (0.0, 3.0)] + .into_iter().map(|(x, y)| acadrust::entities::LwVertex::from_coords(x, y)).collect(); + polyline.is_closed = true; + let handle = scene.add_entity(EntityType::LwPolyline(polyline)); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); + for (a, b) in [(3, 0), (1, 2), (1, 0)] { + set.add(ConstraintKind::Perpendicular, + vec![ParametricRef::segment(handle, a), ParametricRef::segment(handle, b)], None); + } + let ordered = super::constraints_in_drawing_order(&scene.document, &set); + assert_eq!(ordered.iter().map(|c| c.id).collect::>(), vec![2, 1, 0]); + for constraint in ordered { + assert_eq!(constraint.refs, set.constraints[constraint.id as usize].refs); + } + } + + #[test] + fn non_polyline_constraint_order_is_unchanged() { + let mut scene = Scene::new(); + let line = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + Vector3::ZERO, Vector3::new(4.0, 0.0, 0.0)))); + let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); + for marker in [1, 0] { + set.add(ConstraintKind::Fixed, vec![ParametricRef::point(line, marker)], None); + } + set.add(ConstraintKind::Horizontal, vec![ParametricRef::whole(line)], None); + let ordered = super::constraints_in_drawing_order(&scene.document, &set); + assert_eq!(ordered.iter().map(|c| c.id).collect::>(), vec![0, 1, 2]); + } + + #[test] + fn polyline_preview_satisfies_the_same_constraints_regardless_of_insertion_order() { + for closed in [false, true] { + let mut results = Vec::new(); + for reverse in [false, true] { + let mut scene = Scene::new(); + let mut polyline = acadrust::entities::LwPolyline::new(); + polyline.vertices = [(0.0, 0.0), (4.0, 0.0), (4.0, 3.0), (0.0, 3.0)] + .into_iter().map(|(x, y)| acadrust::entities::LwVertex::from_coords(x, y)).collect(); + polyline.is_closed = closed; + let handle = scene.add_entity(EntityType::LwPolyline(polyline)); + let mut constraints = vec![ + (ConstraintKind::Horizontal, vec![ParametricRef::segment(handle, 0)]), + (ConstraintKind::Perpendicular, vec![ParametricRef::segment(handle, 0), ParametricRef::segment(handle, 1)]), + (ConstraintKind::Parallel, vec![ParametricRef::segment(handle, 0), ParametricRef::segment(handle, 2)]), + ]; + if closed { + constraints.push((ConstraintKind::Perpendicular, + vec![ParametricRef::segment(handle, 2), ParametricRef::segment(handle, 3)])); + } + if reverse { constraints.reverse(); } + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + for (kind, refs) in constraints { set.add(kind, refs, None); } + let before = scene.document.get_entity(handle).unwrap().clone(); + let Some(EntityType::LwPolyline(polyline)) = scene.document.get_entity_mut(handle) else { panic!("polyline") }; + polyline.vertices[0].location = acadrust::types::Vector2::new(-1.0, -2.0); + let solved = scene.solve_parametric_constraints_preview( + &[handle], &[ParametricRef::point(handle, 0)], true, &[(handle, before)]); + let entity = &solved.iter().find(|(h, _)| *h == handle).expect("connected vertices must move").1; + let EntityType::LwPolyline(polyline) = entity else { panic!("polyline") }; + let points: Vec<_> = polyline.vertices.iter().map(|v| v.location).collect(); + assert_eq!(points[0], acadrust::types::Vector2::new(-1.0, -2.0)); + assert!((points[0].y - points[1].y).abs() < 1e-7); + assert!((points[1].x - points[2].x).abs() < 1e-7); + assert!((points[2].y - points[3].y).abs() < 1e-7); + if closed { assert!((points[3].x - points[0].x).abs() < 1e-7); } + results.push(points); + } + for (a, b) in results[0].iter().zip(&results[1]) { + assert!((a.x - b.x).abs() < 1e-7 && (a.y - b.y).abs() < 1e-7); + } + } + } + + #[test] + fn perpendicular_line_grips_distinguish_shared_corner_and_free_ends() { + use acadrust::entities::Line; + for (vertical, marker) in [(false, 0), (false, 1), (true, 1)] { + let mut scene = Scene::new(); + let a = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, Vector3::new(4.0, 0.0, 0.0)))); + let b = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, Vector3::new(0.0, 2.0, 0.0)))); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add(ConstraintKind::Horizontal, vec![ParametricRef::whole(a)], None); + set.add(ConstraintKind::Perpendicular, vec![ParametricRef::whole(a), ParametricRef::whole(b)], None); + set.add(ConstraintKind::Coincident, vec![ParametricRef::point(a, 0), ParametricRef::point(b, 0)], None); + let originals = [a, b].map(|handle| (handle, scene.document.get_entity(handle).unwrap().clone())); + let handle = if vertical { b } else { a }; + let target = Vector3::new(6.0, 3.0, 0.0); + let Some(EntityType::Line(line)) = scene.document.get_entity_mut(handle) else { panic!("line") }; + if marker == 0 { line.start = target; } else { line.end = target; } + let solved = scene.solve_parametric_constraints_preview( + &[handle], &[ParametricRef::point(handle, marker)], true, &originals); + for (handle, entity) in solved { + *scene.document.get_entity_mut(handle).unwrap() = entity; + } + let expected = if marker == 0 { + [[target, Vector3::new(10.0, 3.0, 0.0)], [target, Vector3::new(6.0, 5.0, 0.0)]] + } else if vertical { + [[Vector3::new(6.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)], + [Vector3::new(6.0, 0.0, 0.0), target]] + } else { + [[Vector3::new(0.0, 3.0, 0.0), target], + [Vector3::new(0.0, 3.0, 0.0), Vector3::new(0.0, 5.0, 0.0)]] + }; + for (handle, points) in [a, b].into_iter().zip(expected) { + let Some(EntityType::Line(line)) = scene.document.get_entity(handle) else { panic!("line") }; + assert!((line.start - points[0]).length() < 1e-7, "{vertical}/{marker}: {line:?}"); + assert!((line.end - points[1]).length() < 1e-7, "{vertical}/{marker}: {line:?}"); + } + } + } + #[test] fn validation_rejects_unsupported_entity_shapes_and_planes() { let mut scene = Scene::new(); @@ -2914,69 +4264,87 @@ mod tests { ); } - /// `solve_parametric_constraints_preview` reports the constrained - /// neighbor's solved position without writing anything into the - /// document itself — the grip-drag caller owns applying it. + /// A grip preview lets the paired line and tangent circle follow the + /// edited line while preserving their sizes and parallel separation. #[test] - fn preview_solve_reports_the_neighbors_new_state_without_mutating_the_document() { + fn preview_solve_preserves_parallel_spacing_through_tangent_radius() { let mut scene = Scene::new(); let a = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( Vector3::new(0.0, 0.0, 0.0), - Vector3::new(10.0, 0.0, 0.0), + Vector3::new(0.0, 10.0, 0.0), ))); let b = scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points( - Vector3::new(0.0, 5.0, 0.0), - Vector3::new(10.0, 5.0, 0.0), + Vector3::new(2.0, 0.0, 0.0), + Vector3::new(2.0, 10.0, 0.0), ))); + let circle = scene.add_entity(EntityType::Circle( + acadrust::entities::Circle::from_center_radius(Vector3::new(1.0, 5.0, 0.0), 1.0), + )); let mut set = ParametricConstraintSet::new(ParametricScope::ModelSpace); set.add( ConstraintKind::Parallel, vec![ParametricRef::whole(a), ParametricRef::whole(b)], None, ); + set.add( + ConstraintKind::Tangent, + vec![ParametricRef::whole(a), ParametricRef::whole(circle)], + None, + ); + set.add( + ConstraintKind::Tangent, + vec![ParametricRef::whole(b), ParametricRef::whole(circle)], + None, + ); scene.parametric_constraints.push(set); - // Simulate a live grip drag: mutate `a` directly (as `apply_grip` - // would each frame), without going through `bump_entities` at all. + let a_before = scene.document.get_entity(a).unwrap().clone(); if let Some(EntityType::Line(l)) = scene.document.get_entity_mut(a) { - l.end = Vector3::new(10.0, 6.0, 0.0); + l.end = Vector3::new(2.0, 10.0, 0.0); } let b_before = scene.document.get_entity(b).cloned(); - let solved = scene.solve_parametric_constraints_preview(&[a], &[], false, &[]); - + let solved = scene.solve_parametric_constraints_preview( + &[a], + &[ParametricRef::point(a, 0), ParametricRef::point(a, 1)], + true, + &[(a, a_before)], + ); assert_eq!( scene.document.get_entity(b), b_before.as_ref(), "preview must not mutate the document" ); - // The whole scope solves together (every registered entity's params - // are free, not just `b`'s — see `register_entity`'s doc comment), - // so `a` itself may also have shifted slightly to reach the nearest - // mutually-parallel configuration; read whichever position `a` ends - // up at from `solved` too, rather than assuming it stayed exactly - // where the simulated drag put it. - let line_dir = |entity: &EntityType| { - let EntityType::Line(l) = entity else { - panic!("expected a Line") - }; - (l.end.x - l.start.x, l.end.y - l.start.y) + let solved_entity = |handle| { + solved + .iter() + .find(|(candidate, _)| *candidate == handle) + .map(|(_, entity)| entity) + .unwrap_or_else(|| scene.document.get_entity(handle).unwrap()) }; - let dir_a = solved - .iter() - .find(|(h, _)| *h == a) - .map(|(_, e)| line_dir(e)) - .unwrap_or((10.0, 6.0)); - let (_, moved_entity) = solved - .iter() - .find(|(h, _)| *h == b) - .expect("b should be reported as moved"); - let dir_b = line_dir(moved_entity); - let cross = dir_a.0 * dir_b.1 - dir_a.1 * dir_b.0; + let EntityType::Line(moved_a) = solved_entity(a) else { + panic!("expected a Line") + }; + let EntityType::Line(moved_b) = solved_entity(b) else { + panic!("expected a Line") + }; + let EntityType::Circle(moved_circle) = solved_entity(circle) else { + panic!("expected a Circle") + }; + assert_eq!(moved_a.start, Vector3::new(0.0, 0.0, 0.0)); + assert_eq!(moved_a.end, Vector3::new(2.0, 10.0, 0.0)); + assert_ne!(solved_entity(b), b_before.as_ref().unwrap()); + let dir_a = moved_a.end - moved_a.start; + let dir_b = moved_b.end - moved_b.start; + let cross = dir_a.x * dir_b.y - dir_a.y * dir_b.x; + assert!(cross.abs() < 1.0e-6); + let offset = moved_b.start - moved_a.start; + let separation = (dir_a.x * offset.y - dir_a.y * offset.x).abs() / dir_a.length(); assert!( - cross.abs() < 1e-6, - "the previewed positions should be mutually parallel: dir_a={dir_a:?} dir_b={dir_b:?}" + (separation - 2.0).abs() < 1.0e-6, + "separation={separation}, a={moved_a:?}, b={moved_b:?}, circle={moved_circle:?}" ); + assert!((moved_circle.radius - 1.0).abs() < 1.0e-9); } #[test] @@ -3151,4 +4519,173 @@ mod tests { assert!(jet.tangent[1].abs() < 1e-9, "jet={jet:?}"); assert!(cadkernel::space::Vec3::from(jet.curvature).length() < 5e-3); } + + #[test] + fn smooth_constraint_uses_the_selected_polyline_arc_curvature() { + let mut scene = Scene::new(); + let mut target = acadrust::entities::LwPolyline::new(); + target.vertices = vec![ + acadrust::entities::LwVertex::with_bulge( + acadrust::types::Vector2::new(0.0, 0.0), + 1.0, + ), + acadrust::entities::LwVertex::from_coords(10.0, 0.0), + ]; + let target = scene.add_entity(EntityType::LwPolyline(target)); + let mut spline = acadrust::entities::Spline::new(); + spline.degree = 3; + spline.control_points = vec![ + Vector3::new(2.0, 1.0, 0.0), + Vector3::new(3.0, 2.0, 0.0), + Vector3::new(4.0, 3.0, 0.0), + Vector3::new(5.0, 3.0, 0.0), + ]; + spline.knots = cadkernel::space::clamped_uniform_knots(3, 4); + let spline = scene.add_entity(EntityType::Spline(spline)); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Smooth, + vec![ + ParametricRef::point(spline, 0), + ParametricRef::point(target, 0), + ParametricRef::segment(target, 0), + ], + None, + ); + + scene.bump_entities(&[(target, super::ChangeKind::Modified)]); + + let EntityType::Spline(spline) = scene.document.get_entity(spline).unwrap() else { + panic!("expected spline"); + }; + let curve = crate::entities::spline::nurbs3(spline).unwrap(); + let jet = + cadkernel::space::CurveJet::from_nurbs(&curve, cadkernel::space::SplineEnd::Start) + .unwrap(); + assert!(cadkernel::space::Vec3::from(jet.point).length() < 1.0e-9); + assert!(jet.tangent[0].abs() < 1.0e-9, "jet={jet:?}"); + assert!( + (cadkernel::space::Vec3::from(jet.curvature).length() - 0.2).abs() < 2.0e-4, + "jet={jet:?}" + ); + } + + #[test] + fn smooth_constraint_accepts_a_stale_normal_on_an_arbitrary_plane() { + let mut scene = Scene::new(); + let target = scene.add_entity(EntityType::Line( + acadrust::entities::Line::from_points( + Vector3::new(0.0, 0.0, 1.0), + Vector3::new(5.0, 0.0, 1.0), + ), + )); + let mut source = acadrust::entities::Spline::new(); + source.degree = 3; + source.normal = Vector3::UNIT_Z; + source.control_points = vec![ + Vector3::new(1.0, 0.0, 0.0), + Vector3::new(2.0, 0.0, 2.0), + Vector3::new(4.0, 0.0, 2.0), + Vector3::new(5.0, 0.0, 3.0), + ]; + source.knots = cadkernel::space::clamped_uniform_knots(3, 4); + let source = scene.add_entity(EntityType::Spline(source)); + let refs = vec![ + ParametricRef::point(source, 0), + ParametricRef::point(target, 0), + ]; + assert!(scene + .validate_parametric_constraint(ConstraintKind::Smooth, &refs, None) + .is_ok()); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add(ConstraintKind::Smooth, refs, None); + + scene.bump_entities(&[(target, super::ChangeKind::Modified)]); + + let EntityType::Spline(source) = scene.document.get_entity(source).unwrap() else { + panic!("expected spline"); + }; + let curve = crate::entities::spline::nurbs3(source).unwrap(); + let jet = + cadkernel::space::CurveJet::from_nurbs(&curve, cadkernel::space::SplineEnd::Start) + .unwrap(); + assert!(cadkernel::space::Vec3::from(jet.point) + .distance(cadkernel::space::Vec3::new(0.0, 0.0, 1.0)) + < 1.0e-9); + assert!(jet.tangent[1].abs() < 1.0e-9 && jet.tangent[2].abs() < 1.0e-9); + } + + #[test] + fn spatial_concentric_initial_solve_keeps_the_first_circle_fixed() { + let mut scene = Scene::new(); + let first = scene.add_entity(EntityType::Circle(spatial_circle(Vector3::ZERO, 2.0))); + let second = scene.add_entity(EntityType::Circle(spatial_circle( + Vector3::new(5.0, 0.0, 3.0), + 4.0, + ))); + let first_ref = ParametricRef::center(first); + let second_ref = ParametricRef::center(second); + scene + .parametric_constraint_set_mut(ParametricScope::ModelSpace) + .add( + ConstraintKind::Concentric, + vec![first_ref, second_ref], + None, + ); + + scene.bump_entities_with_initial_parametric_policy( + &[ + (first, super::ChangeKind::Modified), + (second, super::ChangeKind::Modified), + ], + &[first_ref], + true, + ); + + let EntityType::Circle(first_circle) = scene.document.get_entity(first).unwrap() else { + panic!("expected circle"); + }; + let EntityType::Circle(second_circle) = scene.document.get_entity(second).unwrap() else { + panic!("expected circle"); + }; + assert!(first_circle.center_wcs().length() < 1.0e-9); + assert!(second_circle.center_wcs().length() < 1.0e-9); + assert!((second_circle.radius - 4.0).abs() < 1.0e-12); + } + + #[test] + fn spatial_concentric_chain_follows_the_driven_end() { + let mut scene = Scene::new(); + let handles = [1.0, 2.0, 3.0].map(|radius| { + scene.add_entity(EntityType::Circle(spatial_circle(Vector3::ZERO, radius))) + }); + let set = scene.parametric_constraint_set_mut(ParametricScope::ModelSpace); + set.add( + ConstraintKind::Concentric, + vec![ParametricRef::center(handles[0]), ParametricRef::center(handles[1])], + None, + ); + set.add( + ConstraintKind::Concentric, + vec![ParametricRef::center(handles[1]), ParametricRef::center(handles[2])], + None, + ); + let driven_center = Vector3::new(8.0, 0.0, -3.0); + *scene.document.get_entity_mut(handles[2]).unwrap() = + EntityType::Circle(spatial_circle(driven_center, 3.0)); + + scene.bump_entities_with_parametric_driven( + &[(handles[2], super::ChangeKind::Modified)], + &[ParametricRef::whole(handles[2])], + ); + + for handle in handles { + let EntityType::Circle(circle) = scene.document.get_entity(handle).unwrap() else { + panic!("expected circle"); + }; + assert!((circle.center_wcs() - driven_center).length() < 1.0e-9); + } + } } diff --git a/src/scene/pick/selection_state.rs b/src/scene/pick/selection_state.rs index 067a9c2c2..22faca9a5 100644 --- a/src/scene/pick/selection_state.rs +++ b/src/scene/pick/selection_state.rs @@ -30,9 +30,13 @@ pub struct SelectionState { pub poly_points: Vec, pub poly_crossing: bool, pub poly_last_crossing: bool, + /// Canvas position the right-click context menu is anchored at while it + /// is open. `None` = closed. Its rows are rebuilt from app state every + /// frame (`ui::popup::context_menu::build_context_menu`). pub context_menu: Option, - /// True while the context menu's Draw Order sub-items are expanded. - pub draworder_submenu: bool, + /// Transient UI state of the open context menu (expanded submenu, + /// keyboard highlight). Reset every time the menu opens. + pub context_menu_ui: ContextMenuUi, pub last_move_pos: Option, pub left_down: bool, pub left_press_pos: Option, @@ -57,7 +61,31 @@ pub struct SelectionState { pub middle_last_press_time: Option, } +/// Transient state of the open right-click context menu. +#[derive(Clone, Default)] +pub struct ContextMenuUi { + /// The accordion submenu currently expanded (at most one). + pub open_submenu: Option, + /// Keyboard highlight as an index into `ContextMenu::selectable()`. + /// `None` until an arrow key / mnemonic is pressed, so a mouse user is + /// never shown a highlighted row that is not under the pointer. + pub highlighted: Option, +} + impl SelectionState { + /// Open the context menu at `at`, discarding the previous menu's + /// expanded-submenu / highlight state. + pub fn open_context_menu(&mut self, at: Point) { + self.context_menu = Some(at); + self.context_menu_ui = ContextMenuUi::default(); + } + + /// Close the context menu (no-op when it is not open). + pub fn close_context_menu(&mut self) { + self.context_menu = None; + self.context_menu_ui = ContextMenuUi::default(); + } + /// End every left-button selection gesture without disturbing the previous /// completed-window record or command-owned preview marquee. Grip editing /// owns the left button while engaged and calls this before/after placement diff --git a/src/scene/pipeline/circle_gpu.rs b/src/scene/pipeline/circle_gpu.rs index f5e898cd5..a9cd421e2 100644 --- a/src/scene/pipeline/circle_gpu.rs +++ b/src/scene/pipeline/circle_gpu.rs @@ -113,7 +113,7 @@ pub fn create_pipelines( ) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("circle.wgsl"), - source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/circle.wgsl").into()), + source: wgpu::ShaderSource::Wgsl(draw_order_shader!("circle.wgsl").into()), }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("circle.pipeline.layout"), @@ -145,7 +145,7 @@ pub fn create_pipelines( }, depth_stencil: Some(wgpu::DepthStencilState { format: wgpu::TextureFormat::Depth24PlusStencil8, - depth_write_enabled: Some(false), + depth_write_enabled: Some(true), depth_compare: Some(wgpu::CompareFunction::LessEqual), stencil: content_stencil.clone(), bias: wgpu::DepthBiasState::default(), @@ -349,7 +349,7 @@ mod tests { #[test] fn circle_shader_validates_with_naga() { - let source = include_str!("../../shaders/circle.wgsl"); + let source = draw_order_shader!("circle.wgsl"); let module = naga::front::wgsl::parse_str(source).expect("circle.wgsl parses cleanly"); let mut validator = naga::valid::Validator::new( naga::valid::ValidationFlags::all(), diff --git a/src/scene/pipeline/depth_tests.rs b/src/scene/pipeline/depth_tests.rs new file mode 100644 index 000000000..24e4c2c73 --- /dev/null +++ b/src/scene/pipeline/depth_tests.rs @@ -0,0 +1,454 @@ +//! Pixel regressions for projection and draw-order clipping, using the actual +//! wire pipelines in both storage and WebGL-compatible packed modes. +use super::*; +use crate::scene::view::camera::{Camera, Projection}; +use iced::futures::executor::block_on; + +const SIZE: u32 = 512; + +fn pixels( + device: &wgpu::Device, + queue: &wgpu::Queue, + pipeline: &mut Pipeline, + uniforms: &Uniforms, + wires: &[WireModel], + depths: &rustc_hash::FxHashMap, +) -> Vec { + let size = wgpu::Extent3d { + width: SIZE, + height: SIZE, + depth_or_array_layers: 1, + }; + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("draw-order regression"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&Default::default()); + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("draw-order readback"), + size: (SIZE * SIZE * 4) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + pipeline.ensure_depth_texture(device, Size::new(SIZE, SIZE)); + pipeline.upload_uniforms(device, queue, uniforms); + pipeline.gpu_wires = std::sync::Arc::new(WireGpu::from_batch( + device, + queue, + wires, + depths, + pipeline.wire_const_bgl.as_ref(), + )); + let mut encoder = device.create_command_encoder(&Default::default()); + let rect = Rectangle { + x: 0, + y: 0, + width: SIZE, + height: SIZE, + }; + pipeline.render( + &mut encoder, + &view, + Rectangle::with_size(Size::new(SIZE as f32, SIZE as f32)), + rect, + rect, + [0., 0., 0., 1.], + false, + false, + false, + ); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(SIZE * 4), + rows_per_image: Some(SIZE), + }, + }, + size, + ); + queue.submit([encoder.finish()]); + let (tx, rx) = std::sync::mpsc::channel(); + readback.slice(..).map_async(wgpu::MapMode::Read, move |r| { + tx.send(r).unwrap(); + }); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + rx.recv().unwrap().unwrap(); + let result = readback.slice(..).get_mapped_range().to_vec(); + readback.unmap(); + result +} + +fn color_at(bytes: &[u8], ndc_x: f32) -> [u32; 3] { + let x = ((ndc_x * 0.5 + 0.5) * SIZE as f32) as usize; + let mut color = [0; 3]; + for col in x - 2..=x + 2 { + let offset = (SIZE as usize / 2 * SIZE as usize + col) * 4; + for c in 0..3 { + color[c] += bytes[offset + c] as u32; + } + } + color +} + +#[test] +#[ignore = "requires a GPU adapter"] +fn wipeout_respects_close_block_draw_order() { + use acadrust::{entities::Wipeout, types::Vector2, EntityType}; + use crate::scene::{model::wire_model::TangentGeom, Scene}; + + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&Default::default())).expect("GPU adapter"); + for packed in [false, true] { + let mut limits = adapter.limits(); + if packed { + limits.max_storage_buffers_per_shader_stage = 0; + } + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: limits, + ..Default::default() + })) + .expect("GPU device"); + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Rgba8Unorm); + let bounds = Rectangle::with_size(Size::new(SIZE as f32, SIZE as f32)); + let mut uniforms = Uniforms::new(&Camera::default(), bounds, false); + uniforms.view_rot = glam::Mat4::IDENTITY; + uniforms.eye_high = [0.; 3]; + uniforms.eye_low = [0.; 3]; + let mut scene = Scene::new(); + scene.add_entity(EntityType::Wipeout(Wipeout::polygonal( + &[ + Vector2::new(-0.7, -0.7), + Vector2::new(0.7, -0.7), + Vector2::new(0.7, 0.7), + Vector2::new(-0.7, 0.7), + ], + 0.5, + ))); + let mut masks = scene.wipeout_models_arc().as_ref().clone(); + assert_eq!(masks.len(), 1); + masks[0].color = [0., 0., 0., 1.]; + masks[0].draw_depth = 0.1; + pipeline.upload_wipeouts(&device, &queue, &masks); + // Block children share a narrow sub-range of their insert's order. + // The mask must hide earlier geometry without erasing later geometry. + let depths = [(1, [0.0998, 0.]), (2, [0.1002, 0.])].into_iter().collect(); + for kind in ["line", "circle", "arc", "ellipse"] { + let wires: Vec<_> = [(-0.3, 1), (0.3, 2)] + .into_iter() + .map(|(x, id)| { + let mut wire = WireModel::solid( + id.to_string(), + Vec::new(), + [1.; 4], + false, + ); + let center = [x as f64 - 0.1, 0., 0.5]; + let axis_x = [1., 0., 0.]; + let axis_y = [0., 1., 0.]; + match kind { + "circle" => wire.tangent_geoms.push(TangentGeom::PlanarCircle { + center, axis_x, axis_y, radius: 0.1, + }), + "arc" => wire.tangent_geoms.push(TangentGeom::Arc { + center, axis_x, axis_y, radius: 0.1, + start_angle: -std::f64::consts::FRAC_PI_2, + end_angle: std::f64::consts::FRAC_PI_2, + }), + "ellipse" => wire.tangent_geoms.push(TangentGeom::PlanarEllipse { + center, major_axis: [0.1, 0., 0.], normal: [0., 0., 1.], + minor_axis_ratio: 0.6, start_param: 0., end_param: std::f64::consts::TAU, + }), + _ => wire.points = vec![[x, -0.65, 0.5], [x, 0.65, 0.5]], + } + wire + }) + .collect(); + pipeline.gpu_circles = std::sync::Arc::new(pipeline.upload_circles(&device, &queue, &wires, &depths)); + pipeline.gpu_ellipses = std::sync::Arc::new(pipeline.upload_ellipses(&device, &queue, &wires, &depths)); + let bytes = pixels(&device, &queue, &mut pipeline, &uniforms, &wires, &depths); + assert_eq!(color_at(&bytes, -0.3), [0; 3], "mask must cover earlier {kind}"); + assert_ne!(color_at(&bytes, 0.3), [0; 3], "mask erased later {kind}, packed={packed}"); + } + assert!(block_on(validation.pop()).is_none(), "GPU validation failed"); + } +} + +#[test] +#[ignore = "requires a GPU adapter"] +fn perspective_draw_order_keeps_flat_lines_visible() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&Default::default())).expect("GPU adapter"); + let bounds = Rectangle::with_size(Size::new(SIZE as f32, SIZE as f32)); + let mut failures = Vec::new(); + for packed in [false, true] { + let mut limits = adapter.limits(); + if packed { + limits.max_storage_buffers_per_shader_stage = 0; + } + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: limits, + ..Default::default() + })) + .expect("GPU device"); + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Rgba8Unorm); + for projection in [Projection::Orthographic, Projection::Perspective] { + let camera = Camera { + distance: 102.03651, + fov_y: 0.47108996, + rotation: glam::Quat::IDENTITY, + target: glam::DVec3::ZERO, + projection, + ..Camera::default() + }; + // Same 17-entity draw-order labels as the reported drawing. Its + // missing line (45EB) occupies index 1 with depth -0.5794896. + let depths = (0..17) + .map(|i| { + ( + i + 1, + [ + (-800_000_000_i64 + 88_888_888 * (i + 1) as i64) as f32 + / 1_073_741_824., + 0., + ], + ) + }) + .collect(); + let wires: Vec<_> = (0..17) + .map(|i| { + let x = (-0.8 + i as f32 * 0.1) * camera.ortho_size(); + let h = camera.ortho_size() * 0.65; + WireModel::solid( + (i + 1).to_string(), + vec![[x, -h, 0.], [x, h, 0.]], + if i < 2 { [1., 0.3, 0.2, 1.] } else { [1.; 4] }, + false, + ) + }) + .collect(); + let bytes = pixels( + &device, + &queue, + &mut pipeline, + &Uniforms::new(&camera, bounds, false), + &wires, + &depths, + ); + if let Ok(path) = std::env::var("OCS_DEPTH_TEST_IMAGES") { + let path = std::path::Path::new(&path); + std::fs::create_dir_all(path).unwrap(); + image::save_buffer( + path.join(format!( + "{projection:?}-{}.png", + if packed { "packed" } else { "storage" } + )), + &bytes, + SIZE, + SIZE, + image::ColorType::Rgba8, + ) + .unwrap(); + } + for i in 0..17 { + if color_at(&bytes, -0.8 + i as f32 * 0.1) == [0; 3] { + failures.push(format!("{projection:?}, packed={packed}, line {i}")); + } + } + } + assert!( + block_on(validation.pop()).is_none(), + "GPU validation failed" + ); + } + assert!( + failures.is_empty(), + "visible lines were clipped: {failures:?}" + ); +} + +#[test] +fn all_draw_order_shaders_validate() { + for source in [ + draw_order_shader!("wire.wgsl"), + draw_order_shader!("wire_indexed.wgsl"), + draw_order_shader!("block_wire.wgsl"), + draw_order_shader!("block_wire_storage.wgsl"), + draw_order_shader!("circle.wgsl"), + draw_order_shader!("ellipse.wgsl"), + draw_order_shader!("text.wgsl"), + draw_order_shader!("block_text.wgsl"), + draw_order_shader!("face3d.wgsl"), + draw_order_shader!("block_face3d.wgsl"), + draw_order_shader!("hatch.wgsl"), + draw_order_shader!("hatch_texture.wgsl"), + draw_order_shader!("image.wgsl"), + draw_order_shader!("wipeout.wgsl"), + ] { + let module = naga::front::wgsl::parse_str(source).expect("composed shader parses"); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .expect("composed shader validates"); + } +} + +#[test] +#[ignore = "requires a GPU adapter"] +fn draw_order_preserves_clip_boundaries_sorting_and_mesh_occlusion() { + use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&Default::default())).expect("GPU adapter"); + for packed in [false, true] { + let mut limits = adapter.limits(); + if packed { + limits.max_storage_buffers_per_shader_stage = 0; + } + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: limits, + ..Default::default() + })) + .expect("GPU device"); + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Rgba8Unorm); + let bounds = Rectangle::with_size(Size::new(SIZE as f32, SIZE as f32)); + let mut uniforms = Uniforms::new(&Camera::default(), bounds, false); + uniforms.view_rot = glam::Mat4::IDENTITY; + uniforms.eye_high = [0.; 3]; + uniforms.eye_low = [0.; 3]; + let line = |id: u64, x: f32, z: f32, color: [f32; 4]| { + WireModel::solid( + id.to_string(), + vec![[x, -0.65, z], [x, 0.65, z]], + color, + false, + ) + }; + // Both bias signs, the paper's -2 order, and already-clipped geometry. + let cases = [ + (0.00001, 2., true), + (0.00001, -2., true), + (0.99999, 2., true), + (0.99999, -2., true), + (-0.00001, -2., false), + (1.00001, 2., false), + (0., 2., true), + (1., -2., true), + (0.5, 0., true), + ]; + let wires: Vec<_> = cases + .iter() + .enumerate() + .map(|(i, (z, _, _))| line(i as u64 + 1, -0.8 + i as f32 * 0.2, *z, [1.; 4])) + .collect(); + let depths = cases + .iter() + .enumerate() + .map(|(i, (_, order, _))| (i as u64 + 1, [*order, 0.])) + .collect(); + let bytes = pixels(&device, &queue, &mut pipeline, &uniforms, &wires, &depths); + for (i, (z, order, visible)) in cases.iter().enumerate() { + assert_eq!( + color_at(&bytes, -0.8 + i as f32 * 0.2) != [0; 3], + *visible, + "packed={packed}, depth={z}, order={order}" + ); + } + + // Coplanar draw order must work close to both planes, for negative + // ranks too, regardless of the order in which the GPU sees the wires. + for z in [0.0001, 0.5, 0.9999] { + for (back, front) in [(-0.7, 0.7), (-2., -0.9), (0.3, 0.8)] { + let depths = rustc_hash::FxHashMap::from_iter([(1, [back, 0.]), (2, [front, 0.])]); + let mut wires = [ + line(1, 0., z, [1., 0., 0., 1.]), + line(2, 0., z, [0., 1., 0., 1.]), + ]; + for _ in 0..2 { + let bytes = pixels(&device, &queue, &mut pipeline, &uniforms, &wires, &depths); + let [red, green, _] = color_at(&bytes, 0.); + assert!( + green > 0 && red == 0, + "draw order reversed at z={z}, packed={packed}" + ); + wires.reverse(); + } + } + } + + // A real triangle mesh still occludes a wire behind it. A wire in front + // stays visible: neutral draw order must retain geometric depth. + pipeline.upload_mesh_batch( + &device, + &queue, + &[MeshLodSet::from_single(MeshModel { + name: "100".into(), + verts: vec![ + [-0.6, -0.8, 0.5], + [0.6, -0.8, 0.5], + [0.6, 0.8, 0.5], + [-0.6, 0.8, 0.5], + ], + verts_low: Vec::new(), + normals: vec![[0., 0., 1.]; 4], + indices: vec![0, 1, 2, 0, 2, 3], + triangle_material_handles: Vec::new(), + triangle_colors: Vec::new(), + color: [0., 1., 0., 1.], + selected: false, + })], + ); + let mesh_only = pixels( + &device, + &queue, + &mut pipeline, + &uniforms, + &[], + &Default::default(), + ); + assert_ne!( + color_at(&mesh_only, -0.3), + [0; 3], + "mesh must produce pixels" + ); + let bytes = pixels( + &device, + &queue, + &mut pipeline, + &uniforms, + &[ + line(1, -0.3, 0.6, [1., 0., 0., 1.]), + line(2, 0.3, 0.4, [0., 0., 1., 1.]), + ], + &Default::default(), + ); + assert_eq!( + color_at(&bytes, -0.3), + color_at(&mesh_only, -0.3), + "mesh failed to occlude the rear wire, packed={packed}" + ); + let [red, _, blue] = color_at(&bytes, 0.3); + assert!(blue > red, "mesh hid the front wire, packed={packed}"); + assert!( + block_on(validation.pop()).is_none(), + "GPU validation failed" + ); + } +} diff --git a/src/scene/pipeline/ellipse_gpu.rs b/src/scene/pipeline/ellipse_gpu.rs index 0ed65407e..966859365 100644 --- a/src/scene/pipeline/ellipse_gpu.rs +++ b/src/scene/pipeline/ellipse_gpu.rs @@ -113,7 +113,7 @@ pub fn create_pipelines( ) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("ellipse.wgsl"), - source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/ellipse.wgsl").into()), + source: wgpu::ShaderSource::Wgsl(draw_order_shader!("ellipse.wgsl").into()), }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("ellipse.pipeline.layout"), @@ -145,7 +145,7 @@ pub fn create_pipelines( }, depth_stencil: Some(wgpu::DepthStencilState { format: wgpu::TextureFormat::Depth24PlusStencil8, - depth_write_enabled: Some(false), + depth_write_enabled: Some(true), depth_compare: Some(wgpu::CompareFunction::LessEqual), stencil: content_stencil.clone(), bias: wgpu::DepthBiasState::default(), @@ -305,7 +305,7 @@ mod tests { #[test] fn ellipse_shader_validates_with_naga() { - let source = include_str!("../../shaders/ellipse.wgsl"); + let source = draw_order_shader!("ellipse.wgsl"); let module = naga::front::wgsl::parse_str(source).expect("ellipse.wgsl parses cleanly"); let mut validator = naga::valid::Validator::new( naga::valid::ValidationFlags::all(), diff --git a/src/scene/pipeline/hatch_gpu/mod.rs b/src/scene/pipeline/hatch_gpu/mod.rs index ea5db5789..39ece95f8 100644 --- a/src/scene/pipeline/hatch_gpu/mod.rs +++ b/src/scene/pipeline/hatch_gpu/mod.rs @@ -78,9 +78,9 @@ impl HatchGpu { "hatch.texture.shader" }), source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(if uses_storage { - include_str!("../../../shaders/hatch.wgsl") + draw_order_shader!("hatch.wgsl") } else { - include_str!("../../../shaders/hatch_texture.wgsl") + draw_order_shader!("hatch_texture.wgsl") })), }); let vertex_layouts = if uses_storage { diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 49172946a..8989c8538 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,5 +1,22 @@ +// WGSL has no includes. Compose one shared draw-order function into each 2D +// shader at compile time, including the sources used by shader validation tests. +macro_rules! draw_order_shader { + ($file:literal) => { + concat!( + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/shaders/draw_order.wgsl" + )), + "\n", + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/shaders/", $file)) + ) + }; +} + #[cfg(test)] mod gpu_tests; +#[cfg(test)] +mod depth_tests; mod device_capabilities; pub mod circle_gpu; pub mod ellipse_gpu; @@ -36,6 +53,13 @@ use crate::scene::model::image_model::ImageModel; use crate::scene::model::mesh_model::MeshLodSet; use crate::scene::model::wire_model::WireModel; +/// Worst-case raster depth bias (in 24-bit quanta, toward the camera) block +/// text is required to clear. The wipeout pipeline currently carries no bias +/// (a raster bias can jump a block's narrow depth band and erase its +/// foreground), so this is a safety budget: the block-text regression asserts +/// a larger margin over whatever bias the pipeline could reintroduce. +pub const WIPEOUT_DEPTH_BIAS_QUANTA: i32 = 2; + struct SilhouetteChunk { vertex_buffer: wgpu::Buffer, instance_buffer: wgpu::Buffer, @@ -614,18 +638,18 @@ impl Pipeline { label: Some("wire.shader"), source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(match wire_mode { wire_gpu::WirePipelineMode::IndexedStorage => { - include_str!("../../shaders/wire_indexed.wgsl") + draw_order_shader!("wire_indexed.wgsl") } - wire_gpu::WirePipelineMode::Packed => include_str!("../../shaders/wire.wgsl"), + wire_gpu::WirePipelineMode::Packed => draw_order_shader!("wire.wgsl"), })), }); let block_wire_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("block_wire.shader"), source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed( if wire_mode.uses_storage() { - include_str!("../../shaders/block_wire_storage.wgsl") + draw_order_shader!("block_wire_storage.wgsl") } else { - include_str!("../../shaders/block_wire.wgsl") + draw_order_shader!("block_wire.wgsl") }, )), }); @@ -1011,8 +1035,8 @@ impl Pipeline { let wipeout_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("wipeout.shader"), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( - "../../shaders/wipeout.wgsl" + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!( + "wipeout.wgsl" ))), }); @@ -1038,16 +1062,15 @@ impl Pipeline { depth_write_enabled: Some(true), depth_compare: Some(wgpu::CompareFunction::LessEqual), stencil: content_stencil.clone(), - // Bias TOWARD the camera: a wipeout must win against geometry at - // its own depth (a block's wipeout + shapes are coincident at - // Z=0). A positive bias pushed the mask behind, so LessEqual - // rejected it and the geometry showed through. Tiny enough that - // meaningfully-nearer geometry still occludes the mask. - bias: wgpu::DepthBiasState { - constant: -8, - slope_scale: -1.0, - clamp: 0.0, - }, + // The shader already applies draw order. A raster bias can + // jump across a block's narrow range and erase its foreground + // — block text clears its siblings' wipes by only 9-16 depth + // quanta, so the pipeline must stay bias-free. Coincident ties + // still resolve toward the mask, which draws later under + // LessEqual. WIPEOUT_DEPTH_BIAS_QUANTA is the budget the + // block-text regression asserts against if a bias ever + // returns. + bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState { count: MSAA_SAMPLES, @@ -1826,14 +1849,14 @@ impl Pipeline { // ── Face3D pipeline ──────────────────────────────────────────────── let face3d_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("face3d.shader"), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( - "../../shaders/face3d.wgsl" + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!( + "face3d.wgsl" ))), }); let block_face3d_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("block_face3d.shader"), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( - "../../shaders/block_face3d.wgsl" + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!( + "block_face3d.wgsl" ))), }); @@ -2038,8 +2061,8 @@ impl Pipeline { let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("image.shader"), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( - "../../shaders/image.wgsl" + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(draw_order_shader!( + "image.wgsl" ))), }); diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index 136e0945b..bf1bb1b60 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -341,7 +341,7 @@ pub fn create_pipelines( ) { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("text.wgsl"), - source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/text.wgsl").into()), + source: wgpu::ShaderSource::Wgsl(draw_order_shader!("text.wgsl").into()), }); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("text.pipeline.layout"), @@ -390,7 +390,7 @@ pub fn create_pipelines( }; let block_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("block_text.wgsl"), - source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/block_text.wgsl").into()), + source: wgpu::ShaderSource::Wgsl(draw_order_shader!("block_text.wgsl").into()), }); let create_block = |label, depth_write_enabled, depth_compare| { device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { diff --git a/src/scene/pipeline/viewcube.rs b/src/scene/pipeline/viewcube.rs index 782f10a1e..fc1709aab 100644 --- a/src/scene/pipeline/viewcube.rs +++ b/src/scene/pipeline/viewcube.rs @@ -142,24 +142,39 @@ impl CubeRegion { /// Unit eye-direction vector (from target toward the camera) that /// looks straight at this region. Used by `Camera::snap_to_direction` - /// which derives the full orientation by re-using the current - /// camera's up vector, projected onto the plane perpendicular to - /// this direction — so clicking an edge spins the cube around the - /// edge without rolling the user's "up" sense. + /// / `snap_to_face` which derive a deterministic horizon (world +Z, + /// except top/bottom where north +Y is used) so every cube click + /// lands repeatably. pub fn snap_direction(self) -> glam::Vec3 { let c = region_centroids()[self.id()]; glam::Vec3::new(c[0], c[1], c[2]).normalize_or(glam::Vec3::Z) } pub fn opposite(self) -> CubeRegion { - match self { - CubeRegion::Face(FACE_TOP) => CubeRegion::Face(FACE_BOTTOM), - CubeRegion::Face(FACE_BOTTOM) => CubeRegion::Face(FACE_TOP), - CubeRegion::Face(FACE_FRONT) => CubeRegion::Face(FACE_BACK), - CubeRegion::Face(FACE_BACK) => CubeRegion::Face(FACE_FRONT), - CubeRegion::Face(FACE_RIGHT) => CubeRegion::Face(FACE_LEFT), - CubeRegion::Face(FACE_LEFT) => CubeRegion::Face(FACE_RIGHT), - other => other, + // Generic opposite: the region whose centroid is nearest to -self. + // Covers faces, edges and corners so "already there → flip" works + // for every cube click, not just faces. + let centroids = region_centroids(); + let c = centroids[self.id()]; + let neg = [-c[0], -c[1], -c[2]]; + let mut best = self.id(); + let mut best_d = f32::MAX; + for (i, cc) in centroids.iter().enumerate() { + let dx = cc[0] - neg[0]; + let dy = cc[1] - neg[1]; + let dz = cc[2] - neg[2]; + let d = dx * dx + dy * dy + dz * dz; + if d < best_d { + best_d = d; + best = i; + } + } + if best < 6 { + CubeRegion::Face(best) + } else if best < 18 { + CubeRegion::Edge(best) + } else { + CubeRegion::Corner(best) } } diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index e58ebd8fb..292db3963 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -1460,7 +1460,7 @@ mod block_wire_storage_tests { /// real device, so the stride is checked against the Rust record instead. #[test] fn segment_struct_matches_the_rust_record() { - let module = module(include_str!("../../shaders/block_wire_storage.wgsl")); + let module = module(draw_order_shader!("block_wire_storage.wgsl")); validate(&module); let mut layouter = naga::proc::Layouter::default(); layouter @@ -1484,8 +1484,8 @@ mod block_wire_storage_tests { /// fragment behaviour. Only the geometry source differs. #[test] fn both_block_wire_shaders_expose_the_same_entry_points() { - let packed = module(include_str!("../../shaders/block_wire.wgsl")); - let storage = module(include_str!("../../shaders/block_wire_storage.wgsl")); + let packed = module(draw_order_shader!("block_wire.wgsl")); + let storage = module(draw_order_shader!("block_wire_storage.wgsl")); validate(&packed); let names = |m: &naga::Module| { let mut n: Vec = m.entry_points.iter().map(|e| e.name.clone()).collect(); @@ -1498,8 +1498,8 @@ mod block_wire_storage_tests { #[test] fn both_block_wire_shaders_honor_fixed_screen_width() { for source in [ - include_str!("../../shaders/block_wire.wgsl"), - include_str!("../../shaders/block_wire_storage.wgsl"), + draw_order_shader!("block_wire.wgsl"), + draw_order_shader!("block_wire_storage.wgsl"), ] { assert!(source.contains("if world_hw < 0.0")); assert!(source.contains( @@ -1510,7 +1510,7 @@ mod block_wire_storage_tests { #[test] fn wire_shader_validates_with_naga() { - let source = include_str!("../../shaders/wire.wgsl"); + let source = draw_order_shader!("wire.wgsl"); let module = naga::front::wgsl::parse_str(source).expect("wire.wgsl parses cleanly"); let mut validator = naga::valid::Validator::new( naga::valid::ValidationFlags::all(), @@ -1523,7 +1523,7 @@ mod block_wire_storage_tests { #[test] fn wire_indexed_shader_validates_with_naga() { - let source = include_str!("../../shaders/wire_indexed.wgsl"); + let source = draw_order_shader!("wire_indexed.wgsl"); let module = naga::front::wgsl::parse_str(source).expect("wire_indexed.wgsl parses cleanly"); let mut validator = naga::valid::Validator::new( naga::valid::ValidationFlags::all(), diff --git a/src/scene/project.rs b/src/scene/project.rs index f20c10e38..4d67327ac 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -674,10 +674,7 @@ pub(crate) fn clip_boundary_polygon_for_document( z: f32, ) -> Vec<[f32; 3]> { use std::f64::consts::TAU; - let Some(entity) = document - .entities() - .find(|e| e.common().handle == handle) - else { + let Some(entity) = document.get_entity(handle) else { return vec![]; }; // Circles and ellipses tessellate directly — their `to_render` returns a diff --git a/src/scene/selection.rs b/src/scene/selection.rs index f5684e31f..7f1393b5e 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -12,6 +12,20 @@ pub(crate) fn pe_url_of(entity: &EntityType) -> Option<&str> { .filter(|text| !text.is_empty()) } +/// Optional description stored after the URL in the standard PE_URL record. +pub(crate) fn pe_url_description_of(entity: &EntityType) -> Option<&str> { + let record = entity.common().extended_data.get_record("PE_URL")?; + record + .values + .iter() + .filter_map(|value| match value { + acadrust::xdata::XDataValue::String(text) => Some(text.trim()), + _ => None, + }) + .nth(1) + .filter(|text| !text.is_empty()) +} + impl Scene { // ── Selection ───────────────────────────────────────────────────────── /// Treat a classic LEADER and its attached annotation as one logical object. @@ -680,7 +694,9 @@ impl Scene { ]) } else { match prop.value { - PropValue::PlainText(_) => QSelectValueEditor::Text, + PropValue::PlainText(_) | PropValue::Hyperlink(_) => { + QSelectValueEditor::Text + } PropValue::ReadOnly(ref value) | PropValue::ReadOnlyWithTooltip { ref value, .. } | PropValue::EditText(ref value) => { @@ -882,7 +898,8 @@ impl Scene { PropValue::ReadOnly(s) | PropValue::ReadOnlyWithTooltip { value: s, .. } | PropValue::EditText(s) - | PropValue::PlainText(s) => s, + | PropValue::PlainText(s) + | PropValue::Hyperlink(s) => s, PropValue::LayerChoice(s) => s, PropValue::Choice { selected, .. } => selected, PropValue::EditChoice { value, .. } => value, @@ -1018,6 +1035,16 @@ impl Scene { hover_changed = true; } hover_changed |= self.constraint_hover_highlights.remove(&h); + if self + .constraint_hover_refs + .iter() + .any(|reference| reference.entity == h) + { + self.constraint_hover_refs.clear(); + self.constraint_hover_wires.clear(); + self.constraint_hover_highlights.clear(); + hover_changed = true; + } self.hatches.remove(&h); self.images.remove(&h); self.meshes.remove(&h); diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index 7b87a2a8d..c5a92d8c3 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -84,13 +84,6 @@ impl Default for Camera { } } -pub const OPENGL_TO_WGPU: Mat4 = glam::mat4( - glam::vec4(1.0, 0.0, 0.0, 0.0), - glam::vec4(0.0, 1.0, 0.0, 0.0), - glam::vec4(0.0, 0.0, 0.5, 0.0), - glam::vec4(0.0, 0.0, 0.5, 1.0), -); - impl Camera { // ── Eye position ─────────────────────────────────────────────────────── @@ -238,13 +231,9 @@ impl Camera { /// Orthographic near/far that CENTRE the target plane at ndc-z ≈ 0.5. /// - /// The draw-order depth bias shifts clip-z by ±`DRAW_ORDER_BIAS` (0.001). - /// The old range (`near = distance*0.001`, `far = distance*1000`) parked the - /// geometry at ndc-z ≈ 0.001 — right on the near plane — so a front-biased - /// entity landed exactly at z = 0 and got clipped the moment f32 rounding - /// (worse at high zoom) tipped it past the plane, making the drawing vanish. - /// A symmetric range gives the bias half the depth buffer of headroom on - /// each side; ortho permits a negative near. + /// A symmetric range gives draw-order offsets headroom on each side and + /// keeps the target away from both clipping planes. The shared shader + /// bounds those offsets near either plane; ortho permits a negative near. fn ortho_depth_range(&self) -> (f32, f32) { // Generous headroom based on the current screen size so that rotating any // geometry visible on screen in 3D never penetrates the near/far planes. @@ -296,7 +285,9 @@ impl Camera { orthographic(-w, w, -h, h, near, far) } }; - OPENGL_TO_WGPU * proj * view + // The DirectX projection functions already produce WebGPU's [0, 1] + // depth range. An OpenGL remap here would compress it to [0.5, 1]. + proj * view } /// Project a world point to screen pixels with full f64 precision: the @@ -654,44 +645,29 @@ impl Camera { /// Snap to a canonical view direction (called by ViewCubeSnap). /// `eye_dir` is the unit vector from the target toward the camera. /// - /// Up vector resolution: - /// 1. Take the current up. - /// 2. Pick the world axis (±X, ±Y, ±Z) whose dot product with the - /// current up is highest — skipping any axis (anti-)parallel to - /// the new gaze direction. - /// 3. Project that axis onto the plane ⊥ `new_eye` and use that as - /// the new up. - /// - /// Result: small tilts collapse onto the nearest world axis (so the - /// view always lands cleanly aligned), while genuine flips of the - /// up-sense (e.g. orbited upside-down) are preserved. + /// Deterministic horizon: when looking close to ±Z (top/bottom) the + /// up is north (+Y), otherwise world up (+Z), projected onto the + /// plane ⊥ `new_eye`. This matches the turntable `orbit` (which never + /// banks) so a cube corner always lands with the same roll and the + /// base never appears rotated. The sign is flipped only if the + /// current view is intentionally upside-down, preserving that sense. pub fn snap_to_direction(&mut self, eye_dir: Vec3, ucs: glam::Mat4) { let new_eye = eye_dir.normalize_or(Vec3::Z); - let cur_up = self.rotation * Vec3::Y; - // Candidate up axes are the UCS axes, not world X/Y/Z, so a face snap - // lands the view square to the user's coordinate system (in-plane roll - // included). Identity `ucs` reproduces the world-aligned snap. - let ux = ucs.transform_vector3(Vec3::X).normalize_or(Vec3::X); let uy = ucs.transform_vector3(Vec3::Y).normalize_or(Vec3::Y); let uz = ucs.transform_vector3(Vec3::Z).normalize_or(Vec3::Z); - let cardinals = [ux, -ux, uy, -uy, uz, -uz]; - let mut best_score = f32::NEG_INFINITY; - let mut best_up = uz; - for axis in cardinals { - // Skip axes (nearly) collinear with the new gaze — they can't - // serve as up because the projection onto the plane would - // vanish. - if axis.dot(new_eye).abs() > 0.999 { - continue; - } - let score = axis.dot(cur_up); - if score > best_score { - best_score = score; - best_up = axis; - } - } - // Project the chosen axis onto the plane ⊥ new_eye and normalize. - let projected = best_up - new_eye * best_up.dot(new_eye); + // Deterministic horizon: top/bottom views (eye ≈ ±Z) use north (+Y) + // as up; every other direction uses world up (+Z). This keeps the + // cube's "Top Front Right" etc. repeatable and aligned with the + // turntable orbit (which never banks), and avoids the previous + // adaptive choice that picked the nearest cardinal to the current + // up — from a top view that was +Y, so an oblique top corner kept + // Y as up and tilted the base (roll) instead of keeping the horizon + // level. Preserve the sign only to keep an intentionally inverted + // (upside-down) view inverted. + let cur_up = self.rotation * Vec3::Y; + let raw_ref = if new_eye.dot(uz).abs() > 0.9 { uy } else { uz }; + let up_ref = if cur_up.dot(raw_ref) < 0.0 { -raw_ref } else { raw_ref }; + let projected = up_ref - new_eye * up_ref.dot(new_eye); let new_up = projected.normalize_or(if new_eye.dot(uz).abs() < 0.99 { (uz - new_eye * uz.dot(new_eye)).normalize() } else { @@ -761,7 +737,7 @@ impl Camera { // ── Internal helpers ─────────────────────────────────────────────────── /// Derive yaw and pitch from the current quaternion. - fn sync_yaw_pitch(&mut self) { + pub(crate) fn sync_yaw_pitch(&mut self) { let eye_dir = self.rotation * Vec3::Z; 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 { @@ -798,6 +774,40 @@ pub fn yaw_pitch_to_quat(yaw: f32, pitch: f32, roll: f32) -> Quat { mod tests { use super::*; + #[test] + fn projection_uses_the_webgpu_depth_range_once() { + let bounds = Rectangle::with_size(iced::Size::new(800., 600.)); + for projection in [Projection::Orthographic, Projection::Perspective] { + let camera = Camera { + projection, + rotation: Quat::IDENTITY, + ..Camera::default() + }; + let (near, far) = match projection { + Projection::Perspective => (camera.distance * 0.001, camera.distance * 1000.), + Projection::Orthographic => camera.ortho_depth_range(), + }; + let matrix = camera.view_proj_rte(bounds); + let depth = |distance: f32| { + let clip = matrix * glam::vec4(0., 0., -distance, 1.); + clip.z / clip.w + }; + assert!(depth(near).abs() < 1e-5, "{projection:?}: near must map to 0"); + assert!( + (depth(far) - 1.).abs() < 1e-5, + "{projection:?}: far must map to 1" + ); + assert!( + depth(near - (far - near) * 1e-7) < 0., + "near clipping boundary moved" + ); + assert!(depth(far * 2.) > 1., "far clipping boundary moved"); + if projection == Projection::Orthographic { + assert!((depth(camera.distance) - 0.5).abs() < 1e-5); + } + } + } + /// A drawing 140 units wide carrying one entity 800 km below its plane must /// still zoom to the 140 units — the outlier belongs to the depth range, not /// to the zoom. (Sizing both from the 3-D diagonal made the drawing a dot.) diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 82bfd2eb7..a1f79be28 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -3102,14 +3102,19 @@ impl Scene { let Some(style) = self.display_plot_style() else { return None; }; + // Depth generation rides in the key: the fills below compose their + // depth from the live depth map, which can move independently of the + // wire set (a DRAWORDER edit re-ranks without retessellating). let key = ( source_gen, style.name.to_ascii_lowercase(), pattern_scale.to_bits(), + self.draw_depth_generation(), ); if let Some(hatches) = self.styled_wire_fill_cache.borrow().get(&key) { return Some(Arc::clone(hatches)); } + let depths = self.draw_depth_map(); let mut hatches = Vec::new(); for wire in wires.iter().filter(|wire| wire.fill_is_2d_solid && wire.aci > 0) { let Some(pattern) = style @@ -3161,7 +3166,14 @@ impl Scene { line_weight_px, angle_offset: 0.0, scale: pattern_scale, - draw_depth: wire.depth_override.unwrap_or(0.0), + // Compose against the scene graph exactly like the wire + // pipeline does. The raw depth_override is a per-block + // child label (or None for top-level wires) — either way + // it would place the fill outside its host's depth band + // and let sibling wipes/masks bury it. + draw_depth: crate::scene::pipeline::wire_gpu::wire_draw_depth( + wire, &depths, + ), }); } } @@ -4254,15 +4266,20 @@ impl Scene { Arc::new(Vec::new()) }; let preview_wires = if !show_live_overlay - || (self.interim_wire.is_none() && self.preview_wires.is_empty()) + || (self.interim_wire.is_none() + && self.preview_wires.is_empty() + && self.constraint_hover_wires.is_empty()) { Arc::new(Vec::new()) } else { - let mut v: Vec = Vec::with_capacity(self.preview_wires.len() + 1); + let mut v: Vec = Vec::with_capacity( + self.preview_wires.len() + self.constraint_hover_wires.len() + 1, + ); if let Some(iw) = &self.interim_wire { v.push(iw.clone()); } v.extend(self.preview_wires.iter().cloned()); + v.extend(self.constraint_hover_wires.iter().cloned()); Arc::new(v) }; let preview_hatches = if show_live_overlay { diff --git a/src/scene/viewport_dimension_pick.rs b/src/scene/viewport_dimension_pick.rs new file mode 100644 index 000000000..32ce10be5 --- /dev/null +++ b/src/scene/viewport_dimension_pick.rs @@ -0,0 +1,423 @@ +//! Pick dimension geometry through a layout viewport. Resolve block instances +//! in model space, then project the picked entity onto the paper sheet. + +use super::*; + +use crate::command::EntityTransform; +use crate::scene::viewport_ref::ViewportFrame; +use acadrust::types::{Matrix4, Transform}; +#[cfg(test)] +use glam::DVec2; +use glam::DVec3; + +/// A model entity resolved by clicking inside a paper-space content viewport. +#[derive(Clone, Debug)] +pub struct ViewportDimensionPick { + /// The viewport the click looked through. + pub frame: ViewportFrame, + /// Handle of the innermost model entity that owns the picked geometry. + pub entity_handle: Handle, + /// INSERT handles from the outermost instance down to the instance that + /// directly contains `entity_handle`; empty for top-level model geometry. + pub block_path: Vec, + /// The picked geometry with the block-instance transform *and* the + /// viewport model→paper transform applied, so paper-space dimension + /// commands can consume it unchanged. + pub paper_entity: EntityType, + /// The click position on the sheet. + pub paper_point: DVec3, + /// The click position in model space. + pub model_point: DVec3, +} + +impl Scene { + /// Resolve an explicit dimension object pick made on the sheet but landing + /// inside a content viewport, against the *model* geometry that viewport + /// displays. + /// + /// `aperture_paper` is the pick aperture in paper units. + pub fn dimension_pick_through_viewport( + &self, + paper: DVec3, + aperture_paper: f64, + ) -> Option { + for frame in self.viewport_frames_at_paper_point(paper) { + let model_point = frame.paper_to_model(paper); + let aperture_model = + (aperture_paper.max(0.0) * frame.paper_to_model_length_factor()).max(1e-9); + + let Some(top) = self.nearest_model_wire_handle(&frame, model_point, aperture_model) + else { + continue; + }; + let Some((entity, block_path)) = + self.resolve_measurable_entity(top, model_point, Some(frame.viewport)) + else { + continue; + }; + + if !planar_pick_distance(&entity, model_point).is_some_and(|d| d <= aperture_model) { + continue; + } + let mut paper_entity = entity; + crate::scene::view::dispatch::apply_transform( + &mut paper_entity, + &EntityTransform::Affine(viewport_model_to_paper_transform(&frame)), + ); + let entity_handle = paper_entity.as_entity().handle(); + return Some(ViewportDimensionPick { + frame, + entity_handle: if entity_handle.is_valid() { + entity_handle + } else { + top + }, + block_path, + paper_entity, + paper_point: paper, + model_point, + }); + } + None + } + + /// Nearest resident model wire of `viewport` to `model_point`, within + /// `aperture` model units. Wires are the same tessellation the viewport + /// draws, so this matches what the user sees. + fn nearest_model_wire_handle( + &self, + frame: &ViewportFrame, + model_point: DVec3, + aperture: f64, + ) -> Option { + let viewport = frame.viewport; + let camera = self.camera_for_viewport(viewport)?; + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1000.0, + height: 1000.0, + }; + let radius_px = + (aperture / (2.0 * camera.ortho_size() as f64) * bounds.height as f64) as f32; + let wires = self.interaction_pick_candidates_near( + self.model_wires_for_viewport_arc(viewport, 0.0), + model_point, + camera.view_proj_rte(bounds), + camera.eye(), + bounds, + radius_px.max(1.0), + ); + let mut best: Option<(f64, Handle)> = None; + for wire in wires.iter() { + if !wire.display_visible { + continue; + } + let Some(handle) = Self::handle_from_wire_name(&wire.name) else { + continue; + }; + let offset = wire + .render_instance + .as_ref() + .map(|inst| DVec3::from_array(inst.translation)) + .unwrap_or(DVec3::ZERO); + let distance = wire_polyline_nearest(wire, offset, model_point); + if let Some((distance, nearest)) = distance { + if !self.viewport_displays_paper_point( + viewport, + frame.model_to_paper(nearest).truncate(), + ) { + continue; + } + if distance <= aperture && best.as_ref().is_none_or(|(d, _)| distance < *d) { + best = Some((distance, handle)); + } + } + } + best.map(|(_, handle)| handle) + } + + /// Turn a picked handle into a measurable planar entity in model (WCS) + /// coordinates, descending through block instances and baking the + /// instance transform into the returned clone. + pub(crate) fn resolve_measurable_entity( + &self, + handle: Handle, + model_point: DVec3, + viewport: Option, + ) -> Option<(EntityType, Vec)> { + self.resolve_measurable_feature(handle, model_point, viewport, None) + } + + pub(crate) fn resolve_measurable_feature( + &self, + handle: Handle, + model_point: DVec3, + viewport: Option, + kind: Option, + ) -> Option<(EntityType, Vec)> { + let entity = self.document.get_entity(handle)?; + match entity { + EntityType::Insert(insert) => self.descend_block_instance( + insert, + Transform::identity(), + model_point, + 0, + viewport, + kind, + ), + other => { + feature_pick_distance(other, model_point, kind)?; + Some((other.clone(), Vec::new())) + } + } + } + + /// Depth-first search for the nearest measurable entity inside an INSERT. + /// Returns the entity already transformed into WCS plus the INSERT path. + fn descend_block_instance( + &self, + insert: &acadrust::entities::Insert, + outer: Transform, + model_point: DVec3, + depth: usize, + viewport: Option, + kind: Option, + ) -> Option<(EntityType, Vec)> { + const MAX_DEPTH: usize = 8; + if depth >= MAX_DEPTH { + return None; + } + if insert.row_count > 1 || insert.column_count > 1 { + return None; + } + let insert_handle = insert.common.handle; + let local = crate::scene::render_graph::insert_transform(&self.document, insert); + let combined = local.then(&outer); + let block = self.document.block_records.get(&insert.block_name)?; + + let mut best: Option<(f64, EntityType, Vec)> = None; + for &child in &block.entity_handles { + let Some(child_entity) = self.document.get_entity(child) else { + continue; + }; + let common = child_entity.common(); + let layer = self.document.layers.get(&common.layer); + let frozen = viewport.and_then(|h| self.document.get_entity(h)).is_some_and(|entity| { + matches!(entity, EntityType::Viewport(vp) if layer.is_some_and(|l| vp.frozen_layers.contains(&l.handle))) + }); + if common.invisible || layer.is_some_and(|l| l.flags.off || l.flags.frozen) || frozen { + continue; + } + if let EntityType::Insert(child_insert) = child_entity { + if let Some((entity, mut nested_path)) = self.descend_block_instance( + child_insert, + combined, + model_point, + depth + 1, + viewport, + kind, + ) { + if let Some(distance) = feature_pick_distance(&entity, model_point, kind) { + if best.as_ref().is_none_or(|(d, _, _)| distance < *d) { + let mut full = vec![insert_handle]; + full.append(&mut nested_path); + best = Some((distance, entity, full)); + } + } + } + continue; + } + // A nonuniform block transform turns circular geometry into an + // ellipse. The entity transform API retains the circle type, so + // decline this object pick instead of manufacturing a radius. + let curved = matches!(child_entity, EntityType::Circle(_) | EntityType::Arc(_)) + || matches!(child_entity, EntityType::LwPolyline(p) if p.vertices.iter().any(|v| v.bulge != 0.0)); + let m = &combined.matrix.m; + let x = DVec3::new(m[0][0], m[1][0], m[2][0]); + let y = DVec3::new(m[0][1], m[1][1], m[2][1]); + let magnitude = x.length_squared().max(y.length_squared()); + if curved + && kind != Some(crate::snap::SnapType::Center) + && (magnitude < 1e-24 + || (x.length_squared() - y.length_squared()).abs() > magnitude * 1e-10 + || x.dot(y).abs() > magnitude * 1e-10) + { + continue; + } + let mut placed = child_entity.clone(); + crate::scene::view::dispatch::apply_transform( + &mut placed, + &EntityTransform::Affine(combined), + ); + let Some(distance) = feature_pick_distance(&placed, model_point, kind) else { + continue; + }; + if best.as_ref().is_none_or(|(d, _, _)| distance < *d) { + best = Some((distance, placed, vec![insert_handle])); + } + } + let (_, entity, found_path) = best?; + Some((entity, found_path)) + } +} + +/// Model→paper for a viewport, as an affine entity transform. Z passes +/// through unchanged so planar geometry keeps its elevation. +pub fn viewport_model_to_paper_transform(frame: &ViewportFrame) -> Transform { + let (sin, cos) = frame.twist.sin_cos(); + let s = frame.scale; + let a = cos * s; + let b = sin * s; + // t = paper_center - R*s*model_target + let tx = frame.paper_center.x - (a * frame.model_target.x - b * frame.model_target.y); + let ty = frame.paper_center.y - (b * frame.model_target.x + a * frame.model_target.y); + Transform::from_matrix(Matrix4 { + m: [ + [a, -b, 0.0, tx], + [b, a, 0.0, ty], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + }) +} + +/// Distance from `point` to a wire's polyline, in XY. `None` when the wire has +/// no usable segment. +fn wire_polyline_nearest( + wire: &crate::scene::WireModel, + offset: DVec3, + point: DVec3, +) -> Option<(f64, DVec3)> { + let count = wire.points.len(); + if count == 0 { + return None; + } + let at = |i: usize| -> DVec3 { + let hi = wire.points[i]; + let lo = wire.points_low.get(i).copied().unwrap_or([0.0; 3]); + DVec3::new( + hi[0] as f64 + lo[0] as f64, + hi[1] as f64 + lo[1] as f64, + hi[2] as f64 + lo[2] as f64, + ) + offset + }; + let mut best = f64::INFINITY; + let mut best_point = DVec3::ZERO; + let mut previous: Option = None; + for i in 0..count { + let current = at(i); + if !current.x.is_finite() || !current.y.is_finite() { + previous = None; + continue; + } + let nearest = previous.map_or(current, |start| { + let segment = cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line { + start: start.truncate().to_array(), + end: current.truncate().to_array(), + }); + let near = cadkernel::geom2d::closest_point(&segment, point.truncate().to_array()); + start.lerp(current, near.t) + }); + let distance = (nearest - point).truncate().length(); + if distance < best { + best = distance; + best_point = nearest; + } + previous = Some(current); + } + best.is_finite().then_some((best, best_point)) +} + +/// Distance from `point` to the planar geometry object-pick dimensioning +/// supports. `None` for entity types that cannot be dimensioned by picking. +pub fn planar_pick_distance(entity: &EntityType, point: DVec3) -> Option { + let planar = crate::entities::curve::entity_curve(entity)?; + let normal = DVec3::from_array(planar.plane.normal()?); + if normal.cross(DVec3::Z).length_squared() > 1e-12 { + return None; + } + let uv = planar.plane.project(point.to_array())?; + let nearest = cadkernel::geom2d::closest_point(&planar.curve, uv); + Some( + (DVec3::from_array(planar.plane.point_at(nearest.point)) - point) + .truncate() + .length(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame() -> ViewportFrame { + ViewportFrame { + viewport: Handle::NULL, + paper_center: DVec2::new(100.0, 50.0), + model_target: DVec2::new(1000.0, 2000.0), + scale: 0.1, + twist: 0.4, + locked: false, + } + } + + #[test] + fn affine_transform_matches_the_frame_mapping() { + let f = frame(); + let t = viewport_model_to_paper_transform(&f); + for model in [ + DVec3::new(1000.0, 2000.0, 0.0), + DVec3::new(1100.0, 2000.0, 0.0), + DVec3::new(940.5, 2113.25, 0.0), + ] { + let expected = f.model_to_paper(model); + let actual = t.apply(acadrust::types::Vector3::new(model.x, model.y, model.z)); + assert!( + (actual.x - expected.x).abs() < 1e-9 && (actual.y - expected.y).abs() < 1e-9, + "{actual:?} != {expected:?}" + ); + } + } +} + +/// Distance to the particular feature accepted by a snap, rather than to the +/// nearest curve. A circle's center is deliberately not on its circumference. +pub(crate) fn feature_pick_distance( + entity: &EntityType, + point: DVec3, + kind: Option, +) -> Option { + use crate::snap::SnapType as S; + let points: Vec = match kind { + Some(S::Center) => match entity { + EntityType::Circle(c) => vec![c.center_wcs()], + EntityType::Arc(a) => vec![a.center_wcs()], + EntityType::Ellipse(e) => vec![e.center], + _ => return None, + }, + Some(S::Endpoint) => crate::scene::dimension_assoc::source_points(entity), + Some(S::Node) => match entity { + EntityType::Point(p) => vec![p.location], + _ => return None, + }, + Some(S::Midpoint | S::Quadrant) => { + let planar = crate::entities::curve::entity_curve(entity)?; + cadkernel::geom2d::snap::characteristic_points(&planar.curve) + .into_iter() + .filter(|p| match kind { + Some(S::Midpoint) => p.kind == cadkernel::geom2d::snap::SnapKind::Midpoint, + _ => p.kind == cadkernel::geom2d::snap::SnapKind::Quadrant, + }) + .map(|p| { + let p = planar.plane.point_at(p.point); + acadrust::types::Vector3::new(p[0], p[1], p[2]) + }) + .collect() + } + _ => return planar_pick_distance(entity, point), + }; + points + .into_iter() + .map(|p| (DVec3::new(p.x, p.y, p.z) - point).truncate().length()) + .min_by(f64::total_cmp) +} diff --git a/src/scene/viewport_ref.rs b/src/scene/viewport_ref.rs new file mode 100644 index 000000000..60fd4df70 --- /dev/null +++ b/src/scene/viewport_ref.rs @@ -0,0 +1,303 @@ +//! Coordinates and feature identity shared by viewport acquisition and dimensions. + +use acadrust::types::Handle; +use glam::{DMat3, DVec2, DVec3}; + +use crate::command::DimensionAssociationSource; +use crate::snap::SnapType; + +/// Planar orthographic mapping between a layout viewport's paper rectangle and +/// the model plane it displays. Handles pan (`model_target` / `paper_center`), +/// scale (paper units per model unit) and in-plane rotation (`twist`, +/// radians, counter-clockwise, applied to model geometry before scaling). +/// +/// Constructed by `Scene::viewport_frame`. All arithmetic is `f64`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ViewportFrame { + /// Viewport entity handle. + pub viewport: Handle, + /// Paper-space centre of the viewport rectangle. + pub paper_center: DVec2, + /// Model-space point displayed at `paper_center`. + pub model_target: DVec2, + /// Paper units per model unit (e.g. 0.1 for a 1:10 viewport). + pub scale: f64, + /// In-plane rotation (DXF `twist_angle`), radians. + pub twist: f64, + /// `true` when the viewport is display-locked (snapping still allowed). + pub locked: bool, +} + +impl ViewportFrame { + /// Model -> paper, as a 2-D affine matrix (homogeneous, column vectors). + pub fn model_to_paper_matrix(&self) -> DMat3 { + let (s, c) = self.twist.sin_cos(); + let rs = DMat3::from_cols( + DVec3::new(c * self.scale, s * self.scale, 0.0), + DVec3::new(-s * self.scale, c * self.scale, 0.0), + DVec3::new(0.0, 0.0, 1.0), + ); + let t_model = DMat3::from_translation(-self.model_target); + let t_paper = DMat3::from_translation(self.paper_center); + t_paper * rs * t_model + } + + /// Paper -> model (inverse of [`Self::model_to_paper_matrix`]). + pub fn paper_to_model_matrix(&self) -> DMat3 { + self.model_to_paper_matrix().inverse() + } + + /// Project a model-space point onto the paper sheet. `z` passes through. + pub fn model_to_paper(&self, p: DVec3) -> DVec3 { + let q = self.model_to_paper_matrix().transform_point2(p.truncate()); + DVec3::new(q.x, q.y, p.z) + } + + /// Lift a paper-sheet point into model space. `z` passes through. + pub fn paper_to_model(&self, p: DVec3) -> DVec3 { + let q = self.paper_to_model_matrix().transform_point2(p.truncate()); + DVec3::new(q.x, q.y, p.z) + } + + /// Model -> paper for a direction (rotation + scale, no translation). + pub fn model_to_paper_dir(&self, d: DVec2) -> DVec2 { + self.model_to_paper_matrix().transform_vector2(d) + } + + /// Factor a paper-space length must be multiplied by to recover the model + /// length it displays (`1 / scale`). This is the *viewport compensation* + /// used by [`MeasurementScale`]. + pub fn paper_to_model_length_factor(&self) -> f64 { + 1.0 / self.scale + } +} + +/// Identity of the geometry a snap landed on, in model space, including the +/// block-instance path when the entity lives inside a block reference. +/// +/// `source.handle` is the innermost entity (the one owning the feature). +/// `block_path` is the ordered list of INSERT handles from the outermost +/// instance in the space down to the instance that directly contains the +/// entity; empty for top-level geometry. +#[derive(Clone, Debug, PartialEq)] +pub struct SnapSourceRef { + pub source: DimensionAssociationSource, + pub block_path: Vec, + pub snap_type: SnapType, + /// The other entity/instance path for a true intersection. + pub intersection: Option>, +} + +/// A snap accepted by a point-input command, with enough information for +/// dimension commands to distinguish *placement* (paper) from *measurement* +/// (model) and for association creation to record where it came from. +/// +/// Invariant: when `viewport` is `Some`, `paper_point` is the point on the +/// sheet and `model_point` is that same point in model space through the +/// viewport frame. When `viewport` is `None` the snap was on paper-space (or +/// model-space) geometry directly and `paper_point == model_point`. +#[derive(Clone, Debug, PartialEq)] +pub struct AcceptedSnap { + /// Point in the coordinate system of the current space (paper when a + /// layout is active). + pub paper_point: DVec3, + /// Point in model space (identical to `paper_point` when `viewport` is + /// `None`). + pub model_point: DVec3, + /// The viewport the snap looked through, if any. + pub viewport: Option, + /// The frame in effect when the snap was accepted (so a later measurement + /// uses the same scale even if the viewport is changed afterwards). + pub frame: Option, + /// Geometry identity, when the snap landed on an entity feature (not a + /// grid/free point). + pub source: Option, +} + +impl AcceptedSnap { + /// A plain point in the current space with no viewport and no source. + pub fn free(point: DVec3) -> Self { + Self { + paper_point: point, + model_point: point, + viewport: None, + frame: None, + source: None, + } + } + + pub fn through_viewport(&self) -> bool { + self.viewport.is_some() + } + + /// `snap.world` is always in the command's current space. A projected + /// viewport hit also retains its original model coordinate explicitly. + pub fn from_snap(snap: &crate::snap::SnapResult, frame: Option) -> Self { + let frame = frame.filter(|frame| snap.viewport == Some(frame.viewport)); + let source_ref = |source| SnapSourceRef { + source, + block_path: Vec::new(), + snap_type: snap.snap_type, + intersection: None, + }; + Self { + paper_point: snap.world, + model_point: frame.map_or(snap.world, |frame| { + snap.model_point + .unwrap_or_else(|| frame.paper_to_model(snap.world)) + }), + viewport: frame.map(|f| f.viewport), + frame, + source: snap.source.map(|source| SnapSourceRef { + intersection: snap + .secondary_source + .map(|source| Box::new(source_ref(source))), + ..source_ref(source) + }), + } + } + + /// The paper point with the viewport context of `self`, but no geometry + /// identity — used when a command constrains a snapped point (ortho/polar, + /// axis lock) so the recorded point matches what was committed. + pub fn with_paper_point(mut self, paper_point: DVec3) -> Self { + // Compare in the sheet plane only: paper space is flat and callers + // clamp `z` to 0, which must not be mistaken for the point having been + // moved off the snapped feature (and must not discard the snapped + // model point's real elevation). + let moved = self + .paper_point + .truncate() + .distance_squared(paper_point.truncate()) + > 1e-18; + if moved { + // The committed point is no longer the feature that was snapped. + self.source = None; + self.model_point = match &self.frame { + Some(frame) => frame.paper_to_model(paper_point), + None => paper_point, + }; + } + self.paper_point = paper_point; + self + } +} + +/// Length dimensions store raw distances in their owning space. The displayed +/// value uses `user_lfac * viewport_compensation`; angular dimensions are exempt. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MeasurementScale { + pub user_lfac: f64, + pub viewport_compensation: f64, +} + +impl MeasurementScale { + /// OCS bookkeeping keeps user scaling separate from the changing viewport + /// scale. Standard DIMLFAC still carries the full factor for other readers. + pub const APP_ID: &'static str = "OCS_VIEWPORT_MEASUREMENT"; + + pub fn read(data: &acadrust::xdata::ExtendedData) -> Option { + use acadrust::xdata::XDataValue; + let record = data.get_record(Self::APP_ID)?; + let [XDataValue::Integer16(1), XDataValue::Real(user), XDataValue::Real(compensation)] = + record.values.as_slice() + else { + return None; + }; + if !user.is_finite() || *user <= 0.0 || !compensation.is_finite() || *compensation <= 0.0 { + return None; + } + Some(Self { + user_lfac: *user, + viewport_compensation: *compensation, + }) + } + + pub fn write_to_entity(self, entity: &mut acadrust::EntityType) { + use acadrust::xdata::{ExtendedData, ExtendedDataRecord, XDataValue}; + crate::entities::dim_override::set_on_entity( + entity, + crate::entities::dim_override::DIMLFAC, + Some(XDataValue::Real(-self.paper_factor())), + ); + let common = entity.common_mut(); + let mut data = ExtendedData::new(); + for record in common + .extended_data + .records() + .iter() + .filter(|r| r.application_name != Self::APP_ID) + { + data.add_record(record.clone()); + } + let mut record = ExtendedDataRecord::new(Self::APP_ID); + record.add_value(XDataValue::Integer16(1)); + record.add_value(XDataValue::Real(self.user_lfac)); + record.add_value(XDataValue::Real(self.viewport_compensation)); + data.add_record(record); + // Parsed records are authoritative after changing measurement data. + common.extended_data = data; + } + + /// Combined factor applied to a distance measured on the paper sheet. + pub fn paper_factor(&self) -> f64 { + self.user_lfac * self.viewport_compensation + } + + /// Positive DIMLFAC applies everywhere; negative DIMLFAC applies only on + /// paper. Zero means one. Saved viewport compensation is already in DIMLFAC. + pub fn user_lfac_for_space(dimlfac: f64, paper_space: bool) -> f64 { + if dimlfac == 0.0 { + 1.0 + } else if dimlfac > 0.0 { + dimlfac + } else if paper_space { + -dimlfac + } else { + 1.0 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(scale: f64, twist: f64) -> ViewportFrame { + ViewportFrame { + viewport: Handle::NULL, + paper_center: DVec2::new(100.0, 50.0), + model_target: DVec2::new(1000.0, 2000.0), + scale, + twist, + locked: false, + } + } + + #[test] + fn round_trips_with_scale_and_twist() { + let f = frame(0.1, 0.3); + let m = DVec3::new(1234.5, 1987.25, 0.0); + let p = f.model_to_paper(m); + let back = f.paper_to_model(p); + assert!((back - m).length() < 1e-9); + } + + #[test] + fn scale_maps_lengths() { + let f = frame(0.1, 0.0); + let a = f.model_to_paper(DVec3::new(0.0, 0.0, 0.0)); + let b = f.model_to_paper(DVec3::new(100.0, 0.0, 0.0)); + assert!(((b - a).length() - 10.0).abs() < 1e-9); + assert!((f.paper_to_model_length_factor() - 10.0).abs() < 1e-12); + assert_eq!(frame(1e-14, 0.0).paper_to_model_length_factor(), 1e14); + } + + #[test] + fn dimlfac_sign_depends_on_dimension_space() { + for (factor, paper, model) in [(-2.0, 2.0, 1.0), (0.0, 1.0, 1.0), (3.5, 3.5, 3.5)] { + assert_eq!(MeasurementScale::user_lfac_for_space(factor, true), paper); + assert_eq!(MeasurementScale::user_lfac_for_space(factor, false), model); + } + } +} diff --git a/src/shaders/block_face3d.wgsl b/src/shaders/block_face3d.wgsl index e3520039d..4d2b59bd5 100644 --- a/src/shaders/block_face3d.wgsl +++ b/src/shaders/block_face3d.wgsl @@ -28,16 +28,13 @@ struct VertexOut { @location(0) color: vec4, } -const DRAW_ORDER_BIAS: f32 = 0.001; - @vertex fn vs_main(v: VertexIn) -> VertexOut { var out: VertexOut; let rel = (v.position + v.translation - u.eye_high) + (v.position_low + v.translation_low - u.eye_low); out.clip_pos = u.view_rot * vec4(rel, 1.0); - out.clip_pos.z = out.clip_pos.z - - (v.source_depth + v.instance_depth) * DRAW_ORDER_BIAS * out.clip_pos.w; + out.clip_pos = apply_draw_order(out.clip_pos, v.source_depth + v.instance_depth); out.color = v.color; return out; } diff --git a/src/shaders/block_text.wgsl b/src/shaders/block_text.wgsl index 72e489ecf..e8e854598 100644 --- a/src/shaders/block_text.wgsl +++ b/src/shaders/block_text.wgsl @@ -32,16 +32,13 @@ struct VertOut { @location(1) color: vec4, } -const DRAW_ORDER_BIAS: f32 = 0.001; - @vertex fn vs_main(v: VertIn) -> VertOut { var out: VertOut; let rel = (v.pos + v.translation - u.eye_high) + (v.pos_low + v.translation_low - u.eye_low); out.clip_pos = u.view_rot * vec4(rel, 1.0); - out.clip_pos.z = out.clip_pos.z - - (v.source_depth + v.instance_depth) * DRAW_ORDER_BIAS * out.clip_pos.w; + out.clip_pos = apply_draw_order(out.clip_pos, v.source_depth + v.instance_depth); out.uv = v.uv; out.color = v.color; return out; diff --git a/src/shaders/block_wire.wgsl b/src/shaders/block_wire.wgsl index 55963405f..31683b58e 100644 --- a/src/shaders/block_wire.wgsl +++ b/src/shaders/block_wire.wgsl @@ -44,7 +44,6 @@ struct VertexIn { @location(8) depth: vec2, } -const DRAW_ORDER_BIAS: f32 = 0.001; const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; @@ -165,7 +164,7 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut let pos_rel = mix(rel_a, rel_b, which_end); let world_pos = pos_rel + perp_world * (eff_hw * side); var clip_pos = u.view_rot * vec4(world_pos, 1.0); - clip_pos.z = clip_pos.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos.w; + clip_pos = apply_draw_order(clip_pos, in.depth.x); final_clip = clip_pos; out_dist = mix(in.distances.x, in.distances.y, which_end); @@ -192,7 +191,7 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut + direction * half_width * extension; let ndc_offset = offset_px / (u.viewport_size * 0.5); var clip_pos_out = clip_position + vec4(ndc_offset * clip_position.w, 0.0, 0.0); - clip_pos_out.z = clip_pos_out.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos_out.w; + clip_pos_out = apply_draw_order(clip_pos_out, in.depth.x); final_clip = clip_pos_out; out_dist = mix(in.distances.x, in.distances.y, which_end) diff --git a/src/shaders/block_wire_storage.wgsl b/src/shaders/block_wire_storage.wgsl index f168504e6..33ee2d636 100644 --- a/src/shaders/block_wire_storage.wgsl +++ b/src/shaders/block_wire_storage.wgsl @@ -68,7 +68,6 @@ struct VertexIn { @location(8) depth: vec2, } -const DRAW_ORDER_BIAS: f32 = 0.001; const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; @@ -196,7 +195,7 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut let pos_rel = mix(rel_a, rel_b, which_end); let world_pos = pos_rel + perp_world * (eff_hw * side); var clip_pos = u.view_rot * vec4(world_pos, 1.0); - clip_pos.z = clip_pos.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos.w; + clip_pos = apply_draw_order(clip_pos, in.depth.x); final_clip = clip_pos; out_dist = mix(seg_distances.x, seg_distances.y, which_end); @@ -223,7 +222,7 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut + direction * half_width * extension; let ndc_offset = offset_px / (u.viewport_size * 0.5); var clip_pos_out = clip_position + vec4(ndc_offset * clip_position.w, 0.0, 0.0); - clip_pos_out.z = clip_pos_out.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos_out.w; + clip_pos_out = apply_draw_order(clip_pos_out, in.depth.x); final_clip = clip_pos_out; out_dist = mix(seg_distances.x, seg_distances.y, which_end) diff --git a/src/shaders/circle.wgsl b/src/shaders/circle.wgsl index 43d157286..e178340bf 100644 --- a/src/shaders/circle.wgsl +++ b/src/shaders/circle.wgsl @@ -26,7 +26,6 @@ struct Uniforms { } @group(0) @binding(0) var u: Uniforms; -const DRAW_ORDER_BIAS: f32 = 0.001; const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; const TAU: f32 = 6.283185307179586; @@ -116,7 +115,7 @@ fn resolve_px_hw(px_hw: f32) -> f32 { let world_pos_rel = center_rel + (u_val * axis_x + v_val * axis_y) * ext; var clip_pos = u.view_rot * vec4(world_pos_rel, 1.0); - clip_pos.z = clip_pos.z - draw_depth * DRAW_ORDER_BIAS * clip_pos.w; + clip_pos = apply_draw_order(clip_pos, draw_depth); var out: VertexOut; out.clip_pos = clip_pos; diff --git a/src/shaders/draw_order.wgsl b/src/shaders/draw_order.wgsl new file mode 100644 index 000000000..d0d243d85 --- /dev/null +++ b/src/shaders/draw_order.wgsl @@ -0,0 +1,13 @@ +// Positive order draws in front; zero retains geometric depth. Keep the usual +// 0.001 NDC offset away from the clipping planes, but reduce it near either +// plane: perspective depth can be much closer to 1 than this fixed offset. +// +// Using order / (1 + abs(order)) for the limited region keeps ordering monotonic +// (including the paper background's -2). Less than half the remaining depth is +// consumed, so valid vertices stay valid. Out-of-range/behind-eye vertices get +// no offset and remain subject to the real near/far clipping planes. +fn apply_draw_order(clip: vec4, order: f32) -> vec4 { + let room = 0.5 * max(0.0, min(clip.z, clip.w - clip.z)); + let scale = min(0.001 * max(clip.w, 0.0), room / (1.0 + abs(order))); + return vec4(clip.xy, clip.z - order * scale, clip.w); +} diff --git a/src/shaders/ellipse.wgsl b/src/shaders/ellipse.wgsl index b6c35ba62..5fac0447c 100644 --- a/src/shaders/ellipse.wgsl +++ b/src/shaders/ellipse.wgsl @@ -21,7 +21,6 @@ struct Uniforms { } @group(0) @binding(0) var u: Uniforms; -const DRAW_ORDER_BIAS: f32 = 0.001; const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; const TAU: f32 = 6.283185307179586; @@ -98,7 +97,7 @@ fn resolve_hw(px_hw: f32) -> f32 { let world_pos_rel = center_rel + (u_val * ext_u) * u_axis + (v_val * ext_v) * v_axis; var clip_pos = u.view_rot * vec4(world_pos_rel, 1.0); - clip_pos.z = clip_pos.z - draw_depth * DRAW_ORDER_BIAS * clip_pos.w; + clip_pos = apply_draw_order(clip_pos, draw_depth); var out: VertexOut; out.clip_pos = clip_pos; diff --git a/src/shaders/face3d.wgsl b/src/shaders/face3d.wgsl index ec1e3a36a..e2c2639b3 100644 --- a/src/shaders/face3d.wgsl +++ b/src/shaders/face3d.wgsl @@ -34,17 +34,16 @@ struct VertexOut { @location(0) color: vec4, }; -// Draw-order depth bias (see wire.wgsl). Signed draw_depth; 0.0 (all 3D +// Draw-order depth bias (see draw_order.wgsl). Signed draw_depth; 0.0 (all 3D // surface faces — 3DFACE, PolyfaceMesh, PolygonMesh) leaves real depth // untouched so they occlude against solids; only 2D fills order by rank. -const DRAW_ORDER_BIAS: f32 = 0.001; @vertex fn vs_main(v: VertexIn) -> VertexOut { var out: VertexOut; let rel = (v.position - u.eye_high) + (v.position_low - u.eye_low); out.clip_pos = u.view_rot * vec4(rel, 1.0); - out.clip_pos.z = out.clip_pos.z - v.draw_depth * DRAW_ORDER_BIAS * out.clip_pos.w; + out.clip_pos = apply_draw_order(out.clip_pos, v.draw_depth); out.color = v.color; return out; } diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index bf34140bd..b8800c41e 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -47,9 +47,6 @@ struct HatchInstance { grad_kind: u32, // shape (0=linear,1=cyl,2=sph,3=hemi,4=curved), bit4=invert } -// Draw-order depth bias (see wire.wgsl). Higher draw_depth → smaller z → -// drawn on top, ordering this fill against other entity types. -const DRAW_ORDER_BIAS: f32 = 0.001; const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; @@ -122,8 +119,7 @@ struct VOut { local.y + inst.world_origin_low.y + v.translation_low.y - u.eye_low.y, -u.eye_low.z); o.clip = u.view_rot * vec4(hi + lo, 1.0); - o.clip.z = o.clip.z - - (inst.draw_depth + v.draw_depth) * DRAW_ORDER_BIAS * o.clip.w; + o.clip = apply_draw_order(o.clip, inst.draw_depth + v.draw_depth); o.xz = local; o.instance_index = v.instance_index; return o; diff --git a/src/shaders/hatch_texture.wgsl b/src/shaders/hatch_texture.wgsl index 237ad8341..a46457d39 100644 --- a/src/shaders/hatch_texture.wgsl +++ b/src/shaders/hatch_texture.wgsl @@ -110,7 +110,7 @@ struct VOut { v.pos.y + h.origin_low.y + v.translation_low.y - u.eye_low.y, v.pos.z - u.eye_low.z); o.clip = u.view_rot * vec4(hi + lo, 1.0); - o.clip.z = o.clip.z - v.draw_depth * 0.001 * o.clip.w; + o.clip = apply_draw_order(o.clip, v.draw_depth); o.xz = vec2(v.pos.x, v.pos.y); return o; } diff --git a/src/shaders/image.wgsl b/src/shaders/image.wgsl index 9e795a5a5..ca7860aa7 100644 --- a/src/shaders/image.wgsl +++ b/src/shaders/image.wgsl @@ -33,9 +33,6 @@ struct ImageParams { }; @group(1) @binding(2) var img_params: ImageParams; -// Draw-order depth bias (see wire.wgsl). -const DRAW_ORDER_BIAS: f32 = 0.001; - // ── Vertex stage ────────────────────────────────────────────────────────────── struct VertIn { @location(0) pos: vec3, @@ -57,8 +54,7 @@ fn vs_main(in: VertIn) -> VertOut { let rel = (in.pos + in.translation - u.eye_high) + (in.pos_low + in.translation_low - u.eye_low); out.clip_pos = u.view_rot * vec4(rel, 1.0); - out.clip_pos.z = out.clip_pos.z - - (img_params.draw_depth + in.draw_depth) * DRAW_ORDER_BIAS * out.clip_pos.w; + out.clip_pos = apply_draw_order(out.clip_pos, img_params.draw_depth + in.draw_depth); out.uv = in.uv; return out; } diff --git a/src/shaders/text.wgsl b/src/shaders/text.wgsl index a3ff27167..9062f8320 100644 --- a/src/shaders/text.wgsl +++ b/src/shaders/text.wgsl @@ -26,9 +26,6 @@ struct Uniforms { @group(1) @binding(0) var atlas_tex: texture_2d; @group(1) @binding(1) var atlas_samp: sampler; -// Draw-order depth bias (see wire.wgsl / image.wgsl). -const DRAW_ORDER_BIAS: f32 = 0.001; - // ── Vertex stage ────────────────────────────────────────────────────────────── struct VertIn { @location(0) pos: vec3, @@ -50,7 +47,7 @@ fn vs_main(in: VertIn) -> VertOut { // Double-single relative-to-eye, then rotation-only projection. let rel = (in.pos - u.eye_high) + (in.pos_low - u.eye_low); out.clip_pos = u.view_rot * vec4(rel, 1.0); - out.clip_pos.z = out.clip_pos.z - in.draw_depth * DRAW_ORDER_BIAS * out.clip_pos.w; + out.clip_pos = apply_draw_order(out.clip_pos, in.draw_depth); out.uv = in.uv; out.color = in.color; return out; diff --git a/src/shaders/wipeout.wgsl b/src/shaders/wipeout.wgsl index 2558441ba..ae0d937ed 100644 --- a/src/shaders/wipeout.wgsl +++ b/src/shaders/wipeout.wgsl @@ -106,10 +106,10 @@ struct VOut { let hi = h.plane_origin_high.xyz + v.translation - u.eye_high; let lo = local_world + h.plane_origin_low.xyz + v.translation_low - u.eye_low; o.clip = u.view_rot * vec4(hi + lo, 1.0); - // Draw-order bias (see wire.wgsl): the mask erases what draws BELOW its + // Draw-order bias (see draw_order.wgsl): the mask erases what draws BELOW its // entity and loses to what draws above — including its own frame wire, // which rides a +half-rank override. - o.clip.z = o.clip.z - (h.draw_depth + v.draw_depth) * 0.001 * o.clip.w; + o.clip = apply_draw_order(o.clip, h.draw_depth + v.draw_depth); o.xz = vec2(v.pos.x, v.pos.y); return o; } diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index 6a458b1dd..69eefa06b 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -67,11 +67,6 @@ struct InstanceIn { @location(12) marker_normal_scale: vec4, } -// Draw-order depth bias: shifts clip-space z so 2D entities of different -// types order against each other through the shared LessEqual depth test. -// draw_depth is signed (-1,1): front → positive → smaller z → drawn on top; -// 0.0 = neutral (real depth). Depth32Float gives ample precision. -const DRAW_ORDER_BIAS: f32 = 0.001; const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; @@ -199,7 +194,7 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: let pos_rel = mix(rel_a, rel_b, which_end); let world_pos = pos_rel + perp_world * (eff_hw * side); var clip_pos = u.view_rot * vec4(world_pos, 1.0); - clip_pos.z = clip_pos.z - in.misc.x * DRAW_ORDER_BIAS * clip_pos.w; + clip_pos = apply_draw_order(clip_pos, in.misc.x); final_clip = clip_pos; out_dist = mix(in.dists.x, in.dists.y, which_end); @@ -233,7 +228,7 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: let offset_px = perp * hw * side + dir * hw * ext; let ndc_offset = offset_px / (u.viewport_size * 0.5); var clip_pos_out = clip_pos + vec4(ndc_offset * clip_pos.w, 0.0, 0.0); - clip_pos_out.z = clip_pos_out.z - in.misc.x * DRAW_ORDER_BIAS * clip_pos_out.w; + clip_pos_out = apply_draw_order(clip_pos_out, in.misc.x); final_clip = clip_pos_out; out_dist = mix(in.dists.x, in.dists.y, which_end) + ext * hw * u.world_per_pixel; diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index 8a3d099e3..9344b199a 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -53,7 +53,6 @@ struct InstanceIn { @location(7) taper_ratio: vec2, } -const DRAW_ORDER_BIAS: f32 = 0.001; const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; @@ -172,7 +171,7 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon let pos_rel = mix(rel_a, rel_b, which_end); let world_pos = pos_rel + perp_world * (eff_hw * side); var clip_pos = u.view_rot * vec4(world_pos, 1.0); - clip_pos.z = clip_pos.z - c.draw_depth * DRAW_ORDER_BIAS * clip_pos.w; + clip_pos = apply_draw_order(clip_pos, c.draw_depth); final_clip = clip_pos; out_dist = mix(in.distance_a, in.distance_b, which_end); @@ -206,7 +205,7 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon let offset_px = perp * hw * side + dir * hw * ext; let ndc_offset = offset_px / (u.viewport_size * 0.5); var clip_pos_out = clip_pos + vec4(ndc_offset * clip_pos.w, 0.0, 0.0); - clip_pos_out.z = clip_pos_out.z - c.draw_depth * DRAW_ORDER_BIAS * clip_pos_out.w; + clip_pos_out = apply_draw_order(clip_pos_out, c.draw_depth); final_clip = clip_pos_out; out_dist = mix(in.distance_a, in.distance_b, which_end) + ext * hw * u.world_per_pixel; diff --git a/src/snap.rs b/src/snap.rs index e2ae67b2f..05e9055fb 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -9,8 +9,9 @@ use iced::time::Instant; use iced::{Point, Rectangle}; use cadkernel::geom2d::Curve; +use acadrust::types::Handle; -use crate::command::TangentObject; +use crate::command::{DimensionAssociationSource, TangentObject}; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; use crate::scene::pick::interaction_index::WireSource; const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0; @@ -42,6 +43,39 @@ pub enum SnapType { Grid, /// Object acquisition (domain-object pick, e.g. network structure) — orange marker. ObjectPick, + /// B-rep corner of a 3D solid. Part of the separate 3D object-snap + /// system (`Snapper::snap3d_enabled` + `enabled3d`): the 2D master toggle + /// and mode set never catch it. + Vertex, + /// Centre of a 3D solid B-rep edge. Same separate 3D system as `Vertex`. + EdgeMidpoint, + /// Centre of a 3D solid B-rep face (loop-vertex average; exact for + /// planar faces). Same separate 3D system. + FaceCenter, + /// NURBS knot location on a spline entity. Same separate 3D system. + Knot, + /// Foot of the perpendicular from the command base point onto a solid + /// face. Same separate 3D system. + FacePerpendicular, + /// Nearest point on a solid face to the cursor. Same separate 3D system. + NearestFace, +} + +impl SnapType { + /// True for the separate 3D object-snap system (F4 master + `enabled3d` + /// set). The single source of truth — every master-gating check must use + /// this rather than listing variants, so new 3D modes stay independent. + pub fn is_3d(self) -> bool { + matches!( + self, + SnapType::Vertex + | SnapType::EdgeMidpoint + | SnapType::FaceCenter + | SnapType::Knot + | SnapType::FacePerpendicular + | SnapType::NearestFace + ) + } } /// Ordered list used by the popup and snap engine. @@ -64,6 +98,20 @@ pub const ALL_SNAP_MODES: &[(SnapType, &str, &str)] = &[ // grid point; it is toggled on its own and handled directly in `snap()`. ]; +/// 3D object-snap modes: solid B-rep features with their own master toggle +/// (`Snapper::snap3d_enabled`, F4) and mode set (`Snapper::enabled3d`), +/// configured on the Drafting Settings "3D Object Snap" tab. Deliberately +/// absent from [`ALL_SNAP_MODES`] so the 2D Select All / Clear All, the snap +/// popup and `$OSMODE` persistence never touch them. +pub const ALL_3D_SNAP_MODES: &[(SnapType, &str, &str)] = &[ + (SnapType::Vertex, "◈", "Vertex"), + (SnapType::EdgeMidpoint, "▽", "Midpoint on edge"), + (SnapType::FaceCenter, "◉", "Center of face"), + (SnapType::Knot, "⬥", "Knot"), + (SnapType::FacePerpendicular, "⟂", "Perpendicular to face"), + (SnapType::NearestFace, "✦", "Nearest to face"), +]; + // ── Snap result ─────────────────────────────────────────────────────────── #[derive(Debug, Clone, Copy)] @@ -86,6 +134,37 @@ pub struct SnapResult { pub extension_origin: Option, /// Source direction paired with `extension_origin`. pub extension_dir: Option, + /// Layout viewport this snap was seen *through*, when the snap ran against + /// model geometry displayed by a paper-space viewport. `None` for ordinary + /// model-space / paper-sheet snaps. Filled by the paper-space viewport snap + /// query, never by the engine itself. + pub viewport: Option, + /// Owning entity of the geometry the snap landed on, when the engine could + /// attribute the feature to a wire. Block sub-entities report the top-level + /// INSERT handle; the block path is resolved later by + /// [`crate::scene::viewport_ref::SnapSourceRef::block_path`]. + pub source: Option, + /// Second real object at an intersection, when both objects are known. + pub secondary_source: Option, + /// Original model point when `world` has been projected onto a sheet. + pub model_point: Option, +} + +impl SnapResult { + /// Handle of the entity the snap landed on, when known. + pub fn source_handle(&self) -> Option { + self.source.map(|s| s.handle) + } +} + +/// Entity handle carried by a wire (`WireModel::name` is the decimal handle +/// value). Returns `None` for preview / interim wires with a symbolic name. +#[inline] +pub(crate) fn wire_source(wire: &WireModel) -> Option { + wire.name + .parse::() + .ok() + .map(|v| DimensionAssociationSource::inferred(Handle::new(v))) } /// Object-snap-tracking alignment: the cursor projected onto a ray from an @@ -112,11 +191,21 @@ pub struct Snapper { pub snap_enabled: bool, /// Which snap modes are configured (used when `snap_enabled` is true). pub enabled: HashSet, + /// 3D object-snap master toggle (F4). Independent of the 2D master: + /// when false, solid B-rep snaps never fire but `enabled3d` is kept. + pub snap3d_enabled: bool, + /// Which 3D snap modes are configured (used when `snap3d_enabled`). + pub enabled3d: HashSet, /// Grid snap on/off — a system fully separate from object snap. When on, /// `snap()` can pick the nearest grid corner; object snap never does. pub grid_snap_on: bool, /// World-space grid spacing. pub grid_spacing: f32, + /// User-configurable snap spacing (SNAPUNIT X/Y). Used for grid-snap + /// positions; kept separate from the adaptive `grid_spacing`, which + /// tracks the visible grid step and drives tolerances. + pub snap_spacing_x: f32, + pub snap_spacing_y: f32, /// Pixel-radius snap aperture, shared by OSNAP, tracking, polar and /// extension so the catch distance is the same everywhere. pub osnap_radius_px: f32, @@ -179,11 +268,24 @@ impl Default for Snapper { enabled.insert(SnapType::Quadrant); enabled.insert(SnapType::Intersection); enabled.insert(SnapType::Nearest); + let mut enabled3d = HashSet::default(); + enabled3d.insert(SnapType::Vertex); + enabled3d.insert(SnapType::EdgeMidpoint); + enabled3d.insert(SnapType::FaceCenter); + enabled3d.insert(SnapType::Knot); + enabled3d.insert(SnapType::FacePerpendicular); + // Nearest-to-face stays off by default: on dense curved solids it + // would catch nearly every cursor position, masking the discrete + // vertex/edge/face snaps. Opt in via the 3D Object Snap tab. Self { - snap_enabled: false, + snap_enabled: true, enabled, + snap3d_enabled: true, + enabled3d, grid_snap_on: false, grid_spacing: 1.0, + snap_spacing_x: 10.0, + snap_spacing_y: 10.0, osnap_radius_px: DEFAULT_OSNAP_RADIUS_PX, otrack_enabled: false, tracking_points: Vec::new(), @@ -314,6 +416,17 @@ impl Snapper { self.snap_enabled = true; } + /// One-shot "None" override (Snap Overrides ▸ None): the next pick takes + /// the raw cursor point, ignoring every running object snap, after which + /// `clear_override` restores the saved configuration. + pub fn set_override_none(&mut self) { + if self.override_saved.is_none() { + self.override_saved = Some((self.enabled.clone(), self.snap_enabled)); + } + self.enabled.clear(); + self.snap_enabled = false; + } + /// Restore the pre-override snap configuration. No-op when inactive. pub fn clear_override(&mut self) { if let Some((enabled, on)) = self.override_saved.take() { @@ -350,6 +463,28 @@ impl Snapper { self.grid_snap_on = !self.grid_snap_on; } + /// 3D object snap on/off (F4) — independent of the 2D object-snap master + /// and mode set. Only gates the solid B-rep snaps (`Vertex`, + /// `EdgeMidpoint`). + pub fn snap3d(&self) -> bool { + self.snap3d_enabled + } + + pub fn toggle_snap3d(&mut self) { + self.snap3d_enabled = !self.snap3d_enabled; + } + + /// Whether 3D snap mode `t` can fire: master on and mode configured. + pub fn is_on_3d(&self, t: SnapType) -> bool { + self.snap3d_enabled && self.enabled3d.contains(&t) + } + + pub fn toggle_3d(&mut self, t: SnapType) { + if !self.enabled3d.remove(&t) { + self.enabled3d.insert(t); + } + } + pub fn toggle(&mut self, t: SnapType) { if !self.enabled.remove(&t) { self.enabled.insert(t); @@ -954,6 +1089,10 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: None, + secondary_source: None, + model_point: None, }) } @@ -976,6 +1115,10 @@ impl Snapper { }, grid_snap_on: false, grid_spacing: self.grid_spacing, + snap_spacing_x: self.snap_spacing_x, + snap_spacing_y: self.snap_spacing_y, + snap3d_enabled: false, + enabled3d: HashSet::default(), osnap_radius_px: self.osnap_radius_px, otrack_enabled: false, tracking_points: Vec::new(), @@ -1048,9 +1191,16 @@ impl Snapper { // Object snaps therefore NEVER catch grid points; only when grid snap is // on can a grid corner be picked. It is evaluated first and at the // lowest priority, so any object snap inside the aperture overrides it. + // Unlike object snap, grid snap LOCKS: like AutoCAD SNAP, every point + // rounds to the grid — it is not gated by the aperture. (Gating it + // would break as soon as the fixed SNAPUNIT spacing differs from the + // adaptive visible-grid step on screen.) if self.grid_snap_on { - let s = self.grid_spacing as f64; - if s.abs() > 1e-9 { + let sx = self.snap_spacing_x as f64; + let sy = self.snap_spacing_y as f64; + // Z has no independent spacing in the dialog; follow X. + let sz = sx; + if sx.abs() > 1e-9 && sy.abs() > 1e-9 && sz.abs() > 1e-9 { // Round in the UCS grid frame, then map back to world. let (ax, ay, az) = grid_axes; let ax = ax.normalize_or(Vec3::X).as_dvec3(); @@ -1071,13 +1221,16 @@ impl Snapper { } else { (rel.dot(ax), rel.dot(ay)) }; - let ux = (ux / s).round() * s; - let uy = (uy / s).round() * s; - let uz = (rel.dot(az) / s).round() * s; + let ux = (ux / sx).round() * sx; + let uy = (uy / sy).round() * sy; + let uz = (rel.dot(az) / sz).round() * sz; let gp = origin + ax * ux + ay * uy + az * uz; let screen = world_to_screen(gp, view_rot, eye, bounds); let d2 = dist2(screen, cursor_screen); - if d2 < radius2 && in_bounds(screen) { + // Lock, don't magnet: any in-pane grid point stands, so the + // cursor jumps grid-to-grid even when the SNAPUNIT spacing + // is far from the adaptive visible-grid step. + if in_bounds(screen) { best = Some(SnapResult { world: gp, screen, @@ -1087,6 +1240,10 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: None, + secondary_source: None, + model_point: None, }); best_rank = snap_tier(SnapType::Grid); best_sub = snap_priority(SnapType::Grid); @@ -1095,9 +1252,10 @@ impl Snapper { } } - // Object snaps are gated by the object-snap master toggle. With it off - // only the grid result (if any) stands. - if !self.snap_enabled { + // The 2D object-snap master gates the 2D passes below. With it off, + // the grid result (if any) and the independent 3D system still stand. + // (`try_pt` drops 2D candidates itself when the master is off.) + if !self.snap_enabled && !self.snap3d_enabled { return best; } @@ -1182,7 +1340,15 @@ impl Snapper { return best; } - let mut try_pt = |world: glam::DVec3, snap_type: SnapType| { + let mut try_pt = |world: glam::DVec3, + snap_type: SnapType, + src: Option, + secondary: Option| { + // Masters are per-system: 2D candidates need the 2D master, 3D + // solid candidates answer only to the F4 master. + if !snap_type.is_3d() && !self.snap_enabled { + return; + } let screen = world_to_screen(world, view_rot, eye, bounds); if !in_bounds(screen) { return; @@ -1196,7 +1362,20 @@ impl Snapper { return; } let (tier, sub) = (snap_tier(snap_type), snap_priority(snap_type)); - if snap_better(tier, d2, sub, (best_rank, best_d2, best_sub)) { + // Coincident 3D features share pixels (top and bottom face + // centres coincide in plan view): break exact ties by eye depth + // so the nearer one wins instead of whichever was evaluated + // first. 2D geometry is coplanar, so this never triggers there. + let depth_tie = snap_type.is_3d() + && tier == best_rank + && (d2 - best_d2).abs() <= 1e-4 + && sub == best_sub + && best.is_some_and(|prev| { + (world - eye).length_squared() < (prev.world - eye).length_squared() + }); + if depth_tie + || snap_better(tier, d2, sub, (best_rank, best_d2, best_sub)) + { best_rank = tier; best_sub = sub; best_d2 = d2; @@ -1209,38 +1388,57 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: src, + secondary_source: secondary, + model_point: None, }); } }; // ── Pre-baked snap points (Center, Node, Quadrant, Insertion) ────── - let mut try_snap_hint = |world: DVec3, hint: SnapHint| { - let snap_type = match hint { - SnapHint::Center => SnapType::Center, - SnapHint::Node => SnapType::Node, - SnapHint::Quadrant => SnapType::Quadrant, - SnapHint::Insertion => SnapType::Insertion, - SnapHint::Midpoint => SnapType::Midpoint, - SnapHint::Endpoint => SnapType::Endpoint, + let mut try_snap_hint = |world: DVec3, + hint: SnapHint, + src: Option| { + // 3D hints run on the separate 3D master + mode set; everything + // else stays on the 2D master + mode set. + let (snap_type, on) = match hint { + SnapHint::Center => (SnapType::Center, self.is_on(SnapType::Center)), + SnapHint::Node => (SnapType::Node, self.is_on(SnapType::Node)), + SnapHint::Quadrant => (SnapType::Quadrant, self.is_on(SnapType::Quadrant)), + SnapHint::Insertion => (SnapType::Insertion, self.is_on(SnapType::Insertion)), + SnapHint::Midpoint => (SnapType::Midpoint, self.is_on(SnapType::Midpoint)), + SnapHint::Endpoint => (SnapType::Endpoint, self.is_on(SnapType::Endpoint)), + SnapHint::Vertex => (SnapType::Vertex, self.is_on_3d(SnapType::Vertex)), + SnapHint::EdgeMidpoint => ( + SnapType::EdgeMidpoint, + self.is_on_3d(SnapType::EdgeMidpoint), + ), + SnapHint::FaceCenter => ( + SnapType::FaceCenter, + self.is_on_3d(SnapType::FaceCenter), + ), + SnapHint::Knot => (SnapType::Knot, self.is_on_3d(SnapType::Knot)), }; - if self.is_on(snap_type) { - try_pt(world, snap_type); + if on { + try_pt(world, snap_type, src, None); } }; if let Some(points) = wires.snap_points() { for point_ref in points { - let Some(&(world, hint)) = wires - .source_wire(point_ref.wire) - .and_then(|wire| wire.snap_pts.get(point_ref.index as usize)) - else { + let Some(wire) = wires.source_wire(point_ref.wire) else { continue; }; - try_snap_hint(world, hint); + let Some(&(world, hint)) = wire.snap_pts.get(point_ref.index as usize) else { + continue; + }; + try_snap_hint(world, hint, wire_source(wire)); } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for &(world, hint) in &wire.snap_pts { - try_snap_hint(world, hint); + try_snap_hint(world, hint, src); } } } @@ -1250,13 +1448,18 @@ impl Snapper { if self.is_on(SnapType::Endpoint) { if let Some(vertices) = wires.key_vertices() { for vertex_ref in vertices { - let Some(&point) = wires - .source_wire(vertex_ref.wire) - .and_then(|wire| wire.key_vertices.get(vertex_ref.index as usize)) - else { + let Some(wire) = wires.source_wire(vertex_ref.wire) else { continue; }; - try_pt(DVec3::from_array(point), SnapType::Endpoint); + let Some(&point) = wire.key_vertices.get(vertex_ref.index as usize) else { + continue; + }; + try_pt( + DVec3::from_array(point), + SnapType::Endpoint, + wire_source(wire), + None, + ); } // Tessellated open curves have no key-vertex set. Their only // endpoints are first/last, so testing candidate wires remains @@ -1270,17 +1473,23 @@ impl Snapper { continue; } if !wire.points.is_empty() { - try_pt(wp_f64(wire, 0), SnapType::Endpoint); + try_pt(wp_f64(wire, 0), SnapType::Endpoint, wire_source(wire), None); } if wire.points.len() > 1 { - try_pt(wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint); + try_pt( + wp_f64(wire, wire.points.len() - 1), + SnapType::Endpoint, + wire_source(wire), + None, + ); } } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); if !wire.key_vertices.is_empty() { for &point in &wire.key_vertices { - try_pt(DVec3::from_array(point), SnapType::Endpoint); + try_pt(DVec3::from_array(point), SnapType::Endpoint, src, None); } } else { let closed = wire @@ -1289,10 +1498,15 @@ impl Snapper { .any(|(_, hint)| matches!(hint, SnapHint::Quadrant)); if !closed { if !wire.points.is_empty() { - try_pt(wp_f64(wire, 0), SnapType::Endpoint); + try_pt(wp_f64(wire, 0), SnapType::Endpoint, src, None); } if wire.points.len() > 1 { - try_pt(wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint); + try_pt( + wp_f64(wire, wire.points.len() - 1), + SnapType::Endpoint, + src, + None, + ); } } } @@ -1319,16 +1533,17 @@ impl Snapper { let a = DVec3::from_array(a); let b = DVec3::from_array(b); if a.distance_squared(b) > 1e-12 { - try_pt((a + b) * 0.5, SnapType::Midpoint); + try_pt((a + b) * 0.5, SnapType::Midpoint, wire_source(wire), None); } } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for segment in wire.key_vertices.windows(2) { let a = DVec3::from_array(segment[0]); let b = DVec3::from_array(segment[1]); if a.distance_squared(b) > 1e-12 { - try_pt((a + b) * 0.5, SnapType::Midpoint); + try_pt((a + b) * 0.5, SnapType::Midpoint, src, None); } } } @@ -1342,14 +1557,17 @@ impl Snapper { try_pt( nearest_on_segment(cursor_world, seg.a, seg.b), SnapType::Nearest, + wires.source_wire(seg.wire).and_then(wire_source), + None, ); } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for i in 0..wire.points.len().saturating_sub(1) { let p = nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1)); - try_pt(p, SnapType::Nearest); + try_pt(p, SnapType::Nearest, src, None); } } } @@ -1362,14 +1580,20 @@ impl Snapper { if let Some(segments) = &local_segments { for seg in segments { if let Some(foot) = perp_foot(q, seg.a, seg.b) { - try_pt(foot, SnapType::Perpendicular); + try_pt( + foot, + SnapType::Perpendicular, + wires.source_wire(seg.wire).and_then(wire_source), + None, + ); } } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for i in 0..wire.points.len().saturating_sub(1) { if let Some(foot) = perp_foot(q, wp_f64(wire, i), wp_f64(wire, i + 1)) { - try_pt(foot, SnapType::Perpendicular); + try_pt(foot, SnapType::Perpendicular, src, None); } } } @@ -1387,12 +1611,18 @@ impl Snapper { ray_segment_intersect_3d(origin, through, segment.a, segment.b) { if (point - origin).length_squared() > 1e-18 { - try_pt(point, SnapType::Intersection); + try_pt( + point, + SnapType::Intersection, + wires.source_wire(segment.wire).and_then(wire_source), + None, + ); } } } } else { for wire in in_range_wires.iter() { + let src = wire_source(wire); for index in 0..wire.points.len().saturating_sub(1) { if let Some(point) = ray_segment_intersect_3d( origin, @@ -1401,7 +1631,7 @@ impl Snapper { wp_f64(wire, index + 1), ) { if (point - origin).length_squared() > 1e-18 { - try_pt(point, SnapType::Intersection); + try_pt(point, SnapType::Intersection, src, None); } } } @@ -1483,8 +1713,11 @@ impl Snapper { for (idx, &wire_i) in local_wires.iter().enumerate() { for &wire_j in &local_wires[idx + 1..] { if let Some(points) = exact_curve_intersections(wire_i, wire_j) { + // Two wires meet here; attribute the feature to the + // first. Association resolution requires both source paths. + let src = wire_source(wire_i); for point in points { - try_pt(point, SnapType::Intersection); + try_pt(point, SnapType::Intersection, src, wire_source(wire_j)); } resolved_pairs.insert(pair_key(wire_i, wire_j)); } @@ -1515,7 +1748,12 @@ impl Snapper { world_to_screen(pt, view_rot, eye, bounds), cursor_screen, ) <= f32::EPSILON; - try_pt(pt, SnapType::Intersection); + try_pt( + pt, + SnapType::Intersection, + wires.source_wire(a.wire).and_then(wire_source), + wires.source_wire(b.wire).and_then(wire_source), + ); if exact_cursor { break 'intersection_sweep; } @@ -1535,8 +1773,9 @@ impl Snapper { // Curved pairs are solved exactly (bug #1052); see // `exact_curve_intersections`'s doc comment. if let Some(pts) = exact_curve_intersections(wire_i, wire_j) { + let src = wire_source(wire_i); for pt in pts { - try_pt(pt, SnapType::Intersection); + try_pt(pt, SnapType::Intersection, src, wire_source(wire_j)); } continue; } @@ -1560,7 +1799,12 @@ impl Snapper { continue; } if let Some(pt) = seg_intersect_3d(a0, a1, b0, b1) { - try_pt(pt, SnapType::Intersection); + try_pt( + pt, + SnapType::Intersection, + wire_source(wire_i), + wire_source(wire_j), + ); } } } @@ -1586,7 +1830,7 @@ impl Snapper { bounds, self.osnap_radius_px, ) { - try_pt(ext, SnapType::Extension); + try_pt(ext, SnapType::Extension, None, None); } } } @@ -1633,7 +1877,7 @@ impl Snapper { // feet (which sit closer to the cursor on their own lines), // or the cursor would snap to a line instead of the crossing. let pt = glam::DVec3::new(a0.x + t1 * d1.x, a0.y + t1 * d1.y, a0.z); - try_pt(pt, SnapType::Intersection); + try_pt(pt, SnapType::Intersection, None, None); } } } @@ -1686,6 +1930,8 @@ impl Snapper { try_pt( segment_a.a + ta as f64 * (segment_a.b - segment_a.a), SnapType::ApparentIntersection, + wires.source_wire(segment_a.wire).and_then(wire_source), + wires.source_wire(segment_b.wire).and_then(wire_source), ); if dist2(apparent_screen, cursor_screen) <= f32::EPSILON { break 'apparent_sweep; @@ -1726,6 +1972,8 @@ impl Snapper { try_pt( wa0 + ta as f64 * (wa1 - wa0), SnapType::ApparentIntersection, + wire_source(wire_i), + wire_source(wire_j), ); } } @@ -1738,7 +1986,9 @@ impl Snapper { // ── Tangent ──────────────────────────────────────────────────────── // Operates directly on tangent_geoms geometry — independent of the // wire.points rendering structure so polyline segments work correctly. - if self.is_on(SnapType::Tangent) { + // 2D master applies (the shared `try_pt` gate does not cover this + // direct-evaluation pass). + if self.snap_enabled && self.is_on(SnapType::Tangent) { let mut eval_tangent = |wire: &WireModel| { for tg in &wire.tangent_geoms { let (world_pt, d2) = match tg { @@ -1947,6 +2197,10 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: wire_source(wire), + secondary_source: None, + model_point: None, }); } } @@ -1974,7 +2228,8 @@ impl Snapper { // cursor is near such a curve, offer its centre, ranked by how close // the cursor is to the curve. Runs here, after `try_pt`'s borrow ends, // so it can update the candidate state directly. (#152) - if self.is_on(SnapType::Center) { + // 2D master applies (this direct-evaluation pass bypasses `try_pt`). + if self.snap_enabled && self.is_on(SnapType::Center) { let mut offer = |wire: &WireModel, curve_d2: f32| { let Some(center) = wire .snap_pts @@ -2009,6 +2264,10 @@ impl Snapper { extension_base2: None, extension_origin: None, extension_dir: None, + viewport: None, + source: wire_source(wire), + secondary_source: None, + model_point: None, }); } }; @@ -2098,7 +2357,7 @@ impl Snapper { /// among them — a circle's Center must not mask its Quadrants just by rank /// (#420). Continuous snaps keep their individual lower tiers, preserving the /// #118 guarantee that they never suppress a discrete snap in the aperture. -fn snap_tier(t: SnapType) -> u8 { +pub(crate) fn snap_tier(t: SnapType) -> u8 { match t { SnapType::Endpoint | SnapType::Intersection @@ -2107,16 +2366,49 @@ fn snap_tier(t: SnapType) -> u8 { | SnapType::Center | SnapType::Node | SnapType::Quadrant - | SnapType::Insertion => 0, + | SnapType::Insertion + // Solid B-rep snaps are discrete like the 2D ones above. + | SnapType::Vertex + | SnapType::EdgeMidpoint + | SnapType::FaceCenter + | SnapType::Knot => 0, + // The face-continuous modes slot alongside their 2D analogues — + // without these arms the sub-priority fallback would rank them + // below Grid. + SnapType::FacePerpendicular => 9, + SnapType::NearestFace => 13, other => snap_priority(other), } } +/// Merge paper and viewport snaps using the engine's ordering. +/// Both screen positions must use canvas pixels. +pub fn merge_snap( + a: Option, + b: Option, + cursor: Point, +) -> Option { + match (a, b) { + (Some(a), Some(b)) => { + let (at, asub) = (snap_tier(a.snap_type), snap_priority(a.snap_type)); + let (bt, bsub) = (snap_tier(b.snap_type), snap_priority(b.snap_type)); + let bd2 = dist2(b.screen, cursor); + let ad2 = dist2(a.screen, cursor); + if snap_better(bt, bd2, bsub, (at, ad2, asub)) { + Some(b) + } else { + Some(a) + } + } + (some, None) | (None, some) => some, + } +} + /// Candidate ordering: tier first, then cursor distance, and only when two /// candidates are effectively equidistant (coincident features) the classic /// sub-priority — so an Endpoint still beats an Intersection sitting on the /// exact same point. Distances are screen-px²; 4.0 ≈ a 2 px coincidence band. -fn snap_better(tier: u8, d2: f32, sub: u8, best: (u8, f32, u8)) -> bool { +pub(crate) fn snap_better(tier: u8, d2: f32, sub: u8, best: (u8, f32, u8)) -> bool { let (bt, bd2, bsub) = best; if tier != bt { return tier < bt; @@ -2127,7 +2419,7 @@ fn snap_better(tier: u8, d2: f32, sub: u8, best: (u8, f32, u8)) -> bool { sub < bsub } -fn snap_priority(t: SnapType) -> u8 { +pub(crate) fn snap_priority(t: SnapType) -> u8 { match t { SnapType::Endpoint => 0, SnapType::Intersection => 1, @@ -2144,6 +2436,15 @@ fn snap_priority(t: SnapType) -> u8 { SnapType::Extension => 12, SnapType::Nearest => 13, SnapType::Grid => 14, + // 3D discretes sort after the 2D ones so a coincident 2D feature wins + // the tie-break; distance still decides first (`snap_better`). The + // face-continuous modes slot alongside their 2D analogues. + SnapType::Vertex => 15, + SnapType::EdgeMidpoint => 16, + SnapType::FaceCenter => 17, + SnapType::Knot => 18, + SnapType::FacePerpendicular => 19, + SnapType::NearestFace => 20, } } @@ -2464,131 +2765,215 @@ impl WirePlane { } fn wire_plane(wire: &WireModel) -> Option { - let [geom] = wire.tangent_geoms.as_slice() else { - return None; - }; - let (origin, axis_x, axis_y) = match geom { - TangentGeom::Circle { center, .. } => (DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64), DVec3::X, DVec3::Y), - TangentGeom::PlanarCircle { center, axis_x, axis_y, .. } | TangentGeom::Arc { center, axis_x, axis_y, .. } => ( - DVec3::new(center[0], center[1], center[2]), - DVec3::new(axis_x[0], axis_x[1], axis_x[2]), - DVec3::new(axis_y[0], axis_y[1], axis_y[2]), - ), - TangentGeom::PlanarEllipse { center, major_axis, normal, .. } => { - let origin = DVec3::new(center[0], center[1], center[2]); - let n = DVec3::new(normal[0], normal[1], normal[2]).normalize(); - let major = DVec3::new(major_axis[0], major_axis[1], major_axis[2]); - if major.length() <= 1e-12 { - return None; + for geom in &wire.tangent_geoms { + let plane_axes = match geom { + TangentGeom::Circle { center, .. } => Some(( + DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64), + DVec3::X, + DVec3::Y, + )), + TangentGeom::PlanarCircle { center, axis_x, axis_y, .. } + | TangentGeom::Arc { center, axis_x, axis_y, .. } => Some(( + DVec3::new(center[0], center[1], center[2]), + DVec3::new(axis_x[0], axis_x[1], axis_x[2]), + DVec3::new(axis_y[0], axis_y[1], axis_y[2]), + )), + TangentGeom::PlanarEllipse { center, major_axis, normal, .. } => { + let origin = DVec3::new(center[0], center[1], center[2]); + let n = DVec3::new(normal[0], normal[1], normal[2]).normalize(); + let major = DVec3::new(major_axis[0], major_axis[1], major_axis[2]); + if major.length() <= 1e-12 { + None + } else { + Some((origin, major.normalize(), n.cross(major.normalize()))) + } } - (origin, major.normalize(), n.cross(major.normalize())) + TangentGeom::Line { .. } => None, + }; + if let Some((origin, axis_x, axis_y)) = plane_axes { + return Some(WirePlane { origin, axis_x, axis_y, normal: axis_x.cross(axis_y).normalize() }); } - TangentGeom::Line { .. } => return None, - }; - Some(WirePlane { origin, axis_x, axis_y, normal: axis_x.cross(axis_y).normalize() }) + } + None } -fn curve_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option { +/// Resolve a `TangentGeom::Line`'s f32 endpoints to the wire's f64 +/// `key_vertices`. The two lists are not index-aligned in general — a split +/// polyline keeps every vertex but only its straight geoms, a center mark +/// stores two vertices per segment, a block entry appends per entity — so the +/// vertices are found by value. `cursor` carries the slot of the previous +/// match: the geoms come in vertex order, so the next pair sits at or just +/// after it and the scan is amortised linear over the wire rather than +/// quadratic (a dense contour polyline is checked against every curved wire +/// in the aperture on each cursor move). +fn tangent_line_endpoints( + wire: &WireModel, + p1: [f32; 3], + p2: [f32; 3], + cursor: &mut usize, +) -> (DVec3, DVec3) { + if wire.points.len() == 2 && wire.tangent_geoms.len() == 1 { + return (wp_f64(wire, 0), wp_f64(wire, 1)); + } + + let matches = |vertex: [f64; 3], point: [f32; 3]| { + (vertex[0] as f32 - point[0]).abs() < 1e-3 + && (vertex[1] as f32 - point[1]).abs() < 1e-3 + && (vertex[2] as f32 - point[2]).abs() < 1e-3 + }; + let count = wire.key_vertices.len(); + if count >= 2 { + for step in 0..count { + let start = (*cursor + step) % count; + let end = (start + 1) % count; + if matches(wire.key_vertices[start], p1) && matches(wire.key_vertices[end], p2) { + *cursor = end; + return ( + DVec3::from_array(wire.key_vertices[start]), + DVec3::from_array(wire.key_vertices[end]), + ); + } + } + } + + ( + Vec3::from_array(p1).as_dvec3(), + Vec3::from_array(p2).as_dvec3(), + ) +} + +fn curves_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option> { use cadkernel::geom2d::{Arc as KArc, Circle as KCircle, Ellipse as KEllipse, EllipseArc as KEllipseArc, Line as KLine}; + let mut curves = Vec::new(); + if wire.tangent_geoms.is_empty() { if wire.points.len() != 2 { return None; } let (p0, p1) = (wp_f64(wire, 0), wp_f64(wire, 1)); - return (frame.contains(p0, tol) && frame.contains(p1, tol)).then(|| Curve::Line(KLine { start: frame.to_2d(p0), end: frame.to_2d(p1) })); + if !frame.contains(p0, tol) || !frame.contains(p1, tol) { + return None; + } + return Some(vec![Curve::Line(KLine { + start: frame.to_2d(p0), + end: frame.to_2d(p1), + })]); } - let [geom] = wire.tangent_geoms.as_slice() else { - return None; - }; let angle_in_frame = |world_point: DVec3, centre: DVec3| { let (p2, c2) = (frame.to_2d(world_point), frame.to_2d(centre)); (p2[1] - c2[1]).atan2(p2[0] - c2[0]) }; - match geom { - TangentGeom::Line { p1, p2 } => { - let (p1, p2) = if wire.points.len() == 2 { - (wp_f64(wire, 0), wp_f64(wire, 1)) - } else { - (Vec3::from_array(*p1).as_dvec3(), Vec3::from_array(*p2).as_dvec3()) - }; - (frame.contains(p1, tol) && frame.contains(p2, tol)).then(|| Curve::Line(KLine { start: frame.to_2d(p1), end: frame.to_2d(p2) })) - } - TangentGeom::Circle { center, radius } => { - let c = DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64); - (DVec3::Z.cross(frame.normal).length() <= tol && frame.contains(c, tol)).then(|| Curve::Circle(KCircle { centre: frame.to_2d(c), radius: *radius as f64 })) - } - TangentGeom::PlanarCircle { center, axis_x, axis_y, radius } => { - let c = DVec3::new(center[0], center[1], center[2]); - let n = DVec3::new(axis_x[0], axis_x[1], axis_x[2]).cross(DVec3::new(axis_y[0], axis_y[1], axis_y[2])).normalize(); - (n.cross(frame.normal).length() <= tol && frame.contains(c, tol)).then(|| Curve::Circle(KCircle { centre: frame.to_2d(c), radius: *radius })) - } - TangentGeom::Arc { center, axis_x, axis_y, radius, start_angle, end_angle } => { - let c = DVec3::new(center[0], center[1], center[2]); - let (ax, ay) = (DVec3::new(axis_x[0], axis_x[1], axis_x[2]), DVec3::new(axis_y[0], axis_y[1], axis_y[2])); - let n = ax.cross(ay).normalize(); - if n.cross(frame.normal).length() > tol || !frame.contains(c, tol) { - return None; + let mut vertex_cursor = 0usize; + for geom in &wire.tangent_geoms { + match geom { + TangentGeom::Line { p1, p2 } => { + let (p1, p2) = tangent_line_endpoints(wire, *p1, *p2, &mut vertex_cursor); + if !frame.contains(p1, tol) || !frame.contains(p2, tol) { + return None; + } + curves.push(Curve::Line(KLine { start: frame.to_2d(p1), end: frame.to_2d(p2) })); } - let arc = KArc { centre: frame.to_2d(c), radius: *radius, start_angle: *start_angle, end_angle: *end_angle }; - let sweep = arc.sweep(); - if (sweep - std::f64::consts::TAU).abs() <= 1e-12 { - return Some(Curve::Circle(KCircle { centre: arc.centre, radius: *radius })); + TangentGeom::Circle { center, radius } => { + let c = DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64); + if DVec3::Z.cross(frame.normal).length() > tol || !frame.contains(c, tol) { + return None; + } + curves.push(Curve::Circle(KCircle { centre: frame.to_2d(c), radius: *radius as f64 })); } - let boundary = if n.dot(frame.normal) < 0.0 { *end_angle } else { *start_angle }; - let start_angle = angle_in_frame(c + *radius * (boundary.cos() * ax + boundary.sin() * ay), c); - Some(Curve::Arc(KArc { start_angle, end_angle: start_angle + sweep, ..arc })) - } - - TangentGeom::PlanarEllipse { center, major_axis, normal, minor_axis_ratio, start_param, end_param } => { - let c = DVec3::new(center[0], center[1], center[2]); - let ell_normal = DVec3::new(normal[0], normal[1], normal[2]).normalize(); - if ell_normal.cross(frame.normal).length() > tol || !frame.contains(c, tol) { - return None; + TangentGeom::PlanarCircle { center, axis_x, axis_y, radius } => { + let c = DVec3::new(center[0], center[1], center[2]); + let n = DVec3::new(axis_x[0], axis_x[1], axis_x[2]).cross(DVec3::new(axis_y[0], axis_y[1], axis_y[2])).normalize(); + if n.cross(frame.normal).length() > tol || !frame.contains(c, tol) { + return None; + } + curves.push(Curve::Circle(KCircle { centre: frame.to_2d(c), radius: *radius })); } - let major = DVec3::new(major_axis[0], major_axis[1], major_axis[2]); - let major_radius = major.length(); - if major_radius <= 1e-12 { - return None; + TangentGeom::Arc { center, axis_x, axis_y, radius, start_angle, end_angle } => { + let c = DVec3::new(center[0], center[1], center[2]); + let (ax, ay) = (DVec3::new(axis_x[0], axis_x[1], axis_x[2]), DVec3::new(axis_y[0], axis_y[1], axis_y[2])); + let n = ax.cross(ay).normalize(); + if n.cross(frame.normal).length() > tol || !frame.contains(c, tol) { + return None; + } + let arc = KArc { centre: frame.to_2d(c), radius: *radius, start_angle: *start_angle, end_angle: *end_angle }; + let sweep = arc.sweep(); + if (sweep - std::f64::consts::TAU).abs() <= 1e-12 { + curves.push(Curve::Circle(KCircle { centre: arc.centre, radius: *radius })); + } else { + let boundary = if n.dot(frame.normal) < 0.0 { *end_angle } else { *start_angle }; + let start_angle = angle_in_frame(c + *radius * (boundary.cos() * ax + boundary.sin() * ay), c); + curves.push(Curve::Arc(KArc { start_angle, end_angle: start_angle + sweep, ..arc })); + } } - let minor_radius = major_radius * *minor_axis_ratio; - let dir2d = frame.dir_to_2d(major / major_radius); - let len = (dir2d[0] * dir2d[0] + dir2d[1] * dir2d[1]).sqrt(); - if len <= 1e-12 { - return None; + TangentGeom::PlanarEllipse { center, major_axis, normal, minor_axis_ratio, start_param, end_param } => { + let c = DVec3::new(center[0], center[1], center[2]); + let ell_normal = DVec3::new(normal[0], normal[1], normal[2]).normalize(); + if ell_normal.cross(frame.normal).length() > tol || !frame.contains(c, tol) { + return None; + } + let major = DVec3::new(major_axis[0], major_axis[1], major_axis[2]); + let major_radius = major.length(); + if major_radius <= 1e-12 { + return None; + } + let minor_radius = major_radius * *minor_axis_ratio; + let dir2d = frame.dir_to_2d(major / major_radius); + let len = (dir2d[0] * dir2d[0] + dir2d[1] * dir2d[1]).sqrt(); + if len <= 1e-12 { + return None; + } + let major_axis_2d = [dir2d[0] / len, dir2d[1] / len]; + let (start_parameter, end_parameter) = if ell_normal.dot(frame.normal) < 0.0 { + (-*end_param, -*start_param) + } else { + (*start_param, *end_param) + }; + curves.push(Curve::Ellipse(KEllipseArc { + ellipse: KEllipse { centre: frame.to_2d(c), major_radius, minor_radius, major_axis: major_axis_2d }, + start_parameter, + end_parameter, + })); } - let major_axis_2d = [dir2d[0] / len, dir2d[1] / len]; - let (start_parameter, end_parameter) = if ell_normal.dot(frame.normal) < 0.0 { - (-*end_param, -*start_param) - } else { - (*start_param, *end_param) - }; - Some(Curve::Ellipse(KEllipseArc { - ellipse: KEllipse { centre: frame.to_2d(c), major_radius, minor_radius, major_axis: major_axis_2d }, - start_parameter, - end_parameter, - })) } } + + Some(curves) } -fn exact_curve_intersections(wire_a: &WireModel, wire_b: &WireModel) -> Option> { +pub(crate) fn exact_curve_intersections( + wire_a: &WireModel, + wire_b: &WireModel, +) -> Option> { let frame = wire_plane(wire_a).or_else(|| wire_plane(wire_b))?; const PLANE_TOL: f64 = 1e-7; - let curve_a = curve_in_frame(wire_a, &frame, PLANE_TOL)?; - let curve_b = curve_in_frame(wire_b, &frame, PLANE_TOL)?; + let curves_a = curves_in_frame(wire_a, &frame, PLANE_TOL)?; + let curves_b = curves_in_frame(wire_b, &frame, PLANE_TOL)?; + // Two lines have nothing this path can improve on (the segment sweep is // already exact for a straight pair); avoid the extra work. - if matches!(curve_a, Curve::Line(_)) && matches!(curve_b, Curve::Line(_)) { + let a_all_lines = curves_a.iter().all(|c| matches!(c, Curve::Line(_))); + let b_all_lines = curves_b.iter().all(|c| matches!(c, Curve::Line(_))); + if a_all_lines && b_all_lines { return None; } let tolerance = cadkernel::geom2d::Tolerance::new(1e-9_f64.max(PLANE_TOL)); - let crossings = cadkernel::geom2d::intersect(&curve_a, &curve_b, tolerance); - let points: Vec = crossings.into_iter().map(|c| frame.to_3d(c.point)).collect(); + let mut points: Vec = Vec::new(); + for ca in &curves_a { + for cb in &curves_b { + let crossings = cadkernel::geom2d::intersect(ca, cb, tolerance); + for c in crossings { + let pt = frame.to_3d(c.point); + if !points.iter().any(|existing| existing.distance_squared(pt) <= 1e-12) { + points.push(pt); + } + } + } + } Some(points) } @@ -2855,6 +3240,113 @@ fn dist2(a: Point, b: Point) -> f32 { dx * dx + dy * dy } +/// Closest point on the 2D triangle (a, b, c) to `p`: the point, its squared +/// distance, and its barycentric weights for (a, b, c) so callers can lift +/// the result back to 3D. Used to land Nearest-to-face on a projected mesh +/// triangle. +pub(crate) fn closest_point_on_tri_2d( + p: [f32; 2], + a: [f32; 2], + b: [f32; 2], + c: [f32; 2], +) -> ([f32; 2], f32, [f32; 3]) { + let close = |q: [f32; 2], w: [f32; 3]| { + let dx = p[0] - q[0]; + let dy = p[1] - q[1]; + (q, dx * dx + dy * dy, w) + }; + // Barycentric region tests (Real-Time Collision Detection §5.1.5). + let ab = [b[0] - a[0], b[1] - a[1]]; + let ac = [c[0] - a[0], c[1] - a[1]]; + let ap = [p[0] - a[0], p[1] - a[1]]; + let d1 = ab[0] * ap[0] + ab[1] * ap[1]; + let d2 = ac[0] * ap[0] + ac[1] * ap[1]; + if d1 <= 0.0 && d2 <= 0.0 { + return close(a, [1.0, 0.0, 0.0]); + } + let bp = [p[0] - b[0], p[1] - b[1]]; + let d3 = ab[0] * bp[0] + ab[1] * bp[1]; + let d4 = ac[0] * bp[0] + ac[1] * bp[1]; + if d3 >= 0.0 && d4 <= d3 { + return close(b, [0.0, 1.0, 0.0]); + } + let vc = d1 * d4 - d3 * d2; + if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 { + let v = d1 / (d1 - d3); + return close([a[0] + v * ab[0], a[1] + v * ab[1]], [1.0 - v, v, 0.0]); + } + let cp = [p[0] - c[0], p[1] - c[1]]; + let d5 = ab[0] * cp[0] + ab[1] * cp[1]; + let d6 = ac[0] * cp[0] + ac[1] * cp[1]; + if d6 >= 0.0 && d5 <= d6 { + return close(c, [0.0, 0.0, 1.0]); + } + let vb = d5 * d2 - d1 * d6; + if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 { + let w = d2 / (d2 - d6); + return close([a[0] + w * ac[0], a[1] + w * ac[1]], [1.0 - w, 0.0, w]); + } + let va = d3 * d6 - d5 * d4; + if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 { + let w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); + return close( + [b[0] + w * (c[0] - b[0]), b[1] + w * (c[1] - b[1])], + [0.0, 1.0 - w, w], + ); + } + // Inside: barycentric interpolation. + let denom = 1.0 / (va + vb + vc); + let v = vb * denom; + let w = vc * denom; + close( + [a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w], + [1.0 - v - w, v, w], + ) +} + +/// Foot of the perpendicular from `base` onto the triangle (a, b, c): +/// `None` for degenerate triangles, a base already in the face plane +/// (perpendicular undefined), or a foot landing outside the triangle. +pub(crate) fn foot_on_triangle( + base: glam::DVec3, + a: glam::DVec3, + b: glam::DVec3, + c: glam::DVec3, +) -> Option { + let ab = b - a; + let ac = c - a; + let n = ab.cross(ac); + let n2 = n.length_squared(); + if !(n2 > 1e-24) { + return None; + } + let dist = (base - a).dot(n) / n2.sqrt(); + if dist.abs() <= 1e-9 { + return None; + } + let foot = base - n * ((base - a).dot(n) / n2); + // Inside test via barycentric areas (tolerant sliver at the rim). + let v0 = c - a; + let v1 = b - a; + let v2 = foot - a; + let d00 = v0.dot(v0); + let d01 = v0.dot(v1); + let d11 = v1.dot(v1); + let d20 = v2.dot(v0); + let d21 = v2.dot(v1); + let denom = d00 * d11 - d01 * d01; + if denom.abs() <= 1e-24 { + return None; + } + let v = (d11 * d20 - d01 * d21) / denom; + let w = (d00 * d21 - d01 * d20) / denom; + if v >= -1e-9 && w >= -1e-9 && v + w <= 1.0 + 1e-9 { + Some(foot) + } else { + None + } +} + /// The nearest line / polyline segment under the cursor as (unit direction, /// world point on it), within `aperture_px` in screen space, or None. /// Tessellated curves (circle / arc / ellipse) are skipped — they carry a @@ -2989,6 +3481,68 @@ fn t_on_segment(p: Point, a: Point, b: Point) -> f32 { mod ext_tests { use super::*; + #[test] + fn closest_point_on_triangle_2d_handles_inside_edge_and_vertex() { + let (a, b, c) = ([0.0f32, 0.0], [10.0, 0.0], [0.0, 10.0]); + // Interior → itself, with barycentric weights for (a, b, c). + let (q, d2, w) = closest_point_on_tri_2d([2.0, 2.0], a, b, c); + assert!((q[0] - 2.0).abs() < 1e-6 && (q[1] - 2.0).abs() < 1e-6); + assert!(d2 < 1e-12); + assert!((w[0] - 0.6).abs() < 1e-6 && (w[1] - 0.2).abs() < 1e-6 && (w[2] - 0.2).abs() < 1e-6); + // Outside near an edge → foot on the edge. + let (q, d2, w) = closest_point_on_tri_2d([5.0, -3.0], a, b, c); + assert!((q[0] - 5.0).abs() < 1e-6 && q[1].abs() < 1e-6); + assert!((d2 - 9.0).abs() < 1e-6); + assert!(w[2].abs() < 1e-6, "edge AB carries no C weight: {w:?}"); + // Outside near a vertex → the vertex. + let (q, d2, _) = closest_point_on_tri_2d([-4.0, -3.0], a, b, c); + assert!(q[0].abs() < 1e-6 && q[1].abs() < 1e-6); + assert!((d2 - 25.0).abs() < 1e-6); + } + + #[test] + fn foot_on_triangle_needs_off_plane_base_and_inside_landing() { + let (a, b, c) = ( + DVec3::new(0.0, 0.0, 5.0), + DVec3::new(10.0, 0.0, 5.0), + DVec3::new(0.0, 10.0, 5.0), + ); + // Base below the face interior → foot straight above it. + let foot = foot_on_triangle(DVec3::new(2.0, 3.0, 0.0), a, b, c) + .expect("interior foot"); + assert!((foot - DVec3::new(2.0, 3.0, 5.0)).length() < 1e-9); + // Base outside the triangle's span → the foot misses it. + assert!(foot_on_triangle(DVec3::new(9.0, 9.0, 0.0), a, b, c).is_none()); + // Base already in the face plane → perpendicular undefined. + assert!(foot_on_triangle(DVec3::new(2.0, 3.0, 5.0), a, b, c).is_none()); + } + + #[test] + fn snap3d_master_and_set_gate_independently_of_2d() { + let mut s = Snapper::default(); + // Defaults: master on; every 3D mode configured except + // Nearest-to-face, which would mask the discrete snaps. + assert!(s.is_on_3d(SnapType::Vertex)); + assert!(s.is_on_3d(SnapType::EdgeMidpoint)); + assert!(!s.is_on_3d(SnapType::NearestFace)); + // The 2D master has no say over 3D modes. + s.snap_enabled = false; + assert!(s.is_on_3d(SnapType::Vertex)); + // Per-mode toggle. + s.toggle_3d(SnapType::Vertex); + assert!(!s.is_on_3d(SnapType::Vertex)); + assert!(s.is_on_3d(SnapType::EdgeMidpoint)); + // Master toggle gates the whole 3D system, keeping the set. + s.toggle_snap3d(); + assert!(!s.is_on_3d(SnapType::EdgeMidpoint)); + assert!(s.enabled3d.contains(&SnapType::EdgeMidpoint)); + s.toggle_snap3d(); + assert!(s.is_on_3d(SnapType::EdgeMidpoint)); + // 3D modes never leak into the 2D set. + assert!(!s.is_on(SnapType::Vertex)); + assert!(!s.is_on(SnapType::EdgeMidpoint)); + } + #[test] fn tracking_active_covers_otrack_and_extension() { let mut s = Snapper::default(); @@ -3684,4 +4238,219 @@ mod ext_tests { assert_eq!(res.world, DVec3::ZERO); } + #[test] + fn grid_snap_locks_beyond_aperture_with_independent_xy() { + let mut s = Snapper::default(); + s.grid_snap_on = true; + s.snap_enabled = false; // grid only + s.snap_spacing_x = 1.0; + s.snap_spacing_y = 0.5; + s.osnap_radius_px = 15.0; + + let view_rot = Mat4::IDENTITY; + let eye = DVec3::new(0.0, 0.0, 500.0); + let bounds = Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 }; + let wires: Vec = Vec::new(); + + // 500 px per world unit: the cursor sits ~160 px from the nearest + // grid corner — far outside the 15 px aperture — yet SNAP must still + // lock like AutoCAD instead of magnetizing only when near a point. + let cursor_world = DVec3::new(0.32, 0.47, 500.0); + let cursor_screen = world_to_screen(cursor_world, view_rot, eye, bounds); + let res = s.snap( + cursor_world, cursor_screen, &wires, + view_rot, eye, bounds, Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None, + ).expect("grid snap must lock even outside the aperture"); + assert_eq!(res.snap_type, SnapType::Grid); + assert!((res.world.x - 0.0).abs() < 1e-9, "x rounds to 0: {:?}", res.world); + assert!((res.world.y - 0.5).abs() < 1e-9, "y rounds to 0.5: {:?}", res.world); + } + + #[test] + fn exact_curve_intersections_handles_multisegment_polyline_against_circle_at_vertex() { + let polyline = WireModel { + key_vertices: vec![[-10.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 10.0, 0.0]], + tangent_geoms: vec![ + TangentGeom::Line { p1: [-10.0, 0.0, 0.0], p2: [0.0, 0.0, 0.0] }, + TangentGeom::Line { p1: [0.0, 0.0, 0.0], p2: [0.0, 10.0, 0.0] }, + ], + ..Default::default() + }; + let circle = WireModel { + tangent_geoms: vec![TangentGeom::PlanarCircle { + center: [0.0, 5.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + }], + ..Default::default() + }; + let pts = exact_curve_intersections(&polyline, &circle) + .expect("polyline must analytically intersect circle"); + assert_eq!(pts.len(), 2, "expected intersections at (0,0) and (0,10), got {pts:?}"); + let mut ys: Vec = pts.iter().map(|p| p.y).collect(); + ys.sort_by(f64::total_cmp); + assert!((ys[0] - 0.0).abs() < 1e-9, "expected corner intersection at y=0, got {}", ys[0]); + assert!((ys[1] - 10.0).abs() < 1e-9, "expected endpoint intersection at y=10, got {}", ys[1]); + for p in &pts { + assert!(p.x.abs() < 1e-9, "expected x=0, got {}", p.x); + assert!(p.z.abs() < 1e-9, "expected z=0, got {}", p.z); + } + } + + #[test] + fn exact_curve_intersections_handles_polyline_with_bulge_arc_against_circle() { + let polyline = WireModel { + tangent_geoms: vec![ + TangentGeom::Line { p1: [-10.0, 0.0, 0.0], p2: [0.0, 0.0, 0.0] }, + TangentGeom::Arc { + center: [0.0, 4.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 4.0, + start_angle: -std::f64::consts::FRAC_PI_2, + end_angle: std::f64::consts::FRAC_PI_2, + }, + ], + ..Default::default() + }; + let circle = WireModel { + tangent_geoms: vec![TangentGeom::PlanarCircle { + center: [0.0, 0.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + }], + ..Default::default() + }; + let pts = exact_curve_intersections(&polyline, &circle) + .expect("polyline with arc must intersect circle"); + assert!(!pts.is_empty()); + let line_pt = pts.iter().find(|p| (p.x - -5.0).abs() < 1e-9 && p.y.abs() < 1e-9); + assert!(line_pt.is_some(), "expected line crossing at (-5, 0, 0), got {pts:?}"); + } + + fn xy_circle(cx: f64, cy: f64, radius: f64) -> WireModel { + WireModel { + tangent_geoms: vec![TangentGeom::PlanarCircle { + center: [cx, cy, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius, + }], + ..Default::default() + } + } + + fn line_geom(a: [f64; 3], b: [f64; 3]) -> TangentGeom { + TangentGeom::Line { + p1: [a[0] as f32, a[1] as f32, a[2] as f32], + p2: [b[0] as f32, b[1] as f32, b[2] as f32], + } + } + + /// A center mark (`entities/line.rs`) stores two `key_vertices` per + /// segment plus the centre, so index `i` of `tangent_geoms` does not name + /// vertex `i`; the endpoints must be found by value. + #[test] + fn exact_curve_intersections_handles_center_mark_vertex_layout() { + let segments: [([f64; 3], [f64; 3]); 4] = [ + ([-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]), + ([0.0, -1.0, 0.0], [0.0, 1.0, 0.0]), + ([2.0, 0.0, 0.0], [7.0, 0.0, 0.0]), + ([0.0, 2.0, 0.0], [0.0, 7.0, 0.0]), + ]; + let mut mark = WireModel::default(); + for (a, b) in segments { + mark.key_vertices.push(a); + mark.key_vertices.push(b); + mark.tangent_geoms.push(line_geom(a, b)); + } + mark.key_vertices.push([0.0, 0.0, 0.0]); + + let mut pts = exact_curve_intersections(&mark, &xy_circle(0.0, 0.0, 5.0)) + .expect("the extension lines cross the circle"); + pts.sort_by(|a, b| a.x.total_cmp(&b.x)); + assert_eq!(pts.len(), 2, "only the two extension lines reach r=5: {pts:?}"); + assert!((pts[0] - DVec3::new(0.0, 5.0, 0.0)).length() < 1e-9, "{pts:?}"); + assert!((pts[1] - DVec3::new(5.0, 0.0, 0.0)).length() < 1e-9, "{pts:?}"); + } + + /// A block entry (`cache/block_cache.rs`) appends each entity's + /// `key_vertices` and `tangent_geoms` in turn; the seam between two + /// entities must not be read as a segment. + #[test] + fn exact_curve_intersections_handles_block_entry_vertex_layout() { + let (a0, a1) = ([-10.0, 0.0, 0.0], [10.0, 0.0, 0.0]); + let (b0, b1) = ([20.0, -10.0, 0.0], [20.0, 10.0, 0.0]); + let block = WireModel { + key_vertices: vec![a0, a1, b0, b1], + tangent_geoms: vec![ + line_geom(a0, a1), + line_geom(b0, b1), + TangentGeom::PlanarCircle { + center: [30.0, 0.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 1.0, + }, + ], + ..Default::default() + }; + + let mut pts = exact_curve_intersections(&block, &xy_circle(0.0, 0.0, 5.0)) + .expect("the first line crosses the circle"); + pts.sort_by(|a, b| a.x.total_cmp(&b.x)); + assert_eq!(pts.len(), 2, "the seam a1->b0 is not a segment: {pts:?}"); + assert!((pts[0] - DVec3::new(-5.0, 0.0, 0.0)).length() < 1e-9, "{pts:?}"); + assert!((pts[1] - DVec3::new(5.0, 0.0, 0.0)).length() < 1e-9, "{pts:?}"); + } + + /// The closing segment of a closed polyline runs from the last vertex + /// back to the first; it must still resolve to f64 vertices rather than + /// the f32 geom endpoints, which drift by centimetres at UTM scale. + #[test] + fn exact_curve_intersections_closing_segment_keeps_f64_vertices() { + let (ox, oy) = (500_000.123456, 4_000_000.654321); + let v = [ + [ox + 10.0, oy, 0.0], + [ox + 10.0, oy + 10.0, 0.0], + [ox, oy + 10.0, 0.0], + [ox, oy, 0.0], + ]; + let square = WireModel { + key_vertices: v.to_vec(), + tangent_geoms: (0..4).map(|i| line_geom(v[i], v[(i + 1) % 4])).collect(), + ..Default::default() + }; + + // Centred on the closing edge x = ox, so both crossings sit on it. + let pts = exact_curve_intersections(&square, &xy_circle(ox, oy + 5.0, 1.0)) + .expect("the circle crosses the closing edge"); + assert_eq!(pts.len(), 2, "{pts:?}"); + for p in &pts { + assert!((p.x - ox).abs() < 1e-9, "closing edge fell back to f32 endpoints: {p:?}"); + } + } + + /// The by-value vertex lookup must stay linear over the wire: each geom's + /// pair is expected at the cursor left by the previous match. + #[test] + fn tangent_line_endpoints_cursor_walks_the_wire_once() { + let n = 64; + let verts: Vec<[f64; 3]> = (0..=n).map(|i| [i as f64, (i % 3) as f64, 0.0]).collect(); + let wire = WireModel { + key_vertices: verts.clone(), + tangent_geoms: (0..n).map(|i| line_geom(verts[i], verts[i + 1])).collect(), + ..Default::default() + }; + let mut cursor = 0usize; + for (i, geom) in wire.tangent_geoms.iter().enumerate() { + let TangentGeom::Line { p1, p2 } = geom else { unreachable!() }; + let (a, b) = tangent_line_endpoints(&wire, *p1, *p2, &mut cursor); + assert_eq!(a, DVec3::from_array(verts[i])); + assert_eq!(b, DVec3::from_array(verts[i + 1])); + assert_eq!(cursor, i + 1, "cursor must land on the segment's end vertex"); + } + } } diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index a578fa99d..a703c3800 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -14,6 +14,9 @@ use std::ops::Range; use crate::ui::style::common::accessible_accent_threshold; +/// Maximum entries kept for the context menu's "Recent Input" list. +pub const RECENT_INPUT_CAP: usize = 10; + pub const CMD_INPUT_ID: &str = "cmd_input"; pub const HISTORY_SCROLL_ID: &str = "command_history_scroll"; @@ -68,15 +71,28 @@ fn mcp_status(enabled: bool, busy: bool) -> (&'static str, Color) { } /// Drop a prompt's "[A / B / …]" option listing — used for the pinned line -/// when the same options render as clickable buttons beside it. Collapses the -/// leftover double spaces and the orphaned " :" the removal leaves behind. -fn strip_option_listing(s: &str) -> String { +/// when the same options render as clickable buttons beside it. Only a +/// bracket group that reads as a listing goes (several choices, or a single +/// choice that is one of `options`); an informational group such as +/// "[3 objects]" or "[base 1.0,2.0]" stays. Collapses the leftover double +/// spaces and the orphaned " :" the removal leaves behind. +fn strip_option_listing(s: &str, options: &[CmdOption]) -> String { let Some(a) = s.find('[') else { return s.to_string(); }; let Some(off) = s[a..].find(']') else { return s.to_string(); }; + let inner = s[a + 1..a + off].trim(); + let is_listing = inner.contains('/') + || inner.contains('|') + || inner.contains('=') + || options.iter().any(|o| { + o.label.eq_ignore_ascii_case(inner) || o.keyword.eq_ignore_ascii_case(inner) + }); + if !is_listing { + return s.to_string(); + } let mut out = format!("{}{}", &s[..a], &s[a + off + 1..]); while out.contains(" ") { out = out.replace(" ", " "); @@ -84,6 +100,23 @@ fn strip_option_listing(s: &str) -> String { out.replace(" :", ":").trim().to_string() } +// Font swaps must preserve U+276F, U+24D8, and U+2715. +const COMMAND_PREFIX: &str = "❯ "; +const INFO_PREFIX: &str = "ⓘ "; +const ERROR_PREFIX: &str = "✕ "; + +fn format_info(msg: &str) -> String { + if msg.starts_with(INFO_PREFIX) { + msg.to_string() + } else { + format!("{INFO_PREFIX}{msg}") + } +} + +fn format_error(msg: &str) -> String { + format!("{ERROR_PREFIX}{}: {msg}", t!("Invalid").to_uppercase()) +} + const MAX_HISTORY: usize = 64; #[derive(Clone)] @@ -103,6 +136,10 @@ pub struct CommandLine { /// context menu, shortcuts), newest last. Drives the right-click "Repeat" /// menu so it reflects every command source, not only typed ones. pub recent_commands: Vec, + /// Tokens typed into a running command (points, distances, keywords), + /// newest first, de-duplicated. Drives the context menu's "Recent Input" + /// submenu so a value can be re-fed with one click. Not persisted. + pub recent_inputs: Vec, /// Current position in `cmd_recall` while navigating (None = not navigating). recall_cursor: Option, /// Saved draft input before the user started navigating history. @@ -151,6 +188,7 @@ impl Default for CommandLine { last_error: None, cmd_recall: Vec::new(), recent_commands: Vec::new(), + recent_inputs: Vec::new(), recall_cursor: None, recall_draft: String::new(), history_open: false, @@ -292,6 +330,22 @@ impl CommandLine { self.cancel_history_navigation(); } + /// Remember a token fed to a running command for the "Recent Input" + /// submenu: newest first, case-insensitive move-to-front de-duplication, + /// capped at `RECENT_INPUT_CAP`. Empty, overly long and entity-handle + /// tokens are skipped (a handle is meaningless outside the pick it + /// answered). + pub fn record_recent_input(&mut self, token: &str) { + let token = token.trim(); + if token.is_empty() || token.chars().count() > 40 { + return; + } + let upper = token.to_uppercase(); + self.recent_inputs.retain(|t| t.to_uppercase() != upper); + self.recent_inputs.insert(0, token.to_string()); + self.recent_inputs.truncate(RECENT_INPUT_CAP); + } + pub fn history_navigation_active(&self) -> bool { self.recall_cursor.is_some() } @@ -335,7 +389,10 @@ impl CommandLine { } pub fn push_command(&mut self, cmd: &str) { - self.push(EntryKind::Command, format!("{} {cmd}", t!("Command:"))); + self.push( + EntryKind::Command, + format!("{COMMAND_PREFIX}{} {cmd}", t!("Command:")), + ); } pub fn push_output(&mut self, msg: &str) { self.push(EntryKind::Output, msg.to_string()); @@ -343,7 +400,7 @@ impl CommandLine { pub fn push_error(&mut self, msg: &str) { self.error_revision = self.error_revision.wrapping_add(1); self.last_error = Some(msg.to_owned()); - self.push(EntryKind::Error, format!("*{}* {msg}", t!("Invalid"))); + self.push(EntryKind::Error, format_error(msg)); } /// Append an error unless it is already the latest history line. Repeated /// retry failures should refresh the concise message, not flood history @@ -352,7 +409,7 @@ impl CommandLine { pub fn push_error_once(&mut self, msg: &str) { self.error_revision = self.error_revision.wrapping_add(1); self.last_error = Some(msg.to_owned()); - let text = format!("*{}* {msg}", t!("Invalid")); + let text = format_error(msg); if let Some(last) = self .history .last_mut() @@ -372,12 +429,7 @@ impl CommandLine { self.push(EntryKind::Error, format!("*{}* {msg}", t!("Warning"))); } pub fn push_info(&mut self, msg: &str) { - let text = if msg.starts_with("i ") { - msg.to_string() - } else { - format!("i {msg}") - }; - self.push(EntryKind::Info, text); + self.push(EntryKind::Info, format_info(msg)); } fn push(&mut self, kind: EntryKind, text: String) { self.history.push(HistoryEntry { @@ -407,11 +459,7 @@ impl CommandLine { e.created_at = Instant::now(); } if let Some(p) = &prompt { - let formatted = if p.starts_with("i ") { - p.clone() - } else { - format!("i {p}") - }; + let formatted = format_info(p); // Reuse the prompt line dispatch/step-transition just pushed; // otherwise add it. if self.history.last().map(|e| &e.text) != Some(&formatted) { @@ -452,6 +500,24 @@ impl CommandLine { e.pinned || (self.fade_ms > 0 && e.created_at.elapsed().as_secs_f32() < self.fade_secs()) } + /// Height of the overlaid prompt lines currently shown above the input + /// row (CLIPROMPTLINES, minus lines that have faded out), in pixels. Lets + /// cursor-anchored panels stay clear of the whole command area, not just + /// the input row. + pub fn overlay_lines_height(&self) -> f32 { + if self.cliprompt_lines == 0 || self.history_open { + return 0.0; + } + let visible = self + .history + .iter() + .filter(|e| self.entry_visible(e)) + .count() + .min(self.cliprompt_lines as usize); + // Text size 12 (line height ≈ 15.6) plus the 1 px padding each side. + visible as f32 * 18.0 + } + /// Visible overlay count respecting CLIPROMPTLINES (0–50). Used in tests. #[cfg(test)] pub fn visible_history_count(&self) -> usize { @@ -583,13 +649,18 @@ impl CommandLine { .iter() .fold(column![].spacing(0), |col, entry| { let kind = entry.kind.clone(); + let is_error = kind == EntryKind::Error; let entry_text = |value: String| { - text(value).size(11).style({ + let mut txt = text(value).size(11).style({ let kind = kind.clone(); move |theme: &Theme| iced::widget::text::Style { color: Some(history_color(theme, &kind)), } - }) + }); + if is_error { + txt = txt.font(iced::Font::DEFAULT.weight(iced::font::Weight::Bold)); + } + txt }; // The current step's prompt is the single pinned line. When the // step offers options, render them as clickable buttons inline @@ -598,7 +669,7 @@ impl CommandLine { // already name every choice, so the prompt's own "[A / B / …]" // listing is dropped here (the history log keeps the full text). if entry.pinned && !self.step_options.is_empty() { - let shown = strip_option_listing(&entry.text); + let shown = strip_option_listing(&entry.text, &self.step_options); let mut r = row![entry_text(shown)].spacing(6).align_y(iced::Center); for opt in &self.step_options { let btn = button(text(opt.label.to_uppercase()).size(11)) @@ -1041,13 +1112,19 @@ fn history_highlight_format( ) -> iced::advanced::text::highlighter::Format { iced::advanced::text::highlighter::Format { color: Some(history_color(theme, kind)), - font: None, + font: match kind { + EntryKind::Error => Some(iced::Font::DEFAULT.weight(iced::font::Weight::Bold)), + _ => None, + }, } } #[cfg(test)] mod tests { - use super::{mcp_status, ranked_matches, CommandLine}; + use super::{ + format_error, history_highlight_format, mcp_status, ranked_matches, CommandLine, EntryKind, + ERROR_PREFIX, INFO_PREFIX, + }; use crate::t; use rustc_hash::FxHashMap; @@ -1121,13 +1198,81 @@ mod tests { assert_eq!(m.first().map(String::as_str), Some("LINE"), "got {m:?}"); } + #[test] + fn history_markers_and_copy_text_are_clean() { + let mut line = CommandLine::new(); + line.clear_history(); + let command = "LINE"; + let info = "Object selected."; + let error = "Unable to save: file is in use."; + let output = "12.5, 7.0\nraw output"; + + line.push_command(command); + line.push_info(info); + line.push_error(error); + line.push_output(output); + + let expected = vec![ + format!("❯ {} {command}", t!("Command:")), + format!("{INFO_PREFIX}{info}"), + format!("{ERROR_PREFIX}{}: {error}", t!("Invalid").to_uppercase()), + output.to_string(), + ]; + let actual = line + .history + .iter() + .map(|entry| entry.text.as_str()) + .collect::>(); + assert_eq!( + actual, + expected.iter().map(String::as_str).collect::>() + ); + assert_eq!( + line.history + .iter() + .map(|entry| entry.kind.clone()) + .collect::>(), + vec![ + EntryKind::Command, + EntryKind::Info, + EntryKind::Error, + EntryKind::Output, + ] + ); + + let plain = line.history_plain_text(); + assert!(!plain.contains('*')); + assert_eq!(plain, expected.join("\n")); + assert_eq!(line.history[3].text.as_bytes(), output.as_bytes()); + } + + #[test] + fn error_history_uses_bold_highlight_format() { + let theme = iced::Theme::Light; + let bold = iced::Font::DEFAULT.weight(iced::font::Weight::Bold); + assert_eq!( + history_highlight_format(&EntryKind::Error, &theme).font, + Some(bold) + ); + for kind in [EntryKind::Command, EntryKind::Info, EntryKind::Output] { + assert_eq!(history_highlight_format(&kind, &theme).font, None); + } + } + #[test] fn issue_498_repeated_save_error_is_not_duplicated() { let mut line = CommandLine::new(); let initial_len = line.history.len(); - line.push_error_once(t!("Unable to save: file is in use.").as_ref()); - line.push_error_once(t!("Unable to save: file is in use.").as_ref()); + let message = t!("Unable to save: file is in use."); + let expected_error = format_error(message.as_ref()); + line.push_error_once(message.as_ref()); + line.push_error_once(message.as_ref()); assert_eq!(line.history.len(), initial_len + 1); + assert_eq!( + line.history.last().map(|entry| entry.text.as_str()), + Some(expected_error.as_str()) + ); + assert!(!line.history_plain_text().contains('*')); } #[test] @@ -1193,8 +1338,14 @@ mod tests { fn info_entries_carry_prefix_marker() { let mut line = CommandLine::new(); line.push_info("Object selected."); - assert_eq!(line.history.last().map(|e| e.text.as_str()), Some("i Object selected.")); - assert_eq!(line.history.last().map(|e| &e.kind), Some(&super::EntryKind::Info)); + assert_eq!( + line.history.last().map(|entry| entry.text.as_str()), + Some("ⓘ Object selected.") + ); + assert_eq!( + line.history.last().map(|entry| &entry.kind), + Some(&EntryKind::Info) + ); } #[test] @@ -1204,9 +1355,13 @@ mod tests { line.push_info("Specify first point:"); assert_eq!(line.history.len(), initial_len + 1); line.set_step_prompt(Some("Specify first point:".to_string())); - assert_eq!(line.history.len(), initial_len + 1, "prompt must reuse push_info entry without duplicating"); + assert_eq!( + line.history.len(), + initial_len + 1, + "prompt must reuse push_info entry without duplicating" + ); let last = line.history.last().unwrap(); assert!(last.pinned); - assert_eq!(last.text, "i Specify first point:"); + assert_eq!(last.text, "ⓘ Specify first point:"); } } diff --git a/src/ui/dock.rs b/src/ui/dock.rs index 37d99f258..2be4829a2 100644 --- a/src/ui/dock.rs +++ b/src/ui/dock.rs @@ -41,6 +41,9 @@ pub enum DockMsg { pub enum PanelId { Properties, BlockPalette, + ExternalReferences, + /// Outline of the drawing's origin planes, open sketch and solid bodies. + Browser, } impl PanelId { @@ -49,6 +52,8 @@ impl PanelId { match self { PanelId::Properties => "Properties", PanelId::BlockPalette => "Block Palette", + PanelId::ExternalReferences => "External References", + PanelId::Browser => "Browser", } } @@ -57,6 +62,17 @@ impl PanelId { match self { PanelId::Properties => 250.0, PanelId::BlockPalette => 260.0, + PanelId::ExternalReferences => 460.0, + PanelId::Browser => 230.0, + } + } + + /// Widest a panel may be dragged or sized to. The references table is + /// column-rich, so it allows double the shared maximum. + fn max_width(self) -> f32 { + match self { + PanelId::ExternalReferences => DOCK_MAX_W * 2.0, + _ => DOCK_MAX_W, } } } @@ -131,7 +147,12 @@ impl DockState { /// resize never hit a missing configuration. Also a cheap heal for configs /// written by an older version. pub fn ensure_settings(&mut self) { - for id in [PanelId::Properties, PanelId::BlockPalette] { + for id in [ + PanelId::Properties, + PanelId::BlockPalette, + PanelId::ExternalReferences, + PanelId::Browser, + ] { self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); } } @@ -157,7 +178,7 @@ impl DockState { pub fn width(&self, id: PanelId, win_w: f32) -> f32 { self.settings(id) .width - .clamp(DOCK_MIN_W, DOCK_MAX_W.min(win_w * 0.45).max(DOCK_MIN_W)) + .clamp(DOCK_MIN_W, id.max_width().min(win_w * 0.45).max(DOCK_MIN_W)) } pub fn auto_collapse(&self, id: PanelId) -> bool { @@ -167,7 +188,7 @@ impl DockState { /// Set the persisted width, clamped. pub fn set_width(&mut self, id: PanelId, width: f32) { let entry = self.panels.entry(id).or_insert_with(|| DockPanel::for_id(id)); - entry.width = width.clamp(DOCK_MIN_W, DOCK_MAX_W); + entry.width = width.clamp(DOCK_MIN_W, id.max_width()); } /// Reset width to the panel's default. @@ -223,6 +244,20 @@ pub const DOCK_MAX_W: f32 = 600.0; mod tests { use super::*; + #[test] + fn external_references_allows_double_max_width() { + let mut state = DockState::default(); + state.ensure_settings(); + // Wider default for the column-rich references table, double the maximum. + // (The 45%-of-window rule still dominates on narrow windows.) + assert_eq!(state.width(PanelId::ExternalReferences, 3000.0), 460.0); + state.set_width(PanelId::ExternalReferences, 5000.0); + assert_eq!(state.width(PanelId::ExternalReferences, 3000.0), DOCK_MAX_W * 2.0); + // Other panels keep the shared maximum. + state.set_width(PanelId::BlockPalette, 5000.0); + assert_eq!(state.width(PanelId::BlockPalette, 3000.0), DOCK_MAX_W); + } + #[test] fn default_docks_each_known_panel_on_an_edge() { let state = DockState::default(); diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 4e598715a..9879c0564 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -44,6 +44,8 @@ static OSNAP_APPARENT: &[u8] = include_bytes!("../../assets/icons/osnap/apparent static OSNAP_PARALLEL: &[u8] = include_bytes!("../../assets/icons/osnap/parallel.svg"); static OSNAP_GRID: &[u8] = include_bytes!("../../assets/icons/osnap/grid.svg"); static OSNAP_MTP: &[u8] = include_bytes!("../../assets/icons/osnap/mtp.svg"); +static NAV_PAN: &[u8] = include_bytes!("../../assets/icons/pan.svg"); +static NAV_ZOOM: &[u8] = include_bytes!("../../assets/icons/zoom_in.svg"); static LAY_ON: &[u8] = include_bytes!("../../assets/icons/layers/layon.svg"); static LAY_OFF: &[u8] = include_bytes!("../../assets/icons/layers/layoff.svg"); @@ -69,6 +71,7 @@ pub static SPLIT_H: &[u8] = include_bytes!("../../assets/icons/ui/split_h.svg"); pub static GRID: &[u8] = include_bytes!("../../assets/icons/ui/grid.svg"); pub static SNAP: &[u8] = include_bytes!("../../assets/icons/ui/snap.svg"); pub static DOC_NEW: &[u8] = include_bytes!("../../assets/icons/ui/doc_new.svg"); +pub static DOC: &[u8] = include_bytes!("../../assets/icons/ui/doc.svg"); pub static FOLDER_OPEN: &[u8] = include_bytes!("../../assets/icons/ui/folder_open.svg"); pub static SAVE: &[u8] = include_bytes!("../../assets/icons/ui/save.svg"); pub static FILE_EXPORT: &[u8] = include_bytes!("../../assets/icons/ui/file_export.svg"); @@ -626,6 +629,14 @@ pub fn osnap(snap: crate::snap::SnapType) -> &'static [u8] { S::Grid => OSNAP_GRID, // Not shown in the snap menu; fall back to a neutral marker. S::ObjectPick => OSNAP_NEAREST, + // 3D modes have no dedicated menu glyphs yet; reuse the closest 2D + // shapes (hollow diamond ≈ vertex, hollow triangle ≈ edge midpoint). + S::Vertex => OSNAP_NODE, + S::EdgeMidpoint => OSNAP_MIDPOINT, + S::FaceCenter => OSNAP_CENTER, + S::Knot => OSNAP_NODE, + S::FacePerpendicular => OSNAP_PERPENDICULAR, + S::NearestFace => OSNAP_NEAREST, } } @@ -634,6 +645,15 @@ pub fn mtp_icon() -> &'static [u8] { OSNAP_MTP } +/// Context-menu glyphs for the transparent navigation rows. +pub fn pan_icon() -> &'static [u8] { + NAV_PAN +} + +pub fn zoom_icon() -> &'static [u8] { + NAV_ZOOM +} + /// Layer visibility icon bytes (on / off). pub fn layer_visible(visible: bool) -> &'static [u8] { if visible { diff --git a/src/ui/modal.rs b/src/ui/modal.rs index 90139a5d7..3ab2d1c9d 100644 --- a/src/ui/modal.rs +++ b/src/ui/modal.rs @@ -244,6 +244,60 @@ pub fn backdrop<'a>( stack![base.into(), opaque(shield)].into() } +/// A dialog's "unsaved changes" guard: `main` dimmed behind a small panel +/// that offers to discard the changes and close, or to keep editing. Clicking +/// the dim area keeps editing. Callers show it in place of the plain dialog +/// once the user asked to close with something to lose. +pub fn discard_guard<'a>( + main: impl Into>, + on_discard: Message, + on_keep: Message, +) -> Element<'a, Message> { + let shield = mouse_area( + container(Space::new()) + .width(Length::Fill) + .height(Length::Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme + .palette() + .background + .strongest + .color + .scale_alpha(0.55), + )), + ..Default::default() + }), + ) + .on_press(on_keep.clone()); + let panel = container( + column![ + iced::widget::text(crate::t!("Unsaved changes will be discarded.")).size(13.5), + Space::new().height(12), + row![ + button(iced::widget::text(crate::t!("Discard && close")).size(12)) + .on_press(on_discard) + .padding([5, 14]) + .style(button::danger), + button(iced::widget::text(crate::t!("Keep editing")).size(12)) + .on_press(on_keep) + .padding([5, 14]) + .style(button::secondary), + ] + .spacing(10), + ] + .spacing(0), + ) + .padding([18, 22]) + .style(container::rounded_box); + stack![ + main.into(), + shield, + container(panel).center_x(Length::Fill).center_y(Length::Fill) + ] + .into() +} + /// Stack `content` over `base` behind a dimmed backdrop, framed with a title bar /// (the ✕ close button at its right end). The backdrop only dims and blocks /// clicks from reaching the view beneath — it does **not** dismiss the dialog; diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 001dc39d5..a1c7f2e02 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -28,14 +28,56 @@ const CONSTRAINT_GLYPH_PAD_Y: f32 = 4.0; const CONSTRAINT_GLYPH_GAP: f32 = 6.0; const CONSTRAINT_GLYPH_ROW_GAP: f32 = 4.0; const CONSTRAINT_HOVER_MARKER_RADIUS: f32 = 7.0; +const COINCIDENT_GLYPH_SIZE: f32 = 9.0; + +fn is_compact_coincident_glyph(label: &str) -> bool { + matches!(label, "≡" | "∈") +} + +/// Fixed's two padlock labels (`parametric_constraints::fixed_glyph_label`). +fn is_fixed_glyph(label: &str) -> bool { + label == "F" || label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH +} + +/// Vertical's two axis-mark labels (`parametric_constraints::vertical_glyph_label`). +fn is_vertical_glyph(label: &str) -> bool { + label == "│" || label == crate::scene::parametric_constraints::VERTICAL_POINTS_GLYPH +} fn constraint_glyph_size(label: &str) -> Size { + if is_compact_coincident_glyph(label) { + return Size::new(COINCIDENT_GLYPH_SIZE, COINCIDENT_GLYPH_SIZE); + } + if label == "G²" || is_fixed_glyph(label) || is_vertical_glyph(label) { + let side = CONSTRAINT_GLYPH_SIZE + CONSTRAINT_GLYPH_PAD_Y * 2.0; + return Size::new(side, side); + } let w = label.chars().count() as f32 * CONSTRAINT_GLYPH_SIZE * 0.62 + CONSTRAINT_GLYPH_PAD_X * 2.0; let h = CONSTRAINT_GLYPH_SIZE + CONSTRAINT_GLYPH_PAD_Y * 2.0; Size::new(w, h) } +fn draw_smooth_constraint_glyph(frame: &mut canvas::Frame, center: Point, color: Color) { + let curve = canvas::Path::new(|builder| { + for step in 0..=16 { + let x = -7.0 + step as f32 * 0.875; + let y = 0.012 * x * x * x; + let point = Point::new(center.x + x, center.y - y); + if step == 0 { + builder.move_to(point); + } else { + builder.line_to(point); + } + } + }); + frame.stroke( + &curve, + canvas::Stroke::default().with_color(color).with_width(1.45), + ); + frame.fill(&canvas::Path::circle(center, 1.35), color); +} + fn draw_tangent_constraint_glyph( frame: &mut canvas::Frame, center: Point, @@ -52,6 +94,117 @@ fn draw_tangent_constraint_glyph( frame.stroke(&canvas::Path::line(contact, tangent_end), stroke); } +fn draw_concentric_constraint_glyph( + frame: &mut canvas::Frame, + center: Point, + color: Color, +) { + let stroke = canvas::Stroke::default().with_color(color).with_width(1.25); + frame.stroke(&canvas::Path::circle(center, 4.7), stroke.clone()); + frame.stroke(&canvas::Path::circle(center, 2.15), stroke); +} + +/// The reference bar's padlock: white with a small orange point marker +/// when one point is held, red when a whole curve or segment is. +fn draw_fixed_constraint_glyph( + frame: &mut canvas::Frame, + center: Point, + text: Color, + badge: Color, + point_marker: bool, +) { + let lock = if point_marker { + text + } else { + Color::from_rgb8(214, 76, 76) + }; + let shackle = canvas::Path::new(|builder| { + let shackle_center = Point::new(center.x, center.y - 1.6); + for step in 0..=12 { + let angle = std::f32::consts::PI * (1.0 - step as f32 / 12.0); + let point = Point::new( + shackle_center.x + angle.cos() * 3.1, + shackle_center.y - angle.sin() * 3.1, + ); + if step == 0 { + builder.move_to(Point::new(point.x, center.y - 0.6)); + builder.line_to(point); + } else { + builder.line_to(point); + } + } + builder.line_to(Point::new(center.x + 3.1, center.y - 0.6)); + }); + frame.stroke( + &shackle, + canvas::Stroke::default().with_color(lock).with_width(1.5), + ); + let body = canvas::Path::rounded_rectangle( + Point::new(center.x - 5.0, center.y - 0.8), + Size::new(10.0, 7.6), + 1.4.into(), + ); + frame.fill(&body, lock); + frame.stroke( + &canvas::Path::line( + Point::new(center.x, center.y + 1.4), + Point::new(center.x, center.y + 4.6), + ), + canvas::Stroke::default().with_color(badge).with_width(1.3), + ); + if point_marker { + frame.stroke( + &canvas::Path::rectangle(Point::new(center.x + 4.0, center.y + 3.6), Size::new(4.6, 4.6)), + canvas::Stroke::default() + .with_color(Color::from_rgb8(224, 130, 62)) + .with_width(1.2), + ); + } +} + +/// The reference bar's Vertical mark: a red datum line hatched on its left, +/// the constrained direction beside it, plus the orange point marker for a +/// two-point relation. +fn draw_vertical_constraint_glyph( + frame: &mut canvas::Frame, + center: Point, + text: Color, + point_marker: bool, +) { + let datum = Color::from_rgb8(214, 76, 76); + let datum_x = center.x - 2.5; + let stroke = canvas::Stroke::default().with_color(datum).with_width(1.5); + frame.stroke( + &canvas::Path::line( + Point::new(datum_x, center.y - 6.5), + Point::new(datum_x, center.y + 6.5), + ), + stroke.clone(), + ); + for step in 0..3 { + let y = center.y - 4.5 + step as f32 * 4.0; + frame.stroke( + &canvas::Path::line(Point::new(datum_x, y), Point::new(datum_x - 4.0, y + 3.5)), + stroke.clone(), + ); + } + frame.stroke( + &canvas::Path::line( + Point::new(center.x + 2.5, center.y - 6.0), + Point::new(center.x + 2.5, center.y + 6.0), + ), + canvas::Stroke::default().with_color(text).with_width(1.4), + ); + if point_marker { + frame.stroke( + &canvas::Path::rectangle(Point::new(center.x + 4.5, center.y - 2.3), Size::new(4.6, 4.6)), + canvas::Stroke::default() + .with_color(Color::from_rgb8(224, 130, 62)) + .with_width(1.2), + ); + } +} + fn constraint_glyph_box( anchor: Point, outward: [f32; 2], @@ -59,9 +212,14 @@ fn constraint_glyph_box( tangent_offset: f32, ) -> (Point, Size) { let size = constraint_glyph_size(label); + let gap = if is_compact_coincident_glyph(label) { + 1.0 + } else { + CONSTRAINT_GLYPH_GAP + }; let distance = outward[0].abs() * size.width * 0.5 + outward[1].abs() * size.height * 0.5 - + CONSTRAINT_GLYPH_GAP; + + gap; let tangent = [-outward[1], outward[0]]; ( Point::new( @@ -477,9 +635,13 @@ pub struct GridParams { /// Camera eye in absolute world f64 — subtracted from each grid point. pub eye: glam::DVec3, pub bounds: iced::Rectangle, - /// Adaptive world-space spacing derived from camera zoom at its pivot. + /// World-space X spacing (GRIDUNIT X, after adaptive scaling). /// Rotation does not affect this value, so orbiting cannot rescale the grid. - pub step: f32, + pub step_x: f32, + /// World-space Y spacing (GRIDUNIT Y, after adaptive scaling). + pub step_y: f32, + /// Draw every Nth line as a brighter major line. < 2 disables majors. + pub major_every: u32, /// Grid origin in absolute world f64 and the active UCS axis directions. /// The grid always lies on the active UCS XY plane. Plain WCS passes /// `(ZERO, X, Y, Z)`. @@ -498,6 +660,8 @@ pub struct GridParams { #[doc(hidden)] pub struct GridGeometry { pub segments: Vec<(Point, Point)>, + /// Brighter every-Nth lines (major grid). Stroked separately in `draw_grid`. + pub major_segments: Vec<(Point, Point)>, pub axis_extent: f32, } @@ -508,7 +672,7 @@ impl GridGeometry { /// the `None` path. #[doc(hidden)] pub fn empty() -> Self { - Self { segments: Vec::new(), axis_extent: 0.0 } + Self { segments: Vec::new(), major_segments: Vec::new(), axis_extent: 0.0 } } } @@ -620,19 +784,61 @@ fn clip_seg(p0: Point, p1: Point, bounds: iced::Rectangle) -> Option<(Point, Poi } pub fn compute_grid_step(distance: f32, fov_y: f32, bounds: iced::Rectangle) -> f32 { + compute_grid_steps(1.0, 1.0, distance, fov_y, bounds, true).0 +} + +/// Adaptive world-space grid spacing for non-uniform GRIDUNIT X/Y. +/// +/// BUG FIX: the old `compute_grid_step` hardcoded a 1.0 base, so the +/// Drafting Settings grid spacing had no effect on the display. The base now +/// comes from the user's GRIDUNIT setting. With `adaptive == true`, each axis +/// is scaled up by powers of 5 until neighbouring lines are at least +/// `MIN_GRID_PX` apart; with `adaptive == false` the raw base is returned so +/// the grid resizes exactly as typed. +pub fn compute_grid_steps( + base_x: f32, + base_y: f32, + distance: f32, + fov_y: f32, + bounds: iced::Rectangle, + adaptive: bool, +) -> (f32, f32) { + let sanitize = |b: f32| { + if b.is_finite() && b > 0.0 && b <= 1e9 { + b + } else { + 1.0 + } + }; + let bx = sanitize(base_x); + let by = sanitize(base_y); + if !adaptive { + return (bx, by); + } let half_height = distance * (fov_y * 0.5).tan(); if !half_height.is_finite() || half_height <= 1e-9 || bounds.height <= 0.0 { - return 1.0; + return (bx, by); } let px_per_unit = bounds.height / (2.0 * half_height); - let mut s = 1.0_f32; - while s * px_per_unit < MIN_GRID_PX { - s *= 5.0; - if s > 1e9 { - return 1.0; - } + if !px_per_unit.is_finite() || px_per_unit <= 0.0 { + return (bx, by); } - s + let adapt = |mut s: f32, base: f32| { + // Grow-only: shrinking below the user's base would silently change the + // requested GRIDUNIT when zoomed in. Guard the loop so a degenerate + // px_per_unit can never spin forever. The overflow fallback returns + // this axis's own base (returning the other axis's base mixed X/Y). + let mut guard = 0; + while s * px_per_unit < MIN_GRID_PX && guard < 32 { + s *= 5.0; + guard += 1; + if !s.is_finite() || s > 1e9 { + return base; + } + } + s + }; + (adapt(bx, bx), adapt(by, by)) } /// Parameters for the screen-space UCS icon drawn in the viewport corner. @@ -726,7 +932,9 @@ impl canvas::Program for GridCanvas { g.view_rot, g.eye, gb, - g.step, + g.step_x, + g.step_y, + g.major_every, g.origin, g.axes, g.limits, @@ -767,6 +975,8 @@ pub fn selection_overlay<'a>( crosshair: CrosshairOptions, selection_visual: SelectionVisualOptions, constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec)>, + constraint_glyph_tooltip: Option, + constraint_cursor_badge: Option, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -791,6 +1001,8 @@ pub fn selection_overlay<'a>( crosshair, selection_visual, constraint_glyphs, + constraint_glyph_tooltip, + constraint_cursor_badge, }) .width(Length::Fill) .height(Length::Fill) @@ -853,6 +1065,11 @@ struct SelectionCanvas { /// state, whether the pill itself is the current click-to-select target, /// and the points to mark while the pill is hovered. constraint_glyphs: Vec<(Point, [f32; 2], String, bool, bool, Vec)>, + /// Localized kind name made visible after the app-level hover dwell. + constraint_glyph_tooltip: Option, + /// Symbol shown beside the cursor while it targets an entity that already + /// participates in an enabled geometric constraint. + constraint_cursor_badge: Option, } fn draw_grip_marker( @@ -1563,6 +1780,68 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&h, stroke.clone()); frame.stroke(&v, stroke); } + SnapType::Vertex => { + // Filled square: the solid rhyme of Endpoint's hollow box. + let h = 5.0_f32; + frame.fill( + &canvas::Path::rectangle( + Point::new(sp.x - h, sp.y - h), + Size::new(h * 2.0, h * 2.0), + ), + marker, + ); + } + SnapType::EdgeMidpoint => { + // Filled triangle: the solid rhyme of Midpoint's outline. + let r = 6.0_f32; + let path = canvas::Path::new(|b| { + b.move_to(Point::new(sp.x, sp.y - r)); + b.line_to(Point::new(sp.x + r * 0.866, sp.y + r * 0.5)); + b.line_to(Point::new(sp.x - r * 0.866, sp.y + r * 0.5)); + b.close(); + }); + frame.fill(&path, marker); + } + SnapType::FaceCenter => { + // Filled disc: the solid rhyme of Center's outline. + frame.fill(&canvas::Path::circle(sp, 5.5_f32), marker); + } + SnapType::Knot => { + // Filled diamond: distinct from Quadrant's outline. + let r = 5.5_f32; + let path = canvas::Path::new(|b| { + b.move_to(Point::new(sp.x, sp.y - r)); + b.line_to(Point::new(sp.x + r, sp.y)); + b.line_to(Point::new(sp.x, sp.y + r)); + b.line_to(Point::new(sp.x - r, sp.y)); + b.close(); + }); + frame.fill(&path, marker); + } + SnapType::FacePerpendicular => { + // Right-angle hook like the 2D marker, plus a filled foot + // dot marking the face contact. + let r = 6.0_f32; + let p = canvas::Path::new(|b| { + b.move_to(Point::new(sp.x - r, sp.y - r)); + b.line_to(Point::new(sp.x - r, sp.y + r)); + b.line_to(Point::new(sp.x + r, sp.y + r)); + }); + frame.stroke(&p, stroke.clone()); + frame.fill(&canvas::Path::circle(sp, 2.0_f32), marker); + } + SnapType::NearestFace => { + // Filled bowtie: the solid rhyme of Nearest's outline. + let r = 5.5_f32; + let path = canvas::Path::new(|b| { + b.move_to(Point::new(sp.x - r, sp.y - r)); + b.line_to(Point::new(sp.x + r, sp.y - r)); + b.line_to(Point::new(sp.x - r, sp.y + r)); + b.line_to(Point::new(sp.x + r, sp.y + r)); + b.close(); + }); + frame.fill(&path, marker); + } } } @@ -1689,6 +1968,28 @@ impl canvas::Program for SelectionCanvas { let hole = canvas::Path::circle(Point::new(bx + 6.0, by + 10.5), 1.4); frame.fill(&hole, dark); } + if let Some(label) = &self.constraint_cursor_badge { + let center = Point::new(cp.x + sq + if self.hover_locked { 30.0 } else { 12.0 }, cp.y - sq - 7.0); + let blue = Color::from_rgb8(35, 145, 230); + let badge = canvas::Path::circle(center, 8.5); + frame.fill(&badge, blue); + if label == "◎" { + draw_concentric_constraint_glyph(&mut frame, center, Color::WHITE); + } else if label == "T" { + draw_tangent_constraint_glyph(&mut frame, center, Color::WHITE); + } else { + frame.fill_text(canvas::Text { + content: label.clone(), + position: center, + color: Color::WHITE, + size: iced::Pixels(11.0), + align_x: iced::alignment::Horizontal::Center.into(), + align_y: iced::alignment::Vertical::Center, + shaping: iced::advanced::text::Shaping::Advanced, + ..Default::default() + }); + } + } } } // end !over_viewcube @@ -1776,8 +2077,8 @@ impl canvas::Program for SelectionCanvas { let hovered = cursor .position_in(bounds) .and_then(|point| constraint_glyph_hit_test(&glyphs_for_offsets, point)); - let normal_bg = theme.palette().primary.base.color; - let normal_fg = theme.palette().primary.base.text; + let normal_bg = Color::from_rgb8(103, 109, 118); + let normal_fg = Color::WHITE; // A redundant or conflicting constraint gets the danger palette // instead of the // ordinary primary one — same information a resolver panel @@ -1785,19 +2086,32 @@ impl canvas::Program for SelectionCanvas { let conflict_bg = theme.palette().danger.base.color; let conflict_fg = theme.palette().danger.base.text; let selected_ring = theme.palette().primary.strong.color; + let coincident_bg = Color::from_rgb8(35, 145, 230); for ((anchor, outward, label, is_conflicting, is_selected, _), tangent_offset) in - self.constraint_glyphs.iter().zip(offsets) + self.constraint_glyphs.iter().zip(offsets.iter().copied()) { if !anchor.x.is_finite() || !anchor.y.is_finite() { continue; } - let (bg, fg) = if *is_conflicting { (conflict_bg, conflict_fg) } else { (normal_bg, normal_fg) }; + let compact_coincident = is_compact_coincident_glyph(label); + let (bg, fg) = if *is_conflicting { + (conflict_bg, conflict_fg) + } else if compact_coincident { + (coincident_bg, normal_fg) + } else { + (normal_bg, normal_fg) + }; let (top_left, size) = constraint_glyph_box(*anchor, *outward, label, tangent_offset); let pill = canvas::Path::rounded_rectangle( top_left, size, - (size.height * 0.5).into(), + (if compact_coincident { + 1.0 + } else { + size.height * 0.5 + }) + .into(), ); frame.fill(&pill, bg); if *is_selected { @@ -1806,23 +2120,50 @@ impl canvas::Program for SelectionCanvas { canvas::Stroke::default().with_color(selected_ring).with_width(2.0), ); } - let glyph_center = Point::new( - top_left.x + size.width * 0.5, - top_left.y + size.height * 0.5, - ); - if label == "T" { - draw_tangent_constraint_glyph(&mut frame, glyph_center, fg); - } else { - frame.fill_text(canvas::Text { - content: label.clone(), - position: glyph_center, - color: fg, - size: iced::Pixels(CONSTRAINT_GLYPH_SIZE), - align_x: iced::alignment::Horizontal::Center.into(), - align_y: iced::alignment::Vertical::Center, - shaping: iced::advanced::text::Shaping::Advanced, - ..Default::default() - }); + if !compact_coincident { + let glyph_center = Point::new( + top_left.x + size.width * 0.5, + top_left.y + size.height * 0.5, + ); + if label == "T" { + draw_tangent_constraint_glyph(&mut frame, glyph_center, fg); + } else if label == "G²" { + draw_smooth_constraint_glyph(&mut frame, glyph_center, fg); + } else if label == "◎" { + draw_concentric_constraint_glyph(&mut frame, glyph_center, fg); + } else if is_fixed_glyph(label) { + draw_fixed_constraint_glyph( + &mut frame, + glyph_center, + fg, + bg, + label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH, + ); + } else if is_vertical_glyph(label) { + draw_vertical_constraint_glyph( + &mut frame, + glyph_center, + fg, + label == crate::scene::parametric_constraints::VERTICAL_POINTS_GLYPH, + ); + } else { + // The Equal symbol reads green on its badge, as asked. + let color = if label == "=" { + Color::from_rgb8(72, 199, 116) + } else { + fg + }; + frame.fill_text(canvas::Text { + content: label.clone(), + position: glyph_center, + color, + size: iced::Pixels(CONSTRAINT_GLYPH_SIZE), + align_x: iced::alignment::Horizontal::Center.into(), + align_y: iced::alignment::Vertical::Center, + shaping: iced::advanced::text::Shaping::Advanced, + ..Default::default() + }); + } } } if let Some(index) = hovered { @@ -1852,6 +2193,41 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&first, stroke.clone()); frame.stroke(&second, stroke.clone()); } + if let Some(label) = &self.constraint_glyph_tooltip { + let (glyph_top_left, glyph_size) = constraint_glyph_box( + self.constraint_glyphs[index].0, + self.constraint_glyphs[index].1, + &self.constraint_glyphs[index].2, + offsets[index], + ); + let width = (label.chars().count() as f32 * 7.0 + 14.0).max(54.0); + let height = 24.0; + let left = glyph_top_left.x.clamp(2.0, (bounds.width - width - 2.0).max(2.0)); + let top = (glyph_top_left.y + glyph_size.height + 4.0) + .clamp(2.0, (bounds.height - height - 2.0).max(2.0)); + let tooltip = canvas::Path::rounded_rectangle( + Point::new(left, top), + Size::new(width, height), + 4.0.into(), + ); + frame.fill(&tooltip, theme.palette().background.strong.color); + frame.stroke( + &tooltip, + canvas::Stroke::default() + .with_color(theme.palette().background.strong.text) + .with_width(1.0), + ); + frame.fill_text(canvas::Text { + content: label.clone(), + position: Point::new(left + width * 0.5, top + height * 0.5), + color: theme.palette().background.strong.text, + size: iced::Pixels(12.0), + align_x: iced::alignment::Horizontal::Center.into(), + align_y: iced::alignment::Vertical::Center, + shaping: iced::advanced::text::Shaping::Advanced, + ..Default::default() + }); + } } } // Small cross at each acquired tracking point. @@ -1902,36 +2278,35 @@ fn draw_grid( view_rot: Mat4, eye: glam::DVec3, bounds: iced::Rectangle, - step: f32, + step_x: f32, + step_y: f32, + major_every: u32, grid_origin: glam::DVec3, grid_axes: (Vec3, Vec3, Vec3), limits: Option<(glam::DVec2, glam::DVec2)>, style: GridStyle, ) { let alpha = (style.opacity as f32 / 100.0).clamp(0.02, 1.0); - let gc = if style.bg_luminance > 0.5 { - // Light background: subtle dark grid lines - Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: alpha, - } + // Major lines stay subtle: ~1.5x the minor alpha with a slightly heavier + // stroke, matching the reference hierarchy without glaring. + let major_alpha = (alpha * 1.5).clamp(0.03, 1.0); + let (r, g, b) = if style.bg_luminance > 0.5 { + (0.10, 0.10, 0.10) } else { - // Dark background: subtle light grid lines - Color { - r: 0.80, - g: 0.80, - b: 0.80, - a: alpha, - } + (0.80, 0.80, 0.80) }; - let st = canvas::Stroke { + let minor_stroke = canvas::Stroke { width: 0.5, - style: canvas::Style::Solid(gc), + style: canvas::Style::Solid(Color { r, g, b, a: alpha }), ..Default::default() }; - let geometry = grid_segments(view_rot, eye, bounds, step, grid_origin, grid_axes, limits); + let major_stroke = canvas::Stroke { + width: 0.75, + style: canvas::Style::Solid(Color { r, g, b, a: major_alpha }), + ..Default::default() + }; + let geometry = + grid_segments(view_rot, eye, bounds, step_x, step_y, major_every, grid_origin, grid_axes, limits); if !geometry.segments.is_empty() { let path = canvas::Path::new(|builder| { for (p0, p1) in &geometry.segments { @@ -1939,11 +2314,20 @@ fn draw_grid( builder.line_to(*p1); } }); - frame.stroke(&path, st); + frame.stroke(&path, minor_stroke); + } + if !geometry.major_segments.is_empty() { + let path = canvas::Path::new(|builder| { + for (p0, p1) in &geometry.major_segments { + builder.move_to(*p0); + builder.line_to(*p1); + } + }); + frame.stroke(&path, major_stroke); } if geometry.axis_extent > 0.0 { let (gx, gy, gz) = grid_axes; - let extent = (geometry.axis_extent + step) * 1.5; + let extent = (geometry.axis_extent + step_x.max(step_y)) * 1.5; draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, (gx, gy, gz), style.bg_luminance); } } @@ -1961,7 +2345,9 @@ pub fn grid_segments( view_rot: Mat4, eye: glam::DVec3, bounds: iced::Rectangle, - step: f32, + step_x: f32, + step_y: f32, + major_every: u32, grid_origin: glam::DVec3, grid_axes: (Vec3, Vec3, Vec3), limits: Option<(glam::DVec2, glam::DVec2)>, @@ -2033,7 +2419,7 @@ pub fn grid_segments( // family at a point on the grid. Measuring the perpendicular component, // rather than point-to-point distance, remains correct for a skewed // perspective grid. - let grid_gaps = |world: glam::DVec3, step: f32| -> Option<(f32, f32)> { + let grid_gaps = |world: glam::DVec3, sx: f32, sy: f32| -> Option<(f32, f32)> { let p = project(world)?; let projected_deltas = |axis: Vec3, amount: f32| { [amount, -amount].map(|signed_step| { @@ -2041,14 +2427,16 @@ pub fn grid_segments( .map(|next| glam::Vec2::new(next.x - p.x, next.y - p.y)) }) }; - let neighbours1 = projected_deltas(axis1, step); - let neighbours2 = projected_deltas(axis2, step); + let neighbours1 = projected_deltas(axis1, sx); + let neighbours2 = projected_deltas(axis2, sy); // A full grid step is needed to measure adjacent-line distance, but it // is too large for the line's local tangent near the eye. A small // derivative keeps the tangent measurable without crossing the eye. - let tangent_step = (step * 0.01).max(1e-4); - let tangents1 = projected_deltas(axis1, tangent_step); - let tangents2 = projected_deltas(axis2, tangent_step); + // BUG FIX: the tangent used a single shared step for both families, so + // a non-square GRIDUNIT (sx != sy) measured the wrong local direction. + // Each family now uses its own derivative. + let tangents1 = projected_deltas(axis1, (sx * 0.01).max(1e-4)); + let tangents2 = projected_deltas(axis2, (sy * 0.01).max(1e-4)); // At the near side of a perspective plane a large +step neighbour may // cross behind the eye while the -step neighbour remains perfectly @@ -2098,10 +2486,15 @@ pub fn grid_segments( // 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 { + // BUG FIX: a single shared step forced square grids; X and Y are now + // validated independently so GRIDUNIT X/Y resize each family. + if !step_x.is_finite() || step_x <= 0.0 || !step_y.is_finite() || step_y <= 0.0 { return GridGeometry::empty(); } - let s = step; + let (sx, sy) = (step_x, step_y); + let is_major = |index: i32| { + major_every >= 2 && (index.rem_euclid(major_every as i32) == 0) + }; // Trace a family-specific visible region around the viewport perimeter. // When a boundary ray points through the horizon, binary-search back toward @@ -2113,7 +2506,7 @@ pub fn grid_segments( | -> Vec { let visible_at = |screen: glam::Vec2| -> Option { let world = unproject(screen.x, screen.y)?; - let gaps = grid_gaps(world, s)?; + let gaps = grid_gaps(world, sx, sy)?; let gap = if family == 0 { gaps.0 } else { gaps.1 }; (gap >= MIN_HORIZON_GRID_PX).then_some(world) }; @@ -2155,7 +2548,7 @@ pub fn grid_segments( let best_anchor = |family: usize| -> Option<(glam::Vec2, glam::DVec3, f32)> { let mut best = None; for (screen, world) in &samples { - let Some(gaps) = grid_gaps(*world, s) else { + let Some(gaps) = grid_gaps(*world, sx, sy) else { continue; }; let gap = if family == 0 { gaps.0 } else { gaps.1 }; @@ -2177,13 +2570,13 @@ pub fn grid_segments( } (min <= max).then_some((min, max)) }; - let line_range = |min: f32, max: f32, anchor: f32| -> (i32, i32) { - let mut start = (min / s).floor() as i32; - let mut end = (max / s).ceil() as i32; + let line_range = |min: f32, max: f32, anchor: f32, step: f32| -> (i32, i32) { + let mut start = (min / step).floor() as i32; + let mut end = (max / step).ceil() as i32; // The pixel-gap cut-off naturally bounds this by viewport resolution. Keep // malformed projection data from creating an unbounded CPU loop. let limit = ((bounds.width + bounds.height).ceil() as i32 + 64).max(128); - let center = (anchor / s).round() as i32; + let center = (anchor / step).round() as i32; start = start.max(center.saturating_sub(limit)); end = end.min(center.saturating_add(limit)); (start, end) @@ -2284,7 +2677,7 @@ pub fn grid_segments( let Some(world) = unproject(screen.x, screen.y) else { return false; }; - let Some(gaps) = grid_gaps(world, s) else { + let Some(gaps) = grid_gaps(world, sx, sy) else { return false; }; let gap = if family == 0 { gaps.0 } else { gaps.1 }; @@ -2400,14 +2793,15 @@ pub fn grid_segments( let (min1, max1) = coordinate_range(axis1); let (min2, max2) = coordinate_range(axis2); - let mut segments = Vec::new(); + let mut minor = Vec::new(); + let mut major = Vec::new(); if let Some((_, anchor_world, gap)) = best_anchor(0) { if gap >= MIN_HORIZON_GRID_PX { let anchor = (anchor_world - grid_origin).as_vec3().dot(axis1); - let (start, end) = line_range(min1, max1, anchor); + let (start, end) = line_range(min1, max1, anchor, sx); for index in start..=end { - if let Some(segment) = clip_world_line(0, index as f32 * s) { - segments.push(segment); + if let Some(segment) = clip_world_line(0, index as f32 * sx) { + if is_major(index) { major.push(segment); } else { minor.push(segment); } } } } @@ -2415,15 +2809,16 @@ pub fn grid_segments( if let Some((_, anchor_world, gap)) = best_anchor(1) { if gap >= MIN_HORIZON_GRID_PX { let anchor = (anchor_world - grid_origin).as_vec3().dot(axis2); - let (start, end) = line_range(min2, max2, anchor); + let (start, end) = line_range(min2, max2, anchor, sy); for index in start..=end { - if let Some(segment) = clip_world_line(1, index as f32 * s) { - segments.push(segment); + if let Some(segment) = clip_world_line(1, index as f32 * sy) { + if is_major(index) { major.push(segment); } else { minor.push(segment); } } } } } - all_segments.extend(segments); + all_segments.extend(minor); + let all_major: Vec<(Point, Point)> = major; // 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 @@ -2437,9 +2832,10 @@ pub fn grid_segments( if limits_extent > 0.0 { axis_extent = limits_extent; } - return GridGeometry { segments: all_segments, axis_extent }; + return GridGeometry { segments: all_segments, major_segments: all_major, axis_extent }; } + let mut all_major: Vec<(Point, Point)> = Vec::new(); // Lines parallel to axis2 (varying axis1 position). if let Some((anchor_screen, anchor_world, gap)) = best_anchor(0) { if gap >= MIN_HORIZON_GRID_PX { @@ -2448,15 +2844,14 @@ pub fn grid_segments( (axis_range(&hits, axis1), axis_range(&hits, axis2)) { let anchor1 = (anchor_world - grid_origin).as_vec3().dot(axis1); - let (start, end) = line_range(min1, max1, anchor1); - let mut segments = Vec::with_capacity((end - start + 1).max(0) as usize); + let (start, end) = line_range(min1, max1, anchor1, sx); for i in start..=end { - let value = i as f32 * s; + let value = i as f32 * sx; if let Some((p0, p1)) = project_line(0, value) { - segments.extend(trim_line(0, p0, p1)); + let segs = trim_line(0, p0, p1); + if is_major(i) { all_major.extend(segs); } else { all_segments.extend(segs); } } } - all_segments.extend(segments); axis_extent = axis_extent.max(min1.abs().max(max1.abs()).max(min2.abs()).max(max2.abs())); } @@ -2471,15 +2866,14 @@ pub fn grid_segments( (axis_range(&hits, axis1), axis_range(&hits, axis2)) { let anchor2 = (anchor_world - grid_origin).as_vec3().dot(axis2); - let (start, end) = line_range(min2, max2, anchor2); - let mut segments = Vec::with_capacity((end - start + 1).max(0) as usize); + let (start, end) = line_range(min2, max2, anchor2, sy); for i in start..=end { - let value = i as f32 * s; + let value = i as f32 * sy; if let Some((p0, p1)) = project_line(1, value) { - segments.extend(trim_line(1, p0, p1)); + let segs = trim_line(1, p0, p1); + if is_major(i) { all_major.extend(segs); } else { all_segments.extend(segs); } } } - all_segments.extend(segments); axis_extent = axis_extent.max(min1.abs().max(max1.abs()).max(min2.abs()).max(max2.abs())); } @@ -2487,7 +2881,7 @@ pub fn grid_segments( } let _ = gz; // gz unused after move; retained for symmetry with `draw_axes` call sites. - GridGeometry { segments: all_segments, axis_extent } + GridGeometry { segments: all_segments, major_segments: all_major, axis_extent } } // ── Coloured UCS axes ────────────────────────────────────────────────────── @@ -2940,7 +3334,9 @@ impl DynInputCanvas { fn box_content(b: &DynBox) -> String { match b.role { - DynRole::Angle => format!("{}\u{00B0}", b.value), + // Formatted live values already carry their unit marker; typed + // buffers stay unadorned while they are being edited. + DynRole::Angle => b.value.clone(), _ if b.label.is_empty() => b.value.clone(), _ => format!("{}{}", b.label, b.value), } @@ -3561,7 +3957,9 @@ mod grid_key_tests { width: 1280.0, height: 720.0, }, - step: 80.0, + step_x: 80.0, + step_y: 80.0, + major_every: 5, origin: glam::DVec3::new(0.0, 0.0, 0.0), axes: (Vec3::X, Vec3::Y, Vec3::Z), limits: None, @@ -3611,13 +4009,31 @@ mod grid_key_tests { "eye change must invalidate" ); - // step: zoom in + // step_x: zoom in let mut p = baseline_params(); - p.step = 40.0; + p.step_x = 40.0; assert_ne!( GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()), baseline_key, - "step change must invalidate" + "step_x change must invalidate" + ); + + // step_y: non-square grid resize + let mut p = baseline_params(); + p.step_y = 40.0; + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()), + baseline_key, + "step_y change must invalidate" + ); + + // major_every: major-line frequency change + let mut p = baseline_params(); + p.major_every = 10; + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds, GridStyle::default()), + baseline_key, + "major_every change must invalidate" ); // origin: translate the UCS origin off-zero @@ -3691,7 +4107,7 @@ mod grid_key_tests { let baseline = GridKey::from_grids(&both, bounds, GridStyle::default()); let mut pane2_changed = pane2; - pane2_changed.step = 160.0; + pane2_changed.step_x = 160.0; let dirty = vec![pane1, pane2_changed]; assert_ne!( GridKey::from_grids(&dirty, bounds, GridStyle::default()), @@ -3724,13 +4140,90 @@ mod grid_key_tests { fn should_reuse_changed() { let grids_a = vec![baseline_params()]; let mut pane2 = baseline_params(); - pane2.step = 160.0; + pane2.step_x = 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, GridStyle::default()); let new = GridKey::from_grids(&grids_b, bounds, GridStyle::default()); assert!(!should_reuse(Some(&old), &new)); } + + /// Adaptive steps grow from the GRIDUNIT base (the old hardcoded 1.0 base + /// ignored the DSettings grid spacing). Fixed mode returns the base verbatim. + #[test] + fn grid_steps_follow_base_and_adaptive_flag() { + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1280.0, height: 720.0 }; + // Far zoom: 10-unit base must scale up to stay readable. + let (sx, sy) = compute_grid_steps(10.0, 10.0, 5000.0, 0.6, bounds, true); + assert!(sx >= 10.0 && sy >= 10.0); + assert_eq!((sx, sy), (sx.max(10.0), sy.max(10.0))); + // Non-uniform bases stay non-uniform. + let (nx, ny) = compute_grid_steps(10.0, 2.0, 5000.0, 0.6, bounds, true); + assert!(nx >= 10.0 && ny >= 2.0); + // Fixed mode returns exactly what was typed. + assert_eq!(compute_grid_steps(7.5, 2.5, 5000.0, 0.6, bounds, false), (7.5, 2.5)); + // Degenerate input sanitizes to 1.0 instead of emptying the grid. + assert_eq!(compute_grid_steps(0.0, -3.0, 5000.0, 0.6, bounds, false), (1.0, 1.0)); + } + + /// Overflow fallback keeps each axis on its own base: an extreme zoom-out + /// that pushes the 5x growth past 1e9 must return (bx, by), never (bx, bx). + #[test] + fn grid_steps_overflow_falls_back_per_axis() { + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1280.0, height: 720.0 }; + assert_eq!( + compute_grid_steps(10.0, 2.0, 1e15, 0.6, bounds, true), + (10.0, 2.0) + ); + } + + /// Non-uniform steps still produce grid geometry on both families. + #[test] + fn grid_segments_support_non_square_spacing() { + // Top-down orthographic-ish view over the origin: deterministic lines. + let view_rot = glam::camera::rh::proj::directx::orthographic( + -400.0, 400.0, -300.0, 300.0, 0.1, 2000.0, + ) * glam::camera::rh::view::look_at_mat4( + Vec3::new(0.0, 0.0, 500.0), + Vec3::ZERO, + Vec3::Y, + ); + let eye = glam::DVec3::new(0.0, 0.0, 500.0); + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 800.0, height: 600.0 }; + let g = grid_segments( + view_rot, eye, bounds, 10.0, 2.0, 5, + glam::DVec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None, + ); + assert!(!g.segments.is_empty(), "grid lines expected"); + } + + /// Every-Nth line lands in `major_segments`, the rest in `segments`. + #[test] + fn grid_segments_partition_major_lines() { + // Top-down orthographic-ish view over the origin: deterministic lines. + let view_rot = glam::camera::rh::proj::directx::orthographic( + -400.0, 400.0, -300.0, 300.0, 0.1, 2000.0, + ) * glam::camera::rh::view::look_at_mat4( + Vec3::new(0.0, 0.0, 500.0), + Vec3::ZERO, + Vec3::Y, + ); + let eye = glam::DVec3::new(0.0, 0.0, 500.0); + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 800.0, height: 600.0 }; + let g = grid_segments( + view_rot, eye, bounds, 10.0, 10.0, 5, + glam::DVec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None, + ); + assert!(!g.segments.is_empty(), "minor lines expected"); + assert!(!g.major_segments.is_empty(), "major lines expected"); + // major_every = 1 disables the split: everything is minor. + let flat = grid_segments( + view_rot, eye, bounds, 10.0, 10.0, 1, + glam::DVec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None, + ); + assert!(flat.major_segments.is_empty()); + assert!(!flat.segments.is_empty()); + } } #[cfg(test)] @@ -3755,7 +4248,6 @@ mod grid_canvas_state_tests { 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; @@ -3764,7 +4256,9 @@ mod grid_canvas_state_tests { view_rot, eye, bounds, - step, + step_x: 80.0, + step_y: 80.0, + major_every: 5, origin: grid_origin, axes: grid_axes, limits, diff --git a/src/ui/popup/context_menu.rs b/src/ui/popup/context_menu.rs new file mode 100644 index 000000000..539e28378 --- /dev/null +++ b/src/ui/popup/context_menu.rs @@ -0,0 +1,1061 @@ +//! Viewport right-click context menu — the *model*. +//! +//! `build_context_menu` is a pure function from the app's current state +//! (`MenuContext`) to the rows the menu shows (`ContextMenu`). It knows nothing +//! about iced: the view renders the rows, and the keyboard handler in `update` +//! walks the same rows to resolve Up/Down/Enter/mnemonic picks, so what the +//! user sees and what a keypress selects can never diverge. +//! +//! The layout covers the shortcut menus of commercial solutions, which the user base already +//! knows: while a command runs the menu offers Enter / Cancel / the command's +//! own keyword options (with the keyword shown as a hint, so the menu also +//! teaches the typed shortcut); when idle it offers Repeat / Recent Input / +//! Clipboard / Undo-Redo / Isolate / Pan-Zoom / selection tools; during a grip +//! edit it offers the grip modes. + +use crate::command::CmdOption; +use crate::scene::parametric_constraints::ConstraintId; +use crate::scene::pick::grip::GripEditMode; +use crate::snap::SnapType; +use crate::t; + +/// Fixed row height used by the renderer, so `ContextMenu::default_row_y` +/// (which anchors the default row under the cursor) agrees with the layout. +pub const MENU_ROW_H: f32 = 22.0; +/// Height of a separator row (1 px line plus its vertical padding). +pub const MENU_SEP_H: f32 = 7.0; +/// Vertical padding inside the menu panel above the first row. +pub const MENU_PAD_TOP: f32 = 4.0; +/// Column width without keyword hints (the width the menu always had). +pub const MENU_WIDTH: f32 = 200.0; +/// Column width when any row carries a right-aligned keyword hint. +pub const MENU_WIDTH_WITH_HINTS: f32 = 236.0; +/// How many recent entries the Recent Input / Repeat lists show. +pub const RECENT_LIMIT: usize = 10; + +/// Identifies an inline-expanding (accordion) submenu. Only one is open at a +/// time; the open one is tracked in `SelectionState::context_menu_ui`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SubmenuId { + RecentInput, + Clipboard, + DrawOrder, + Isolate, + SnapOverrides, +} + +/// Glyph drawn in a row's icon gutter (commercial solutions show one for object snaps +/// and the navigation rows). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MenuIcon { + Snap(SnapType), + Mtp, + Pan, + Zoom, +} + +/// What a grip-menu row does (the grip-mode shortcut menu of commercial solutions). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GripMenuCmd { + Stretch, + Move, + Rotate, + Scale, + Mirror, + BasePoint, + CopyToggle, + Undo, + Exit, +} + +/// Everything a menu row can do. Translated into the app's existing messages +/// by `OpenCADStudio::on_context_menu_pick`, so a row does exactly what the +/// equivalent typed input / shortcut does. +#[derive(Clone, Debug, PartialEq)] +pub enum MenuAction { + /// Enter — finish / accept the current step (`CommandFinalize`). + Enter, + /// Escape — cancel the running command (`CommandEscape`). + Cancel, + /// Feed a command option keyword (`CommandOptionPick`). Empty = Enter. + Option(String), + /// Re-feed a recent typed token (point, distance, keyword) to the + /// running command. + FeedInput(String), + /// Run a command line string (`Message::Command`). + Command(String), + /// One-shot "Mid Between 2 Points" snap override. + Mtp, + /// One-shot object snap override for the next pick. + SnapOverride(SnapType), + /// Next pick ignores object snaps. + SnapOverrideNone, + DeleteSelected, + SelectSimilar, + InvertSelection, + DeselectAll, + QuickSelect, + Properties, + Options, + Undo, + Redo, + DrawOrderPickRef(bool), + ConstraintDelete(ConstraintId), + Grip(GripMenuCmd), + /// Expand / collapse an accordion submenu (never "picked" as a command). + ToggleSubmenu(SubmenuId), +} + +/// One selectable row. +#[derive(Clone, Debug, PartialEq)] +pub struct MenuItem { + pub label: String, + /// Right-aligned keyword hint, e.g. `"C"` for Close, `"⏎"` for the Enter + /// row. Teaches the typed shortcut without cluttering the label. + pub hint: Option, + /// Uppercase letter that picks this row from the keyboard while the menu + /// is open. Derived from the option keyword's first character. + pub mnemonic: Option, + pub action: MenuAction, + /// The row Enter picks when nothing is highlighted; rendered bold and + /// anchored under the cursor when the menu opens. + pub default: bool, + pub enabled: bool, + /// Rendered with a leading check mark (toggle / current-state rows). + pub checked: bool, + /// Glyph in the icon gutter. + pub icon: Option, +} + +impl MenuItem { + fn new(label: impl Into, action: MenuAction) -> Self { + Self { + label: label.into(), + hint: None, + mnemonic: None, + action, + default: false, + enabled: true, + checked: false, + icon: None, + } + } + + fn icon(mut self, icon: MenuIcon) -> Self { + self.icon = Some(icon); + self + } + + fn hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + fn mnemonic(mut self, ch: Option) -> Self { + self.mnemonic = ch; + self + } + + fn default(mut self) -> Self { + self.default = true; + self + } + + fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + fn checked(mut self, checked: bool) -> Self { + self.checked = checked; + self + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum MenuRow { + Item(MenuItem), + Separator, + Submenu { + id: SubmenuId, + label: String, + items: Vec, + open: bool, + }, +} + +/// A keyboard-navigable row in display order: either an item or a submenu +/// header. Separators and the children of collapsed submenus are skipped. +#[derive(Clone, Debug, PartialEq)] +pub struct Selectable { + pub action: MenuAction, + pub mnemonic: Option, + pub enabled: bool, + /// `Some(id)` for a submenu header row. + pub header: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ContextMenu { + pub rows: Vec, + pub width: f32, +} + +impl ContextMenu { + /// Keyboard-navigable rows in display order. The index into this list is + /// the highlight index kept in `ContextMenuUi::highlighted`. + pub fn selectable(&self) -> Vec { + let mut out = Vec::new(); + for row in &self.rows { + match row { + MenuRow::Item(item) => out.push(Selectable { + action: item.action.clone(), + mnemonic: item.mnemonic, + enabled: item.enabled, + header: None, + }), + MenuRow::Separator => {} + MenuRow::Submenu { id, items, open, .. } => { + out.push(Selectable { + action: MenuAction::ToggleSubmenu(*id), + mnemonic: None, + enabled: !items.is_empty(), + header: Some(*id), + }); + if *open { + for item in items { + out.push(Selectable { + action: item.action.clone(), + mnemonic: item.mnemonic, + enabled: item.enabled, + header: None, + }); + } + } + } + } + } + out + } + + /// Index (into `selectable()`) of the default row, else the first row. + pub fn default_index(&self) -> usize { + let mut idx = 0; + for row in &self.rows { + match row { + MenuRow::Item(item) => { + if item.default { + return idx; + } + idx += 1; + } + MenuRow::Separator => {} + MenuRow::Submenu { items, open, .. } => { + idx += 1; + if *open { + for item in items { + if item.default { + return idx; + } + idx += 1; + } + } + } + } + } + 0 + } + + /// The enabled row whose mnemonic is `ch`, searching forward from (and + /// excluding) `from` and wrapping, so repeated presses cycle through rows + /// sharing a letter (PLINE's CEnter / CLose both answer to `C`). + pub fn find_mnemonic(&self, ch: char, from: Option) -> Option { + let ch = ch.to_ascii_uppercase(); + let rows = self.selectable(); + if rows.is_empty() { + return None; + } + let start = from.map_or(0, |f| (f + 1) % rows.len()); + (0..rows.len()) + .map(|k| (start + k) % rows.len()) + .find(|&i| rows[i].enabled && rows[i].mnemonic == Some(ch)) + } + + /// Vertical offset from the panel's top edge to the top of the default + /// row, using the renderer's fixed row metrics. + pub fn default_row_y(&self) -> f32 { + let mut y = MENU_PAD_TOP; + for row in &self.rows { + match row { + MenuRow::Item(item) => { + if item.default { + return y; + } + y += MENU_ROW_H; + } + MenuRow::Separator => y += MENU_SEP_H, + MenuRow::Submenu { items, open, .. } => { + y += MENU_ROW_H; + if *open { + for item in items { + if item.default { + return y; + } + y += MENU_ROW_H; + } + } + } + } + } + MENU_PAD_TOP + } + + /// Total panel height (rows plus the panel's vertical padding). + pub fn height(&self) -> f32 { + let mut h = MENU_PAD_TOP * 2.0; + for row in &self.rows { + match row { + MenuRow::Item(_) => h += MENU_ROW_H, + MenuRow::Separator => h += MENU_SEP_H, + MenuRow::Submenu { items, open, .. } => { + h += MENU_ROW_H; + if *open { + h += items.len() as f32 * MENU_ROW_H; + } + } + } + } + h + } + + fn has_hints(&self) -> bool { + self.rows.iter().any(|row| match row { + MenuRow::Item(item) => item.hint.is_some(), + MenuRow::Separator => false, + MenuRow::Submenu { items, .. } => items.iter().any(|item| item.hint.is_some()), + }) + } +} + +/// State of a grip edit the menu was opened over. +#[derive(Clone, Debug, PartialEq)] +pub struct GripMenuContext { + pub mode: GripEditMode, + pub copy_on: bool, + /// The grip has been dragged away from its origin (Undo snaps it back). + pub moved: bool, +} + +/// Inputs the builder needs, gathered by `OpenCADStudio::context_menu_context`. +#[derive(Clone, Debug)] +pub enum MenuContext { + /// A command is running: its current step's options. + Command { + options: Vec, + /// The step takes a point pick (the M2P override applies). + has_point_step: bool, + /// Recently typed tokens (newest first) for the Recent Input submenu. + recent_inputs: Vec, + }, + /// Nothing running. + Idle { + has_selection: bool, + selected_constraint: Option, + isolation_active: bool, + /// Recently run commands, newest first. + recent_cmds: Vec, + clipboard_nonempty: bool, + undo_label: Option, + redo_label: Option, + props_open: bool, + }, + /// A grip is hot / being dragged. + Grip(GripMenuContext), +} + +/// Build the menu rows for `ctx`, with `open_submenu` expanded. +pub fn build_context_menu(ctx: &MenuContext, open_submenu: Option) -> ContextMenu { + let rows = match ctx { + MenuContext::Command { + options, + has_point_step, + recent_inputs, + } => command_rows(options, *has_point_step, recent_inputs, open_submenu), + MenuContext::Idle { + has_selection, + selected_constraint, + isolation_active, + recent_cmds, + clipboard_nonempty, + undo_label, + redo_label, + props_open, + } => idle_rows( + *has_selection, + *selected_constraint, + *isolation_active, + recent_cmds, + *clipboard_nonempty, + undo_label.as_deref(), + redo_label.as_deref(), + *props_open, + open_submenu, + ), + MenuContext::Grip(grip) => grip_rows(grip), + }; + let mut menu = ContextMenu { rows, width: MENU_WIDTH }; + if menu.has_hints() { + menu.width = MENU_WIDTH_WITH_HINTS; + } + menu +} + +/// Keyboard letter for an option keyword: its first ASCII alphanumeric. +fn mnemonic_for(keyword: &str) -> Option { + keyword + .chars() + .find(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_uppercase()) +} + +fn command_rows( + options: &[CmdOption], + has_point_step: bool, + recent_inputs: &[String], + open_submenu: Option, +) -> Vec { + let mut rows = Vec::new(); + // Ordered by how often a drafter reaches for each row: Enter / Cancel + // under the pointer, the command's own keywords right below them (the + // choices that change from step to step), then the recall and snap + // helpers, and the transparent navigation commands last. + // + // A command may name its own finish action (PLINE "Done", OFFSET's + // "<0.5000>" default). That row *is* Enter: it is not listed twice, and + // a default value is shown as the Enter row's hint. + let finish = options.iter().find(|o| o.keyword.is_empty()); + let enter_hint = match finish { + Some(o) if o.label.starts_with('<') => o.label.clone(), + _ => "⏎".to_string(), + }; + rows.push(MenuRow::Item( + MenuItem::new(t!("Enter").into_owned(), MenuAction::Enter).hint(enter_hint).default(), + )); + rows.push(MenuRow::Item( + MenuItem::new(t!("Cancel").into_owned(), MenuAction::Cancel).hint("Esc"), + )); + + let keyword_opts: Vec<&CmdOption> = options.iter().filter(|o| !o.keyword.is_empty()).collect(); + if !keyword_opts.is_empty() { + rows.push(MenuRow::Separator); + for opt in keyword_opts { + rows.push(MenuRow::Item( + MenuItem::new(opt.label.clone(), MenuAction::Option(opt.keyword.clone())) + .hint(opt.keyword.clone()) + .mnemonic(mnemonic_for(&opt.keyword)), + )); + } + } + + rows.push(MenuRow::Separator); + rows.push(recent_input_submenu( + recent_inputs + .iter() + .take(RECENT_LIMIT) + .map(|tok| MenuItem::new(tok.clone(), MenuAction::FeedInput(tok.clone()))) + .collect(), + open_submenu, + )); + if has_point_step { + rows.push(MenuRow::Submenu { + id: SubmenuId::SnapOverrides, + label: t!("Snap Overrides").into_owned(), + items: snap_override_items(), + open: open_submenu == Some(SubmenuId::SnapOverrides), + }); + } + + rows.push(MenuRow::Separator); + rows.extend(navigation_rows(true)); + rows +} + +/// Recent Input ▸ — always listed (commercial solutions keep the entry even when there +/// is nothing to recall yet; it is simply disabled then). +fn recent_input_submenu(items: Vec, open_submenu: Option) -> MenuRow { + MenuRow::Submenu { + id: SubmenuId::RecentInput, + label: t!("Recent Input").into_owned(), + items, + open: open_submenu == Some(SubmenuId::RecentInput), + } +} + +/// Snap Overrides ▸: one-shot object snaps for the next pick, in the +/// grouping (M2P, then the edge snaps, the curve snaps, the relation snaps, +/// None) plus the settings dialog. +fn snap_override_items() -> Vec { + let snap = |label: &str, t: SnapType| { + MenuItem::new(t!(label).into_owned(), MenuAction::SnapOverride(t)).icon(MenuIcon::Snap(t)) + }; + vec![ + MenuItem::new(t!("Mid Between 2 Points").into_owned(), MenuAction::Mtp).icon(MenuIcon::Mtp), + snap("Endpoint", SnapType::Endpoint), + snap("Midpoint", SnapType::Midpoint), + snap("Intersection", SnapType::Intersection), + snap("Apparent Intersection", SnapType::ApparentIntersection), + snap("Extension", SnapType::Extension), + snap("Center", SnapType::Center), + snap("Quadrant", SnapType::Quadrant), + snap("Tangent", SnapType::Tangent), + snap("Perpendicular", SnapType::Perpendicular), + snap("Parallel", SnapType::Parallel), + snap("Node", SnapType::Node), + snap("Insertion", SnapType::Insertion), + snap("Nearest", SnapType::Nearest), + MenuItem::new(t!("None").into_owned(), MenuAction::SnapOverrideNone), + MenuItem::new( + t!("Osnap Settings...").into_owned(), + MenuAction::Command("OSNAP".to_string()), + ), + ] +} + +/// Pan / Zoom rows. While a command runs they execute transparently (the +/// `'` prefix, as in commercial solutions) so the command resumes afterwards. +fn navigation_rows(transparent: bool) -> Vec { + let prefix = if transparent { "'" } else { "" }; + vec![ + MenuRow::Item( + MenuItem::new(t!("Pan").into_owned(), MenuAction::Command(format!("{prefix}PAN"))) + .icon(MenuIcon::Pan), + ), + MenuRow::Item( + MenuItem::new( + t!("Zoom").into_owned(), + MenuAction::Command(format!("{prefix}ZOOM DYNAMIC")), + ) + .icon(MenuIcon::Zoom), + ), + ] +} + +#[allow(clippy::too_many_arguments)] +fn idle_rows( + has_selection: bool, + selected_constraint: Option, + isolation_active: bool, + recent_cmds: &[String], + clipboard_nonempty: bool, + undo_label: Option<&str>, + redo_label: Option<&str>, + props_open: bool, + open_submenu: Option, +) -> Vec { + let cmd = |label: String, name: &str| MenuItem::new(label, MenuAction::Command(name.to_string())); + let mut rows = Vec::new(); + + // ── Repeat / Recent Input ────────────────────────────────────────── + if let Some(last) = recent_cmds.first() { + rows.push(MenuRow::Item( + MenuItem::new( + t!("Repeat %{last}", last = last.clone()).into_owned(), + MenuAction::Command(last.to_uppercase()), + ) + .default(), + )); + } + rows.push(recent_input_submenu( + recent_cmds + .iter() + .take(RECENT_LIMIT) + .map(|c| MenuItem::new(c.clone(), MenuAction::Command(c.to_uppercase()))) + .collect(), + open_submenu, + )); + rows.push(MenuRow::Separator); + + // ── Clipboard ────────────────────────────────────────────────────── + rows.push(MenuRow::Submenu { + id: SubmenuId::Clipboard, + label: t!("Clipboard").into_owned(), + items: vec![ + cmd(t!("Cut").into_owned(), "CUTCLIP").enabled(has_selection), + cmd(t!("Copy").into_owned(), "COPYCLIP").enabled(has_selection), + cmd(t!("Copy with Base Point").into_owned(), "COPYBASE").enabled(has_selection), + cmd(t!("Paste").into_owned(), "PASTECLIP").enabled(clipboard_nonempty), + cmd(t!("Paste as Block").into_owned(), "PASTEBLOCK").enabled(clipboard_nonempty), + cmd(t!("Paste to Original Coordinates").into_owned(), "PASTEORIG") + .enabled(clipboard_nonempty), + ], + open: open_submenu == Some(SubmenuId::Clipboard), + }); + rows.push(MenuRow::Separator); + + // ── Undo / Redo ──────────────────────────────────────────────────── + let undo = match undo_label { + Some(label) => t!("Undo %{label}", label = label.to_string()).into_owned(), + None => t!("Undo").into_owned(), + }; + let redo = match redo_label { + Some(label) => t!("Redo %{label}", label = label.to_string()).into_owned(), + None => t!("Redo").into_owned(), + }; + rows.push(MenuRow::Item( + MenuItem::new(undo, MenuAction::Undo).hint("Ctrl+Z").enabled(undo_label.is_some()), + )); + rows.push(MenuRow::Item( + MenuItem::new(redo, MenuAction::Redo).hint("Ctrl+Y").enabled(redo_label.is_some()), + )); + rows.push(MenuRow::Separator); + + // ── Selection edit block (the "Edit" shortcut menu of commercial solutions) ── + if has_selection { + rows.push(MenuRow::Item(MenuItem::new( + t!("Erase").into_owned(), + MenuAction::DeleteSelected, + ))); + rows.push(MenuRow::Item(cmd(t!("Move").into_owned(), "MOVE"))); + rows.push(MenuRow::Item(cmd(t!("Copy Selection").into_owned(), "COPY"))); + rows.push(MenuRow::Item(cmd(t!("Scale").into_owned(), "SCALE"))); + rows.push(MenuRow::Item(cmd(t!("Rotate").into_owned(), "ROTATE"))); + rows.push(MenuRow::Item(cmd(t!("Mirror").into_owned(), "MIRROR"))); + rows.push(MenuRow::Submenu { + id: SubmenuId::DrawOrder, + label: t!("Draw Order").into_owned(), + items: vec![ + cmd(t!("Bring to Front").into_owned(), "DRAWORDER F"), + cmd(t!("Send to Back").into_owned(), "DRAWORDER B"), + MenuItem::new( + t!("Bring Above Object").into_owned(), + MenuAction::DrawOrderPickRef(true), + ), + MenuItem::new( + t!("Send Under Object").into_owned(), + MenuAction::DrawOrderPickRef(false), + ), + ], + open: open_submenu == Some(SubmenuId::DrawOrder), + }); + rows.push(MenuRow::Separator); + } else if let Some(id) = selected_constraint { + // A selected constraint-glyph pill gets a minimal edit block — none + // of the entity actions above apply to it. + rows.push(MenuRow::Item(MenuItem::new( + t!("Delete").into_owned(), + MenuAction::ConstraintDelete(id), + ))); + rows.push(MenuRow::Separator); + } + + // ── Isolate ──────────────────────────────────────────────────────── + rows.push(MenuRow::Submenu { + id: SubmenuId::Isolate, + label: t!("Isolate").into_owned(), + items: vec![ + cmd(t!("Isolate Objects").into_owned(), "ISOLATEOBJECTS").enabled(has_selection), + cmd(t!("Hide Objects").into_owned(), "HIDEOBJECTS").enabled(has_selection), + cmd(t!("End Object Isolation").into_owned(), "UNISOLATEOBJECTS") + .enabled(isolation_active), + ], + open: open_submenu == Some(SubmenuId::Isolate), + }); + rows.push(MenuRow::Separator); + + // ── Navigation ───────────────────────────────────────────────────── + rows.extend(navigation_rows(false)); + // The edit menu is long already; Zoom Extents stays on the idle menu + // (and on the middle button's double-click), as in commercial solutions. + if !has_selection { + rows.push(MenuRow::Item(cmd(t!("Zoom Extents").into_owned(), "ZOOM EXTENTS"))); + } + rows.push(MenuRow::Separator); + + // ── Selection tools ──────────────────────────────────────────────── + if has_selection { + rows.push(MenuRow::Item(MenuItem::new( + t!("Select Similar").into_owned(), + MenuAction::SelectSimilar, + ))); + rows.push(MenuRow::Item(MenuItem::new( + t!("Invert Selection").into_owned(), + MenuAction::InvertSelection, + ))); + rows.push(MenuRow::Item(MenuItem::new( + t!("Deselect All").into_owned(), + MenuAction::DeselectAll, + ))); + } + if !has_selection { + rows.push(MenuRow::Item(cmd(t!("Select All").into_owned(), "SELECTALL"))); + } + rows.push(MenuRow::Item(MenuItem::new( + t!("Quick Select...").into_owned(), + MenuAction::QuickSelect, + ))); + rows.push(MenuRow::Separator); + + // ── Panels ───────────────────────────────────────────────────────── + rows.push(MenuRow::Item( + MenuItem::new(t!("Properties").into_owned(), MenuAction::Properties).checked(props_open), + )); + rows.push(MenuRow::Item(MenuItem::new( + t!("Options...").into_owned(), + MenuAction::Options, + ))); + rows +} + +fn grip_rows(grip: &GripMenuContext) -> Vec { + let g = |label: String, cmd: GripMenuCmd| MenuItem::new(label, MenuAction::Grip(cmd)); + vec![ + MenuRow::Item(MenuItem::new(t!("Enter").into_owned(), MenuAction::Enter).hint("⏎").default()), + MenuRow::Separator, + MenuRow::Item( + g(t!("Stretch").into_owned(), GripMenuCmd::Stretch) + .checked(matches!(grip.mode, GripEditMode::Stretch)), + ), + MenuRow::Item(g(t!("Move").into_owned(), GripMenuCmd::Move)), + MenuRow::Item(g(t!("Rotate").into_owned(), GripMenuCmd::Rotate)), + MenuRow::Item(g(t!("Scale").into_owned(), GripMenuCmd::Scale)), + MenuRow::Item(g(t!("Mirror").into_owned(), GripMenuCmd::Mirror)), + MenuRow::Separator, + MenuRow::Item(g(t!("Base Point").into_owned(), GripMenuCmd::BasePoint)), + MenuRow::Item(g(t!("Copy").into_owned(), GripMenuCmd::CopyToggle).checked(grip.copy_on)), + MenuRow::Item(g(t!("Undo").into_owned(), GripMenuCmd::Undo).enabled(grip.moved)), + MenuRow::Separator, + MenuRow::Item(g(t!("Exit").into_owned(), GripMenuCmd::Exit).hint("Esc")), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Labels are translated at build time, so expectations go through the + /// same catalog (tests run under whatever UI language the host has). + fn tl(source: &str) -> String { + t!(source).into_owned() + } + + fn labels(menu: &ContextMenu) -> Vec { + let mut out = Vec::new(); + for row in &menu.rows { + match row { + MenuRow::Item(item) => out.push(item.label.clone()), + MenuRow::Separator => out.push("---".into()), + MenuRow::Submenu { label, items, open, .. } => { + out.push(format!("{label} >")); + if *open { + out.extend(items.iter().map(|i| format!(" {}", i.label))); + } + } + } + } + out + } + + fn actions(menu: &ContextMenu) -> Vec { + menu.selectable().into_iter().map(|s| s.action).collect() + } + + fn item_at(menu: &ContextMenu, idx: usize) -> &MenuItem { + match &menu.rows[idx] { + MenuRow::Item(item) => item, + other => panic!("expected item at {idx}, got {other:?}"), + } + } + + fn line_ctx() -> MenuContext { + MenuContext::Command { + options: vec![CmdOption::new("Close", "C"), CmdOption::new("Undo", "U")], + has_point_step: true, + recent_inputs: Vec::new(), + } + } + + #[test] + fn command_menu_lists_options_with_hints() { + let menu = build_context_menu(&line_ctx(), None); + // Enter / Cancel, the keywords, then the recall / snap helpers and + // the navigation rows. + assert_eq!( + labels(&menu), + vec![ + tl("Enter"), + tl("Cancel"), + "---".into(), + tl("Close"), + tl("Undo"), + "---".into(), + format!("{} >", tl("Recent Input")), + format!("{} >", tl("Snap Overrides")), + "---".into(), + tl("Pan"), + tl("Zoom"), + ] + ); + let close = item_at(&menu, 3); + assert_eq!(close.hint.as_deref(), Some("C")); + assert_eq!(close.mnemonic, Some('C')); + assert_eq!(close.action, MenuAction::Option("C".into())); + assert!(item_at(&menu, 0).default); + assert_eq!(menu.default_index(), 0); + assert_eq!(menu.width, MENU_WIDTH_WITH_HINTS); + } + + #[test] + fn enter_option_becomes_default_row() { + let ctx = MenuContext::Command { + options: vec![CmdOption::new("Arc", "A"), CmdOption::enter("Done")], + has_point_step: false, + recent_inputs: Vec::new(), + }; + let menu = build_context_menu(&ctx, None); + // The finish option is the Enter row itself — listed once, as "Enter". + let done = item_at(&menu, 0); + assert_eq!(done.label, tl("Enter")); + assert!(done.default); + assert_eq!(done.action, MenuAction::Enter); + assert_eq!(done.hint.as_deref(), Some("⏎")); + assert_eq!( + actions(&menu)[..4], + [ + MenuAction::Enter, + MenuAction::Cancel, + MenuAction::Option("A".into()), + MenuAction::ToggleSubmenu(SubmenuId::RecentInput), + ] + ); + // A default value shows on the Enter row as its hint (OFFSET ). + let ctx = MenuContext::Command { + options: vec![CmdOption::enter("<0.5000>")], + has_point_step: false, + recent_inputs: Vec::new(), + }; + let menu = build_context_menu(&ctx, None); + assert_eq!(item_at(&menu, 0).hint.as_deref(), Some("<0.5000>")); + } + + #[test] + fn command_menu_without_options_has_only_enter_cancel() { + let ctx = MenuContext::Command { + options: Vec::new(), + has_point_step: false, + recent_inputs: Vec::new(), + }; + let menu = build_context_menu(&ctx, None); + assert_eq!( + actions(&menu), + vec![ + MenuAction::Enter, + MenuAction::Cancel, + MenuAction::ToggleSubmenu(SubmenuId::RecentInput), + MenuAction::Command("'PAN".into()), + MenuAction::Command("'ZOOM DYNAMIC".into()), + ] + ); + // Nothing to recall yet: the Recent Input header is disabled. + assert!(!menu.selectable()[2].enabled); + } + + #[test] + fn recent_input_submenu_lists_tokens_when_open() { + let ctx = MenuContext::Command { + options: Vec::new(), + has_point_step: false, + recent_inputs: vec!["10,20".into(), "C".into()], + }; + let closed = build_context_menu(&ctx, None); + assert_eq!( + actions(&closed)[..3], + [ + MenuAction::Enter, + MenuAction::Cancel, + MenuAction::ToggleSubmenu(SubmenuId::RecentInput) + ] + ); + assert!(closed.selectable()[2].enabled); + let open = build_context_menu(&ctx, Some(SubmenuId::RecentInput)); + assert_eq!( + actions(&open)[..5], + [ + MenuAction::Enter, + MenuAction::Cancel, + MenuAction::ToggleSubmenu(SubmenuId::RecentInput), + MenuAction::FeedInput("10,20".into()), + MenuAction::FeedInput("C".into()), + ] + ); + } + + #[test] + fn snap_overrides_submenu_lists_every_object_snap() { + let ctx = MenuContext::Command { + options: Vec::new(), + has_point_step: true, + recent_inputs: Vec::new(), + }; + let menu = build_context_menu(&ctx, Some(SubmenuId::SnapOverrides)); + let acts = actions(&menu); + assert!(acts.contains(&MenuAction::Mtp)); + for &(t, _, _) in crate::snap::ALL_SNAP_MODES { + assert!(acts.contains(&MenuAction::SnapOverride(t)), "missing {t:?}"); + } + assert!(acts.contains(&MenuAction::SnapOverrideNone)); + assert!(acts.contains(&MenuAction::Command("OSNAP".into()))); + // An entity-pick step offers no snap overrides. + let ctx = MenuContext::Command { + options: Vec::new(), + has_point_step: false, + recent_inputs: Vec::new(), + }; + let menu = build_context_menu(&ctx, None); + assert!(!actions(&menu).contains(&MenuAction::ToggleSubmenu(SubmenuId::SnapOverrides))); + } + + fn idle_ctx(has_selection: bool) -> MenuContext { + MenuContext::Idle { + has_selection, + selected_constraint: None, + isolation_active: false, + recent_cmds: vec!["LINE".into(), "CIRCLE".into()], + clipboard_nonempty: false, + undo_label: Some("LINE".into()), + redo_label: None, + props_open: false, + } + } + + #[test] + fn idle_menu_without_selection_has_repeat_default() { + let menu = build_context_menu(&idle_ctx(false), None); + let first = item_at(&menu, 0); + assert_eq!(first.action, MenuAction::Command("LINE".into())); + assert!(first.default); + assert_eq!(menu.default_index(), 0); + let acts = actions(&menu); + assert!(acts.contains(&MenuAction::Command("PAN".into()))); + assert!(acts.contains(&MenuAction::Options)); + assert!(!acts.contains(&MenuAction::DeleteSelected)); + assert!(!acts.contains(&MenuAction::DeselectAll)); + } + + #[test] + fn selection_menu_lists_modify_actions() { + let menu = build_context_menu(&idle_ctx(true), Some(SubmenuId::DrawOrder)); + let acts = actions(&menu); + for want in [ + MenuAction::DeleteSelected, + MenuAction::Command("MOVE".into()), + MenuAction::Command("COPY".into()), + MenuAction::Command("SCALE".into()), + MenuAction::Command("ROTATE".into()), + MenuAction::Command("MIRROR".into()), + MenuAction::ToggleSubmenu(SubmenuId::DrawOrder), + MenuAction::Command("DRAWORDER F".into()), + MenuAction::DrawOrderPickRef(false), + MenuAction::SelectSimilar, + MenuAction::DeselectAll, + ] { + assert!(acts.contains(&want), "missing {want:?} in {acts:?}"); + } + } + + #[test] + fn clipboard_rows_disabled_when_empty() { + let menu = build_context_menu(&idle_ctx(false), Some(SubmenuId::Clipboard)); + let sel = menu.selectable(); + let paste = sel + .iter() + .find(|s| s.action == MenuAction::Command("PASTECLIP".into())) + .expect("paste row"); + assert!(!paste.enabled); + let cut = sel + .iter() + .find(|s| s.action == MenuAction::Command("CUTCLIP".into())) + .expect("cut row"); + assert!(!cut.enabled); + } + + #[test] + fn undo_row_uses_history_label() { + let menu = build_context_menu(&idle_ctx(false), None); + let rows = labels(&menu); + let undo = t!("Undo %{label}", label = "LINE").into_owned(); + assert!(rows.contains(&undo), "{rows:?}"); + let sel = menu.selectable(); + let redo = sel.iter().find(|s| s.action == MenuAction::Redo).expect("redo row"); + assert!(!redo.enabled); + let undo = sel.iter().find(|s| s.action == MenuAction::Undo).expect("undo row"); + assert!(undo.enabled); + } + + #[test] + fn selectable_skips_separators_and_closed_submenus() { + let menu = build_context_menu(&idle_ctx(false), None); + let sel = menu.selectable(); + assert!(sel.iter().all(|s| s.action != MenuAction::Command("PASTECLIP".into()))); + assert!(sel.iter().any(|s| s.header == Some(SubmenuId::Clipboard))); + let open = build_context_menu(&idle_ctx(false), Some(SubmenuId::Clipboard)); + assert!(open + .selectable() + .iter() + .any(|s| s.action == MenuAction::Command("PASTECLIP".into()))); + } + + #[test] + fn find_mnemonic_cycles_duplicates() { + let ctx = MenuContext::Command { + options: vec![ + CmdOption::new("Angle", "A"), + CmdOption::new("CEnter", "CE"), + CmdOption::new("CLose", "CL"), + ], + has_point_step: false, + recent_inputs: Vec::new(), + }; + let menu = build_context_menu(&ctx, None); + // rows: Enter(0) Cancel(1) Angle(2) CEnter(3) CLose(4) … + assert_eq!(menu.find_mnemonic('c', None), Some(3)); + assert_eq!(menu.find_mnemonic('C', Some(3)), Some(4)); + assert_eq!(menu.find_mnemonic('C', Some(4)), Some(3)); + assert_eq!(menu.find_mnemonic('Z', None), None); + } + + #[test] + fn default_row_y_accounts_for_separators() { + let menu = build_context_menu(&line_ctx(), None); + assert_eq!(menu.default_row_y(), MENU_PAD_TOP); + let menu = build_context_menu(&idle_ctx(false), None); + assert_eq!(menu.default_row_y(), MENU_PAD_TOP); + // 10 items + 3 submenu headers + 6 separators, plus panel padding. + let expected = MENU_PAD_TOP * 2.0 + 13.0 * MENU_ROW_H + 6.0 * MENU_SEP_H; + assert_eq!(menu.height(), expected); + } + + #[test] + fn grip_menu_marks_current_mode() { + let menu = build_context_menu( + &MenuContext::Grip(GripMenuContext { + mode: GripEditMode::Stretch, + copy_on: true, + moved: false, + }), + None, + ); + assert_eq!(item_at(&menu, 0).action, MenuAction::Enter); + assert!(item_at(&menu, 2).checked); + let sel = menu.selectable(); + let stretch = sel + .iter() + .position(|s| s.action == MenuAction::Grip(GripMenuCmd::Stretch)) + .unwrap(); + assert_eq!(stretch, 1); + let undo = sel + .iter() + .find(|s| s.action == MenuAction::Grip(GripMenuCmd::Undo)) + .unwrap(); + assert!(!undo.enabled); + } +} diff --git a/src/ui/popup/mod.rs b/src/ui/popup/mod.rs index e5ff95d10..b541650d7 100644 --- a/src/ui/popup/mod.rs +++ b/src/ui/popup/mod.rs @@ -1,3 +1,4 @@ +pub mod context_menu; pub mod cycle_popup; pub mod isolate_popup; pub mod polar_popup; diff --git a/src/ui/properties.rs b/src/ui/properties.rs index d5b233e06..5f41b1847 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -854,6 +854,7 @@ impl PropertiesPanel { PropValue::EditText(val) | PropValue::PlainText(val) => { self.render_edit_row(label, prop.field, val) } + PropValue::Hyperlink(val) => render_hyperlink_row(label, val), PropValue::ReadOnly(val) if prop.field == "annotative_scale" => { render_annotative_scale_row(label, val) } @@ -1892,6 +1893,18 @@ fn render_annotative_scale_row<'a>( prop_row_widget(label, controls.into()) } +fn render_hyperlink_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> { + let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill); + let manage = button(text("...").size(FONT_SZ)) + .on_press(Message::PropHyperlinkOpen) + .style(button::secondary) + .padding([2, 7]); + let controls = row![field, manage, iced::widget::space().width(10)] + .spacing(2) + .align_y(iced::Center) + .width(Length::Fill); + prop_row_widget(label, controls.into()) +} fn render_ro_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> { // A read-only value is shown as a non-editable but selectable field: no // on_input means the caret never appears, but the text can be selected diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index 52ec2916e..ea546a0cf 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -1,6 +1,7 @@ //! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info pub mod status_menu; +pub(crate) mod spacemouse; pub mod statusbar_config; pub mod statusbar_menu; @@ -135,6 +136,7 @@ impl StatusBar { parametric_conflicts: usize, // What is drawing the scene. Only a degraded verdict shows anything. gpu_status: &'a crate::scene::pipeline::GpuStatus, + spacemouse: Option>, ) -> Element<'a, Message> { let StatusMenuData { layout_names, @@ -241,6 +243,11 @@ impl StatusBar { // when the width can't hold them all on one line. let vis = |p: StatusPill| config.is_visible(p); let mut pills: Vec> = Vec::new(); + if vis(StatusPill::SpaceMouse) { + if let Some(pill) = spacemouse { + pills.push(pill); + } + } // Not a `StatusPill` and not hideable: the scene is on a software // rasterizer or not drawn at all, and the popup that said so has been // dismissed. This stays for the session and reopens it. Many people diff --git a/src/ui/statusbar/spacemouse.rs b/src/ui/statusbar/spacemouse.rs new file mode 100644 index 000000000..b3b78025f --- /dev/null +++ b/src/ui/statusbar/spacemouse.rs @@ -0,0 +1,78 @@ +use super::status_menu::{self, Entry}; +use crate::app::Message; +use crate::input::spacemouse::{NavigationMode, Preferences, Status}; +use iced::widget::{button, row, text}; +use iced::{Element, Fill}; + +pub(crate) fn view( + preferences: Preferences, + status: Status, + paused: bool, + label: String, + sheet: bool, +) -> Element<'static, Message> { + let root = button( + row![ + crate::ui::icons::themed(crate::ui::window::options::spacemouse::ICON, 15.), + text(label).size(11), + crate::ui::icons::themed_arrow_toggle(false, 8.) + ] + .spacing(5) + .align_y(iced::Center), + ) + .padding([3, 6]) + .style(button::subtle); + let mut entries = vec![Entry::stay( + text(format!("SpaceMouse · {}", crate::t!(status.label()))).size(11), + )]; + for mode in NavigationMode::ALL { + let label = format!( + "{} {}", + if mode == preferences.mode { + "●" + } else { + "○" + }, + crate::t!(mode.label()) + ); + entries.push(Entry::close( + button(text(label).size(12)) + .width(Fill) + .padding([7, 10]) + .style(button::text) + .on_press(Message::SpaceMouseMode(mode)), + )); + } + if sheet { + entries.push(Entry::stay( + text(crate::t!("Paper sheets keep rotation locked.")).size(11), + )); + } + entries.push(Entry::close( + button( + text(crate::t!(if paused { + "Resume SpaceMouse" + } else { + "Pause SpaceMouse" + })) + .size(12), + ) + .on_press(Message::SpaceMousePause) + .style(button::text) + .width(Fill) + .padding([7, 10]), + )); + for (label, message) in [ + ("SpaceMouse preferences…", Message::SpaceMousePreferences), + ("3Dconnexion settings…", Message::SpaceMouseDriverSettings), + ] { + entries.push(Entry::close( + button(text(crate::t!(label)).size(12)) + .on_press(message) + .style(button::text) + .width(Fill) + .padding([7, 10]), + )); + } + status_menu::menu_bar(root, entries, 260.) +} diff --git a/src/ui/statusbar/statusbar_config.rs b/src/ui/statusbar/statusbar_config.rs index 84dee3ca8..b0faadbf9 100644 --- a/src/ui/statusbar/statusbar_config.rs +++ b/src/ui/statusbar/statusbar_config.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; /// Identifies a toggleable status-bar pill. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] pub enum StatusPill { + SpaceMouse, Coords, Ortho, Lwt, @@ -37,6 +38,7 @@ impl StatusPill { /// Every pill, in status-bar display order. Drives both the bar layout and /// the customization menu. pub const ALL: &'static [StatusPill] = &[ + StatusPill::SpaceMouse, StatusPill::Coords, StatusPill::Ortho, StatusPill::Lwt, @@ -62,6 +64,7 @@ impl StatusPill { /// Stable identifier used for persistence. pub fn id(self) -> &'static str { match self { + StatusPill::SpaceMouse => "spacemouse", StatusPill::Coords => "coords", StatusPill::Ortho => "ortho", StatusPill::Lwt => "lwt", @@ -88,6 +91,7 @@ impl StatusPill { /// Label shown in the customization menu. pub fn label(self) -> &'static str { match self { + StatusPill::SpaceMouse => "SpaceMouse", StatusPill::Coords => "Coordinates", StatusPill::Ortho => "Ortho Mode", StatusPill::Lwt => "Show Lineweight", diff --git a/src/ui/style/dimstyle.rs b/src/ui/style/dimstyle.rs index aed2e9b74..c1d01ccab 100644 --- a/src/ui/style/dimstyle.rs +++ b/src/ui/style/dimstyle.rs @@ -6,6 +6,7 @@ use iced::widget::{ }; use iced::{mouse, Background, Border, Element, Length, Point, Rectangle, Theme}; use crate::ui::style::common::muted_style; +use crate::ui::style::form::hdivider; use crate::t; use std::borrow::Cow; use std::fmt; @@ -263,19 +264,6 @@ impl canvas::Program for DimensionPreview { } } -fn hdivider<'a>(width: iced::Length) -> Element<'a, Message> { - container(Space::new().width(width).height(1)) - .width(width) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() -} - pub fn view_window<'a>( styles: Vec, selected: &'a str, diff --git a/src/ui/style/form.rs b/src/ui/style/form.rs new file mode 100644 index 000000000..050fa0eb4 --- /dev/null +++ b/src/ui/style/form.rs @@ -0,0 +1,219 @@ +//! Form building blocks shared by the in-canvas dialogs. +//! +//! The Plot dialog grew its own `label : control` rows, field and button +//! styles, and section dividers; every dialog that followed copied them. They +//! live here once so a new dialog reads like the existing ones and a theme +//! tweak lands everywhere. + +use crate::app::Message; +use crate::ui::style::common::muted_style; +use iced::widget::{button, container, pick_list, row, text, text_input, Space}; +use iced::{Background, Border, Element, Length, Theme}; +use std::borrow::Cow; + +/// Width of the label column in a `label : control` row. +pub const LABEL_WIDTH: f32 = 92.0; + +/// Dialog button: accent (primary action) or neutral. +pub fn button_style(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style { + move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = match (accent, status) { + (true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong, + (false, button::Status::Hovered | button::Status::Pressed) => palette.background.strong, + (true, _) => palette.primary.base, + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + shadow: iced::Shadow::default(), + snap: false, + } + } +} + +/// Text field: base background, neutral border that turns primary on focus. +pub fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { + let palette = theme.palette(); + let border = match status { + text_input::Status::Focused { .. } => palette.primary.base.color, + _ => palette.background.neutral.color, + }; + text_input::Style { + background: Background::Color(palette.background.base.color), + border: Border { + color: border, + width: 1.0, + radius: 3.0.into(), + }, + icon: palette.background.base.text, + placeholder: palette.background.base.text.scale_alpha(0.48), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), + } +} + +/// One-pixel horizontal rule. +pub fn hdivider<'a>(width: Length) -> Element<'a, Message> { + container(Space::new().width(width).height(1)) + .width(width) + .height(1) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }) + .into() +} + +/// One-pixel vertical rule. +pub fn vseparator<'a>(height: Length) -> Element<'a, Message> { + container(Space::new().width(1).height(height)) + .width(1) + .height(height) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }) + .into() +} + +/// Alias kept for call sites that historically used `vsep`. +/// Same implementation as [`vseparator`]. +pub fn vsep<'a>(height: Length) -> Element<'a, Message> { + vseparator(height) +} + +/// Muted section heading. +pub fn section_label<'a>(label: Cow<'static, str>) -> Element<'a, Message> { + text(label).size(11).style(muted_style).into() +} + +fn row_label<'a>(label: Cow<'static, str>) -> Element<'a, Message> { + text(label) + .size(11) + .style(muted_style) + .width(LABEL_WIDTH) + .into() +} + +/// A `label : dropdown` row. +pub fn labeled_pick_list<'a, T>( + label: Cow<'static, str>, + options: Vec, + selected: Option, + on_select: impl Fn(T) -> Message + 'a, + width: Length, +) -> Element<'a, Message> +where + T: ToString + Clone + PartialEq + 'a, +{ + let list = pick_list(selected, options, |value| value.to_string()) + .on_select(on_select) + .text_size(12) + .padding([3, 6]) + .width(width); + row![row_label(label), list] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A `label : dropdown` row that renders read-only when `enabled` is false. +pub fn labeled_pick_list_enabled<'a, T>( + label: Cow<'static, str>, + options: Vec, + selected: Option, + on_select: impl Fn(T) -> Message + 'a, + width: Length, + enabled: bool, +) -> Element<'a, Message> +where + T: ToString + Clone + PartialEq + 'a, +{ + if enabled { + return labeled_pick_list(label, options, selected, on_select, width); + } + row![ + row_label(label), + crate::ui::read_only::field( + selected + .map(|choice| choice.to_string()) + .unwrap_or_default() + .as_str(), + 12.0, + width, + ), + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A `label : text field` row. +pub fn labeled_field<'a>( + label: Cow<'static, str>, + value: &'a str, + on_input: impl Fn(String) -> Message + 'a, + width: f32, +) -> Element<'a, Message> { + row![ + row_label(label), + text_input("", value) + .on_input(on_input) + .style(field_style) + .size(12) + .width(width), + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A `label : text field` row that renders read-only when `enabled` is false. +pub fn labeled_field_enabled<'a>( + label: Cow<'static, str>, + value: &'a str, + on_input: impl Fn(String) -> Message + 'a, + width: f32, + enabled: bool, +) -> Element<'a, Message> { + if enabled { + return labeled_field(label, value, on_input, width); + } + row![ + row_label(label), + crate::ui::read_only::field(value, 12.0, Length::Fixed(width)), + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A `label : text field` pair with its own label width, for rows that put +/// several small values side by side. +pub fn labeled_field_compact<'a>( + label: Cow<'static, str>, + value: &'a str, + on_input: impl Fn(String) -> Message + 'a, + label_width: f32, + field_width: f32, +) -> Element<'a, Message> { + row![ + text(label).size(11).style(muted_style).width(label_width), + text_input("", value) + .on_input(on_input) + .style(field_style) + .size(12) + .padding([3, 6]) + .width(field_width), + ] + .spacing(6) + .align_y(iced::Center) + .into() +} diff --git a/src/ui/style/fusion_theme.rs b/src/ui/style/fusion_theme.rs new file mode 100644 index 000000000..ba869c1da --- /dev/null +++ b/src/ui/style/fusion_theme.rs @@ -0,0 +1,144 @@ +//! The Fusion theme pair: black chrome around a white canvas, and its +//! inverse. +//! +//! "Mixed black/white" is about the split, not about draining every colour +//! out of the UI. Ribbon, docks, status bar and command line sit at +//! near-black while model space stays paper-white, so the drawing is the +//! only bright thing on screen. One accent survives that split: selection +//! highlight defaults to the theme primary +//! (`ModelSpaceThemeConfig::selection_highlight_color` = 0), and a white +//! primary would be invisible against a white canvas. The accent is +//! therefore a saturated blue that reads against both halves. +//! +//! These are `iced::Theme::Custom` values, so they are absent from +//! `iced::Theme::ALL`. Anything enumerating themes must go through +//! [`crate::app::config::all_themes`] instead. + +use iced::theme::palette::Seed; +use iced::{Color, Theme}; + +/// Display name of the black-chrome theme. Persisted verbatim in +/// `settings.json` as `theme.name`, so changing it orphans a user's choice. +pub const FUSION_BLACK: &str = "Fusion Black"; + +/// Display name of the light-chrome theme. +pub const FUSION_WHITE: &str = "Fusion White"; + +/// Model-space canvas for [`FUSION_BLACK`] — paper white against the +/// near-black chrome. Deliberately not pure #FFFFFF: a fraction below white +/// keeps the paper-space sheet distinguishable from the model canvas. +pub const FUSION_BLACK_CANVAS: [u8; 3] = [250, 250, 250]; + +/// Model-space canvas for [`FUSION_WHITE`]. +pub const FUSION_WHITE_CANVAS: [u8; 3] = [255, 255, 255]; + +fn rgb(hex: u32) -> Color { + Color::from_rgb8( + ((hex >> 16) & 0xFF) as u8, + ((hex >> 8) & 0xFF) as u8, + (hex & 0xFF) as u8, + ) +} + +/// Near-black chrome, white text, blue accent. +#[must_use] +pub fn fusion_black() -> Theme { + Theme::custom( + FUSION_BLACK, + Seed { + background: rgb(0x1A_1A1A), + text: rgb(0xF2_F2F2), + primary: rgb(0x06_96D7), + success: rgb(0x4C_AF50), + warning: rgb(0xFF_B300), + danger: rgb(0xE5_3935), + }, + ) +} + +/// The inverse: light chrome, near-black text. The accent darkens so it +/// keeps its contrast against a light surface. +#[must_use] +pub fn fusion_white() -> Theme { + Theme::custom( + FUSION_WHITE, + Seed { + background: rgb(0xFA_FAFA), + text: rgb(0x1A_1A1A), + primary: rgb(0x02_77BD), + success: rgb(0x2E_7D32), + warning: rgb(0xE6_5100), + danger: rgb(0xC6_2828), + }, + ) +} + +/// Both Fusion themes, in the order the theme picker should show them. +#[must_use] +pub fn fusion_themes() -> Vec { + vec![fusion_black(), fusion_white()] +} + +/// The canvas colour a Fusion theme asks for, or `None` for any other theme. +/// Keyed by name because `Theme::Custom` carries no discriminant to match on. +#[must_use] +pub fn fusion_canvas(theme: &Theme) -> Option<[u8; 3]> { + match theme.to_string().as_str() { + FUSION_BLACK => Some(FUSION_BLACK_CANVAS), + FUSION_WHITE => Some(FUSION_WHITE_CANVAS), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::style::common::wcag_contrast; + + /// The same bar `theme_accessibility_tests` holds the built-in themes to. + /// These themes are hand-written rather than generated, so nothing else + /// would catch a palette typo that drops text below AA. + #[test] + fn fusion_themes_meet_wcag_aa_on_every_surface() { + for theme in fusion_themes() { + let p = theme.palette(); + for (name, pair) in [ + ("base", p.background.base), + ("weak", p.background.weak), + ("strong", p.background.strong), + ("weakest", p.background.weakest), + ] { + let contrast = wcag_contrast(pair.text, pair.color); + assert!( + contrast >= 4.5, + "{theme} {name} surface text is {contrast:.2}:1, below AA" + ); + } + } + } + + /// The accent exists so selection stays visible on the white canvas. If + /// it ever drifts toward white this catches it. + #[test] + fn fusion_accent_is_visible_on_the_canvas() { + for (theme, canvas) in [ + (fusion_black(), FUSION_BLACK_CANVAS), + (fusion_white(), FUSION_WHITE_CANVAS), + ] { + let canvas = Color::from_rgb8(canvas[0], canvas[1], canvas[2]); + let contrast = wcag_contrast(theme.palette().primary.base.color, canvas); + assert!( + contrast >= 3.0, + "{theme} accent is {contrast:.2}:1 on its canvas, too faint \ + to show a selection" + ); + } + } + + #[test] + fn fusion_canvas_is_keyed_by_name() { + assert_eq!(fusion_canvas(&fusion_black()), Some(FUSION_BLACK_CANVAS)); + assert_eq!(fusion_canvas(&fusion_white()), Some(FUSION_WHITE_CANVAS)); + assert_eq!(fusion_canvas(&Theme::Dark), None); + } +} diff --git a/src/ui/style/mod.rs b/src/ui/style/mod.rs index f8c3fa3c7..f98b8d751 100644 --- a/src/ui/style/mod.rs +++ b/src/ui/style/mod.rs @@ -10,6 +10,8 @@ pub mod scale_manager; pub mod anno_object_scale; pub mod style_manager; pub mod common; +pub mod form; +pub mod fusion_theme; #[cfg(test)] mod common_tests { diff --git a/src/ui/style/plotstyle.rs b/src/ui/style/plotstyle.rs index 22a01c9e6..cf2b93a0b 100644 --- a/src/ui/style/plotstyle.rs +++ b/src/ui/style/plotstyle.rs @@ -4,6 +4,7 @@ use crate::app::Message; use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; use iced::{Background, Border, Element, Theme}; use crate::ui::style::common::muted_style; +use crate::ui::style::form::{hdivider, vsep}; use crate::t; use std::borrow::Cow; use std::fmt; @@ -83,32 +84,6 @@ fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { } } -fn hdivider<'a>(width: iced::Length) -> Element<'a, Message> { - container(Space::new().width(width).height(1)) - .width(width) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() -} - -fn vsep<'a>(height: iced::Length) -> Element<'a, Message> { - container(Space::new().width(1).height(height)) - .width(1) - .height(height) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() -} - pub fn view_window<'a>( document: &'a acadrust::CadDocument, table: Option<&'a crate::io::plot_style::PlotStyleTable>, diff --git a/src/ui/style/style_manager.rs b/src/ui/style/style_manager.rs index 64cc1be9d..7595f48ff 100644 --- a/src/ui/style/style_manager.rs +++ b/src/ui/style/style_manager.rs @@ -327,28 +327,4 @@ pub(crate) fn muted_text_style(theme: &Theme) -> iced::widget::text::Style { crate::ui::style::common::muted_style(theme) } -pub(crate) fn hdivider<'a>(width: iced::Length) -> Element<'a, Message> { - container(Space::new().width(width).height(1)) - .width(width) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() -} - -pub(crate) fn vsep<'a>(height: iced::Length) -> Element<'a, Message> { - container(Space::new().width(1).height(height)) - .width(1) - .height(height) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() -} +pub(crate) use crate::ui::style::form::{hdivider, vsep}; diff --git a/src/ui/theme_accessibility_tests.rs b/src/ui/theme_accessibility_tests.rs index 576d167f5..f8e6caecf 100644 --- a/src/ui/theme_accessibility_tests.rs +++ b/src/ui/theme_accessibility_tests.rs @@ -9,6 +9,8 @@ //! - Properties & Dock Panels (field labels, section headers, muted text) //! - Modals & Action Buttons (dialog body, Primary, Danger, Success buttons) //! - Dropdowns & Selection Overlays (visual style picker, item checkmarks) +//! - Block Palette (card labels in normal/hover/pressed/placing states, +//! header icon buttons, empty-state muted text) use crate::ui::style::common::{accessible_accent, accessible_accent_threshold, wcag_contrast}; use iced::{Color, Theme}; @@ -462,3 +464,174 @@ fn test_viewport_controls_toggle_buttons_contrast() { } } +#[test] +fn test_block_palette_contrast() { + use crate::ui::window::block_palette::{ + block_card_border, block_card_colors, block_icon_button_text_color, + }; + use iced::widget::button::Status as BtnStatus; + + for theme in Theme::ALL { + let p = theme.palette(); + + // ── 1. Card label pairs resolve to the theme's own text colors ── + // Regression guard for the light-theme bug where labels were hardcoded + // to `Color::WHITE` (unreadable white-on-light). If anyone reintroduces + // a hardcoded foreground, these equality checks fail on every theme + // whose surface text is not pure white. + let (normal_bg, normal_fg) = block_card_colors(theme, false, BtnStatus::Active); + assert_eq!( + normal_bg, p.background.base.color, + "Theme {:?} block card normal background drifted from base pair", + theme + ); + assert_eq!( + normal_fg, p.background.base.text, + "Theme {:?} block card normal label must use base text (was hardcoded WHITE)", + theme + ); + + let (_, hover_fg) = block_card_colors(theme, false, BtnStatus::Hovered); + assert_eq!( + hover_fg, p.background.strong.text, + "Theme {:?} block card hover label must use strong text (was hardcoded WHITE)", + theme + ); + + let (_, pressed_fg) = block_card_colors(theme, false, BtnStatus::Pressed); + assert_eq!( + pressed_fg, p.background.strong.text, + "Theme {:?} block card pressed label must use strong text (was hardcoded WHITE)", + theme + ); + + // Default-branch statuses (Active/Disabled/Focused/...) share the base pair. + let (_, disabled_fg) = block_card_colors(theme, false, BtnStatus::Disabled); + assert_eq!( + disabled_fg, p.background.base.text, + "Theme {:?} block card default-state label must use base text", + theme + ); + + // Placing (selected) cards always use the primary pair, regardless of + // hover — the background does not switch on hover while placing. + for status in [BtnStatus::Active, BtnStatus::Hovered, BtnStatus::Pressed] { + let (bg, fg) = block_card_colors(theme, true, status); + assert_eq!(bg, p.primary.base.color, "Theme {:?} placing card bg must be primary.base", theme); + assert_eq!(fg, p.primary.base.text, "Theme {:?} placing card label must be primary.base.text", theme); + } + + // ── 2. Card label contrast: normal / hover / pressed ── + // 11px single-line labels are normal text → WCAG AA >= 4.5:1. + let normal_contrast = wcag_contrast(normal_fg, normal_bg); + assert!( + normal_contrast >= 4.5, + "Theme {:?} block card label ({:.2}:1) fails WCAG AA on card background", + theme, normal_contrast + ); + + let (hover_bg, hover_fg) = block_card_colors(theme, false, BtnStatus::Hovered); + let hover_contrast = wcag_contrast(hover_fg, hover_bg); + assert!( + hover_contrast >= 4.5, + "Theme {:?} block card hovered label ({:.2}:1) fails WCAG AA on hovered background", + theme, hover_contrast + ); + + let (pressed_bg, pressed_fg) = block_card_colors(theme, false, BtnStatus::Pressed); + let pressed_contrast = wcag_contrast(pressed_fg, pressed_bg); + assert!( + pressed_contrast >= 4.5, + "Theme {:?} block card pressed label ({:.2}:1) fails WCAG AA on pressed background", + theme, pressed_contrast + ); + + // ── 3. Placing card contrast ── + // Selected-card label on `primary.base`: UI button component threshold + // (>= 3.0:1 per WCAG 1.4.11, matching the Primary-button test above). + let (placing_bg, placing_fg) = block_card_colors(theme, true, BtnStatus::Active); + let placing_contrast = wcag_contrast(placing_fg, placing_bg); + assert!( + placing_contrast >= 3.0, + "Theme {:?} placing block card label ({:.2}:1) fails button threshold (>=3.0:1)", + theme, placing_contrast + ); + // Placing + hover must not silently drop contrast (bg is sticky). + let (placing_hover_bg, placing_hover_fg) = + block_card_colors(theme, true, BtnStatus::Hovered); + let placing_hover_contrast = wcag_contrast(placing_hover_fg, placing_hover_bg); + assert!( + placing_hover_contrast >= 3.0, + "Theme {:?} placing+hover block card label ({:.2}:1) fails button threshold (>=3.0:1)", + theme, placing_hover_contrast + ); + + // ── 4. Card border is theme-driven (no invisible borders) ── + assert_eq!( + block_card_border(theme, false), + p.background.neutral.color, + "Theme {:?} block card border must use neutral color", + theme + ); + assert_eq!( + block_card_border(theme, true), + p.primary.base.color, + "Theme {:?} placing block card border must use primary color", + theme + ); + + // ── 5. Header icon buttons (glyphs inherit button text_color) ── + // Resting buttons are transparent over the dock (`base`) background. + let icon_rest = block_icon_button_text_color(theme, BtnStatus::Active); + assert_eq!(icon_rest, p.background.base.text); + let icon_rest_contrast = wcag_contrast(icon_rest, p.background.base.color); + assert!( + icon_rest_contrast >= 4.5, + "Theme {:?} block palette icon button ({:.2}:1) fails WCAG AA on dock background", + theme, icon_rest_contrast + ); + // Hovered/pressed buttons sit on the `strong` surface with its text. + for status in [BtnStatus::Hovered, BtnStatus::Pressed] { + let icon_fg = block_icon_button_text_color(theme, status); + assert_eq!( + icon_fg, p.background.strong.text, + "Theme {:?} hovered icon button must use strong text", + theme + ); + let c = wcag_contrast(icon_fg, p.background.strong.color); + assert!( + c >= 4.5, + "Theme {:?} hovered icon button ({:.2}:1) fails WCAG AA on hovered background", + theme, c + ); + } + + // ── 6. Empty-state muted text ("No blocks in this drawing") ── + // Rendered as `base.text` at 0.72 alpha over the dock `base` + // background → secondary text threshold >= 3.0:1. (The old hardcoded + // 0.55-gray failed this on light themes.) + let empty_text = composite_over(p.background.base.text.scale_alpha(0.72), p.background.base.color); + let empty_contrast = wcag_contrast(empty_text, p.background.base.color); + assert!( + empty_contrast >= 3.0, + "Theme {:?} block palette empty-state text ({:.2}:1) fails secondary text threshold (>=3.0:1)", + theme, empty_contrast + ); + + // ── 7. Hardcoded-white regression probe ── + // The exact reported bug: pure white labels on the light card + // background. Every theme's card background must NOT be near-white + // with white text — i.e. white-on-card must fail while the real + // theme pair passes (proves the fix matters on light themes and the + // test would catch a WHITE hardcode). + let white_on_normal = wcag_contrast(Color::WHITE, normal_bg); + if white_on_normal < 4.5 { + assert!( + normal_contrast >= 4.5, + "Theme {:?} needs theme-aware labels: white-on-card is {:.2}:1 but theme pair is {:.2}:1", + theme, white_on_normal, normal_contrast + ); + } + } +} + diff --git a/src/ui/window/about.rs b/src/ui/window/about.rs index 388f0e295..a9e4fc809 100644 --- a/src/ui/window/about.rs +++ b/src/ui/window/about.rs @@ -1,14 +1,9 @@ use crate::app::Message; use crate::t; +use crate::ui::style::common::muted_style; use iced::widget::{button, column, container, row, svg, text, Space}; use iced::{Background, Border, Element, Fill, Length, Shrink, Theme}; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn primary_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().primary.base.color), diff --git a/src/ui/window/alias_editor.rs b/src/ui/window/alias_editor.rs index b3da9f8aa..66835b7a7 100644 --- a/src/ui/window/alias_editor.rs +++ b/src/ui/window/alias_editor.rs @@ -1,10 +1,13 @@ //! Command-alias editor — an in-canvas modal (Plan B) for adding, remapping and //! removing command-line aliases (the `ocad.pgp` table). Opened by ALIASEDIT. //! Rows are `(alias, command)`; edits are buffered in `alias_editor_rows` and -//! committed to the alias table when the dialog closes. Mirrors the editable-row -//! pattern of the attribute editor and plugin manager. +//! committed to the alias table on Apply. Mirrors the shortcut editor: +//! transient draft row with explicit accept, duplicate/unknown validation, +//! reset-to-defaults with confirmation, apply-and-exit, alias count, and an +//! unsaved-changes guard on close. use crate::app::Message; +use crate::ui::style::common::muted_style; use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; use iced::{Background, Element, Length, Theme}; use crate::t; @@ -19,20 +22,54 @@ pub enum AliasField { /// Right-hand lane reserved for the scrollbar so it never overlaps the ✕ column. const GUTTER: f32 = 16.0; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { +fn danger_text_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), + color: Some(theme.palette().danger.base.color), + } +} + +/// Text-input variant flagging an invalid value: danger border and value +/// color on the theme's danger weak background. +fn danger_input_style( + theme: &Theme, + status: iced::widget::text_input::Status, +) -> iced::widget::text_input::Style { + let danger = theme.palette().danger.base; + iced::widget::text_input::Style { + background: Background::Color(theme.palette().danger.weak.color), + border: iced::border::rounded(4) + .color(danger.color) + .width( + if matches!( + status, + iced::widget::text_input::Status::Focused { is_hovered: _ } + ) { + 1.5 + } else { + 1.0 + }, + ), + icon: danger.color, + placeholder: danger.color.scale_alpha(0.7), + value: danger.text, + selection: danger.color, } } /// Build the alias editor content. `rows` is the live working buffer. -pub fn view_window( - rows: &[(String, String)], +pub fn view_window<'a>( + rows: &'a [(String, String)], + pending_add: bool, + reset_confirm: bool, + duplicate_aliases: &rustc_hash::FxHashSet, + duplicate_conflicts: &[(String, String)], + unknown_commands: &rustc_hash::FxHashSet, + close_confirm: bool, sizing: crate::ui::modal::ModalSizing, -) -> Element<'_, Message> { +) -> Element<'a, Message> { let title = text(t!("Command Aliases")).size(15); let hint = text(t!( - "Type an alias and the command it runs (e.g. L → LINE). Apply to save to ocad.pgp; closing discards unapplied edits." + "Click + Add alias, type the alias and command, accept the row, then Apply. Esc cancels the pending row." )) .size(11) .style(muted_style); @@ -46,7 +83,7 @@ pub fn view_window( row![ container(text(t!("Alias")).size(11).style(muted_style)).width(Length::Fixed(120.0)), container(text(t!("Command")).size(11).style(muted_style)).width(sizing.width), - Space::new().width(Length::Fixed(30.0)), + Space::new().width(Length::Fixed(62.0)), ] .spacing(8), ) @@ -54,39 +91,167 @@ pub fn view_window( let mut list = column![].spacing(3); for (idx, (alias, cmd)) in rows.iter().enumerate() { + let alias_key = alias.trim().to_uppercase(); + let duplicate = !alias_key.is_empty() && duplicate_aliases.contains(alias_key.as_str()); let alias_box = text_input(t!("alias").as_ref(), alias) .on_input(move |v| Message::AliasEditorInput { idx, field: AliasField::Alias, value: v }) .size(13) .padding([3, 6]) .width(Length::Fixed(120.0)); + let alias_box = if duplicate { + alias_box.style(danger_input_style) + } else { + alias_box + }; + let unknown_command = + !cmd.trim().is_empty() && unknown_commands.contains(cmd.trim()); let cmd_box = text_input(t!("command").as_ref(), cmd) .on_input(move |v| Message::AliasEditorInput { idx, field: AliasField::Command, value: v }) .size(13) .padding([3, 6]) .width(sizing.width); - let del = button(crate::ui::icons::themed_danger_text(crate::ui::icons::CLOSE, 12.0)) + let cmd_box = if unknown_command { + cmd_box.style(danger_input_style) + } else { + cmd_box + }; + // Draft rows get a check (finish the addition, without applying) and + // a cancel ✕; committed rows get the trash bin. + let remove = if pending_add && idx == 0 { + let done_ok = !alias.trim().is_empty() + && !cmd.trim().is_empty() + && !duplicate + && !unknown_commands.contains(cmd.trim()); + row![ + button(crate::ui::icons::themed_success_text( + crate::ui::icons::CHECK, + 12.0, + )) + .on_press_maybe(done_ok.then_some(Message::AliasEditorDraftAccept)) + .padding([2, 6]) + .style(button::success), + button(crate::ui::icons::themed_danger_text( + crate::ui::icons::CLOSE, + 12.0, + )) + .on_press(Message::AliasEditorDraftCancel) + .padding([2, 6]) + .style(button::danger), + ] + .spacing(4) + .align_y(iced::Center) + } else { + row![button(crate::ui::icons::themed_danger_text( + crate::ui::icons::TRASH, + 12.0, + )) .on_press(Message::AliasEditorRemove(idx)) .padding([2, 6]) - .style(button::danger); + .style(button::danger)] + .align_y(iced::Center) + }; list = list.push( - row![alias_box, cmd_box, del] + row![alias_box, cmd_box, remove] .spacing(8) .align_y(iced::Center), ); } - let add = button(text(t!("+ Add alias")).size(12)) - .on_press(Message::AliasEditorAdd) - .padding([4, 10]) - .style(button::secondary); - + // While a draft row is pending, the add button doubles as the visible + // cancel affordance (Esc works too). + let add = if pending_add { + button(text(t!("Cancel add (Esc)")).size(12)) + .on_press(Message::AliasEditorDraftCancel) + .padding([4, 10]) + .style(button::danger) + } else { + button(text(t!("+ Add alias")).size(12)) + .on_press(Message::AliasEditorAdd) + .padding([4, 10]) + .style(button::secondary) + }; + // The reset confirmation replaces the alias count so the warning + // gets the whole left side of the bar. + let stats = if reset_confirm { + row![Space::new().width(Length::Fixed(0.0))] + } else { + row![text(format!("{}: {}", t!("Number of aliases"), rows.len())) + .size(12) + .style(muted_style)] + }; + // Reset asks for confirmation in place: the add/reset buttons are + // replaced by the question with Yes / No. + let (reset_area, add_area) = if reset_confirm { + ( + row![ + text(t!( + "Are you sure you want to reset? You will lose all your current aliases!" + )) + .size(12) + .style(danger_text_style), + button(text(t!("Yes, reset")).size(12)) + .on_press(Message::AliasEditorResetConfirm) + .padding([4, 10]) + .style(button::danger), + button(text(t!("No")).size(12)) + .on_press(Message::AliasEditorResetDeny) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(8) + .align_y(iced::Center), + row![Space::new().width(Length::Fixed(0.0))], + ) + } else { + ( + row![button(text(t!("Reset to default")).size(12)) + .on_press(Message::AliasEditorResetAsk) + .padding([4, 10]) + .style(button::secondary)] + .spacing(8) + .align_y(iced::Center), + row![add], + ) + }; // Apply — primary action; commits the rows to ocad.pgp and stays open. let apply = button(text(t!("Apply")).size(12)) .on_press(Message::AliasEditorApply) .padding([4, 16]) .style(button::primary); + let apply_exit = button(text(t!("Apply && Exit")).size(12)) + .on_press(Message::AliasEditorApplyExit) + .padding([4, 16]) + .style(button::primary); + // The reset confirmation takes over the whole action bar. + let apply_area = if reset_confirm { + row![Space::new().width(Length::Fixed(0.0))] + } else { + row![apply, apply_exit].spacing(8).align_y(iced::Center) + }; - container( + // Persistent validation warnings: shown until every conflicting alias is + // resolved (edited or a row removed) and every command is runnable. + let mut conflict_banner = column![].spacing(2); + for (alias, command) in duplicate_conflicts { + conflict_banner = conflict_banner.push( + text(crate::tf!( + "Alias already used for command: {} → {}", + alias, + command + )) + .size(12) + .style(danger_text_style), + ); + } + for command in unknown_commands { + conflict_banner = conflict_banner.push( + text(crate::tf!("Unknown command: {}", command)) + .size(12) + .style(danger_text_style), + ); + } + + let content = container( column![ title, hint, @@ -94,7 +259,11 @@ pub fn view_window( head, scrollable(container(list).padding(gutter)).height(sizing.height), Space::new().height(6), - row![add, Space::new().width(sizing.width), apply].align_y(iced::Center), + conflict_banner, + Space::new().height(4), + row![stats, add_area, reset_area, Space::new().width(sizing.width), apply_area] + .spacing(8) + .align_y(iced::Center), ] .spacing(6) .width(sizing.width) @@ -108,6 +277,66 @@ pub fn view_window( theme.palette().background.base.color, )), ..Default::default() - }) - .into() + }); + + // Unsaved-changes guard: closing with un-applied rows stacks a dimmed + // shield and a confirmation panel on top of the editor. + if !close_confirm { + return content.into(); + } + let shield = iced::widget::mouse_area( + container(Space::new()) + .width(Length::Fill) + .height(Length::Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme + .palette() + .background + .strongest + .color + .scale_alpha(0.55), + )), + ..Default::default() + }), + ) + .on_press(Message::AliasEditorCloseKeep) + .interaction(iced::mouse::Interaction::Idle); + let panel = container( + column![ + text(t!("Unsaved changes will be discarded.")).size(14), + Space::new().height(10), + row![ + button(text(t!("Discard && close")).size(12)) + .on_press(Message::AliasEditorCloseDiscard) + .padding([4, 12]) + .style(button::danger), + button(text(t!("Keep editing")).size(12)) + .on_press(Message::AliasEditorCloseKeep) + .padding([4, 12]) + .style(button::secondary), + ] + .spacing(8), + ] + .spacing(4), + ) + .padding(16) + .width(Length::Fixed(320.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.base.color, + )), + border: iced::Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }); + let centered = container(panel) + .width(Length::Fill) + .height(Length::Fill) + .align_x(iced::alignment::Horizontal::Center) + .align_y(iced::alignment::Vertical::Center); + iced::widget::stack![content, iced::widget::opaque(shield), centered].into() } diff --git a/src/ui/window/attribute_editor.rs b/src/ui/window/attribute_editor.rs index 65dc18914..0128ce1b2 100644 --- a/src/ui/window/attribute_editor.rs +++ b/src/ui/window/attribute_editor.rs @@ -25,6 +25,8 @@ use std::borrow::Cow; use std::fmt; use crate::ui::properties::{lw_options, LwItem}; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::hdivider; const LABEL_W: f32 = 120.0; @@ -139,12 +141,6 @@ pub fn color_from_label(label: &str) -> Option { }) } -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { let palette = theme.palette(); let border = match status { @@ -161,20 +157,6 @@ fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { } } -/// Horizontal 1px divider in the shared border colour (matches style windows). -fn hdivider<'a>(width: Length) -> Element<'a, Message> { - container(Space::new().width(width).height(1)) - .width(width) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() -} - /// One `label : widget` row with a fixed-width label column. fn field_row<'a>( label: Cow<'static, str>, diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs index bd8e7f351..f82ed09c8 100644 --- a/src/ui/window/block_palette.rs +++ b/src/ui/window/block_palette.rs @@ -264,7 +264,20 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< } else { "No matches" }; - container(text(crate::t!(msg)).size(12).color(iced::Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 })) + container( + text(crate::t!(msg)) + .size(12) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some( + theme + .palette() + .background + .base + .text + .scale_alpha(0.72), + ), + }), + ) .center_x(Fill) .center_y(Fill) .width(Fill) @@ -307,6 +320,56 @@ pub fn view(palette: &BlockPalette, width: f32, auto_collapse: bool) -> Element< .into() } +/// Theme-aware foreground/background pair for a block card. +/// +/// Centralizes the card's text/background mapping so the contrast suite can +/// exercise the exact colors the UI renders (instead of re-implementing the +/// match). Normal cards use the `base` pair, hovered/pressed cards use the +/// `strong` pair, and the actively-placing card uses the `primary.base` pair. +pub(crate) fn block_card_colors( + theme: &Theme, + is_placing: bool, + status: button::Status, +) -> (Color, Color) { + let palette = theme.palette(); + if is_placing { + (palette.primary.base.color, palette.primary.base.text) + } else { + match status { + button::Status::Hovered | button::Status::Pressed => { + (palette.background.strong.color, palette.background.strong.text) + } + _ => ( + palette.background.base.color, + palette.background.base.text, + ), + } + } +} + +/// Border color for a block card in a given state (exposed for contrast tests). +pub(crate) fn block_card_border(theme: &Theme, is_placing: bool) -> Color { + let palette = theme.palette(); + if is_placing { + palette.primary.base.color + } else { + palette.background.neutral.color + } +} + +/// Theme-aware foreground for the palette's header icon buttons. +/// +/// Transparent resting state composites over the dock (`base`) background; +/// hovered/pressed state sits on the `strong` surface, so each state pairs +/// with its own background's text color to preserve contrast. +pub(crate) fn block_icon_button_text_color(theme: &Theme, status: button::Status) -> Color { + let palette = theme.palette(); + match status { + button::Status::Hovered | button::Status::Pressed => palette.background.strong.text, + _ => palette.background.base.text, + } +} + fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<'a, Message> { let is_placing = palette.placing.as_deref() == Some(block.name.as_str()); let preview = canvas(BlockPreviewCanvas { wires: &block.wires }) @@ -315,9 +378,13 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<' // Fixed height guarantees the label occupies exactly one line, so every // cell in a row keeps the same height even for long names (the elide budget // handles overflow, and this clips anything that still won't fit). + // + // NOTE: no explicit `.color(...)` here — the label inherits the button's + // `text_color` so normal/hover/placing states each get a theme-aware + // foreground with sufficient contrast on both light and dark themes. + // (A hardcoded `Color::WHITE` is unreadable on light themes.) let label = text(crate::ui::text_util::elide(&block.name, MAX_LABEL_CHARS)) .size(11) - .color(Color::WHITE) .width(Fill) .height(Length::Fixed(LABEL_LINE_H)) .center(); @@ -326,18 +393,8 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<' .on_press(Message::BlockPalette(BlockPaletteMsg::Insert(block.name.clone()))) .width(Fill) .style(move |theme: &Theme, status| { - let palette = theme.palette(); - let neutral = palette.background.neutral.color; - let base = palette.background.base.color; - let strong = palette.background.strong.color; - let (bg, border) = if is_placing { - (palette.primary.base.color, palette.primary.base.color) - } else { - match status { - button::Status::Hovered | button::Status::Pressed => (strong, neutral), - _ => (base, neutral), - } - }; + let (bg, fg) = block_card_colors(theme, is_placing, status); + let border = block_card_border(theme, is_placing); button::Style { background: Some(Background::Color(bg)), border: Border { @@ -345,7 +402,7 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<' width: 1.0, radius: 3.0.into(), }, - text_color: Color::WHITE, + text_color: fg, ..Default::default() } }) @@ -354,7 +411,9 @@ fn block_card<'a>(palette: &'a BlockPalette, block: &'a BlockEntry) -> Element<' fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message> { let icon_el: Element<'_, Message> = match icon { - IconKind::Glyph(s) => text(s).size(15).color(Color::WHITE).into(), + // No explicit color — inherits the button's theme-aware `text_color` + // so glyphs stay legible on light themes. + IconKind::Glyph(s) => text(s).size(15).into(), IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, TOOL_H), }; button(icon_el) @@ -372,7 +431,7 @@ fn icon_button<'a>(icon: IconKind, msg: BlockPaletteMsg) -> Element<'a, Message> radius: 3.0.into(), ..Default::default() }, - text_color: Color::WHITE, + text_color: block_icon_button_text_color(theme, status), ..Default::default() }) .into() diff --git a/src/ui/window/browser.rs b/src/ui/window/browser.rs new file mode 100644 index 000000000..c14dd4d54 --- /dev/null +++ b/src/ui/window/browser.rs @@ -0,0 +1,205 @@ +//! The Browser: a standing outline of what the drawing is built from. +//! +//! Three sections, each read straight from the document rather than from a +//! parallel model the panel would have to keep in sync: +//! +//! * **Origin planes** — the three world planes, as somewhere to start a +//! sketch when the drawing is still empty and there is no face to pick. +//! * **Sketch** — the open sketch, if there is one. +//! * **Bodies** — every `Solid3D`, labelled with the operation that made it, +//! recovered from the DWG solid history. +//! +//! What this panel deliberately is *not* is a parametric feature tree. The +//! history records how each solid was built, but nothing re-runs it, so +//! editing an earlier feature cannot rebuild the ones after it. Showing the +//! list as if it were editable history would promise exactly that. It is a +//! table of contents, and it is labelled as one. + +use crate::app::Message; +use crate::ui::dock::{DockMsg, PanelId}; +use acadrust::objects::SolidHistoryOperation; +use acadrust::{CadDocument, EntityType, Handle}; +use iced::widget::{button, column, container, mouse_area, row, scrollable, text, tooltip}; +use iced::{Background, Border, Element, Fill, Length, Theme}; + +/// The three world planes, with the argument `CREATESKETCH` takes for each. +const ORIGIN_PLANES: [(&str, &str); 3] = [("XY", "XY"), ("XZ", "XZ"), ("YZ", "YZ")]; + +/// Human name for the operation that produced a solid. `None` when the +/// drawing carries no history for it — a solid imported as plain geometry, +/// or one whose history was stripped on save. +fn operation_label(operation: &SolidHistoryOperation) -> &'static str { + match operation { + SolidHistoryOperation::Box(_) => "Box", + SolidHistoryOperation::Wedge(_) => "Wedge", + SolidHistoryOperation::Sphere(_) => "Sphere", + SolidHistoryOperation::Cone(_) => "Cone", + SolidHistoryOperation::Cylinder(_) => "Cylinder", + SolidHistoryOperation::Torus(_) => "Torus", + SolidHistoryOperation::Pyramid(_) => "Pyramid", + SolidHistoryOperation::Sweep(_) => "Sweep", + SolidHistoryOperation::Loft(_) => "Loft", + SolidHistoryOperation::Extrusion(_) => "Extrude", + SolidHistoryOperation::Revolve(_) => "Revolve", + SolidHistoryOperation::Fillet(_) => "Fillet", + SolidHistoryOperation::Chamfer(_) => "Chamfer", + _ => "Solid", + } +} + +/// Every `Solid3D` in model space, with the label to show for it. +fn bodies(document: &CadDocument) -> Vec<(Handle, String)> { + document + .entities() + .filter(|entity| matches!(entity, EntityType::Solid3D(_))) + .map(|entity| { + let handle = entity.common().handle; + let label = crate::scene::model::solid_history::primitive_property_operation( + document, handle, + ) + .as_ref() + .map_or("Solid", operation_label); + (handle, format!("{label} {:X}", handle.value())) + }) + .collect() +} + +fn section_header(label: String) -> Element<'static, Message> { + container(text(label).size(11)) + .width(Fill) + .padding([3, 6]) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + }) + .into() +} + +/// A clickable row that runs `command`. +fn entry_row(label: String, command: String) -> Element<'static, Message> { + button(text(label).size(12)) + .on_press(Message::Command(command)) + .width(Fill) + .padding([3, 10]) + .style(button::subtle) + .into() +} + +/// A row that states something rather than doing something. +fn note_row(label: String) -> Element<'static, Message> { + container(text(label).size(11).style(crate::ui::style::common::muted_style)) + .width(Fill) + .padding([3, 10]) + .into() +} + +pub fn view<'a>( + document: &'a CadDocument, + open_sketch: Option<&'a str>, + width: f32, + auto_collapse: bool, +) -> Element<'a, Message> { + // ── Dock chrome (title, pin, close) — matches the other panels ──────── + let pin_icon = if auto_collapse { + crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) + } else { + crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) + }; + let pin = button(pin_icon) + .on_press(Message::Dock(DockMsg::AutoCollapseToggle(PanelId::Browser))) + .style(move |theme: &Theme, status| { + let mut style = button::subtle(theme, status); + if auto_collapse { + let palette = theme.palette(); + style.background = Some(Background::Color(palette.primary.weak.color)); + style.text_color = palette.primary.weak.text; + style.border.color = palette.primary.base.color; + style.border.width = 1.0; + } + style + }) + .padding([3, 5]); + let pin = tooltip(pin, text(crate::t!("Auto")).size(10), tooltip::Position::Bottom).gap(4); + + let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0)) + .on_press(Message::Dock(DockMsg::Close(PanelId::Browser))) + .style(button::subtle) + .padding([3, 5]); + let close = tooltip(close, text(crate::t!("Close")).size(10), tooltip::Position::Bottom).gap(4); + + let title_bar = mouse_area( + container( + row![ + text(crate::t!("Browser")).size(12), + iced::widget::Space::new().width(Fill), + pin, + close, + ] + .spacing(3) + .align_y(iced::Center), + ) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + }) + .width(Fill) + .padding([3, 6]), + ) + .on_press(Message::Dock(DockMsg::DockGrab(PanelId::Browser))) + .interaction(iced::mouse::Interaction::Grab); + + let mut tree = column![].spacing(1); + + tree = tree.push(section_header(crate::t!("Origin planes").into_owned())); + for (label, argument) in ORIGIN_PLANES { + tree = tree.push(entry_row( + label.to_string(), + format!("CREATESKETCH {argument}"), + )); + } + + tree = tree.push(section_header(crate::t!("Sketch").into_owned())); + match open_sketch { + Some(name) => { + tree = tree.push(entry_row(name.to_string(), "FINISHSKETCH".to_string())); + } + None => { + tree = tree.push(note_row(crate::t!("No sketch open").into_owned())); + } + } + + let bodies = bodies(document); + tree = tree.push(section_header(crate::tf!("Bodies ({})", bodies.len()).into_owned())); + if bodies.is_empty() { + tree = tree.push(note_row(crate::t!("No solids yet").into_owned())); + } else { + for (handle, label) in bodies { + // Selecting by handle is what SELECT already takes, so clicking a + // row reuses the ordinary selection path. + tree = tree.push(entry_row(label, format!("SELECT {:X}", handle.value()))); + } + } + + let body = scrollable(container(tree).padding(iced::Padding { + top: 0.0, + right: 8.0, + bottom: 6.0, + left: 0.0, + })) + .width(Fill) + .height(Fill); + + container(column![title_bar, body].spacing(6).padding(6)) + .width(Length::Fixed(width)) + .height(Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.base.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .into() +} diff --git a/src/ui/window/drafting_settings.rs b/src/ui/window/drafting_settings.rs index 2ea71b51c..91d32051c 100644 --- a/src/ui/window/drafting_settings.rs +++ b/src/ui/window/drafting_settings.rs @@ -1,8 +1,8 @@ use crate::app::settings::IsoPlane; use crate::app::Message; -use crate::snap::{SnapType, ALL_SNAP_MODES}; -use iced::widget::{button, checkbox, column, container, row, scrollable, text, Space}; -use iced::{Background, Border, Element, Fill, Length, Theme}; +use crate::snap::{SnapType, ALL_3D_SNAP_MODES, ALL_SNAP_MODES}; +use iced::widget::{button, checkbox, column, container, row, scrollable, text, text_input, Space}; +use iced::{Background, Border, Element, Fill, Theme}; use std::borrow::Cow; /// Active tab in the Drafting Settings dialog. @@ -28,6 +28,14 @@ pub struct DraftingSettingsState { // Snap and Grid pub snap_on: bool, pub grid_on: bool, + pub snap_x_input: String, + pub snap_y_input: String, + pub snap_equal: bool, + pub grid_x_input: String, + pub grid_y_input: String, + pub grid_major_input: String, + pub grid_adaptive: bool, + pub grid_beyond_limits: bool, pub isometric: bool, pub iso_plane: IsoPlane, pub snap_angle_deg: f32, @@ -41,6 +49,7 @@ pub struct DraftingSettingsState { pub snap_modes: rustc_hash::FxHashSet, // 3D Object Snap pub osnap3d_on: bool, + pub snap3d_modes: rustc_hash::FxHashSet, // Dynamic Input pub dyn_input_on: bool, // Quick Properties @@ -55,6 +64,14 @@ impl DraftingSettingsState { pub fn is_dirty(&self, saved: &Self) -> bool { self.snap_on != saved.snap_on || self.grid_on != saved.grid_on + || self.snap_x_input != saved.snap_x_input + || self.snap_y_input != saved.snap_y_input + || self.snap_equal != saved.snap_equal + || self.grid_x_input != saved.grid_x_input + || self.grid_y_input != saved.grid_y_input + || self.grid_major_input != saved.grid_major_input + || self.grid_adaptive != saved.grid_adaptive + || self.grid_beyond_limits != saved.grid_beyond_limits || self.isometric != saved.isometric || self.iso_plane != saved.iso_plane || (self.snap_angle_deg - saved.snap_angle_deg).abs() > 0.001 @@ -65,12 +82,38 @@ impl DraftingSettingsState { || self.otrack_on != saved.otrack_on || self.snap_modes != saved.snap_modes || self.osnap3d_on != saved.osnap3d_on + || self.snap3d_modes != saved.snap3d_modes || self.dyn_input_on != saved.dyn_input_on || self.quick_props_on != saved.quick_props_on || self.selection_cycling_on != saved.selection_cycling_on } } +/// Format a snap spacing value for the dialog's text inputs. +pub fn format_snap_spacing(v: f32) -> String { + if (v - v.round()).abs() < 1e-4 { + format!("{}", v.round() as i32) + } else { + format!("{v}") + } +} + +/// Parse a snap spacing input. Spacings must be positive and finite. +pub fn parse_snap_spacing(s: &str) -> Option { + let v: f32 = s.trim().parse().ok()?; + if v.is_finite() && v > 0.0 && v <= 1e9 { + Some(v) + } else { + None + } +} + +/// Parse a major-line interval. Must be an integer in 2..=100. +pub fn parse_grid_major(s: &str) -> Option { + let v: u32 = s.trim().parse().ok()?; + (2..=100).contains(&v).then_some(v) +} + /// Helper for grouped sub-panels with a light border and header title. fn group<'a>(title: impl Into, body: impl Into>) -> Element<'a, Message> { container( @@ -111,19 +154,44 @@ pub fn view_window<'a>( }; // ── Tab Bar ────────────────────────────────────────────────────────── + // Real tab strip: a single non-wrapping row of tab-shaped buttons with a + // divider underneath, matching the style-manager editor shell. The row + // never wraps — it clips on overflow so "Selection Cycling" can't spill + // onto a second row. let tab_button = |tab: DraftingSettingsTab, label: Cow<'a, str>| { let is_active = state.active_tab == tab; - button(text(label).size(11.5)) + button(text(label).size(11)) .on_press(Message::DraftingSettingsTabChanged(tab)) - .style(if is_active { - button::primary - } else { - button::secondary + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = match (is_active, status) { + (true, _) => palette.primary.strong, + ( + false, + button::Status::Hovered | button::Status::Pressed, + ) => palette.background.strong, + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: iced::border::Radius { + top_left: 4.0, + top_right: 4.0, + bottom_right: 0.0, + bottom_left: 0.0, + }, + }, + ..Default::default() + } }) - .padding([5, 9]) + .padding([4, 8]) }; - let tab_bar = row![ + let tab_row = row![ tab_button(DraftingSettingsTab::SnapAndGrid, crate::t!("Snap and Grid")), tab_button(DraftingSettingsTab::PolarTracking, crate::t!("Polar Tracking")), tab_button(DraftingSettingsTab::ObjectSnap, crate::t!("Object Snap")), @@ -132,8 +200,21 @@ pub fn view_window<'a>( tab_button(DraftingSettingsTab::QuickProperties, crate::t!("Quick Properties")), tab_button(DraftingSettingsTab::SelectionCycling, crate::t!("Selection Cycling")), ] - .spacing(4) - .wrap(); + .spacing(2) + .width(Fill) + .clip(true); + + let tab_divider = container(Space::new().width(Fill).height(1)) + .width(Fill) + .height(1) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.neutral.color, + )), + ..Default::default() + }); + + let tab_bar = column![tab_row, tab_divider].spacing(0).width(Fill); // ── Tab 1: Snap and Grid ───────────────────────────────────────────── let snap_and_grid_view = { @@ -149,18 +230,28 @@ pub fn view_window<'a>( column![ row![ text(crate::t!("Snap X spacing:")).size(11).width(120), - text("10.00").size(11), + text_input("10", &state.snap_x_input) + .on_input(Message::DraftingSettingsSnapXChanged) + .size(11) + .padding([4, 7]) + .width(100), ] .spacing(8) .align_y(iced::Center), row![ text(crate::t!("Snap Y spacing:")).size(11).width(120), - text("10.00").size(11), + text_input("10", &state.snap_y_input) + .on_input(Message::DraftingSettingsSnapYChanged) + .size(11) + .padding([4, 7]) + .width(100), ] .spacing(8) .align_y(iced::Center), row![ - checkbox(true).size(14), + checkbox(state.snap_equal) + .on_toggle(|_| Message::DraftingSettingsToggleEqualSnap) + .size(14), text(crate::t!("Equal X and Y spacing")).size(11), ] .spacing(6) @@ -223,19 +314,31 @@ pub fn view_window<'a>( column![ row![ text(crate::t!("Grid X spacing:")).size(11).width(120), - text("10.00").size(11), + text_input("10", &state.grid_x_input) + .on_input(Message::DraftingSettingsGridXChanged) + .size(11) + .padding([4, 7]) + .width(100), ] .spacing(8) .align_y(iced::Center), row![ text(crate::t!("Grid Y spacing:")).size(11).width(120), - text("10.00").size(11), + text_input("10", &state.grid_y_input) + .on_input(Message::DraftingSettingsGridYChanged) + .size(11) + .padding([4, 7]) + .width(100), ] .spacing(8) .align_y(iced::Center), row![ text(crate::t!("Major line every:")).size(11).width(120), - text("5").size(11), + text_input("5", &state.grid_major_input) + .on_input(Message::DraftingSettingsGridMajorChanged) + .size(11) + .padding([4, 7]) + .width(100), ] .spacing(8) .align_y(iced::Center), @@ -247,13 +350,17 @@ pub fn view_window<'a>( crate::t!("Grid behavior"), column![ row![ - checkbox(true).size(14), + checkbox(state.grid_adaptive) + .on_toggle(|_| Message::DraftingSettingsToggleAdaptiveGrid) + .size(14), text(crate::t!("Adaptive grid")).size(11), ] .spacing(6) .align_y(iced::Center), row![ - checkbox(true).size(14), + checkbox(state.grid_beyond_limits) + .on_toggle(|_| Message::DraftingSettingsToggleBeyondLimits) + .size(14), text(crate::t!("Display grid beyond Limits")).size(11), ] .spacing(6) @@ -398,44 +505,26 @@ pub fn view_window<'a>( Message::DraftingSettingsToggle3dOsnap, ); - let placeholder_group = group( - crate::t!("3D Object Snap modes"), - column![ - row![checkbox(false).size(14), text(crate::t!("Vertex")).size(11)] - .spacing(7) - .align_y(iced::Center), + // Implemented 3D modes follow the same checkbox pattern as the + // Object Snap tab, driven by ALL_3D_SNAP_MODES. + let mut modes_col = column![].spacing(6); + for &(snap_type, _, label) in ALL_3D_SNAP_MODES { + let is_checked = state.snap3d_modes.contains(&snap_type); + modes_col = modes_col.push( row![ - checkbox(false).size(14), - text(crate::t!("Midpoint on edge")).size(11), + checkbox(is_checked) + .on_toggle(move |_| Message::DraftingSettingsToggleSnapMode3d(snap_type)) + .size(14), + text(crate::t!(label)).size(11), ] .spacing(7) .align_y(iced::Center), - row![ - checkbox(false).size(14), - text(crate::t!("Center of face")).size(11), - ] - .spacing(7) - .align_y(iced::Center), - row![checkbox(false).size(14), text(crate::t!("Knot")).size(11)] - .spacing(7) - .align_y(iced::Center), - row![ - checkbox(false).size(14), - text(crate::t!("Perpendicular to face")).size(11), - ] - .spacing(7) - .align_y(iced::Center), - row![ - checkbox(false).size(14), - text(crate::t!("Nearest to face")).size(11), - ] - .spacing(7) - .align_y(iced::Center), - ] - .spacing(6), - ); + ); + } - column![osnap3d_toggle, Space::new().height(4), placeholder_group] + let modes_group = group(crate::t!("3D Object Snap modes"), modes_col); + + column![osnap3d_toggle, Space::new().height(4), modes_group] .spacing(10) .width(Fill) }; @@ -628,51 +717,10 @@ pub fn view_window<'a>( if !close_confirm { return main_content.into(); } - - let shield = iced::widget::mouse_area( - container(Space::new()) - .width(Length::Fill) - .height(Length::Fill) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme - .palette() - .background - .strongest - .color - .scale_alpha(0.55), - )), - ..Default::default() - }), - ) - .on_press(Message::DraftingSettingsCloseKeep); - - let warning_panel = container( - column![ - text(crate::t!("Unsaved changes will be discarded.")).size(13.5), - Space::new().height(12), - row![ - button(text(crate::t!("Discard && close")).size(12)) - .on_press(Message::DraftingSettingsCloseDiscard) - .padding([5, 14]) - .style(button::danger), - button(text(crate::t!("Keep editing")).size(12)) - .on_press(Message::DraftingSettingsCloseKeep) - .padding([5, 14]) - .style(button::secondary), - ] - .spacing(10), - ] - .spacing(0), - ) - .padding([18, 22]) - .style(container::rounded_box); - - iced::widget::stack![ + crate::ui::modal::discard_guard( main_content, - shield, - container(warning_panel).center_x(Fill).center_y(Fill) - ] - .into() + Message::DraftingSettingsCloseDiscard, + Message::DraftingSettingsCloseKeep, + ) } diff --git a/src/ui/window/layers.rs b/src/ui/window/layers.rs index f25b08eac..bf2ee1d9f 100644 --- a/src/ui/window/layers.rs +++ b/src/ui/window/layers.rs @@ -2,6 +2,7 @@ use crate::app::Message; use crate::ui::properties::{lw_options, LinetypeItem, LwItem}; +use crate::ui::style::common::muted_style; use crate::ui::ROW_H; use acadrust::tables::layer::Layer as DocLayer; use acadrust::tables::Table; @@ -51,12 +52,6 @@ const LINETYPE_MENU_W: f32 = 220.0; /// scrolled into view after it is added (#271). pub const LAYER_TABLE_SCROLL_ID: &str = "layer-manager-table-scroll"; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn table_input_style( theme: &Theme, status: iced::widget::text_input::Status, diff --git a/src/ui/window/layout_manager.rs b/src/ui/window/layout_manager.rs index 4891a2c0f..9d0132cec 100644 --- a/src/ui/window/layout_manager.rs +++ b/src/ui/window/layout_manager.rs @@ -1,16 +1,12 @@ //! Layout Manager window — fills the entire OS window. use crate::app::Message; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::{hdivider, vsep}; use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; use iced::{Background, Element, Theme}; use crate::t; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn primary_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().primary.base.color), @@ -37,32 +33,6 @@ fn list_item(active: bool) -> impl Fn(&Theme, button::Status) -> button::Style { } } -fn hdivider<'a>(width: iced::Length) -> Element<'a, Message> { - container(Space::new().width(width).height(1)) - .width(width) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color, - )), - ..Default::default() - }) - .into() -} - -fn vsep<'a>(height: iced::Length) -> Element<'a, Message> { - container(Space::new().width(1).height(height)) - .width(1) - .height(height) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color, - )), - ..Default::default() - }) - .into() -} - pub fn view_window<'a>( layouts: Vec, selected: &'a str, diff --git a/src/ui/window/missing_fonts.rs b/src/ui/window/missing_fonts.rs new file mode 100644 index 000000000..cdc002d15 --- /dev/null +++ b/src/ui/window/missing_fonts.rs @@ -0,0 +1,84 @@ +// Missing-fonts prompt — shown after a drawing opens with `.shx` fonts this +// machine doesn't have. Offers to fetch them from the community repository +// (see `fonts/README.md` in the repo root) instead of rendering with a +// mismatched substitute. + +use crate::app::Message; +use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; +use iced::{Element, Fill, Length, Shrink, Theme}; + +fn muted_style(theme: &Theme) -> text::Style { + text::Style { + color: Some(theme.palette().background.weak.text), + } +} + +/// The prompt: the missing-font list, a download action, and a skip. +pub fn view_window<'a>( + fonts: &'a [String], + font_source: &'a str, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let content_width = if matches!(sizing.width, Length::Fill) { + Fill + } else { + Shrink + }; + let content_height = if matches!(sizing.height, Length::Fill) { + Fill + } else { + Shrink + }; + let listed: Vec> = fonts + .iter() + .map(|name| text(format!(" {name}")).size(12).into()) + .collect(); + let actions = row![ + Space::new().width(content_width), + button(text(crate::t!("Skip")).size(12)) + .on_press(Message::MissingFontsDismiss) + .style(button::secondary) + .padding([6, 14]), + button(text(crate::t!("Download")).size(12)) + .on_press(Message::MissingFontsDownload) + .style(button::primary) + .padding([6, 14]), + ] + .spacing(8); + + container( + column![ + text(crate::t!("Missing fonts")).size(20), + text(crate::t!( + "This drawing uses fonts that are not installed on this machine. Download them from the OpenCADStudio community font repository?" + )) + .size(11) + .style(muted_style), + container(scrollable(column(listed).spacing(2)).height(content_height)) + .padding([10, 12]) + .width(content_width) + .height(content_height) + .style(container::bordered_box), + column![ + text(crate::t!("Font source — leave empty for the community repository, or point at any folder (e.g. your company's font server):")) + .size(10) + .style(muted_style), + text_input( + crate::t!("https://server/fonts or a GitHub raw folder").as_ref(), + font_source, + ) + .on_input(Message::MissingFontsSourceChanged) + .size(11) + .padding([4, 8]) + .width(content_width), + ] + .spacing(3), + actions, + ] + .spacing(10) + .width(sizing.width) + .height(sizing.height), + ) + .padding([12, 16]) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 09b3b2314..85c39ac3c 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -1,5 +1,6 @@ pub mod about; pub mod block_palette; +pub mod browser; pub mod layout_manager; pub mod layer_state_manager; pub mod drawing_units; @@ -14,6 +15,7 @@ pub mod shortcuts; pub mod layers; pub mod update_notice; pub mod open_progress; +pub mod missing_fonts; pub mod recovery; pub mod options; pub mod attribute_editor; @@ -21,3 +23,5 @@ pub mod annotation_data; pub mod alias_editor; pub mod find_replace; pub mod named_parameters; +pub mod xref_help; +pub mod xref_manager; diff --git a/src/ui/window/named_parameters.rs b/src/ui/window/named_parameters.rs index 88afb3bf8..9148cac98 100644 --- a/src/ui/window/named_parameters.rs +++ b/src/ui/window/named_parameters.rs @@ -17,6 +17,7 @@ use crate::app::Message; use crate::scene::named_parameters::ParameterTable; use crate::scene::Scene; use crate::t; +use crate::ui::style::common::muted_style; use acadrust::types::Handle; use acadrust::EntityType; use iced::widget::tooltip::Position as TipPos; @@ -113,12 +114,6 @@ pub struct ParamEditorRow { /// column — same convention and value as `alias_editor::GUTTER`. const GUTTER: f32 = 16.0; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn danger_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().danger.base.color), diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 3c7f168c3..5e7cbdd9f 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -1,5 +1,6 @@ +pub(crate) mod spacemouse; use crate::app::config::UiThemeConfig; -use crate::app::settings::CursorType; +use crate::app::settings::{CursorType, RightClickMode}; use crate::app::Message; use iced::widget::{ button, column, container, row, scrollable, slider, text, text_input, Space, @@ -72,6 +73,12 @@ pub struct AppPrefs { pub show_ucs_icon: bool, /// UCSICON ORigin: draw it at the origin rather than the corner. pub ucs_icon_at_origin: bool, + /// SHORTCUTMENU: what a right-click in the drawing area does. + pub right_click_mode: RightClickMode, + /// SHORTCUTMENUDURATION: time-sensitive hold threshold, ms. + pub right_click_hold_ms: i32, + /// Open the Plot / Page Setup dialog for every new layout. + pub page_setup_on_new_layout: bool, } /// The fixed locations the Files page lists. @@ -141,6 +148,43 @@ impl fmt::Display for Labelled { } } +/// A background swatch that opens the colour wheel when clicked. +/// +/// The wheel sits alongside the hex field rather than replacing it: typing +/// `#1E1E1E` stays the fastest way to reproduce an exact colour, while the +/// wheel is for choosing one by eye. +fn bg_swatch_picker<'a>( + target: crate::app::BgTarget, + rgb: [u8; 3], + open: Option, +) -> Element<'a, Message> { + let colour = iced::Color::from_rgb8(rgb[0], rgb[1], rgb[2]); + let swatch = container(Space::new()) + .width(28) + .height(22) + .style(move |theme: &Theme| container::Style { + background: Some(Background::Color(colour)), + border: Border { + color: theme.palette().background.strong.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }); + let underlay = button(swatch) + .on_press(Message::BgPickerOpen(target)) + .padding(0) + .style(button::text); + iced_aw::ColorPicker::new( + open == Some(target), + colour, + underlay, + Message::BgPickerCancel, + Message::BgPickerSubmit, + ) + .into() +} + #[allow(clippy::too_many_arguments)] pub fn view_window<'a>( default_save_format: &'a str, @@ -153,6 +197,7 @@ pub fn view_window<'a>( cursor_size: i32, selection: SelectionPrefs, prefs: AppPrefs, + spacemouse: Element<'a, Message>, snap_angle_input: &'a str, drawing_prefs: DrawingPrefs, folders: Folders, @@ -166,6 +211,9 @@ pub fn view_window<'a>( model_bg_input: &'a str, paper_bg_input: &'a str, desk_bg_input: &'a str, + bg_picker: Option, + dirty: bool, + close_confirm: bool, sizing: crate::ui::modal::ModalSizing, ) -> Element<'a, Message> { let selected_format = crate::io::SAVE_FORMAT_OPTIONS @@ -173,7 +221,7 @@ pub fn view_window<'a>( .copied() .find(|candidate| *candidate == default_save_format); - let theme_options = Theme::ALL + let theme_options = crate::app::config::all_themes() .iter() .map(ToString::to_string) .chain(std::iter::once("Custom".to_string())) @@ -216,6 +264,18 @@ pub fn view_window<'a>( .find(|choice| choice.value == cursor_type) .cloned(); + let right_click_options = RightClickMode::ALL + .into_iter() + .map(|value: RightClickMode| Labelled { + label: crate::t!(value.label()).into_owned(), + value, + }) + .collect::>(); + let selected_right_click = right_click_options + .iter() + .find(|choice| choice.value == prefs.right_click_mode) + .cloned(); + let palette = ui_theme.palette.to_iced(); let colors = [ (crate::tr!("options", "color-background"), palette.background), @@ -253,8 +313,18 @@ pub fn view_window<'a>( ); } + // Changes show at once but are committed by OK / Apply; Close puts them + // back (asking first when there is something to lose). + let ok = button(text(crate::t!("OK")).size(12)) + .on_press(Message::OptionsOk) + .padding([6, 18]) + .style(button::primary); + let apply = button(text(crate::t!("Apply")).size(12)) + .on_press_maybe(dirty.then_some(Message::OptionsApply)) + .padding([6, 18]) + .style(if dirty { button::secondary } else { button::text }); let close = button(text(crate::tr!("action", "close")).size(12)) - .on_press(Message::CloseModal) + .on_press(Message::OptionsClose) .padding([6, 18]) .style(button::secondary); @@ -305,6 +375,29 @@ pub fn view_window<'a>( ] .spacing(10) .align_y(iced::Center), + Space::new().height(22), + text(crate::t!("Plotting")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Plot device, paper, scale and plot styles")) + .size(12) + .width(Fill), + button(text(crate::t!("Plot and Page Setup…")).size(11)) + .on_press(Message::PlotDialogOpen) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(10), + row![ + iced::widget::checkbox(prefs.page_setup_on_new_layout) + .on_toggle(Message::PageSetupOnNewLayoutChanged) + .size(15), + text(crate::t!("Show the page setup for new layouts")).size(12), + ] + .spacing(8) + .align_y(iced::Center), ] .spacing(0) .width(sizing.width); @@ -400,20 +493,6 @@ pub fn view_window<'a>( ] .spacing(8) .align_y(iced::Center), - Space::new().height(22), - text(crate::t!("Plotting")).size(15), - Space::new().height(10), - row![ - text(crate::t!("Plot device, paper, scale and plot styles")) - .size(12) - .width(Fill), - button(text(crate::t!("Plot and Page Setup…")).size(11)) - .on_press(Message::PlotDialogOpen) - .padding([4, 10]) - .style(button::secondary), - ] - .spacing(10) - .align_y(iced::Center), ] .spacing(0) .width(sizing.width); @@ -437,58 +516,10 @@ pub fn view_window<'a>( }); let model_bg_rgb = model_space.custom_bg.unwrap_or(crate::app::config::CLASSIC_CAD_DARK_BG); - let model_bg_swatch = container(Space::new()) - .width(28) - .height(22) - .style(move |theme: &Theme| container::Style { - background: Some(Background::Color(iced::Color::from_rgb8( - model_bg_rgb[0], - model_bg_rgb[1], - model_bg_rgb[2], - ))), - border: Border { - color: theme.palette().background.strong.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }); let paper_bg_rgb = model_space.custom_paper_bg.unwrap_or(crate::app::config::DEFAULT_PAPER_BG); - let paper_bg_swatch = container(Space::new()) - .width(28) - .height(22) - .style(move |theme: &Theme| container::Style { - background: Some(Background::Color(iced::Color::from_rgb8( - paper_bg_rgb[0], - paper_bg_rgb[1], - paper_bg_rgb[2], - ))), - border: Border { - color: theme.palette().background.strong.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }); let desk_bg_rgb = model_space.custom_desk_bg.unwrap_or(crate::app::config::DEFAULT_DESK_BG); - let desk_bg_swatch = container(Space::new()) - .width(28) - .height(22) - .style(move |theme: &Theme| container::Style { - background: Some(Background::Color(iced::Color::from_rgb8( - desk_bg_rgb[0], - desk_bg_rgb[1], - desk_bg_rgb[2], - ))), - border: Border { - color: theme.palette().background.strong.color, - width: 1.0, - radius: 3.0.into(), - }, - ..Default::default() - }); let mode_options = crate::app::config::ModelSpaceMode::ALL .into_iter() @@ -623,7 +654,7 @@ pub fn view_window<'a>( display = display.push(Space::new().height(10)).push( row![ text(crate::t!("Model background")).size(12).width(140), - model_bg_swatch, + bg_swatch_picker(crate::app::BgTarget::Model, model_bg_rgb, bg_picker), text_input("#RRGGBB", model_bg_input) .on_input(Message::ModelSpaceBgChanged) .width(150), @@ -636,7 +667,7 @@ pub fn view_window<'a>( display = display.push(Space::new().height(10)).push( row![ text(crate::t!("Paper background")).size(12).width(140), - paper_bg_swatch, + bg_swatch_picker(crate::app::BgTarget::Paper, paper_bg_rgb, bg_picker), text_input("#RRGGBB", paper_bg_input) .on_input(Message::PaperSpaceBgChanged) .width(150), @@ -648,7 +679,7 @@ pub fn view_window<'a>( display = display.push(Space::new().height(10)).push( row![ text(crate::t!("Desk surround")).size(12).width(140), - desk_bg_swatch, + bg_swatch_picker(crate::app::BgTarget::Desk, desk_bg_rgb, bg_picker), text_input("#RRGGBB", desk_bg_input) .on_input(Message::DeskSpaceBgChanged) .width(150), @@ -1135,6 +1166,8 @@ pub fn view_window<'a>( let user_prefs = column![ text(crate::t!("User Preferences")).size(15), Space::new().height(10), + spacemouse, + Space::new().height(12), text(crate::t!("Zoom")).size(15), Space::new().height(10), row![ @@ -1217,6 +1250,43 @@ pub fn view_window<'a>( .size(11) .width(sizing.width), Space::new().height(24), + text(crate::t!("Right-click Customization")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Right-click in drawing area")).size(12).width(150), + iced::widget::pick_list( + selected_right_click, + right_click_options, + |choice| choice.label.clone(), + ) + .on_select(|choice| Message::RightClickModeChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(12), + row![ + text(crate::t!("Hold duration")).size(12).width(150), + slider( + 100..=1000, + prefs.right_click_hold_ms.clamp(100, 1000), + Message::RightClickHoldMsChanged + ) + .step(50) + .width(Fill), + text(format!("{} ms", prefs.right_click_hold_ms.clamp(100, 1000))) + .size(11) + .width(52), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "Shortcut menu: right-click always opens the menu. Time-sensitive: a quick click is Enter, holding longer opens the menu (SHORTCUTMENUDURATION)." + )) + .size(11) + .width(sizing.width), + Space::new().height(24), text(crate::t!("Block Edit")).size(15), Space::new().height(10), row![ @@ -1610,7 +1680,7 @@ pub fn view_window<'a>( // controls at the trailing edge of the Options content. scrollable(content).spacing(8).height(Fill), Space::new().height(12), - row![Space::new().width(Fill), close], + row![Space::new().width(Fill), ok, apply, close].spacing(8), ] .width(Fill) .height(sizing.height); @@ -1620,10 +1690,13 @@ pub fn view_window<'a>( .height(sizing.height); let intrinsic = sizing.width == crate::ui::modal::ModalSizing::INTRINSIC.width; - container(body) + let main = container(body) .style(container::rounded_box) .padding([16, 18]) .width(if intrinsic { iced::Length::Fixed(DIALOG_WIDTH) } else { sizing.width }) - .height(if intrinsic { iced::Length::Fixed(DIALOG_HEIGHT) } else { sizing.height }) - .into() + .height(if intrinsic { iced::Length::Fixed(DIALOG_HEIGHT) } else { sizing.height }); + if !close_confirm { + return main.into(); + } + crate::ui::modal::discard_guard(main, Message::OptionsCloseDiscard, Message::OptionsCloseKeep) } diff --git a/src/ui/window/options/spacemouse.rs b/src/ui/window/options/spacemouse.rs new file mode 100644 index 000000000..dea7fca9d --- /dev/null +++ b/src/ui/window/options/spacemouse.rs @@ -0,0 +1,135 @@ +use crate::app::Message; +use crate::input::spacemouse::{NavigationMode, Preferences, Status}; +use iced::widget::{button, checkbox, column, container, pick_list, row, slider, text, Space}; +use iced::{Element, Fill}; + +pub(crate) const ICON: &[u8] = include_bytes!("../../../../assets/icons/ui/spacemouse.svg"); + +pub(crate) fn view( + preferences: Preferences, + status: Status, + paused: bool, + details: bool, +) -> Element<'static, Message> { + let supported = cfg!(windows); + let mut enabled = checkbox(preferences.enabled).size(15); + if supported { + enabled = enabled.on_toggle(Message::SpaceMouseEnabled); + } + let mode = pick_list(Some(preferences.mode), NavigationMode::ALL, |mode| { + crate::t!(mode.label()).into_owned() + }) + .width(220); + let mode = if supported { + mode.on_select(Message::SpaceMouseMode) + } else { + mode + }; + let status_text = if !preferences.enabled { + crate::t!("Disabled").into_owned() + } else if paused { + crate::t!("Paused").into_owned() + } else { + crate::t!(status.label()).into_owned() + }; + let mut content = column![ + row![ + crate::ui::icons::themed(ICON, 22.), + text(crate::t!("SpaceMouse")).size(15) + ] + .spacing(8) + .align_y(iced::Center), + row![enabled, text(crate::t!("Enable SpaceMouse")).size(12)] + .spacing(8) + .align_y(iced::Center), + row![ + text(crate::t!("Status")).size(12).width(150), + text(status_text).size(12) + ] + .spacing(10), + row![text(crate::t!("Navigation")).size(12).width(150), mode] + .spacing(10) + .align_y(iced::Center), + text(crate::t!(preferences.mode.description()).into_owned()) + .size(11) + .width(Fill), + ] + .spacing(12); + if preferences.mode != NavigationMode::Full3D && supported { + content = content.push( + row![ + checkbox(preferences.pan_reversed) + .size(15) + .on_toggle(Message::SpaceMousePanReversed), + text(crate::t!("Reverse pan direction")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + ); + content = content.push( + row![ + text(crate::t!("Pan speed")).size(12).width(150), + slider( + 10..=300, + preferences.pan_speed.clamp(10, 300), + Message::SpaceMousePanSpeed + ) + .step(10) + .width(160), + text(format!("{}%", preferences.pan_speed.clamp(10, 300))).size(11), + ] + .spacing(10) + .align_y(iced::Center), + ); + } + if paused { + content = content.push( + button(text(crate::t!("Resume SpaceMouse")).size(11)) + .on_press(Message::SpaceMousePause) + .style(button::secondary), + ); + } + content = content.push( + button( + text(crate::t!(if supported { + "Open 3Dconnexion settings…" + } else { + "SpaceMouse information…" + })) + .size(11), + ) + .on_press(Message::SpaceMouseDriverSettings) + .style(button::secondary) + .padding([5, 10]), + ); + content = content.push( + text(crate::t!( + "Configure 3D navigation, buttons, and radial menus in 3Dconnexion settings. Pan only and Pan and zoom share the push/tilt mapping and pan settings shown here." + )) + .size(11) + .width(Fill), + ); + content = content.push( + button( + row![ + crate::ui::icons::themed_arrow_toggle(details, 9.), + text(crate::t!("Connection details")).size(11) + ] + .spacing(6), + ) + .on_press(Message::SpaceMouseDetails) + .style(button::text), + ); + if details { + let detail = match status { + Status::Unavailable(reason) => reason, + Status::Unsupported => "This version supports SpaceMouse through 3DxWare on Windows. Other platforms do not yet have a device adapter.".into(), + Status::Disconnected => "3DxWare is running. Connect a SpaceMouse; Open CAD Studio reconnects automatically.".into(), + _ => "Uses the installed 3DxWare driver. Navigation pauses while a dialog is open. Your selected navigation mode is saved across drawings and sessions.".into(), + }; + content = content.push(text(crate::t!(&detail).into_owned()).size(11).width(Fill)); + } + container(content.push(Space::new().height(8))) + .width(Fill) + .into() +} diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index f4d744990..05f1fdaf6 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -5,7 +5,11 @@ //! PDF. Styled to match the other OCS dialogs (dark pills + fields). use crate::app::Message; -use crate::io::paper_sizes::PaperSize; +use crate::io::paper_catalog::{self, CustomPaper, Margins, PaperSize, PaperUnits}; +use crate::io::plot_device::PrinterCapabilities; +use crate::ui::style::common::muted_style; +use crate::ui::style::form; +use crate::ui::style::form::{hdivider, vseparator as vsep}; use iced::widget::{ button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, Space, @@ -28,25 +32,39 @@ pub const STYLE_NONE: &str = ""; /// A plot dropdown value keeps its persisted/raw value separate from the /// localized label shown by Iced. Printer names, scale names, paper sizes, and /// style-table file names remain verbatim; built-in choices use the catalog. +/// A paper choice keeps the canonical media name as its value and shows the +/// sheet's human name with its dimensions. #[derive(Clone, Debug, Eq, PartialEq)] struct PlotChoice { raw: String, localized: bool, + /// Display text when it differs from `raw` (paper sizes). + display: Option, } impl PlotChoice { fn raw(value: impl Into) -> Self { - Self { raw: value.into(), localized: false } + Self { raw: value.into(), localized: false, display: None } } fn localized(value: impl Into) -> Self { - Self { raw: value.into(), localized: true } + Self { raw: value.into(), localized: true, display: None } + } + + fn paper(paper: &PaperSize) -> Self { + Self { + raw: paper.canonical.to_string(), + localized: false, + display: Some(paper.display()), + } } } impl fmt::Display for PlotChoice { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.localized { + if let Some(display) = &self.display { + formatter.write_str(display) + } else if self.localized { if let Some(name) = self.raw.strip_prefix("View: ") { formatter.write_str(crate::tf!("View: {name}").as_ref()) } else { @@ -60,7 +78,45 @@ impl fmt::Display for PlotChoice { #[cfg(test)] mod tests { - use super::PlotChoice; + use super::{CustomPaperDraft, CustomPaperError, PlotChoice}; + use crate::io::paper_catalog::{resolve, Margins, PaperUnits}; + + #[test] + fn custom_sheet_draft_validates_and_builds() { + let mut draft = CustomPaperDraft { + name: " Roll 24 ".into(), + width: "1500".into(), + height: "609,6".into(), + inches: false, + margins: ["5".into(), "17".into(), "".into(), "abc".into()], + error: None, + }; + let custom = draft.build().expect("valid sheet"); + assert_eq!(custom.name, "Roll 24"); + assert_eq!((custom.width, custom.height), (1500.0, 609.6)); + assert_eq!(custom.units, PaperUnits::Millimeters); + // Blank or unparsable margins mean no margin, never a refusal. + assert_eq!(custom.margins, Margins { left: 5.0, bottom: 17.0, right: 0.0, top: 0.0 }); + assert_eq!(custom.canonical(), "Roll_24_(609.60_x_1500.00_MM)"); + draft.width = "0".into(); + assert_eq!(draft.build().unwrap_err(), CustomPaperError::Size); + draft.width = "x".into(); + assert_eq!(draft.build().unwrap_err(), CustomPaperError::Size); + } + + #[test] + fn custom_sheet_draft_seeds_from_a_sheet_in_its_own_unit() { + let arch_d = resolve("ARCH_D_(24.00_x_36.00_Inches)").unwrap(); + let draft = CustomPaperDraft::from_paper(&arch_d, Margins::uniform(6.35)); + assert_eq!(draft.name, "ARCH D"); + assert_eq!((draft.width.as_str(), draft.height.as_str()), ("24", "36")); + assert!(draft.inches); + assert_eq!(draft.margins, ["0.25", "0.25", "0.25", "0.25"]); + let a4 = resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap(); + let draft = CustomPaperDraft::from_paper(&a4, Margins { left: 5.0, bottom: 17.0, right: 6.0, top: 18.0 }); + assert!(!draft.inches); + assert_eq!(draft.margins, ["5", "17", "6", "18"]); + } #[test] fn named_view_display_preserves_the_saved_plot_area() { @@ -88,6 +144,48 @@ pub enum PlotFlag { Transparency, PaperspaceLast, Stamp, + /// Hidden-line removal for paper-space objects (the page setup's + /// "plot hidden" flag). + HidePaperspace, + /// Write the dialog into the layout when a plot is sent. + SaveToLayout, +} + +/// The page setups of another drawing, offered for import (`PSETUPIN`). +#[derive(Debug, Clone, PartialEq)] +pub struct PageSetupImportDraft { + /// The drawing they come from, as shown to the user. + pub file: String, + /// Each setup with whether it is ticked for import. The settings are + /// carried along so the source file is read once. + pub setups: Vec<(String, acadrust::objects::PlotSettings, bool)>, + /// Why the file gave nothing, when it did not. + pub error: Option, +} + +impl PageSetupImportDraft { + pub fn selected(&self) -> impl Iterator { + self.setups + .iter() + .filter(|(_, _, on)| *on) + .map(|(name, ps, _)| (name.as_str(), ps)) + } +} + +/// Edits of the page-setup import chooser. +#[derive(Debug, Clone)] +pub enum PageSetupImportMsg { + /// Ask for the drawing to import from. + Pick, + /// The drawing was read (or could not be): its name and page setups. + Loaded(Result<(String, Vec<(String, acadrust::objects::PlotSettings)>), String>), + /// Tick or untick one setup. + Toggle(String), + /// Tick or untick every setup. + All(bool), + /// Import the ticked setups and close the chooser. + Apply, + Cancel, } /// Every edit the Plot dialog can emit. Wrapped in `Message::PlotDlg` so the @@ -98,11 +196,35 @@ pub enum PlotDlgMsg { Commit, Preview, PrinterProperties, + /// The driver options of a printer arrived (or failed to), for the + /// in-line properties editor; carries the printer's name so a late + /// answer for a printer the user has since left is ignored. + PrinterOptionsLoaded(String, Result, String>), + /// One option of the properties editor was set to a choice keyword. + PrinterOptionSet(String, String), + /// Keep the editor's choices for the printer and close it. + PrinterOptionsApply, + /// Put every option back to the driver's default. + PrinterOptionsReset, + /// Close the editor without keeping its changes. + PrinterOptionsCancel, Printer(String), + /// A printer answered (or failed) the media query started when it was + /// selected; carries the printer's name so a stale answer for a printer + /// the user has since left is ignored. + PrinterMedia(String, Option>), Paper(String), + /// Edits of the inline "custom paper size" editor under the Size row. + CustomPaper(CustomPaperMsg), Orientation(String), Area(String), Scale(String), + /// The unit the page setup counts in (offsets, custom scale). + PaperUnits(String), + /// The paper side of the custom scale, in the page-setup unit. + CustomScalePaper(String), + /// The drawing side of the custom scale. + CustomScaleDrawing(String), Quality(String), Shade(String), Copies(String), @@ -124,6 +246,8 @@ pub enum PlotDlgMsg { CopySetup, /// Begin an inline rename of the given page setup row. RenameStart(String), + /// Import named page setups from another drawing (`PSETUPIN`). + Import(PageSetupImportMsg), /// Delete the selected named page setup. DeleteSetup, /// Live edit of the new/rename name field. @@ -134,6 +258,151 @@ pub enum PlotDlgMsg { NameCancel, } +/// Which side of the printable-margin row a value belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MarginSide { + Left, + Bottom, + Right, + Top, +} + +#[derive(Debug, Clone)] +pub enum CustomPaperMsg { + /// Open the editor, seeded from the selected sheet. + Open, + Cancel, + Name(String), + Width(String), + Height(String), + /// Units picked from the dropdown, by their catalogue label. + Units(String), + Margin(MarginSide, String), + /// Add (or replace) the sheet and select it. + Add, + /// Remove the selected user-defined sheet. + Remove, +} + +/// The inline custom-sheet editor's fields, kept as typed text so a half-typed +/// number does not snap to a value while editing. Everything is in the +/// draft's own units. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct CustomPaperDraft { + pub name: String, + pub width: String, + pub height: String, + pub inches: bool, + /// Left, bottom, right, top. + pub margins: [String; 4], + /// Why the last Add was refused, shown under the fields. + pub error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CustomPaperError { + /// Width or height is not a positive number. + Size, +} + +impl CustomPaperDraft { + fn units(&self) -> PaperUnits { + if self.inches { + PaperUnits::Inches + } else { + PaperUnits::Millimeters + } + } + + /// Seed the editor from a sheet: its dimensions and unit, and the margins + /// given (in millimetres) converted into that unit. + pub fn from_paper(paper: &PaperSize, margins_mm: Margins) -> Self { + let units = paper.units; + let fmt = |v: f64| { + let v = units.from_mm(v); + if (v - v.round()).abs() < 1e-6 { + format!("{}", v.round() as i64) + } else { + format!("{v:.2}") + } + }; + let dim = |v: f64| { + if (v - v.round()).abs() < 1e-6 { + format!("{}", v.round() as i64) + } else { + format!("{v:.2}") + } + }; + Self { + name: paper.label.to_string(), + width: dim(paper.width), + height: dim(paper.height), + inches: units == PaperUnits::Inches, + margins: [ + fmt(margins_mm.left), + fmt(margins_mm.bottom), + fmt(margins_mm.right), + fmt(margins_mm.top), + ], + error: None, + } + } + + /// The sheet the fields describe, or the reason they do not describe one. + pub fn build(&self) -> Result { + let number = |text: &str| text.trim().replace(',', ".").parse::().ok(); + let (Some(width), Some(height)) = (number(&self.width), number(&self.height)) else { + return Err(CustomPaperError::Size); + }; + if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { + return Err(CustomPaperError::Size); + } + let margin = |text: &str| number(text).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0); + Ok(CustomPaper { + name: self.name.trim().to_string(), + width, + height, + units: self.units(), + margins: Margins { + left: margin(&self.margins[0]), + bottom: margin(&self.margins[1]), + right: margin(&self.margins[2]), + top: margin(&self.margins[3]), + }, + }) + } +} + +/// The in-line printer-properties editor: the printer it belongs to, the +/// driver's options, and the choice currently shown for each. +#[derive(Debug, Clone, PartialEq)] +pub struct PrinterOptionsDraft { + pub printer: String, + /// `None` while the options are still being fetched. + pub options: Option>, + /// Current choice keyword per option key. + pub choices: std::collections::BTreeMap, + /// Why the options could not be listed, when they could not. + pub error: Option, +} + +impl PrinterOptionsDraft { + /// The choices that differ from the driver's defaults — all a job needs + /// to pass along. + pub fn overrides(&self) -> std::collections::BTreeMap { + let Some(options) = &self.options else { + return std::collections::BTreeMap::new(); + }; + options + .iter() + .filter_map(|option| { + let choice = self.choices.get(&option.key)?; + (choice != &option.default).then(|| (option.key.clone(), choice.clone())) + }) + .collect() + } +} + /// Transient state backing the Plot dialog. Seeded from the layout's plot /// settings when the dialog opens; consumed on commit. // The persisted fields form the "plot" section of the app config @@ -147,6 +416,37 @@ pub struct PlotDialogState { /// sentinels. #[serde(skip)] pub printers: Vec, + /// Why the system gave no printer list (a stopped spooler, a failing + /// print service), shown under the printer row so an empty list is + /// never mistaken for "no printers". + #[serde(skip)] + pub printers_error: Option, + /// Name of the system default printer, shown next to the default entry. + #[serde(skip)] + pub default_printer: Option, + /// Sheets and printable areas the selected printer reported; `None` + /// while unknown, when the platform cannot ask, or for PDF output. + #[serde(skip)] + pub printer_media: Option>, + /// Sheets the user defined, offered for every device and remembered + /// across sessions. + pub custom_papers: Vec, + /// The inline custom-sheet editor while it is open. + #[serde(skip)] + pub custom_editor: Option, + /// Driver options chosen per printer (`printer → key → choice`), the way + /// a plotter configuration remembers its device settings; applied to + /// every job sent to that printer and kept across sessions. + pub driver_options: std::collections::BTreeMap>, + /// The in-line printer-properties editor while it is open. + #[serde(skip)] + pub printer_editor: Option, + /// The page-setup import chooser while it is open. + #[serde(skip)] + pub import_draft: Option, + /// Open the Plot / Page Setup dialog whenever a new layout is created, + /// the way a page-setup manager is expected to greet a new layout. + pub page_setup_on_new_layout: bool, /// Chosen printer name, or `None` for the system default. pub printer: Option, /// Output goes to a PDF file instead of a printer. @@ -160,10 +460,26 @@ pub struct PlotDialogState { pub upside_down: bool, pub copies: String, pub area: String, + /// The picked world-space plot window (x0, y0, x1, y1) backing + /// `area == "Window"`. Persisted so reopening the dialog — or the app — + /// keeps the same window instead of reporting an empty plot area. + pub window: Option<(f64, f64, f64, f64)>, pub center: bool, + /// Plot offsets in the page-setup unit (`paper_units`); the file keeps + /// them in millimetres. pub offset_x: String, pub offset_y: String, + /// The unit the page setup counts in. Only the offsets and the custom + /// scale fields are shown in it; the paper space keeps its own unit + /// because the stored scale is converted along with it. + pub paper_units: PaperUnits, pub scale: String, + /// The custom scale as `paper = drawing` in the page-setup unit — the + /// same ratio the picker names, or the one typed here. + #[serde(skip)] + pub custom_scale_paper: String, + #[serde(skip)] + pub custom_scale_drawing: String, #[serde(default = "legacy_fit_to_paper_default")] pub fit_to_paper: bool, #[serde(skip)] @@ -179,6 +495,26 @@ pub struct PlotDialogState { pub transparency: bool, pub paperspace_last: bool, pub stamp: bool, + /// Hidden-line removal for paper-space objects. + pub hide_paperspace: bool, + /// Sending a plot also writes the dialog into the layout, the way a + /// page setup is expected to remember the last plot. + pub save_to_layout: bool, + /// `BACKGROUNDPLOT` bit 2: batch plots (PRINTALL / PUBLISH) run in the + /// background; `background` is bit 1 for single plots. + pub background_publish: bool, + /// `PLOTOFFSET`: offsets count from the paper edge instead of the + /// printable area's corner. + pub plot_offset_from_edge: bool, + /// `PAPERUPDATE`: 1 switches to the printer's default sheet when it does + /// not support the page setup's, 0 keeps the sheet and warns. + pub paper_update: u8, + /// `PLOTROTMODE` (0–2): kept for page setups that read it; every + /// rotation here turns the sheet about the page. + pub plot_rot_mode: u8, + /// `PLOTTRANSPARENCYOVERRIDE`: 0 never plots transparency, 1 follows + /// the dialog, 2 always plots it. + pub transparency_override: u8, /// Display name of the active plot style table ("" = none). pub style_name: String, pub apply_plot_styles: bool, @@ -189,6 +525,9 @@ pub struct PlotDialogState { /// The selected setup references a style table that is not loaded. #[serde(skip)] pub style_missing: bool, + /// Why the selected table could not be loaded, shown under the Table row. + #[serde(skip)] + pub style_error: Option, /// Named page setups in the document (refreshed when the dialog opens). #[serde(skip)] pub page_setups: Vec, @@ -210,19 +549,32 @@ impl Default for PlotDialogState { fn default() -> Self { Self { printers: Vec::new(), + printers_error: None, + default_printer: None, + printer_media: None, + custom_papers: Vec::new(), + custom_editor: None, + driver_options: std::collections::BTreeMap::new(), + printer_editor: None, + import_draft: None, + page_setup_on_new_layout: false, printer: None, to_file: false, - paper: "A4".into(), + paper: paper_catalog::default_paper().canonical.to_string(), paper_width_mm: 297.0, paper_height_mm: 210.0, orientation: "Landscape".into(), upside_down: false, copies: "1".into(), area: "Window".into(), + window: None, center: true, offset_x: "0.0".into(), offset_y: "0.0".into(), + paper_units: PaperUnits::Millimeters, scale: "1:1".into(), + custom_scale_paper: "1".into(), + custom_scale_drawing: "1".into(), fit_to_paper: true, scales: Vec::new(), plot_views: Vec::new(), @@ -235,11 +587,19 @@ impl Default for PlotDialogState { transparency: false, paperspace_last: false, stamp: false, + hide_paperspace: false, + save_to_layout: true, + background_publish: false, + plot_offset_from_edge: false, + paper_update: 0, + plot_rot_mode: 2, + transparency_override: 1, style_name: String::new(), apply_plot_styles: true, show_plot_styles: false, plot_styles: Vec::new(), style_missing: false, + style_error: None, page_setups: Vec::new(), selected_setup: String::new(), name_input: None, @@ -263,10 +623,14 @@ impl PlotDialogState { self.upside_down = o.upside_down; self.copies = o.copies.clone(); self.area = o.area.clone(); + self.window = o.window; self.center = o.center; self.offset_x = o.offset_x.clone(); self.offset_y = o.offset_y.clone(); + self.paper_units = o.paper_units; self.scale = o.scale.clone(); + self.custom_scale_paper = o.custom_scale_paper.clone(); + self.custom_scale_drawing = o.custom_scale_drawing.clone(); self.fit_to_paper = o.fit_to_paper; self.scale_lw = o.scale_lw; self.quality = o.quality.clone(); @@ -277,6 +641,7 @@ impl PlotDialogState { self.transparency = o.transparency; self.paperspace_last = o.paperspace_last; self.stamp = o.stamp; + self.hide_paperspace = o.hide_paperspace; self.style_name = o.style_name.clone(); self.apply_plot_styles = o.apply_plot_styles; self.show_plot_styles = o.show_plot_styles; @@ -290,80 +655,11 @@ fn legacy_fit_to_paper_default() -> bool { } fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style { - move |theme: &Theme, st| { - let palette = theme.palette(); - let pair = match (accent, st) { - (true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong, - (false, button::Status::Hovered | button::Status::Pressed) => { - palette.background.strong - } - (true, _) => palette.primary.base, - _ => palette.background.weak, - }; - button::Style { - background: Some(Background::Color(pair.color)), - text_color: pair.text, - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 4.0.into(), - }, - shadow: iced::Shadow::default(), - snap: false, - } - } -} - -fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { - let palette = theme.palette(); - let border = match status { - text_input::Status::Focused { .. } => palette.primary.base.color, - _ => palette.background.neutral.color, - }; - text_input::Style { - background: Background::Color(palette.background.base.color), - border: Border { color: border, width: 1.0, radius: 3.0.into() }, - icon: palette.background.base.text, - placeholder: palette.background.base.text.scale_alpha(0.48), - value: palette.background.base.text, - selection: palette.primary.base.color.scale_alpha(0.5), - } -} - -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - -fn hdivider<'a>(width: Length) -> Element<'a, Message> { - container(Space::new().width(width).height(1)) - .width(width) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() + form::button_style(accent) } fn section_label<'a>(s: Cow<'static, str>) -> Element<'a, Message> { - text(s).size(11).style(muted_style).into() -} - -fn vsep<'a>(height: Length) -> Element<'a, Message> { - container(Space::new().width(1).height(height)) - .width(1) - .height(height) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() + form::section_label(s) } fn setup_row<'a>( @@ -376,7 +672,7 @@ fn setup_row<'a>( return text_input("", rename_buf) .on_input(|value| Message::PlotDlg(PlotDlgMsg::NameInput(value))) .on_submit(Message::PlotDlg(PlotDlgMsg::NameCommit)) - .style(field_style) + .style(form::field_style) .size(11) .padding([4, 8]) .width(Fit) @@ -414,15 +710,7 @@ fn drop_row<'a>( ctor: fn(String) -> PlotDlgMsg, width: Length, ) -> Element<'a, Message> { - let pl = iced::widget::pick_list(selected, options, |value| value.to_string()) - .on_select(move |choice| Message::PlotDlg(ctor(choice.raw))) - .text_size(12) - .padding([3, 6]) - .width(width); - row![text(label).size(11).style(muted_style).width(92), pl] - .spacing(8) - .align_y(iced::Center) - .into() + form::labeled_pick_list(label, options, selected, move |choice| Message::PlotDlg(ctor(choice.raw)), width) } fn drop_row_enabled<'a>( @@ -433,20 +721,14 @@ fn drop_row_enabled<'a>( width: Length, enabled: bool, ) -> Element<'a, Message> { - if enabled { - return drop_row(label, options, selected, ctor, width); - } - row![ - text(label).size(11).style(muted_style).width(92), - crate::ui::read_only::field( - selected.map(|choice| choice.to_string()).unwrap_or_default().as_str(), - 12.0, - width, - ), - ] - .spacing(8) - .align_y(iced::Center) - .into() + form::labeled_pick_list_enabled( + label, + options, + selected, + move |choice| Message::PlotDlg(ctor(choice.raw)), + width, + enabled, + ) } /// A `label : text field` row. @@ -456,17 +738,7 @@ fn field_row<'a>( ctor: fn(String) -> PlotDlgMsg, width: u16, ) -> Element<'a, Message> { - row![ - text(label).size(11).style(muted_style).width(92), - text_input("", value) - .on_input(move |s| Message::PlotDlg(ctor(s))) - .style(field_style) - .size(12) - .width(width as f32), - ] - .spacing(8) - .align_y(iced::Center) - .into() + form::labeled_field(label, value, move |s| Message::PlotDlg(ctor(s)), width as f32) } fn field_row_enabled<'a>( @@ -476,19 +748,9 @@ fn field_row_enabled<'a>( width: u16, enabled: bool, ) -> Element<'a, Message> { - if enabled { - return field_row(label, value, ctor, width); - } - row![ - text(label).size(11).style(muted_style).width(92), - crate::ui::read_only::field(value, 12.0, Length::Fixed(width as f32)), - ] - .spacing(8) - .align_y(iced::Center) - .into() + form::labeled_field_enabled(label, value, move |s| Message::PlotDlg(ctor(s)), width as f32, enabled) } -/// A single option checkbox bound to a `PlotFlag`. fn check<'a>(label: Cow<'static, str>, on: bool, flag: PlotFlag) -> Element<'a, Message> { checkbox(on) .label(label) @@ -515,6 +777,96 @@ fn check_enabled<'a>( .into() } +/// The import chooser: the source drawing's name, one tick per page setup, +/// All / None, and Import / Cancel. +fn page_setup_import_chooser<'a>( + draft: &'a PageSetupImportDraft, + height: Length, +) -> Element<'a, Message> { + let msg = |m: PageSetupImportMsg| Message::PlotDlg(PlotDlgMsg::Import(m)); + let mut rows = column![].spacing(3); + if let Some(error) = &draft.error { + rows = rows.push(text(error.clone()).size(10).style(muted_style)); + } else if draft.setups.is_empty() { + let none = text(t!("No named page setups in this drawing.")).size(10); + rows = rows.push(none.style(muted_style)); + } + for (name, _, on) in &draft.setups { + let name = name.clone(); + rows = rows.push( + checkbox(*on) + .label(name.clone()) + .on_toggle(move |_| msg(PageSetupImportMsg::Toggle(name.clone()))) + .size(13) + .text_size(11), + ); + } + let all = !draft.setups.is_empty() && draft.setups.iter().all(|(_, _, on)| *on); + let any = draft.setups.iter().any(|(_, _, on)| *on); + let mut import = button(text(t!("Import")).size(11)).style(btn(true)).padding([4, 8]); + if any { + import = import.on_press(msg(PageSetupImportMsg::Apply)); + } + column![ + text(draft.file.clone()).size(10).style(muted_style), + scrollable(rows).height(height), + checkbox(all) + .label(t!("All")) + .on_toggle(move |on| msg(PageSetupImportMsg::All(on))) + .size(13) + .text_size(11), + row![ + import, + button(text(t!("Cancel")).size(11)) + .on_press(msg(PageSetupImportMsg::Cancel)) + .style(btn(false)) + .padding([4, 8]), + ] + .spacing(4), + ] + .spacing(6) + .padding(4) + .into() +} + +/// The custom-scale row: `Custom: [paper] mm = [drawing] units`. Editing +/// either side replaces the picked scale with the typed ratio. +fn custom_scale_row<'a>(s: &'a PlotDialogState, enabled: bool) -> Element<'a, Message> { + let field = |value: &'a str, ctor: fn(String) -> PlotDlgMsg| { + let mut input = text_input("", value).size(11).padding([3, 6]).width(58); + if enabled { + input = input.on_input(move |v| Message::PlotDlg(ctor(v))); + } + input + }; + let unit = match s.paper_units { + PaperUnits::Inches => t!("Inches"), + PaperUnits::Millimeters => t!("Millimeters"), + }; + row![ + text(t!("Custom")).size(11).width(form::LABEL_WIDTH), + field(&s.custom_scale_paper, PlotDlgMsg::CustomScalePaper), + text(unit).size(11), + text("=").size(11), + field(&s.custom_scale_drawing, PlotDlgMsg::CustomScaleDrawing), + text(t!("units")).size(11), + ] + .spacing(6) + .align_y(iced::Center) + .into() +} + +/// Why the selected plot style table is not loaded, under the Table row — +/// the file's own problem, or where a copy would be found. +fn style_note<'a>(s: &'a PlotDialogState) -> Element<'a, Message> { + match &s.style_error { + Some(error) if s.style_missing => { + text(error.clone()).size(10).style(muted_style).into() + } + _ => Space::new().height(0).into(), + } +} + fn panel<'a>(content: impl Into>) -> Element<'a, Message> { container(content) .width(Length::Fill) @@ -525,6 +877,196 @@ fn choices(items: &[&str]) -> Vec { items.iter().map(|&value| PlotChoice::localized(value)).collect() } +/// The in-line printer-properties editor: one `label : choice` row per +/// driver option (media type, colour mode, quality, duplex, trays…), with +/// Reset / Cancel / Apply. Mirrors what LibreOffice's Properties… shows for a +/// CUPS printer, without leaving the plot dialog. +fn printer_options_editor<'a>(draft: &'a PrinterOptionsDraft, width: Length) -> Element<'a, Message> { + let mut rows = column![section_label(Cow::Owned(crate::tf!( + "Printer properties — {printer}", + printer = draft.printer + ).into_owned()))] + .spacing(6); + match (&draft.options, &draft.error) { + (Some(options), _) if options.is_empty() => { + rows = rows.push( + text(t!("This printer reports no driver options.")) + .size(10) + .style(muted_style), + ); + } + (Some(options), _) => { + for option in options { + let current = draft + .choices + .get(&option.key) + .cloned() + .unwrap_or_else(|| option.default.clone()); + let choices: Vec = option + .choices + .iter() + .map(|choice| PlotChoice { + raw: choice.keyword.clone(), + localized: false, + display: Some(choice.label.clone()), + }) + .collect(); + let selected = choices.iter().find(|c| c.raw == current).cloned(); + let key = option.key.clone(); + rows = rows.push(form::labeled_pick_list( + Cow::Owned(option.label.clone()), + choices, + selected, + move |choice| Message::PlotDlg(PlotDlgMsg::PrinterOptionSet(key.clone(), choice.raw)), + Length::Fill, + )); + } + } + (None, Some(error)) => { + rows = rows.push( + text(crate::tf!("Could not read the printer's options: {error}")) + .size(10) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.palette().danger.base.color), + }), + ); + } + (None, None) => { + rows = rows.push(text(t!("Reading the printer's options…")).size(10).style(muted_style)); + } + } + let mut buttons = row![ + button(text(t!("Reset")).size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsReset)) + .style(btn(false)) + .padding([4, 10]), + Space::new().width(Length::Fill), + button(text(t!("Cancel")).size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsCancel)) + .style(btn(false)) + .padding([4, 10]), + ] + .spacing(6); + if draft.options.is_some() { + buttons = buttons.push( + button(text(t!("Apply")).size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsApply)) + .style(btn(true)) + .padding([4, 12]), + ); + } + rows = rows.push(buttons); + container(rows) + .padding(8) + .width(width) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .into() +} + +/// The inline editor for a user-defined sheet: name, dimensions and unit, +/// printable margins, Add / Cancel. Numbers stay text until Add validates +/// them. Every margin field carries its own side label so the four values +/// cannot be confused, and the unit is repeated where the numbers are typed. +fn custom_paper_editor<'a>(draft: &'a CustomPaperDraft, width: Length) -> Element<'a, Message> { + let msg = |m: CustomPaperMsg| Message::PlotDlg(PlotDlgMsg::CustomPaper(m)); + let unit = if draft.inches { "in" } else { "mm" }; + let unit_options: Vec = vec![ + PlotChoice::localized("Millimeters"), + PlotChoice::localized("Inches"), + ]; + let unit_selected = Some(PlotChoice::localized(if draft.inches { "Inches" } else { "Millimeters" })); + const SIDE_LABEL: f32 = 52.0; + const NUMBER: f32 = 64.0; + let dimension = |label: Cow<'static, str>, value: &'a str, ctor: fn(String) -> CustomPaperMsg| { + form::labeled_field_compact(label, value, move |v| msg(ctor(v)), SIDE_LABEL, NUMBER) + }; + let margin = |label: Cow<'static, str>, side: MarginSide, index: usize| { + form::labeled_field_compact( + label, + &draft.margins[index], + move |v| msg(CustomPaperMsg::Margin(side, v)), + SIDE_LABEL, + NUMBER, + ) + }; + let error: Element<'_, Message> = match draft.error { + Some(CustomPaperError::Size) => text(t!("Enter a positive width and height.")) + .size(10) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.palette().danger.base.color), + }) + .into(), + None => Space::new().height(0).into(), + }; + container( + column![ + section_label(t!("Custom paper size")), + form::labeled_field(t!("Name"), &draft.name, move |v| msg(CustomPaperMsg::Name(v)), 200.0), + form::labeled_pick_list( + t!("Units"), + unit_options, + unit_selected, + move |choice| msg(CustomPaperMsg::Units(choice.raw)), + Length::Fixed(150.0), + ), + row![ + dimension(t!("Width"), &draft.width, CustomPaperMsg::Width), + dimension(t!("Height"), &draft.height, CustomPaperMsg::Height), + text(unit).size(11).style(muted_style), + ] + .spacing(14) + .align_y(iced::Center), + text(format!("{} ({unit})", t!("Printable margins"))) + .size(11) + .style(muted_style), + row![ + margin(t!("Left"), MarginSide::Left, 0), + margin(t!("Right"), MarginSide::Right, 2), + ] + .spacing(14), + row![ + margin(t!("Top"), MarginSide::Top, 3), + margin(t!("Bottom"), MarginSide::Bottom, 1), + ] + .spacing(14), + error, + row![ + Space::new().width(Length::Fill), + button(text(t!("Cancel")).size(11)) + .on_press(msg(CustomPaperMsg::Cancel)) + .style(btn(false)) + .padding([4, 10]), + button(text(t!("Add")).size(11)) + .on_press(msg(CustomPaperMsg::Add)) + .style(btn(true)) + .padding([4, 12]), + ] + .spacing(6), + ] + .spacing(6), + ) + .padding(8) + .width(width) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .into() +} + pub fn view_window( s: &PlotDialogState, print_all_options: bool, @@ -571,7 +1113,7 @@ pub fn view_window( text_input("", rename_buf) .on_input(|value| Message::PlotDlg(PlotDlgMsg::NameInput(value))) .on_submit(Message::PlotDlg(PlotDlgMsg::NameCommit)) - .style(field_style) + .style(form::field_style) .size(11) .padding([4, 8]) .width(Length::Fill), @@ -589,6 +1131,12 @@ pub fn view_window( } else { Space::new().height(0).into() }; + // While an import is being chosen, the list shows the other drawing's + // setups with a tick each instead of this drawing's. + let list_body: Element<'_, Message> = match &s.import_draft { + Some(draft) => page_setup_import_chooser(draft, height), + None => list_body, + }; let list_panel = container( column![ text(t!("Page setups")).size(10).style(muted_style), @@ -640,15 +1188,33 @@ pub fn view_window( if is_named && !print_all_options { delete_button = delete_button.on_press(Message::PlotDlg(PlotDlgMsg::DeleteSetup)); } + let mut import_button = button(text(t!("Import…")).size(11)) + .style(btn(false)) + .padding([4, 12]); + if !print_all_options && s.import_draft.is_none() { + import_button = import_button + .on_press(Message::PlotDlg(PlotDlgMsg::Import(PageSetupImportMsg::Pick))); + } let left_bar = row![ new_button, copy_button, delete_button, + import_button, ] .spacing(4); // ── Printer / plotter ───────────────────────────────────────────────── - let mut printer_opts = vec![PlotChoice::localized(OUT_DEFAULT)]; + // The default entry names the printer it resolves to, when known, so the + // user sees where a plot will go without leaving the dialog. + let default_entry = match &s.default_printer { + Some(name) => PlotChoice { + raw: OUT_DEFAULT.to_string(), + localized: true, + display: Some(format!("{} ({name})", crate::i18n::translate(OUT_DEFAULT))), + }, + None => PlotChoice::localized(OUT_DEFAULT), + }; + let mut printer_opts = vec![default_entry.clone()]; printer_opts.extend(s.printers.iter().cloned().map(PlotChoice::raw)); printer_opts.push(PlotChoice::localized(OUT_PDF)); let printer_sel = if s.to_file { @@ -656,13 +1222,44 @@ pub fn view_window( } else { Some(match &s.printer { Some(printer) => PlotChoice::raw(printer.clone()), - None => PlotChoice::localized(OUT_DEFAULT), + None => default_entry, }) }; - 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()); + // A printer that reported its media lists those sheets (with the + // printable areas it will actually honour); PDF output and printers that + // could not be asked list the catalogue by series. A sheet that only + // exists in the drawing (a driver's custom size, an old config's bare + // name) is added at the top so the current selection stays selectable. + let selected_paper = paper_catalog::from_drawing(&s.paper, s.paper_width_mm, s.paper_height_mm); + let printer_sheets = (!s.to_file).then_some(()).and(s.printer_media.as_deref()); + let mut paper_opts: Vec = match printer_sheets { + Some(caps) => caps.media.iter().map(|media| PlotChoice::paper(&media.paper)).collect(), + None => paper_catalog::catalog().iter().map(PlotChoice::paper).collect(), + }; + // User-defined sheets come last, after whatever the device offers. + for custom in &s.custom_papers { + let choice = PlotChoice::paper(&custom.paper()); + if !paper_opts.contains(&choice) { + paper_opts.push(choice); + } } + let paper_sel = PlotChoice::paper(&selected_paper); + if !paper_opts.contains(&paper_sel) { + paper_opts.insert(0, paper_sel.clone()); + } + let selected_is_custom = s + .custom_papers + .iter() + .any(|custom| custom.canonical() == selected_paper.canonical); + let paper_source_note: Element<'_, Message> = if printer_sheets.is_some() { + text(t!("Sheet sizes and printable areas reported by the printer.")) + .size(10) + .style(muted_style) + .width(width) + .into() + } else { + Space::new().height(0).into() + }; let paper_note: Element<'_, Message> = if s.area == "Layout" { text(t!("Layout plots the current sheet using the selected paper size.")) .size(10) @@ -672,7 +1269,11 @@ pub fn view_window( } else { Space::new().height(0).into() }; - let mut output_row = row![ + // Heading and picker share one line — the section has nothing else to + // announce — and the picker takes the whole row so a long queue name + // stays readable; the driver's Properties… button sits beneath it. + let output_field = row![ + text(t!("Printer / plotter")).size(11).style(muted_style).width(form::LABEL_WIDTH), iced::widget::pick_list(printer_sel, printer_opts, |value| value.to_string()) .on_select(|choice| Message::PlotDlg(PlotDlgMsg::Printer(choice.raw))) .text_size(12) @@ -680,45 +1281,77 @@ pub fn view_window( .width(Length::Fill), ] .spacing(8) - .align_y(iced::Center); - if !s.to_file { - output_row = output_row.push( + .align_y(iced::Center) + .width(Length::Fill); + let printer_controls: Element<'_, Message> = if s.to_file { + Space::new().height(0).into() + } else { + row![ + field_row(t!("Copies"), &s.copies, PlotDlgMsg::Copies, 60), + Space::new().width(Length::Fill), button(text(t!("Properties…")).size(11)) .on_press(Message::PlotDlg(PlotDlgMsg::PrinterProperties)) .style(btn(false)) - .padding([4, 8]), - ); - } - let output_field = column![ - text(t!("Output")).size(11).style(muted_style), - output_row, - ] - .spacing(4) - .width(Length::Fill); - let copies_row: Element<'_, Message> = if s.to_file { - Space::new().height(0).into() - } else { - field_row(t!("Copies"), &s.copies, PlotDlgMsg::Copies, 60) - }; - let printer_panel = panel( - column![ - section_label(t!("Printer / plotter")), - output_field, - copies_row, + .padding([4, 10]), ] - .spacing(7), - ); + .align_y(iced::Center) + .into() + }; + // A printer with remembered driver choices says so under its row; the + // editor itself unfolds there when Properties… is pressed. + let printer_note: Element<'_, Message> = match (&s.printer_editor, &s.printer) { + (Some(draft), _) => printer_options_editor(draft, width), + (None, _) if s.printers_error.is_some() => { + let error = s.printers_error.clone().unwrap_or_default(); + text(crate::tf!("Could not list printers: {error}")) + .size(10) + .style(muted_style) + .into() + } + (None, Some(printer)) if s.driver_options.get(printer).is_some_and(|o| !o.is_empty()) => { + let count = s.driver_options[printer].len(); + text(crate::tf!("{count} driver option(s) set for this printer.")) + .size(10) + .style(muted_style) + .into() + } + _ => Space::new().height(0).into(), + }; + let printer_panel = panel(column![output_field, printer_controls, printer_note].spacing(7)); // ── Paper, area, offset, scale ──────────────────────────────────────── + // The picker takes the whole row so long media names stay readable; the + // custom-sheet buttons sit right-aligned beneath it, and the editor + // unfolds in their place. + let size_row = drop_row(t!("Paper size"), paper_opts, Some(paper_sel), PlotDlgMsg::Paper, Length::Fill); + let custom_controls: Element<'_, Message> = match &s.custom_editor { + Some(draft) => custom_paper_editor(draft, width), + None => { + let mut buttons = row![Space::new().width(Length::Fill)].spacing(6); + if selected_is_custom { + buttons = buttons.push( + button(text(t!("Remove")).size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Remove))) + .style(btn(false)) + .padding([4, 10]), + ); + } + buttons + .push( + button(text(t!("Custom…")).size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Open))) + .style(btn(false)) + .padding([4, 10]), + ) + .into() + } + }; + // Like the printer row: the section is its one picker, so heading and + // control share a line. let paper_panel = panel(column![ - section_label(t!("Paper")), - drop_row( - t!("Size"), - paper_opts.into_iter().map(PlotChoice::raw).collect(), - Some(PlotChoice::raw(s.paper.clone())), - PlotDlgMsg::Paper, - width, - ), + size_row, + custom_controls, + paper_source_note, paper_note, ].spacing(7)); @@ -760,13 +1393,19 @@ pub fn view_window( ); } let common_area = s.area != "Layout"; + let inches = s.paper_units == PaperUnits::Inches; + let (offset_x_label, offset_y_label) = if inches { + (t!("X (in)"), t!("Y (in)")) + } else { + (t!("X (mm)"), t!("Y (mm)")) + }; let area_panel = panel(column![ section_label(t!("Plot area")), area_row, section_label(t!("Plot offset")), column![ - field_row_enabled(t!("X (mm)"), &s.offset_x, PlotDlgMsg::OffsetX, 70, common_area && !s.center), - field_row_enabled(t!("Y (mm)"), &s.offset_y, PlotDlgMsg::OffsetY, 70, common_area && !s.center), + field_row_enabled(offset_x_label, &s.offset_x, PlotDlgMsg::OffsetX, 70, common_area && !s.center), + field_row_enabled(offset_y_label, &s.offset_y, PlotDlgMsg::OffsetY, 70, common_area && !s.center), ] .spacing(7), check_enabled(t!("Center the plot"), s.center, PlotFlag::Center, common_area), @@ -794,6 +1433,16 @@ pub fn view_window( width, common_area && !s.fit_to_paper, ), + // The custom scale spells the picked ratio in the page-setup unit + // and accepts one of its own; the unit picker decides that unit. + drop_row( + t!("Units"), + vec![PlotChoice::localized("Millimeters"), PlotChoice::localized("Inches")], + Some(PlotChoice::localized(if inches { "Inches" } else { "Millimeters" })), + PlotDlgMsg::PaperUnits, + width, + ), + custom_scale_row(s, common_area && !s.fit_to_paper), check_enabled( t!("Scale lineweights"), s.scale_lw && !s.fit_to_paper, @@ -826,6 +1475,7 @@ pub fn view_window( PlotDlgMsg::Style, width, ), + style_note(s), check_enabled( t!("Plot with plot styles"), s.apply_plot_styles, @@ -881,14 +1531,12 @@ pub fn view_window( ].spacing(7)); // ── Output options and orientation ──────────────────────────────────── - let paper_order_option: Element<'_, Message> = if s.paper_space { - check( - t!("Paper space last"), - s.paperspace_last, - PlotFlag::PaperspaceLast, - ) - } else { - Space::new().height(0).into() + let paper_space_option = |on: bool, label: Cow<'static, str>, flag: PlotFlag| -> Element<'_, Message> { + if s.paper_space { + check(label, on, flag) + } else { + Space::new().height(0).into() + } }; let options_panel = panel(column![ section_label(t!("Plot options")), @@ -897,13 +1545,15 @@ pub fn view_window( check(t!("Plot in background"), s.background, PlotFlag::Background), check(t!("Object lineweights"), s.lineweights, PlotFlag::Lineweights), check(t!("Plot transparency"), s.transparency, PlotFlag::Transparency), + paper_space_option(s.hide_paperspace, t!("Hide paperspace objects"), PlotFlag::HidePaperspace), ] .spacing(6) .width(width), column![ - paper_order_option, + paper_space_option(s.paperspace_last, t!("Paper space last"), PlotFlag::PaperspaceLast), check(t!("Merge overlapping lines"), s.merge_lines, PlotFlag::MergeLines), check(t!("Plot stamp"), s.stamp, PlotFlag::Stamp), + paper_space_option(s.save_to_layout, t!("Save changes to layout"), PlotFlag::SaveToLayout), ] .spacing(6) .width(width), diff --git a/src/ui/window/plugin_manager.rs b/src/ui/window/plugin_manager.rs index 753038da7..f81d4a44e 100644 --- a/src/ui/window/plugin_manager.rs +++ b/src/ui/window/plugin_manager.rs @@ -8,6 +8,7 @@ use crate::app::Message; use crate::plugin::external::{ExternalPlugin, RegistryEntry, ReleaseInfo}; +use crate::ui::style::common::muted_style; use iced::widget::{ button, column, container, markdown, row, rule, scrollable, text, text_input, Space, }; @@ -48,12 +49,6 @@ inventory::submit!(crate::command::CommandRegistration { names: &["PLUGINS", "PLUGINMANAGER"] }); -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn primary_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().primary.base.color), diff --git a/src/ui/window/print_all.rs b/src/ui/window/print_all.rs index 05b5f5265..c95f39619 100644 --- a/src/ui/window/print_all.rs +++ b/src/ui/window/print_all.rs @@ -1,5 +1,6 @@ use crate::app::Message; use crate::t; +use crate::ui::style::common::muted_style as muted; use iced::widget::{button, checkbox, column, container, row, scrollable, text, Space}; use iced::{Background, Border, Element, Length, Theme}; @@ -13,12 +14,6 @@ fn button_style(primary: bool) -> impl Fn(&Theme, button::Status) -> button::Sty } } -fn muted(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - pub fn view_window<'a>( layouts: &'a [(String, bool)], printer: Option<&'a str>, diff --git a/src/ui/window/recovery.rs b/src/ui/window/recovery.rs index 0ae3ed5f3..8bfa1d8da 100644 --- a/src/ui/window/recovery.rs +++ b/src/ui/window/recovery.rs @@ -1,14 +1,9 @@ use crate::app::Message; use crate::io::recovery::{RecoveryReport, RecoveryStatus}; +use crate::ui::style::common::muted_style; use iced::widget::{button, column, container, row, scrollable, text, Space}; use iced::{Background, Border, Element, Fill, Length, Shrink, Theme}; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn status_style( status: RecoveryStatus, ) -> impl Fn(&Theme) -> iced::widget::text::Style + Copy { diff --git a/src/ui/window/shortcuts.rs b/src/ui/window/shortcuts.rs index 63440fa33..50e3b098d 100644 --- a/src/ui/window/shortcuts.rs +++ b/src/ui/window/shortcuts.rs @@ -2,6 +2,7 @@ use crate::app::Message; use crate::t; +use crate::ui::style::common::muted_style; use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; use iced::{Background, Element, Length, Theme}; @@ -13,12 +14,6 @@ pub enum ShortcutField { const GUTTER: f32 = 16.0; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn danger_text_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().danger.base.color), diff --git a/src/ui/window/update_notice.rs b/src/ui/window/update_notice.rs index 2aa020118..2ac897d32 100644 --- a/src/ui/window/update_notice.rs +++ b/src/ui/window/update_notice.rs @@ -1,15 +1,10 @@ use crate::app::Message; +use crate::ui::style::common::muted_style; use iced::widget::{button, column, container, row, scrollable, text, Space}; use iced::{Background, Border, Element, Theme}; use crate::t; use std::borrow::Cow; -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } -} - fn primary_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().primary.base.color), diff --git a/src/ui/window/xref_help.rs b/src/ui/window/xref_help.rs new file mode 100644 index 000000000..09e07490c --- /dev/null +++ b/src/ui/window/xref_help.rs @@ -0,0 +1,97 @@ +//! Reference Manager help window: how the manager works on the web build +//! (read-only list, disabled mutations, no previews) and where to report +//! issues. Rendered as a modal like About/DSettings so the text gets a +//! proper window width — an anchored toolbar dropdown squeezed it into an +//! unreadable sliver. + +use crate::app::Message; +use crate::ui::style::common::muted_style; +use iced::widget::{button, column, container, row, text, Space}; +use iced::{Background, Element, Fill, Length, Theme}; + +/// GitHub issues page linked from the help window, so web users can report +/// missing or broken behavior where it will be seen. +pub const XREF_ISSUES_URL: &str = "https://github.com/HakanSeven12/OpenCADStudio/issues"; + +pub fn view_window(sizing: crate::ui::modal::ModalSizing) -> Element<'static, Message> { + // Fixed window width: paragraphs wrap against this instead of the + // toolbar, which is what made the dropdown unreadable. + let body_w = Length::Fixed(460.0); + let para = |label: String| -> Element<'static, Message> { + text(label).size(12).width(Fill).into() + }; + + let intro = para(crate::t!("How the Reference Manager works on web").into_owned()); + let list = para( + crate::t!( + "This panel lists the drawing's references read-only: status, type, size, date and saved path, plus the Details pane. Select rows to inspect them; List and Tree switch the presentation." + ) + .into_owned(), + ); + let limits = para( + crate::t!( + "Attach, Refresh and Change Path stay disabled here: the web build has no filesystem access and no native file pickers, so references can be neither added, reloaded nor repathed — and previews stay unavailable. Nested references are read-only on every platform, and relative paths need the drawing saved first." + ) + .into_owned(), + ); + let report_hint = para( + crate::t!( + "Hit something broken or missing? Report it on the GitHub issues page so it can be fixed." + ) + .into_owned(), + ); + + let report_button = button(text(crate::t!("Report an issue")).size(12)) + .on_press(Message::OpenUrl(XREF_ISSUES_URL.to_string())) + .style(button::primary) + .padding([6, 18]); + let close_button = button(text(crate::t!("Close")).size(12)) + .on_press(Message::CloseModal) + .style(button::secondary) + .padding([6, 18]); + let action_row: Element<'static, Message> = row![ + Space::new().width(Fill), + report_button, + close_button, + ] + .spacing(8) + .align_y(iced::Center) + .into(); + + let content = column![ + text(crate::t!("Reference Manager — Web").into_owned()) + .size(14), + text(crate::t!("EXTERNALREFERENCES on the web build").into_owned()) + .size(11) + .style(muted_style), + intro, + list, + limits, + report_hint, + Space::new().height(4), + action_row, + ] + .spacing(10) + .padding(16) + .width(body_w); + + container(content) + .width(sizing.width) + .height(sizing.height) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.base.color, + )), + ..Default::default() + }) + .into() +} + +#[cfg(test)] +mod tests { + #[test] + fn issues_url_points_at_github_issues() { + assert!(super::XREF_ISSUES_URL.starts_with("https://github.com/")); + assert!(super::XREF_ISSUES_URL.contains("/issues")); + } +} diff --git a/src/ui/window/xref_manager.rs b/src/ui/window/xref_manager.rs new file mode 100644 index 000000000..e3edf4148 --- /dev/null +++ b/src/ui/window/xref_manager.rs @@ -0,0 +1,2738 @@ +//! Reference Manager palette — list of external references with operations. +//! +//! Task 8b build: the toolbar's mutating affordances are wired to the same +//! engine fns the CLI uses, operating on the multi-selection with per-item +//! report lines. Session state (unloaded set, stat cache) lives per-tab on +//! `DocumentTab`; this panel only caches display rows plus text inputs. + +use crate::app::Message; +use crate::io::xref::collect_entries_with_prev; +use crate::io::xref_model::{normalize_lexical, Pathtype, RefKind, RefStatus, RefType, ReferenceEntry}; +use crate::ui::ROW_H; +use crate::ui::style::common::muted_style; +use acadrust::CadDocument; +use iced::widget::{button, column, container, mouse_area, row, scrollable, text, tooltip}; +use iced::Padding; +use iced::{Background, Border, Element, Fill, Length, Theme}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// `true` on web builds, where the filesystem and native file pickers do not +/// exist. Mutating affordances stay compiled but render disabled; the +/// read-only list renders everywhere. +const IS_WASM: bool = cfg!(target_arch = "wasm32"); + + + +/// Font size for table cells (mirrors `layers.rs`). +const FONT_SZ: f32 = ROW_H * 0.42; // ≈11 px at ROW_H=26 +/// Fixed table height so the details pane below keeps stable space. +const TABLE_H: f32 = 480.0; +/// Minimum table area height under split dragging. +const TABLE_MIN_H: f32 = 120.0; +/// Minimum table content width so columns stay readable in narrow docks. +const TABLE_MIN_W: f32 = 480.0; +/// Inter-column gutter shared by header, rows, and grab zones so columns align. +const COL_GUTTER: f32 = 6.0; +/// Tree-mode text size: a step above the table cells, like the +/// industry tree. +/// Indent per tree depth level. +const INDENT_W: f32 = 16.0; + +/// Dashed vertical tree guide (one per ancestor level), stretched to the row. +const TREE_GUIDE_SVG: &[u8] = b""; + +/// Shared parsed handle for [`TREE_GUIDE_SVG`] (parsed once, not per row). +fn tree_guide_handle() -> iced::widget::svg::Handle { + static HANDLE: std::sync::OnceLock = std::sync::OnceLock::new(); + HANDLE + .get_or_init(|| iced::widget::svg::Handle::from_memory(TREE_GUIDE_SVG)) + .clone() +} + +/// Indent cells for `depth` ancestor levels: dashed guides explaining the +/// hierarchy, same total width as the old blank indent. +fn tree_indent(depth: u32) -> iced::widget::Row<'static, crate::app::Message> { + let mut row = iced::widget::Row::new().spacing(0); + for _ in 0..depth { + row = row.push( + container( + iced::widget::svg(tree_guide_handle()) + .width(Length::Fixed(INDENT_W)) + .height(Fill), + ) + .align_y(iced::Center), + ); + } + row +} +const TREE_FONT_SZ: f32 = 13.0; +/// Longest edge of a preview image, in pixels. +const PREVIEW_MAX: u32 = 256; + +/// One visible row in [`XrefManagerPanel::display_rows`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DisplayRow { + /// Index into [`XrefManagerPanel::entries`]. + pub index: usize, + /// Tree depth (0 in list mode and for roots). + pub depth: u32, + /// True for entries enumerated from a nested file (never rendered). + pub is_nested: bool, +} + +/// Display-row index reserved for the host-drawing pseudo-row (list first +/// row, tree root). It addresses no entry: row rendering special-cases it +/// and selection ignores it. +pub const HOST_ROW: usize = usize::MAX; + +/// Palette state for the Reference Manager. +/// +/// Session state (unloaded set, stat cache) lives per-tab on `DocumentTab` +/// and is passed into [`refresh`](XrefManagerPanel::refresh); this panel +/// only caches display rows, expansion, selection, and text inputs. +#[derive(Debug, Clone)] +pub struct XrefManagerPanel { + /// Cached [`collect_entries_with_prev`] output for the active drawing. + pub entries: Vec, + /// Display name of the host drawing (first list row / tree root). + pub host_name: String, + /// Absolute path of the host drawing, if saved (host row Saved Path). + pub host_path: String, + /// Multi-selected entry indices — consumed by batch path ops. + pub selected: HashSet, + /// Anchor entry driving the details pane (last row clicked). + pub anchor: Option, + /// List (`false`) vs tree (`true`) presentation. + pub tree: bool, + /// Parent keys expanded in tree mode. Empty by default = collapsed. + pub expanded: HashSet, + /// Parent key → child entry indices, rebuilt on every refresh. + pub children: HashMap>, + /// Entry indices enumerated from nested files (not the host drawing). + pub nested: HashSet, + /// Document tab id that produced `entries` (stale check). + pub source_tab_id: Option, + /// `edit_revision` that produced `entries` — the palette auto-rescans + /// when the tab's revision moves (reuses the undo-snapshot counter, so + /// CLI and palette mutations both trip it). + pub source_edit_revision: u64, + /// Reference-table column widths: Reference, Status, Size, Type, Date, + /// Saved Path. Draggable via the header dividers (clamped). + pub col_widths: [f32; 6], + /// Reference-table area height. Draggable via the split divider above + /// the Details/Preview pane (clamped). + pub table_h: f32, + /// Open dropdown menus (one at a time; dismissed together). + pub attach_open: bool, + pub refresh_open: bool, + pub path_open: bool, + pub row_change_path_open: bool, + /// Decoded preview images keyed by `(entry key, resolved path)` — rebuilt + /// on every refresh for the anchor entry only, so the per-frame `view` + /// stays pure and file I/O never happens during rendering. + pub previews: HashMap<(u64, String), iced::widget::image::Handle>, + /// Details (`false`) vs Preview (`true`) lower pane. + pub show_preview: bool, +} + +/// Default reference-table column widths: Reference, Status, Size, Type, +/// Date, Saved Path. +pub const DEFAULT_COL_WIDTHS: [f32; 6] = [150.0, 104.0, 76.0, 76.0, 96.0, 200.0]; +/// Narrowest any reference-table column drags to. +pub const COL_MIN_W: f32 = 48.0; +/// Widest any reference-table column drags to. +pub const COL_MAX_W: f32 = 420.0; + +impl Default for XrefManagerPanel { + fn default() -> Self { + Self { + entries: Vec::new(), + host_name: String::new(), + host_path: String::new(), + selected: HashSet::new(), + anchor: None, + tree: false, + expanded: HashSet::new(), + children: HashMap::new(), + nested: HashSet::new(), + source_tab_id: None, + source_edit_revision: 0, + col_widths: DEFAULT_COL_WIDTHS, + table_h: TABLE_H, + attach_open: false, + refresh_open: false, + path_open: false, + row_change_path_open: false, + previews: HashMap::new(), + show_preview: false, + } + } +} + +/// Row-pick modifier mode for [`XrefManagerPanel::click_select`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SelectExtend { + /// Plain click: exactly one row selected. + Single, + /// Ctrl/Cmd-click: toggle one row. + Toggle, + /// Shift-click: contiguous range from the anchor. + Range, +} + +/// Selection-gated palette operation the toolbar offers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum XrefPaletteOp { + Open, + Detach, + Unload, + Reload, + Bind, + Overlay, + Attach, + Pathtype(Pathtype), +} + +impl XrefManagerPanel { + /// Rebuild `entries` from `doc` via [`collect_entries_with_prev`] and + /// recompute the parent → children linkage for tree mode. Selection/anchor + /// survive by `(key, saved_path)` identity; dead indices are dropped. + /// + /// `unloaded`/`prev` are the active tab's session sets, so CLI and + /// palette agree. Returns fresh `(key, mtime)` baselines for every + /// `Loaded` entry — the caller writes them into the tab's stat cache, + /// which makes `Stale` detectable from the second refresh on. + /// Wasm-safe by construction: the only I/O is `collect_entries`' path + /// stat plus `load_file` for nested enumeration, both already wasm-safe. + pub fn refresh( + &mut self, + doc: &CadDocument, + base_dir: &Path, + unloaded: &HashSet, + prev: &HashMap, + host_name: &str, + host_path: &str, + ) -> Vec<(u64, SystemTime)> { + self.host_name = host_name.to_string(); + self.host_path = host_path.to_string(); + let old = std::mem::take(&mut self.entries); + let sel_ids: HashSet<(u64, String)> = self + .selected + .iter() + .filter_map(|&i| old.get(i)) + .map(|e| (e.key, e.saved_path.clone())) + .collect(); + let anchor_id: Option<(u64, String)> = self + .anchor + .and_then(|i| old.get(i)) + .map(|e| (e.key, e.saved_path.clone())); + + let entries = collect_entries_with_prev(doc, base_dir, unloaded, prev); + // Direct references, keyed by (handle, saved path) exactly as + // `collect_entries` emits them. The saved path disambiguates a nested + // entry whose foreign handle happens to collide with a host handle. + let direct_ids = direct_identities(doc); + let mut nested = HashSet::new(); + for (i, e) in entries.iter().enumerate() { + if !direct_ids.contains(&(e.key, e.saved_path.clone())) { + nested.insert(i); + } + } + // Parent linkage: each loaded drawing root's file is opened read-only + // and its direct reference paths are matched (by lexical identity) + // against nested entries. First parent wins; unmatchable nested + // entries stay unclaimed and render as roots so nothing vanishes. + let mut children: HashMap> = HashMap::new(); + let mut claimed: HashSet = HashSet::new(); + let direct_order: Vec = entries + .iter() + .enumerate() + .filter(|(i, _)| !nested.contains(i)) + .map(|(i, _)| i) + .collect(); + for &pi in &direct_order { + let parent = &entries[pi]; + if parent.kind != RefKind::DwgXref + || parent.status != RefStatus::Loaded + || parent.found_at.is_none() + { + continue; + } + let found = parent.found_at.as_deref().unwrap_or(""); + let child_paths: HashSet = match crate::io::load_file(Path::new(found)) { + Ok(nested_doc) => nested_doc + .block_records + .iter() + .filter(|br| br.flags.is_xref || br.flags.is_xref_overlay) + .map(|br| normalize_lexical(&br.xref_path)) + .collect(), + Err(_) => continue, + }; + for (ci, child) in entries.iter().enumerate() { + if !nested.contains(&ci) || claimed.contains(&ci) { + continue; + } + if child_paths.contains(&normalize_lexical(&child.saved_path)) { + children.entry(parent.key).or_default().push(ci); + claimed.insert(ci); + } + } + } + + let live_keys: HashSet = entries.iter().map(|e| e.key).collect(); + self.entries = entries; + self.nested = nested; + self.children = children; + self.selected = self + .entries + .iter() + .enumerate() + .filter(|(_, e)| sel_ids.contains(&(e.key, e.saved_path.clone()))) + .map(|(i, _)| i) + .collect(); + self.anchor = anchor_id.and_then(|(k, p)| { + self.entries + .iter() + .position(|e| e.key == k && e.saved_path == p) + }); + // Previews decode here and on selection change (view stays pure): + // the anchor entry only, and only under single selection. + self.previews.clear(); + self.ensure_preview_for_anchor(); + self.expanded.retain(|k| live_keys.contains(k)); + self.entries + .iter() + .filter(|e| e.status == RefStatus::Loaded) + .filter_map(|e| e.modified.map(|m| (e.key, m))) + .collect() + } + + /// Click handling for row picks with normal GUI list semantics: + /// - plain click selects exactly one row (previous selection clears); + /// - Ctrl/Cmd-click toggles one row; + /// - Shift-click extends a contiguous range from the anchor (or the + /// clicked row when there is no anchor yet). + /// Tree mode always single-selects per spec, whatever the modifiers. + pub fn click_select(&mut self, index: usize, extend: SelectExtend) { + if index == HOST_ROW || index >= self.entries.len() { + return; + } + if self.tree { + // Tree view selects a single file reference at a time. + if self.selected.contains(&index) { + return; + } + self.selected.clear(); + self.selected.insert(index); + self.anchor = Some(index); + self.ensure_preview_for_anchor(); + return; + } + match extend { + SelectExtend::Single => { + self.selected.clear(); + self.selected.insert(index); + self.anchor = Some(index); + } + SelectExtend::Toggle => { + if !self.selected.remove(&index) { + self.selected.insert(index); + self.anchor = Some(index); + } else if self.anchor == Some(index) { + self.anchor = self.selected.iter().copied().max(); + } + } + SelectExtend::Range => { + // The anchor stays fixed across extends: each Shift-click + // re-ranges from the original anchor (plain click moves it). + // Moving it every time collapsed the range and dropped rows. + let from = self.anchor.unwrap_or(index); + let (lo, hi) = if from <= index { (from, index) } else { (index, from) }; + self.selected.clear(); + self.selected + .extend((lo..=hi).filter(|i| *i < self.entries.len())); + if self.anchor.is_none() { + self.anchor = Some(index); + } + } + } + self.ensure_preview_for_anchor(); + } + + /// Legacy toggle entry point: Ctrl-style toggle in list mode, single + /// select in tree mode. Kept for tests and non-pointer callers. + pub fn toggle_select(&mut self, index: usize) { + self.click_select(index, SelectExtend::Toggle); + } + + /// Right-click selection: select the row alone when it is not already + /// selected (standard right-click behavior — the menu then acts on the + /// selection, which now includes this row). Keeps multi-selections when + /// right-clicking inside them. + pub fn right_click_select(&mut self, index: usize) { + if index == HOST_ROW || index >= self.entries.len() { + return; + } + if !self.selected.contains(&index) { + self.click_select(index, SelectExtend::Single); + } else { + self.ensure_preview_for_anchor(); + } + } + + /// Flip list/tree presentation. + pub fn toggle_tree(&mut self) { + self.tree = !self.tree; + } + + fn ensure_preview_for_anchor(&mut self) { + if self.selected.len() > 1 { + return; + } + let Some(a) = self.anchor.and_then(|i| self.entries.get(i)) else { + return; + }; + let Some(found) = a.found_at.clone() else { + return; + }; + let key = (a.key, found.clone()); + if self.previews.contains_key(&key) { + return; + } + if let Some(img) = reference_preview(a) { + let (w, h) = (img.width(), img.height()); + self.previews + .insert(key, iced::widget::image::Handle::from_rgba(w, h, img.into_raw())); + } + } + + /// Total table content width: column widths plus inter-column gutters. + /// Header, rows, and grab zones all share this geometry so columns align. + pub fn table_content_width(&self) -> f32 { + (self.col_widths.iter().sum::() + COL_GUTTER * 5.0).max(TABLE_MIN_W) + } + + /// Apply a horizontal divider drag to column `i` (clamped). Called from + /// the dock drag handler with the pointer delta. + pub fn drag_col_by(&mut self, i: usize, dx: f32) { + if let Some(w) = self.col_widths.get_mut(i) { + *w = (*w + dx).clamp(COL_MIN_W, COL_MAX_W); + } + } + + /// Apply a vertical split-divider drag to the table area height + /// (clamped). Called with the pointer delta. + pub fn drag_table_by(&mut self, dy: f32) { + self.table_h = (self.table_h + dy).clamp(TABLE_MIN_H, 1200.0); + } + + /// Expand/collapse one tree parent (no-op for unknown keys). + pub fn toggle_expand(&mut self, parent_key: u64) { + if !self.expanded.remove(&parent_key) && self.children.contains_key(&parent_key) { + self.expanded.insert(parent_key); + } + } + + /// True when the selection includes at least one nested row. Nested rows + /// stay read-only — every mutating button disables with a reason then. + pub fn selection_has_nested(&self) -> bool { + self.selected.iter().any(|i| self.nested.contains(i)) + } + + /// Selected entry indices that are directly actionable (nested rows + /// excluded — they have no host definition to mutate). + pub fn actionable_selection(&self) -> Vec { + let mut out: Vec = self + .selected + .iter() + .copied() + .filter(|i| *i < self.entries.len() && !self.nested.contains(i)) + .collect(); + out.sort_unstable(); + out + } + + /// Flat render order for the current mode. Tree mode emits roots + /// (direct entries plus unclaimed nested ones) with expanded children + /// inline, skipping repeats by normalized saved path so a cyclic closure + /// can never recurse on screen. + pub fn display_rows(&self) -> Vec { + // The host drawing leads in both modes (list first row, tree root). + let host = DisplayRow { + index: HOST_ROW, + depth: 0, + is_nested: false, + }; + if !self.tree { + let mut rows = vec![host]; + rows.extend(self.entries.iter().enumerate().map(|(index, _)| { + DisplayRow { + index, + depth: 0, + is_nested: self.nested.contains(&index), + } + })); + return rows; + } + fn append_tree( + panel: &XrefManagerPanel, + index: usize, + depth: u32, + seen: &mut HashSet<(String, String)>, + rows: &mut Vec, + ) { + let Some(entry) = panel.entries.get(index) else { return; }; + if !claim_path(seen, &entry.saved_path, &entry.name) { + return; + } + rows.push(DisplayRow { + index, + depth, + is_nested: panel.nested.contains(&index), + }); + if !panel.expanded.contains(&entry.key) { + return; + } + if let Some(children) = panel.children.get(&entry.key) { + for &child in children { + append_tree(panel, child, depth + 1, seen, rows); + } + } + } + + let mut rows = Vec::new(); + let mut seen: HashSet<(String, String)> = HashSet::new(); + // Roots first, then recursively expanded descendants. + for (index, _) in self.entries.iter().enumerate() { + let dominated = self + .children + .values() + .any(|kids| kids.contains(&index)); + if self.nested.contains(&index) && dominated { + continue; + } + append_tree(self, index, 0, &mut seen, &mut rows); + } + // The host drawing leads: first list row, tree root. In tree mode + // everything below renders one level deeper as its descendants. + if self.tree { + for row in rows.iter_mut() { + row.depth += 1; + } + } + rows.insert( + 0, + DisplayRow { + index: HOST_ROW, + depth: 0, + is_nested: false, + }, + ); + rows + } + + /// Render the palette as a docked side panel (EXTERNALREFERENCES). + /// + /// `missing` is the active tab's open-time NotFound count — a neutral + /// notice renders while non-zero. Mirrors the block palette's dock + /// chrome (title, pin, close) and sizing conventions. + pub fn view<'a>( + &'a self, + width: f32, + auto_collapse: bool, + missing: usize, + doc: &'a CadDocument, + ) -> Element<'a, Message> { + use crate::ui::dock::{DockMsg, PanelId}; + // ── Dock chrome (title, pin, close) — matches the block palette ── + let pin_icon = if auto_collapse { + crate::ui::icons::themed_primary_weak_text(crate::ui::icons::PIN, 12.0) + } else { + crate::ui::icons::themed_secondary(crate::ui::icons::PIN, 12.0) + }; + let pin = button(pin_icon) + .on_press(Message::Dock(DockMsg::AutoCollapseToggle( + PanelId::ExternalReferences, + ))) + .style(move |theme: &Theme, status| { + let mut style = button::subtle(theme, status); + if auto_collapse { + let palette = theme.palette(); + style.background = Some(Background::Color(palette.primary.weak.color)); + style.text_color = palette.primary.weak.text; + style.border.color = palette.primary.base.color; + style.border.width = 1.0; + } + style + }) + .padding([3, 5]); + let pin = tooltip(pin, text("Auto").size(10), tooltip::Position::Bottom).gap(4); + let close = button(crate::ui::icons::themed_secondary(crate::ui::icons::CLOSE, 12.0)) + .on_press(Message::Dock(DockMsg::Close(PanelId::ExternalReferences))) + .style(button::subtle) + .padding([3, 5]); + let close = tooltip(close, text("Close").size(10), tooltip::Position::Bottom).gap(4); + let title_bar = mouse_area( + container( + row![ + text(format!( + "{} ({})", + crate::t!("External References").as_ref(), + self.entries.len() + )) + .size(12), + iced::widget::Space::new().width(Fill), + pin, + close, + ] + .spacing(3) + .align_y(iced::Center), + ) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + ..Default::default() + }) + .width(Fill) + .padding([3, 6]), + ) + .on_press(Message::Dock(DockMsg::DockGrab(PanelId::ExternalReferences))) + .interaction(iced::mouse::Interaction::Grab); + // Table content width: column widths plus gutters, stretched to the + // dock when wider so rows fill the panel; narrower docks sidescroll. + let table_w = (width - 16.0).max(self.table_content_width()); + // ── Toolbar: split-button groups per spec ─────────────────────── + // Attach ▾ (default DWG), Refresh ▾ (default Refresh), Change Path ▾. + // The main button runs the default; the triangle opens the rest in an + // overlay menu. Formats without an attach command in this build + // (DWF/DGN/point clouds/coordination models) are omitted, not dead. + let web_tip = crate::t!("File attach is not available on web — the reference list below is read-only.").into_owned(); + let attach = if IS_WASM { + toolbar_tip(crate::t!("Attach DWG").into_owned(), web_tip.clone(), false) + } else { + split_button( + crate::t!("Attach DWG").into_owned(), + Some(Message::XAttachPick), + self.attach_open, + Message::XrefManagerAttachMenu, + vec![ + menu_item( + crate::t!("Attach Image").into_owned(), + Some(Message::ImagePick), + None, + ), + menu_item( + crate::t!("Embed Image (in drawing)").into_owned(), + Some(Message::ImageEmbedPick), + None, + ), + menu_item( + crate::t!("Attach PDF").into_owned(), + Some(Message::PdfAttachPick), + None, + ), + ], + ) + }; + let refresh = if IS_WASM { + toolbar_tip(crate::t!("Refresh").into_owned(), web_tip.clone(), false) + } else { + split_button( + crate::t!("Refresh").into_owned(), + Some(Message::XrefManagerRefresh), + self.refresh_open, + Message::XrefManagerRefreshMenu, + vec![menu_item( + crate::t!("Reload All References").into_owned(), + Some(Message::XrefManagerReloadAll), + None, + )], + ) + }; + // List / Tree view buttons with active-state coloring: the current + // mode renders in the primary accent (blue), the other is clickable. + let mode_btn = |label: String, active: bool| -> Element<'static, Message> { + let msg = if active { + None + } else { + Some(Message::XrefManagerToggleTree) + }; + let mut b = button(text(label).size(11)) + .style(move |theme: &Theme, status| { + let palette = theme.palette(); + if active { + button::Style { + background: Some(Background::Color(palette.primary.base.color)), + border: Border { + radius: 3.0.into(), + color: palette.primary.base.color, + width: 1.0, + }, + text_color: palette.primary.base.text, + ..Default::default() + } + } else { + let pair = match status { + button::Status::Hovered | button::Status::Pressed => { + palette.background.strong + } + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + border: Border { + radius: 3.0.into(), + color: palette.background.neutral.color, + width: 1.0, + }, + text_color: pair.text, + ..Default::default() + } + } + }) + .padding([4, 10]); + if let Some(m) = msg { + b = b.on_press(m); + } + b.into() + }; + let list_btn = mode_btn(crate::t!("List").into_owned(), !self.tree); + let tree_btn = mode_btn(crate::t!("Tree").into_owned(), self.tree); + // Reference operations (Detach/Unload/Reload/Bind/Overlay/Attach) + // live in the per-row right-click menu, not the toolbar. + // Change Path group: greyed until a reference whose path can change + // (a direct, non-nested row) is selected. Each option carries its own + // gate so non-executable choices render greyed with the reason. + let has_direct = self + .entries + .iter() + .enumerate() + .any(|(i, _)| self.selected.contains(&i) && !self.nested.contains(&i)); + let single_direct_anchor = self.anchor.is_some_and(|a| { + self.selected.len() == 1 + && self.entries.get(a).is_some() + && !self.nested.contains(&a) + }); + let host_saved = !self.host_path.is_empty(); + let web_readonly: Option = IS_WASM.then(|| { + crate::t!("Reference changes are not available on web — the reference list is read-only.").into_owned() + }); + let path_group_gate: Option = web_readonly.clone().or_else(|| { + if has_direct { + None + } else if self.selected.is_empty() { + Some(crate::t!("Select a reference first.").into_owned()) + } else { + Some( + crate::t!("Nested references are read-only — edit them in their host drawing.") + .into_owned(), + ) + } + }); + let change_path = match path_group_gate { + Some(reason) => toolbar_tip(crate::t!("Change Path").into_owned(), reason, false), + None => { + let relative_gate = if host_saved { + None + } else { + Some( + crate::t!("XREF Save the drawing first to resolve relative XREF paths.") + .into_owned(), + ) + }; + let new_path_gate = if single_direct_anchor { + None + } else { + Some(crate::t!("Select a single reference first.").into_owned()) + }; + split_button( + crate::t!("Change Path").into_owned(), + Some(Message::XrefManagerPathMenu), + self.path_open, + Message::XrefManagerPathMenu, + vec![ + menu_item( + crate::t!("Make Absolute").into_owned(), + Some(Message::XrefManagerOp(XrefPaletteOp::Pathtype( + Pathtype::Full, + ))), + None, + ), + menu_item( + crate::t!("Make Relative").into_owned(), + Some(Message::XrefManagerOp(XrefPaletteOp::Pathtype( + Pathtype::Relative, + ))), + relative_gate, + ), + menu_item( + crate::t!("Remove Path").into_owned(), + Some(Message::XrefManagerOp(XrefPaletteOp::Pathtype( + Pathtype::None, + ))), + None, + ), + menu_item( + crate::t!("Select New Path").into_owned(), + Some(Message::XrefPathPick), + new_path_gate, + ), + menu_item( + crate::t!("Find and Replace").into_owned(), + Some(Message::XrefFindReplacePrompt), + None, + ), + ], + ) + } + }; + // Help opens a modal window explaining the manager (and its web + // shortcomings): the old disabled tip could never be clicked, and + // an anchored dropdown squeezes the text into the toolbar's width. + let help = toolbar_btn( + crate::t!("Help").into_owned(), + Some(Message::XrefHelpOpen), + false, + ); + let toolbar = container( + row![ + attach, + refresh, + change_path, + help + ] + .spacing(4) + .align_y(iced::Center), + ) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.weakest.color, + )), + ..Default::default() + }) + .width(Fill) + .padding([4, 8]); + + // ── File References pane title ────────────────────────────────── + // List / Tree live on this row, right-aligned. + let table_title: Element<'_, Message> = container( + row![ + text(format!("{} :", crate::t!("File References").as_ref())) + .size(11) + .width(Fill), + list_btn, + tree_btn, + ] + .spacing(4) + .align_y(iced::Center), + ) + .width(Fill) + .padding(Padding { + top: 4.0, + right: 8.0, + bottom: 0.0, + left: 8.0, + }) + .into(); + // Each divider between header cells grabs for a width drag + // (`XrefColGrab(i)` resizes column `i`); mirrors the Layers + // Name-column divider. + let mut header_row = row![].spacing(0).align_y(iced::Center); + let headers = [ + crate::t!("Reference").into_owned(), + crate::t!("Status").into_owned(), + crate::t!("Size").into_owned(), + crate::t!("Type").into_owned(), + crate::t!("Date").into_owned(), + crate::t!("Saved Path").into_owned(), + ]; + for (i, label) in headers.into_iter().enumerate() { + header_row = header_row.push( + text(label) + .size(10) + .style(muted_style) + .width(Length::Fixed(self.col_widths[i])), + ); + if i + 1 < self.col_widths.len() { + let grab = mouse_area( + container(iced::widget::Space::new().width(Length::Fixed(6.0))) + .width(Length::Fixed(6.0)) + .height(Length::Fixed(ROW_H * 0.7)), + ) + .on_press(Message::XrefColGrab(i)) + .interaction(iced::mouse::Interaction::ResizingHorizontally); + header_row = header_row.push(grab); + } + } + let col_header: Element<'_, Message> = mouse_area( + container(header_row.width(Length::Fixed(table_w))) + .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: 0.0.into(), + }, + ..Default::default() + } + }) + .padding([4, 8]) + .width(Fill), + ) + .on_move(|p| Message::XrefColMove(p)) + .on_release(Message::XrefColRelease) + .into(); + + // ── Reference rows ──────────────────────────────────────────────── + let mut rows_col = column![].spacing(0); + for display in self.display_rows() { + if display.index == HOST_ROW { + rows_col = rows_col.push(host_row( + display, + &self.host_name, + &self.host_path, + table_w, + &self.col_widths, + )); + continue; + } + let Some(entry) = self.entries.get(display.index) else { + continue; + }; + let is_sel = self.selected.contains(&display.index); + let kids = self.children.get(&entry.key); + let has_children = kids.is_some_and(|k| !k.is_empty()); + let is_expanded = has_children && self.expanded.contains(&entry.key); + rows_col = rows_col.push(xref_row( + display, + entry, + is_sel, + self.tree && has_children, + is_expanded, + table_w, + &self.col_widths, + self.row_change_path_open, + )); + } + // ── Reference table: header + rows scroll together. A horizontal + // outer scrollable carries a fixed-width column; the inner vertical + // scrollable pages rows. (A single Both-direction scrollable renders + // blank content in this iced rev — verified by screenshot — so the + // axes stay split across nested scrollables.) + let table_rows: Element<'_, Message> = if self.entries.is_empty() { + container( + text(crate::t!("XREF No external references in this drawing.")) + .size(12) + .color(iced::Color { + r: 0.55, + g: 0.55, + b: 0.55, + a: 1.0, + }), + ) + .center_x(Fill) + .center_y(Fill) + .width(Fill) + .height(Length::Fixed(self.table_h)) + .into() + } else if self.tree { + // Tree view replaces the table: icon + name rows in nesting + // order, no columns. Single-select still applies. + let mut tree_col = column![].spacing(0); + for display in self.display_rows() { + if display.index == HOST_ROW { + tree_col = tree_col.push(tree_host_row(display, &self.host_name)); + continue; + } + let Some(entry) = self.entries.get(display.index) else { + continue; + }; + let is_sel = self.selected.contains(&display.index); + let kids = self.children.get(&entry.key); + let has_children = kids.is_some_and(|k| !k.is_empty()); + let is_expanded = has_children && self.expanded.contains(&entry.key); + tree_col = tree_col.push(tree_row( + display, + entry, + is_sel, + has_children, + is_expanded, + self.row_change_path_open, + )); + } + scrollable(tree_col).height(Length::Fixed(self.table_h)).into() + } else { + scrollable( + column![ + col_header, + scrollable(rows_col).height(Length::Fixed(self.table_h)), + ] + .width(Length::Fixed(table_w)), + ) + .direction(iced::widget::scrollable::Direction::Horizontal( + iced::widget::scrollable::Scrollbar::new(), + )) + .height(Length::Fixed(self.table_h)) + .into() + }; + // Thin external padding + darkest surface around the table, like the + // properties panel content. + let table_rows: Element<'_, Message> = container(table_rows) + .padding(4) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.weakest.color, + )), + ..Default::default() + }) + .width(Fill) + .into(); + + // ── Missing-on-open notice + details pane ───────────────────────── + let notice: Option> = if missing > 0 { + Some( + container(text(crate::tf!( + "{} reference(s) not found — open the reference manager with EXTERNALREFERENCES.", + missing + ))) + .padding([6, 8]) + .width(Fill) + .into(), + ) + } else { + None + }; + // ── Details / Preview lower pane ──────────────────────────────── + // Toggle buttons mirror the spec's Details/Preview switch. Preview is + // a placeholder in v1 (no thumbnail pipeline): a grey field, or + // "Preview not available" — never a broken image control. + let details_tab = toolbar_btn( + crate::t!("Details").into_owned(), + if self.show_preview { + Some(Message::XrefManagerTogglePreview) + } else { + None + }, + false, + ); + let preview_tab = toolbar_btn( + crate::t!("Preview").into_owned(), + if self.show_preview { + None + } else { + Some(Message::XrefManagerTogglePreview) + }, + false, + ); + let pane_tabs = row![details_tab, preview_tab] + .spacing(4) + .align_y(iced::Center); + let lower: Element<'_, Message> = if self.show_preview { + // Single selection → the decoded image, or "Preview not + // available" (DXF/PDF/unresolvable). Anything else → the spec's + // solid grey field. + let single = self.anchor.and_then(|i| { + (self.selected.len() <= 1) + .then(|| self.entries.get(i)) + .flatten() + }); + let preview_body: Element<'_, Message> = match single { + Some(e) => { + let cached = e.found_at.as_deref().and_then(|found| { + self.previews.get(&(e.key, found.to_string())) + }); + match cached { + Some(handle) => container( + iced::widget::image(handle.clone()) + .width(Length::Fixed(220.0)), + ) + .center_x(Fill) + .center_y(Fill) + .width(Fill) + .height(Length::Fixed(120.0)) + .into(), + None => container( + column![ + text(e.name.as_str()).size(11), + text(crate::t!("Preview not available")).size(11).style(muted_style), + ] + .spacing(4) + .align_x(iced::Center), + ) + .center_x(Fill) + .center_y(Fill) + .width(Fill) + .height(Length::Fixed(120.0)) + .into(), + } + } + None => container(text("—").size(11).style(muted_style)) + .center_x(Fill) + .center_y(Fill) + .width(Fill) + .height(Length::Fixed(120.0)) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.weak.color, + )), + ..Default::default() + }) + .into(), + }; + container(column![pane_tabs, preview_body].spacing(2)) + .padding([6, 8]) + .width(Fill) + .into() + } else { + let details = + details_pane(self.anchor.and_then(|i| self.entries.get(i)), doc); + container(column![pane_tabs, details].spacing(2)) + .padding([6, 8]) + .width(Fill) + .into() + }; + + // Horizontal split divider between the table and the lower pane: + // grabs vertically to give the table more (or less) height. + let split = mouse_area( + container(iced::widget::Space::new().height(Length::Fixed(6.0))) + .width(Fill) + .height(Length::Fixed(6.0)), + ) + .on_press(Message::XrefSplitGrab) + .interaction(iced::mouse::Interaction::ResizingVertically); + let mut content = column![title_bar, toolbar, table_title, table_rows, split]; + if let Some(notice) = notice { + content = content.push(notice); + } + content = content.push(lower); + // Panel-wide move/release tracking for the split drag (mirrors the + // header divider tracking for columns). + let content: Element<'_, Message> = mouse_area(content.spacing(0)) + .on_move(|p| Message::XrefSplitMove(p)) + .on_release(Message::XrefSplitRelease) + .into(); + // Fixed to the dock width like the block palette: the panel never + // sizes itself from its (wider) table content, so the dock width + // setting — and resize — actually takes effect. + container(content) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.base.color, + )), + ..Default::default() + }) + .width(Length::Fixed(width)) + .height(Fill) + .into() + } +} + +/// `(key, saved_path)` identities for every reference the host drawing holds +/// directly — mirrors the three [`collect_entries`] scans so nested entries +/// (foreign handles) classify correctly even on handle collisions. +fn direct_identities(doc: &CadDocument) -> HashSet<(u64, String)> { + use acadrust::entities::UnderlayType; + use acadrust::objects::ObjectType; + + let mut ids: HashSet<(u64, String)> = HashSet::new(); + for br in doc.block_records.iter() { + if br.flags.is_xref || br.flags.is_xref_overlay { + ids.insert((br.handle.value(), br.xref_path.clone())); + } + } + for (handle, obj) in doc.objects.iter() { + match obj { + ObjectType::ImageDefinition(def) => { + ids.insert((handle.value(), def.file_name.clone())); + } + ObjectType::UnderlayDefinition(def) => { + if def.underlay_type == UnderlayType::Pdf { + ids.insert((handle.value(), def.file_path.clone())); + } + } + _ => {} + } + } + ids +} + +/// Claim a `(normalized saved path, entry name)` pair for display; empty +/// paths always pass, exact repeats are skipped (cycle guard). Two entries +/// sharing one file under different names both render. +fn claim_path(seen: &mut HashSet<(String, String)>, saved_path: &str, name: &str) -> bool { + if saved_path.is_empty() { + return true; + } + seen.insert((normalize_lexical(saved_path), name.to_string())) +} + +// ── Text helpers ────────────────────────────────────────────────────────── + +fn status_text(status: RefStatus) -> std::borrow::Cow<'static, str> { + match status { + RefStatus::Loaded => crate::t!("Loaded"), + RefStatus::Unloaded => crate::t!("Unloaded"), + RefStatus::NotFound => crate::t!("Not found"), + // Unreadable file: the industry palette term is Unresolved. + RefStatus::Failed => crate::t!("Unresolved"), + RefStatus::Stale => crate::t!("Stale"), + RefStatus::Orphaned => crate::t!("Orphaned"), + RefStatus::Unreferenced => crate::t!("Unreferenced"), + } +} + +/// Status icon for the list view: one distinct glyph + theme color per +/// lifecycle state, drawn from the shared monochrome chrome set so the +/// artwork stays font-independent on the web build. +fn status_icon(status: RefStatus) -> Element<'static, Message> { + use crate::ui::icons; + let (bytes, tone) = status_icon_key(status); + match tone { + StatusTone::Success => icons::themed_success(bytes, 12.0), + StatusTone::Secondary => icons::themed_secondary(bytes, 12.0), + StatusTone::Warning => icons::themed_warning(bytes, 12.0), + StatusTone::Danger => icons::themed_danger(bytes, 12.0), + } +} + +/// Icon tone: which theme color a status glyph renders in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum StatusTone { + Success, + Secondary, + Warning, + Danger, +} + +/// Pure status → (glyph bytes, tone) mapping behind [`status_icon`]. +/// Every lifecycle state resolves to a distinct pair. +fn status_icon_key(status: RefStatus) -> (&'static [u8], StatusTone) { + use crate::ui::icons; + use StatusTone as T; + match status { + RefStatus::Loaded => (icons::CHECK, T::Success), + RefStatus::Unloaded => (icons::MINUS, T::Secondary), + RefStatus::Stale => (icons::DIRTY_DOT, T::Warning), + RefStatus::NotFound => (icons::CLOSE, T::Danger), + RefStatus::Failed => (icons::DOT, T::Danger), + RefStatus::Orphaned => (icons::DOT, T::Warning), + RefStatus::Unreferenced => (icons::DOC, T::Secondary), + } +} + +fn type_text(entry: &ReferenceEntry) -> std::borrow::Cow<'static, str> { + match entry.kind { + RefKind::DwgXref => match entry.ref_type { + RefType::Attach => crate::t!("Attach"), + RefType::Overlay => crate::t!("Overlay"), + }, + // Raster images display their file format (spec: type column shows + // the image format); unknown extensions fall back to Image. + RefKind::Image => image_format(&entry.saved_path), + RefKind::Pdf => crate::t!("PDF"), + } +} + +/// Uppercase file extension of an image path (`plan.BMP` → `BMP`). +/// Extensionless or overlong suffixes fall back to `Image`. +fn image_format(saved_path: &str) -> std::borrow::Cow<'static, str> { + let file = saved_path.rsplit(['/', '\\']).next().unwrap_or(""); + let ext = file + .rsplit('.') + .next() + .filter(|_| file.contains('.')) + .unwrap_or(""); + if ext.is_empty() || ext.len() > 5 { + crate::t!("Image") + } else { + std::borrow::Cow::Owned(ext.to_ascii_uppercase()) + } +} + +fn format_size(size_bytes: Option) -> String { + match size_bytes { + None => "—".to_string(), + Some(b) if b < 1024 => format!("{b} B"), + Some(b) if b < 1024 * 1024 => format!("{:.1} KB", b as f64 / 1024.0), + Some(b) if b < 1024 * 1024 * 1024 => format!("{:.1} MB", b as f64 / 1_048_576.0), + Some(b) => format!("{:.1} GB", b as f64 / 1_073_741_824.0), + } +} + +/// Calendar date (`YYYY-MM-DD`) for a file modification time. Hand-rolled +/// civil-from-days so no date crate joins the dependency tree. +fn format_date(modified: Option) -> String { + let Some(t) = modified else { + return "—".to_string(); + }; + let secs = t + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let z = secs.div_euclid(86_400) + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy as i64 - (153 * mp as u64 + 2) as i64 / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as i64; + format!("{:04}-{:02}-{:02}", if m <= 2 { y + 1 } else { y }, m, d) +} + +// ── Widget helpers (layers.rs conventions) ──────────────────────────────── + +fn row_button_style(selected: bool, index: usize) -> impl Fn(&Theme, button::Status) -> button::Style { + move |theme: &Theme, status: button::Status| { + let palette = theme.palette(); + let highlighted = matches!(status, button::Status::Hovered); + let pair = if highlighted { + palette.background.strong + } else if selected { + palette.primary.weak + } else if index % 2 == 0 { + palette.background.base + } else { + palette.background.weak + }; + button::Style { + background: highlighted.then_some(Background::Color(pair.color)), + text_color: pair.text, + ..Default::default() + } + } +} + +fn toolbar_btn(label: String, msg: Option, fill: bool) -> Element<'static, Message> { + let mut b = button(text(label).size(11)) + .style(|theme: &Theme, status| { + let palette = theme.palette(); + let pair = match status { + button::Status::Hovered | button::Status::Pressed => { + palette.background.strong + } + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + border: Border { + radius: 3.0.into(), + color: palette.background.neutral.color, + width: 1.0, + }, + text_color: pair.text, + ..Default::default() + } + }) + .padding([4, 10]); + if fill { + b = b.width(Fill); + } + if let Some(m) = msg { + b = b.on_press(m); + } + b.into() +} + +/// Disabled toolbar affordance with an explanatory tooltip (gated +/// operations, nested-selection blocks, or web-gated mutations). +fn toolbar_tip(label: String, tip: String, fill: bool) -> Element<'static, Message> { + let b = button(text(label).size(11).style(|theme: &Theme| { + iced::widget::text::Style { + color: Some( + theme + .palette() + .background + .base + .text + .scale_alpha(0.42), + ), + } + })) + .style(|theme: &Theme, _| { + let palette = theme.palette(); + button::Style { + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + radius: 3.0.into(), + color: palette.background.neutral.color, + width: 1.0, + }, + text_color: palette.background.weak.text.scale_alpha(0.42), + ..Default::default() + } + }) + .padding([4, 10]); + let b = if fill { b.width(Fill) } else { b }; + tooltip(b, text(tip).size(11), tooltip::Position::Bottom).into() +} + +/// Split button: a default action plus a triangle that opens the rest in an +/// overlay menu (spec toolbar: Attach ▾, Refresh ▾, Change Path ▾). +/// +/// Both halves share one bordered group look: the main half keeps only the +/// left corners rounded (no right border), the caret keeps only the right +/// corners (no left border), so the pair reads as a single control. Hover +/// and press states highlight each half independently. +/// `main_msg` runs the default action; `toggle` flips this menu (closing the +/// others is handled at the message site); `items` are the menu rows, each +/// full-width. Dismissal (Escape / outside click) funnels to +/// [`Message::XrefManagerDismissMenus`]. +fn split_button( + main_label: String, + main_msg: Option, + menu_open: bool, + toggle: Message, + items: Vec>, +) -> Element<'static, Message> { + use iced::border::Radius; + // Triangle matches the main button's height and bordered style so the + // group reads as one split control: same padding, icon box fixed to the + // 11px text line height. + let half_style = |left: bool| { + move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = match status { + button::Status::Hovered | button::Status::Pressed => { + palette.background.strong + } + _ => palette.background.weak, + }; + let radius = if left { + Radius { + top_left: 3.0, + top_right: 0.0, + bottom_right: 0.0, + bottom_left: 3.0, + } + } else { + Radius { + top_left: 0.0, + top_right: 3.0, + bottom_right: 3.0, + bottom_left: 0.0, + } + }; + button::Style { + background: Some(Background::Color(pair.color)), + border: Border { + radius: radius.into(), + color: palette.background.neutral.color, + width: 1.0, + }, + text_color: pair.text, + ..Default::default() + } + } + }; + let mut main = button(text(main_label).size(11)) + .style(half_style(true)) + .padding([4, 10]); + if let Some(m) = main_msg { + main = main.on_press(m); + } + let caret = button( + container(crate::ui::icons::themed_arrow_down(9.0)) + .align_y(iced::Center) + .height(Length::Fixed(15.0)), + ) + .on_press(toggle) + .style(half_style(false)) + .padding([4, 10]); + // Overlap the halves by the 1px shared border so no double line shows + // where they meet. + let head = row![main, caret].spacing(-1.0).align_y(iced::Center); + let popup: Element<'static, Message> = container( + column(items.into_iter().map(|item| { + container(item).width(Fill).into() + })) + .spacing(2) + .padding(4), + ) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.base.color, + )), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }) + .width(Length::Fixed(220.0)) + .into(); + iced_aw::DropDown::new(head, popup, menu_open) + .alignment(iced_aw::drop_down::Alignment::Bottom) + .offset(2.0) + .on_dismiss(Message::XrefManagerDismissMenus) + .into() +} + +/// One dropdown-menu row: a full-width button when the option can execute, +/// otherwise greyed text carrying the reason. +fn menu_item(label: String, msg: Option, gate: Option) -> Element<'static, Message> { + match (msg, gate) { + (Some(m), None) => toolbar_btn(label, Some(m), true), + (_, reason) => toolbar_tip(label, reason.unwrap_or_default(), true), + } +} + +/// Wrapper that publishes a right-click message before delegating to child +/// (which may capture right-clicks, e.g. `ContextMenu`). +struct RightClickArea<'a> { + child: Element<'a, Message>, + on_right_press: Message, +} + +impl<'a> RightClickArea<'a> { + pub fn new(child: impl Into>, on_right_press: Message) -> Self { + Self { + child: child.into(), + on_right_press, + } + } +} + +impl<'a> iced_core::Widget for RightClickArea<'a> { + fn diff(&mut self, tree: &mut iced_core::widget::Tree) { + tree.diff_children(std::slice::from_mut(&mut self.child)); + } + + fn size(&self) -> iced::Size { + self.child.as_widget().size() + } + + fn layout( + &mut self, + tree: &mut iced_core::widget::Tree, + renderer: &iced::Renderer, + limits: &iced_core::layout::Limits, + ) -> iced_core::layout::Node { + self.child + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + + fn update( + &mut self, + tree: &mut iced_core::widget::Tree, + event: &iced_core::Event, + layout: iced_core::Layout<'_>, + cursor: iced_core::mouse::Cursor, + renderer: &iced::Renderer, + shell: &mut iced_core::Shell<'_, Message>, + viewport: &iced::Rectangle, + ) { + if let iced_core::Event::Mouse(iced_core::mouse::Event::ButtonPressed( + iced_core::mouse::Button::Right, + )) = event + { + if cursor.is_over(layout.bounds()) { + shell.publish(self.on_right_press.clone()); + } + } + self.child.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + shell, + viewport, + ); + } + + fn mouse_interaction( + &self, + tree: &iced_core::widget::Tree, + layout: iced_core::Layout<'_>, + cursor: iced_core::mouse::Cursor, + viewport: &iced::Rectangle, + renderer: &iced::Renderer, + ) -> iced_core::mouse::Interaction { + self.child + .as_widget() + .mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer) + } + + fn operate( + &mut self, + tree: &mut iced_core::widget::Tree, + layout: iced_core::Layout<'_>, + renderer: &iced::Renderer, + operation: &mut dyn iced_core::widget::Operation, + ) { + self.child + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + + fn draw( + &self, + tree: &iced_core::widget::Tree, + renderer: &mut iced::Renderer, + theme: &Theme, + style: &iced_core::renderer::Style, + layout: iced_core::Layout<'_>, + cursor: iced_core::mouse::Cursor, + viewport: &iced::Rectangle, + ) { + self.child.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut iced_core::widget::Tree, + layout: iced_core::Layout<'b>, + renderer: &iced::Renderer, + viewport: &iced::Rectangle, + translation: iced::Vector, + ) -> Option> { + self.child.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} + +impl<'a> From> for Element<'a, Message> { + fn from(widget: RightClickArea<'a>) -> Self { + Element::new(widget) + } +} + +const MENU_ROW_H: f32 = 24.0; +const MENU_SEP_H: f32 = 5.0; +const MENU_PADDING: f32 = 4.0; +const MENU_SPACING: f32 = 1.0; + +/// One right-click menu row, styled like the model-space context menu: +/// borderless subtle text rows. Hovering a regular row dismisses any open flyout. +/// +/// NOTE: this must stay a SINGLE interactive widget (`mouse_area` alone). +/// Wrapping a `button` in a `mouse_area` here breaks click delivery inside +/// the `ContextMenu` overlay: the overlay subtree state (including a button's +/// press state) is wiped on every view rebuild, so a press → release button +/// never publishes. `on_press` fires in the press batch itself and needs no +/// saved state. +fn menu_row(label: String, msg: Message) -> Element<'static, Message> { + mouse_area( + container(text(label).size(12)) + .padding([4, 12]) + .height(Length::Fixed(MENU_ROW_H)) + .width(Fill), + ) + .on_press(msg) + .on_enter(Message::XrefRowChangePathLeave) + .interaction(iced::mouse::Interaction::Pointer) + .into() +} + +/// One flyout submenu row: does not close the flyout on hover. Same +/// single-`mouse_area` rule as [`menu_row`] — no nested `button`. +fn submenu_row(label: String, msg: Message) -> Element<'static, Message> { + mouse_area( + container(text(label).size(12)) + .padding([4, 12]) + .height(Length::Fixed(MENU_ROW_H)) + .width(Fill), + ) + .on_press(msg) + .on_enter(Message::XrefRowChangePathEnter) + .on_exit(Message::XrefRowChangePathLeave) + .interaction(iced::mouse::Interaction::Pointer) + .into() +} + +fn menu_separator() -> Element<'static, Message> { + container( + container(iced::widget::Space::new().height(Length::Fixed(1.0))) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }) + .width(Fill), + ) + .padding([2, 8]) + .height(Length::Fixed(MENU_SEP_H)) + .width(Fill) + .into() +} + +/// Per-row right-click menu. Eight items exactly: universal tools + +/// path tools. No Bind, no Attach/Overlay — those are DWG-only and +/// hidden entirely. Change Path Type shows a right triangle and a +/// second list adjacent to "Change Path Type" on hover. +fn row_menu_for(index: usize, change_path_open: bool) -> Element<'static, Message> { + let item = |label: &str, op: XrefPaletteOp| -> Element<'static, Message> { + menu_row(label.to_string(), Message::XrefRowOp(index, op)) + }; + let pathtype_item = |label: &str, pt: Pathtype| -> Element<'static, Message> { + submenu_row( + label.to_string(), + Message::XrefRowOp(index, XrefPaletteOp::Pathtype(pt)), + ) + }; + let change_path_row: Element<'static, Message> = mouse_area( + container( + row![ + text(crate::t!("Change Path Type").into_owned()).size(12).width(Fill), + crate::ui::icons::themed_arrow_right(10.0), + ] + .align_y(iced::Center) + .spacing(8), + ) + .style(move |theme: &Theme| { + let palette = theme.palette(); + let bg = if change_path_open { + Some(Background::Color(palette.background.weak.color)) + } else { + None + }; + container::Style { + background: bg, + border: Border { + radius: 2.0.into(), + ..Default::default() + }, + ..Default::default() + } + }) + .padding([4, 12]) + .height(Length::Fixed(MENU_ROW_H)) + .width(Fill), + ) + .on_enter(Message::XrefRowChangePathEnter) + .into(); + + let main: Element<'static, Message> = container( + column![ + item(&crate::t!("Open").into_owned(), XrefPaletteOp::Open), + item(&crate::t!("Attach...").into_owned(), XrefPaletteOp::Attach), + item(&crate::t!("Unload").into_owned(), XrefPaletteOp::Unload), + item(&crate::t!("Reload").into_owned(), XrefPaletteOp::Reload), + item(&crate::t!("Detach").into_owned(), XrefPaletteOp::Detach), + menu_separator(), + change_path_row, + menu_separator(), + menu_row( + crate::t!("Select New Path").into_owned(), + Message::XrefRowPathPick(index), + ), + menu_row( + crate::t!("Find and Replace...").into_owned(), + Message::XrefRowFindReplacePrompt(index), + ), + ] + .spacing(MENU_SPACING) + .padding(MENU_PADDING), + ) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.base.color, + )), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }) + .width(Length::Fixed(220.0)) + .into(); + + if !change_path_open { + return main; + } + + // Top offset so the flyout top border aligns directly with the "Change Path Type" row. + // 5 items * 24.0 + 5 * 1.0 (spacing) + 5.0 (separator) + 1.0 (spacing) + 4.0 (container padding) = 135.0 + let flyout_offset = MENU_PADDING + 5.0 * MENU_ROW_H + 5.0 * MENU_SPACING + MENU_SEP_H + MENU_SPACING; + + // Plain container on purpose: each submenu row tracks hover itself + // (`on_enter` keeps the flyout open, `on_exit` closes it). Wrapping the + // clickable rows in an outer `mouse_area` would nest interactives and + // break click delivery inside the `ContextMenu` overlay — see `menu_row`. + let flyout: Element<'static, Message> = container( + column![ + pathtype_item( + &crate::t!("Make Absolute").into_owned(), + Pathtype::Full + ), + pathtype_item( + &crate::t!("Make Relative").into_owned(), + Pathtype::Relative + ), + pathtype_item(&crate::t!("Remove Path").into_owned(), Pathtype::None), + ] + .spacing(MENU_SPACING) + .padding(MENU_PADDING), + ) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.base.color, + )), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }) + .width(Length::Fixed(180.0)) + .into(); + + let flyout_column = column![ + iced::widget::Space::new().height(Length::Fixed(flyout_offset)), + flyout, + ]; + + row![main, flyout_column] + .spacing(0) + .align_y(iced::alignment::Vertical::Top) + .into() +} + +fn xref_row<'a>( + display: DisplayRow, + entry: &'a ReferenceEntry, + is_selected: bool, + show_expand: bool, + is_expanded: bool, + table_w: f32, + cw: &[f32; 6], + change_path_open: bool, +) -> Element<'a, Message> { + let mut name = entry.name.clone(); + if display.is_nested { + name.push_str(&crate::t!(" (nested — not rendered)")); + } + let mut name_cell = row![].spacing(2).align_y(iced::Center); + if display.depth > 0 { + name_cell = name_cell.push( + iced::widget::Space::new().width(Length::Fixed(INDENT_W * display.depth as f32)), + ); + } + // Tree parents own the only interactive cell besides selection: the + // expand/collapse arrow. Nested rows never show one. + if show_expand { + let key = entry.key; + let arrow = button(crate::ui::icons::themed_arrow_toggle(is_expanded, 10.0)) + .on_press(Message::XrefManagerToggleExpand(key)) + .style(row_button_style(is_selected, display.index)) + .padding(Padding { + top: 6.0, + bottom: 6.0, + left: 2.0, + right: 2.0, + }); + name_cell = name_cell.push(arrow); + } + let name_text: Element<'_, Message> = text(name).size(FONT_SZ).into(); + name_cell = name_cell.push(name_text); + let gutter = || iced::widget::Space::new().width(Length::Fixed(COL_GUTTER)); + let status_cell: Element<'_, Message> = row![ + container(status_icon(entry.status)).align_y(iced::Center), + text(status_text(entry.status)).size(FONT_SZ).width(Fill), + ] + .spacing(4) + .align_y(iced::Center) + .width(Length::Fixed(cw[1])) + .into(); + let content = row![ + name_cell.width(Length::Fixed(cw[0])), + gutter(), + status_cell, + gutter(), + text(format_size(entry.size_bytes)).size(FONT_SZ).width(Length::Fixed(cw[2])), + gutter(), + text(type_text(entry)).size(FONT_SZ).width(Length::Fixed(cw[3])), + gutter(), + text(format_date(entry.modified)).size(FONT_SZ).width(Length::Fixed(cw[4])), + gutter(), + text(entry.saved_path.clone()).size(FONT_SZ).width(Length::Fixed(cw[5])), + ] + .spacing(0) + .width(Length::Fixed(table_w)) + .align_y(iced::Center); + + let index = display.index; + let row = mouse_area( + container(content) + .style(move |theme: &Theme| { + let palette = theme.palette(); + let pair = if is_selected { + palette.primary.weak + } else if index % 2 == 0 { + palette.background.base + } else { + palette.background.weak + }; + container::Style { + background: Some(Background::Color(pair.color)), + text_color: Some(pair.text), + ..Default::default() + } + }) + .padding(Padding { + top: 0.0, + bottom: 0.0, + left: 8.0, + right: 8.0, + }) + .height(Length::Fixed(ROW_H)) + .width(Fill), + ) + .on_press(Message::XrefManagerSelect(index)); + let cm = iced_aw::ContextMenu::new(row, move || row_menu_for(index, change_path_open)); + RightClickArea::new(cm, Message::XrefRowRightClick(index)).into() +} + +/// One tree-mode row: indent + optional expand arrow + file icon + name. +/// Same selection highlight and right-click menu as table rows. +fn tree_row( + display: DisplayRow, + entry: &ReferenceEntry, + is_selected: bool, + show_expand: bool, + is_expanded: bool, + change_path_open: bool, +) -> Element<'_, Message> { + let mut cells = row![].spacing(2).align_y(iced::Center); + cells = cells.push(tree_indent(display.depth)); + if show_expand { + let key = entry.key; + let arrow = button(crate::ui::icons::themed_arrow_toggle(is_expanded, 10.0)) + .on_press(Message::XrefManagerToggleExpand(key)) + .style(row_button_style(is_selected, display.index)) + .padding(Padding { + top: 6.0, + bottom: 6.0, + left: 2.0, + right: 2.0, + }); + cells = cells.push(arrow); + } + cells = cells.push( + container(crate::ui::icons::themed_secondary(crate::ui::icons::DOC, 14.0)) + .align_y(iced::Center), + ); + cells = cells.push(text(entry.name.clone()).size(TREE_FONT_SZ)); + let index = display.index; + let row = mouse_area( + container(cells.spacing(4).width(Fill)) + .style(move |theme: &Theme| { + let palette = theme.palette(); + let pair = if is_selected { + palette.primary.weak + } else if index % 2 == 0 { + palette.background.base + } else { + palette.background.weak + }; + container::Style { + background: Some(Background::Color(pair.color)), + text_color: Some(pair.text), + ..Default::default() + } + }) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 8.0, + right: 8.0, + }) + .width(Fill), + ) + .on_press(Message::XrefManagerSelect(index)); + let cm = iced_aw::ContextMenu::new(row, move || row_menu_for(index, change_path_open)); + RightClickArea::new(cm, Message::XrefRowRightClick(index)).into() +} + +/// Tree-mode host root: home icon + name with the current-drawing marker. +/// Never selectable, like its table counterpart. +fn tree_host_row(display: DisplayRow, host_name: &str) -> Element<'_, Message> { + let mut name = if host_name.is_empty() { + crate::t!("Untitled").into_owned() + } else { + host_name.to_string() + }; + name.push('*'); + let mut cells = row![].spacing(2).align_y(iced::Center); + cells = cells.push(tree_indent(display.depth)); + cells = cells.push( + container(crate::ui::icons::themed_home(14.0)).align_y(iced::Center), + ); + cells = cells.push(text(name).size(TREE_FONT_SZ)); + container(cells.spacing(4).width(Fill)) + .style(|theme: &Theme| { + let pair = theme.palette().background.base; + container::Style { + background: Some(Background::Color(pair.color)), + text_color: Some(pair.text), + ..Default::default() + } + }) + .padding(Padding { + top: 5.0, + bottom: 5.0, + left: 8.0, + right: 8.0, + }) + .width(Fill) + .into() +} +fn host_row<'a>(display: DisplayRow, host_name: &'a str, host_path: &'a str, table_w: f32, cw: &[f32; 6]) -> Element<'a, Message> { + let name = if host_name.is_empty() { + crate::t!("Untitled").into_owned() + } else { + host_name.to_string() + }; + let saved = if host_path.is_empty() { "—" } else { host_path }; + let mut name_cell = row![].spacing(2).align_y(iced::Center); + if display.depth > 0 { + name_cell = name_cell.push( + iced::widget::Space::new().width(Length::Fixed(INDENT_W * display.depth as f32)), + ); + } + name_cell = name_cell.push({ + let name_text: Element<'_, Message> = text(name).size(FONT_SZ).into(); + name_text + }); + let gutter = || iced::widget::Space::new().width(Length::Fixed(COL_GUTTER)); + let content = row![ + name_cell.width(Length::Fixed(cw[0])), + gutter(), + text(crate::t!("Opened")).size(FONT_SZ).width(Length::Fixed(cw[1])), + gutter(), + text("—").size(FONT_SZ).width(Length::Fixed(cw[2])), + gutter(), + text(crate::t!("Current")).size(FONT_SZ).width(Length::Fixed(cw[3])), + gutter(), + text("—").size(FONT_SZ).width(Length::Fixed(cw[4])), + gutter(), + text(saved).size(FONT_SZ).width(Length::Fixed(cw[5])), + ] + .spacing(0) + .width(Length::Fixed(table_w)) + .align_y(iced::Center); + container(content) + .style(|theme: &Theme| { + let pair = theme.palette().background.base; + container::Style { + background: Some(Background::Color(pair.color)), + text_color: Some(pair.text), + ..Default::default() + } + }) + .padding(Padding { + top: 0.0, + bottom: 0.0, + left: 8.0, + right: 8.0, + }) + .height(Length::Fixed(ROW_H)) + .width(Fill) + .into() +} + +fn details_pane<'a>(entry: Option<&'a ReferenceEntry>, doc: &'a CadDocument) -> Element<'a, Message> { + let inner: Element<'_, Message> = match entry { + None => text(crate::t!("Select a reference to inspect its details")) + .size(11) + .style(muted_style) + .into(), + Some(e) => { + let found = e.found_at.as_deref().unwrap_or("—"); + let saved = if e.saved_path.is_empty() { + "—" + } else { + e.saved_path.as_str() + }; + let size = format_size(e.size_bytes); + let date = format_date(e.modified); + let mut rows = column![ + detail_row(crate::t!("Reference"), e.name.as_str()), + detail_row(crate::t!("Status"), status_text(e.status)), + detail_row(crate::t!("Size"), size), + detail_row(crate::t!("Type"), type_text(e)), + detail_row(crate::t!("Date"), date), + detail_row(crate::t!("Found At"), found), + detail_row(crate::t!("Saved Path"), saved), + ] + .spacing(1); + // Image-specific properties (read-only). The file format exposes + // pixel dimensions and resolution units; color system / color + // depth are not stored by the format and are omitted deliberately. + if e.kind == RefKind::Image { + if let Some(def) = find_image_def(doc, e.key) { + rows = rows + .push(detail_row( + crate::t!("Pixel Width"), + format!("{}", def.size_in_pixels.0), + )) + .push(detail_row( + crate::t!("Pixel Height"), + format!("{}", def.size_in_pixels.1), + )) + .push(detail_row( + crate::t!("Resolution Unit"), + format!("{:?}", def.resolution_unit), + )); + } + if let Some((w, h)) = find_image_display_size(doc, e.key) { + rows = rows + .push(detail_row( + crate::t!("Display Width"), + format!("{:.2}", w), + )) + .push(detail_row( + crate::t!("Display Height"), + format!("{:.2}", h), + )); + } + } + rows.into() + } + }; + container( + column![ + text(crate::t!("Details")).size(10).style(muted_style), + inner, + ] + .spacing(2), + ) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .padding([6, 8]) + .width(Fill) + .into() +} + +/// Decode one entry's preview image (`None` = placeholder territory). +/// +/// Sources mirror what the formats actually carry: DWG files embed a preview +/// (read header-only via `dwg_thumbnailer`, no full parse — DXF has none, so +/// it resolves to no preview); raster images decode through the `image` +/// crate and downscale; PDFs have no rasterizer in this build. Only +/// Loaded/Stale rows resolve — Unloaded/NotFound show the placeholder by +/// design. Web builds skip filesystem decoding entirely. +fn reference_preview(entry: &ReferenceEntry) -> Option { + if IS_WASM { + return None; + } + // Any entry whose file resolves decodes — including Unloaded and + // Unreferenced rows — so previews don't depend on load state. Missing + // files fail the decode below and fall back to the placeholder. + let found = entry.found_at.as_deref().filter(|s| !s.is_empty())?; + match entry.kind { + RefKind::DwgXref => { + let ext = found + .rsplit(['/', '\\']) + .next() + .and_then(|f| f.rsplit('.').next()) + .unwrap_or("") + .to_ascii_lowercase(); + if ext != "dwg" { + return None; + } + dwg_thumbnailer::extract(std::path::Path::new(found), PREVIEW_MAX) + } + RefKind::Image => { + let img = image::open(found).ok()?; + Some(img.thumbnail(PREVIEW_MAX, PREVIEW_MAX).to_rgba8()) + } + RefKind::Pdf => None, + } +} + +/// Image definition backing a [`RefKind::Image`] entry, looked up by the +/// entry key (definition-object handle) for the Details pane extras. +fn find_image_def( + doc: &CadDocument, + key: u64, +) -> Option<&acadrust::objects::ImageDefinition> { + doc.objects.get(&acadrust::types::Handle::from(key)).and_then(|o| match o { + acadrust::objects::ObjectType::ImageDefinition(def) => Some(def), + _ => None, + }) +} + +fn find_image_display_size(doc: &CadDocument, key: u64) -> Option<(f64, f64)> { + let handle = acadrust::types::Handle::from(key); + for entity in doc.entities() { + if let acadrust::EntityType::RasterImage(img) = entity { + if img.definition_handle == Some(handle) { + let w = (img.u_vector.x.powi(2) + img.u_vector.y.powi(2) + img.u_vector.z.powi(2)).sqrt(); + let h = (img.v_vector.x.powi(2) + img.v_vector.y.powi(2) + img.v_vector.z.powi(2)).sqrt(); + return Some((w, h)); + } + } + } + None +} + +fn detail_row<'a>( + label: impl Into>, + value: impl Into>, +) -> Element<'a, Message> { + let label: std::borrow::Cow<'a, str> = label.into(); + let value: std::borrow::Cow<'a, str> = value.into(); + row![ + text(label).size(11).style(muted_style).width(Length::Fixed(84.0)), + text(value).size(11), + ] + .spacing(6) + .align_y(iced::Center) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::io::xref_model::RefKind; + + #[test] + fn size_formats_without_deps() { + assert_eq!(format_size(None), "—"); + assert_eq!(format_size(Some(0)), "0 B"); + assert_eq!(format_size(Some(900)), "900 B"); + assert_eq!(format_size(Some(2048)), "2.0 KB"); + assert_eq!(format_size(Some(5 * 1_048_576)), "5.0 MB"); + } + + #[test] + fn date_formats_known_days() { + assert_eq!(format_date(None), "—"); + let day = |days: u64| { + format_date(Some(UNIX_EPOCH + std::time::Duration::from_secs(days * 86_400))) + }; + assert_eq!(day(0), "1970-01-01"); + assert_eq!(day(10957), "2000-01-01"); + assert_eq!(day(19723), "2024-01-01"); + } + + fn entry(key: u64, name: &str, saved: &str) -> ReferenceEntry { + let mut e = ReferenceEntry::new(key, name, RefKind::DwgXref); + e.saved_path = saved.to_string(); + e + } + + #[test] + fn select_toggles_anchor_and_set() { + let mut panel = XrefManagerPanel::default(); + panel.entries = vec![entry(1, "A", "a.dwg"), entry(2, "B", "b.dwg")]; + panel.toggle_select(0); + panel.toggle_select(1); + assert!(panel.selected.contains(&0) && panel.selected.contains(&1)); + assert_eq!(panel.anchor, Some(1)); + panel.toggle_select(1); + assert!(!panel.selected.contains(&1)); + assert_eq!(panel.anchor, Some(0)); + panel.toggle_select(9); // out of range: no-op + assert_eq!(panel.selected.len(), 1); + } + + #[test] + fn tree_skips_repeat_paths() { + let mut panel = XrefManagerPanel::default(); + panel.entries = vec![ + entry(1, "A", "a.dwg"), + entry(2, "B", "b.dwg"), + entry(3, "B2", "b.dwg"), + ]; + panel.tree = true; + // Host row leads; same file under two names renders twice; only + // exact (path, name) repeats collapse. + let rows = panel.display_rows(); + assert_eq!(rows.len(), 4); + assert_eq!(rows[0].index, HOST_ROW); + assert!(rows[1..].iter().all(|r| r.depth >= 1)); + panel.entries.push(entry(4, "B", "b.dwg")); + assert_eq!(panel.display_rows().len(), 4); + // Mark B2 nested under A: A claims (a.dwg, A), B2-as-child claims + // (b.dwg, B2); the B root still renders as (b.dwg, B). + panel.nested.insert(2); + panel.children.insert(1, vec![2]); + panel.expanded.insert(1); + let rows = panel.display_rows(); + assert_eq!(rows.len(), 4); // host, A, B2-as-child-of-A, B + assert!(rows.iter().any(|r| r.index == 2 && r.is_nested)); + // A second parent claiming the same child shows nothing twice. + panel.entries.push(entry(5, "C", "c.dwg")); + panel.children.insert(5, vec![2]); + panel.expanded.insert(5); + let count = panel + .display_rows() + .iter() + .filter(|r| r.index == 2) + .count(); + assert_eq!(count, 1); + } + + #[test] + fn host_row_leads_and_ignores_selection() { + let mut panel = XrefManagerPanel::default(); + panel.entries = vec![entry(1, "A", "a.dwg")]; + panel.host_name = "host".to_string(); + for tree in [false, true] { + panel.tree = tree; + let rows = panel.display_rows(); + assert_eq!(rows[0].index, HOST_ROW); + assert_eq!(rows[0].depth, 0); + } + panel.toggle_select(HOST_ROW); // no-op, never selected + assert!(panel.selected.is_empty()); + assert_eq!(panel.anchor, None); + } + + #[test] + fn tree_selects_single_reference() { + let mut panel = XrefManagerPanel::default(); + panel.entries = vec![entry(1, "A", "a.dwg"), entry(2, "B", "b.dwg")]; + panel.tree = true; + panel.toggle_select(0); + panel.toggle_select(1); + assert_eq!(panel.selected, HashSet::from([1])); + assert_eq!(panel.anchor, Some(1)); + panel.tree = false; + panel.toggle_select(0); + assert_eq!(panel.selected, HashSet::from([0, 1])); + } + + #[test] + fn actionable_selection_skips_nested() { + let mut panel = XrefManagerPanel::default(); + panel.entries = vec![entry(1, "A", "a.dwg"), entry(2, "B", "b.dwg")]; + panel.nested.insert(1); + panel.toggle_select(0); + panel.toggle_select(1); + assert!(panel.selection_has_nested()); + // Only the direct row is actionable; the nested row stays read-only. + assert_eq!(panel.actionable_selection(), vec![0]); + panel.toggle_select(1); + assert!(!panel.selection_has_nested()); + } + + #[test] + fn column_drag_clamps_and_ignores_unknown() { + let mut panel = XrefManagerPanel::default(); + assert_eq!(panel.col_widths, super::DEFAULT_COL_WIDTHS); + panel.drag_col_by(0, 40.0); + assert_eq!(panel.col_widths[0], super::DEFAULT_COL_WIDTHS[0] + 40.0); + panel.drag_col_by(1, -1000.0); + assert_eq!(panel.col_widths[1], super::COL_MIN_W); + panel.drag_col_by(2, 10000.0); + assert_eq!(panel.col_widths[2], super::COL_MAX_W); + panel.drag_col_by(99, 10.0); // no-op, no panic + assert_eq!(panel.table_content_width(), panel.col_widths.iter().sum::() + super::COL_GUTTER * 5.0); + } + + #[test] + fn table_split_drag_clamps() { + let mut panel = XrefManagerPanel::default(); + assert_eq!(panel.table_h, super::TABLE_H); + panel.drag_table_by(60.0); + assert_eq!(panel.table_h, super::TABLE_H + 60.0); + panel.drag_table_by(-10000.0); + assert_eq!(panel.table_h, super::TABLE_MIN_H); + } + + #[test] + fn status_icons_are_unique_per_status() { + use super::{status_icon_key, RefStatus}; + let all = [ + RefStatus::Loaded, + RefStatus::Unloaded, + RefStatus::NotFound, + RefStatus::Failed, + RefStatus::Stale, + RefStatus::Orphaned, + RefStatus::Unreferenced, + ]; + let mut seen = std::collections::HashSet::new(); + for status in all { + let (bytes, tone) = status_icon_key(status); + assert!( + seen.insert((bytes.as_ptr(), tone)), + "status {status:?} shares its icon with another state" + ); + } + assert_eq!(seen.len(), all.len()); + } + + #[test] + fn image_format_reads_extension() { + assert_eq!(image_format("C:/exa/plan.BMP"), "BMP"); + assert_eq!(image_format("a/b/c.png"), "PNG"); + assert_eq!(image_format("noext"), "Image"); + assert_eq!(image_format("toolong.abcdef"), "Image"); + assert_eq!(image_format(""), "Image"); + } + + #[test] + fn click_select_single_then_range() { + use super::SelectExtend; + let mut panel = XrefManagerPanel::default(); + panel.entries = vec![ + entry(1, "A", "a.dwg"), + entry(2, "B", "b.dwg"), + entry(3, "C", "c.dwg"), + ]; + // Plain click selects exactly one row. + panel.click_select(0, SelectExtend::Single); + panel.click_select(2, SelectExtend::Single); + assert_eq!(panel.selected, HashSet::from([2])); + assert_eq!(panel.anchor, Some(2)); + // Shift-click extends a contiguous range from the anchor. + panel.click_select(2, SelectExtend::Single); + panel.click_select(0, SelectExtend::Range); + assert_eq!(panel.selected, HashSet::from([0, 1, 2])); + // Repeated extends keep the original anchor — rows are never lost. + panel.entries.push(entry(4, "D", "d.dwg")); + panel.entries.push(entry(5, "E", "e.dwg")); + panel.click_select(4, SelectExtend::Range); + assert_eq!(panel.selected, HashSet::from([2, 3, 4])); + assert_eq!(panel.anchor, Some(2)); + // Ctrl-click toggles one row. + panel.click_select(3, SelectExtend::Toggle); + assert_eq!(panel.selected, HashSet::from([2, 4])); + // Range with no anchor starts at the clicked row. + let mut fresh = XrefManagerPanel::default(); + fresh.entries = vec![entry(1, "A", "a.dwg"), entry(2, "B", "b.dwg")]; + fresh.click_select(1, SelectExtend::Range); + assert_eq!(fresh.selected, HashSet::from([1])); + } + + #[test] + fn expand_collapses_by_default() { + let mut panel = XrefManagerPanel::default(); + panel.entries = vec![entry(1, "A", "a.dwg"), entry(2, "B", "b.dwg")]; + panel.nested.insert(1); + panel.children.insert(1, vec![1]); + panel.tree = true; + assert!(!panel.display_rows().iter().any(|r| r.is_nested)); + panel.toggle_expand(1); + assert!(panel.display_rows().iter().any(|r| r.is_nested)); + panel.toggle_expand(1); + assert!(!panel.display_rows().iter().any(|r| r.is_nested)); + } + + fn preview_doc2(dir: &std::path::Path) -> acadrust::CadDocument { + // Two referenced images → genuine multi-select. + use acadrust::objects::{ImageDefinition, ObjectType}; + let mut doc = acadrust::CadDocument::new(); + for (i, name) in ["a.png", "b.png"].iter().enumerate() { + let img = image::RgbaImage::from_pixel(8, 8, image::Rgba([9, 9, 9, 255])); + img.save(dir.join(name)).unwrap(); + let path = dir.join(name).to_string_lossy().into_owned(); + let h = doc.allocate_handle(); + let mut def = ImageDefinition::with_dimensions(path.clone(), 8, 8); + def.handle = h; + doc.objects.insert(h, ObjectType::ImageDefinition(def)); + let mut ent = acadrust::entities::RasterImage::new( + &path, + acadrust::types::Vector3::ZERO, + 8.0 + i as f64, + 8.0, + ); + ent.definition_handle = Some(h); + doc.add_entity(acadrust::EntityType::RasterImage(ent)).unwrap(); + } + doc + } + + fn preview_doc(dir: &std::path::Path, name: &str) -> acadrust::CadDocument { + // Referenced 8x8 PNG under `dir`; the entry resolves Loaded. + use acadrust::objects::{ImageDefinition, ObjectType}; + let img = image::RgbaImage::from_pixel(8, 8, image::Rgba([9, 9, 9, 255])); + img.save(dir.join(name)).unwrap(); + let img_path = dir.join(name).to_string_lossy().into_owned(); + let mut doc = acadrust::CadDocument::new(); + let h = doc.allocate_handle(); + let mut def = ImageDefinition::with_dimensions(img_path.clone(), 8, 8); + def.handle = h; + doc.objects.insert(h, ObjectType::ImageDefinition(def)); + let mut ent = acadrust::entities::RasterImage::new( + &img_path, + acadrust::types::Vector3::ZERO, + 8.0, + 8.0, + ); + ent.definition_handle = Some(h); + doc.add_entity(acadrust::EntityType::RasterImage(ent)).unwrap(); + doc + } + + #[test] + fn preview_decodes_for_single_selection() { + let dir = std::env::temp_dir().join(format!( + "ocs_xref_preview_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let doc = preview_doc(&dir, "img.png"); + let mut panel = XrefManagerPanel::default(); + let empty = std::collections::HashSet::new(); + let no_prev = std::collections::HashMap::new(); + panel.refresh(&doc, &dir, &empty, &no_prev, "host", ""); + assert!(panel.previews.is_empty(), "nothing selected → no decode"); + panel.toggle_select(0); + panel.refresh(&doc, &dir, &empty, &no_prev, "host", ""); + assert_eq!(panel.previews.len(), 1, "single anchor decodes once"); + let (handle, _) = panel.previews.iter().next().unwrap(); + assert!(panel.entries.iter().any(|e| e.key == handle.0)); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn preview_skipped_for_multi_selection() { + // Spec: the preview pane shows an image for a single selected + // reference only — multi-select decodes nothing (grey field). + let dir = std::env::temp_dir().join(format!( + "ocs_xref_preview_multi_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let doc = preview_doc2(&dir); + let mut panel = XrefManagerPanel::default(); + let empty = std::collections::HashSet::new(); + let no_prev = std::collections::HashMap::new(); + panel.refresh(&doc, &dir, &empty, &no_prev, "host", ""); + // One image row exists; duplicate it as a second row to multi-select. + panel.entries.push(panel.entries[0].clone()); + panel.toggle_select(0); + panel.toggle_select(1); + assert_eq!(panel.selected.len(), 2); + panel.refresh(&doc, &dir, &empty, &no_prev, "host", ""); + assert!( + panel.previews.is_empty(), + "multi-select → grey field, no decode" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn preview_missing_without_embedded_data() { + // A DWG reference whose file has no embedded preview resolves to no + // preview (placeholder path), never an error. + let dir = std::env::temp_dir().join(format!( + "ocs_xref_preview_dwg_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("plan.dwg"), b"not-a-real-dwg").unwrap(); + let mut doc = acadrust::CadDocument::new(); + let mut br = acadrust::tables::BlockRecord::new("PLAN"); + br.flags.is_xref = true; + br.xref_path = dir.join("plan.dwg").to_string_lossy().into_owned(); + br.handle = doc.allocate_handle(); + doc.block_records.add(br).unwrap(); + let mut panel = XrefManagerPanel::default(); + let empty = std::collections::HashSet::new(); + let no_prev = std::collections::HashMap::new(); + panel.refresh(&doc, &dir, &empty, &no_prev, "host", ""); + panel.toggle_select(0); + panel.refresh(&doc, &dir, &empty, &no_prev, "host", ""); + assert!( + panel.previews.is_empty(), + "undecodable file → placeholder, no cache entry" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn preview_decodes_unloaded_entry_with_file() { + // Previews key off file resolvability, not load state: an Unloaded + // image whose file exists still decodes. + use crate::io::xref_model::UnloadKey; + let dir = std::env::temp_dir().join(format!( + "ocs_xref_preview_unloaded_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let doc = preview_doc(&dir, "img.png"); + let key = doc + .objects + .iter() + .find_map(|(h, o)| match o { + acadrust::objects::ObjectType::ImageDefinition(_) => Some(h.value()), + _ => None, + }) + .unwrap(); + let mut unloaded = std::collections::HashSet::new(); + unloaded.insert(UnloadKey::Direct(key)); + let no_prev = std::collections::HashMap::new(); + let mut panel = XrefManagerPanel::default(); + panel.refresh(&doc, &dir, &unloaded, &no_prev, "host", ""); + panel.toggle_select(0); + panel.refresh(&doc, &dir, &unloaded, &no_prev, "host", ""); + assert_eq!(panel.previews.len(), 1); + std::fs::remove_dir_all(&dir).ok(); + } + + // ── Headless click-delivery tests for the row context menu ────────── + // + // These drive the REAL iced runtime through the `iced_test` headless + // simulator — including the `iced_aw::ContextMenu` overlay — and prove + // that clicks on menu rows publish their messages. + + type MenuSimulator = iced_test::simulator::Simulator<'static, Message>; + + /// Builds the shipped row menu inside a real `ContextMenu` overlay and + /// opens it with a synthetic right-click on the underlay, mirroring a + /// user right-clicking a reference row. + fn open_row_menu(change_path_open: bool) -> MenuSimulator { + let mut ui = iced_test::simulator(iced_aw::ContextMenu::new( + text("anchor"), + move || row_menu_for(0, change_path_open), + )); + let anchor = ui.find("anchor").expect("anchor underlay present"); + let center = anchor + .visible_bounds() + .expect("anchor visible") + .center(); + ui.point_at(center); + ui.simulate([iced_core::Event::Mouse(iced::mouse::Event::ButtonPressed( + iced::mouse::Button::Right, + ))]); + ui + } + + fn published_ops(ui: MenuSimulator) -> Vec<(usize, XrefPaletteOp)> { + let mut ops = Vec::new(); + for message in ui.into_messages() { + if let Message::XrefRowOp(index, op) = message { + ops.push((index, op)); + } + } + ops + } + + #[test] + fn context_menu_row_click_delivers_op() { + let unload = crate::t!("Unload").into_owned(); + let mut ui = open_row_menu(false); + ui.click(unload.as_str()) + .expect("Unload row is clickable inside the overlay"); + assert!( + published_ops(ui).contains(&(0, XrefPaletteOp::Unload)), + "clicking Unload must publish XrefRowOp(0, Unload)" + ); + } + + #[test] + fn hover_change_path_row_requests_flyout() { + // The "Change Path Type >" row reveals the second menu on hover. + let label = crate::t!("Change Path Type").into_owned(); + let mut ui = open_row_menu(false); + let target = ui + .find(label.as_str()) + .expect("Change Path Type row present in overlay"); + let center = target + .visible_bounds() + .expect("row visible") + .center(); + ui.point_at(center); + ui.simulate([iced_core::Event::Mouse(iced::mouse::Event::CursorMoved { + position: center, + })]); + let mut saw_enter = false; + for message in ui.into_messages() { + if matches!(message, Message::XrefRowChangePathEnter) { + saw_enter = true; + } + } + assert!( + saw_enter, + "hovering Change Path Type must publish XrefRowChangePathEnter (opens the second menu)" + ); + } + + #[test] + fn context_menu_flyout_click_delivers_pathtype_op() { + let make_absolute = crate::t!("Make Absolute").into_owned(); + let mut ui = open_row_menu(true); + ui.click(make_absolute.as_str()) + .expect("flyout row is clickable inside the overlay"); + assert!( + published_ops(ui).contains(&(0, XrefPaletteOp::Pathtype(Pathtype::Full))), + "clicking Make Absolute must publish the Full-pathtype op" + ); + } + + #[test] + fn context_menu_overlay_subtree_state_is_reset_every_view() { + // Root-cause pin. `iced_aw::ContextMenu` keeps its overlay `Element` + // in the WIDGET STRUCT (`overlay_instance`), which is rebuilt on every + // `view()` — not in persistent widget `Tree` state. So `diff` takes + // the `None` arm below on every app update cycle and wipes the whole + // overlay subtree state: + // + // match self.overlay_instance.as_mut() { + // Some(overlay) => tree.children[1].diff(overlay), + // None => tree.children[1] = Tree::empty(), // <-- always hit + // } + // + // Consequence: a `button` inside the menu sets `is_pressed` on press + // and only publishes on RELEASE — but any message in between (ticks, + // hover, clock) rebuilds the view, wipes `is_pressed`, and the + // release publishes nothing ("buttons do nothing"). Menu rows must + // therefore publish on PRESS via a single `mouse_area` (see + // `menu_row`), which needs no transient state. If this test ever + // fails, iced_aw learned to persist overlay state and rows may use + // buttons again. + use iced_core::Widget; + + let mut menu = iced_aw::ContextMenu::new(text("anchor"), || row_menu_for(0, false)); + let mut tree = iced_core::widget::Tree::new( + &menu as &dyn Widget, + ); + menu.diff(&mut tree); + assert_eq!(tree.children.len(), 2); + assert!( + tree.children[1].children.is_empty(), + "fresh ContextMenu widget must start with an empty overlay subtree" + ); + } +} diff --git a/tests/block_text_draw_depth.rs b/tests/block_text_draw_depth.rs new file mode 100644 index 000000000..691aac58c --- /dev/null +++ b/tests/block_text_draw_depth.rs @@ -0,0 +1,149 @@ +// Regression: text INSIDE a block must reach the GPU at neutral vertex depth +// with its composed child rank carried in `WireModel::depth_override`, so the +// upload resolves it against the insert's scene-graph entry +// (depths[insert] + override * half) — the same composition the wipeout/hatch +// fills get. Baking the raw per-block rank into the vertices instead sinks +// text with a negative rank below its own block's wipes (the "G-3" mark +// labels of BS33-11) and catapults text with a positive rank above later +// draw-order geometry. +// +// The scene mirrors the real-world shape of the bug: a block whose children +// are [wipeout, line, text], inserted into a drawing big enough that the +// insert's child band is as fine-grained as a real one. +use acadrust::entities::{Insert, Text, Wipeout}; +use acadrust::tables::BlockRecord; +use acadrust::types::Vector3; +use acadrust::EntityType; +use OpenCADStudio::scene::cache::block_cache::{expand_insert, BlockCache}; +use OpenCADStudio::scene::view::render::InheritStyle; +use OpenCADStudio::scene::Scene; +use OpenCADStudio::scene::WireModel; + +const DRAW_ORDER_BIAS: f32 = 0.001; +const DEPTH_QUANTA: f32 = 16_777_216.0; // 2^24 (Depth24Plus) + +#[test] +fn block_text_depth_composes_through_the_instance_path() { + let mut scene = Scene::new(); + + let br_h = acadrust::Handle::new(scene.document.next_handle()); + let mut br = BlockRecord::new("MARK"); + br.handle = br_h; + scene.document.block_records.add(br).unwrap(); + + // Child 0: the wipeout the text is drawn after (the text must win). + let mut wipe = Wipeout::new(); + wipe.insertion_point = Vector3::new(-10.0, -10.0, 0.0); + wipe.u_vector = Vector3::new(20.0, 0.0, 0.0); + wipe.v_vector = Vector3::new(0.0, 20.0, 0.0); + let mut wipe_e = EntityType::Wipeout(wipe); + wipe_e.common_mut().owner_handle = br_h; + let wipe_handle = scene.document.add_entity(wipe_e).unwrap(); + + // Child 1: a spacer so the text sits two sibling steps above the wipe, + // the same gap as the real BS33-11 mark blocks. + let mut spacer = acadrust::entities::Line::default(); + spacer.start = Vector3::new(-10.0, -10.0, 0.0); + spacer.end = Vector3::new(10.0, 10.0, 0.0); + let mut spacer_e = EntityType::Line(spacer); + spacer_e.common_mut().owner_handle = br_h; + let _ = scene.document.add_entity(spacer_e).unwrap(); + + // Child 2: the text. + let mut txt = Text::new(); + txt.value = "G-3".to_string(); + txt.height = 2.5; + txt.insertion_point = Vector3::new(0.0, 0.0, 0.0); + let mut txt_e = EntityType::Text(txt); + txt_e.common_mut().owner_handle = br_h; + let text_handle = scene.document.add_entity(txt_e).unwrap(); + + // A large model space, so the insert's child band compresses to the + // fine granularity real drawings have (~600 top-level entities puts the + // per-sibling spacing at a handful of 24-bit quanta, where an oversized + // wipeout depth bias would swallow it). + for i in 0..600 { + let mut line = acadrust::entities::Line::default(); + line.start = Vector3::new(f64::from(i) * 10.0 - 3000.0, -50.0, 0.0); + line.end = Vector3::new(f64::from(i) * 10.0 - 3000.0, 50.0, 0.0); + let _ = scene.document.add_entity(EntityType::Line(line)); + } + + let ins = Insert::new("MARK", Vector3::new(0.0, 0.0, 0.0)); + let ins_handle = scene.add_entity(EntityType::Insert(ins)); + + let depths = scene.draw_depth_map(); + let [ins_d, ins_half] = depths + .get(&ins_handle.value()) + .copied() + .expect("insert must be ranked"); + let text_label = depths + .get(&text_handle.value()) + .expect("block children must be ranked")[0]; + let wipe_label = depths.get(&wipe_handle.value()).expect("wipe ranked")[0]; + assert!( + text_label > wipe_label, + "precondition: the drawing orders the text after the wipeout" + ); + + let cache = BlockCache::build( + &scene.document, 1.0, None, true, [0.0, 0.0, 0.0, 1.0], None, &depths, + ); + let wires = expand_insert( + &scene.document, &cache, &Insert::new("MARK", Vector3::new(0.0, 0.0, 0.0)), + ins_handle, + [1.0, 1.0, 1.0, 1.0], 0, 0.0, [0.0; 8], 1.0, + InheritStyle { color: [1.0, 1.0, 1.0, 1.0], pat_len: 0.0, pat: [0.0; 8], lw_px: 1.0 }, + 0, false, false, 1.0, None, None, false, [0.0, 0.0, 0.0, 1.0], 1.0, + OpenCADStudio::scene::BlockScalePolicy::FromInsert, false, + ) + .expect("block defn cached"); + + let text_wires: Vec<&WireModel> = + wires.iter().filter(|w| !w.text_verts.is_empty()).collect(); + assert!( + !text_wires.is_empty(), + "block TEXT must tessellate into glyph quads" + ); + + // The wire is named after its insert (decimal handle), and the upload + // resolves that name against the depth map: this is the composition the + // GPU finally sees. + for w in &text_wires { + assert!( + w.text_verts.iter().all(|v| v.draw_depth == 0.0), + "glyph vertices must stay at neutral depth; the instance path owns it" + ); + assert_eq!( + w.depth_override, + Some(text_label), + "the wire must carry the text's child rank as its depth override" + ); + let resolved = w + .name + .parse::() + .ok() + .and_then(|h| depths.get(&h).copied()); + let instance = match (resolved, w.depth_override) { + (Some([d, half]), Some(local)) => d + local * half, + (Some([d, _]), None) => d, + _ => 0.0, + }; + assert_eq!( + instance, + ins_d + text_label * ins_half, + "instance depth must compose to depths[insert] + label * half" + ); + + // The wipeout mask, composed the same way the scene graph does it. + let mask = ins_d + wipe_label * ins_half; + let margin_quanta = + (instance - mask) * DRAW_ORDER_BIAS * DEPTH_QUANTA; + let bias = OpenCADStudio::scene::pipeline::WIPEOUT_DEPTH_BIAS_QUANTA as f32; + assert!( + margin_quanta > bias + 2.0, + "text drawn after its block's wipeout must clear it by more than \ + the wipeout depth bias plus headroom, got {margin_quanta} quanta" + ); + } +} diff --git a/tests/fixtures/dim_assoc/README.md b/tests/fixtures/dim_assoc/README.md new file mode 100644 index 000000000..4a01c475d --- /dev/null +++ b/tests/fixtures/dim_assoc/README.md @@ -0,0 +1,10 @@ +# Viewport association fixture + +`viewport_associations.dxf` contains line, circle, arc, polyline, spline, +block-instance, and intersection references through a 1:2 viewport. +`src/app/viewport_dimension_tests.rs` tests refresh and intersection source edits. + +Each dimension stores `DIMLFAC=-2` and `ACAD_DIMASSOC_CALC_DIMLFAC=-2` so its paper +definition points measure in model units. The spline tangent from `(0, 300)` +touches `(90, 283.125)` at parameter `0.75`; tests verify its tangency and preserve +the dimension if an edited reference cannot resolve. diff --git a/tests/fixtures/dim_assoc/viewport_associations.dxf b/tests/fixtures/dim_assoc/viewport_associations.dxf new file mode 100644 index 000000000..dcb198569 --- /dev/null +++ b/tests/fixtures/dim_assoc/viewport_associations.dxf @@ -0,0 +1,1746 @@ +0 +SECTION +2 +HEADER +9 +$ACADVER +1 +AC1021 +9 +$HANDSEED +5 +FFFF +9 +$INSBASE +10 +0.0 +20 +0.0 +30 +0.0 +9 +$DIMASSOC +280 +2 +9 +$DIMLFAC +40 +1.0 +0 +ENDSEC +0 +SECTION +2 +TABLES +0 +TABLE +2 +LTYPE +5 +5 +100 +AcDbSymbolTable +70 +1 +0 +LTYPE +5 +14 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord +2 +ByLayer +70 +0 +3 + +72 +65 +73 +0 +40 +0.0 +0 +ENDTAB +0 +TABLE +2 +LAYER +5 +2 +100 +AcDbSymbolTable +70 +1 +0 +LAYER +5 +10 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord +2 +0 +70 +0 +62 +7 +6 +ByLayer +370 +-3 +390 +F +0 +ENDTAB +0 +TABLE +2 +STYLE +5 +3 +100 +AcDbSymbolTable +70 +1 +0 +STYLE +5 +13 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord +2 +Standard +70 +0 +40 +0.0 +41 +1.0 +50 +0.0 +71 +0 +42 +2.5 +3 +txt +4 + +0 +ENDTAB +0 +TABLE +2 +DIMSTYLE +5 +A +100 +AcDbSymbolTable +70 +1 +0 +DIMSTYLE +105 +11 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord +2 +Standard +70 +0 +340 +13 +0 +ENDTAB +0 +TABLE +2 +APPID +5 +9 +100 +AcDbSymbolTable +70 +1 +0 +APPID +5 +12 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord +2 +ACAD +70 +0 +0 +ENDTAB +0 +TABLE +2 +BLOCK_RECORD +5 +1 +100 +AcDbSymbolTable +70 +3 +0 +BLOCK_RECORD +5 +1F +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord +2 +*Model_Space +70 +0 +280 +1 +281 +0 +0 +BLOCK_RECORD +5 +20 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord +2 +*Paper_Space +70 +0 +280 +1 +281 +0 +0 +BLOCK_RECORD +5 +21 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord +2 +TESTBLK +70 +0 +280 +1 +281 +0 +0 +ENDTAB +0 +ENDSEC +0 +SECTION +2 +BLOCKS +0 +BLOCK +5 +101F +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbBlockBegin +2 +*Model_Space +70 +0 +10 +0.0 +20 +0.0 +30 +0.0 +3 +*Model_Space +1 + +0 +ENDBLK +5 +201F +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbBlockEnd +0 +BLOCK +5 +1020 +330 +20 +100 +AcDbEntity +8 +0 +100 +AcDbBlockBegin +2 +*Paper_Space +70 +0 +10 +0.0 +20 +0.0 +30 +0.0 +3 +*Paper_Space +1 + +0 +ENDBLK +5 +2020 +330 +20 +100 +AcDbEntity +8 +0 +100 +AcDbBlockEnd +0 +BLOCK +5 +1021 +330 +21 +100 +AcDbEntity +8 +0 +100 +AcDbBlockBegin +2 +TESTBLK +70 +0 +10 +0.0 +20 +0.0 +30 +0.0 +3 +TESTBLK +1 + +0 +LINE +5 +106 +330 +21 +100 +AcDbEntity +8 +0 +100 +AcDbLine +10 +0.0 +20 +0.0 +30 +0.0 +11 +40.0 +21 +0.0 +31 +0.0 +0 +ENDBLK +5 +2021 +330 +21 +100 +AcDbEntity +8 +0 +100 +AcDbBlockEnd +0 +ENDSEC +0 +SECTION +2 +ENTITIES +0 +LINE +5 +100 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbLine +10 +0.0 +20 +0.0 +30 +0.0 +11 +100.0 +21 +0.0 +31 +0.0 +0 +LINE +5 +107 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbLine +10 +60.0 +20 +-30.0 +30 +0.0 +11 +60.0 +21 +30.0 +31 +0.0 +0 +CIRCLE +5 +101 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbCircle +10 +200.0 +20 +0.0 +30 +0.0 +40 +25.0 +0 +ARC +5 +102 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbCircle +10 +300.0 +20 +0.0 +30 +0.0 +40 +25.0 +100 +AcDbArc +50 +0.0 +51 +90.0 +0 +LWPOLYLINE +5 +103 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbPolyline +90 +3 +70 +0 +10 +0.0 +20 +100.0 +42 +0.5 +10 +100.0 +20 +100.0 +10 +100.0 +20 +200.0 +0 +SPLINE +5 +104 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbSpline +210 +0.0 +220 +0.0 +230 +1.0 +70 +8 +71 +3 +72 +8 +73 +4 +74 +0 +40 +0.0 +40 +0.0 +40 +0.0 +40 +0.0 +40 +1.0 +40 +1.0 +40 +1.0 +40 +1.0 +10 +0.0 +20 +300.0 +30 +0.0 +10 +40.0 +20 +360.0 +30 +0.0 +10 +80.0 +20 +240.0 +30 +0.0 +10 +120.0 +20 +300.0 +30 +0.0 +0 +INSERT +5 +105 +330 +1F +100 +AcDbEntity +8 +0 +100 +AcDbBlockReference +2 +TESTBLK +10 +400.0 +20 +0.0 +30 +0.0 +41 +2.0 +42 +2.0 +43 +1.0 +50 +0.0 +0 +VIEWPORT +5 +200 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbViewport +10 +150.0 +20 +100.0 +30 +0.0 +40 +200.0 +41 +150.0 +68 +2 +69 +2 +100 +AcDbViewport +12 +0.0 +22 +0.0 +13 +0.0 +23 +0.0 +14 +1.0 +24 +1.0 +15 +1.0 +25 +1.0 +16 +0.0 +26 +0.0 +36 +1.0 +17 +150.0 +27 +150.0 +37 +0.0 +42 +50.0 +43 +0.0 +44 +0.0 +45 +300.0 +50 +0.0 +51 +0.0 +72 +100 +90 +32864 +281 +0 +71 +1 +74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 +79 +0 +146 +0.0 +170 +0 +61 +5 +292 +0 +282 +1 +141 +0.0 +142 +0.0 +63 +250 +421 +3355443 +0 +DIMENSION +5 +201 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +80.0 +20 +20.0 +30 +0.0 +11 +80.0 +21 +25.0 +31 +0.0 +70 +1 +71 +5 +42 +100.0 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +75.0 +23 +25.0 +33 +0.0 +14 +125.0 +24 +25.0 +34 +0.0 +50 +0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} +0 +DIMENSION +5 +202 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +180.0 +20 +20.0 +30 +0.0 +11 +180.0 +21 +25.0 +31 +0.0 +70 +1 +71 +5 +42 +50.0 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +162.5 +23 +25.0 +33 +0.0 +14 +187.5 +24 +25.0 +34 +0.0 +50 +0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} +0 +DIMENSION +5 +203 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +250.0 +20 +20.0 +30 +0.0 +11 +250.0 +21 +25.0 +31 +0.0 +70 +1 +71 +5 +42 +35.355339 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +237.5 +23 +25.0 +33 +0.0 +14 +225.0 +24 +37.5 +34 +0.0 +50 +0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} +0 +DIMENSION +5 +204 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +80.0 +20 +140.0 +30 +0.0 +11 +80.0 +21 +145.0 +31 +0.0 +70 +1 +71 +5 +42 +141.421356 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +75.0 +23 +75.0 +33 +0.0 +14 +125.0 +24 +125.0 +34 +0.0 +50 +0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} +0 +DIMENSION +5 +205 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +80.0 +20 +190.0 +30 +0.0 +11 +80.0 +21 +195.0 +31 +0.0 +70 +1 +71 +5 +42 +91.56836585306085 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +75.0 +23 +175.0 +33 +0.0 +14 +120.0 +24 +166.5625 +34 +0.0 +50 +0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} +0 +DIMENSION +5 +206 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +330.0 +20 +20.0 +30 +0.0 +11 +330.0 +21 +25.0 +31 +0.0 +70 +1 +71 +5 +42 +80.0 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +275.0 +23 +25.0 +33 +0.0 +14 +315.0 +24 +25.0 +34 +0.0 +50 +0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} +0 +DIMENSION +5 +207 +330 +20 +100 +AcDbEntity +67 +1 +8 +0 +100 +AcDbDimension +280 +0 +2 +*D0 +10 +80.0 +20 +35.0 +30 +0.0 +11 +80.0 +21 +40.0 +31 +0.0 +70 +1 +71 +5 +42 +60.0 +1 + +3 +Standard +100 +AcDbAlignedDimension +13 +75.0 +23 +25.0 +33 +0.0 +14 +105.0 +24 +25.0 +34 +0.0 +50 +0.0 +1001 +ACAD_DIMASSOC_CALC_DIMLFAC +1040 +-2.0 +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 +144 +1040 +-2.0 +1002 +} +0 +ENDSEC +0 +SECTION +2 +OBJECTS +0 +DICTIONARY +5 +C +330 +0 +100 +AcDbDictionary +281 +1 +3 +ACAD_LAYOUT +350 +D +0 +DICTIONARY +5 +D +330 +C +100 +AcDbDictionary +281 +1 +0 +DIMASSOC +5 +300 +102 +{ACAD_REACTORS +330 +201 +102 +} +330 +C +100 +AcDbDimAssoc +330 +201 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +100 +73 +1 +91 +0 +40 +0.0 +10 +0.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +100 +73 +1 +91 +1 +40 +0.0 +10 +100.0 +20 +0.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +301 +102 +{ACAD_REACTORS +330 +202 +102 +} +330 +C +100 +AcDbDimAssoc +330 +202 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +5 +331 +200 +331 +101 +73 +1 +91 +0 +40 +0.0 +10 +175.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +5 +331 +200 +331 +101 +73 +1 +91 +0 +40 +0.0 +10 +225.0 +20 +0.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +302 +102 +{ACAD_REACTORS +330 +203 +102 +} +330 +C +100 +AcDbDimAssoc +330 +203 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +102 +73 +1 +91 +0 +40 +0.0 +10 +325.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +102 +73 +1 +91 +1 +40 +0.0 +10 +300.0 +20 +25.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +303 +102 +{ACAD_REACTORS +330 +204 +102 +} +330 +C +100 +AcDbDimAssoc +330 +204 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +103 +73 +1 +91 +0 +40 +0.0 +10 +0.0 +20 +100.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +103 +73 +1 +91 +2 +40 +0.0 +10 +100.0 +20 +200.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +304 +102 +{ACAD_REACTORS +330 +205 +102 +} +330 +C +100 +AcDbDimAssoc +330 +205 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +104 +73 +1 +91 +0 +40 +0.0 +10 +0.0 +20 +300.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +9 +331 +200 +331 +104 +73 +1 +91 +-2 +40 +0.75 +10 +90.0 +20 +283.125 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +305 +102 +{ACAD_REACTORS +330 +206 +102 +} +330 +C +100 +AcDbDimAssoc +330 +206 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +105 +331 +106 +73 +1 +91 +0 +40 +0.0 +10 +400.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +105 +331 +106 +73 +1 +91 +1 +40 +0.0 +10 +480.0 +20 +0.0 +30 +0.0 +75 +0 +0 +DIMASSOC +5 +306 +102 +{ACAD_REACTORS +330 +207 +102 +} +330 +C +100 +AcDbDimAssoc +330 +207 +90 +3 +70 +1 +71 +0 +1 +AcDbOsnapPointRef +72 +1 +331 +200 +331 +100 +73 +1 +91 +0 +40 +0.0 +10 +0.0 +20 +0.0 +30 +0.0 +75 +0 +1 +AcDbOsnapPointRef +72 +6 +331 +200 +331 +100 +73 +1 +91 +0 +40 +0.0 +10 +60.0 +20 +0.0 +30 +0.0 +332 +200 +332 +107 +74 +1 +92 +0 +75 +0 +0 +ENDSEC +0 +EOF diff --git a/tests/fixtures/plot/README.md b/tests/fixtures/plot/README.md new file mode 100644 index 000000000..54d2f0873 --- /dev/null +++ b/tests/fixtures/plot/README.md @@ -0,0 +1,29 @@ +Page-setup fixtures saved by a commercial CAD application (2016 release, DXF +AC1027 and the matching DWG). Provided by Panos for the plotting parity track; +the drawing holds one 10 m line (`$INSUNITS` = 6, metres) and three paper +layouts, each with a viewport of that line and its dimension: + +| Layout | Device | Media (`4`) | `44`×`45` | `73` | `72` | `142`/`143` | `147` | `75` | +|---|---|---|---|---|---|---|---|---| +| `A4 1-100` | `DWG To PDF.pc3` | `ISO_A4_(297.00_x_210.00_MM)` | 297 × 210 | 0 | mm | 1 / 1 | 25.4 | 16 | +| `A3 - plotter` | system printer `HPB602DE (HP OfficeJet Pro 7740 series)` | `A3` | 297.01 × 419.99 | 1 | mm | 1 / 1 | 25.4 | 16 | +| `ARCH D - PLOTTER` | the application's second PDF plotter configuration (`… PDF (High Quality Print).pc3`) | `ARCH_D_(36.00_x_24.00_Inches)` | 914.4 × 609.6 | 0 | in | 1 / 25.4 | 0.03937 | 16 | + +What the file establishes (see `src/app/update/file.rs`, `plot_settings_from_dialog`): + +- A landscape sheet is written either as the driver's own landscape medium + with rotation 0 (the PDF driver lists both orientations) or as a portrait + medium with rotation 90 (system printers) — rotation is relative to the + medium's orientation, and the medium's exact driver dimensions are kept. +- Margins (`40`–`43`, mm) belong to the medium's own edges: the PDF driver's + A4 has 5.79375 left/right and 17.79375 bottom/top. Layout limits (`10`/`20`) + put the sheet at minus the *displayed* margins: at rotation 90 the medium's + top margin (20.02) becomes the layout's left one, its left margin (5.00) the + layout's bottom one. +- Plot flags `70` = 672 on every layout: draw viewports first, print + lineweights, plot with plot styles — the "use standard scale" bit stays clear + even with a standard code in `75`; the model tab (fit to paper) sets it. +- `147` is `142/143` × 25.4 on millimetre page setups and `142/143` itself on + inch ones. +- An inch page setup over a millimetre paper space stores "1:1" as + `1 in = 25.4 units` (`75` = 16 kept). diff --git a/tests/fixtures/plot/page-setups-metric.dwg b/tests/fixtures/plot/page-setups-metric.dwg new file mode 100644 index 000000000..2df54fb00 Binary files /dev/null and b/tests/fixtures/plot/page-setups-metric.dwg differ diff --git a/tests/fixtures/plot/page-setups-metric.dxf b/tests/fixtures/plot/page-setups-metric.dxf new file mode 100644 index 000000000..e730c81d6 --- /dev/null +++ b/tests/fixtures/plot/page-setups-metric.dxf @@ -0,0 +1,15882 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1027 + 9 +$ACADMAINTVER + 70 + 105 + 9 +$DWGCODEPAGE + 3 +ANSI_1253 + 9 +$LASTSAVEDBY + 1 +Administrator + 9 +$REQUIREDVERSIONS +160 + 0 + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +14.59833903654579 + 20 +10.82430074186357 + 30 +0.0 + 9 +$EXTMAX + 10 +24.5983390365458 + 20 +12.51140470044061 + 30 +0.0 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +12.0 + 20 +9.0 + 9 +$ORTHOMODE + 70 + 0 + 9 +$REGENMODE + 70 + 1 + 9 +$FILLMODE + 70 + 1 + 9 +$QTEXTMODE + 70 + 0 + 9 +$MIRRTEXT + 70 + 0 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 + 1 + 9 +$TEXTSIZE + 40 +0.2 + 9 +$TRACEWID + 40 +0.05 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 + 256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 + 0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +0.18 + 9 +$DIMEXO + 40 +0.0625 + 9 +$DIMDLI + 40 +0.38 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +0.18 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +0.18 + 9 +$DIMCEN + 40 +0.09 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 + 0 + 9 +$DIMLIM + 70 + 0 + 9 +$DIMTIH + 70 + 1 + 9 +$DIMTOH + 70 + 1 + 9 +$DIMSE1 + 70 + 0 + 9 +$DIMSE2 + 70 + 0 + 9 +$DIMTAD + 70 + 0 + 9 +$DIMZIN + 70 + 0 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 + 1 + 9 +$DIMSHO + 70 + 1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 + 0 + 9 +$DIMALTD + 70 + 2 + 9 +$DIMALTF + 40 +25.4 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 + 0 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 + 0 + 9 +$DIMSOXD + 70 + 0 + 9 +$DIMSAH + 70 + 0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +Standard + 9 +$DIMCLRD + 70 + 0 + 9 +$DIMCLRE + 70 + 0 + 9 +$DIMCLRT + 70 + 0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.09 + 9 +$DIMJUST + 70 + 0 + 9 +$DIMSD1 + 70 + 0 + 9 +$DIMSD2 + 70 + 0 + 9 +$DIMTOLJ + 70 + 1 + 9 +$DIMTZIN + 70 + 0 + 9 +$DIMALTZ + 70 + 0 + 9 +$DIMALTTZ + 70 + 0 + 9 +$DIMUPT + 70 + 0 + 9 +$DIMDEC + 70 + 3 + 9 +$DIMTDEC + 70 + 3 + 9 +$DIMALTU + 70 + 2 + 9 +$DIMALTTD + 70 + 2 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 + 0 + 9 +$DIMADEC + 70 + 0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 + 0 + 9 +$DIMDSEP + 70 + 46 + 9 +$DIMATFIT + 70 + 3 + 9 +$DIMFRAC + 70 + 0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 + 2 + 9 +$DIMLWD + 70 + -2 + 9 +$DIMLWE + 70 + -2 + 9 +$DIMTMOVE + 70 + 0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 + 0 + 9 +$DIMJOGANG + 40 +0.7853981633974483 + 9 +$DIMTFILL + 70 + 0 + 9 +$DIMTFILLCLR + 70 + 0 + 9 +$DIMARCSYM + 70 + 0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 + 0 + 9 +$LUNITS + 70 + 2 + 9 +$LUPREC + 70 + 3 + 9 +$SKETCHINC + 40 +0.1 + 9 +$FILLETRAD + 40 +0.0 + 9 +$AUNITS + 70 + 0 + 9 +$AUPREC + 70 + 0 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 + 0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 + 0 + 9 +$TDCREATE + 40 +2461301.715020046 + 9 +$TDUCREATE + 40 +2461301.590020046 + 9 +$TDUPDATE + 40 +2461301.731632813 + 9 +$TDUUPDATE + 40 +2461301.606632813 + 9 +$TDINDWG + 40 +0.0166130903 + 9 +$TDUSRTIMER + 40 +0.0166130440 + 9 +$USRTIMER + 70 + 1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 + 0 + 9 +$PDMODE + 70 + 0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 + 0 + 9 +$SPLINETYPE + 70 + 6 + 9 +$SPLINESEGS + 70 + 8 + 9 +$HANDSEED + 5 +35D + 9 +$SURFTAB1 + 70 + 6 + 9 +$SURFTAB2 + 70 + 6 + 9 +$SURFTYPE + 70 + 6 + 9 +$SURFU + 70 + 6 + 9 +$SURFV + 70 + 6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 + 0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 + 0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 + 0 + 9 +$USERI2 + 70 + 0 + 9 +$USERI3 + 70 + 0 + 9 +$USERI4 + 70 + 0 + 9 +$USERI5 + 70 + 0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 + 1 + 9 +$SHADEDGE + 70 + 3 + 9 +$SHADEDIF + 70 + 70 + 9 +$TILEMODE + 70 + 1 + 9 +$MAXACTVP + 70 + 64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 + 0 + 9 +$PEXTMIN + 10 +1.049999952316284 + 20 +0.7999999523162842 + 30 +0.0 + 9 +$PEXTMAX + 10 +239.9322544340976 + 20 +168.782338224235 + 30 +0.0 + 9 +$PLIMMIN + 10 +-5.793749809265136 + 20 +-17.79375076293945 + 9 +$PLIMMAX + 10 +291.2062501907349 + 20 +192.2062492370605 + 9 +$UNITMODE + 70 + 0 + 9 +$VISRETAIN + 70 + 1 + 9 +$PLINEGEN + 70 + 0 + 9 +$PSLTSCALE + 70 + 1 + 9 +$TREEDEPTH + 70 + 3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 + 0 + 9 +$CMLSCALE + 40 +1.0 + 9 +$PROXYGRAPHICS + 70 + 1 + 9 +$MEASUREMENT + 70 + 0 + 9 +$CELWEIGHT +370 + -1 + 9 +$ENDCAPS +280 + 0 + 9 +$JOINSTYLE +280 + 0 + 9 +$LWDISPLAY +290 + 0 + 9 +$INSUNITS + 70 + 6 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 + 1 + 9 +$CEPSNTYPE +380 + 0 + 9 +$PSTYLEMODE +290 + 1 + 9 +$FINGERPRINTGUID + 2 +{F3A6E6ED-1342-47C7-994D-2309A1CA9EF6} + 9 +$VERSIONGUID + 2 +{99446C9A-1E85-4B59-9216-C450698EB011} + 9 +$EXTNAMES +290 + 1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 + 0 + 9 +$SORTENTS +280 + 127 + 9 +$INDEXCTL +280 + 0 + 9 +$HIDETEXT +280 + 1 + 9 +$XCLIPFRAME +280 + 2 + 9 +$HALOGAP +280 + 0 + 9 +$OBSCOLOR + 70 + 257 + 9 +$OBSLTYPE +280 + 0 + 9 +$INTERSECTIONDISPLAY +280 + 0 + 9 +$INTERSECTIONCOLOR + 70 + 257 + 9 +$DIMASSOC +280 + 2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 + 0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +2.0 + 9 +$STEPSIZE + 40 +6.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.25 + 9 +$PSOLHEIGHT + 40 +4.0 + 9 +$LOFTANG1 + 40 +1.570796326794896 + 9 +$LOFTANG2 + 40 +1.570796326794896 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 + 7 + 9 +$LOFTNORMALS +280 + 1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 + -8000 + 9 +$LIGHTGLYPHDISPLAY +280 + 1 + 9 +$TILEMODELIGHTSYNCH +280 + 1 + 9 +$CMATERIAL +347 +96 + 9 +$SOLIDHIST +280 + 0 + 9 +$SHOWHIST +280 + 1 + 9 +$DWFFRAME +280 + 2 + 9 +$DGNFRAME +280 + 0 + 9 +$REALWORLDSCALE +290 + 1 + 9 +$INTERFERECOLOR + 62 + 1 + 9 +$INTERFEREOBJVS +345 +A3 + 9 +$INTERFEREVPVS +346 +A0 + 9 +$CSHADOW +280 + 0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 + 0 + 91 + 1 +280 + 0 +281 + 0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 + 0 + 91 + 9 +280 + 0 +281 + 0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 + 4095 + 91 + 1 +280 + 0 +281 + 0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 + 1153 + 91 + 3 +280 + 0 +281 + 0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 + 4095 + 91 + 24 +280 + 0 +281 + 0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 + 1153 + 91 + 33 +280 + 0 +281 + 0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 + 4095 + 91 + 2 +280 + 0 +281 + 0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 + 1152 + 91 + 1 +280 + 0 +281 + 0 + 0 +CLASS + 1 +EXACXREFPANELOBJECT + 2 +ExAcXREFPanelObject + 3 +EXAC_ESW + 90 + 1025 + 91 + 0 +280 + 0 +281 + 0 + 0 +CLASS + 1 +NPOCOLLECTION + 2 +AcDbImpNonPersistentObjectsCollection + 3 +ObjectDBX Classes + 90 + 1153 + 91 + 0 +280 + 0 +281 + 0 + 0 +CLASS + 1 +LAYER_INDEX + 2 +AcDbLayerIndex + 3 +ObjectDBX Classes + 90 + 0 + 91 + 0 +280 + 0 +281 + 0 + 0 +CLASS + 1 +SPATIAL_INDEX + 2 +AcDbSpatialIndex + 3 +ObjectDBX Classes + 90 + 0 + 91 + 0 +280 + 0 +281 + 0 + 0 +CLASS + 1 +IDBUFFER + 2 +AcDbIdBuffer + 3 +ObjectDBX Classes + 90 + 0 + 91 + 0 +280 + 0 +281 + 0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 + 1025 + 91 + 1 +280 + 0 +281 + 0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 + 1025 + 91 + 1 +280 + 0 +281 + 0 + 0 +CLASS + 1 +DIMASSOC + 2 +AcDbDimAssoc + 3 +"AcDbDimAssoc|Product Desc: AcDim ARX App For Dimension|Company: Autodesk, Inc.|WEB Address: www.autodesk.com" + 90 + 0 + 91 + 3 +280 + 0 +281 + 0 + 0 +CLASS + 1 +RAPIDRTRENDERSETTINGS + 2 +AcDbRapidRTRenderSettings + 3 +ObjectDBX Classes + 90 + 1024 + 91 + 6 +280 + 0 +281 + 0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 + 1 + 0 +VPORT + 5 +94 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 + 0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +43.9091672749249 + 22 +12.33162421725632 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +100.9001375182816 + 41 +2.491869918699186 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 + 0 + 72 + 1000 + 73 + 1 + 74 + 3 + 75 + 0 + 76 + 1 + 77 + 0 + 78 + 0 +281 + 0 + 65 + 1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 + 0 +146 +0.0 +348 +9F + 60 + 3 + 61 + 5 +292 + 1 +282 + 1 +141 +0.0 +142 +0.0 + 63 + 250 +421 + 3355443 +1001 +ACAD_NAV_VCDISPLAY +1070 + 3 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 + 1 + 0 +LTYPE + 5 +14 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 + 0 + 3 + + 72 + 65 + 73 + 0 + 40 +0.0 + 0 +LTYPE + 5 +15 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 + 0 + 3 + + 72 + 65 + 73 + 0 + 40 +0.0 + 0 +LTYPE + 5 +16 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 + 0 + 3 +Solid line + 72 + 65 + 73 + 0 + 40 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +2 +102 +{ACAD_XDICTIONARY +360 +18E +102 +} +330 +0 +100 +AcDbSymbolTable + 70 + 2 + 0 +LAYER + 5 +10 +102 +{ACAD_XDICTIONARY +360 +E6 +102 +} +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 + 0 + 62 + 7 + 6 +Continuous +370 + -3 +390 +F +347 +98 +348 +0 + 0 +LAYER + 5 +1EC +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 + 0 + 62 + 7 + 6 +Continuous +290 + 0 +370 + -3 +390 +F +347 +98 +348 +0 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 + 7 + 0 +STYLE + 5 +11 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 + 0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 + 0 + 42 +0.2 + 3 +arial.ttf + 4 + +1001 +ACAD +1000 +Arial +1071 + 34 + 0 +STYLE + 5 +DC +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Annotative + 70 + 0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 + 0 + 42 +0.2 + 3 +arial.ttf + 4 + +1001 +AcadAnnotative +1000 +AnnotativeData +1002 +{ +1070 + 1 +1070 + 1 +1002 +} +1001 +ACAD +1000 +Arial +1071 + 34 + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 + 13 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 + 0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 + 8 + 0 +APPID + 5 +12 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 + 0 + 0 +APPID + 5 +DD +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +AcadAnnoPO + 70 + 0 + 0 +APPID + 5 +DE +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +AcadAnnotative + 70 + 0 + 0 +APPID + 5 +DF +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD_DSTYLE_DIMJAG + 70 + 0 + 0 +APPID + 5 +E0 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD_DSTYLE_DIMTALN + 70 + 0 + 0 +APPID + 5 +107 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD_MLEADERVER + 70 + 0 + 0 +APPID + 5 +1A6 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD_NAV_VCDISPLAY + 70 + 0 + 0 +APPID + 5 +28D +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD_PSEXT + 70 + 0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +A +330 +0 +100 +AcDbSymbolTable + 70 + 7 +100 +AcDbDimStyleTable + 71 + 2 +340 +27 +340 +E1 + 0 +DIMSTYLE +105 +27 +102 +{ACAD_REACTORS +330 +22B +102 +} +330 +A +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 + 0 +271 + 3 +272 + 3 +340 +11 +1001 +ACAD_DSTYLE_DIMJAG +1070 + 388 +1040 +1.5 +1001 +ACAD_DSTYLE_DIMTALN +1070 + 392 +1070 + 0 + 0 +DIMSTYLE +105 +E1 +330 +A +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Annotative + 70 + 0 + 40 +0.0 +340 +11 +1001 +AcadAnnotative +1000 +AnnotativeData +1002 +{ +1070 + 1 +1070 + 1 +1002 +} +1001 +ACAD_DSTYLE_DIMJAG +1070 + 388 +1040 +1.5 +1001 +ACAD_DSTYLE_DIMTALN +1070 + 392 +1070 + 0 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 + 11 + 0 +BLOCK_RECORD + 5 +1F +102 +{ACAD_XDICTIONARY +360 +15D +102 +} +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +22 + 70 + 0 +280 + 1 +281 + 0 + 0 +BLOCK_RECORD + 5 +58 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +59 + 70 + 0 +280 + 1 +281 + 0 + 0 +BLOCK_RECORD + 5 +2E8 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space0 +340 +2F1 + 70 + 0 +280 + 1 +281 + 0 + 0 +BLOCK_RECORD + 5 +1ED +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D1 +340 +0 + 70 + 0 +280 + 1 +281 + 0 + 0 +BLOCK_RECORD + 5 +207 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D2 +340 +0 + 70 + 0 +280 + 1 +281 + 0 + 0 +BLOCK_RECORD + 5 +22C +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*D3 +340 +0 + 70 + 0 +280 + 1 +281 + 0 + 0 +BLOCK_RECORD + 5 +2C3 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space9 +340 +2CC + 70 + 0 +280 + 1 +281 + 0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +20 +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 + 0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +21 +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +5A +330 +58 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 + 0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +5B +330 +58 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +2E9 +330 +2E8 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space0 + 70 + 0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space0 + 1 + + 0 +VIEWPORT + 5 +2EA +102 +{ACAD_XDICTIONARY +360 +2EB +102 +} +330 +2E8 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbViewport + 10 +451.4062623977661 + 20 +287.0062370300293 + 30 +0.0 + 40 +1607.148384415293 + 41 +644.9567741699219 + 68 + 1 + 69 + 1 + 12 +451.4062623977661 + 22 +287.0062370300293 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +644.9567741699219 + 50 +0.0 + 51 +0.0 + 72 + 1000 + 90 + 819232 + 1 + +281 + 0 + 71 + 1 + 74 + 0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 + 0 +146 +0.0 +170 + 0 + 61 + 5 +348 +9F +292 + 1 +282 + 1 +141 +0.0 +142 +0.0 + 63 + 250 +421 + 3355443 + 0 +VIEWPORT + 5 +2ED +102 +{ACAD_XDICTIONARY +360 +2EE +102 +} +330 +2E8 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbViewport + 10 +449.6130217068744 + 20 +286.5965321160482 + 30 +0.0 + 40 +897.1260435091162 + 41 +571.5930643274638 + 68 + 2 + 69 + 2 + 12 +-3905.922247134887 + 22 +-2994.001929285971 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +44111.74567509561 + 17 +3925.520858223038 + 27 +3005.669898567031 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +6.409292904431198 + 50 +0.0 + 51 +0.0 + 72 + 1000 + 90 + 819296 + 1 + +281 + 0 + 71 + 1 + 74 + 0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 + 0 +146 +0.0 +170 + 0 + 61 + 5 +348 +9F +292 + 1 +282 + 1 +141 +0.0 +142 +0.0 + 63 + 250 +421 + 3355443 + 0 +ENDBLK + 5 +2F0 +330 +2E8 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1EE +330 +1ED +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D1 + 70 + 1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D1 + 1 + + 0 +LINE + 5 +1FB +330 +1ED +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +370 + -2 +100 +AcDbLine + 10 +14.25447586915491 + 20 +11.64902104705702 + 30 +0.0 + 11 +14.25447586915491 + 21 +14.11493427750241 + 31 +0.0 + 0 +LINE + 5 +1FC +330 +1ED +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +370 + -2 +100 +AcDbLine + 10 +24.25447586915492 + 20 +11.64902104705702 + 30 +0.0 + 11 +24.25447586915492 + 21 +14.11493427750241 + 31 +0.0 + 0 +LINE + 5 +1FD +330 +1ED +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +370 + -2 +100 +AcDbLine + 10 +14.43447586915491 + 20 +13.93493427750241 + 30 +0.0 + 11 +18.72970097147415 + 21 +13.93493427750241 + 31 +0.0 + 0 +LINE + 5 +1FE +330 +1ED +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +370 + -2 +100 +AcDbLine + 10 +24.07447586915492 + 20 +13.93493427750241 + 30 +0.0 + 11 +19.77925076683568 + 21 +13.93493427750241 + 31 +0.0 + 0 +SOLID + 5 +1FF +330 +1ED +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +100 +AcDbTrace + 10 +14.43447586915491 + 20 +13.96493427750241 + 30 +0.0 + 11 +14.43447586915491 + 21 +13.90493427750241 + 31 +0.0 + 12 +14.25447586915491 + 22 +13.93493427750241 + 32 +0.0 + 13 +14.25447586915491 + 23 +13.93493427750241 + 33 +0.0 + 0 +SOLID + 5 +200 +330 +1ED +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +100 +AcDbTrace + 10 +24.07447586915492 + 20 +13.96493427750241 + 30 +0.0 + 11 +24.07447586915492 + 21 +13.90493427750241 + 31 +0.0 + 12 +24.25447586915492 + 22 +13.93493427750241 + 32 +0.0 + 13 +24.25447586915492 + 23 +13.93493427750241 + 33 +0.0 + 0 +MTEXT + 5 +201 +330 +1ED +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +100 +AcDbMText + 10 +19.25447586915491 + 20 +13.93493427750241 + 30 +0.0 + 40 +0.18 + 41 +0.0 + 46 +0.0 + 71 + 5 + 72 + 1 + 1 +\A1;10.0000 + 73 + 1 + 44 +1.0 + 0 +POINT + 5 +202 +330 +1ED +100 +AcDbEntity + 8 +Defpoints + 62 + 0 +440 + 16777216 +100 +AcDbPoint + 10 +14.25447586915491 + 20 +11.58652104705702 + 30 +0.0 + 0 +POINT + 5 +203 +330 +1ED +100 +AcDbEntity + 8 +Defpoints + 62 + 0 +440 + 16777216 +100 +AcDbPoint + 10 +24.25447586915492 + 20 +11.58652104705702 + 30 +0.0 + 0 +POINT + 5 +204 +330 +1ED +100 +AcDbEntity + 8 +Defpoints + 62 + 0 +440 + 16777216 +100 +AcDbPoint + 10 +24.25447586915492 + 20 +13.93493427750241 + 30 +0.0 + 0 +ENDBLK + 5 +1EF +330 +1ED +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +208 +330 +207 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D2 + 70 + 1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D2 + 1 + + 0 +LINE + 5 +220 +330 +207 +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +370 + -2 +100 +AcDbLine + 10 +14.25447586915491 + 20 +11.64902104705702 + 30 +0.0 + 11 +14.25447586915491 + 21 +12.71928111055961 + 31 +0.0 + 0 +LINE + 5 +221 +330 +207 +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +370 + -2 +100 +AcDbLine + 10 +24.25447586915492 + 20 +11.64902104705702 + 30 +0.0 + 11 +24.25447586915492 + 21 +12.71928111055961 + 31 +0.0 + 0 +LINE + 5 +222 +330 +207 +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +370 + -2 +100 +AcDbLine + 10 +14.43447586915491 + 20 +12.53928111055961 + 30 +0.0 + 11 +18.72970097147415 + 21 +12.53928111055961 + 31 +0.0 + 0 +LINE + 5 +223 +330 +207 +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +370 + -2 +100 +AcDbLine + 10 +24.07447586915492 + 20 +12.53928111055961 + 30 +0.0 + 11 +19.77925076683568 + 21 +12.53928111055961 + 31 +0.0 + 0 +SOLID + 5 +224 +330 +207 +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +100 +AcDbTrace + 10 +14.43447586915491 + 20 +12.5692811105596 + 30 +0.0 + 11 +14.43447586915491 + 21 +12.50928111055961 + 31 +0.0 + 12 +14.25447586915491 + 22 +12.53928111055961 + 32 +0.0 + 13 +14.25447586915491 + 23 +12.53928111055961 + 33 +0.0 + 0 +SOLID + 5 +225 +330 +207 +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +100 +AcDbTrace + 10 +24.07447586915492 + 20 +12.5692811105596 + 30 +0.0 + 11 +24.07447586915492 + 21 +12.50928111055961 + 31 +0.0 + 12 +24.25447586915492 + 22 +12.53928111055961 + 32 +0.0 + 13 +24.25447586915492 + 23 +12.53928111055961 + 33 +0.0 + 0 +MTEXT + 5 +226 +330 +207 +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +100 +AcDbMText + 10 +19.25447586915491 + 20 +12.53928111055961 + 30 +0.0 + 40 +0.18 + 41 +0.0 + 46 +0.0 + 71 + 5 + 72 + 1 + 1 +\A1;10.0000 + 73 + 1 + 44 +1.0 + 0 +POINT + 5 +227 +330 +207 +100 +AcDbEntity + 8 +Defpoints + 62 + 0 +440 + 16777216 +100 +AcDbPoint + 10 +14.25447586915491 + 20 +11.58652104705702 + 30 +0.0 + 0 +POINT + 5 +228 +330 +207 +100 +AcDbEntity + 8 +Defpoints + 62 + 0 +440 + 16777216 +100 +AcDbPoint + 10 +24.25447586915492 + 20 +11.58652104705702 + 30 +0.0 + 0 +POINT + 5 +229 +330 +207 +100 +AcDbEntity + 8 +Defpoints + 62 + 0 +440 + 16777216 +100 +AcDbPoint + 10 +24.25447586915492 + 20 +12.53928111055961 + 30 +0.0 + 0 +ENDBLK + 5 +209 +330 +207 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +22D +330 +22C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*D3 + 70 + 1 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*D3 + 1 + + 0 +LINE + 5 +277 +330 +22C +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +370 + -2 +100 +AcDbLine + 10 +14.59833903654579 + 20 +10.88680074186357 + 30 +0.0 + 11 +14.59833903654579 + 21 +12.51140470044061 + 31 +0.0 + 0 +LINE + 5 +278 +330 +22C +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +370 + -2 +100 +AcDbLine + 10 +24.5983390365458 + 20 +10.88680074186357 + 30 +0.0 + 11 +24.5983390365458 + 21 +12.51140470044061 + 31 +0.0 + 0 +LINE + 5 +279 +330 +22C +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +370 + -2 +100 +AcDbLine + 10 +14.77833903654579 + 20 +12.33140470044061 + 30 +0.0 + 11 +19.14348910475862 + 21 +12.33140470044061 + 31 +0.0 + 0 +LINE + 5 +27A +330 +22C +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +370 + -2 +100 +AcDbLine + 10 +24.41833903654579 + 20 +12.33140470044061 + 30 +0.0 + 11 +20.05318896833297 + 21 +12.33140470044061 + 31 +0.0 + 0 +SOLID + 5 +27B +330 +22C +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +100 +AcDbTrace + 10 +14.77833903654579 + 20 +12.36140470044061 + 30 +0.0 + 11 +14.77833903654579 + 21 +12.30140470044061 + 31 +0.0 + 12 +14.59833903654579 + 22 +12.33140470044061 + 32 +0.0 + 13 +14.59833903654579 + 23 +12.33140470044061 + 33 +0.0 + 0 +SOLID + 5 +27C +330 +22C +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +100 +AcDbTrace + 10 +24.41833903654579 + 20 +12.36140470044061 + 30 +0.0 + 11 +24.41833903654579 + 21 +12.30140470044061 + 31 +0.0 + 12 +24.5983390365458 + 22 +12.33140470044061 + 32 +0.0 + 13 +24.5983390365458 + 23 +12.33140470044061 + 33 +0.0 + 0 +MTEXT + 5 +27D +330 +22C +100 +AcDbEntity + 8 +0 + 62 + 0 +440 + 16777216 +100 +AcDbMText + 10 +19.59833903654579 + 20 +12.33140470044061 + 30 +0.0 + 40 +0.18 + 41 +0.0 + 46 +0.0 + 71 + 5 + 72 + 1 + 1 +\A1;10.000 + 73 + 1 + 44 +1.0 + 0 +POINT + 5 +27E +330 +22C +100 +AcDbEntity + 8 +Defpoints + 62 + 0 +440 + 16777216 +100 +AcDbPoint + 10 +14.59833903654579 + 20 +10.82430074186357 + 30 +0.0 + 0 +POINT + 5 +27F +330 +22C +100 +AcDbEntity + 8 +Defpoints + 62 + 0 +440 + 16777216 +100 +AcDbPoint + 10 +24.5983390365458 + 20 +10.82430074186357 + 30 +0.0 + 0 +POINT + 5 +280 +330 +22C +100 +AcDbEntity + 8 +Defpoints + 62 + 0 +440 + 16777216 +100 +AcDbPoint + 10 +24.5983390365458 + 20 +12.33140470044061 + 30 +0.0 + 0 +ENDBLK + 5 +22E +330 +22C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +2C4 +330 +2C3 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space9 + 70 + 0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space9 + 1 + + 0 +VIEWPORT + 5 +2C5 +102 +{ACAD_XDICTIONARY +360 +2C6 +102 +} +330 +2C3 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbViewport + 10 +189.9708404541016 + 20 +143.5099921226501 + 30 +0.0 + 40 +783.0383963893487 + 41 +314.237268371582 + 68 + 1 + 69 + 1 + 12 +189.9708404541016 + 22 +143.5099921226501 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +314.237268371582 + 50 +0.0 + 51 +0.0 + 72 + 1000 + 90 + 819232 + 1 + +281 + 0 + 71 + 1 + 74 + 0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 + 0 +146 +0.0 +170 + 0 + 61 + 5 +348 +9F +292 + 1 +282 + 1 +141 +0.0 +142 +0.0 + 63 + 250 +421 + 3355443 + 0 +VIEWPORT + 5 +2C8 +102 +{ACAD_XDICTIONARY +360 +2C9 +102 +} +330 +2C3 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbViewport + 10 +120.4911271932069 + 20 +84.79116908827563 + 30 +0.0 + 40 +238.8822544817813 + 41 +167.9823382719187 + 68 + 2 + 69 + 2 + 12 +-3905.594010084151 + 22 +-2994.633675946912 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +44111.74567509561 + 17 +3925.520858223038 + 27 +3005.669898567031 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +16.79790352501503 + 50 +0.0 + 51 +0.0 + 72 + 1000 + 90 + 819296 + 1 + +281 + 0 + 71 + 1 + 74 + 0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 + 0 +146 +0.0 +170 + 0 + 61 + 5 +348 +9F +292 + 1 +282 + 1 +141 +0.0 +142 +0.0 + 63 + 250 +421 + 3355443 + 0 +ENDBLK + 5 +2CB +330 +2C3 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 5 +22A +102 +{ACAD_REACTORS +330 +244 +102 +} +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbLine + 10 +14.59833903654579 + 20 +10.82430074186357 + 30 +0.0 + 11 +24.5983390365458 + 21 +10.82430074186357 + 31 +0.0 + 0 +DIMENSION + 5 +22B +102 +{ACAD_XDICTIONARY +360 +239 +102 +} +102 +{ACAD_REACTORS +330 +244 +102 +} +330 +1F +100 +AcDbEntity + 8 +0 +100 +AcDbDimension +280 + 0 + 2 +*D3 + 10 +24.5983390365458 + 20 +12.33140470044061 + 30 +0.0 + 11 +19.59833903654579 + 21 +12.33140470044061 + 31 +0.0 + 70 + 32 + 71 + 5 + 42 +10.0 + 73 + 0 + 74 + 0 + 75 + 0 + 3 +Standard +100 +AcDbAlignedDimension + 13 +14.59833903654579 + 23 +10.82430074186357 + 33 +0.0 + 14 +24.5983390365458 + 24 +10.82430074186357 + 34 +0.0 +100 +AcDbRotatedDimension +1001 +ACAD +1000 +DSTYLE +1002 +{ +1070 + 271 +1070 + 3 +1002 +} + 0 +VIEWPORT + 5 +28A +102 +{ACAD_XDICTIONARY +360 +28B +102 +} +330 +58 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbViewport + 10 +78.82313944341524 + 20 +77.2092821871629 + 30 +0.0 + 40 +574.3653659734484 + 41 +230.4957259860821 + 68 + 1 + 69 + 1 + 12 +78.82313944341524 + 22 +77.2092821871629 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +230.4957259860821 + 50 +0.0 + 51 +0.0 + 72 + 1000 + 90 + 819232 + 1 + +281 + 0 + 71 + 1 + 74 + 0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 + 0 +146 +0.0 +170 + 0 + 61 + 5 +348 +9F +292 + 1 +282 + 1 +141 +0.0 +142 +0.0 + 63 + 250 +421 + 3355443 + 0 +VIEWPORT + 5 +28F +102 +{ACAD_XDICTIONARY +360 +290 +102 +} +330 +58 +100 +AcDbEntity + 67 + 1 + 8 +0 +100 +AcDbViewport + 10 +120.4911271932069 + 20 +84.79116908827563 + 30 +0.0 + 40 +238.8822544817813 + 41 +167.9823382719187 + 68 + 2 + 69 + 2 + 12 +-3905.594010084151 + 22 +-2994.633675946912 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +44111.74567509561 + 17 +3925.520858223038 + 27 +3005.669898567031 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +16.79790352501503 + 50 +0.0 + 51 +0.0 + 72 + 1000 + 90 + 819296 + 1 + +281 + 0 + 71 + 1 + 74 + 0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 + 0 +146 +0.0 +170 + 0 + 61 + 5 +348 +9F +292 + 1 +282 + 1 +141 +0.0 +142 +0.0 + 63 + 250 +421 + 3355443 + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +C +330 +0 +100 +AcDbDictionary +281 + 1 + 3 +ACAD_CIP_PREVIOUS_PRODUCT_INFO +350 +1A5 + 3 +ACAD_COLOR +350 +73 + 3 +ACAD_DETAILVIEWSTYLE +350 +1AB + 3 +ACAD_GROUP +350 +D + 3 +ACAD_LAYOUT +350 +1A + 3 +ACAD_MATERIAL +350 +72 + 3 +ACAD_MLEADERSTYLE +350 +D7 + 3 +ACAD_MLINESTYLE +350 +17 + 3 +ACAD_PLOTSETTINGS +350 +19 + 3 +ACAD_PLOTSTYLENAME +350 +E + 3 +ACAD_RENDER_RAPIDRT_SETTINGS +350 +292 + 3 +ACAD_SCALELIST +350 +B6 + 3 +ACAD_SECTIONVIEWSTYLE +350 +1A8 + 3 +ACAD_TABLESTYLE +350 +86 + 3 +ACAD_VISUALSTYLE +350 +99 + 3 +ACDB_RECOMPOSE_DATA +350 +35C + 3 +AcDbVariableDictionary +350 +66 + 0 +DICTIONARY + 5 +18E +330 +2 +100 +AcDbDictionary +280 + 1 +281 + 1 + 3 +ACAD_LAYERSTATES +360 +18F + 0 +DICTIONARY + 5 +E6 +330 +10 +100 +AcDbDictionary +280 + 1 +281 + 1 + 0 +DICTIONARY + 5 +15D +330 +1F +100 +AcDbDictionary +280 + 1 +281 + 1 + 0 +DICTIONARY + 5 +2EB +330 +2EA +100 +AcDbDictionary +280 + 1 +281 + 1 + 3 +ASDK_XREC_ANNOTATION_SCALE_INFO +360 +2EC + 0 +DICTIONARY + 5 +2EE +330 +2ED +100 +AcDbDictionary +280 + 1 +281 + 1 + 3 +ASDK_XREC_ANNOTATION_SCALE_INFO +360 +2EF + 0 +DICTIONARY + 5 +2C6 +330 +2C5 +100 +AcDbDictionary +280 + 1 +281 + 1 + 3 +ASDK_XREC_ANNOTATION_SCALE_INFO +360 +2C7 + 0 +DICTIONARY + 5 +2C9 +330 +2C8 +100 +AcDbDictionary +280 + 1 +281 + 1 + 3 +ASDK_XREC_ANNOTATION_SCALE_INFO +360 +2CA + 0 +DICTIONARY + 5 +239 +330 +22B +100 +AcDbDictionary +280 + 1 +281 + 1 + 3 +ACAD_DIMASSOC +360 +244 + 0 +DICTIONARY + 5 +28B +330 +28A +100 +AcDbDictionary +280 + 1 +281 + 1 + 3 +ASDK_XREC_ANNOTATION_SCALE_INFO +360 +28C + 0 +DICTIONARY + 5 +290 +330 +28F +100 +AcDbDictionary +280 + 1 +281 + 1 + 3 +ASDK_XREC_ANNOTATION_SCALE_INFO +360 +291 + 0 +XRECORD + 5 +1A5 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbXrecord +280 + 1 +300 +ACD +300 +2016 +300 +ACD_F_S + 0 +DICTIONARY + 5 +73 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 0 +DICTIONARY + 5 +1AB +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +Imperial24 +350 +1AC + 0 +DICTIONARY + 5 +D +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 0 +DICTIONARY + 5 +1A +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +A3 - plotter +350 +2CC + 3 +A4 1-100 +350 +59 + 3 +ARCH D - PLOTTER +350 +2F1 + 3 +Model +350 +22 + 0 +DICTIONARY + 5 +72 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +ByBlock +350 +97 + 3 +ByLayer +350 +96 + 3 +Global +350 +98 + 0 +DICTIONARY + 5 +D7 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +Annotative +350 +E5 + 3 +Standard +350 +D8 + 0 +DICTIONARY + 5 +17 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +Standard +350 +18 + 0 +DICTIONARY + 5 +19 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 0 +ACDBDICTIONARYWDFLT + 5 +E +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +Normal +350 +F +100 +AcDbDictionaryWithDefault +340 +F + 0 +DICTIONARY + 5 +292 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +Coffee-Break Quality +350 +296 + 3 +High +350 +295 + 3 +Low +350 +293 + 3 +Lunch Quality +350 +297 + 3 +Medium +350 +294 + 3 +Overnight Quality +350 +298 + 0 +DICTIONARY + 5 +B6 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +A0 +350 +B7 + 3 +A1 +350 +124 + 3 +A2 +350 +125 + 3 +A3 +350 +126 + 3 +A4 +350 +127 + 3 +A5 +350 +128 + 3 +A6 +350 +129 + 3 +A7 +350 +12A + 3 +A8 +350 +12B + 3 +A9 +350 +12C + 3 +B0 +350 +12D + 3 +B1 +350 +12E + 3 +B2 +350 +12F + 3 +B3 +350 +130 + 3 +B4 +350 +131 + 3 +B5 +350 +132 + 3 +B6 +350 +133 + 3 +B7 +350 +134 + 3 +B8 +350 +135 + 3 +B9 +350 +136 + 3 +C0 +350 +137 + 3 +C1 +350 +138 + 3 +C2 +350 +139 + 3 +C3 +350 +13A + 3 +C4 +350 +13B + 3 +C5 +350 +13C + 3 +C6 +350 +13D + 3 +C7 +350 +13E + 3 +C8 +350 +13F + 3 +C9 +350 +140 + 3 +D0 +350 +141 + 3 +D1 +350 +142 + 3 +D2 +350 +143 + 0 +DICTIONARY + 5 +1A8 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +Imperial24 +350 +1AA + 0 +DICTIONARY + 5 +86 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +Standard +350 +87 + 0 +DICTIONARY + 5 +99 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +2dWireframe +350 +9F + 3 +Basic +350 +9E + 3 +Brighten +350 +A5 + 3 +ColorChange +350 +A9 + 3 +Conceptual +350 +A2 + 3 +Dim +350 +A4 + 3 +EdgeColorOff +350 +175 + 3 +Facepattern +350 +A8 + 3 +Flat +350 +9A + 3 +FlatWithEdges +350 +9B + 3 +Gouraud +350 +9C + 3 +GouraudWithEdges +350 +9D + 3 +Hidden +350 +A1 + 3 +JitterOff +350 +173 + 3 +Linepattern +350 +A7 + 3 +OverhangOff +350 +174 + 3 +Realistic +350 +A3 + 3 +Shaded +350 +182 + 3 +Shaded with edges +350 +181 + 3 +Shades of Gray +350 +17E + 3 +Sketchy +350 +17F + 3 +Thicken +350 +A6 + 3 +Wireframe +350 +A0 + 3 +X-Ray +350 +180 + 0 +XRECORD + 5 +35C +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbXrecord +280 + 1 + 90 + 1 +330 +87 +330 +1AA +330 +1AC + 0 +DICTIONARY + 5 +66 +102 +{ACAD_REACTORS +330 +C +102 +} +330 +C +100 +AcDbDictionary +281 + 1 + 3 +CANNOSCALE +350 +F0 + 3 +CMLEADERSTYLE +350 +EF + 3 +CTABLESTYLE +350 +89 + 3 +CVIEWDETAILSTYLE +350 +1B7 + 3 +CVIEWSECTIONSTYLE +350 +1B8 + 3 +DIMASSOC +350 +67 + 3 +HIDETEXT +350 +6B + 3 +LAYEREVAL +350 +14D + 3 +LAYERNOTIFY +350 +14E + 0 +DICTIONARY + 5 +18F +102 +{ACAD_REACTORS +330 +18E +102 +} +330 +18E +100 +AcDbDictionary +281 + 1 + 0 +XRECORD + 5 +2EC +102 +{ACAD_REACTORS +330 +2EB +102 +} +330 +2EB +100 +AcDbXrecord +280 + 1 + 90 + 1 +340 +B7 + 0 +XRECORD + 5 +2EF +102 +{ACAD_REACTORS +330 +2EE +102 +} +330 +2EE +100 +AcDbXrecord +280 + 1 + 90 + 1 +340 +B7 + 0 +XRECORD + 5 +2C7 +102 +{ACAD_REACTORS +330 +2C6 +102 +} +330 +2C6 +100 +AcDbXrecord +280 + 1 + 90 + 1 +340 +B7 + 0 +XRECORD + 5 +2CA +102 +{ACAD_REACTORS +330 +2C9 +102 +} +330 +2C9 +100 +AcDbXrecord +280 + 1 + 90 + 1 +340 +B7 + 0 +DIMASSOC + 5 +244 +102 +{ACAD_REACTORS +330 +239 +102 +} +330 +239 +100 +AcDbDimAssoc +330 +22B + 90 + 3 + 70 + 0 + 71 + 0 + 1 +AcDbOsnapPointRef + 72 + 13 +331 +22A + 73 + 2 + 91 + 0 + 40 +0.0 + 10 +14.59833903654579 + 20 +10.82430074186357 + 30 +0.0 + 75 + 0 + 1 +AcDbOsnapPointRef + 72 + 1 +331 +22A + 73 + 2 + 91 + 0 + 40 +1.0 + 10 +24.5983390365458 + 20 +10.82430074186357 + 30 +0.0 + 75 + 0 + 0 +XRECORD + 5 +28C +102 +{ACAD_REACTORS +330 +28B +102 +} +330 +28B +100 +AcDbXrecord +280 + 1 + 90 + 1 +340 +B7 + 0 +XRECORD + 5 +291 +102 +{ACAD_REACTORS +330 +290 +102 +} +330 +290 +100 +AcDbXrecord +280 + 1 + 90 + 1 +340 +B7 + 0 +ACDBDETAILVIEWSTYLE + 5 +1AC +102 +{ACAD_XDICTIONARY +360 +1C6 +102 +} +102 +{ACAD_REACTORS +330 +1AB +102 +} +330 +1AB +100 +AcDbModelDocViewStyle + 70 + 0 + 3 +Imperial24 +290 + 0 +100 +AcDbDetailViewStyle + 70 + 0 + 71 + 0 + 90 + 3 + 71 + 1 +340 +11 + 62 + 256 + 40 +0.24 +340 +0 + 62 + 256 + 40 +0.24 +300 + + 40 +0.36 +280 + 3 + 71 + 2 +340 +16 + 90 + 25 + 62 + 256 + 71 + 3 +340 +11 + 62 + 256 + 40 +0.24 + 90 + 1 + 40 +0.75 + 90 + 1 +300 +%<\AcVar ViewType \f "%tc1">% %<\AcVar ViewDetailId>%\PSCALE %<\AcVar ViewScale \f "%sn">% + 71 + 4 +340 +16 + 90 + 25 + 62 + 256 +340 +16 + 90 + 25 + 62 + 256 +280 + 0 + 0 +LAYOUT + 5 +2CC +102 +{ACAD_REACTORS +330 +1A +102 +} +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +HPB602DE (HP OfficeJet Pro 7740 series) + 4 +A3 + 6 + + 40 +4.995333194732666 + 41 +19.98135375976562 + 42 +5.03765869140625 + 43 +20.02365112304687 + 44 +297.0106506347656 + 45 +419.9889831542968 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 + 672 + 72 + 1 + 73 + 1 + 74 + 5 + 7 + + 75 + 16 +147 +25.4 + 76 + 0 + 77 + 2 + 78 + 300 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +A3 - plotter + 70 + 1 + 71 + 2 + 10 +-20.02365112304687 + 20 +-4.995333194732666 + 11 +399.96533203125 + 21 +292.015317440033 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1.049999952316284 + 24 +0.7999999523162842 + 34 +0.0 + 15 +239.9322544340976 + 25 +168.782338224235 + 35 +0.0 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 + 0 +330 +2C3 +331 +2C5 +1001 +ACAD_PSEXT +1000 +HPB602DE (HP OfficeJet Pro 7740 series) +1000 +HP PCL3 B-size Printer Class Driver +1000 +WSD-435235e4-432b-4da3-b38d-30ad096c097b.0064 +1000 +This is a web services printer +1070 + 0 + 0 +LAYOUT + 5 +59 +102 +{ACAD_REACTORS +330 +1A +102 +} +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +DWG To PDF.pc3 + 4 +ISO_A4_(297.00_x_210.00_MM) + 6 + + 40 +5.793749809265136 + 41 +17.79375076293945 + 42 +5.79376220703125 + 43 +17.79374694824219 + 44 +297.0 + 45 +210.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 + 672 + 72 + 1 + 73 + 0 + 74 + 5 + 7 + + 75 + 16 +147 +25.4 + 76 + 0 + 77 + 2 + 78 + 300 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +A4 1-100 + 70 + 1 + 71 + 1 + 10 +-5.793749809265136 + 20 +-17.79375076293945 + 11 +291.2062501907349 + 21 +192.2062492370605 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1.049999952316284 + 24 +0.7999999523162842 + 34 +0.0 + 15 +239.9322544340976 + 25 +168.782338224235 + 35 +0.0 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 + 0 +330 +58 +331 +28A +1001 +ACAD_PSEXT +1000 +DWG To PDF.pc3 +1000 +DWG To PDF +1000 +File +1000 + +1070 + 0 + 0 +LAYOUT + 5 +2F1 +102 +{ACAD_REACTORS +330 +1A +102 +} +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +AutoCAD PDF (High Quality Print).pc3 + 4 +ARCH_D_(36.00_x_24.00_Inches) + 6 + + 40 +5.793749809265136 + 41 +17.79375076293945 + 42 +5.79376220703125 + 43 +17.79376220703125 + 44 +914.4000244140626 + 45 +609.5999755859375 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +25.4 + 70 + 672 + 72 + 0 + 73 + 0 + 74 + 5 + 7 + + 75 + 16 +147 +0.0393700787401575 + 76 + 0 + 77 + 2 + 78 + 300 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +ARCH D - PLOTTER + 70 + 1 + 71 + 3 + 10 +-5.793749809265136 + 20 +-17.79375076293945 + 11 +908.6062746047975 + 21 +591.806224822998 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1.049999952316284 + 24 +0.7999999523162842 + 34 +0.0 + 15 +898.1760434614326 + 25 +572.39306427978 + 35 +0.0 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 + 0 +330 +2E8 +331 +2EA +1001 +ACAD_PSEXT +1000 +AutoCAD PDF (High Quality Print).pc3 +1000 +DWG To PDF +1000 +File +1000 + +1070 + 0 + 0 +LAYOUT + 5 +22 +102 +{ACAD_XDICTIONARY +360 +194 +102 +} +102 +{ACAD_REACTORS +330 +1A +102 +} +330 +1A +100 +AcDbPlotSettings + 1 + + 2 +none_device + 4 +ANSI_A_(8.50_x_11.00_Inches) + 6 + + 40 +6.349999904632568 + 41 +19.04999923706055 + 42 +6.350006103515625 + 43 +19.04998779296875 + 44 +215.8999938964844 + 45 +279.3999938964844 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +2.584895464708373 + 70 + 11952 + 72 + 0 + 73 + 1 + 74 + 0 + 7 + + 75 + 0 +147 +0.3868628397755418 + 76 + 0 + 77 + 2 + 78 + 300 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 + 1 + 71 + 0 + 10 +0.0 + 20 +0.0 + 11 +12.0 + 21 +9.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +0.0 + 24 +0.0 + 34 +0.0 + 15 +0.0 + 25 +0.0 + 35 +0.0 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 + 0 +330 +1F +331 +94 + 0 +MATERIAL + 5 +97 +102 +{ACAD_XDICTIONARY +360 +188 +102 +} +102 +{ACAD_REACTORS +330 +72 +102 +} +330 +72 +100 +AcDbMaterial + 1 +ByBlock + 94 + 63 + 0 +MATERIAL + 5 +96 +102 +{ACAD_XDICTIONARY +360 +186 +102 +} +102 +{ACAD_REACTORS +330 +72 +102 +} +330 +72 +100 +AcDbMaterial + 1 +ByLayer + 94 + 63 + 0 +MATERIAL + 5 +98 +102 +{ACAD_XDICTIONARY +360 +110 +102 +} +102 +{ACAD_REACTORS +330 +72 +102 +} +330 +72 +100 +AcDbMaterial + 1 +Global + 43 +0.0208000000566244 + 43 +0.0 + 43 +0.0 + 43 +0.0 + 43 +0.0 + 43 +0.0208000000566244 + 43 +0.0 + 43 +0.0 + 43 +0.0 + 43 +0.0 + 43 +1.0 + 43 +0.0 + 43 +0.0 + 43 +0.0 + 43 +0.0 + 43 +1.0 + 49 +0.0208000000566244 + 49 +0.0 + 49 +0.0 + 49 +0.0 + 49 +0.0 + 49 +0.0208000000566244 + 49 +0.0 + 49 +0.0 + 49 +0.0 + 49 +0.0 + 49 +1.0 + 49 +0.0 + 49 +0.0 + 49 +0.0 + 49 +0.0 + 49 +1.0 +142 +0.0208000000566244 +142 +0.0 +142 +0.0 +142 +0.0 +142 +0.0 +142 +0.0208000000566244 +142 +0.0 +142 +0.0 +142 +0.0 +142 +0.0 +142 +1.0 +142 +0.0 +142 +0.0 +142 +0.0 +142 +0.0 +142 +1.0 +144 +0.0208000000566244 +144 +0.0 +144 +0.0 +144 +0.0 +144 +0.0 +144 +0.0208000000566244 +144 +0.0 +144 +0.0 +144 +0.0 +144 +0.0 +144 +1.0 +144 +0.0 +144 +0.0 +144 +0.0 +144 +0.0 +144 +1.0 + 94 + 63 +1001 +ACAD +1070 + -1 +1070 + 3 +1070 + 0 +1000 + +1071 + 0 +1070 + 0 + 0 +MLEADERSTYLE + 5 +E5 +102 +{ACAD_REACTORS +330 +D7 +102 +} +330 +D7 +100 +AcDbMLeaderStyle +179 + 2 +170 + 2 +171 + 1 +172 + 0 + 90 + 2 + 40 +0.0 + 41 +0.0 +173 + 1 + 91 +-1056964608 +340 +14 + 92 + -2 +290 + 1 + 42 +0.09 +291 + 1 + 43 +0.36 + 3 +Standard + 44 +0.18 +300 + +342 +11 +174 + 1 +178 + 1 +175 + 1 +176 + 0 + 93 +-1056964608 + 45 +0.18 +292 + 0 +297 + 0 + 46 +0.18 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +293 + 1 +141 +0.0 +294 + 1 +177 + 0 +142 +1.0 +295 + 0 +296 + 1 +143 +0.125 +271 + 0 +272 + 9 +273 + 9 +298 + 0 + 0 +MLEADERSTYLE + 5 +D8 +102 +{ACAD_REACTORS +330 +D7 +102 +} +330 +D7 +100 +AcDbMLeaderStyle +179 + 2 +170 + 2 +171 + 1 +172 + 0 + 90 + 2 + 40 +0.0 + 41 +0.0 +173 + 1 + 91 +-1056964608 +340 +14 + 92 + -2 +290 + 1 + 42 +0.09 +291 + 1 + 43 +0.36 + 3 +Standard + 44 +0.18 +300 + +342 +11 +174 + 1 +178 + 1 +175 + 1 +176 + 0 + 93 +-1056964608 + 45 +0.18 +292 + 0 +297 + 0 + 46 +0.18 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +293 + 1 +141 +0.0 +294 + 1 +177 + 0 +142 +1.0 +295 + 0 +296 + 0 +143 +0.125 +271 + 0 +272 + 9 +273 + 9 +298 + 0 + 0 +MLINESTYLE + 5 +18 +102 +{ACAD_REACTORS +330 +17 +102 +} +330 +17 +100 +AcDbMlineStyle + 2 +STANDARD + 70 + 0 + 3 + + 62 + 256 + 51 +90.0 + 52 +90.0 + 71 + 2 + 49 +0.5 + 62 + 256 + 6 +BYLAYER + 49 +-0.5 + 62 + 256 + 6 +BYLAYER + 0 +ACDBPLACEHOLDER + 5 +F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E + 0 +RAPIDRTRENDERSETTINGS + 5 +296 +102 +{ACAD_REACTORS +330 +292 +102 +} +330 +292 +100 +AcDbRenderSettings + 90 + 2 + 1 +Coffee-Break Quality +290 + 1 +290 + 0 +290 + 1 +290 + 1 + 1 + + 1 +Renders for 10 minutes + 90 + 3 +100 +AcDbRapidRTRenderSettings + 90 + 3 + 70 + 1 + 90 + 1 + 90 + 10 + 70 + 1 + 70 + 2 + 40 +3.0 + 40 +3.0 +290 + 1 + 0 +RAPIDRTRENDERSETTINGS + 5 +295 +102 +{ACAD_REACTORS +330 +292 +102 +} +330 +292 +100 +AcDbRenderSettings + 90 + 2 + 1 +High +290 + 1 +290 + 0 +290 + 1 +290 + 1 + 1 + + 1 +Applies 10 rendering levels + 90 + 2 +100 +AcDbRapidRTRenderSettings + 90 + 3 + 70 + 0 + 90 + 10 + 90 + 1 + 70 + 2 + 70 + 2 + 40 +3.0 + 40 +3.0 +290 + 1 + 0 +RAPIDRTRENDERSETTINGS + 5 +293 +102 +{ACAD_REACTORS +330 +292 +102 +} +330 +292 +100 +AcDbRenderSettings + 90 + 2 + 1 +Low +290 + 1 +290 + 0 +290 + 1 +290 + 1 + 1 + + 1 +Applies 1 rendering level + 90 + 0 +100 +AcDbRapidRTRenderSettings + 90 + 3 + 70 + 0 + 90 + 1 + 90 + 1 + 70 + 0 + 70 + 2 + 40 +3.0 + 40 +3.0 +290 + 1 + 0 +RAPIDRTRENDERSETTINGS + 5 +297 +102 +{ACAD_REACTORS +330 +292 +102 +} +330 +292 +100 +AcDbRenderSettings + 90 + 2 + 1 +Lunch Quality +290 + 1 +290 + 0 +290 + 1 +290 + 1 + 1 + + 1 +Renders for 60 minutes + 90 + 4 +100 +AcDbRapidRTRenderSettings + 90 + 3 + 70 + 1 + 90 + 1 + 90 + 60 + 70 + 2 + 70 + 2 + 40 +3.0 + 40 +3.0 +290 + 1 + 0 +RAPIDRTRENDERSETTINGS + 5 +294 +102 +{ACAD_REACTORS +330 +292 +102 +} +330 +292 +100 +AcDbRenderSettings + 90 + 2 + 1 +Medium +290 + 1 +290 + 0 +290 + 1 +290 + 1 + 1 + + 1 +Applies 5 rendering levels + 90 + 1 +100 +AcDbRapidRTRenderSettings + 90 + 3 + 70 + 0 + 90 + 5 + 90 + 1 + 70 + 1 + 70 + 2 + 40 +3.0 + 40 +3.0 +290 + 1 + 0 +RAPIDRTRENDERSETTINGS + 5 +298 +102 +{ACAD_REACTORS +330 +292 +102 +} +330 +292 +100 +AcDbRenderSettings + 90 + 2 + 1 +Overnight Quality +290 + 1 +290 + 0 +290 + 1 +290 + 1 + 1 + + 1 +Renders for 12 hours + 90 + 5 +100 +AcDbRapidRTRenderSettings + 90 + 3 + 70 + 1 + 90 + 1 + 90 + 720 + 70 + 2 + 70 + 2 + 40 +3.0 + 40 +3.0 +290 + 1 + 0 +SCALE + 5 +B7 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1:1 +140 +1.0 +141 +1.0 +290 + 1 + 0 +SCALE + 5 +124 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1:2 +140 +1.0 +141 +2.0 +290 + 0 + 0 +SCALE + 5 +125 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1:4 +140 +1.0 +141 +4.0 +290 + 0 + 0 +SCALE + 5 +126 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1:5 +140 +1.0 +141 +5.0 +290 + 0 + 0 +SCALE + 5 +127 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1:8 +140 +1.0 +141 +8.0 +290 + 0 + 0 +SCALE + 5 +128 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1:10 +140 +1.0 +141 +10.0 +290 + 0 + 0 +SCALE + 5 +129 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1:16 +140 +1.0 +141 +16.0 +290 + 0 + 0 +SCALE + 5 +12A +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1:20 +140 +1.0 +141 +20.0 +290 + 0 + 0 +SCALE + 5 +12B +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1:30 +140 +1.0 +141 +30.0 +290 + 0 + 0 +SCALE + 5 +12C +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1:40 +140 +1.0 +141 +40.0 +290 + 0 + 0 +SCALE + 5 +12D +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1:50 +140 +1.0 +141 +50.0 +290 + 0 + 0 +SCALE + 5 +12E +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1:100 +140 +1.0 +141 +100.0 +290 + 0 + 0 +SCALE + 5 +12F +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +2:1 +140 +2.0 +141 +1.0 +290 + 0 + 0 +SCALE + 5 +130 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +4:1 +140 +4.0 +141 +1.0 +290 + 0 + 0 +SCALE + 5 +131 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +8:1 +140 +8.0 +141 +1.0 +290 + 0 + 0 +SCALE + 5 +132 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +10:1 +140 +10.0 +141 +1.0 +290 + 0 + 0 +SCALE + 5 +133 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +100:1 +140 +100.0 +141 +1.0 +290 + 0 + 0 +SCALE + 5 +134 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1/128" = 1'-0" +140 +0.0078125 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +135 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1/64" = 1'-0" +140 +0.015625 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +136 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1/32" = 1'-0" +140 +0.03125 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +137 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1/16" = 1'-0" +140 +0.0625 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +138 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +3/32" = 1'-0" +140 +0.09375 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +139 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1/8" = 1'-0" +140 +0.125 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +13A +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +3/16" = 1'-0" +140 +0.1875 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +13B +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1/4" = 1'-0" +140 +0.25 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +13C +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +3/8" = 1'-0" +140 +0.375 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +13D +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1/2" = 1'-0" +140 +0.5 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +13E +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +3/4" = 1'-0" +140 +0.75 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +13F +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1" = 1'-0" +140 +1.0 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +140 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1-1/2" = 1'-0" +140 +1.5 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +141 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +3" = 1'-0" +140 +3.0 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +142 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +6" = 1'-0" +140 +6.0 +141 +12.0 +290 + 0 + 0 +SCALE + 5 +143 +102 +{ACAD_REACTORS +330 +B6 +102 +} +330 +B6 +100 +AcDbScale + 70 + 0 +300 +1'-0" = 1'-0" +140 +12.0 +141 +12.0 +290 + 0 + 0 +ACDBSECTIONVIEWSTYLE + 5 +1AA +102 +{ACAD_XDICTIONARY +360 +1C4 +102 +} +102 +{ACAD_REACTORS +330 +1A8 +102 +} +330 +1A8 +100 +AcDbModelDocViewStyle + 70 + 0 + 3 +Imperial24 +290 + 0 +100 +AcDbSectionViewStyle + 70 + 0 + 71 + 0 + 90 + 78 + 71 + 1 +340 +11 + 62 + 256 + 40 +0.24 +340 +0 +340 +0 + 62 + 256 + 40 +0.24 +300 +I, O, Q, S, X, Z + 40 +0.48 + 90 + 3 + 40 +0.18 + 90 + 1 + 71 + 2 +340 +16 + 90 + 25 + 62 + 256 +340 +16 + 90 + 50 + 62 + 256 + 40 +0.24 + 40 +0.0 + 40 +0.24 + 71 + 3 +340 +11 + 62 + 256 + 40 +0.24 + 90 + 1 + 40 +0.75 + 90 + 1 +300 +%<\AcVar ViewType \f "%tc1">% %<\AcVar ViewSectionStartId>%-%<\AcVar ViewSectionEndId>%\PSCALE %<\AcVar ViewScale \f "%sn">% + 71 + 4 + 62 + 256 + 62 + 257 +300 +ANSI31 + 40 +1.0 + 90 + 0 +290 + 0 +290 + 0 + 90 + 6 + 40 +0.0 + 40 +1.570796326794896 + 40 +0.2617993877991494 + 40 +1.308996938995747 + 40 +-0.2617993877991494 + 40 +1.832595714594046 + 0 +TABLESTYLE + 5 +87 +102 +{ACAD_XDICTIONARY +360 +104 +102 +} +102 +{ACAD_REACTORS +330 +86 +102 +} +330 +86 +100 +AcDbTableStyle +280 + 0 + 3 +Standard + 70 + 0 + 71 + 0 + 40 +0.06 + 41 +0.06 +280 + 0 +281 + 0 + 7 +Standard +140 +0.18 +170 + 2 + 62 + 0 + 63 + 7 +283 + 0 + 90 + 512 + 91 + 0 + 1 + +274 + -2 +284 + 1 + 64 + 0 +275 + -2 +285 + 1 + 65 + 0 +276 + -2 +286 + 1 + 66 + 0 +277 + -2 +287 + 1 + 67 + 0 +278 + -2 +288 + 1 + 68 + 0 +279 + -2 +289 + 1 + 69 + 0 + 7 +Standard +140 +0.25 +170 + 5 + 62 + 0 + 63 + 7 +283 + 0 + 90 + 512 + 91 + 0 + 1 + +274 + -2 +284 + 1 + 64 + 0 +275 + -2 +285 + 1 + 65 + 0 +276 + -2 +286 + 1 + 66 + 0 +277 + -2 +287 + 1 + 67 + 0 +278 + -2 +288 + 1 + 68 + 0 +279 + -2 +289 + 1 + 69 + 0 + 7 +Standard +140 +0.18 +170 + 5 + 62 + 0 + 63 + 7 +283 + 0 + 90 + 512 + 91 + 0 + 1 + +274 + -2 +284 + 1 + 64 + 0 +275 + -2 +285 + 1 + 65 + 0 +276 + -2 +286 + 1 + 66 + 0 +277 + -2 +287 + 1 + 67 + 0 +278 + -2 +288 + 1 + 68 + 0 +279 + -2 +289 + 1 + 69 + 0 + 0 +VISUALSTYLE + 5 +9F +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +2dWireframe + 70 + 4 +177 + 3 +291 + 0 + 70 + 58 + 90 + 0 +176 + 1 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 90 + 0 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 1 +176 + 1 + 90 + 4 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 257 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 5 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 1 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +9E +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Basic + 70 + 7 +177 + 3 +291 + 1 + 70 + 58 + 90 + 1 +176 + 1 + 90 + 0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 0 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 0 +176 + 1 + 90 + 4 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 8 +176 + 1 + 62 + 7 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 5 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 1 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +A5 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Brighten + 70 + 12 +177 + 3 +291 + 1 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 90 + 0 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 1 +176 + 1 + 90 + 4 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 8 +176 + 1 + 62 + 7 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 5 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 1 +176 + 1 + 40 +50.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +A9 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +ColorChange + 70 + 16 +177 + 3 +291 + 1 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 90 + 3 +176 + 1 + 90 + 0 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 8 +420 + 8421504 +176 + 1 + 90 + 1 +176 + 1 + 90 + 4 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 8 +176 + 1 + 62 + 8 +420 + 8421504 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 5 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 1 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +A2 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Conceptual + 70 + 9 +177 + 3 +291 + 0 + 70 + 58 + 90 + 3 +176 + 1 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 90 + 0 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +40.0 +176 + 1 + 90 + 8 +176 + 1 + 62 + 7 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 3 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 1 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +A4 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Dim + 70 + 11 +177 + 3 +291 + 1 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 90 + 0 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 1 +176 + 1 + 90 + 4 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 8 +176 + 1 + 62 + 7 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 5 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 1 +176 + 1 + 40 +-50.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +175 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +EdgeColorOff + 70 + 22 +177 + 3 +291 + 1 + 70 + 58 + 90 + 2 +176 + 0 + 90 + 2 +176 + 0 + 90 + 0 +176 + 0 + 90 + 0 +176 + 0 + 40 +0.6 +176 + 0 + 40 +30.0 +176 + 0 + 62 + 7 +420 + 16777215 +176 + 0 + 90 + 1 +176 + 0 + 90 + 4 +176 + 0 + 62 + 7 +176 + 0 + 62 + 257 +176 + 0 + 90 + 1 +176 + 0 + 90 + 1 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 8 +176 + 2 + 62 + 7 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 1 +176 + 0 + 90 + 6 +176 + 0 + 90 + 2 +176 + 0 + 62 + 7 +176 + 0 + 90 + 5 +176 + 0 + 90 + 0 +176 + 0 + 90 + 0 +176 + 0 +290 + 0 +176 + 0 + 90 + 1 +176 + 0 + 40 +0.0 +176 + 0 + 90 + 0 +176 + 0 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +A8 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Facepattern + 70 + 15 +177 + 3 +291 + 1 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 90 + 0 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 1 +176 + 1 + 90 + 4 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 8 +176 + 1 + 62 + 7 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 5 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 1 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +9A +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Flat + 70 + 0 +177 + 3 +291 + 1 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 90 + 2 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 8 +176 + 1 + 62 + 7 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 5 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 13 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +9B +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +FlatWithEdges + 70 + 1 +177 + 3 +291 + 1 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 90 + 2 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 1 +176 + 1 + 90 + 0 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 257 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 5 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 13 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +9C +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Gouraud + 70 + 2 +177 + 3 +291 + 1 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 90 + 2 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 7 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 5 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 13 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +9D +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +GouraudWithEdges + 70 + 3 +177 + 3 +291 + 1 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 90 + 2 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 1 +176 + 1 + 90 + 0 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 257 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 5 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 13 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +A1 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Hidden + 70 + 6 +177 + 3 +291 + 0 + 70 + 58 + 90 + 1 +176 + 1 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 90 + 0 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 40 +40.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 257 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 3 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 1 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +173 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +JitterOff + 70 + 20 +177 + 3 +291 + 1 + 70 + 58 + 90 + 2 +176 + 0 + 90 + 2 +176 + 0 + 90 + 0 +176 + 0 + 90 + 0 +176 + 0 + 40 +0.6 +176 + 0 + 40 +30.0 +176 + 0 + 62 + 7 +420 + 16777215 +176 + 0 + 90 + 1 +176 + 0 + 90 + 4 +176 + 0 + 62 + 7 +176 + 0 + 62 + 257 +176 + 0 + 90 + 1 +176 + 0 + 90 + 1 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 10 +176 + 2 + 62 + 7 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 1 +176 + 0 + 90 + 6 +176 + 0 + 90 + 2 +176 + 0 + 62 + 7 +176 + 0 + 90 + 5 +176 + 0 + 90 + 0 +176 + 0 + 90 + 0 +176 + 0 +290 + 0 +176 + 0 + 90 + 1 +176 + 0 + 40 +0.0 +176 + 0 + 90 + 0 +176 + 0 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +A7 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Linepattern + 70 + 14 +177 + 3 +291 + 1 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 90 + 0 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 1 +176 + 1 + 90 + 4 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 7 +176 + 1 + 90 + 7 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 8 +176 + 1 + 62 + 7 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 5 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 1 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +174 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +OverhangOff + 70 + 21 +177 + 3 +291 + 1 + 70 + 58 + 90 + 2 +176 + 0 + 90 + 2 +176 + 0 + 90 + 0 +176 + 0 + 90 + 0 +176 + 0 + 40 +0.6 +176 + 0 + 40 +30.0 +176 + 0 + 62 + 7 +420 + 16777215 +176 + 0 + 90 + 1 +176 + 0 + 90 + 4 +176 + 0 + 62 + 7 +176 + 0 + 62 + 257 +176 + 0 + 90 + 1 +176 + 0 + 90 + 1 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 9 +176 + 2 + 62 + 7 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 1 +176 + 0 + 90 + 6 +176 + 0 + 90 + 2 +176 + 0 + 62 + 7 +176 + 0 + 90 + 5 +176 + 0 + 90 + 0 +176 + 0 + 90 + 0 +176 + 0 +290 + 0 +176 + 0 + 90 + 1 +176 + 0 + 40 +0.0 +176 + 0 + 90 + 0 +176 + 0 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +A3 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Realistic + 70 + 8 +177 + 3 +291 + 0 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 3 +176 + 1 + 90 + 0 +176 + 1 + 90 + 2 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 8 +176 + 1 + 62 + 257 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 3 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 13 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +182 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Shaded + 70 + 27 +177 + 3 +291 + 0 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 90 + 2 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 0 +176 + 1 + 90 + 4 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 8 +176 + 1 + 62 + 257 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 8 +420 + 7895160 +176 + 1 + 90 + 3 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 5 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +181 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Shaded with edges + 70 + 26 +177 + 3 +291 + 0 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 90 + 2 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 1 +176 + 1 + 90 + 10 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 8 +176 + 1 + 62 + 257 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 3 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 5 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +17E +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Shades of Gray + 70 + 23 +177 + 3 +291 + 0 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 90 + 3 +176 + 1 + 90 + 0 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 62 + 7 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +40.0 +176 + 1 + 90 + 8 +176 + 1 + 62 + 7 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 3 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 1 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +17F +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Sketchy + 70 + 24 +177 + 3 +291 + 0 + 70 + 58 + 90 + 1 +176 + 1 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 90 + 0 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 62 + 7 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +40.0 +176 + 1 + 90 + 11 +176 + 1 + 62 + 7 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 6 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 1 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +A6 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Thicken + 70 + 13 +177 + 3 +291 + 1 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 90 + 0 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 1 +176 + 1 + 90 + 4 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 12 +176 + 1 + 62 + 7 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 5 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 1 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +A0 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +Wireframe + 70 + 5 +177 + 3 +291 + 0 + 70 + 58 + 90 + 0 +176 + 1 + 90 + 2 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 + 40 +0.6 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 1 +176 + 1 + 90 + 4 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 257 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 3 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 1 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +VISUALSTYLE + 5 +180 +102 +{ACAD_REACTORS +330 +99 +102 +} +330 +99 +100 +AcDbVisualStyle + 2 +X-Ray + 70 + 25 +177 + 3 +291 + 0 + 70 + 58 + 90 + 2 +176 + 1 + 90 + 2 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +0.5 +176 + 1 + 40 +30.0 +176 + 1 + 62 + 7 +420 + 16777215 +176 + 1 + 90 + 1 +176 + 1 + 90 + 0 +176 + 1 + 62 + 7 +176 + 1 + 62 + 257 +176 + 1 + 90 + 1 +176 + 1 + 90 + 1 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 8 +176 + 1 + 62 + 7 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 1 +176 + 1 + 90 + 6 +176 + 1 + 90 + 2 +176 + 1 + 62 + 7 +176 + 1 + 90 + 3 +176 + 1 + 90 + 0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 13 +176 + 1 + 40 +0.0 +176 + 1 + 90 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 1 +176 + 1 +290 + 1 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 40 +0.0 +176 + 1 + 40 +1.0 +176 + 1 + 90 + 0 +176 + 1 + 62 + 18 +420 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 3 +176 + 1 + 62 + 5 +420 + 255 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 + 90 + 50 +176 + 1 +290 + 0 +176 + 1 + 90 + 50 +176 + 1 + 62 + 256 +176 + 0 + 40 +1.0 +176 + 0 + 90 + 2 +176 + 1 + 1 +strokes_ogs.tif +176 + 1 +290 + 0 +176 + 1 + 40 +1.0 +176 + 1 + 40 +1.0 +176 + 1 + 0 +DICTIONARYVAR + 5 +F0 +102 +{ACAD_REACTORS +330 +66 +102 +} +330 +66 +100 +DictionaryVariables +280 + 0 + 1 +1:1 + 0 +DICTIONARYVAR + 5 +EF +102 +{ACAD_REACTORS +330 +66 +102 +} +330 +66 +100 +DictionaryVariables +280 + 0 + 1 +STANDARD + 0 +DICTIONARYVAR + 5 +89 +102 +{ACAD_REACTORS +330 +66 +102 +} +330 +66 +100 +DictionaryVariables +280 + 0 + 1 +STANDARD + 0 +DICTIONARYVAR + 5 +1B7 +102 +{ACAD_REACTORS +330 +66 +102 +} +330 +66 +100 +DictionaryVariables +280 + 0 + 1 +Imperial24 + 0 +DICTIONARYVAR + 5 +1B8 +102 +{ACAD_REACTORS +330 +66 +102 +} +330 +66 +100 +DictionaryVariables +280 + 0 + 1 +Imperial24 + 0 +DICTIONARYVAR + 5 +67 +102 +{ACAD_REACTORS +330 +66 +102 +} +330 +66 +100 +DictionaryVariables +280 + 0 + 1 +2 + 0 +DICTIONARYVAR + 5 +6B +102 +{ACAD_REACTORS +330 +66 +102 +} +330 +66 +100 +DictionaryVariables +280 + 0 + 1 +1 + 0 +DICTIONARYVAR + 5 +14D +102 +{ACAD_REACTORS +330 +66 +102 +} +330 +66 +100 +DictionaryVariables +280 + 0 + 1 +0 + 0 +DICTIONARYVAR + 5 +14E +102 +{ACAD_REACTORS +330 +66 +102 +} +330 +66 +100 +DictionaryVariables +280 + 0 + 1 +0 + 0 +DICTIONARY + 5 +1C6 +330 +1AC +100 +AcDbDictionary +280 + 1 +281 + 1 + 3 +ACAD_XREC_ROUNDTRIP +360 +35B + 0 +DICTIONARY + 5 +194 +330 +22 +100 +AcDbDictionary +280 + 1 +281 + 1 + 0 +DICTIONARY + 5 +188 +330 +97 +100 +AcDbDictionary +280 + 1 +281 + 1 + 3 +FBXASSET +360 +189 + 0 +DICTIONARY + 5 +186 +330 +96 +100 +AcDbDictionary +280 + 1 +281 + 1 + 3 +FBXASSET +360 +187 + 0 +DICTIONARY + 5 +110 +330 +98 +100 +AcDbDictionary +280 + 1 +281 + 1 + 3 +BUMPTILE +360 +112 + 3 +DIFFUSETILE +360 +111 + 3 +FBXASSET +360 +18A + 3 +OPACITYTILE +360 +113 + 3 +REFLECTIONTILE +360 +114 + 0 +DICTIONARY + 5 +1C4 +330 +1AA +100 +AcDbDictionary +280 + 1 +281 + 1 + 3 +ACAD_XREC_ROUNDTRIP +360 +35A + 0 +DICTIONARY + 5 +104 +330 +87 +100 +AcDbDictionary +280 + 1 +281 + 1 + 3 +ACAD_ROUNDTRIP_2008_TABLESTYLE_CELLSTYLEMAP +360 +359 + 0 +XRECORD + 5 +35B +102 +{ACAD_REACTORS +330 +1C6 +102 +} +330 +1C6 +100 +AcDbXrecord +280 + 1 +102 +DISPLAYNAME + 1 +Imperial24 +102 +FLAGS + 90 + 0 + 0 +XRECORD + 5 +189 +102 +{ACAD_REACTORS +330 +188 +102 +} +330 +188 +100 +AcDbXrecord +280 + 1 + 70 + 1 + 90 +429727718 + 1 +508B35C9-AEE7-456F-BE74-D52651303AC6 +310 +504B03040A0000080000B670623B3DEE336E79000000790000001B0000006175746F6465736B2D64657369676E2D7061636B6167652E786D6C3C3F786D6C2076657273696F6E3D22312E302220656E636F64696E673D227574662D3822203F3E3C666F726D6174733E3C666F726D61743E687474703A2F2F736368656D612E +310 +6175746F6465736B2E636F6D2F64657369676E2D7061636B6167652F323030393C2F666F726D61743E3C2F666F726D6174733E504B0304140006080800B670623BD486FD9CA0000000F4000000130000005B436F6E74656E745F54797065735D2E786D6C7D8EC10E82300C865F65E91D8A1E8C310C0EEA1BF002731658846E +310 +D98AC1B77784ABF1D8FE5FBFBF75BBCE937A534CCEB38643598122B6FEE978D0B0485F9C41B54DDD7D022595594E1A469170414C76A4D9A4D207E29CF43ECE46F218070CC6BECC4078ACAA135ACF422C856C0E68EA1BF5669944DDD7BCDE7B1F8E415D77AECB980613C2E4AC91FC167A2B244592486606FC29C8FD7F045B9A +310 +EF7053A7E60B504B0304140006080800B670623B28ED286BEB0000007C01000008000000636F72652E786D6C8D90416EC3201045AF82665B6183218D6D61A254764FD00B204C5C94182C03558F5F9C3852BBEB6A467FFE9B197D71FA9E6FE8CBACC17AD7012D0820E3B41FAD9B3A48F1826B40272956EFE32F1B20A766D3C1 +310 +268314D3EAD3B24BDABB685CCCAAF3A3D94515828980F42D371D04FD696655A814B3235C8B45E9AB9A4C419F5D8653B2237A1CB163DE672FD6AC209BF3C08E0D1B30EB19C3FC9D72FCC6EB231EFA33EDE96BDD341517E5064B11EDFCBCAF57A3A2194156843498524CAA0FCA5B7268F9E185D42D21A2DCEC7FA0B48CFF8492 +310 +759155FBBB0F2C80A4F991FB408A72CB22977B50B96EB9C91F504B0304140006080800B670623B55019EA25F0000007C00000007000000636E782E786D6C4DCB3B0E80201045D1AD90E915ED2CF8AC45050C11660C8261F98AB1B07AC9C97D42D718D865D3E909258CFD00CCE24AC6E326A164D74DC0B4128928FF32603847 +310 +2BA13128B1252AC747C6BAB984FC68F0B87FE8960A5C09FE86CFB69FBA01504B0304140006080800B670623B7BC41AC871010000D40300000C0000006662782F636F72652E786D6CC553ED6E8320147D15C2DF4501B56B6D90A64BBB27D80B50A5CE54C120983EFEAE1F4DD7A56BBA5FF3879273EEE59E7310BE393735EA95 +310 +ED2AA333CC428A91D2B9292A5D66D8BB63B0C26823B835C67D2BC348CB46657880B1E0A535BE9DA1DC68A7B403549B42CDA0EC3AE530CA6B5864B8CB3F552343E91D5474A7B095F949962A6421D0502778E72C0840D390E18DC5F170E664C205F7BE2A66B62A605A75AC94C522DDEEE3651AEF837817C741F2CE92E02D592D +310 +83FD6ECB76EC7595A651C2C9D02CB8AB9A8BBADC2AE954814544691A3016D0E883256BBA58278B17BA5A53CAC9507ED3E4DBE2C9265F691747B3DCA9ADC382819091F8E1162272A8933DE82182932144F88C09DF060D8140C84E1EEA8B8F1340BDCA9DB1093085F157AA97B557309586747CD0EF0B4EA64E983A6E0EAEEFCE +310 +889E9EF17847386AA94B30211D2C0FDE0D3AF9A4610EAD5536E8DD1969631B593F67E381C33F5157F17725E5A636F6BF1491F980C8FC7B90E13E8A2F504B0304140006080800B670623B70102A487D0000009D0000000B0000006662782F636E782E786D6C4DCCD10A83201886E15B91FFDC95A5C30335ACCDFB886521330D +310 +A76397BF1A1DECE883878F57749FD5A3B74D2F17830472A901D9F088930B8B849267CC01754AA418F3DF0D5018572BE160506249B16C274D761E8BCFBB7A179E27F65CB39A0F14D396184C9921B81F34C3ACA1B4B99ABB6E6F062A25AA5F69DF23ACBE504B0304140006080800B670623B3610CA3329010000120200003100 +310 +00006662782F42384135303843342D343331462D343546312D424341352D3532343432364645413344462F636F72652E786D6C8D515B6EC32010BC0AE22B55850D0E446E848992B43E412FE000495162B07854397EA176A4F62F3F2C9A9D991D58BEBB8F37F0AD7D30CE76905418026DA553C65E3A98E219B510EC04F7CEC5 +310 +3F3408EC30EA0E16180A7EF12E4D0B249D8DDAC68C5AA7F4020E21E80881BCE54B0783FCD2E3500D296646B856D320AFC34557A4CAEDCC133C449F0380794839A138B47B86DB2345744D7A44594FD0E1B867883594369BFE63BF7EEF793D0B054FC9A8456E548E63CE46FB674D8A58F068C6477CE9F510B582A2C1F80D1182 +310 +70F349E816B32D65AFB8DD62CCEB42FF274A937A52948C8DEB66893BCB021484D7734370E5D2E9F608A3EF93F3119479ABA0E50B14B8C298119A43CCC4FCFCECB8A18B6388CEE7EF5D5D4F994BD86CBBA182D76543B9FCAE2FD7B24DF103504B0304140006080800B670623B1B8617914E0100003303000030000000666278 +310 +2F42384135303843342D343331462D343546312D424341352D3532343432364645413344462F636E782E786D6CD593C16EC32010447F0571AD6CE3D489D20813A9877E410FBD62D82434182CC055F3F75D1292E692438FBD206B7698593D61BEFD1E2DF982108D773D6D6B460938E5B571FB9ECE6957AD29D90A1EBC4F7736 +310 +4A9C1CA1A759A682EF839FA72269D8C9D92654AD71C72206887E0E0A22CAF926B15E498B01E0AA19451807D01A34B919F16B070157417138917400F2F6FA41FCF0092A45DEE414C19BDC21783E4BD39D891265658C3D8DEA00A3ACE59CBC8678ACB54CB26EEB6BD7839D7E9348BE70AD9C8321B9BCA7F9A4A26C542347DEE0 +310 +50F09802D22BA6749A800A394DD62899107273365E3C82CFC6A55557BC06C10774B7ABE7AEC3B0F34CF06446280E154026D0542C187BA9DAB6628BF7B6DBB0E5A65B3EB1F58631DE64BBE083F7B65CC218087E22CABB042E51D1F2268F1FE12B3EA24D40D63E9CFE00120194D60B9D5BC67FE6D39CDF37E20AF817881F504B +310 +0304140006080800B670623BBFEF2E3B690500003C100000340000006662782F42384135303843342D343331462D343546312D424341352D3532343432364645413344462F6F626A656374732E786D6CAD575D739B3814FD2B0CFB0C98D84E931D4227B6938EA793B6534F3BFB90198F0099B001899144EAECAFDF231018B0 +310 +D37A3FFC609090EEE7B94757C1FB7D915B2F54C88CB31BDB7727B64559CC938CA53776A576CE956DBD0F03C1B9EA2DB32D460A7A63EB693B0C52C1ABD24C257447AA5C8D66EF177F5832A68CDA569C13296F6C193FD182B8A4523CA1F2D94D88226EBADBBBBEDB2C1C8A4D05299F46424B222853B014F30C52ACC6287F389C +310 +D85E1878FA7B18E87FB36AFAF62AAFF667A83F635211161F291B8999FAB3D5DDC5FCDA99CF6653677679E13B8BFBC5CAF1DF4D27CBCBE5F2FAF6EE1A8AA51290642C117467871F101B91C581D77CEA2C36869F3229E68CD1B8F61F1ED6361B89092D294B90C78C4A68EB793D190E7D1D9BDEE7A91E1B95FDE9F3BCEAEFE809 +310 +6A6D6F9F7D4B79F4275C90E7A04248AA51D5C4C738DAE06F72885AC4796EBE65D22A88A22223B995679120E2D50EB1542F098381188D4EA111AE3DB043FF20AF6F2B4AE3D7D83D65E5AECA730D4D3BAC91BD05FEA992B565D95F549CD6069B90AB2A63EACA38A45E4B6A87D781574F86817E4C2FCCC72690D62E2729520E37 +310 +9BAF234797BAF6D62B3BFC781FED37BA1E4F2B2F052FA9501A3E80569BB8F6A9A364D4FA67654E6F803383981F82F21591FF84F23D9882BC896C3FDBCF8C162508933B2E0AE4C69DD43F6BD2BE38DDDBF10BF86CB4FA784D37F3AF16075E67EC9030DECA1FC0A55373F5DFF3378C591FA9BDF485C10B0A8C8BA909E5262639 +310 +A8E72163488806A595734C81CB77D1DE0ED7604A303B76913CEC0272F412782FF88EFF4636D8AA56DF7B322474C4B767303F625997056C7B032C473C592B36CEBD15F3F9FF5233B7ABCDC74D257624A60F8658567497B14CE1EC3C80B76FD02011038F9EE9EB0F2E12387A9C844D3F09855125116D1D731DF8B43D2CF4E0A0 +310 +79A020C6BE946BCE0B7EA1E1219331CD73C228AF5A2DDD31A4B1DA918CE91350854D40A71723140BFA92E94E62B0626016CEFA5864A50E5977E8753CEDBEE1CBB7F52780D20E17AF0BA0F5F9B02A3AD07DCC732EB6D1EBB661421BE0C39E21BABF0FE16D22EA6929407C8DE596724C88B749B6DB5512BC7B228ADFD7CB7EA6 +310 +3A22397E690BA8D5D8D4E40CEC8CDA23C2C0B7D5898640101CEC9C6D331CE328CB93CAFBBAC70A642DF70DF95155945B52F08AA973C23469FD69ADEF84F7BB18F983A8F8C90E37F5D35980B2139A1C523540813E824EF83448CF7C72B598CE97D7CEEDDDDD3B6736BFBC771677EF66CE6A7E7139F7A793E9EDF2B2B3A8B624 +310 +0CDA13AA5F8388264EC4A0129909F3B7F5A16CEDF08128E97D680ACA4B9BE7B7B58BB638F0B00700EF36AE8B32A705658A68F83EE897FC2B41573190D1CCAF0B92D233A47CFEB019D9809933F67DD1A5467F8CF69AD9A5AE859342D45355448C64F968A371DC2D596ADC6E43D93D1B466F877A64E2893E54F23C4B40388995 +310 +525E5005DA3983ED45412510D3EFCA13737B3863F74BD30F2A12E5B4B5E489A025CE25500DB855B11ACE5BBFA185D35DD48D8DC3CE7DC69F2944DB42AA6F6CF09BCA622ADD08C7A36E7C6AE18000BAB08E040135C2D21C9D75B3A91B77BB746C403F38018DFE6EC939DD92F16B40BC05294B9CDA035AAD6D330AE27FE4786B +310 +8E45145EA34A75BE1C263A676A35A8ACC6297337400BF16C54EF515F3F77CB77EB3583889C754FFA29081AA98E2222ADEF05DA3E63D2EDEA8B8756C05A7FB6CABC4A91CBF325398202CC952E70A008A830324BA200D5E5EF8F5F04C75DB4B0EE3340E071C98B823333B83577596BF3849B69F2085AA0FAFA212F26BEFF5877 +310 +FDE622B28D88A42E49E43326DA82D31E8CB006401E1DA726059ECEC168F97A6599036306A135688F9AD2E61A6EA178F4CD51974A9F2CCB9F36FE5EBD14601068D9C3BF01504B0304140006080000B670623B000000000000000000000000350000006662782F42384135303843342D343331462D343546312D424341352D35 +310 +32343432364645413344462F76657274696365732E62696E504B0304140006080800B670623BF270F1330600000004000000360000006662782F42384135303843342D343331462D343546312D424341352D3532343432364645413344462F747269616E676C65732E62696E636660600000504B0304140006080000B67062 +310 +3B000000000000000000000000370000006662782F42384135303843342D343331462D343546312D424341352D3532343432364645413344462F617474726962757465732E62696E504B0304140006080800B670623B08DA0BD19000000041010000360000006662782F42384135303843342D343331462D343546312D4243 +310 +41352D3532343432364645413344462F6469726563746F72792E786D6C858F410AC3201045AF22B36F53E9A60B35CBEEBAE80D244E44489C6234F4F835464A48035D0DF398FF9F8AF63D0E6CC63039F212F8F9020C7D47C6792B21C5FE7403D62A1188E2E60C98D7234A5830286103A55745067B9D86B8A3CF9C7F90C11DE6 +310 +07FB144396D7B6A82D282E9A152AD114D5EF2CB866EEE831B8EEA07A7BB5A8D7D61A2BAAEB7FD5F70921FF497D00504B0304140006080800B670623B3B4606F3FE000000A00100003B0000006662782F42384135303843342D343331462D343546312D424341352D3532343432364645413344462F7265736F75726365732F +310 +636F72652E786D6C8D504B6E833010BD0A9A6D65B08134808CA354A427E8052C332156828DFCA97AFC9A40A576D7CDCCE87D344F8F9FBEE647F689CE6B6B7A6039850C8DB2A336530F315C4903D94970676DF82583CCC8197B5861107C72362E3BA4AC096842428D1D7107A5F71820538F74F4E0D50D6799CB1892C2DFF345 +310 +AABB9C306779A2934E701F5C0A906D4FD609C2A1B7D129F4BCD858C163D4E3AED163FAA9AF1A1D88F67CA98E6D35906AA82A52BFB39ABCD5CD915C86331BD86BD3B665CD8BD52C78D0F34F46E550061C419494B6843142CB0F5677F4D0D58717DA7494F26295FF31C565FCA7296A13AA728FBBD93C0896823C09C18BB5AFB4 +310 +9E65A6BD762BBE01504B010214000A0000080000B670623B3DEE336E79000000790000001B00000000000000000000000000000000006175746F6465736B2D64657369676E2D7061636B6167652E786D6C504B01021400140006080800B670623BD486FD9CA0000000F40000001300000000000000000000000000B2000000 +310 +5B436F6E74656E745F54797065735D2E786D6C504B01021400140006080800B670623B28ED286BEB0000007C010000080000000000000000000000000083010000636F72652E786D6C504B01021400140006080800B670623B55019EA25F0000007C000000070000000000000000000000000094020000636E782E786D6C50 +310 +4B01021400140006080800B670623B7BC41AC871010000D40300000C00000000000000000000000000180300006662782F636F72652E786D6C504B01021400140006080800B670623B70102A487D0000009D0000000B00000000000000000000000000B30400006662782F636E782E786D6C504B01021400140006080800B6 +310 +70623B3610CA3329010000120200003100000000000000000000000000590500006662782F42384135303843342D343331462D343546312D424341352D3532343432364645413344462F636F72652E786D6C504B01021400140006080800B670623B1B8617914E010000330300003000000000000000000000000000D10600 +310 +006662782F42384135303843342D343331462D343546312D424341352D3532343432364645413344462F636E782E786D6C504B01021400140006080800B670623BBFEF2E3B690500003C10000034000000000000000000000000006D0800006662782F42384135303843342D343331462D343546312D424341352D35323434 +310 +32364645413344462F6F626A656374732E786D6C504B01021400140006080000B670623B0000000000000000000000003500000000000000000000000000280E00006662782F42384135303843342D343331462D343546312D424341352D3532343432364645413344462F76657274696365732E62696E504B010214001400 +310 +06080800B670623BF270F133060000000400000036000000000000000000000000007B0E00006662782F42384135303843342D343331462D343546312D424341352D3532343432364645413344462F747269616E676C65732E62696E504B01021400140006080000B670623B00000000000000000000000037000000000000 +310 +00000000000000D50E00006662782F42384135303843342D343331462D343546312D424341352D3532343432364645413344462F617474726962757465732E62696E504B01021400140006080800B670623B08DA0BD1900000004101000036000000000000000000000000002A0F00006662782F42384135303843342D3433 +310 +31462D343546312D424341352D3532343432364645413344462F6469726563746F72792E786D6C504B01021400140006080800B670623B3B4606F3FE000000A00100003B000000000000000000000000000E1000006662782F42384135303843342D343331462D343546312D424341352D3532343432364645413344462F72 +310 +65736F75726365732F636F72652E786D6C504B0506000000000E000E0080040000651100000000 + 0 +XRECORD + 5 +187 +102 +{ACAD_REACTORS +330 +186 +102 +} +330 +186 +100 +AcDbXrecord +280 + 1 + 70 + 1 + 90 +429727718 + 1 +887D55A9-D0DD-414E-B741-A7DCD4DA2B4C +310 +504B03040A0000080000B670623B3DEE336E79000000790000001B0000006175746F6465736B2D64657369676E2D7061636B6167652E786D6C3C3F786D6C2076657273696F6E3D22312E302220656E636F64696E673D227574662D3822203F3E3C666F726D6174733E3C666F726D61743E687474703A2F2F736368656D612E +310 +6175746F6465736B2E636F6D2F64657369676E2D7061636B6167652F323030393C2F666F726D61743E3C2F666F726D6174733E504B0304140006080800B670623BD486FD9CA0000000F4000000130000005B436F6E74656E745F54797065735D2E786D6C7D8EC10E82300C865F65E91D8A1E8C310C0EEA1BF002731658846E +310 +D98AC1B77784ABF1D8FE5FBFBF75BBCE937A534CCEB38643598122B6FEE978D0B0485F9C41B54DDD7D022595594E1A469170414C76A4D9A4D207E29CF43ECE46F218070CC6BECC4078ACAA135ACF422C856C0E68EA1BF5669944DDD7BCDE7B1F8E415D77AECB980613C2E4AC91FC167A2B244592486606FC29C8FD7F045B9A +310 +EF7053A7E60B504B0304140006080800B670623BBC1638F5EC0000007C01000008000000636F72652E786D6C8D90416EC3201045AF82665B61836BE2D8C24471DB9CA01740401C94182C03558F5F9C3852BBEB6A467FFE9B197D7EF89E6EE8CB2CC17AD7032D0820E394D7D68D3DA478C67B4007C117EFE32F1B202727D3C3 +310 +2A83E0E3E2D3BC49CABB685CCCAAF3DA6CA20CC14440EA969B1E82BA98491632C5EC08D76296EA2A4753D06797E194AC468F2356E77DF66CCD02825543D50CACC62D39ED70FDC68EB8FD68F6F8787A6784342D1DDA1D2F5758F068A7E77DB518198D065111D2624A31A93E69DD11D6D5EC85EC3B4278B9DAFF4069D6FF8492 +310 +75F1B5DADE7D600104CD8FDC0782976B16B9DC83CA75CD4DFC00504B0304140006080800B670623B55019EA25F0000007C00000007000000636E782E786D6C4DCB3B0E80201045D1AD90E915ED2CF8AC45050C11660C8261F98AB1B07AC9C97D42D718D865D3E909258CFD00CCE24AC6E326A164D74DC0B4128928FF326038 +310 +472BA13128B1252AC747C6BAB984FC68F0B87FE8960A5C09FE86CFB69FBA01504B0304140006080800B670623B3586FFB671010000D40300000C0000006662782F636F72652E786D6CC553ED6E8320147D15C2DF4541A76D6D90A6DDD627D80B50A4CE54C120983EFEAE1F4DD7A55BBA5FF3879273EEE59E7310B6393735EA +310 +95ED2AA3731C851423A5A5292A5DE6D8BB63B0C268C39935C67D29C3488B46E578803167A535BE9D2169B453DA01AA4DA16650749D7218C91A1639EEE4876A4428BC838AEE14B6429E44A9C228041AEA38EB9C0501681A32BC313F1ECE8C4C3867DE57C5CC56054CAB8E95B298A7F12E5EEED224C8E87E11242FE936C8DE96 +310 +AB60BB7F4D295D66D12E5B30323473E6AAE6A24E5A259C2A308F29CD82280A68FC1E256B9AAE93F489AED694323294DF34F9B678B0C957DA3DC7B3DCA9ADC33C022123F1CD2D44E450277AD04338234388F01913BE0D1A0281909D38D4171F27807A259DB1093085F157AA17B557309586747CD0CF0B46A64E983A6E0EAEEF +310 +CE881F9EF1FB8E70D44297604238581EBC1B74B249C31C5AAB6CD0BB33D2C636A27ECCC62F0EFF445DC5DF95244D6DEC7F2922F30191F9F720C37DE49F504B0304140006080800B670623B4220A0FF7D0000009D0000000B0000006662782F636E782E786D6C4DCCD10A83201886E15B91FFDCE542CB0335D0EC3E6259C84A +310 +C3E9D8E5AF46073BFAE0E1E315DD675BD1DBA5978F41C2FD4600B9F088930F8B849267CC01754AA418F3DF0D50183727E164506249B1EC174D6E1ECB9A0F5D7D785E68DB8151CD286E5A5363CA6B8A39E90D36D40E96B086F55A43A544F52B1D7B86D517504B0304140006080800B670623B2FA3FEDE290100001202000031 +310 +0000006662782F45374635344235342D363743322D343832342D383044432D4334454645303536354442422F636F72652E786D6C8D515B6EC32010BC0AE22B55850D2E386E848994D7097A0117488A1283C5A3CAF10BB523B57FF961D1ECCCECC0F2ED7DBC816FED8371B687A4C210682B9D32F6D2C314CFA883602BB8772E +310 +FEA141608751F7B0C050F08B77695A20E96CD43666D43AA5177008414708E42D5F7A18E4971E876A483133C2B59A06791D2EBA22556E679EE021FA1C00CC43CA09C5717D6274C7286AD7FB06D1AEA1A8C3873DDAD3E3E98859CB0EBB1DAF67A1E02919B5C88DCA71CCD968FFAC49110B1ECDF8882FBD1EA256503418BF2342 +310 +106E3E08DD60B6A1EC15771B8C795DE8FF4469524F8A92B1F1AD59E2CEB20005E1F5DC105CB9F4797B84D1F7C9F908CABC55D0F2050A5C61CC1AD2F27A26E6E767C7962E8E213A9FBF7775FDCC5CC266DB960A5E970DE5F2BBBE5CCB36C50F504B0304140006080800B670623B1B8617914E01000033030000300000006662 +310 +782F45374635344235342D363743322D343832342D383044432D4334454645303536354442422F636E782E786D6CD593C16EC32010447F0571AD6CE3D489D20813A9877E410FBD62D82434182CC055F3F75D1292E692438FBD206B7698593D61BEFD1E2DF982108D773D6D6B460938E5B571FB9ECE6957AD29D90A1EBC4F77 +310 +364A9C1CA1A759A682EF839FA72269D8C9D92654AD71C72206887E0E0A22CAF926B15E498B01E0AA19451807D01A34B919F16B070157417138917400F2F6FA41FCF0092A45DEE414C19BDC21783E4BD39D891265658C3D8DEA00A3ACE59CBC8678ACB54CB26EEB6BD7839D7E9348BE70AD9C8321B9BCA7F9A4A26C542347DE +310 +E050F09802D22BA6749A800A394DD62899107273365E3C82CFC6A55557BC06C10774B7ABE7AEC3B0F34CF06446280E154026D0542C187BA9DAB6628BF7B6DBB0E5A65B3EB1F58631DE64BBE083F7B65CC218087E22CABB042E51D1F2268F1FE12B3EA24D40D63E9CFE00120194D60B9D5BC67FE6D39CDF37E20AF817881F50 +310 +4B0304140006080800B670623B29652116630500003C100000340000006662782F45374635344235342D363743322D343832342D383044432D4334454645303536354442422F6F626A656374732E786D6CAD575D739B3814FD2B0CFB0C989834C90EA1639B24E3E9A6EDD4D3CE3E64C623834CD8006224913AFDF57B040203 +310 +B65BEF871F0C12D2FD3CF7E8CA7FBFCB33E3957291B2E2D674ED8969D02262715A24B76625B7D6B569BC0F7CCE98EC2D338D82E4F4D654D366E0279C55A59E8AE99654991CCDDECFFF3444440B6A1A514684B83545F44C7362934AB2988A173B2692D8C97667BB76B3702836E1A47C1E092D09A78584A5982F20C5688C7287 +310 +C389E904BEA3BE07BEFAD7ABA6A75739B53F43FD69212429A20365233153D70BEF2E2E6FAC4BCF9B5ADEBB0BD79ADFCF43CBBD9A4E16EF168B9BD9DD0D140BC921495BC2E9D60C1E101B9E46BED37CEA2CD6861F3329624541A3DA7F7858DBAC25C6B4A4458C3CA654405BCFEBC970E8AAD8F43E4FD558ABEC4F9FE7557F47 +310 +4F506B7BFBEC5BCA367FC105710E2AB8A00A554D7CB4A30DFE26FBA86D18CBF4B75418399194A72433B274C3097F33032C554B027F2046A1932B842B0FCCC0DDCBEBDB8AD2F835768F59B9ADB24C41D30C6A64AF817F2A456D59FA83F2E3DA60137255A585BCD60EC9B7929AC18DEFD49381AF1ED30BFDB109A4B1CD488294 +310 +C3CDE6EBC8D185AABD6568061FEE37BB95AAC7E3CA4BCE4ACAA5820FA0D526AE7DAA2869B5EE5999531BE0CC20E6FBA07C41E43FA27CF7A6206F3CDD793B4F6B919C1462CB788EDCD893FA674CDA17AB7B3B7C019F8D561FAEE966FED562DFE98C1D12C6A9FC015C2A35D7FF3D7FC398F591DA4B5FE0BFA2C0189FEA50AE22 +310 +92817A1ED3020951A03432862970F976B33383259812CC8E5D240BBA801CBCF8CE2BBEE3BF910DB6AAD5F79E05123AE2DB33981FB1ACCB02B69D00CB014FD68AB573A7627EF9BFD4CC2C5C7D58557C4B22FAA88925A4DBB44825CECE3D78FB060D1231F0E885BE7D673C86A3874958F593906B5502D1563157814FDAC3420D +310 +F69A070A22EC4B98E23CFF171A1E5311D12C23056555ABA53B8614563B92D17D02AAB009E8F46284624E5F53D5490C560CCCC2591FF1B45421EB0EBD8EA7ED13BE7C5D7E0428CD60FEF60779EB13E6664FF711CB185F6FDED60D139A001FF60CD1FD6D086F1D51474901E26B2CB794A343BC8ED3EDB612E0DD2351FCB65CF4 +310 +33D511C9E14B5B40ADC6A6263DB0336A8F700DDF56271A024E70B0B3629DE21847591E55DED73D56206AB927E46FAABC5C939C55853C274C93D69FD6FA4E78BF8B11DF898C9ECD60553FAD39283BA6F18984AA23E8884F83F45C5F5F859797B31B2B9C84A1E5B9DE9D35BFF25C6B76152E422F9C5DCC3D847F5801ED09D5AF +310 +41441327A25FF15487F9EB725FB666F048A4701E9A827292E6F97569A32DF61DEC01C0BB8DCBBCCC684E0B49147C1FD54BF685A0AB18C868E6973949E819523E3DAC463660E68C7D9F55A9D1EFA3BD7A76A16AE1A810F95CE59B82A4D968A376DC2E8B44BBDD86B27B368CDE0ED548C7137DA860591A83706223A12CA712B4 +310 +7306DBF39C0A20A6DF95C7FAF670C6EED7A61F946493D1D6926782963813403548A78AE470DEF80D2D9CEAA26E4D1C76F60BFE74219A06527D6B82DF641A51616F703CAAC6A7160E08A00BEB4810A0274592A1B36E3675E36E978A0DE80727A0D6DF2D39A75BD27E0D8837276589537B40ABB56D5A41F48F1C6FCD3188C4EB +310 +A6929D2FFB89CE995A4D00AE813BDD032DC48B56BD437DFDDC2DD7AED70C2272D63DE9A72068A45A92F0A4BE17A8A06B9366E16707AD80B1FC649459952097E74BB23805982B55E0401150A165964402AA8BDF9F3E7386BB686EDCA780C0D382E5392BF460A6EFB2C6EA1937D3F809B440D5F5435C4C5CF7A9EEFAF54564BD +310 +2182DA24162F98680B4E7930C21A0079709CEAEB99A372305ABE0C0D7D6078105A83F6A0296DAEE1068A47DD1C55A9F4C9B2FC69E3EFD44B81028E963DF81B504B0304140006080000B670623B000000000000000000000000350000006662782F45374635344235342D363743322D343832342D383044432D433445464530 +310 +3536354442422F76657274696365732E62696E504B0304140006080800B670623BF270F1330600000004000000360000006662782F45374635344235342D363743322D343832342D383044432D4334454645303536354442422F747269616E676C65732E62696E636660600000504B0304140006080000B670623B00000000 +310 +0000000000000000370000006662782F45374635344235342D363743322D343832342D383044432D4334454645303536354442422F617474726962757465732E62696E504B0304140006080800B670623B08DA0BD19000000041010000360000006662782F45374635344235342D363743322D343832342D383044432D4334 +310 +454645303536354442422F6469726563746F72792E786D6C858F410AC3201045AF22B36F53E9A60B35CBEEBAE80D244E44489C6234F4F835464A48035D0DF398FF9F8AF63D0E6CC63039F212F8F9020C7D47C6792B21C5FE7403D62A1188E2E60C98D7234A5830286103A55745067B9D86B8A3CF9C7F90C11DE607FB144396 +310 +D7B6A82D282E9A152AD114D5EF2CB866EEE831B8EEA07A7BB5A8D7D61A2BAAEB7FD5F70921FF497D00504B0304140006080800B670623B6378F24DFF000000A00100003B0000006662782F45374635344235342D363743322D343832342D383044432D4334454645303536354442422F7265736F75726365732F636F72652E +310 +786D6C8D504B6E833010BD0A9A6D65B0094E42641C85B639412F6099496225D8C89FAAC7AF09546A77DDCC8CDE47F3F4C4F16B7C149FE88371B603565228D06A37187BED20C50BD9437194C23B177FC9A0B06AC40E6618A4B87A97A615D2CE46B431A3D60DB8822A048C50E8473E3A08FA86A32A558A5911EEE5A4F45D5DB1 +310 +6465A6B34E8A107D0E502C4FE609D26370C96B0CA25A58295232C3AA3143FE692E063D485EF7F5AEE71BD2D2F39634AFFC44DAF7DD9E9CCE6F9CD25DCBFA762BAAD92C4534E34F46ED51451C40D694B6843142EB0FD61C283F34FC85EE0F948A6A96FF31A569F8A729191B37F51A77B105902C0779125254735F793DCBCC7B +310 +EE567E03504B010214000A0000080000B670623B3DEE336E79000000790000001B00000000000000000000000000000000006175746F6465736B2D64657369676E2D7061636B6167652E786D6C504B01021400140006080800B670623BD486FD9CA0000000F40000001300000000000000000000000000B20000005B436F6E +310 +74656E745F54797065735D2E786D6C504B01021400140006080800B670623BBC1638F5EC0000007C010000080000000000000000000000000083010000636F72652E786D6C504B01021400140006080800B670623B55019EA25F0000007C000000070000000000000000000000000095020000636E782E786D6C504B010214 +310 +00140006080800B670623B3586FFB671010000D40300000C00000000000000000000000000190300006662782F636F72652E786D6C504B01021400140006080800B670623B4220A0FF7D0000009D0000000B00000000000000000000000000B40400006662782F636E782E786D6C504B01021400140006080800B670623B2F +310 +A3FEDE290100001202000031000000000000000000000000005A0500006662782F45374635344235342D363743322D343832342D383044432D4334454645303536354442422F636F72652E786D6C504B01021400140006080800B670623B1B8617914E010000330300003000000000000000000000000000D2060000666278 +310 +2F45374635344235342D363743322D343832342D383044432D4334454645303536354442422F636E782E786D6C504B01021400140006080800B670623B29652116630500003C10000034000000000000000000000000006E0800006662782F45374635344235342D363743322D343832342D383044432D4334454645303536 +310 +354442422F6F626A656374732E786D6C504B01021400140006080000B670623B0000000000000000000000003500000000000000000000000000230E00006662782F45374635344235342D363743322D343832342D383044432D4334454645303536354442422F76657274696365732E62696E504B01021400140006080800 +310 +B670623BF270F13306000000040000003600000000000000000000000000760E00006662782F45374635344235342D363743322D343832342D383044432D4334454645303536354442422F747269616E676C65732E62696E504B01021400140006080000B670623B0000000000000000000000003700000000000000000000 +310 +000000D00E00006662782F45374635344235342D363743322D343832342D383044432D4334454645303536354442422F617474726962757465732E62696E504B01021400140006080800B670623B08DA0BD190000000410100003600000000000000000000000000250F00006662782F45374635344235342D363743322D34 +310 +3832342D383044432D4334454645303536354442422F6469726563746F72792E786D6C504B01021400140006080800B670623B6378F24DFF000000A00100003B00000000000000000000000000091000006662782F45374635344235342D363743322D343832342D383044432D4334454645303536354442422F7265736F75 +310 +726365732F636F72652E786D6C504B0506000000000E000E0080040000611100000000 + 0 +XRECORD + 5 +112 +102 +{ACAD_REACTORS +330 +110 +102 +} +330 +110 +100 +AcDbXrecord +280 + 1 +270 + 1 +271 + 1 + 0 +XRECORD + 5 +111 +102 +{ACAD_REACTORS +330 +110 +102 +} +330 +110 +100 +AcDbXrecord +280 + 1 +270 + 1 +271 + 1 + 0 +XRECORD + 5 +18A +102 +{ACAD_REACTORS +330 +110 +102 +} +330 +110 +100 +AcDbXrecord +280 + 1 + 70 + 0 + 90 +1778669749 + 1 +9BF507EE-FAF7-4A99-A33D-4EE51894BC40 +310 +504B03040A0000080000B670623B3DEE336E79000000790000001B0000006175746F6465736B2D64657369676E2D7061636B6167652E786D6C3C3F786D6C2076657273696F6E3D22312E302220656E636F64696E673D227574662D3822203F3E3C666F726D6174733E3C666F726D61743E687474703A2F2F736368656D612E +310 +6175746F6465736B2E636F6D2F64657369676E2D7061636B6167652F323030393C2F666F726D61743E3C2F666F726D6174733E504B0304140006080800B670623BD486FD9CA0000000F4000000130000005B436F6E74656E745F54797065735D2E786D6C7D8EC10E82300C865F65E91D8A1E8C310C0EEA1BF002731658846E +310 +D98AC1B77784ABF1D8FE5FBFBF75BBCE937A534CCEB38643598122B6FEE978D0B0485F9C41B54DDD7D022595594E1A469170414C76A4D9A4D207E29CF43ECE46F218070CC6BECC4078ACAA135ACF422C856C0E68EA1BF5669944DDD7BCDE7B1F8E415D77AECB980613C2E4AC91FC167A2B244592486606FC29C8FD7F045B9A +310 +EF7053A7E60B504B0304140006080800B670623BCA089DFFEC0000007C01000008000000636F72652E786D6C8D90416E83301045AF62CDB632D8402020E32882F604BD80653BC44AB011B6AB1EBF262152BBEB6A467FFE9B197D76FA9EEFE84BAFDE38DB03CD08206DA553C64E3DC470C1474027CE56E7C22F1B202B66DDC3 +310 +260367D3EAE2B24BD2D9A06D48AA754AEFA2F05E0740F29E9A1EBCBCEA59642286E4F0B76C11F226269DD15797E0188D42CF2346A57DE662F40ABCAEC77628C6120F654571D50C04B7F5B9C1E3997CD4F550BE3745C3F20DE62C98F9755FAE5A04AD801784B498524C8A4F5A75E4D055873772EC0861F966FF03C545FD138A +310 +C686B2D8DF7D621E384D8F3C069CE55B16A93C824A75CB8DFF00504B0304140006080800B670623B55019EA25F0000007C00000007000000636E782E786D6C4DCB3B0E80201045D1AD90E915ED2CF8AC45050C11660C8261F98AB1B07AC9C97D42D718D865D3E909258CFD00CCE24AC6E326A164D74DC0B4128928FF326038 +310 +472BA13128B1252AC747C6BAB984FC68F0B87FE8960A5C09FE86CFB69FBA01504B0304140006080800B670623B68FC0B7272010000D40300000C0000006662782F636F72652E786D6CC5535B6E833010BC8AE5DF0A6C1E8124328E22D29EA01770C0A128602363A31CBFCB234A53A555FA553EC09AD9F5CE8C31DB5DDA060D +310 +D2F4B556190E7C8A9154852E6B5565D8D993B7C668C799D1DA7E29C3488956667884316795D1AE5BA0422B2B950554E9522EA0E87B69312A1A5864B82F3E642B7CE12C54F467BF13C55954D20F7CA0A18EB3DE1A1080E621E31BF3D3F1C2C88C73E65C5D2E6C5DC2B4FA544B8379921C36797888BC3C8A032F4E73EA6D927D +310 +EA1DF6F42D49F2E8350D5346C666CE6CDD5ED515460A2B4BCC434A375E1078347C0FE22D5D6DE3D50B5D6F2965642CBF6B725DF96493AB958DC245EEDCD6631E809089F8E61622B2A81703E8219C913144F84C09DF070D8140C8561C9BAB8F3340832CAC363130A576376A108D933095FA747AD0CF0B46E64E983A6D0EAE1F +310 +CE089F9EF1FB8E70D44255604258581E9D1D75B259C3125A278D37D80B52DAB4A279CEC62F0EFF44DDC43F9454E8469BFF5244960322CBEF41C6FBC83F01504B0304140006080800B670623B0F71DDB67E0000009D0000000B0000006662782F636E782E786D6C4DCCD10A83201886E15B91FFDCA54DB73F502386BB8F5816 +310 +32D3703976F9ABD1C18E3E78F87855FB990379BBFCF2296AE02706C4C5471A7C9C349475A408A4352AA7B4FEDD80C47E761A7606A3A69CCA72D0E0C6BE8475D3E0E3F3409428EA6B27A84079A782899AA2EC1A7AE3ECCC2FD858CB2D544655BFD2B67BD87C01504B0304140006080800B670623BE134B4FB28010000120200 +310 +00310000006662782F38353834323741342D343835462D343034322D383541392D4331303331363839454531452F636F72652E786D6C8D515B6EC32010BC0AE22B55850D0EA424C24451959EA0172086A42831583CAA1CBF503B52FB971F16CDCECC0EACD8DFC71BF836215AEF7A481A0C817183D7D65D7A98D3197108F652 +310 +04EFD31F1A044E8DA6871586525C82CFD3020DDE25E352419DD76601558C264130DCCAA58771F832A36A544E8511AFCDA486ABBA988634A55D7852C4144A00300FA927949C71DABD1D28A29C7D208A6987383B6CD13BC16BB2E1DBE3911C453B0BA5C8D9EA456E758963CFD684674DAA588A64C747FC2118958C86B2C3788B +310 +0841B8FB247487D98EB257CC77188BB6D2FF89F2A49F1465EBD2BA5BE2CEB2082511EDDC9042FB7CBA3DC298FBE4430275DE2A9AE1054ADC60CC08E3A29D89E5F9C5714317C7987C28DFBBBA9E0A97B0D97643A568EB864AF95D5FA9759BF207504B0304140006080800B670623B1B8617914E010000330300003000000066 +310 +62782F38353834323741342D343835462D343034322D383541392D4331303331363839454531452F636E782E786D6CD593C16EC32010447F0571AD6CE3D489D20813A9877E410FBD62D82434182CC055F3F75D1292E692438FBD206B7698593D61BEFD1E2DF982108D773D6D6B460938E5B571FB9ECE6957AD29D90A1EBC4F +310 +77364A9C1CA1A759A682EF839FA72269D8C9D92654AD71C72206887E0E0A22CAF926B15E498B01E0AA19451807D01A34B919F16B070157417138917400F2F6FA41FCF0092A45DEE414C19BDC21783E4BD39D891265658C3D8DEA00A3ACE59CBC8678ACB54CB26EEB6BD7839D7E9348BE70AD9C8321B9BCA7F9A4A26C542347 +310 +DEE050F09802D22BA6749A800A394DD62899107273365E3C82CFC6A55557BC06C10774B7ABE7AEC3B0F34CF06446280E154026D0542C187BA9DAB6628BF7B6DBB0E5A65B3EB1F58631DE64BBE083F7B65CC218087E22CABB042E51D1F2268F1FE12B3EA24D40D63E9CFE00120194D60B9D5BC67FE6D39CDF37E20AF817881F +310 +504B0304140006080800B670623B5933D422630500003B100000340000006662782F38353834323741342D343835462D343034322D383541392D4331303331363839454531452F6F626A656374732E786D6CAD575B6FA33814FE2B887D0642934E9B156594264D158D3A534DD4D13E548A0C38942D60649B4EBABF7E3F8321 +310 +40924EF6928780CDF1B97EE762EFF32E4B8D37CA45C2F21BD3B547A641F39045491EDF98A5DC5AD7A6F1D9F73863B243661A39C9E88DA9B64DDF8B392B0BBD15D12D295339D85DDEFE618890E6D434C2940871638AF08566C426A5641115AF764424B1E3EDCE76ED9AB0CF36E6A47819302D08A7B984A6D8CFC1C5A89572FB +310 +CB91E9F89EA3BEFB9EFAD754E3D3544E654F5F7E920B49F2F040D880CDD89D2CEE2E2EA7D6E56432B6269F2E5CEB7679BBB0DCABF168FE693E9FCEEEA6102C240727AD09A75BD3BF876F78127A4EFDA9D5582B7E4CA590E5390D2BFB6161A5B3E618D182E611E2985001691DAB47FDA5AB7CD3F93C566B2DB2BB7D9E55DD13 +310 +1D468DEECDB3AB290BFE8409E21C54704115AA6AFF68436BFC8DF65E0B184BF5B74418199194272435D224E084BF9B3E481589EFF5D828747285706581E9BB7B7E5D5D911ABFC6EE312DB7659A2A689A7E85EC0DF04FA5A8344BFEA2FCB834E8845895492EAFB541F2BDA0A63FF59C6AD3F7D4637CA13FD68E34B629891172 +310 +98597F1D183A57B9B75A98FE9765B05BAB7C3C2EBCE0ACA05C2AF8005A4DE09AA7F29216EB9E15397500C6F47CBE77CA7778FE2BD277AF0AE2C693DD6437D1522427B9D8329E2136F6A8FA19A3E6C56ADF0E5F50CF06D48734EDCEBF22F69C56D97EC138153F804B85E6FABFC7AFEFB32E523BE1F3BD372418E363EDCA7548 +310 +52949E8724474014288D94610BB57C1BEC4C7F854A89CA8E5324F55B871CBC78CE1BBEE3BFE68D6A5589EF3C730474506FCFA8FCF0659516D0ED04580EEA6425581B77CAE797FF4BCECC16EB2FEB926F49481F746159D06D922712BD730FDEAE42BD40F42C7AA5EF3F198F60E86110D6DD20645A9480B795CF95E3E3A659A8 +310 +C55E724F408873315335CFFB8584874484344D494E59D94869DB90C26A5B64F49C802CAC1D3ABE18A098D3B7444D123D8A9E5AE8F5214F0AE5B2B6E9B575DA3E61CBD3EA2B4009FA940524DD1305FB6A1FB294F14DF0BEA90BA109ECE1481FDC3FFAE8D60E75141700BE82725371B4873751B2DD960265F788137FACE6DD40 +310 +B575E4F0A5C99F46629D92131467A41EE11ABD8D4CCC039CA0AFB37C93A08B232B8F0AEFCA1E0A1015DF13FC83322B362463652ECF71D3A8B1A7D1BE65DE1D62C44F22C317D35F574FEB16153BA2D13E543D10A80E74C4A65E78A6B7CBCBD1D5DD9DB59C2DAFACC96C3AB566E3F1C29ADCDD5DBAD7D3C9ED7C822ED74F80A6 +310 +41755310DE4443F44A9E68373FADF6596BFA0F440AE7BECE2727AE9F4F2B1B53B1E7E00CF0DD1E5C65454A339A4BA2D0FBA05ED2EF0443458F47BDBFCA484CCFE0F2ED7E3DD0013B679C7B5499467F0ECEEADDB9CA85A34CE44B99053949D2C1416DB85DE4B136BB7165FBAC0B7AB3542BED4F8CA182A549847A1319316519 +310 +95A83A67147B9E5101C47487F2485F1ECE38FD568F839204296D3479219888530154036E6528FBFBC66F98E0D4107563A2D7D9AFF8D389681A08F58D89F22693900A3B407754734FC51C10C010D6D640809EE4718AC1BA3ED4AEDB53CA37283F68805A7E4B72CEB0A4EDEAD5DD8C14059A76AFAA56BA6901E13F32BC51C720 +310 +12AF41295B5BF61BAD3195181FB506E6B40F4C10AF5AF40EF9F5B159AE5DD1F43C72D635E94310D45C2D49785C5D0B94D3B54AB3C5A38349C0587D338AB48C11CBF339599C02CCA54A70A008A8D03C0B2201D5F9EFCF8F9CE12A9A19CB0410789EB32C63B95ECCF455D658BFE0621A3DA32C5075FB101723D77DAE867E7D0F +310 +D90444509B44E2151B4DC2290B065803200FBAA9BE9D392A0603F2D5C2D00D6302A615680F66D2FA166E2079D4C551A54AB758161FCEFD4E450A14704CECFEDF504B0304140006080000B670623B000000000000000000000000350000006662782F38353834323741342D343835462D343034322D383541392D4331303331 +310 +363839454531452F76657274696365732E62696E504B0304140006080800B670623BF270F1330600000004000000360000006662782F38353834323741342D343835462D343034322D383541392D4331303331363839454531452F747269616E676C65732E62696E636660600000504B0304140006080000B670623B000000 +310 +000000000000000000370000006662782F38353834323741342D343835462D343034322D383541392D4331303331363839454531452F617474726962757465732E62696E504B0304140006080800B670623B08DA0BD19000000041010000360000006662782F38353834323741342D343835462D343034322D383541392D43 +310 +31303331363839454531452F6469726563746F72792E786D6C858F410AC3201045AF22B36F53E9A60B35CBEEBAE80D244E44489C6234F4F835464A48035D0DF398FF9F8AF63D0E6CC63039F212F8F9020C7D47C6792B21C5FE7403D62A1188E2E60C98D7234A5830286103A55745067B9D86B8A3CF9C7F90C11DE607FB1443 +310 +96D7B6A82D282E9A152AD114D5EF2CB866EEE831B8EEA07A7BB5A8D7D61A2BAAEB7FD5F70921FF497D00504B0304140006080800B670623BD9A3B367FE000000A00100003B0000006662782F38353834323741342D343835462D343034322D383541392D4331303331363839454531452F7265736F75726365732F636F7265 +310 +2E786D6C8D504B6E833010BD0A9A6D65B01D0201194711694FD00B586642AD043BF2A7EAF16B122AB5BB6E6646EFA3797AE2F8B5DC8A4FF4C1383B002B291468B59B8C9D0748F1420E501CA5F0CEC55F3228AC5A708015062966EFD27D83B4B3116DCCA875136EA00A012314FA968F0182FEC045952AC5AC08D7F2AEF455CD +310 +58B232D3592745883E07289E4FD609D26370C96B0CA27AB252A464A64D63A6FCD35C0C7A904D73EE467EE664DCD58CD4ED4849D79C5A723ED1B7A61977AF2D6F45B59AA58866F9C9A83DAA8813484E6947182394BFB3BAA7FBBEDEBFD0434FA9A856F91F53BA4FFF342563E38E6F719FB60092E5200F428A6AED2BAF479979 +310 +AFDDCA6F504B010214000A0000080000B670623B3DEE336E79000000790000001B00000000000000000000000000000000006175746F6465736B2D64657369676E2D7061636B6167652E786D6C504B01021400140006080800B670623BD486FD9CA0000000F40000001300000000000000000000000000B20000005B436F6E +310 +74656E745F54797065735D2E786D6C504B01021400140006080800B670623BCA089DFFEC0000007C010000080000000000000000000000000083010000636F72652E786D6C504B01021400140006080800B670623B55019EA25F0000007C000000070000000000000000000000000095020000636E782E786D6C504B010214 +310 +00140006080800B670623B68FC0B7272010000D40300000C00000000000000000000000000190300006662782F636F72652E786D6C504B01021400140006080800B670623B0F71DDB67E0000009D0000000B00000000000000000000000000B50400006662782F636E782E786D6C504B01021400140006080800B670623BE1 +310 +34B4FB280100001202000031000000000000000000000000005C0500006662782F38353834323741342D343835462D343034322D383541392D4331303331363839454531452F636F72652E786D6C504B01021400140006080800B670623B1B8617914E010000330300003000000000000000000000000000D3060000666278 +310 +2F38353834323741342D343835462D343034322D383541392D4331303331363839454531452F636E782E786D6C504B01021400140006080800B670623B5933D422630500003B10000034000000000000000000000000006F0800006662782F38353834323741342D343835462D343034322D383541392D4331303331363839 +310 +454531452F6F626A656374732E786D6C504B01021400140006080000B670623B0000000000000000000000003500000000000000000000000000240E00006662782F38353834323741342D343835462D343034322D383541392D4331303331363839454531452F76657274696365732E62696E504B01021400140006080800 +310 +B670623BF270F13306000000040000003600000000000000000000000000770E00006662782F38353834323741342D343835462D343034322D383541392D4331303331363839454531452F747269616E676C65732E62696E504B01021400140006080000B670623B0000000000000000000000003700000000000000000000 +310 +000000D10E00006662782F38353834323741342D343835462D343034322D383541392D4331303331363839454531452F617474726962757465732E62696E504B01021400140006080800B670623B08DA0BD190000000410100003600000000000000000000000000260F00006662782F38353834323741342D343835462D34 +310 +3034322D383541392D4331303331363839454531452F6469726563746F72792E786D6C504B01021400140006080800B670623BD9A3B367FE000000A00100003B000000000000000000000000000A1000006662782F38353834323741342D343835462D343034322D383541392D4331303331363839454531452F7265736F75 +310 +726365732F636F72652E786D6C504B0506000000000E000E0080040000611100000000 + 0 +XRECORD + 5 +113 +102 +{ACAD_REACTORS +330 +110 +102 +} +330 +110 +100 +AcDbXrecord +280 + 1 +270 + 1 +271 + 1 + 0 +XRECORD + 5 +114 +102 +{ACAD_REACTORS +330 +110 +102 +} +330 +110 +100 +AcDbXrecord +280 + 1 +270 + 1 +271 + 1 + 0 +XRECORD + 5 +35A +102 +{ACAD_REACTORS +330 +1C4 +102 +} +330 +1C4 +100 +AcDbXrecord +280 + 1 +102 +DISPLAYNAME + 1 +Imperial24 +102 +FLAGS + 90 + 0 + 0 +CELLSTYLEMAP + 5 +359 +102 +{ACAD_REACTORS +330 +104 +102 +} +330 +104 +100 +AcDbCellStyleMap + 90 + 3 +300 +CELLSTYLE + 1 +TABLEFORMAT_BEGIN + 90 + 5 +170 + 1 + 91 + 0 + 92 + 32768 + 62 + 257 + 93 + 1 +300 +CONTENTFORMAT + 1 +CONTENTFORMAT_BEGIN + 90 + 0 + 91 + 0 + 92 + 512 + 93 + 0 +300 + + 40 +0.0 +140 +1.0 + 94 + 5 + 62 + 0 +340 +11 +144 +0.25 +309 +CONTENTFORMAT_END +171 + 1 +301 +MARGIN + 1 +CELLMARGIN_BEGIN + 40 +0.06 + 40 +0.06 + 40 +0.06 + 40 +0.06 + 40 +0.18 + 40 +0.18 +309 +CELLMARGIN_END + 94 + 6 + 95 + 1 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 2 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 4 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 8 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 16 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 32 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END +309 +TABLEFORMAT_END + 1 +CELLSTYLE_BEGIN + 90 + 1 + 91 + 1 +300 +_TITLE +309 +CELLSTYLE_END +300 +CELLSTYLE + 1 +TABLEFORMAT_BEGIN + 90 + 5 +170 + 1 + 91 + 0 + 92 + 0 + 62 + 257 + 93 + 1 +300 +CONTENTFORMAT + 1 +CONTENTFORMAT_BEGIN + 90 + 0 + 91 + 0 + 92 + 512 + 93 + 0 +300 + + 40 +0.0 +140 +1.0 + 94 + 5 + 62 + 0 +340 +11 +144 +0.18 +309 +CONTENTFORMAT_END +171 + 1 +301 +MARGIN + 1 +CELLMARGIN_BEGIN + 40 +0.06 + 40 +0.06 + 40 +0.06 + 40 +0.06 + 40 +0.18 + 40 +0.18 +309 +CELLMARGIN_END + 94 + 6 + 95 + 1 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 2 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 4 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 8 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 16 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 32 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END +309 +TABLEFORMAT_END + 1 +CELLSTYLE_BEGIN + 90 + 2 + 91 + 1 +300 +_HEADER +309 +CELLSTYLE_END +300 +CELLSTYLE + 1 +TABLEFORMAT_BEGIN + 90 + 5 +170 + 1 + 91 + 0 + 92 + 0 + 62 + 257 + 93 + 1 +300 +CONTENTFORMAT + 1 +CONTENTFORMAT_BEGIN + 90 + 0 + 91 + 0 + 92 + 512 + 93 + 0 +300 + + 40 +0.0 +140 +1.0 + 94 + 2 + 62 + 0 +340 +11 +144 +0.18 +309 +CONTENTFORMAT_END +171 + 1 +301 +MARGIN + 1 +CELLMARGIN_BEGIN + 40 +0.06 + 40 +0.06 + 40 +0.06 + 40 +0.06 + 40 +0.18 + 40 +0.18 +309 +CELLMARGIN_END + 94 + 6 + 95 + 1 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 2 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 4 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 8 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 16 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END + 95 + 32 +302 +GRIDFORMAT + 1 +GRIDFORMAT_BEGIN + 90 + 0 + 91 + 1 + 62 + 0 + 92 + -2 +340 +0 + 93 + 0 + 40 +0.045 +309 +GRIDFORMAT_END +309 +TABLEFORMAT_END + 1 +CELLSTYLE_BEGIN + 90 + 3 + 91 + 2 +300 +_DATA +309 +CELLSTYLE_END + 0 +ENDSEC + 0 +SECTION + 2 +ACDSDATA + 70 + 2 + 71 + 8 + 0 +ACDSSCHEMA + 90 + 0 + 1 +AcDb_Thumbnail_Schema + 2 +AcDbDs::ID +280 + 10 + 91 + 8 + 2 +Thumbnail_Data +280 + 15 + 91 + 0 +101 +ACDSRECORD + 95 + 0 + 90 + 1 + 2 +AcDbDs::TreatedAsObjectData +280 + 1 +291 + 1 +101 +ACDSRECORD + 95 + 0 + 90 + 2 + 2 +AcDbDs::Legacy +280 + 1 +291 + 1 +101 +ACDSRECORD + 1 +AcDbDs::ID + 90 + 3 + 2 +AcDs:Indexable +280 + 1 +291 + 1 +101 +ACDSRECORD + 1 +AcDbDs::ID + 90 + 4 + 2 +AcDbDs::HandleAttribute +280 + 7 +282 + 1 + 0 +ACDSSCHEMA + 90 + 1 + 1 +AcDbDs::TreatedAsObjectDataSchema + 2 +AcDbDs::TreatedAsObjectData +280 + 1 + 91 + 0 + 0 +ACDSSCHEMA + 90 + 2 + 1 +AcDbDs::LegacySchema + 2 +AcDbDs::Legacy +280 + 1 + 91 + 0 + 0 +ACDSSCHEMA + 90 + 3 + 1 +AcDbDs::IndexedPropertySchema + 2 +AcDs:Indexable +280 + 1 + 91 + 0 + 0 +ACDSSCHEMA + 90 + 4 + 1 +AcDbDs::HandleAttributeSchema + 2 +AcDbDs::HandleAttribute +280 + 7 + 91 + 1 +284 + 1 + 0 +ACDSRECORD + 90 + 0 + 2 +AcDbDs::ID +280 + 10 +320 +2CC + 2 +Thumbnail_Data +280 + 15 + 94 + 1159 +310 +89504E470D0A1A0A0000000D4948445200000100000000B5080300000087DCD12600000300504C5445FFFFFF000000C8C8C80000000000000000000000000000000000000000000000000000330000660000990000CC0000FF0033000033330033660033990033CC0033FF0066000066330066660066990066CC0066FF0099 +310 +000099330099660099990099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF0000FF3300FF6600FF9900FFCC00FFFF3300003300333300663300993300CC3300FF3333003333333333663333993333CC3333FF3366003366333366663366993366CC3366FF3399003399333399663399993399CC3399FF33CC00 +310 +33CC3333CC6633CC9933CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF6600006600336600666600996600CC6600FF6633006633336633666633996633CC6633FF6666006666336666666666996666CC6666FF6699006699336699666699996699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066 +310 +FF3366FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF9933009933339933669933999933CC9933FF9966009966339966669966999966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC3399CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFFCC0000CC00 +310 +33CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFFCCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0000FF0033FF0066FF0099FF00CCFF00FFFF3300FF3333 +310 +FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33FFCC66FFCC99FFCCCCFFCCFFFFFF00FFFF33FFFF66FFFF99FFFFCCFFFFFF0000000D0D0D1A1A1A2828283535354343435050505D5D5D6B6B6B787878868686939393A1A1A1AEAEAEBB +310 +BBBBC9C9C9D6D6D6E4E4E4F1F1F1FFFFFF000000000000000000000000000000000000000000000000000000000000B0E2D235000001424944415478DAEDDD3B0AC330144451ED7F99AF145A845DBA116E8C2C31478521A50F999B0F81B4D1B34F1B2DFC00783EA8FB045CD39F016502F30900089F00001104208200441080 +310 +08021041002208400401882000110420820044108008021041002208400401882000110420820044108008021041002208400401882000110420820044108008021041002208400401882080E323B8DD497F150000203C82004C000000002208C0040000002082004C000000002208C0040000002082004C000000002208C0 +310 +040000002082007E9840ADBAA96D0156FDFCF5C467C0EB3D96099C1F4100F1110400004076048F7E23E4B3000000007C2102C0040000002082004C000000002208C0040000002082004C000000002208C0040000002082004C000000002208C004000000208200A226503BFECFCCE7D73940E201D0B34F1BE1E7021039E2C1 +310 +DA17D3CB0000000049454E44AE426082 + 0 +ACDSRECORD + 90 + 0 + 2 +AcDbDs::ID +280 + 10 +320 +59 + 2 +Thumbnail_Data +280 + 15 + 94 + 1167 +310 +89504E470D0A1A0A0000000D4948445200000100000000B5080300000087DCD12600000300504C5445FFFFFF000000C8C8C80000000000000000000000000000000000000000000000000000330000660000990000CC0000FF0033000033330033660033990033CC0033FF0066000066330066660066990066CC0066FF0099 +310 +000099330099660099990099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF0000FF3300FF6600FF9900FFCC00FFFF3300003300333300663300993300CC3300FF3333003333333333663333993333CC3333FF3366003366333366663366993366CC3366FF3399003399333399663399993399CC3399FF33CC00 +310 +33CC3333CC6633CC9933CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF6600006600336600666600996600CC6600FF6633006633336633666633996633CC6633FF6666006666336666666666996666CC6666FF6699006699336699666699996699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066 +310 +FF3366FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF9933009933339933669933999933CC9933FF9966009966339966669966999966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC3399CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFFCC0000CC00 +310 +33CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFFCCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0000FF0033FF0066FF0099FF00CCFF00FFFF3300FF3333 +310 +FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33FFCC66FFCC99FFCCCCFFCCFFFFFF00FFFF33FFFF66FFFF99FFFFCCFFFFFF0000000D0D0D1A1A1A2828283535354343435050505D5D5D6B6B6B787878868686939393A1A1A1AEAEAEBB +310 +BBBBC9C9C9D6D6D6E4E4E4F1F1F1FFFFFF000000000000000000000000000000000000000000000000000000000000B0E2D2350000014A4944415478DAEDDC410AC2301445D1EC7F997F58B208112776D4466853F34E4087A247DF15056D7DCB3EADB7F00300000000000000000000000000C10055EF4BD8F50E20F0A90790 +310 +DE8012C15D04973A3F00ACF8E2CE8D200000004410800900000040040198000000004410800900000040040198000000004410800900000040040198000000DE050098000000004410800900000040040198000000002EEFCD5DB77A6704077FD37BC58F7F67038C389DBDFFB5DC04069FD8D2000000FE2582004C00000000 +310 +22E8C3D0C47F42F27D00000000E645108009000000400401980000000044108009000000400401980000000044108009000000400401980000000044108009000000400401980000000044108009000000400401980000000044108009000000E029115CEC7C1ED1D1F51740F001000000000000000000000000804C802DFB +310 +B41E7E5E76608B6E6FFAA4310000000049454E44AE426082 + 0 +ACDSRECORD + 90 + 0 + 2 +AcDbDs::ID +280 + 10 +320 +2F1 + 2 +Thumbnail_Data +280 + 15 + 94 + 1159 +310 +89504E470D0A1A0A0000000D4948445200000100000000AA0803000000755C616800000300504C5445FFFFFF000000C8C8C80000000000000000000000000000000000000000000000000000330000660000990000CC0000FF0033000033330033660033990033CC0033FF0066000066330066660066990066CC0066FF0099 +310 +000099330099660099990099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF0000FF3300FF6600FF9900FFCC00FFFF3300003300333300663300993300CC3300FF3333003333333333663333993333CC3333FF3366003366333366663366993366CC3366FF3399003399333399663399993399CC3399FF33CC00 +310 +33CC3333CC6633CC9933CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF6600006600336600666600996600CC6600FF6633006633336633666633996633CC6633FF6666006666336666666666996666CC6666FF6699006699336699666699996699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066 +310 +FF3366FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF9933009933339933669933999933CC9933FF9966009966339966669966999966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC3399CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFFCC0000CC00 +310 +33CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFFCCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0000FF0033FF0066FF0099FF00CCFF00FFFF3300FF3333 +310 +FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33FFCC66FFCC99FFCCCCFFCCFFFFFF00FFFF33FFFF66FFFF99FFFFCCFFFFFF0000000D0D0D1A1A1A2828283535354343435050505D5D5D6B6B6B787878868686939393A1A1A1AEAEAEBB +310 +BBBBC9C9C9D6D6D6E4E4E4F1F1F1FFFFFF000000000000000000000000000000000000000000000000000000000000B0E2D235000001424944415478DAEDDD3B6EC4301044C1B9FF3127DE4348D487C9E624A02E02F63AF416D4CF0E4CB87EE1A7A20F000000C64B9F27F2F30DD0B9E701487DFEBB00840334000000441080 +310 +090000004004019800000000441080090000004004019800000000441080090000004004019800000000441080090000004004019800000000441080097CE7FD0300F0025CB7A9D7F6675EE07EAE31AF7FF33D23B8FED2F600A8F9D2D7D75BBE8F9D4FC01FC0E21F00BB1BB077022208004075FAAFC2004C00000011046002 +310 +000000104100260000000011046002000000104100260000000011CC02F02F36721F010000FCB93C0013000000800802300100000088200013000000800802300100000088200013000000800802300100000088200013000000800802300100000088200013000000C0C722987B73F606483E000000181FD1E700152D5D54 +310 +A48935320000000049454E44AE426082 + 0 +ACDSRECORD + 90 + 0 + 2 +AcDbDs::ID +280 + 10 +320 +22 + 2 +Thumbnail_Data +280 + 15 + 94 + 931 +310 +89504E470D0A1A0A0000000D49484452000001000000006608030000001376BFA700000300504C5445212830000000C8C8C80000000000000000000000000000000000000000000000000000330000660000990000CC0000FF0033000033330033660033990033CC0033FF0066000066330066660066990066CC0066FF0099 +310 +000099330099660099990099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF0000FF3300FF6600FF9900FFCC00FFFF3300003300333300663300993300CC3300FF3333003333333333663333993333CC3333FF3366003366333366663366993366CC3366FF3399003399333399663399993399CC3399FF33CC00 +310 +33CC3333CC6633CC9933CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF6600006600336600666600996600CC6600FF6633006633336633666633996633CC6633FF6666006666336666666666996666CC6666FF6699006699336699666699996699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066 +310 +FF3366FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF9933009933339933669933999933CC9933FF9966009966339966669966999966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC3399CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFFCC0000CC00 +310 +33CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFFCCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0000FF0033FF0066FF0099FF00CCFF00FFFF3300FF3333 +310 +FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33FFCC66FFCC99FFCCCCFFCCFFFFFF00FFFF33FFFF66FFFF99FFFFCCFFFFFF0000000D0D0D1A1A1A2828283535354343435050505D5D5D6B6B6B787878868686939393A1A1A1AEAEAEBB +310 +BBBBC9C9C9D6D6D6E4E4E4F1F1F1FFFFFF000000000000000000000000000000000000000000000000000000000000B21171F60000003C4944415478DAEDD01102000008C0C0FEFFDF3C4C9220EA8EC68B00000000000000000000000080A51CEAD7806600000000000000000000000000E70AAB4B16F94F60314000000000 +310 +49454E44AE4260827C8701000000000000000080D2016EDA0276E5C382FC0000000049454E44AE426082 + 0 +ENDSEC + 0 +EOF diff --git a/tests/hatch_shader_lod.rs b/tests/hatch_shader_lod.rs index 9d46b607c..4ea716717 100644 --- a/tests/hatch_shader_lod.rs +++ b/tests/hatch_shader_lod.rs @@ -1,8 +1,10 @@ const STORAGE_SHADER: &str = include_str!("../src/shaders/hatch.wgsl"); const TEXTURE_SHADER: &str = include_str!("../src/shaders/hatch_texture.wgsl"); +const DRAW_ORDER_SHADER: &str = include_str!("../src/shaders/draw_order.wgsl"); fn assert_all_family_lod(source: &str, name: &str) { - let module = naga::front::wgsl::parse_str(source) + let composed = format!("{DRAW_ORDER_SHADER}\n{source}"); + let module = naga::front::wgsl::parse_str(&composed) .unwrap_or_else(|error| panic!("{name} WGSL must parse: {error}")); naga::valid::Validator::new( naga::valid::ValidationFlags::all(), diff --git a/tests/text_shader_minification.rs b/tests/text_shader_minification.rs index 205fef588..edea39226 100644 --- a/tests/text_shader_minification.rs +++ b/tests/text_shader_minification.rs @@ -1,11 +1,13 @@ #[test] fn adaptive_text_sampling_passes_derivative_uniformity_validation() { + let draw_order = include_str!("../src/shaders/draw_order.wgsl"); for (name, source) in [ ("text", include_str!("../src/shaders/text.wgsl")), ("block text", include_str!("../src/shaders/block_text.wgsl")), ] { - let module = naga::front::wgsl::parse_str(source) - .unwrap_or_else(|error| panic!("{name}: {}", error.emit_to_string(source))); + let composed = format!("{draw_order}\n{source}"); + let module = naga::front::wgsl::parse_str(&composed) + .unwrap_or_else(|error| panic!("{name}: {}", error.emit_to_string(&composed))); naga::valid::Validator::new( naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty(), diff --git a/tests/ucs_face_plane.rs b/tests/ucs_face_plane.rs new file mode 100644 index 000000000..57f49f0a3 --- /dev/null +++ b/tests/ucs_face_plane.rs @@ -0,0 +1,80 @@ +//! `UCS FACE` turns a pick on a solid into a drawing plane. The plane is only +//! as good as the face lookup underneath it, so this covers that half: given a +//! body and a point on one of its faces, does the right face come back, and is +//! its outward normal the one a sketch should be built on? +//! +//! The axis construction on top of that normal is unit-tested beside +//! `ucs_from_normal` in `src/app/helpers.rs`, which is `pub(crate)` and so out +//! of reach from here. + +use acadrust::{entities::Circle, types::Vector3, EntityType}; +use OpenCADStudio::scene::model::{presspull_model, solid_model}; + +/// Extrude a disc into a puck 10 units tall, centred on the origin. +fn puck() -> cadkernel::brep::Body { + let entity = EntityType::Circle(Circle::from_center_radius( + Vector3::new(0.0, 0.0, 0.0), + 100.0, + )); + let body = presspull_model::extrusion_body(&entity, [0.0, 0.0, 10.0]) + .expect("a closed circular profile extrudes"); + assert!(body.validate().is_empty(), "extrusion produced a valid body"); + body +} + +/// The two flat ends point away from the material, in opposite directions. +/// Getting this wrong is what would make a sketch land inside the solid, or +/// extrude back into it. +#[test] +fn opposite_faces_report_opposite_outward_normals() { + let body = puck(); + + let top = solid_model::nearest_planar_face(&body, [0.0, 0.0, 10.0]) + .expect("the top cap is planar"); + let bottom = solid_model::nearest_planar_face(&body, [0.0, 0.0, 0.0]) + .expect("the bottom cap is planar"); + assert_ne!(top, bottom, "the caps are distinct faces"); + + let top_normal = solid_model::planar_face_normal(&body, top).expect("top cap has a normal"); + let bottom_normal = + solid_model::planar_face_normal(&body, bottom).expect("bottom cap has a normal"); + + assert!( + (top_normal[2] - 1.0).abs() < 1e-9, + "top cap should point +Z, got {top_normal:?}" + ); + assert!( + (bottom_normal[2] + 1.0).abs() < 1e-9, + "bottom cap should point -Z, got {bottom_normal:?}" + ); +} + +/// A pick anywhere on a cap resolves to that cap, not merely to the one +/// nearest the origin. A sketch is started by clicking somewhere on a face, +/// rarely at its centre. +#[test] +fn an_off_centre_pick_still_lands_on_its_own_face() { + let body = puck(); + let centre = solid_model::nearest_planar_face(&body, [0.0, 0.0, 10.0]).unwrap(); + let off_centre = solid_model::nearest_planar_face(&body, [60.0, -25.0, 10.0]).unwrap(); + assert_eq!(centre, off_centre, "both picks are on the top cap"); +} + +/// The curved side is not a plane, so it cannot become a sketch plane. The +/// lookup has to decline rather than hand back a nonsense normal. +#[test] +fn the_curved_side_is_not_offered_as_a_plane() { + let body = puck(); + // Halfway up the barrel, on its surface. + let face = solid_model::nearest_planar_face(&body, [100.0, 0.0, 5.0]); + if let Some(face) = face { + // A planar cap may still be the nearest planar face; what must never + // happen is the barrel itself being reported as planar. + let normal = solid_model::planar_face_normal(&body, face) + .expect("whatever came back claims to be planar, so it has a normal"); + assert!( + normal[2].abs() > 1e-9, + "a cap normal has Z; a barrel wall wrongly called planar would not" + ); + } +}