feat(measure): add radius and angle tools with dynamic measurement overlay

(cherry picked from commit aac44906a3)
This commit is contained in:
turbuczn authored and Hakan Seven committed 2026-10-15 00:22:41 +03:00
1 parent 843cbcd607
commit 3c55e6d7e1
42 files changed
+1624 -53

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.5 19H3.5L15.5 5" stroke="#B4B6B9" stroke-width="1.5"/>
<path d="M11.5 19a8 8 0 0 0-2.794-6.074" stroke="#6DB7ED" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 278 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="8.5" stroke="#B4B6B9" stroke-width="1.5"/>
<path d="M12 12 18.01 5.99m-3.6 1.1 3.6-1.1-1.1 3.6" stroke="#6DB7ED" stroke-width="1.5"/>
<circle cx="12" cy="12" r="1" fill="#6DB7ED"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

+173
View File
@@ -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` |
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+17
View File
@@ -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 }
+4
View File
@@ -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();
+6 -2
View File
@@ -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));
}
+1
View File
@@ -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;
+11 -1
View File
@@ -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"
+2
View File
@@ -157,6 +157,7 @@ pub(super) struct DocumentTab {
pub(super) properties: PropertiesPanel,
pub(super) layers: LayerPanel,
pub(super) active_cmd: Option<Box<dyn CadCommand>>,
pub(super) measurement: super::measurement::MeasurementDisplay,
/// Remaining command tokens queued behind a PAUSE (or `\`) awaiting user interaction.
pub(super) pending_pause_tokens: Option<Vec<String>>,
/// 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,
+288
View File
@@ -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<MeasurementReadout>,
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<MeasurementReadout> {
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::<Vec<_>>(),
["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"));
}
}
+1
View File
@@ -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;
+1
View File
@@ -1249,6 +1249,7 @@ impl OpenCADStudio {
}
pub(super) fn on_command_escape(&mut self) -> Task<Message> {
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);
+1
View File
@@ -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).
+4
View File
@@ -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<Message> {
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<Message> {
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<Message> {
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();
+9
View File
@@ -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 {
+119 -1
View File
@@ -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<Message, Theme, iced::Renderer> for ClampedPin<'_> {
@@ -1008,7 +1053,9 @@ impl Widget<Message, Theme, iced::Renderer> 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<Message, Theme, iced::Renderer> 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<Message, Theme, iced::Renderer> 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<Message, Theme, iced::Renderer> 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);
}
}
}
+11
View File
@@ -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<String> {
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<WireModel> {
+103
View File
@@ -29,6 +29,9 @@ pub struct AreaCommand {
points: Vec<DVec3>,
objects_gathering: bool,
selected_entities: Vec<SelectionEntity>,
// Recompute exact curve measurements only when the selection changes,
// not on every UI frame while showing the live readout.
selection_preview: Option<AreaMeasurement>,
preview_regions: Vec<AreaPreviewRegion>,
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<SelectionEntity>) {
self.selected_entities = entities;
self.selection_preview = self.selection_measurement().map(|(value, _)| value);
}
fn on_selection_complete(&mut self, _handles: Vec<Handle>) -> 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<String> {
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<WireModel> {
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());
}
}
+39
View File
@@ -41,6 +41,17 @@ impl CadCommand for DistCommand {
}
}
fn has_measurement_readout(&self) -> bool {
true
}
fn measurement_readout(&self, cursor: DVec3) -> Option<String> {
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);
}
}
+465 -48
View File
@@ -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<DVec3>,
@@ -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<DVec3> {
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<f64> {
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<DVec3> {
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<f64> {
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<f64> {
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<String> {
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<String> {
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<crate::command::CmdOption> {
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<CmdResult> {
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<WireModel> {
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"));
}
}
+3 -1
View File
@@ -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",