diff --git a/assets/icons/measure-angle.svg b/assets/icons/measure-angle.svg
new file mode 100644
index 000000000..3a7bd686f
--- /dev/null
+++ b/assets/icons/measure-angle.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/measure-radius.svg b/assets/icons/measure-radius.svg
new file mode 100644
index 000000000..cea8f0301
--- /dev/null
+++ b/assets/icons/measure-radius.svg
@@ -0,0 +1,5 @@
+
diff --git a/docs/measurement-tools.md b/docs/measurement-tools.md
new file mode 100644
index 000000000..5ab06bbb5
--- /dev/null
+++ b/docs/measurement-tools.md
@@ -0,0 +1,173 @@
+# Mérés eszköztár – fejlesztési csomag
+
+Kiindulás: a feltöltött `measurement-development-full-006be890.zip`, Open CAD Studio `006be890`. A csomag kizárólag az ehhez képest új vagy módosított fájlokat tartalmazza, projektgyökérhez viszonyított útvonalakkal. Távoli tároló módosítása és Pull Request nem történt.
+
+## Változások
+
+- A meglévő `MEASURE_MENU` / `LargeDropdown` sorrendje: **Distance (DIST), Radius (MEASURERADIUS), Angle (MEASUREANGLE), Area (AREA)**. A komponens felépítése változatlan. A Distance és Area eredeti SVG-je változatlan.
+- A `MEASURERADIUS` és `MEASUREANGLE` a meglévő `MeasureGeomCommand` külön kezdőállapotai. Közös kijelölési és számítási kódot használnak; a parancsregisztráció, menüindítás és parancssori indítás össze van kötve. A `MEASUREGEOM` és `MEA` továbbra is elérhető négy mérési móddal.
+- Sugármérés: kör és körív, sugár és átmérő; más típus és hibás sugár esetén hibaüzenet, a parancs továbbra is vár érvényes kijelölést. Az objektum feletti kurzormozgás előzetes értéket mutat.
+- Szögmérés: csúcs, első szár pontja, második szár pontja. Az első lépésben `Lines` / `L` két LINE objektum mérésére vált; onnan az első kijelölés előtt `Points` / `P` visszavált. Azonos objektum, nulla hosszúságú szár, nem LINE objektum és nem véges koordináta kezelve van.
+- A szögszámítás `f64` vektorokat használ, skálázott normalizálással és `atan2`-vel. A végeredmény fokban jelenik meg, az `AUPREC` pontosságával. A hossz- és területadatok a meglévő formázókat használják.
+- Közös, dokumentumfülenkénti, átmeneti mérési felirat. A parancsok csak az élő eredményszöveget szolgáltatják; a pozicionálás, végleges eredmény megőrzése és eltüntetése közös. A végleges kiírás a befejezés helyén marad, egérmozgatásra nem követi a kurzort.
+- A rajzterületi bal, jobb vagy középső gomb lenyomása eltünteti a végleges feliratot, majd a normál eseménykezelés folytatódik. A befejező kattintás felengedése nem törli az éppen elkészült eredményt. ESC és új parancs indítása is töröl.
+- A felirat nem vesz át egéreseményt, a rajzterület széléhez és a parancssorhoz igazodik, és a téma színeit használja. Az élő megjelenítés a DYN kapcsolótól független, alaphelyzetben a kurzor fölött van. A meglévő geometriai előnézet és tárgyraszter megmarad.
+- Az AREA objektumkijelölési előnézete kijelölésváltozáskor számol; az összetett görbék területét nem integrálja újra minden képkockában. Add/Subtract közben az aktuális terület és az előzetes összeg látható, az összeg csak a mérés elfogadásakor változik.
+- A felirat nem rajzi entitás, nem módosítja a DWG tartalmát vagy a dokumentum módosításjelzőjét. A meglévő parancssori eredmény megmarad.
+- Új sugár- és szög-SVG, magyar és angol szövegek. A többi 20 nyelvi katalógus új kulcsai angol szöveget kaptak, hogy a katalógusok kulcskészlete egyezzen.
+- Nincs új külső függőség; a `Cargo.toml` és `Cargo.lock` változatlan.
+
+## Beillesztés PowerShellből
+
+Zárd be a futó CAD-alkalmazást. A következő parancsok tiszta Git-munkafát és pontosan a kiinduló commitot követelik meg; eltérés esetén megállnak. Saját módosításaidat előbb mentsd el vagy commitold. A ZIP-et nem egy új almappába, hanem közvetlenül a projekt gyökerébe kell kicsomagolni.
+
+```powershell
+$ErrorActionPreference = 'Stop'
+$project = 'C:\CAD-Development\OpenCADStudio-Dev'
+$patch = Join-Path $env:USERPROFILE 'Downloads\measurement-tools-patch.zip'
+Set-Location -LiteralPath $project
+if (-not (Test-Path -LiteralPath $patch -PathType Leaf)) { throw 'A ZIP nem található.' }
+$status = git status --porcelain
+if ($LASTEXITCODE -ne 0) { throw 'Nem elérhető Git-munkafa.' }
+if ($status) { throw 'A munkafa nem tiszta. Előbb mentsd a saját módosításaidat.' }
+$baseCommit = git rev-parse '006be890^{commit}'
+if ($LASTEXITCODE -ne 0) { throw 'A kiinduló commit nem található.' }
+$currentCommit = git rev-parse HEAD
+if ($LASTEXITCODE -ne 0 -or $currentCommit -ne $baseCommit) { throw 'Nem a 006be890 commiton állsz.' }
+$currentBranch = git branch --show-current
+if ($currentBranch -ne 'feature/measurement-tools') {
+ git switch -c feature/measurement-tools
+ if ($LASTEXITCODE -ne 0) { throw 'Az ág nem hozható létre. Ellenőrizd a már létező ágakat.' }
+}
+Expand-Archive -LiteralPath $patch -DestinationPath $project -Force
+git status --short
+git diff --stat
+git diff --check
+if ($LASTEXITCODE -ne 0) { throw 'Whitespace-ellenőrzési hiba.' }
+```
+
+Az új fájlokat a `git status --short` mutatja; a `git diff --stat` csak a már követett fájlokat sorolja fel. A fenti művelet nem commitol és nem pushol.
+
+## Fordítás és célzott tesztek PowerShellből
+
+A projekt meglévő Windows/Rust/MSVC előfeltételei és a függőségek letöltéséhez internetelérés szükséges.
+
+```powershell
+Set-Location 'C:\CAD-Development\OpenCADStudio-Dev'
+cargo check --locked
+if ($LASTEXITCODE -ne 0) { throw 'A cargo check sikertelen.' }
+cargo test --locked --lib measurement -- --test-threads=1
+if ($LASTEXITCODE -ne 0) { throw 'A mérési tesztek sikertelenek.' }
+cargo test --locked --lib modules::draw::inquiry -- --test-threads=1
+if ($LASTEXITCODE -ne 0) { throw 'Az inquiry tesztek sikertelenek.' }
+cargo fmt --check
+# A kiinduló projektben is vannak formázási eltérések; lásd az eredményeket.
+cargo run --locked
+```
+
+A `--test-threads=1` a formázási beállításokat módosító tesztek és az alkalmazásállapot-tesztek kiszámítható futását segíti. Memóriahiány esetén a tesztek előtt megadható `$env:CARGO_BUILD_JOBS = '3'` és `$env:CARGO_PROFILE_TEST_DEBUG = '0'`. Ez újraépítést okozhat; a változókat a vizsgálat után `Remove-Item Env:CARGO_BUILD_JOBS, Env:CARGO_PROFILE_TEST_DEBUG -ErrorAction SilentlyContinue` paranccsal törölheted.
+
+## Kézi ellenőrzés Windows alatt
+
+1. A Mérés menüben ellenőrizd a négy elemet és azok sorrendjét; indítsd őket menüből és begépelt paranccsal is. Ellenőrizd külön a `MEASUREGEOM` négy opcióját és a `MEA` aliast.
+2. DIST: mérj a `(0,0,0)` és `(3,4,0)` pontok között: 5 egység. Az első pont után mozgasd a kurzort; a felirat és a geometriai előnézet frissüljön. Próbáld ki tárgyraszterrel, DYN be- és kikapcsolva, más hosszformátummal/pontossággal, valamint nagy koordináták közelében.
+3. MEASURERADIUS: mérj 10 sugarú körön és köríven: sugár 10, átmérő 20. Előbb csak vidd föléjük a kurzort, aztán kattints. Egyenes kijelölésekor hibaüzenetet várj, utána körrel lehessen befejezni. Üres terület fölött ne maradjon az előző objektum élő értéke.
+4. MEASUREANGLE: csúcs `(0,0,0)`, első szár `(1,0,0)`, második `(0,1,0)` → 90°; második `(-1,1,0)` → 135°. A csúcs ismételt kiválasztása érvénytelen szár, és nem fejezheti be a mérést. Próbáld ki 3D pontokkal és eltérő AUPREC értékekkel is.
+5. MEASUREANGLE → Lines: két 45°-ban álló LINE → 45°, merőlegesek → 90°, párhuzamosak → 0°. Fordított végpontsorrenddel ugyanaz az eredmény várható. Ugyanazon egyenes kétszeri, kör vagy nulla hosszúságú egyenes kijelölése ne adjon hamis eredményt.
+6. AREA: `(0,0,0)`, `(4,0,0)`, `(0,3,0)`, Enter → terület 6, kerület 12. A harmadik pont mozgatásakor legyen élő érték. Ellenőrizd Objects módban a kört, zárt polylinet, és Add/Subtract módban több egymás utáni részterület összegzését.
+7. Mindegyik befejezett mérés után mozgasd az egeret: a végleges felirat maradjon a helyén. A következő rajzterületi kattintás törölje, és egyben kezdje meg a normál kijelölést / folytassa az AREA következő részterületét. Próbálj közvetlenül a felirat szövegére is kattintani. A befejező kattintás felengedése ne törölje rögtön a feliratot.
+8. ESC, új mérés, jobb és középső rajzterületi kattintás is törölje a végleges feliratot. Válts világos/sötét témát; teszteld a sarkokat és a nyitott parancssort. Válts dokumentumfület: másik rajz eredménye ne jelenjen meg az aktuális rajzon.
+9. Csak mérés után ne nőjön az entitásszám és ne váljon módosítottá a tiszta rajz. Mentés és újranyitás után ne maradjon mérési felirat a DWG-ben. A parancssori előzményben továbbra is legyen olvasható az eredmény.
+
+## Ismert korlátok
+
+- A két egyenes mód az irányok kisebb, 0–90° közötti szögét adja, a végpontsorrendtől függetlenül. Térben kitérő egyeneseken is az irányszög mérhető; nincs metszéspont- vagy szögtartomány-kiválasztás. A hárompontos mód 0–180°-ot ad, nem irányított/reflex szöget.
+- Az AREA és a MEASUREGEOM Area meglévő számítási jelentése megmarad: előbbi a meglévő térbeli/görbealapú mérés, utóbbi a munkasíkra vetített sokszög. Nem síkbeli pontsorokon ezért eltérhetnek.
+- A végleges felirat képernyőpozícióhoz kötött átmeneti UI, nem modellhez kötött méretfelirat. Nem nyomtatható és nem szerkeszthető rajzi objektumként.
+- A további 20 nyelv új szövegeinek anyanyelvi fordítása külön feladat.
+- A Windows alatti interaktív kézi ellenőrzést a fenti útmutató alapján a helyi gépen kell elvégezni; itt nem történt Windows GUI-próba.
+
+## Automatikus tesztlefedettség
+
+22 új Rust-teszt került a forrásba: kör és körív sugara, érvénytelen/stale kijelölés, ismert és szélsőséges skálájú szögek, degenerált szárak, két egyenes irányszöge, előnézet, f64/pontosság/hosszformátum, AREA élő érték és Add/Subtract, objektumkijelölési előnézet, regisztráció és menüindítás, dokumentum változatlansága, felirat élettartama, valódi viewport-kattintások továbbfeldolgozása, valamint az Iced felirat egéreseményei és szélekhez igazítása. A tesztkód a következő öt fájlban van: `measuregeom.rs`, `dist.rs`, `area.rs`, `src/app/measurement.rs`, `src/app/view/overlay.rs`.
+
+## Automatikus ellenőrzési eredmények
+
+Ellenőrzési környezet: Linux x86_64, Rust/Cargo 1.98.0, 2026.10.14. A fordításnál és tesztelésnél a memóriaigény csökkentésére 3 build szálat, kikapcsolt debug információt és a függőségeknél 0 optimalizációs szintet használtam. A projekt profilfájljai változatlanok.
+
+| Ellenőrzés | Tényleges eredmény |
+|---|---|
+| `cargo check --locked` | Sikeres, kilépési kód 0. |
+| `cargo check --locked --tests` | Sikeres, kilépési kód 0. A tesztkód is lefordítható. |
+| `cargo test --locked --lib measurement -- --test-threads=1` | 26 sikeres, 0 hibás, 1 kihagyott. Mind a 22 új mérési teszt sikeres. |
+| `cargo test --locked --lib modules::draw::inquiry -- --test-threads=1` | 18 sikeres, 0 hibás, 0 kihagyott. |
+| `cargo test --locked --lib i18n::tests::every_catalog_covers_and_formats_the_source_catalog -- --exact --test-threads=1` | 1 sikeres, 0 hibás. A katalógusok kulcskészlete és formázása ellenőrizve. |
+| `cargo fmt --check` | Sikertelen a kiinduló és a módosított forráson is: ugyanaz a 72 fájl tartalmaz örökölt formázási eltéréseket. A feladattól független fájlokat nem formáztam át. |
+| `git diff --check` | Sikeres, nincs whitespace-hiba a patchben. |
+| `python scripts/test_locales.py` | A kiinduló projektben 135, a módosított projektben 134 hiányzó régi literál-leképezés. Új hiányzó leképezés nincs. Ez az eredeti ellenőrzőszkript mindkét állapoton hibát jelez. |
+| SVG-k | XML-ként érvényesek; az új ikonok világos és sötét háttéren megtekintve. Az eredeti Distance/Area SVG és a két Cargo-fájl byte szerint változatlan. |
+| Windows GUI / DWG-mentés kézi próbája | Ebben a környezetben nem futott; a fenti kézi ellenőrzési listával végezhető el. |
+
+A fenti két tesztcsoport részben átfed, a darabszámokat nem szabad összeadni egyedi tesztszámként. A kihagyott teszt meglévő `viewport_dimension_external_fixture_keeps_unchanged_measurements`: az `OPENCAD_VIEWPORT_REGRESSION_DXF` változóban megadott külső DXF-et igényli. Az új tesztek egyike sincs kihagyva.
+
+A Linux alatt használt pontos parancsok (a `+1.98.0` a telepített toolchaint választja):
+
+```sh
+CARGO_BUILD_JOBS=3 CARGO_PROFILE_DEV_DEBUG=0 cargo +1.98.0 check --locked --config 'profile.dev.package."*".opt-level=0'
+CARGO_BUILD_JOBS=3 CARGO_PROFILE_DEV_DEBUG=0 cargo +1.98.0 check --locked --tests --config 'profile.dev.package."*".opt-level=0'
+CARGO_BUILD_JOBS=3 CARGO_PROFILE_TEST_DEBUG=0 cargo +1.98.0 test --locked --config 'profile.test.package."*".opt-level=0' --lib measurement -- --test-threads=1
+CARGO_BUILD_JOBS=3 CARGO_PROFILE_TEST_DEBUG=0 cargo +1.98.0 test --locked --config 'profile.test.package."*".opt-level=0' --lib modules::draw::inquiry -- --test-threads=1
+CARGO_BUILD_JOBS=3 CARGO_PROFILE_TEST_DEBUG=0 cargo +1.98.0 test --locked --config 'profile.test.package."*".opt-level=0' --lib i18n::tests::every_catalog_covers_and_formats_the_source_catalog -- --exact --test-threads=1
+cargo +1.98.0 fmt --check
+```
+
+A környezetben frissen telepített Rust 1.99 már `rustc -vV` alatt SIGBUS hibával leállt; ezen a toolchainen a cargo check/test nem érte el a projekt fordítását. A fenti sikeres eredmények Rust 1.98 alatt születtek. Ebből nem következik, hogy a helyi Windows 1.99 toolchain hibás lenne. A teljes projekt összes tesztjét és release buildjét nem futtattam.
+
+## A csomag összes fájlja
+
+42 fájl: 4 új, 38 módosított.
+
+| Állapot | Fájl |
+|---|---|
+| Új | `assets/icons/measure-angle.svg` |
+| Új | `assets/icons/measure-radius.svg` |
+| Új | `docs/measurement-tools.md` |
+| Módosított | `locales/ar-SA/opencadstudio.ftl` |
+| Módosított | `locales/bg-BG/opencadstudio.ftl` |
+| Módosított | `locales/cs-CZ/opencadstudio.ftl` |
+| Módosított | `locales/de-DE/opencadstudio.ftl` |
+| Módosított | `locales/el-GR/opencadstudio.ftl` |
+| Módosított | `locales/en-US/opencadstudio.ftl` |
+| Módosított | `locales/es-ES/opencadstudio.ftl` |
+| Módosított | `locales/fi-FI/opencadstudio.ftl` |
+| Módosított | `locales/fr-FR/opencadstudio.ftl` |
+| Módosított | `locales/hi-IN/opencadstudio.ftl` |
+| Módosított | `locales/hu-HU/opencadstudio.ftl` |
+| Módosított | `locales/it-IT/opencadstudio.ftl` |
+| Módosított | `locales/ja-JP/opencadstudio.ftl` |
+| Módosított | `locales/ko-KR/opencadstudio.ftl` |
+| Módosított | `locales/lo-LA/opencadstudio.ftl` |
+| Módosított | `locales/nl-NL/opencadstudio.ftl` |
+| Módosított | `locales/pl-PL/opencadstudio.ftl` |
+| Módosított | `locales/pt-BR/opencadstudio.ftl` |
+| Módosított | `locales/ru-RU/opencadstudio.ftl` |
+| Módosított | `locales/tr-TR/opencadstudio.ftl` |
+| Módosított | `locales/zh-CN/opencadstudio.ftl` |
+| Módosított | `locales/zh-TW/opencadstudio.ftl` |
+| Módosított | `src/app/command_driver/mod.rs` |
+| Módosított | `src/app/commands/draw.rs` |
+| Módosított | `src/app/commands/mod.rs` |
+| Módosított | `src/app/control/mod.rs` |
+| Módosított | `src/app/document.rs` |
+| Új | `src/app/measurement.rs` |
+| Módosított | `src/app/mod.rs` |
+| Módosított | `src/app/update/command.rs` |
+| Módosított | `src/app/update/mod.rs` |
+| Módosított | `src/app/update/viewport.rs` |
+| Módosított | `src/app/view/mod.rs` |
+| Módosított | `src/app/view/overlay.rs` |
+| Módosított | `src/command.rs` |
+| Módosított | `src/modules/draw/inquiry/area.rs` |
+| Módosított | `src/modules/draw/inquiry/dist.rs` |
+| Módosított | `src/modules/draw/inquiry/measuregeom.rs` |
+| Módosított | `src/modules/draw/mod.rs` |
diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl
index 1b3ac2476..a6848f12a 100644
--- a/locales/ar-SA/opencadstudio.ftl
+++ b/locales/ar-SA/opencadstudio.ftl
@@ -6582,3 +6582,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl
index 57db6d081..0649b09aa 100644
--- a/locales/bg-BG/opencadstudio.ftl
+++ b/locales/bg-BG/opencadstudio.ftl
@@ -6570,3 +6570,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl
index 6c0eb0eae..5e5ebc79c 100644
--- a/locales/cs-CZ/opencadstudio.ftl
+++ b/locales/cs-CZ/opencadstudio.ftl
@@ -6567,3 +6567,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl
index b87caa55a..0031cdfcd 100644
--- a/locales/de-DE/opencadstudio.ftl
+++ b/locales/de-DE/opencadstudio.ftl
@@ -6568,3 +6568,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl
index 47421fc23..dc8b4286c 100644
--- a/locales/el-GR/opencadstudio.ftl
+++ b/locales/el-GR/opencadstudio.ftl
@@ -6570,3 +6570,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl
index aaa070afe..c8736d86f 100644
--- a/locales/en-US/opencadstudio.ftl
+++ b/locales/en-US/opencadstudio.ftl
@@ -6569,3 +6569,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl
index 350069c0f..965dc1d78 100644
--- a/locales/es-ES/opencadstudio.ftl
+++ b/locales/es-ES/opencadstudio.ftl
@@ -6570,3 +6570,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl
index d7168d455..1d58ef2dd 100644
--- a/locales/fi-FI/opencadstudio.ftl
+++ b/locales/fi-FI/opencadstudio.ftl
@@ -6567,3 +6567,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl
index 213c37ce7..646c4da9f 100644
--- a/locales/fr-FR/opencadstudio.ftl
+++ b/locales/fr-FR/opencadstudio.ftl
@@ -6570,3 +6570,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl
index 5713ca073..574d0a2a8 100644
--- a/locales/hi-IN/opencadstudio.ftl
+++ b/locales/hi-IN/opencadstudio.ftl
@@ -6558,3 +6558,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl
index 6090437ed..b3515fd16 100644
--- a/locales/hu-HU/opencadstudio.ftl
+++ b/locales/hu-HU/opencadstudio.ftl
@@ -6554,3 +6554,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Ideiglenes geometriai mérési eredmények.
+measurement =
+ .distance = Távolság = { $value }
+ .angle-start = { $command } Adja meg a szög csúcspontját vagy [Vonalak]:
+ .first-line = { $command } Jelölje ki az első vonalat vagy [Pontok] (kisebb szög):
+ .second-line = { $command } Jelölje ki a második vonalat (kisebb szög):
+ .lines = Vonalak
+ .points = Pontok
+ .invalid-radius = Jelöljön ki véges, pozitív sugarú kört vagy körívet.
+ .invalid-line = Jelöljön ki véges koordinátájú, nem nulla hosszúságú vonalat.
+ .different-line = Jelöljön ki egy másik vonalat.
+ .invalid-point = Adjon meg véges koordinátájú pontot.
+ .degenerate-ray = A szár végpontja nem egyezhet meg a szög csúcspontjával.
+ .area-preview =
+ { $current }
+ Előzetes összterület = { $total }
diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl
index 865bc395d..b0c8d9e9b 100644
--- a/locales/it-IT/opencadstudio.ftl
+++ b/locales/it-IT/opencadstudio.ftl
@@ -6569,3 +6569,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl
index 2e5a6dab4..c4fd50f57 100644
--- a/locales/ja-JP/opencadstudio.ftl
+++ b/locales/ja-JP/opencadstudio.ftl
@@ -6558,3 +6558,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl
index 47c2ac449..60066bc54 100644
--- a/locales/ko-KR/opencadstudio.ftl
+++ b/locales/ko-KR/opencadstudio.ftl
@@ -6569,3 +6569,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl
index ea947238f..bff99fcdc 100644
--- a/locales/lo-LA/opencadstudio.ftl
+++ b/locales/lo-LA/opencadstudio.ftl
@@ -6570,3 +6570,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl
index ba179b790..ea6da2744 100644
--- a/locales/nl-NL/opencadstudio.ftl
+++ b/locales/nl-NL/opencadstudio.ftl
@@ -6568,3 +6568,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl
index f6c9c62f7..93bbda0ac 100644
--- a/locales/pl-PL/opencadstudio.ftl
+++ b/locales/pl-PL/opencadstudio.ftl
@@ -6569,3 +6569,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl
index cef8d43c1..097102537 100644
--- a/locales/pt-BR/opencadstudio.ftl
+++ b/locales/pt-BR/opencadstudio.ftl
@@ -6570,3 +6570,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl
index d5a77aac7..e994a5dcd 100644
--- a/locales/ru-RU/opencadstudio.ftl
+++ b/locales/ru-RU/opencadstudio.ftl
@@ -6571,3 +6571,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl
index 473a377c1..9fd2212c5 100644
--- a/locales/tr-TR/opencadstudio.ftl
+++ b/locales/tr-TR/opencadstudio.ftl
@@ -6491,3 +6491,20 @@ block-def =
.name-unsupported-chars = Ad desteklenmeyen karakterler içeriyor. Şu karakterler desteklenmez: \ < > / ? " : ; * | , = `
.definition-changed-redefine = Blok tanımı değişti. Yeniden tanımlamak istiyor musunuz?
.create-drawing-file = Çizim Dosyası Oluştur
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl
index 08dfd371f..f57c72aae 100644
--- a/locales/zh-CN/opencadstudio.ftl
+++ b/locales/zh-CN/opencadstudio.ftl
@@ -6553,3 +6553,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl
index 70af3b9ca..971c7df49 100644
--- a/locales/zh-TW/opencadstudio.ftl
+++ b/locales/zh-TW/opencadstudio.ftl
@@ -6565,3 +6565,20 @@ block-def =
.name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = `
.definition-changed-redefine = The block definition has changed. Do you want to redefine it ?
.create-drawing-file = Create Drawing File
+
+# Transient geometry inquiry readouts.
+measurement =
+ .distance = Distance = { $value }
+ .angle-start = { $command } Specify vertex point or [Lines]:
+ .first-line = { $command } Select first line or [Points] (smaller angle):
+ .second-line = { $command } Select second line (smaller angle):
+ .lines = Lines
+ .points = Points
+ .invalid-radius = Select a circle or arc with a finite, positive radius.
+ .invalid-line = Select a nonzero, finite line.
+ .different-line = Select a different line.
+ .invalid-point = Specify a point with finite coordinates.
+ .degenerate-ray = The ray endpoint must differ from the angle vertex.
+ .area-preview =
+ { $current }
+ Preview total area = { $total }
diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs
index 652fdc623..c0bd44f59 100644
--- a/src/app/command_driver/mod.rs
+++ b/src/app/command_driver/mod.rs
@@ -1163,6 +1163,7 @@ impl OpenCADStudio {
fn handle_cancel(&mut self, cancel: CmdResult) {
let i = self.active_tab;
+ self.tabs[i].measurement.clear();
let space_changed = matches!(cancel, CmdResult::CancelForSpaceChange);
self.tabs[i].scene.clear_preview_wire();
if !space_changed && self.tabs[i].suspended_cmd.is_some() {
@@ -1304,6 +1305,7 @@ impl OpenCADStudio {
}
fn handle_measurement(&mut self, msg: String) {
+ self.remember_measurement(&msg);
let i = self.active_tab;
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
@@ -1315,6 +1317,7 @@ impl OpenCADStudio {
}
fn handle_report_measurement(&mut self, msg: String) {
+ self.remember_measurement(&msg);
let i = self.active_tab;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
@@ -1336,6 +1339,7 @@ impl OpenCADStudio {
}
fn handle_report_measurement_and_deselect(&mut self, msg: String) {
+ self.remember_measurement(&msg);
let i = self.active_tab;
self.tabs[i].snap_result = None;
self.tabs[i].scene.deselect_all();
diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs
index ccdb7ba5c..d00d1fe9f 100644
--- a/src/app/commands/draw.rs
+++ b/src/app/commands/draw.rs
@@ -598,9 +598,13 @@ impl OpenCADStudio {
}
}
- "MEASUREGEOM" => {
+ "MEASUREGEOM" | "MEASURERADIUS" | "MEASUREANGLE" => {
use crate::modules::draw::inquiry::measuregeom::MeasureGeomCommand;
- let new_cmd = MeasureGeomCommand::new();
+ let new_cmd = match cmd {
+ "MEASURERADIUS" => MeasureGeomCommand::radius(),
+ "MEASUREANGLE" => MeasureGeomCommand::angle(),
+ _ => MeasureGeomCommand::new(),
+ };
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs
index d3ff4c6b6..82415f89e 100644
--- a/src/app/commands/mod.rs
+++ b/src/app/commands/mod.rs
@@ -64,6 +64,7 @@ impl OpenCADStudio {
/// (the block palette), so they don't inherit the previous command's last
/// point, snaps or dynamic-input values. (#1525)
pub(in crate::app) fn reset_command_start_state(&mut self, i: usize) {
+ self.tabs[i].measurement.clear();
// A command parked behind a transparent zoom / MTP goes with it.
self.tabs[i].suspended_cmd = None;
self.tabs[i].transparent_resume = false;
diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs
index caaff5e31..bbe0eba25 100644
--- a/src/app/control/mod.rs
+++ b/src/app/control/mod.rs
@@ -308,7 +308,17 @@ fn command_category(name: &str) -> &'static str {
.contains(&name)
{
"modify"
- } else if ["DIST", "ID", "AREA", "MEASURE", "MEASUREGEOM"].contains(&name) {
+ } else if [
+ "DIST",
+ "ID",
+ "AREA",
+ "MEASURE",
+ "MEASUREGEOM",
+ "MEASURERADIUS",
+ "MEASUREANGLE",
+ ]
+ .contains(&name)
+ {
"inspect"
} else if ["ZOOM", "PAN", "UCS", "PLAN", "VPORTS", "3DORBIT"].contains(&name) {
"view"
diff --git a/src/app/document.rs b/src/app/document.rs
index 97a8eb9e3..7e5e14500 100644
--- a/src/app/document.rs
+++ b/src/app/document.rs
@@ -157,6 +157,7 @@ pub(super) struct DocumentTab {
pub(super) properties: PropertiesPanel,
pub(super) layers: LayerPanel,
pub(super) active_cmd: Option>,
+ pub(super) measurement: super::measurement::MeasurementDisplay,
/// Remaining command tokens queued behind a PAUSE (or `\`) awaiting user interaction.
pub(super) pending_pause_tokens: Option>,
/// The selection set the most recent command worked on, captured when a
@@ -644,6 +645,7 @@ impl DocumentTab {
properties: PropertiesPanel::empty(),
layers: LayerPanel::default(),
active_cmd: None,
+ measurement: super::measurement::MeasurementDisplay::default(),
pending_pause_tokens: None,
last_cmd: None,
last_draw_anchor: None,
diff --git a/src/app/measurement.rs b/src/app/measurement.rs
new file mode 100644
index 000000000..4b9db9588
--- /dev/null
+++ b/src/app/measurement.rs
@@ -0,0 +1,288 @@
+//! Per-drawing, transient inquiry UI; never serialized into document entities.
+use super::OpenCADStudio;
+use iced::Point;
+
+#[derive(Clone, Debug)]
+pub(super) struct MeasurementReadout {
+ pub text: String,
+ pub anchor: Point,
+}
+
+#[derive(Default)]
+pub(super) struct MeasurementDisplay {
+ pub result: Option,
+ pub live_visible: bool,
+}
+
+impl MeasurementDisplay {
+ pub fn clear(&mut self) {
+ *self = Self::default();
+ }
+
+ /// Called on press before ordinary input processing. It cannot capture
+ /// the event, so this same press can also pick/select/pan/finish a command.
+ pub fn dismiss_result(&mut self) {
+ self.result = None;
+ }
+}
+
+impl OpenCADStudio {
+ pub(super) fn remember_measurement(&mut self, text: &str) {
+ let tab = &mut self.tabs[self.active_tab];
+ if tab
+ .active_cmd
+ .as_ref()
+ .is_some_and(|c| c.has_measurement_readout())
+ {
+ tab.measurement.result = Some(MeasurementReadout {
+ text: text.to_owned(),
+ anchor: tab.last_cursor_screen,
+ });
+ }
+ }
+
+ pub(super) fn measurement_readout(&self, index: usize) -> Option {
+ let tab = &self.tabs[index];
+ if let Some(result) = &tab.measurement.result {
+ return Some(result.clone());
+ }
+ if !tab.measurement.live_visible {
+ return None;
+ }
+ let text = tab
+ .active_cmd
+ .as_ref()?
+ .measurement_readout(tab.last_cursor_world)?;
+ Some(MeasurementReadout {
+ text,
+ anchor: tab.last_cursor_screen,
+ })
+ }
+}
+
+#[cfg(test)]
+mod measurement_tests {
+ use super::*;
+ use crate::app::Message;
+ use crate::command::{all_registered_command_names, StepInput};
+ use crate::modules::{CadModule, ModuleEvent, RibbonItem};
+ use glam::DVec3;
+
+ fn app() -> OpenCADStudio {
+ 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().view.vp_size = (800.0, 600.0);
+ 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.dyn_input = false;
+ app
+ }
+ fn finish_distance(app: &mut OpenCADStudio) {
+ let _ = app.dispatch_command("DIST");
+ let _ = app.feed_command(StepInput::Point(DVec3::ZERO));
+ let _ = app.feed_command(StepInput::Point(DVec3::new(3.0, 4.0, 0.0)));
+ }
+
+ #[test]
+ fn measurement_menu_registry_and_dispatch() {
+ let names = all_registered_command_names();
+ let module = crate::modules::draw::DrawModule;
+ let menu = module
+ .ribbon_groups()
+ .iter()
+ .flat_map(|g| &g.tools)
+ .find(|item| {
+ matches!(
+ item,
+ RibbonItem::LargeDropdown {
+ id: "MEASURE_MENU",
+ ..
+ }
+ )
+ })
+ .unwrap();
+ let RibbonItem::LargeDropdown { items, default, .. } = menu else {
+ unreachable!()
+ };
+ assert_eq!(*default, "DIST");
+ assert_eq!(
+ items.iter().map(|item| item.0).collect::>(),
+ ["DIST", "MEASURERADIUS", "MEASUREANGLE", "AREA"]
+ );
+ let mut app = app();
+ for name in [
+ "DIST",
+ "MEASURERADIUS",
+ "MEASUREANGLE",
+ "AREA",
+ "MEASUREGEOM",
+ ] {
+ assert!(names.contains(&name));
+ let _ = app.dispatch_command(name);
+ assert_eq!(
+ app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(),
+ name
+ );
+ let _ = app.update(Message::RibbonToolClick {
+ tool_id: name.into(),
+ event: ModuleEvent::Command(name.into()),
+ });
+ assert_eq!(
+ app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(),
+ name
+ );
+ }
+ }
+
+ #[test]
+ fn measurement_result_persists_without_document_changes() {
+ let mut app = app();
+ let i = app.active_tab;
+ let count = app.tabs[i].scene.document.entity_count();
+ let dirty = app.tabs[i].dirty;
+ let revision = app.tabs[i].edit_revision;
+ let _ = app.update(Message::ViewportMove(Point::new(350.0, 250.0)));
+ finish_distance(&mut app);
+ assert!(app.tabs[i].active_cmd.is_none());
+ let result = app.measurement_readout(i).unwrap();
+ assert!(app.command_line.history_plain_text().contains(&result.text));
+ let _ = app.update(Message::ViewportLeftRelease);
+ let _ = app.update(Message::ViewportMove(Point::new(450.0, 300.0)));
+ let retained = app.measurement_readout(i).unwrap();
+ assert_eq!(result.text, retained.text);
+ assert_eq!(result.anchor, retained.anchor);
+ assert_eq!(app.tabs[i].scene.document.entity_count(), count);
+ assert_eq!(app.tabs[i].dirty, dirty);
+ assert_eq!(app.tabs[i].edit_revision, revision);
+ }
+
+ #[test]
+ fn measurement_dismiss_click_also_starts_selection() {
+ let mut app = app();
+ let i = app.active_tab;
+ finish_distance(&mut app);
+ let _ = app.update(Message::ViewportMove(Point::new(350.0, 250.0)));
+ let _ = app.update(Message::ViewportLeftPress);
+ assert!(app.measurement_readout(i).is_none());
+ assert!(app.tabs[i].scene.selection.borrow().input.left_down);
+ let _ = app.update(Message::ViewportLeftRelease);
+ assert!(app.tabs[i]
+ .scene
+ .selection
+ .borrow()
+ .gesture
+ .box_anchor
+ .is_some());
+ }
+
+ #[test]
+ fn measurement_final_pick_survives_its_own_release() {
+ let mut app = app();
+ let i = app.active_tab;
+ let _ = app.dispatch_command("DIST");
+ for point in [Point::new(350.0, 250.0), Point::new(430.0, 280.0)] {
+ let _ = app.update(Message::ViewportMove(point));
+ let _ = app.update(Message::ViewportLeftPress);
+ let _ = app.update(Message::ViewportLeftRelease);
+ }
+ assert!(app.tabs[i].active_cmd.is_none());
+ assert!(app.measurement_readout(i).is_some());
+ }
+
+ #[test]
+ fn measurement_live_without_dyn_and_escape() {
+ let mut app = app();
+ let i = app.active_tab;
+ let _ = app.dispatch_command("DIST");
+ let _ = app.feed_command(StepInput::Point(DVec3::ZERO));
+ let _ = app.update(Message::ViewportMove(Point::new(410.0, 310.0)));
+ let first = app.measurement_readout(i).unwrap().text;
+ let _ = app.update(Message::ViewportMove(Point::new(510.0, 310.0)));
+ assert_ne!(first, app.measurement_readout(i).unwrap().text);
+ let _ = app.update(Message::CommandEscape);
+ assert!(app.measurement_readout(i).is_none());
+ }
+
+ #[test]
+ fn measurement_escape_new_command_and_mouse_buttons_clear_result() {
+ let mut app = app();
+ let i = app.active_tab;
+ for event in [
+ Message::CommandEscape,
+ Message::ViewportRightPress,
+ Message::ViewportMiddlePress,
+ ] {
+ finish_distance(&mut app);
+ assert!(app.measurement_readout(i).is_some());
+ let _ = app.update(event);
+ assert!(app.measurement_readout(i).is_none());
+ }
+ finish_distance(&mut app);
+ let _ = app.dispatch_command("MEASURERADIUS");
+ assert!(app.measurement_readout(i).is_none());
+ }
+
+ #[test]
+ fn measurement_area_add_dismiss_click_is_also_next_corner() {
+ let mut app = app();
+ let i = app.active_tab;
+ let _ = app.dispatch_command("AREA");
+ let _ = app.feed_command(StepInput::Text("ADD".into()));
+ for p in [DVec3::ZERO, DVec3::X * 4.0, DVec3::Y * 3.0] {
+ let _ = app.feed_command(StepInput::Point(p));
+ }
+ let _ = app.feed_command(StepInput::Enter);
+ assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "AREA");
+ assert!(app.measurement_readout(i).is_some());
+ for p in [Point::new(350.0, 250.0), Point::new(410.0, 250.0)] {
+ let _ = app.update(Message::ViewportMove(p));
+ let _ = app.update(Message::ViewportLeftPress);
+ let _ = app.update(Message::ViewportLeftRelease);
+ }
+ assert!(app.tabs[i].measurement.result.is_none());
+ assert!(
+ app.measurement_readout(i).is_some(),
+ "both corners must reach AREA"
+ );
+ }
+
+ #[test]
+ fn measurement_radius_and_lines_real_entity_injection() {
+ use codec::{
+ entities::{Circle, Line},
+ types::Vector3,
+ EntityType,
+ };
+ let mut app = app();
+ let i = app.active_tab;
+ let circle = app.tabs[i]
+ .scene
+ .add_entity(EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 2.0)));
+ let first = app.tabs[i]
+ .scene
+ .add_entity(EntityType::Line(Line::from_points(
+ Vector3::ZERO,
+ Vector3::UNIT_X,
+ )));
+ let second = app.tabs[i]
+ .scene
+ .add_entity(EntityType::Line(Line::from_points(
+ Vector3::ZERO,
+ Vector3::UNIT_Y,
+ )));
+ let _ = app.dispatch_command("MEASURERADIUS");
+ let _ = app.feed_command(StepInput::EntityPick(circle, DVec3::X * 2.0));
+ assert!(app.tabs[i].active_cmd.is_none());
+ assert!(app.measurement_readout(i).unwrap().text.contains("Radius"));
+ let _ = app.dispatch_command("MEASUREANGLE");
+ let _ = app.feed_command(StepInput::Text("LINES".into()));
+ let _ = app.feed_command(StepInput::EntityPick(first, DVec3::X));
+ let _ = app.feed_command(StepInput::EntityPick(second, DVec3::Y));
+ assert!(app.tabs[i].active_cmd.is_none());
+ assert!(app.measurement_readout(i).unwrap().text.contains("90"));
+ }
+}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 651eaddca..a0c4a2888 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -29,6 +29,7 @@ mod find_replace;
pub(crate) mod helpers;
mod history;
mod layers;
+mod measurement;
mod model_ops;
mod mtext_editor;
mod navigation;
diff --git a/src/app/update/command.rs b/src/app/update/command.rs
index 7ca474523..c0d6666b5 100644
--- a/src/app/update/command.rs
+++ b/src/app/update/command.rs
@@ -1249,6 +1249,7 @@ impl OpenCADStudio {
}
pub(super) fn on_command_escape(&mut self) -> Task {
+ self.tabs[self.active_tab].measurement.clear();
// Escape answers a pending client getpoint request first: no point.
if self.control.get_point.is_some() {
self.resolve_get_point(None);
diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs
index 9797982ed..ca6c167ec 100644
--- a/src/app/update/mod.rs
+++ b/src/app/update/mod.rs
@@ -4564,6 +4564,7 @@ impl OpenCADStudio {
Message::ViewportRightPress => {
self.graphic_attributes.open_menu = None;
let i = self.active_tab;
+ self.tabs[i].measurement.dismiss_result();
self.ribbon.close_dropdown();
// Shift+RMB: the one-shot snap override menu at the cursor —
// pick a snap for just the next point, then it expires (#337).
diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs
index 00e68266f..301b8fa2a 100644
--- a/src/app/update/viewport.rs
+++ b/src/app/update/viewport.rs
@@ -1236,6 +1236,7 @@ impl OpenCADStudio {
return Task::none();
}
let i = self.active_tab;
+ self.tabs[i].measurement.live_visible = true;
if let Some(task) = self.start_drag_move(i, p) {
return Task::batch([task, self.on_viewport_move(p)]);
}
@@ -3162,6 +3163,7 @@ impl OpenCADStudio {
pub(crate) fn on_viewport_exit(&mut self) -> Task {
let i = self.active_tab;
+ self.tabs[i].measurement.live_visible = false;
self.hover_dwell = None;
if self.tabs[i]
.active_cmd
@@ -3658,6 +3660,7 @@ impl OpenCADStudio {
pub(super) fn on_viewport_left_press(&mut self) -> Task {
let i = self.active_tab;
+ self.tabs[i].measurement.dismiss_result();
self.block_palette.pressed = None;
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
command.set_ctrl(self.ctrl_down);
@@ -5916,6 +5919,7 @@ properties={:.1}ms picked={}",
pub(super) fn on_viewport_middle_press(&mut self) -> Task {
let i = self.active_tab;
+ self.tabs[i].measurement.dismiss_result();
self.clear_navigation_hover(i);
self.ribbon.close_dropdown();
self.tabs[i].scene.remember_current_view();
diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs
index 6473c1c65..cc4f07a1a 100644
--- a/src/app/view/mod.rs
+++ b/src/app/view/mod.rs
@@ -1809,6 +1809,15 @@ bg={bg_ms:.1}ms n={view_count}"
} else {
34.0 + self.command_line.overlay_lines_height()
};
+ if !tab.is_start {
+ if let Some(readout) = self.measurement_readout(self.active_tab) {
+ viewport_stack = viewport_stack.push(overlay::measurement_overlay(
+ readout.anchor,
+ command_line_inset,
+ readout.text,
+ ));
+ }
+ }
// Quick Properties: stay near the selection cursor, flipping around
// it as needed to remain inside the visible drawing area.
if self.quick_properties && !tab.is_start {
diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs
index de085c127..03c8e0a0b 100644
--- a/src/app/view/overlay.rs
+++ b/src/app/view/overlay.rs
@@ -928,6 +928,7 @@ fn position_canvas_overlay_clamped<'a>(
bottom_inset: bottom_inset.max(0.0),
gap: 0.0,
offset,
+ passive: false,
})
}
@@ -944,6 +945,48 @@ pub(super) fn position_canvas_overlay_near_cursor<'a>(
bottom_inset: bottom_inset.max(0.0),
gap: 12.0,
offset: Vector::ZERO,
+ passive: false,
+ })
+}
+
+/// Measured, wrapping text in the existing clamped panel layout. Input goes
+/// straight through, including clicks directly on the result text.
+pub(super) fn measurement_overlay<'a>(
+ anchor: iced::Point,
+ bottom_inset: f32,
+ value: String,
+) -> Element<'a, Message> {
+ // Inquiry commands return a compact, comma-separated summary for the
+ // command line. Show its fields on separate lines in the transient HUD,
+ // without altering the command-line history or the measured precision.
+ let display_value = if value.contains("\n") {
+ value.replace(", ", "\n").replace(", ", "\n")
+ } else {
+ value
+ };
+ let panel = container(text(display_value).size(12))
+ .padding([6, 8])
+ .width(Length::Fit.max(300.0))
+ .style(|theme: &Theme| {
+ let palette = theme.palette();
+ container::Style {
+ background: Some(Background::Color(palette.background.weak.color)),
+ text_color: Some(palette.background.weak.text),
+ border: Border {
+ color: palette.background.neutral.color,
+ width: 1.0,
+ radius: 3.0.into(),
+ },
+ ..Default::default()
+ }
+ });
+ Element::new(ClampedPin {
+ content: panel.into(),
+ anchor,
+ bottom_inset: bottom_inset.max(0.0),
+ gap: 18.0,
+ offset: Vector::ZERO,
+ passive: true,
})
}
@@ -954,6 +997,8 @@ struct ClampedPin<'a> {
gap: f32,
/// Preferred placement shift from the anchor (applied before fitting).
offset: Vector,
+ /// Readouts prefer above the cursor and never participate in input.
+ passive: bool,
}
impl Widget for ClampedPin<'_> {
@@ -1008,7 +1053,9 @@ impl Widget for ClampedPin<'_> {
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 {
+ let y = if self.passive && above >= MARGIN && above <= max_y {
+ above
+ } else if below <= max_y {
below.max(MARGIN)
} else if above >= MARGIN {
above
@@ -1016,6 +1063,9 @@ impl Widget for ClampedPin<'_> {
max_y
};
+ // A cursor over the command-line inset can put even the "above"
+ // candidate below the safe area. Keep this passive readout inside it.
+ let y = if self.passive { y.min(max_y) } else { y };
node = node.move_to(iced::Point::new(x, y));
let size = limits.resolve(Length::Fill, Length::Fill, node.size());
layout::Node::with_children(size, vec![node])
@@ -1046,6 +1096,9 @@ impl Widget for ClampedPin<'_> {
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
+ if self.passive {
+ return;
+ }
self.content.as_widget_mut().update(
tree,
event,
@@ -1065,6 +1118,9 @@ impl Widget for ClampedPin<'_> {
viewport: &Rectangle,
renderer: &iced::Renderer,
) -> mouse::Interaction {
+ if self.passive {
+ return mouse::Interaction::None;
+ }
self.content.as_widget().mouse_interaction(
tree,
layout.children().next().unwrap(),
@@ -1872,3 +1928,65 @@ fn qselect_content<'a>(
panel.into()
}
+
+#[cfg(test)]
+mod measurement_tests {
+ use super::*;
+ #[test]
+ fn measurement_overlay_passes_all_clicks_on_text() {
+ let underlay = mouse_area(Space::new().width(Fill).height(Fill))
+ .on_press(Message::ViewportLeftPress)
+ .on_right_press(Message::ViewportRightPress)
+ .on_middle_press(Message::ViewportMiddlePress);
+ let mut ui = iced_test::simulator(stack![
+ underlay,
+ measurement_overlay(
+ iced::Point::new(400.0, 300.0),
+ 60.0,
+ "measurement-result".into()
+ )
+ ]);
+ let center = ui
+ .find("measurement-result")
+ .unwrap()
+ .visible_bounds()
+ .unwrap()
+ .center();
+ ui.point_at(center);
+ for button in [
+ iced::mouse::Button::Left,
+ iced::mouse::Button::Right,
+ iced::mouse::Button::Middle,
+ ] {
+ ui.simulate([Event::Mouse(iced::mouse::Event::ButtonPressed(button))]);
+ ui.simulate([Event::Mouse(iced::mouse::Event::ButtonReleased(button))]);
+ }
+ let messages: Vec<_> = ui.into_messages().collect();
+ assert!(messages
+ .iter()
+ .any(|m| matches!(m, Message::ViewportLeftPress)));
+ assert!(messages
+ .iter()
+ .any(|m| matches!(m, Message::ViewportRightPress)));
+ assert!(messages
+ .iter()
+ .any(|m| matches!(m, Message::ViewportMiddlePress)));
+ }
+ #[test]
+ fn measurement_overlay_stays_inside_canvas_above_command_line() {
+ for anchor in [iced::Point::new(5.0, 5.0), iced::Point::new(790.0, 595.0)] {
+ let mut ui = iced_test::simulator::Simulator::with_size(
+ Default::default(),
+ iced::Size::new(800.0, 600.0),
+ measurement_overlay(anchor, 90.0, "measurement-result".into()),
+ );
+ let bounds = ui
+ .find("measurement-result")
+ .unwrap()
+ .visible_bounds()
+ .unwrap();
+ assert!(bounds.x >= 4.0 && bounds.y >= 4.0);
+ assert!(bounds.x + bounds.width <= 796.0 && bounds.y + bounds.height <= 506.0);
+ }
+ }
+}
diff --git a/src/command.rs b/src/command.rs
index 38ae3219e..830f98340 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -2876,6 +2876,17 @@ pub trait CadCommand: Send {
fn inject_hover_entity(&mut self, _handle: Handle, _entity: EntityType) {}
+ /// Opt in to transient measurement text; unrelated Measurement users keep
+ /// their existing command-line-only behavior.
+ fn has_measurement_readout(&self) -> bool {
+ false
+ }
+
+ /// Live inquiry text at the same full-precision point as the preview.
+ fn measurement_readout(&self, _cursor: DVec3) -> Option {
+ None
+ }
+
/// Called on every mouse-move in the viewport.
/// Return `Some(WireModel)` to update the rubber-band preview, `None` to skip.
fn on_mouse_move(&mut self, _pt: DVec3) -> Option {
diff --git a/src/modules/draw/inquiry/area.rs b/src/modules/draw/inquiry/area.rs
index ef60cc40a..5dba9d85a 100644
--- a/src/modules/draw/inquiry/area.rs
+++ b/src/modules/draw/inquiry/area.rs
@@ -29,6 +29,9 @@ pub struct AreaCommand {
points: Vec,
objects_gathering: bool,
selected_entities: Vec,
+ // Recompute exact curve measurements only when the selection changes,
+ // not on every UI frame while showing the live readout.
+ selection_preview: Option,
preview_regions: Vec,
total_area: f64,
total_perimeter: f64,
@@ -41,6 +44,7 @@ impl AreaCommand {
points: Vec::new(),
objects_gathering: false,
selected_entities: Vec::new(),
+ selection_preview: None,
preview_regions: Vec::new(),
total_area: 0.0,
total_perimeter: 0.0,
@@ -225,6 +229,7 @@ impl AreaCommand {
self.points.clear();
self.objects_gathering = false;
self.selected_entities.clear();
+ self.selection_preview = None;
let message = self.running_result_message(measurement);
if deselect {
CmdResult::ReportMeasurementAndDeselect(message)
@@ -297,6 +302,7 @@ impl CadCommand for AreaCommand {
}
self.objects_gathering = false;
self.selected_entities.clear();
+ self.selection_preview = None;
return Some(CmdResult::DeselectAndContinue);
}
return Some(CmdResult::NeedPoint);
@@ -323,6 +329,7 @@ impl CadCommand for AreaCommand {
fn inject_selection_entities(&mut self, entities: Vec) {
self.selected_entities = entities;
+ self.selection_preview = self.selection_measurement().map(|(value, _)| value);
}
fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult {
@@ -341,6 +348,7 @@ impl CadCommand for AreaCommand {
if self.objects_gathering {
let Some((measurement, handles)) = self.selection_measurement() else {
self.selected_entities.clear();
+ self.selection_preview = None;
return CmdResult::ReportMeasurementAndDeselect(crate::tr!(
"area",
"objects-not-measurable"
@@ -373,6 +381,39 @@ impl CadCommand for AreaCommand {
self.finish_measurement(measurement, false)
}
+ fn has_measurement_readout(&self) -> bool {
+ true
+ }
+
+ fn measurement_readout(&self, cursor: DVec3) -> Option {
+ let measurement = if self.objects_gathering {
+ self.selection_preview?
+ } else {
+ if self.points.len() < 2 || !cursor.is_finite() {
+ return None;
+ }
+ let mut points = self.points.clone();
+ points.push(cursor);
+ Self::point_measurement(&points, true)
+ };
+ if self.mode == AreaMode::Single {
+ Some(Self::result_message(measurement))
+ } else {
+ let sign = if self.mode == AreaMode::Add {
+ 1.0
+ } else {
+ -1.0
+ };
+ Some(crate::tr!(
+ "measurement",
+ "area-preview",
+ current = Self::result_message(measurement),
+ total =
+ crate::entities::common::format_area(self.total_area + sign * measurement.area)
+ ))
+ }
+ }
+
fn on_mouse_move(&mut self, point: DVec3) -> Option {
if self.objects_gathering || self.points.is_empty() {
return None;
@@ -451,3 +492,65 @@ impl CadCommand for AreaCommand {
}
inventory::submit!(crate::command::CommandRegistration { names: &["AREA"] });
+
+#[cfg(test)]
+mod measurement_tests {
+ use super::*;
+ #[test]
+ fn live_area_matches_final_result_without_changing_points() {
+ let mut command = AreaCommand::new();
+ command.on_point(DVec3::ZERO);
+ assert!(command.measurement_readout(DVec3::Y).is_none());
+ command.on_point(DVec3::X * 4.0);
+ let cursor = DVec3::Y * 3.0;
+ let preview = command.measurement_readout(cursor).unwrap();
+ assert_eq!(command.points.len(), 2);
+ command.on_point(cursor);
+ let CmdResult::Measurement(text) = command.on_enter() else {
+ panic!("result");
+ };
+ assert_eq!(preview, text);
+ }
+ #[test]
+ fn add_subtract_preview_does_not_change_total() {
+ let mut command = AreaCommand::new();
+ command.on_text_input("ADD");
+ for p in [DVec3::ZERO, DVec3::X * 4.0, DVec3::Y * 3.0] {
+ command.on_point(p);
+ }
+ assert!(matches!(
+ command.on_enter(),
+ CmdResult::ReportMeasurement(_)
+ ));
+ command.on_text_input("SUBTRACT");
+ command.on_point(DVec3::ZERO);
+ command.on_point(DVec3::X * 2.0);
+ assert!(command
+ .measurement_readout(DVec3::Y * 2.0)
+ .unwrap()
+ .contains(&crate::entities::common::format_area(4.0)));
+ assert_eq!(command.total_area, 6.0);
+ }
+ #[test]
+ fn object_readout_refreshes_with_selection_and_clears_when_empty() {
+ let mut command = AreaCommand::new();
+ command.on_text_input("OBJECTS");
+ let circle = |radius| SelectionEntity {
+ handle: Handle::new(1),
+ entity: EntityType::Circle(codec::Circle::from_coords(0.0, 0.0, 0.0, radius)),
+ surface_area: None,
+ };
+ command.inject_selection_entities(vec![circle(2.0)]);
+ let first = command.measurement_readout(DVec3::ZERO).unwrap();
+ command.inject_selection_entities(vec![circle(3.0)]);
+ let preview = command.measurement_readout(DVec3::X).unwrap();
+ assert_ne!(first, preview);
+ let CmdResult::Measurement(result) = command.on_enter() else {
+ panic!("result")
+ };
+ assert_eq!(preview, result);
+ command.on_text_input("OBJECTS");
+ command.inject_selection_entities(Vec::new());
+ assert!(command.measurement_readout(DVec3::ZERO).is_none());
+ }
+}
diff --git a/src/modules/draw/inquiry/dist.rs b/src/modules/draw/inquiry/dist.rs
index dd8386cc3..ab4c3c6d7 100644
--- a/src/modules/draw/inquiry/dist.rs
+++ b/src/modules/draw/inquiry/dist.rs
@@ -41,6 +41,17 @@ impl CadCommand for DistCommand {
}
}
+ fn has_measurement_readout(&self) -> bool {
+ true
+ }
+
+ fn measurement_readout(&self, cursor: DVec3) -> Option {
+ let distance = self.plane.vector_to_local(cursor - self.first?).length();
+ distance
+ .is_finite()
+ .then(|| crate::tr!("measurement", "distance", value = format_length(distance)))
+ }
+
fn on_point(&mut self, pt: DVec3) -> CmdResult {
if let Some(p1) = self.first {
let delta = self.plane.vector_to_local(pt - p1);
@@ -252,3 +263,31 @@ mod tests {
}
}
}
+
+#[cfg(test)]
+mod measurement_tests {
+ use super::*;
+ #[test]
+ fn live_distance_keeps_f64_and_unit_settings() {
+ let previous = unit_context();
+ let mut ctx = previous;
+ ctx.lunits = 2;
+ ctx.luprec = 4;
+ crate::entities::common::set_unit_context(ctx);
+ let mut command = DistCommand::new();
+ let first = DVec3::new(500000.001, 600000.002, 0.0);
+ assert!(command.measurement_readout(first).is_none());
+ command.on_point(first);
+ assert!(command
+ .measurement_readout(first + DVec3::X * 0.001)
+ .unwrap()
+ .contains("0.0010"));
+ ctx.lunits = 4;
+ crate::entities::common::set_unit_context(ctx);
+ assert!(command
+ .measurement_readout(first + DVec3::X * 18.0)
+ .unwrap()
+ .contains("1'-6\""));
+ crate::entities::common::set_unit_context(previous);
+ }
+}
diff --git a/src/modules/draw/inquiry/measuregeom.rs b/src/modules/draw/inquiry/measuregeom.rs
index 6e792e2a8..890405056 100644
--- a/src/modules/draw/inquiry/measuregeom.rs
+++ b/src/modules/draw/inquiry/measuregeom.rs
@@ -44,11 +44,21 @@ enum Mode {
Area,
Angle,
Radius,
+ AngleLines,
}
// ── Command implementation ──────────────────────────────────────────────────
+pub const RADIUS_ICON: IconKind = IconKind::Svg(include_bytes!(
+ "../../../../assets/icons/measure-radius.svg"
+));
+pub const ANGLE_ICON: IconKind =
+ IconKind::Svg(include_bytes!("../../../../assets/icons/measure-angle.svg"));
+
pub struct MeasureGeomCommand {
+ name: &'static str,
+ hovered: Option<(Handle, EntityType)>,
+ first_line: Option<(Handle, DVec3)>,
mode: Mode,
/// Picked points for the active point-pick mode.
points: Vec,
@@ -60,6 +70,9 @@ pub struct MeasureGeomCommand {
impl MeasureGeomCommand {
pub fn new() -> Self {
Self {
+ name: "MEASUREGEOM",
+ hovered: None,
+ first_line: None,
mode: Mode::Choose,
points: vec![],
picked: None,
@@ -67,6 +80,72 @@ impl MeasureGeomCommand {
}
}
+ pub fn radius() -> Self {
+ Self {
+ name: "MEASURERADIUS",
+ mode: Mode::Radius,
+ ..Self::new()
+ }
+ }
+
+ pub fn angle() -> Self {
+ Self {
+ name: "MEASUREANGLE",
+ mode: Mode::Angle,
+ ..Self::new()
+ }
+ }
+
+ fn radius_msg(radius: f64) -> String {
+ t!(
+ "Radius = %{radius}, Diameter = %{diameter}",
+ radius = crate::entities::common::format_length(radius),
+ diameter = crate::entities::common::format_length(radius * 2.0)
+ )
+ .into_owned()
+ }
+
+ // Normalize only after rescaling, retaining f64 precision without an
+ // arbitrary drawing-unit tolerance or overflowing on long vectors.
+ fn direction(vector: DVec3) -> Option {
+ if !vector.is_finite() {
+ return None;
+ }
+ let scale = vector.abs().max_element();
+ if scale == 0.0 {
+ return None;
+ }
+ let vector = vector / scale;
+ Some(vector / vector.length())
+ }
+
+ fn angle_degrees(a: DVec3, b: DVec3) -> Option {
+ let a = Self::direction(a)?;
+ let b = Self::direction(b)?;
+ Some(a.cross(b).length().atan2(a.dot(b)).to_degrees())
+ }
+
+ fn line_direction(entity: &EntityType) -> Option {
+ let EntityType::Line(line) = entity else {
+ return None;
+ };
+ Self::direction(
+ DVec3::new(line.end.x, line.end.y, line.end.z)
+ - DVec3::new(line.start.x, line.start.y, line.start.z),
+ )
+ }
+
+ // Unoriented lines: the smaller angle, independent of endpoint order.
+ fn line_angle(a: DVec3, b: DVec3) -> Option {
+ Self::angle_degrees(a, b).map(|angle| angle.min(180.0 - angle))
+ }
+
+ fn angle_value_msg(angle: f64) -> String {
+ let aprec = crate::entities::common::unit_context().auprec.max(0) as usize;
+ let angle = crate::entities::common::without_negative_zero(format!("{angle:.aprec$}"));
+ t!("Angle = %{angle}°", angle = angle).into_owned()
+ }
+
/// Build a cyan preview wire connecting the picked points and the cursor.
fn preview_wire(name: &str, pts: Vec<[f32; 3]>) -> WireModel {
WireModel {
@@ -110,11 +189,12 @@ impl MeasureGeomCommand {
/// Extract (radius) from a Circle or Arc; `None` for anything else.
fn radius_of(entity: &EntityType) -> Option {
- match entity {
- EntityType::Circle(c) => Some(c.radius),
- EntityType::Arc(a) => Some(a.radius),
- _ => None,
- }
+ let radius = match entity {
+ EntityType::Circle(circle) => circle.radius,
+ EntityType::Arc(arc) => arc.radius,
+ _ => return None,
+ };
+ (radius.is_finite() && radius > 0.0 && (radius * 2.0).is_finite()).then_some(radius)
}
/// DISTANCE readout for the two collected points.
@@ -172,27 +252,14 @@ impl MeasureGeomCommand {
.into_owned()
}
- /// ANGLE readout: angle at `vertex` between the rays to `a` and `b`.
- fn angle_msg(vertex: DVec3, a: DVec3, b: DVec3) -> String {
- let va = a - vertex;
- let vb = b - vertex;
- let la = va.length();
- let lb = vb.length();
- let aprec = crate::entities::common::unit_context().auprec.max(0) as usize;
- if la == 0.0 || lb == 0.0 {
- let zero = crate::entities::common::without_negative_zero(format!("{:.aprec$}", 0.0));
- return t!("Angle = %{angle}° (degenerate rays)", angle = zero).into_owned();
- }
- let cos = (va.dot(vb) / (la * lb)).clamp(-1.0, 1.0);
- let angle = cos.acos().to_degrees();
- let angle_s = crate::entities::common::without_negative_zero(format!("{angle:.aprec$}"));
- t!("Angle = %{angle}°", angle = angle_s).into_owned()
+ fn angle_msg(vertex: DVec3, a: DVec3, b: DVec3) -> Option {
+ Self::angle_degrees(a - vertex, b - vertex).map(Self::angle_value_msg)
}
}
impl CadCommand for MeasureGeomCommand {
fn name(&self) -> &'static str {
- "MEASUREGEOM"
+ self.name
}
fn prompt(&self) -> String {
@@ -220,12 +287,20 @@ impl CadCommand for MeasureGeomCommand {
}
}
Mode::Angle => match self.points.len() {
- 0 => t!("MEASUREGEOM Specify vertex point:").into_owned(),
+ 0 => return crate::tr!("measurement", "angle-start", command = self.name),
1 => t!("MEASUREGEOM Specify first ray point:").into_owned(),
_ => t!("MEASUREGEOM Specify second ray point:").into_owned(),
},
Mode::Radius => t!("MEASUREGEOM Select arc or circle:").into_owned(),
+ Mode::AngleLines => {
+ return if self.first_line.is_none() {
+ crate::tr!("measurement", "first-line", command = self.name)
+ } else {
+ crate::tr!("measurement", "second-line", command = self.name)
+ }
+ }
}
+ .replace("MEASUREGEOM", self.name)
}
fn set_working_plane(&mut self, plane: WorkingPlane) {
@@ -233,14 +308,64 @@ impl CadCommand for MeasureGeomCommand {
}
fn wants_text_input(&self) -> bool {
- // Only the opening mode-keyword step reads a typed token.
- self.mode == Mode::Choose
+ self.mode == Mode::Choose || self.point_step_accepts_keywords()
+ }
+
+ fn point_step_accepts_keywords(&self) -> bool {
+ (self.mode == Mode::Angle && self.points.is_empty())
+ || (self.mode == Mode::AngleLines && self.first_line.is_none())
+ }
+
+ fn has_measurement_readout(&self) -> bool {
+ true
+ }
+
+ fn measurement_readout(&self, cursor: DVec3) -> Option {
+ match self.mode {
+ Mode::Distance if !self.points.is_empty() && cursor.is_finite() => Some(crate::tr!(
+ "measurement",
+ "distance",
+ value = crate::entities::common::format_length((cursor - self.points[0]).length())
+ )),
+ Mode::Area if self.points.len() >= 2 && cursor.is_finite() => {
+ let mut points = self.points.clone();
+ points.push(cursor);
+ Some(Self::area_msg(self.plane, &points))
+ }
+ Mode::Angle if self.points.len() >= 2 => {
+ Self::angle_msg(self.points[0], self.points[1], cursor)
+ }
+ Mode::Radius => self
+ .hovered
+ .as_ref()
+ .and_then(|(_, e)| Self::radius_of(e))
+ .map(Self::radius_msg),
+ Mode::AngleLines => {
+ let (first_handle, first) = self.first_line?;
+ let (handle, entity) = self.hovered.as_ref()?;
+ if first_handle == *handle {
+ return None;
+ }
+ Self::line_angle(first, Self::line_direction(entity)?).map(Self::angle_value_msg)
+ }
+ _ => None,
+ }
}
fn options(&self) -> Vec {
use crate::command::CmdOption;
if self.mode != Mode::Choose {
- return Vec::new();
+ return match self.mode {
+ Mode::Angle if self.points.is_empty() => vec![CmdOption {
+ label: crate::tr!("measurement", "lines"),
+ keyword: "LINES".into(),
+ }],
+ Mode::AngleLines if self.first_line.is_none() => vec![CmdOption {
+ label: crate::tr!("measurement", "points"),
+ keyword: "POINTS".into(),
+ }],
+ _ => Vec::new(),
+ };
}
vec![
CmdOption::new("Distance", "D"),
@@ -251,6 +376,22 @@ impl CadCommand for MeasureGeomCommand {
}
fn on_text_input(&mut self, text: &str) -> Option {
+ let input = text.trim().to_ascii_uppercase();
+ if self.mode == Mode::Angle
+ && self.points.is_empty()
+ && matches!(input.as_str(), "L" | "LINES")
+ {
+ self.mode = Mode::AngleLines;
+ return Some(CmdResult::NeedPoint);
+ }
+ if self.mode == Mode::AngleLines
+ && self.first_line.is_none()
+ && matches!(input.as_str(), "P" | "POINTS")
+ {
+ self.mode = Mode::Angle;
+ self.hovered = None;
+ return Some(CmdResult::NeedPoint);
+ }
if self.mode != Mode::Choose {
return None;
}
@@ -266,7 +407,7 @@ impl CadCommand for MeasureGeomCommand {
}
fn needs_entity_pick(&self) -> bool {
- self.mode == Mode::Radius
+ matches!(self.mode, Mode::Radius | Mode::AngleLines)
}
fn inject_before_entity_pick(&self) -> bool {
@@ -277,30 +418,65 @@ impl CadCommand for MeasureGeomCommand {
self.picked = Some(entity);
}
- fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult {
+ fn wants_hover_entity(&self, handle: Handle) -> bool {
+ self.needs_entity_pick()
+ && !handle.is_null()
+ && self.hovered.as_ref().map(|(h, _)| *h) != Some(handle)
+ }
+
+ fn inject_hover_entity(&mut self, handle: Handle, entity: EntityType) {
+ self.hovered = Some((handle, entity));
+ }
+
+ fn on_hover_entity(&mut self, handle: Handle, _point: DVec3) -> Vec {
+ if handle.is_null() || self.hovered.as_ref().map(|(h, _)| *h) != Some(handle) {
+ self.hovered = None;
+ }
+ Vec::new()
+ }
+
+ fn on_entity_pick(&mut self, handle: Handle, _point: DVec3) -> CmdResult {
+ // Never reuse an entity injected for a previous pick.
+ let picked = self.picked.take();
if handle.is_null() {
return CmdResult::NeedPoint;
}
- match self.picked.as_ref().and_then(Self::radius_of) {
- Some(radius) => {
- let diameter = radius * 2.0;
- let radius_s = crate::entities::common::format_length(radius);
- let diameter_s = crate::entities::common::format_length(diameter);
- CmdResult::Measurement(
- t!(
- "Radius = %{radius}, Diameter = %{diameter}",
- radius = radius_s,
- diameter = diameter_s
- )
- .into_owned(),
- )
+ match self.mode {
+ Mode::Radius => match picked.as_ref().and_then(Self::radius_of) {
+ Some(radius) => CmdResult::Measurement(Self::radius_msg(radius)),
+ None => CmdResult::ReportError(crate::tr!("measurement", "invalid-radius")),
+ },
+ Mode::AngleLines => {
+ let Some(direction) = picked.as_ref().and_then(Self::line_direction) else {
+ return CmdResult::ReportError(crate::tr!("measurement", "invalid-line"));
+ };
+ if let Some((first_handle, first)) = self.first_line {
+ if first_handle == handle {
+ return CmdResult::ReportError(crate::tr!("measurement", "different-line"));
+ }
+ match Self::line_angle(first, direction) {
+ Some(angle) => CmdResult::Measurement(Self::angle_value_msg(angle)),
+ None => CmdResult::ReportError(crate::tr!("measurement", "invalid-line")),
+ }
+ } else {
+ self.first_line = Some((handle, direction));
+ CmdResult::NeedPoint
+ }
}
- // Picked something that is not a circle or arc — keep prompting.
- None => CmdResult::NeedPoint,
+ _ => CmdResult::NeedPoint,
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
+ if matches!(self.mode, Mode::Distance | Mode::Angle | Mode::Area) && !pt.is_finite() {
+ return CmdResult::ReportError(crate::tr!("measurement", "invalid-point"));
+ }
+ if self.mode == Mode::Angle
+ && !self.points.is_empty()
+ && Self::direction(pt - self.points[0]).is_none()
+ {
+ return CmdResult::ReportError(crate::tr!("measurement", "degenerate-ray"));
+ }
match self.mode {
Mode::Distance => {
self.points.push(pt);
@@ -317,11 +493,13 @@ impl CadCommand for MeasureGeomCommand {
Mode::Angle => {
self.points.push(pt);
if self.points.len() == 3 {
- CmdResult::Measurement(Self::angle_msg(
- self.points[0],
- self.points[1],
- self.points[2],
- ))
+ match Self::angle_msg(self.points[0], self.points[1], self.points[2]) {
+ Some(text) => CmdResult::Measurement(text),
+ None => {
+ self.points.pop();
+ CmdResult::ReportError(crate::tr!("measurement", "degenerate-ray"))
+ }
+ }
} else {
CmdResult::NeedPoint
}
@@ -360,6 +538,9 @@ impl CadCommand for MeasureGeomCommand {
return None;
}
let mut pts: Vec<[f32; 3]> = self.points.iter().map(|p| f(*p)).collect();
+ if self.mode == Mode::Angle && self.points.len() >= 2 {
+ pts = vec![f(self.points[1]), f(self.points[0])];
+ }
pts.push(f(pt));
Some(Self::preview_wire("measuregeom_preview", pts))
}
@@ -379,5 +560,241 @@ impl CadCommand for MeasureGeomCommand {
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration {
- names: &["MEASUREGEOM", "MEA"]
+ names: &["MEASUREGEOM", "MEA", "MEASURERADIUS", "MEASUREANGLE"]
}); // MeasureGeomCommand
+
+#[cfg(test)]
+mod measurement_tests {
+ use super::*;
+ use crate::entities::common::{set_unit_context, unit_context};
+ use codec::{
+ entities::{Arc, Circle, Line},
+ types::Vector3,
+ };
+
+ fn units() {
+ let mut context = unit_context();
+ context.lunits = 2;
+ context.luprec = 4;
+ context.auprec = 2;
+ set_unit_context(context);
+ }
+ fn result(result: CmdResult) -> String {
+ let CmdResult::Measurement(text) = result else {
+ panic!("expected measurement");
+ };
+ text
+ }
+ fn pick(command: &mut MeasureGeomCommand, entity: EntityType, id: u64) -> CmdResult {
+ command.inject_picked_entity(entity);
+ command.on_entity_pick(Handle::new(id), DVec3::ZERO)
+ }
+ fn line(end: Vector3) -> EntityType {
+ EntityType::Line(Line::from_points(Vector3::ZERO, end))
+ }
+
+ #[test]
+ fn radius_circle_and_arc_respect_length_precision() {
+ units();
+ for entity in [
+ EntityType::Circle(Circle::from_coords(500000.0, 100000.0, 0.0, 2.34567)),
+ EntityType::Arc(Arc::from_center_radius_angles(
+ Vector3::ZERO,
+ 2.34567,
+ 0.0,
+ std::f64::consts::FRAC_PI_2,
+ )),
+ ] {
+ let text = result(pick(&mut MeasureGeomCommand::radius(), entity, 1));
+ assert!(text.contains("2.3457"), "{text}");
+ assert!(text.contains("4.6913"), "{text}");
+ }
+ }
+
+ #[test]
+ fn radius_rejects_invalid_entities_and_stale_injection() {
+ let mut command = MeasureGeomCommand::radius();
+ assert!(matches!(
+ pick(&mut command, line(Vector3::UNIT_X), 1),
+ CmdResult::ReportError(_)
+ ));
+ for radius in [0.0, -1.0, f64::NAN, f64::INFINITY] {
+ assert!(matches!(
+ pick(
+ &mut command,
+ EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, radius)),
+ 1
+ ),
+ CmdResult::ReportError(_)
+ ));
+ }
+ command.inject_picked_entity(EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 2.0)));
+ assert!(matches!(
+ command.on_entity_pick(Handle::NULL, DVec3::ZERO),
+ CmdResult::NeedPoint
+ ));
+ assert!(matches!(
+ command.on_entity_pick(Handle::new(2), DVec3::ZERO),
+ CmdResult::ReportError(_)
+ ));
+ }
+
+ #[test]
+ fn known_angles_extreme_scales_and_degenerate_rays() {
+ for degrees in [0.0_f64, 30.0, 45.0, 90.0, 135.0, 180.0] {
+ let radians = degrees.to_radians();
+ let ray = DVec3::new(radians.cos(), radians.sin(), 0.0);
+ for scale in [1e-200, 1.0, 1e200] {
+ let actual =
+ MeasureGeomCommand::angle_degrees(DVec3::X * scale, ray * scale).unwrap();
+ assert!((actual - degrees).abs() < 1e-10);
+ }
+ }
+ assert_eq!(
+ MeasureGeomCommand::angle_degrees(DVec3::X, DVec3::Z),
+ Some(90.0)
+ );
+ assert_eq!(
+ MeasureGeomCommand::angle_degrees(DVec3::ZERO, DVec3::X),
+ None
+ );
+ assert_eq!(
+ MeasureGeomCommand::angle_degrees(DVec3::NAN, DVec3::X),
+ None
+ );
+ }
+
+ #[test]
+ fn invalid_angle_points_do_not_advance_and_survey_precision_is_retained() {
+ units();
+ let mut command = MeasureGeomCommand::angle();
+ let vertex = DVec3::new(500000.001, 600000.002, 100.0);
+ assert!(matches!(
+ command.on_point(DVec3::NAN),
+ CmdResult::ReportError(_)
+ ));
+ assert!(command.points.is_empty());
+ command.on_point(vertex);
+ assert!(matches!(
+ command.on_point(vertex),
+ CmdResult::ReportError(_)
+ ));
+ assert_eq!(command.points.len(), 1);
+ command.on_point(vertex + DVec3::X * 0.001);
+ assert!(matches!(
+ command.on_point(vertex),
+ CmdResult::ReportError(_)
+ ));
+ assert_eq!(command.points.len(), 2);
+ assert!(result(command.on_point(vertex + DVec3::Y * 0.001)).contains("90.00°"));
+ }
+
+ #[test]
+ fn angle_preview_two_rays_and_live_value() {
+ units();
+ let mut command = MeasureGeomCommand::angle();
+ command.on_point(DVec3::ZERO);
+ assert!(command.measurement_readout(DVec3::Y).is_none());
+ command.on_point(DVec3::X);
+ assert!(command
+ .measurement_readout(DVec3::Y)
+ .unwrap()
+ .contains("90.00°"));
+ assert!(command
+ .measurement_readout(DVec3::X + DVec3::Y)
+ .unwrap()
+ .contains("45.00°"));
+ assert_eq!(
+ command.on_mouse_move(DVec3::Y).unwrap().points,
+ vec![[1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
+ );
+ }
+
+ #[test]
+ fn two_lines_smaller_angle_and_invalid_selections() {
+ units();
+ let mut command = MeasureGeomCommand::angle();
+ command.on_text_input("LINES");
+ assert!(command.needs_entity_pick());
+ assert!(matches!(
+ pick(&mut command, line(Vector3::ZERO), 1),
+ CmdResult::ReportError(_)
+ ));
+ assert!(matches!(
+ pick(&mut command, line(Vector3::UNIT_X), 1),
+ CmdResult::NeedPoint
+ ));
+ assert!(matches!(
+ pick(&mut command, line(Vector3::UNIT_X), 1),
+ CmdResult::ReportError(_)
+ ));
+ assert!(matches!(
+ pick(
+ &mut command,
+ EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 1.0)),
+ 2
+ ),
+ CmdResult::ReportError(_)
+ ));
+ assert!(
+ result(pick(&mut command, line(Vector3::new(-1.0, -1.0, 0.0)), 2)).contains("45.00°")
+ );
+ assert_eq!(
+ MeasureGeomCommand::line_angle(DVec3::X, -DVec3::X),
+ Some(0.0)
+ );
+ }
+
+ #[test]
+ fn hover_clears_on_miss_without_completing_measurement() {
+ units();
+ let mut command = MeasureGeomCommand::radius();
+ let handle = Handle::new(5);
+ assert!(command.wants_hover_entity(handle));
+ command.inject_hover_entity(
+ handle,
+ EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 2.0)),
+ );
+ command.on_hover_entity(handle, DVec3::ZERO);
+ assert!(command
+ .measurement_readout(DVec3::ZERO)
+ .unwrap()
+ .contains("2.0000"));
+ assert!(!command.wants_hover_entity(handle));
+ assert!(command.picked.is_none());
+ command.on_hover_entity(Handle::NULL, DVec3::ZERO);
+ assert!(command.measurement_readout(DVec3::ZERO).is_none());
+ command.inject_hover_entity(handle, line(Vector3::UNIT_X));
+ assert!(command.measurement_readout(DVec3::ZERO).is_none());
+ }
+
+ #[test]
+ fn measuregeom_all_four_modes_remain_available() {
+ units();
+ assert_eq!(MeasureGeomCommand::new().options().len(), 4);
+ let mut command = MeasureGeomCommand::new();
+ command.on_text_input("D");
+ command.on_point(DVec3::ZERO);
+ assert!(result(command.on_point(DVec3::new(3.0, 4.0, 0.0))).contains("5.0000"));
+ let mut command = MeasureGeomCommand::new();
+ command.on_text_input("AR");
+ for p in [DVec3::ZERO, DVec3::X * 4.0, DVec3::Y * 3.0] {
+ command.on_point(p);
+ }
+ let text = result(command.on_enter());
+ assert!(text.contains("6.0000") && text.contains("12.0000"));
+ let mut command = MeasureGeomCommand::new();
+ command.on_text_input("A");
+ command.on_point(DVec3::ZERO);
+ command.on_point(DVec3::X);
+ assert!(result(command.on_point(DVec3::Y)).contains("90.00°"));
+ let mut command = MeasureGeomCommand::new();
+ command.on_text_input("R");
+ assert!(result(pick(
+ &mut command,
+ EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 3.0)),
+ 1
+ ))
+ .contains("3.0000"));
+ }
+}
diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs
index 7fb2cc5a7..7ee8d37a8 100644
--- a/src/modules/draw/mod.rs
+++ b/src/modules/draw/mod.rs
@@ -35,7 +35,7 @@ impl CadModule for DrawModule {
use clipboard::{copy_clip, cut, paste};
use draw::{arc, circle, ellipse, hatch, line, polyline, shapes};
use groups::{group, ungroup};
- use inquiry::{area, dist};
+ use inquiry::{area, dist, measuregeom};
use layers::{
layfrz, layiso, laylck, layoff, layon, laythw, layulk, layuniso, make_current,
match_layer, panel,
@@ -244,6 +244,8 @@ impl CadModule for DrawModule {
icon: dist::ICON,
items: vec![
("DIST", "Distance", dist::ICON),
+ ("MEASURERADIUS", "Radius", measuregeom::RADIUS_ICON),
+ ("MEASUREANGLE", "Angle", measuregeom::ANGLE_ICON),
("AREA", "Area", area::ICON),
],
default: "DIST",