Merge upstream/main into fix/704-active-intersection-snap

This commit is contained in:
gianlucafiore 2026-08-08 22:13:54 -03:00
commit 17b4eafa3b
71 changed files with 4886 additions and 2861 deletions

93
Cargo.lock generated
View file

@ -85,7 +85,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "acadifc"
version = "0.5.0"
source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=0a61cf9#0a61cf98b6736e070e02be3bc7a1818ff7522095"
source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=c65d396#c65d396abc3defcf96b1d71a3f812c8ad993e77c"
dependencies = [
"acadrust",
"base64 0.22.1",
@ -99,7 +99,7 @@ dependencies = [
[[package]]
name = "acadrust"
version = "0.4.1"
source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=f226110#f226110a5b272838f9eeca4e3330ace827817db4"
source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=d645c7f#d645c7f526c199c02fcab7272d6b6f2d1c6b91ae"
dependencies = [
"ahash 0.8.12",
"anyhow",
@ -110,7 +110,6 @@ dependencies = [
"indexmap",
"itoa",
"memmap2",
"nalgebra",
"nom 7.1.3",
"once_cell",
"rayon",
@ -353,15 +352,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
@ -945,7 +935,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadkernel"
version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git#e76334b7697a3c39292c8a6494d9b837bba78a6e"
source = "git+https://github.com/HakanSeven12/cadkernel.git#5499242f1160018e066f78d4e462c9532e665a96"
dependencies = [
"cavalier_contours",
]
@ -1031,7 +1021,7 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317"
dependencies = [
"approx 0.4.0",
"approx",
"num-traits",
"serde",
]
@ -3215,16 +3205,6 @@ dependencies = [
"num-complex",
]
[[package]]
name = "matrixmultiply"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@ -3402,33 +3382,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "nalgebra"
version = "0.32.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4"
dependencies = [
"approx 0.5.1",
"matrixmultiply",
"nalgebra-macros",
"num-complex",
"num-rational",
"num-traits",
"simba",
"typenum",
]
[[package]]
name = "nalgebra-macros"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "254a5372af8fc138e36684761d3c0cdb758a4410e938babcff1c860ce14ddbfc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "ndk"
version = "0.9.0"
@ -4720,12 +4673,6 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "rayon"
version = "1.12.0"
@ -5173,15 +5120,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "safe_arch"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323"
dependencies = [
"bytemuck",
]
[[package]]
name = "same-file"
version = "1.0.6"
@ -5367,19 +5305,6 @@ dependencies = [
"libc",
]
[[package]]
name = "simba"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae"
dependencies = [
"approx 0.5.1",
"num-complex",
"num-traits",
"paste",
"wide",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
@ -6837,16 +6762,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wide"
version = "0.7.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03"
dependencies = [
"bytemuck",
"safe_arch",
]
[[package]]
name = "widestring"
version = "1.2.1"

View file

@ -38,7 +38,7 @@ env_logger = "0.11"
# The CAD stack is reached through acadifc, which re-exports the codec and
# the geometry kernel. Aliased to `acadrust` so existing `use acadrust::…`
# paths keep resolving.
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "0a61cf9", features = ["serde", "offset"] }
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "c65d396", features = ["serde", "offset"] }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }

View file

@ -120,6 +120,7 @@ QS, *QSELECT
FI, *FILTER
; ── View / display ────────────────────────────────────────────────────────
Z, *ZOOM
P, *PAN
U, *UNDO
PR, *PROPERTIES

View file

@ -9,7 +9,7 @@ license = "GPL-3.0-only"
# Pulled in only by the `host` feature, which adds the `acadrust`-typed
# `HostApi` runtime surface. The default crate stays dependency-free so engine
# crates and external tooling can depend on the manifest/ribbon contract cheaply.
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "0a61cf9", optional = true, features = ["serde"] }
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "c65d396", optional = true, features = ["serde"] }
# Runtime IPC and serialization (host feature only).
interprocess = { version = "2", optional = true }

View file

@ -8,7 +8,7 @@ publish = false
crate-type = ["cdylib"]
[dependencies]
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "0a61cf9", features = ["serde"] }
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "c65d396", features = ["serde"] }
bincode = "1.3"
console_error_panic_hook = "0.1"
getrandom = { version = "0.3", features = ["wasm_js"] }

View file

@ -0,0 +1,182 @@
language-system = لغة النظام
language-english = English
language-turkish = Türkçe
language-dutch = Nederlands
language-french = Français
language-german = Deutsch
language-hindi = हिन्दी
language-russian = Русский
language-chinese-simplified = 简体中文
language-spanish = Español
language-portuguese = Português (Brasil)
language-arabic = العربية
language-japanese = 日本語
action-close = إغلاق
action-options = الخيارات
action-plugins = الإضافات
ribbon-tab-draw = رسم
ribbon-tab-annotate = تعليقات توضيحية
ribbon-tab-insert = إدراج
ribbon-tab-model = نموذج
ribbon-tab-layout = تخطيط
ribbon-tab-manage = إدارة
ribbon-tab-view = عرض
options-language-section = اللغة
options-language-label = لغة الواجهة:
options-open-save-section = الفتح والحفظ
options-default-save-format-label = تنسيق الحفظ الافتراضي:
options-default-save-format-help = يُستخدم عند الحفظ الأول لرسم جديد. تحتفظ الرسومات الموجودة بنوع الملف وإصداره.
options-theme-section = السمة
options-theme-label = سمة التطبيق:
options-theme-help = يؤدي تغيير لون أساسي إلى الانتقال إلى السمة المخصصة. تُنشئ السمة جميع درجات المكونات من هذه الألوان الستة.
options-color-background = الخلفية
options-color-text = النص
options-color-primary = أساسي
options-color-success = نجاح
options-color-warning = تحذير
options-color-danger = خطر
command-line-ready = ‏Open CAD Studio جاهز.
command-line-hint = اكتب أمرًا أو استخدم الشريط. افتح OBJ من علامة التبويب إدراج.
command-line-label = الأمر:
command-line-literal-spaces = المسافات الحرفية: يبقى مفتاح المسافة في السطر بدلًا من تنفيذ الأمر. يظل مفعّلًا حتى إيقافه.
area-option-objects = الكائنات
area-option-back = رجوع
area-option-add = إضافة مساحة
area-option-subtract = طرح مساحة
area-prompt-first = AREA حدد نقطة الزاوية الأولى أو [الكائنات/إضافة مساحة/طرح مساحة] <الكائنات>:
area-prompt-add = AREA إضافة حدد نقطة الزاوية الأولى أو [الكائنات/طرح مساحة] <الكائنات>:
area-prompt-subtract = AREA طرح حدد نقطة الزاوية الأولى أو [الكائنات/إضافة مساحة] <الكائنات>:
area-prompt-next = AREA حدد النقطة التالية (تم اختيار { $count }، Enter للحساب):
area-prompt-objects = AREA حدد الكائنات (تم تحديد { $count }، Enter للحساب):
area-objects-not-measurable = AREA: لا يحتوي أي من الكائنات المحددة على مساحة قابلة للقياس.
area-result = المساحة = { $area }، المحيط = { $perimeter }
area-result-area-only = المساحة = { $area }
area-running-result =
المساحة = { $area }، المحيط = { $perimeter }
المساحة الإجمالية = { $total_area }، المحيط الإجمالي = { $total_perimeter }
area-running-result-area-only =
المساحة = { $area }
المساحة الإجمالية = { $total_area }
start-new-drawing = رسم جديد
start-open-file = فتح ملف…
start-donate = تبرع
start-send-feedback = إرسال ملاحظات
start-sponsors = الرعاة
start-tutorials = الدروس التعليمية
start-loading-videos = جارٍ تحميل مقاطع الفيديو…
start-videos-online = تُحمّل مقاطع الفيديو من الإنترنت.
start-open-playlist = فتح قائمة التشغيل على YouTube
start-discussions = المناقشات
start-pinned = المثبتة
start-loading-discussions = جارٍ تحميل المناقشات…
start-discussions-online = تُحمّل المناقشات من GitHub.
start-open-discussions = فتح المناقشات على GitHub
start-supporters = الداعمون
start-support-on-patreon = الدعم على Patreon
start-recent-files = الملفات الأخيرة
start-videos = مقاطع الفيديو
start-welcome = مرحبًا
start-recent-documents = المستندات الأخيرة
start-no-recent-files = ستظهر الملفات التي تفتحها هنا.
start-browser-storage = تخزين المتصفح
start-keep-recent-files = الاحتفاظ بالملفات الأخيرة
modal-about = حول
modal-keyboard-shortcuts = اختصارات لوحة المفاتيح
modal-command-aliases = الأسماء البديلة للأوامر
modal-find-replace = بحث واستبدال
modal-plugin-manager = مدير الإضافات
modal-update-available = يتوفر تحديث
modal-layer-manager = مدير الطبقات
modal-layer-state-manager = مدير حالات الطبقات
modal-edit-layer-state = تحرير حالة الطبقة
modal-plot = طباعة
modal-layout-manager = مدير التخطيطات
modal-scale-manager = مدير المقاييس
modal-annotation-object-scale = مقياس كائن التعليقات التوضيحية
modal-plot-style-editor = محرر أنماط الطباعة
modal-text-style-manager = مدير أنماط النص
modal-multiline-style-manager = مدير أنماط الخطوط المتعددة
modal-table-style-manager = مدير أنماط الجداول
modal-multileader-style-manager = مدير أنماط القادة المتعددين
modal-dimension-style-manager = مدير أنماط الأبعاد
modal-default-application = التطبيق الافتراضي
modal-save-warning = تحذير الحفظ
modal-unable-save = تعذر حفظ الرسم
modal-drawing-changed = تغير الرسم على القرص
modal-delete-layer = حذف الطبقة
modal-unsaved-changes = تغييرات غير محفوظة
modal-unsaved-save-prompt = هل تريد حفظ التغييرات؟
clipboard-no-supported-content = لا تحتوي الحافظة على نص أو كائنات رسم مدعومة.
clipboard-unavailable = حافظة النظام غير متاحة.
clipboard-occupied = حافظة النظام قيد الاستخدام حاليًا. حاول مرة أخرى.
clipboard-conversion-failed = تعذرت قراءة نص الحافظة.
viewport-split-vertical = تقسيم منفذ العرض رأسيًا
viewport-split-horizontal = تقسيم منفذ العرض أفقيًا
viewport-move = نقل منفذ العرض
viewport-close = إغلاق منفذ العرض
modal-point-style = نمط النقطة
modal-attribute-editor = محرر السمات
modal-save-drawing-as = حفظ الرسم باسم
modal-recovery-report = تقرير استرداد الرسم
modal-recovery-prompt = استرداد الرسم
recovery-opened-with-repairs = فُتح الرسم مع تقرير استرداد
recovery-open-failed = تعذر فتح الرسم
recovery-repaired-description = اكتُشفت مشكلات أثناء فتح الرسم. راجع التقرير؛ وإذا أُصلحت بيانات الرسم، فاحفظه كملف جديد.
recovery-failed-description = تعذر على الاسترداد إنتاج بيانات رسم قابلة للاستخدام. راجع التقرير وجرّب نسخة احتياطية أو نسخة حفظ تلقائي.
recovery-entities-checked = الكيانات التي تم فحصها
recovery-issues-found = المشكلات الموجودة
recovery-entities-removed = الكيانات التي تمت إزالتها
recovery-references-checked = المراجع التي تم فحصها
recovery-referenced-entities-removed = الكيانات المرجعية التي تمت إزالتها
recovery-references-unavailable = المراجع غير المتاحة
recovery-log-path = سجل الاسترداد
recovery-log-write-failed = تعذرت كتابة سجل الاسترداد
recovery-log-download-ready = تقرير الاسترداد جاهز للتنزيل.
recovery-save-copy = حفظ النسخة التي تم إصلاحها
recovery-show-log = إظهار سجل الاسترداد
recovery-save-new-file-required = يجب حفظ الرسم الذي تم إصلاحه في ملف جديد.
recovery-prompt-heading = فشل الفتح العادي
recovery-prompt-description = لم يتغير الرسم. يمكن لوضع الاسترداد محاولة إنقاذ البيانات القابلة للاستخدام، لكن قد يتم تخطي السجلات التالفة. هل تريد المتابعة؟
recovery-attempt = محاولة الاسترداد
recovery-decline = إلغاء
command-move-base =
MOVE حدد نقطة الأساس [{ $count ->
[zero] لا توجد كائنات
[one] كائن واحد
[two] كائنان
[few] { $count } كائنات
[many] { $count } كائنًا
*[other] { $count } كائن
}]:
command-move-target = MOVE حدد الوجهة [الأساس { $x },{ $y }]:
command-copy-array-count = COPY أدخل عدد عناصر المصفوفة:
command-copy-base =
COPY حدد نقطة الأساس [{ $count ->
[zero] لا توجد كائنات
[one] كائن واحد
[two] كائنان
[few] { $count } كائنات
[many] { $count } كائنًا
*[other] { $count } كائن
}]:
command-copy-array-target = COPY حدد النقطة الثانية للمصفوفة ذات { $count } عنصر [الأساس { $x },{ $y }]:
command-copy-target =
COPY حدد الوجهة [{ $count ->
[zero] لا توجد نسخ حتى الآن
[one] نسخة واحدة حتى الآن
[two] نسختان حتى الآن
[few] { $count } نسخ حتى الآن
[many] { $count } نسخة حتى الآن
*[other] { $count } نسخة حتى الآن
} | مصفوفة | Enter=إنهاء | الأساس { $x },{ $y }]:
# كتالوج التطبيق الكامل

View file

@ -7,6 +7,10 @@ language-german = Deutsch
language-hindi = हिन्दी
language-russian = Русский
language-chinese-simplified = 简体中文
language-spanish = Español
language-portuguese = Português (Brasil)
language-arabic = العربية
language-japanese = 日本語
action-close = Schließen
action-options = Optionen
@ -3384,3 +3388,55 @@ style-manager-center-of-text = Textzentrum
style-manager-center-of-text-overline = Textzentrum mit Oberstrich
style-manager-block-extents = Blockgrenzen
style-manager-base-point = Basispunkt
area-option-objects = Objekte
area-option-back = Zurück
area-option-add = Fläche addieren
area-option-subtract = Fläche subtrahieren
area-prompt-first = AREA Ersten Eckpunkt angeben oder [Objekte/Fläche addieren/Fläche subtrahieren] <Objekte>:
area-prompt-add = AREA ADDIEREN Ersten Eckpunkt angeben oder [Objekte/Fläche subtrahieren] <Objekte>:
area-prompt-subtract = AREA SUBTRAHIEREN Ersten Eckpunkt angeben oder [Objekte/Fläche addieren] <Objekte>:
area-prompt-next = AREA Nächsten Punkt angeben ({ $count } gewählt, Eingabetaste zum Berechnen):
area-prompt-objects = AREA Objekte auswählen ({ $count } ausgewählt, Eingabetaste zum Berechnen):
area-objects-not-measurable = AREA: Keines der ausgewählten Objekte hat eine messbare Fläche.
area-result = Fläche = { $area }, Umfang = { $perimeter }
area-result-area-only = Fläche = { $area }
area-running-result =
Fläche = { $area }, Umfang = { $perimeter }
Gesamtfläche = { $total_area }, Gesamtumfang = { $total_perimeter }
area-running-result-area-only =
Fläche = { $area }
Gesamtfläche = { $total_area }
modal-recovery-report = Zeichnungswiederherstellungsbericht
modal-recovery-prompt = Zeichnungswiederherstellung
recovery-opened-with-repairs = Zeichnung mit einem Wiederherstellungsbericht geöffnet
recovery-open-failed = Zeichnung konnte nicht geöffnet werden
recovery-repaired-description = Beim Öffnen der Zeichnung wurden Probleme erkannt. Prüfen Sie den Bericht; wenn Zeichnungsdaten repariert wurden, speichern Sie die Zeichnung als neue Datei.
recovery-failed-description = Die Wiederherstellung konnte keine verwendbaren Zeichnungsdaten erzeugen. Prüfen Sie den Bericht und versuchen Sie eine Sicherungskopie oder automatisch gespeicherte Kopie.
recovery-entities-checked = Geprüfte Objekte
recovery-issues-found = Gefundene Probleme
recovery-entities-removed = Entfernte Objekte
recovery-references-checked = Geprüfte Referenzen
recovery-referenced-entities-removed = Entfernte referenzierte Objekte
recovery-references-unavailable = Nicht verfügbare Referenzen
recovery-log-path = Wiederherstellungsprotokoll
recovery-log-write-failed = Wiederherstellungsprotokoll konnte nicht geschrieben werden
recovery-log-download-ready = Der Wiederherstellungsbericht kann heruntergeladen werden.
recovery-save-copy = Reparierte Kopie speichern
recovery-show-log = Wiederherstellungsprotokoll anzeigen
recovery-save-new-file-required = Eine reparierte Zeichnung muss als neue Datei gespeichert werden.
recovery-prompt-heading = Normales Öffnen ist fehlgeschlagen
recovery-prompt-description = Die Zeichnung wurde nicht verändert. Der Wiederherstellungsmodus kann versuchen, verwendbare Daten zu retten; beschädigte Datensätze können dabei übersprungen werden. Möchten Sie fortfahren?
recovery-attempt = Wiederherstellung versuchen
recovery-decline = Abbrechen
catalog-tcount-select-text-objects-then-press-enter-9b1ee71b4d = TCOUNT Textobjekte auswählen und dann die Eingabetaste drücken:
catalog-tcount-starting-number-start-7471e47711 = TCOUNT Startnummer <__ocs_fmt_0__>:
catalog-tcount-increment-increment-79f9ab6108 = TCOUNT Schrittweite <__ocs_fmt_0__>:
catalog-tcount-placement-overwrite-prefix-suffix-overw-9214874a72 = TCOUNT Platzierung [Überschreiben/Präfix/Suffix] <Überschreiben>:
catalog-tcount-placement-must-be-overwrite-prefix-or-s-84211d2b75 = TCOUNT: Die Platzierung muss Überschreiben, Präfix oder Suffix sein.
catalog-tcount-numbered-n-text-object-s-from-start-by-30588884cc = TCOUNT: __ocs_fmt_0__ Textobjekt(e) ab __ocs_fmt_1__ in Schritten von __ocs_fmt_2__ nummeriert (__ocs_fmt_3__).
catalog-prefix-90eceb016c = Präfix
catalog-suffix-885db975a3 = Suffix
catalog-opening-a-drawing-by-path-is-not-available-in-be27bd71d4 = Das Öffnen einer Zeichnung über einen Pfad ist im Browser nicht verfügbar.

View file

@ -7,6 +7,10 @@ language-german = Deutsch
language-hindi = हिन्दी
language-russian = Русский
language-chinese-simplified = Simplified Chinese
language-spanish = Español
language-portuguese = Português (Brasil)
language-arabic = العربية
language-japanese = 日本語
action-close = Close
action-options = Options

View file

@ -0,0 +1,594 @@
language-system = Idioma del sistema
language-english = English
language-turkish = Türkçe
language-dutch = Nederlands
language-french = Français
language-german = Deutsch
language-hindi = हिन्दी
language-russian = Русский
language-chinese-simplified = 简体中文
language-spanish = Español
language-portuguese = Português (Brasil)
language-arabic = العربية
language-japanese = 日本語
action-close = Cerrar
action-options = Opciones
action-plugins = Complementos
ribbon-tab-draw = Dibujo
ribbon-tab-annotate = Anotar
ribbon-tab-insert = Insertar
ribbon-tab-model = Modelo
ribbon-tab-layout = Presentación
ribbon-tab-manage = Administrar
ribbon-tab-view = Vista
options-language-section = Idioma
options-language-label = Idioma de la interfaz:
options-open-save-section = Abrir y guardar
options-default-save-format-label = Formato de guardado predeterminado:
options-default-save-format-help = Se utiliza al guardar por primera vez un dibujo nuevo. Los dibujos existentes conservan su tipo y versión de archivo.
options-theme-section = Tema
options-theme-label = Tema de la aplicación:
options-theme-help = Al cambiar un color base se activa el tema Personalizado. El tema genera todos los tonos de los componentes a partir de estos seis colores.
options-color-background = Fondo
options-color-text = Texto
options-color-primary = Principal
options-color-success = Correcto
options-color-warning = Advertencia
options-color-danger = Peligro
command-line-ready = Open CAD Studio está listo.
command-line-hint = Escriba un comando o utilice la cinta. Abra archivos OBJ desde la pestaña Insertar.
command-line-label = Comando:
command-line-literal-spaces = Espacios literales: el espacio permanece en la línea en lugar de ejecutar el comando. Sigue activo hasta que se desactive.
area-option-objects = Objetos
area-option-back = Atrás
area-option-add = Añadir área
area-option-subtract = Restar área
area-prompt-first = AREA Especifique el primer punto de esquina o [Objetos/Añadir área/Restar área] <Objetos>:
area-prompt-add = AREA AÑADIR Especifique el primer punto de esquina o [Objetos/Restar área] <Objetos>:
area-prompt-subtract = AREA RESTAR Especifique el primer punto de esquina o [Objetos/Añadir área] <Objetos>:
area-prompt-next = AREA Especifique el punto siguiente ({ $count } seleccionados, Enter para calcular):
area-prompt-objects = AREA Seleccione objetos ({ $count } seleccionados, Enter para calcular):
area-objects-not-measurable = AREA: ninguno de los objetos seleccionados tiene un área medible.
area-result = Área = { $area }, Perímetro = { $perimeter }
area-result-area-only = Área = { $area }
area-running-result =
Área = { $area }, Perímetro = { $perimeter }
Área total = { $total_area }, Perímetro total = { $total_perimeter }
area-running-result-area-only =
Área = { $area }
Área total = { $total_area }
start-new-drawing = Dibujo nuevo
start-open-file = Abrir archivo…
start-donate = Donar
start-send-feedback = Enviar comentarios
start-sponsors = Patrocinadores
start-tutorials = Tutoriales
start-loading-videos = Cargando vídeos…
start-videos-online = Los vídeos se cargan desde Internet.
start-open-playlist = Abrir lista de reproducción en YouTube
start-discussions = Debates
start-pinned = Fijados
start-loading-discussions = Cargando debates…
start-discussions-online = Los debates se cargan desde GitHub.
start-open-discussions = Abrir debates en GitHub
start-supporters = Colaboradores
start-support-on-patreon = Apoyar en Patreon
start-recent-files = Archivos recientes
start-videos = Vídeos
start-welcome = Bienvenido
start-recent-documents = Documentos recientes
start-no-recent-files = Los archivos que abra aparecerán aquí.
start-browser-storage = Almacenamiento del navegador
start-keep-recent-files = Conservar archivos recientes
modal-about = Acerca de
modal-keyboard-shortcuts = Métodos abreviados de teclado
modal-command-aliases = Alias de comandos
modal-find-replace = Buscar y reemplazar
modal-plugin-manager = Administrador de complementos
modal-update-available = Actualización disponible
modal-layer-manager = Administrador de capas
modal-layer-state-manager = Administrador de estados de capa
modal-edit-layer-state = Editar estado de capa
modal-plot = Trazar
modal-layout-manager = Administrador de presentaciones
modal-scale-manager = Administrador de escalas
modal-annotation-object-scale = Escala del objeto anotativo
modal-plot-style-editor = Editor de estilos de trazado
modal-text-style-manager = Administrador de estilos de texto
modal-multiline-style-manager = Administrador de estilos de líneas múltiples
modal-table-style-manager = Administrador de estilos de tabla
modal-multileader-style-manager = Administrador de estilos de directriz múltiple
modal-dimension-style-manager = Administrador de estilos de cota
modal-default-application = Aplicación predeterminada
modal-save-warning = Advertencia de guardado
modal-unable-save = No se puede guardar el dibujo
modal-drawing-changed = El dibujo cambió en el disco
modal-delete-layer = Eliminar capa
modal-unsaved-changes = Cambios sin guardar
modal-unsaved-save-prompt = ¿Desea guardar los cambios?
clipboard-no-supported-content = El portapapeles no contiene texto ni objetos de dibujo compatibles.
clipboard-unavailable = El portapapeles del sistema no está disponible.
clipboard-occupied = El portapapeles del sistema está en uso. Inténtelo de nuevo.
clipboard-conversion-failed = No se pudo leer el texto del portapapeles.
viewport-split-vertical = Dividir ventana gráfica verticalmente
viewport-split-horizontal = Dividir ventana gráfica horizontalmente
viewport-move = Mover ventana gráfica
viewport-close = Cerrar ventana gráfica
modal-point-style = Estilo de punto
modal-attribute-editor = Editor de atributos
modal-save-drawing-as = Guardar dibujo como
modal-recovery-report = Informe de recuperación del dibujo
modal-recovery-prompt = Recuperación del dibujo
recovery-opened-with-repairs = Dibujo abierto con un informe de recuperación
recovery-open-failed = No se pudo abrir el dibujo
recovery-repaired-description = Se detectaron problemas al abrir el dibujo. Revise el informe; si se repararon datos del dibujo, guárdelo como un archivo nuevo.
recovery-failed-description = La recuperación no pudo producir datos de dibujo utilizables. Revise el informe e intente usar una copia de seguridad o de guardado automático.
recovery-entities-checked = Entidades comprobadas
recovery-issues-found = Problemas encontrados
recovery-entities-removed = Entidades eliminadas
recovery-references-checked = Referencias comprobadas
recovery-referenced-entities-removed = Entidades referenciadas eliminadas
recovery-references-unavailable = Referencias no disponibles
recovery-log-path = Registro de recuperación
recovery-log-write-failed = No se pudo escribir el registro de recuperación
recovery-log-download-ready = El informe de recuperación está listo para descargarse.
recovery-save-copy = Guardar copia reparada
recovery-show-log = Mostrar registro de recuperación
recovery-save-new-file-required = Un dibujo reparado debe guardarse en un archivo nuevo.
recovery-prompt-heading = Falló la apertura normal
recovery-prompt-description = El dibujo no se modificó. El modo de recuperación puede intentar rescatar los datos utilizables, pero puede omitir los registros dañados. ¿Desea continuar?
recovery-attempt = Intentar recuperación
recovery-decline = Cancelar
command-move-base =
MOVE Especifique el punto base [{ $count ->
[one] { $count } objeto
*[other] { $count } objetos
}]:
command-move-target = MOVE Especifique el destino [base { $x },{ $y }]:
command-copy-array-count = COPY Introduzca el número de elementos de la matriz:
command-copy-base =
COPY Especifique el punto base [{ $count ->
[one] { $count } objeto
*[other] { $count } objetos
}]:
command-copy-array-target = COPY Especifique el segundo punto de la matriz de { $count } elementos [base { $x },{ $y }]:
command-copy-target =
COPY Especifique el destino [{ $count ->
[one] { $count } copia hasta ahora
*[other] { $count } copias hasta ahora
} | Matriz | Enter=terminar | base { $x },{ $y }]:
# Catálogo completo de la aplicación
catalog-message-4883dcba20 = __ocs_fmt_0__
catalog-none-use-shortcuts-set-key-command-c80dfd9247 = (ninguno — use: SHORTCUTS SET <tecla> <comando>)
catalog-alignment-9ebcd20bed = Alineación:
catalog-background-fill-enabled-0c1c0ba4e5 = Relleno de fondo activado
catalog-blocks-4bad59225e = Bloques: __ocs_fmt_0__
catalog-borders-type-weight-color-spacing-hidden-124f1c3e83 = Bordes (tipo / grosor / color / espaciado / oculto)
catalog-code-page-0ee6cb7096 = Página de códigos: __ocs_fmt_0__
catalog-color-cur-color-c5551cdd28 = Color: __ocs_arg_cur_color__
catalog-created-julian-6-73067f26b5 = Creado (juliano): __ocs_fmt_0__
catalog-data-type-81d1887ccc = Tipo de datos:
catalog-fill-color-5ff3cfda77 = Color de relleno:
catalog-fingerprint-guid-45c5d0e61f = GUID de huella: __ocs_fmt_0__
catalog-format-string-8cbc3b8117 = Cadena de formato:
catalog-handle-x-c4f8c4f968 = Identificador __ocs_fmt_0__: «__ocs_fmt_1__»
catalog-hyperlink-base-09068ed00b = Base de hipervínculo: __ocs_fmt_0__
catalog-insert-x-no-attributes-b9883820e5 = Inserción __ocs_fmt_0__: sin atributos.
catalog-last-saved-by-a49aa0e84e = Guardado por última vez por: __ocs_fmt_0__
catalog-layers-f59e27d3f2 = Capas: __ocs_fmt_0__
catalog-linetypes-8b1852ca9d = Tipos de línea: __ocs_fmt_0__
catalog-lineweight-cur-lw-ca24227ace = Grosor de línea: __ocs_arg_cur_lw__
catalog-measurement-31bd3b8028 = Medición: __ocs_fmt_0__ (__ocs_fmt_1__)
catalog-menu-name-f6d04ff4a4 = Nombre del menú: __ocs_fmt_0__
catalog-off-color-ltype-a19d109b27 = Desact. Color Tipo de línea
catalog-project-name-937c9166bc = Nombre del proyecto: __ocs_fmt_0__
catalog-proxy-graphics-ace9a5cbe8 = Gráficos proxy: __ocs_fmt_0__
catalog-required-versions-018x-19f89c2732 = Versiones necesarias: __ocs_fmt_0__
catalog-screening-cur-scr-b002df78e2 = Tramado: __ocs_arg_cur_scr__
catalog-stylesheet-7e0e1382f1 = Hoja de estilos: __ocs_fmt_0__
catalog-text-color-c9b1538f38 = Color de texto:
catalog-text-height-16502af79f = Altura de texto:
catalog-text-style-fafe521a25 = Estilo de texto:
catalog-text-styles-46532e43fd = Estilos de texto: __ocs_fmt_0__
catalog-total-edit-time-4-d8ae993d59 = Tiempo total de edición: __ocs_fmt_0__
catalog-tree-depth-8ff2a32dbe = Profundidad del árbol: __ocs_fmt_0__
catalog-unit-type-419ce6a5da = Tipo de unidad:
catalog-updated-julian-6-d935762c42 = Actualizado (juliano): __ocs_fmt_0__
catalog-user-elapsed-4-43a1242325 = Tiempo transcurrido del usuario: __ocs_fmt_0__
catalog-user-timer-70edaf24f6 = Temporizador del usuario: __ocs_fmt_0__
catalog-user-vars-int-6bc5221565 = Variables de usuario (enteras): __ocs_fmt_0__ __ocs_fmt_1__ __ocs_fmt_2__ __ocs_fmt_3__ __ocs_fmt_4__
catalog-user-vars-real-6-6-6-6-6-ad2697f888 = Variables de usuario (reales): __ocs_fmt_0__ __ocs_fmt_1__ __ocs_fmt_2__ __ocs_fmt_3__ __ocs_fmt_4__
catalog-vp-id-w-1-h-1-1-1-scale-scale-4-state-4bf2fa0893 = VG n.º __ocs_fmt_0__: __ocs_fmt_1__×__ocs_fmt_2__ @ (__ocs_fmt_3__,__ocs_fmt_4__) escala=__ocs_fmt_5__ [__ocs_fmt_6__]
catalog-version-guid-aac810d967 = GUID de versión: __ocs_fmt_0__
catalog-tag-val-009348b9fb = [__ocs_fmt_0__] = __ocs_fmt_1__
catalog-parse-ms-purge-ms-caches-ms-xref-ms-total-ms-9d07a1d8fb = análisis __ocs_fmt_0__ ms · limpieza __ocs_fmt_1__ ms · cachés __ocs_fmt_2__ ms · ref. ext. __ocs_fmt_3__ ms · total __ocs_fmt_4__ ms
catalog-x-value-s-adb79ea510 = __ocs_fmt_0__ [__ocs_fmt_1__]: __ocs_fmt_2__ valor(es)
catalog-x-no-xdata-cd844bf6b1 = __ocs_fmt_0__: sin datos extendidos.
catalog-k-n-610d3c5a70 = __ocs_fmt_0__: __ocs_fmt_1__
catalog-no-closed-boundary-found-7a55b25ecb = ⚠ No se encontró ningún contorno cerrado.
catalog-count-objects-83518eccc2 = (__ocs_arg_count__ objetos)
catalog-edge-extend-90a0cb9b4f = [Borde: Alargar]
catalog-file-name-could-not-be-saved-b35d7df5dd = No se pudo guardar «__ocs_arg_file_name__».
catalog-file-name-was-changed-by-another-application-278da6e091 = Otra aplicación modificó «__ocs_arg_file_name__».
catalog-name-already-in-use-1ce1d7a45c = El nombre «__ocs_fmt_0__» ya está en uso
catalog-rrggbb-or-blank-a67a2b684c = #RRGGBB o vacío
catalog-cmd-select-objects-count-selected-enter-to-app-095ab66a21 = __ocs_arg_cmd__ Seleccione objetos (__ocs_arg_count__ seleccionados, Enter para aplicar):
catalog-cmd-select-objects-6b61510181 = __ocs_arg_cmd__ Seleccione objetos:
catalog-count-objects-selected-5f4cee3b4c = __ocs_arg_count__ objetos seleccionados
catalog-count-selected-layers-558031ed47 = __ocs_arg_count__ capas seleccionadas
catalog-count-steps-available-0f89e5e1fa = __ocs_arg_count__ pasos disponibles
catalog-label-history-defb4ca993 = Historial de __ocs_arg_label__
catalog-name-select-objects-then-press-enter-fa8657f9a8 = __ocs_arg_name__ Seleccione objetos y pulse Enter:
catalog-name-which-register-1-5-fa400449b8 = __ocs_arg_name__ ¿Qué registro? [1-5]:
catalog-name-slot-new-value-7a91d6723d = __ocs_arg_name____ocs_arg_slot__ Nuevo valor:
catalog-n-specify-center-point-2a5fdd544a = __ocs_arg_n__ Especifique el centro:
catalog-n-specify-first-corner-aaab274163 = __ocs_arg_n__ Especifique la primera esquina:
catalog-n-specify-height-enter-for-default-ede62d5a53 = __ocs_arg_n__ Especifique la altura <Enter para predeterminada>:
catalog-n-specify-opposite-corner-e55167705b = __ocs_arg_n__ Especifique la esquina opuesta:
catalog-n-specify-radius-9f59c68028 = __ocs_arg_n__ Especifique el radio:
catalog-n-specify-tube-radius-4b3328cd10 = __ocs_arg_n__ Especifique el radio del tubo:
catalog-n-vp-ffc3c37041 = __ocs_arg_n__ VG
catalog-n-layers-3d7760d9c7 = __ocs_arg_n__ capas
catalog-n-saved-layers-accdcb3129 = __ocs_arg_n__ capas guardadas
catalog-prefix-textedit-enter-text-edit-mode-single-mu-332f576fed = __ocs_arg_prefix__TEXTEDIT Indique el modo de edición de texto [Único/Múltiple] <__ocs_arg_mode__>:
catalog-prefix-texteditmode-enter-new-value-for-texted-7155f96153 = __ocs_arg_prefix__TEXTEDITMODE Introduzca un valor nuevo para TEXTEDITMODE <__ocs_arg_v__>:
catalog-scope-visual-style-3981127921 = Estilo visual de __ocs_arg_scope__
catalog-subject-still-has-count-obj-deleting-will-also-55dbfda19a =
__ocs_arg_subject__ aún __ocs_arg_has__ __ocs_arg_count__ __ocs_arg_obj__.
Al eliminarlo también se quitarán __ocs_arg_those__ del dibujo. ¿Desea continuar?
catalog-default-c8283858bc = (predeterminado)
catalog-no-page-setups-edfbc7b5af = (sin configuraciones de página)
catalog-no-table-loaded-ef95c1e199 = (sin tabla cargada)
catalog-none-3c6b183d34 = (ninguno)
catalog-cancel-81e7a407ec = *Cancelar*
catalog-add-alias-66714e6b33 = + Añadir alias
catalog-inverted-feee82a852 = , invertido
catalog-0-variable-87388fa3ed = 0 = variable
catalog-1st-arrowhead-dimblk1-12777c1288 = 1.ª punta de flecha (DIMBLK1)
catalog-2-point-064de0a7be = 2 puntos
catalog-2d-solid-6483f99ec4 = Sólido 2D
catalog-2d-wireframe-3ac363806a = Estructura alámbrica 2D
catalog-2nd-arrowhead-dimblk2-27ede794e5 = 2.ª punta de flecha (DIMBLK2)
catalog-3-point-93e3a91b12 = 3 puntos
catalog-3d-face-1ddb360ce9 = Cara 3D
catalog-3d-orbit-3f95e247d2 = Órbita 3D
catalog-3d-orbit-drag-with-right-mouse-button-36852405c8 = Órbita 3D: arrastre con el botón derecho del ratón.
catalog-3d-polyline-1607624e1a = Polilínea 3D
catalog-3d-solid-101aeeb575 = Sólido 3D
catalog-3d-visualization-8cd08058f1 = Visualización 3D
catalog-3d-wireframe-5538ca1b90 = Estructura alámbrica 3D
catalog-3dalign-aligned-the-solid-to-the-destination-p-ef4378355c = 3DALIGN: sólido alineado con los puntos de destino.
catalog-3dalign-each-point-triple-must-be-non-coincide-1d59168ea6 = 3DALIGN: cada terna de puntos debe contener puntos distintos y no colineales.
catalog-3dalign-select-exactly-one-solid-created-this-903183ff09 = 3DALIGN: seleccione exactamente un sólido creado en esta sesión.
catalog-3dface-23c5eb1ee2 = Cara 3D
catalog-3dmirror-mirror-plane-x-y-z-110de6e59d = 3DMIRROR Plano de simetría [X / Y / Z]:
catalog-3dmirror-added-a-mirror-across-the-plane-f74aa71107 = 3DMIRROR: se añadió una copia simétrica respecto al plano __ocs_fmt_0__.
catalog-3dmirror-could-not-determine-the-solid-s-exten-f3a69be090 = 3DMIRROR: no se pudo determinar la extensión del sólido.
catalog-3dmirror-select-exactly-one-solid-created-this-a1e696f9f9 = 3DMIRROR: seleccione exactamente un sólido creado en esta sesión.
catalog-3dpoly-specify-next-point-close-undo-428121e01c = 3DPOLY Especifique el punto siguiente [Cerrar/Deshacer]:
catalog-3dpoly-specify-next-point-undo-4701b64d13 = 3DPOLY Especifique el punto siguiente [Deshacer]:
catalog-3dpoly-specify-start-point-of-polyline-37cbc00122 = 3DPOLY Especifique el punto inicial de la polilínea:
catalog-3drotate-angle-in-degrees-6e999b0cde = 3DROTATE Ángulo en grados:
catalog-3drotate-rotation-axis-x-y-z-9b471b7d4d = 3DROTATE Eje de rotación [X / Y / Z]:
catalog-3drotate-could-not-determine-the-solid-s-exten-0809d4b118 = 3DROTATE: no se pudo determinar la extensión del sólido.
catalog-3drotate-rotated-angle-deg-about-the-axis-0beb8a4058 = 3DROTATE: girado __ocs_fmt_0__° alrededor del eje __ocs_fmt_1__.
catalog-3drotate-select-exactly-one-solid-created-this-89c49aa84a = 3DROTATE: seleccione exactamente un sólido creado en esta sesión.
catalog-none-9dcf3a8263 = <ninguno>
catalog-previous-40bf65fa86 = <anterior>
catalog-v-0869c38eb9 = = __ocs_fmt_0__
catalog-a-newer-open-cad-studio-version-is-published-o-72916d24bd = Hay una versión más reciente de Open CAD Studio publicada en GitHub.
catalog-ac-version-1469527046 = Versión AC
catalog-aci-color-index-83dbaf2a98 = Índice de color ACI
catalog-aci-c9f2c0d974 = ACI:
catalog-addselected-creating-a-new-kind-is-not-support-b2f0e7607a = ADDSELECTED: no se admite la creación de un nuevo __ocs_fmt_0__.
catalog-addselected-selected-object-not-found-f4e4fa943b = ADDSELECTED: no se encontró el objeto seleccionado.
catalog-adjust-brightness-contrast-fade-5f7ee97676 = ADJUST [Brillo/Contraste/Desvanecimiento]:
catalog-adjust-brightness-0-100-620b56755f = ADJUST Brillo 0-100:
catalog-adjust-contrast-0-100-6f0f0b5601 = ADJUST Contraste 0-100:
catalog-adjust-fade-0-100-e29fd6bfe5 = ADJUST Desvanecimiento 0-100:
catalog-adjust-select-raster-image-s-first-cda45c767d = ADJUST: seleccione primero una o más imágenes ráster.
catalog-adjust-value-must-be-0-100-8f52a3a68c = ADJUST: el valor debe estar entre 0 y 100.
catalog-adjust-action-v-on-changed-image-s-f55a178ba4 = ADJUST: __ocs_fmt_0__ = __ocs_fmt_1__ en __ocs_fmt_2__ imagen(es).
catalog-align-scale-objects-based-on-alignment-points-e5e0f5e590 = ALIGN ¿Escalar los objetos según los puntos de alineación? [S/N]:
catalog-align-select-objects-count-selected-enter-when-ce0fbaf924 = ALIGN Seleccione objetos (__ocs_arg_count__ seleccionados, Enter al terminar):
catalog-align-specify-1st-destination-point-bd64ad3d0a = ALIGN Especifique el primer punto de destino:
catalog-align-specify-1st-source-point-a0123ef59a = ALIGN Especifique el primer punto de origen:
catalog-align-specify-2nd-destination-point-ec83e4ac3f = ALIGN Especifique el segundo punto de destino:
catalog-align-specify-2nd-source-point-enter-translate-b299106722 = ALIGN Especifique el segundo punto de origen (Enter = solo desplazar):
catalog-align-applied-4b237394ff = ALIGN: aplicado.
catalog-annoallvisible-new-value-0-1-b40aa2f762 = ANNOALLVISIBLE Valor nuevo [0/1]:
catalog-annoallvisible-enter-0-or-1-28860bef7c = ANNOALLVISIBLE: introduzca 0 o 1.
catalog-annoautoscale-new-value-4-4-bd2aa5942c = ANNOAUTOSCALE Valor nuevo [-4..4]:
catalog-annoscale-new-annotation-scale-e-g-1-50-2-1-or-2a9523cf29 = ANNOSCALE Nueva escala anotativa (p. ej., 1:50, 2:1 o un factor):
catalog-annoupdate-select-annotation-objects-first-3c7b6cc302 = ANNOUPDATE: seleccione primero objetos anotativos.
catalog-annoupdate-updated-updated-object-s-8426986d33 = ANNOUPDATE: __ocs_fmt_0__ objeto(s) actualizado(s).
catalog-api-incompatible-7aee4ab3d5 = API incompatible
catalog-arc-specify-center-8ca5921878 = ARC Especifique el centro:
catalog-arc-specify-end-point-c-cx-cy-r-r-e86ec11dd4 = ARC Especifique el punto final [c=(__ocs_arg_cx__,__ocs_arg_cy__) r=__ocs_arg_r__]:
catalog-arc-specify-start-point-153faf380c = ARC Especifique el punto inicial:
catalog-arc-3p-specify-end-point-3bc9227d83 = ARC 3P Especifique el punto final:
catalog-arc-3p-specify-second-point-on-arc-0d9ca492f6 = ARC 3P Especifique el segundo punto del arco:
catalog-arc-3p-specify-start-point-3d72ee7604 = ARC 3P Especifique el punto inicial:
catalog-arc-csa-click-end-direction-or-type-arc-span-i-9517e465a6 = ARC CSA Pulse la dirección final o escriba el ángulo del arco en grados [inicio=__ocs_arg_sa__]:
catalog-arc-csa-specify-center-69db023196 = ARC CSA Especifique el centro:
catalog-arc-csa-specify-start-point-d8128b5bcc = ARC CSA Especifique el punto inicial:
catalog-arc-csl-click-chord-end-or-type-chord-length-r-874fb30c98 = ARC CSL Pulse el extremo de la cuerda o escriba su longitud [r=__ocs_arg_r__]:
catalog-arc-csl-specify-center-a94dd7b827 = ARC CSL Especifique el centro:
catalog-arc-csl-specify-start-point-8386c5fca2 = ARC CSL Especifique el punto inicial:
catalog-arc-continue-no-previous-line-or-arc-to-contin-07fb818cce = ARC Continuar No hay una línea ni un arco anterior que continuar.
catalog-arc-continue-specify-end-point-ctrl-flip-direc-49c2e70b61 = ARC Continuar Especifique el punto final [Ctrl = invertir dirección]:
catalog-arc-sca-click-end-direction-or-type-arc-span-i-cf8ad6b04a = ARC SCA Pulse la dirección final o escriba el ángulo del arco en grados [inicio=__ocs_arg_sa__]:
catalog-arc-sca-specify-center-f13ddbc182 = ARC SCA Especifique el centro:
catalog-arc-sca-specify-start-point-336bca2af1 = ARC SCA Especifique el punto inicial:
catalog-arc-sce-specify-center-bc1b3642cf = ARC SCE Especifique el centro:
catalog-arc-sce-specify-end-point-r-r-022367ed3a = ARC SCE Especifique el punto final [r=__ocs_arg_r__]:
catalog-arc-sce-specify-start-point-d41f00e961 = ARC SCE Especifique el punto inicial:
catalog-arc-scl-click-chord-end-or-type-chord-length-r-01164c14cf = ARC SCL Pulse el extremo de la cuerda o escriba su longitud [r=__ocs_arg_r__]:
catalog-arc-scl-specify-center-0301cbb398 = ARC SCL Especifique el centro:
catalog-arc-scl-specify-start-point-c515072807 = ARC SCL Especifique el punto inicial:
catalog-arc-sea-specify-angle-move-cursor-perpendicula-61017230dd = ARC SEA Especifique el ángulo (mueva el cursor perpendicularmente a la cuerda):
catalog-arc-sea-specify-end-point-77bd4966b9 = ARC SEA Especifique el punto final:
catalog-arc-sea-specify-start-point-e898a4049b = ARC SEA Especifique el punto inicial:
catalog-arc-sed-specify-end-point-b7be2060ba = ARC SED Especifique el punto final:
catalog-arc-sed-specify-start-point-185d74d9e9 = ARC SED Especifique el punto inicial:
catalog-arc-sed-specify-tangent-direction-at-start-aacef7082c = ARC SED Especifique la dirección tangente en el inicio:
catalog-arc-ser-click-radius-point-or-type-radius-valu-3f10a80b2f = ARC SER Pulse el punto del radio o escriba su valor:
catalog-arc-ser-specify-end-point-ce51f1c5c1 = ARC SER Especifique el punto final:
catalog-arc-ser-specify-start-point-437c9f141f = ARC SER Especifique el punto inicial:
catalog-archive-save-the-drawing-first-it-has-no-file-0654e9fa77 = ARCHIVE: guarde primero el dibujo (todavía no tiene una ruta de archivo).
catalog-arctext-text-to-place-along-the-arc-d391455094 = ARCTEXT Texto que se colocará a lo largo del arco:
catalog-arctext-no-printable-text-04a1a7b60e = ARCTEXT: no hay texto imprimible.
catalog-arctext-placed-n-character-s-along-the-arc-9645e47dae = ARCTEXT: se colocaron __ocs_fmt_0__ carácter(es) a lo largo del arco.
catalog-arctext-select-an-arc-first-5c14001453 = ARCTEXT: seleccione primero un arco.
catalog-area-specify-first-corner-point-enter-to-cance-047cdde937 = AREA Especifique el primer punto de esquina (Enter para cancelar):
catalog-area-specify-next-point-n-picked-enter-to-calc-4cc5e3a547 = AREA Especifique el punto siguiente (__ocs_arg_n__ seleccionados, Enter para calcular):
catalog-array3d-column-spacing-rows-cols-levels-row-rs-58a57e15b7 = ARRAY3D Espaciado entre columnas [__ocs_arg_rows__×__ocs_arg_cols__×__ocs_arg_levels__, fila=__ocs_arg_rs__]:
catalog-array3d-enter-column-count-rows-rows-04c74ee0a8 = ARRAY3D Introduzca el número de columnas [__ocs_arg_rows__ filas]:
catalog-array3d-enter-level-count-rows-cols-94d772b78f = ARRAY3D Introduzca el número de niveles [__ocs_arg_rows__×__ocs_arg_cols__]:
catalog-array3d-enter-row-count-4df8b03777 = ARRAY3D Introduzca el número de filas:
catalog-array3d-level-spacing-rows-cols-levels-r-rs-c-739a9348a6 = ARRAY3D Espaciado entre niveles [__ocs_arg_rows__×__ocs_arg_cols__×__ocs_arg_levels__, f=__ocs_arg_rs__ c=__ocs_arg_cs__]:
catalog-array3d-row-spacing-rows-cols-levels-dfaeacd019 = ARRAY3D Espaciado entre filas [__ocs_arg_rows__×__ocs_arg_cols__×__ocs_arg_levels__]:
catalog-arraypath-enter-item-count-3c594b1e1a = ARRAYPATH Introduzca el número de elementos <__ocs_fmt_0__>:
catalog-arraypath-select-path-entity-objects-b957016cff = ARRAYPATH Seleccione la entidad de trayectoria [__ocs_fmt_0__ objetos]:
catalog-arraypolar-enter-item-count-db5a1cb4bc = ARRAYPOLAR Introduzca el número de elementos <__ocs_fmt_0__>:
catalog-arraypolar-enter-total-angle-in-degrees-0-coun-0a521b3d22 = ARRAYPOLAR Introduzca el ángulo total en grados <__ocs_fmt_0__> [__ocs_fmt_1__ elementos]:
catalog-arraypolar-specify-center-point-objects-01c9546098 = ARRAYPOLAR Especifique el centro [__ocs_fmt_0__ objetos]:
catalog-arrayrect-column-spacing-0-rows-cols-row-row-s-60eb63ad7c = ARRAYRECT Espaciado entre columnas <__ocs_fmt_0__> [__ocs_fmt_1__×__ocs_fmt_2__, fila=__ocs_fmt_3__]:
catalog-arrayrect-enter-column-count-rows-rows-44ec061783 = ARRAYRECT Introduzca el número de columnas <__ocs_fmt_0__> [__ocs_fmt_1__ filas]:
catalog-arrayrect-enter-row-count-65fa47e79f = ARRAYRECT Introduzca el número de filas <__ocs_fmt_0__>:
catalog-arrayrect-row-spacing-0-rows-cols-7a96cc7063 = ARRAYRECT Espaciado entre filas <__ocs_fmt_0__> [__ocs_fmt_1__×__ocs_fmt_2__]:
catalog-attdef-enter-attribute-tag-no-spaces-832e173615 = ATTDEF Introduzca la etiqueta del atributo (sin espacios):
catalog-attdef-enter-default-value-for-tag-enter-blank-6f682a8f81 = ATTDEF Introduzca el valor predeterminado de «__ocs_arg_tag__» (Enter=vacío):
catalog-attdef-enter-prompt-for-tag-enter-use-tag-62ee2f91c8 = ATTDEF Introduzca el mensaje de «__ocs_arg_tag__» (Enter=usar etiqueta):
catalog-attdef-specify-insertion-point-for-tag-c14b412fd6 = ATTDEF Especifique el punto de inserción de «__ocs_arg_tag__»:
catalog-attdisp-attribute-display-on-off-normal-5f1ba3187f = ATTDISP Visualización de atributos [Activada/Desactivada/Normal]:
catalog-attdisp-sub-count-attribute-definition-s-updat-64934ed370 = ATTDISP __ocs_fmt_0__: se actualizaron __ocs_fmt_1__ definiciones de atributo.
catalog-attedit-select-block-with-attributes-0d9f102dc5 = ATTEDIT Seleccione un bloque con atributos:
catalog-attedit-no-insert-entities-in-selection-2efd334f89 = ATTEDIT: no hay entidades de inserción en la selección.
catalog-attedit-select-an-insert-entity-first-7306428ee8 = ATTEDIT: seleccione primero una entidad de inserción.
catalog-attedit-tag-tag-up-not-found-in-selection-f6179123ff = ATTEDIT: no se encontró la etiqueta «__ocs_fmt_0__» en la selección.
catalog-attedit-updated-changed-attribute-s-tag-up-new-e42fc240a2 = ATTEDIT: se actualizaron __ocs_fmt_0__ atributos [__ocs_fmt_1__] = __ocs_fmt_2__.
catalog-attsync-block-name-to-sync-213951b511 = ATTSYNC Nombre del bloque que se sincronizará:
catalog-attsync-no-block-named-arg-1693bb3d0a = ATTSYNC: no existe ningún bloque llamado «__ocs_fmt_0__».
catalog-attsync-synchronised-synced-insert-s-of-block-9536b3279c = ATTSYNC: se sincronizaron __ocs_fmt_0__ inserciones de «__ocs_fmt_1__» con __ocs_fmt_2__ definiciones de atributo.
catalog-audit-no-issues-found-1bffbfca7c = AUDIT: no se encontraron problemas.
catalog-audit-reference-s-to-undefined-block-s-9f8580578c = AUDIT: referencias a bloques no definidos: __ocs_fmt_0__
catalog-audit-reference-s-to-undefined-layer-s-4ca4272890 = AUDIT: referencias a capas no definidas: __ocs_fmt_0__
catalog-audit-report-only-no-automatic-repair-performe-665ff2fda6 = AUDIT: solo informe; no se realizó ninguna reparación automática.
catalog-audit-scanned-total-object-s-22e39a658c = AUDIT: se examinaron __ocs_fmt_0__ objetos.
catalog-about-6b21fb791a = Acerca de
catalog-above-6370c271df = Arriba
catalog-above-text-77d508757f = Encima del texto
catalog-accept-bb54db510a = Aceptar
catalog-action-97c89a4d66 = Acción
catalog-active-a733b809d2 = Activo
catalog-active-ucs-named-c213f515c3 = SCP activo: __ocs_fmt_0__ | Con nombre: __ocs_fmt_1__
catalog-active-ucs-no-named-ucss-defined-fede450e16 = SCP activo: __ocs_fmt_0__ | No hay SCP con nombre definidos.
catalog-active-viewport-cb256416bf = Ventana gráfica activa
catalog-active-dim-style-set-to-name-arg-223a582b1b = Estilo de cota activo definido como «__ocs_fmt_0__»
catalog-add-61cc55aa04 = Añadir
catalog-add-leader-89cc8c25ab = Añadir directriz
catalog-add-scale-0b67ea5641 = Añadir escala
catalog-add-selected-drawing-a-new-kind-on-layer-layer-3b3b470d3e = Añadir seleccionado: dibujando un nuevo __ocs_fmt_0__ en la capa «__ocs_fmt_1__».
catalog-add-from-github-eeb16a4e4e = Añadir desde GitHub
catalog-add-leader-cee76facb8 = Añadir directriz
catalog-add-repository-1b73cdafd0 = Añadir repositorio
catalog-added-annotation-scale-arg-40be971a98 = Se añadió la escala anotativa __ocs_fmt_0__.
catalog-added-from-github-e1efdbf07b = Añadido desde GitHub
catalog-adjust-ab166bd5e8 = Ajustar
catalog-adjust-colors-for-background-6b76eaf659 = Ajustar colores al fondo
catalog-advanced-4d06472695 = Avanzado
catalog-alias-04259816ac = Alias
catalog-align-leaders-b9f937bb33 = Alinear directrices
catalog-align-space-08a021e08e = Espacio de alineación:
catalog-aligned-6ad112f324 = Alineado
catalog-alignment-7f8c5176c3 = Alineación
catalog-all-6a72085653 = Todo
catalog-all-layers-thawed-24edb0d02c = Se han reutilizado todas las capas.
catalog-all-layers-turned-on-87e4339b14 = Se han activado todas las capas.
catalog-alt-enabled-a14ad88d38 = Alternativas activadas
catalog-alt-format-46994a6359 = Formato alternativo
catalog-alt-precision-f3371cdf77 = Precisión alternativa
catalog-alt-prefix-suffix-5dd9b50190 = Prefijo/sufijo alternativo
catalog-alt-roundoff-7381768fb5 = Redondeo alternativo
catalog-alt-scale-factor-953b5847c3 = Factor de escala alternativo
catalog-alt-suppress-leading-zeros-c1ef8064a6 = Suprimir ceros iniciales alternativos
catalog-alt-suppress-trailing-zeros-984f3ed380 = Suprimir ceros finales alternativos
catalog-alternate-9ed2535f09 = Alternativo
catalog-alternate-units-2991d968e8 = Unidades alternativas
catalog-ambient-c10e8f39fc = Ambiente
catalog-angle-1fd70473fb = Ángulo
catalog-angle-angle-6c7de6261f = Ángulo = __ocs_arg_angle__°
catalog-angle-0-0000-degenerate-rays-deda28c583 = Ángulo = 0,0000° (rayos degenerados)
catalog-angle-format-196af98163 = Formato de ángulo
catalog-angle-precision-dc1c64d6e2 = Precisión angular
catalog-angles-ff8084f15b = Ángulos
catalog-angular-8e09000e5c = Angular
catalog-angular-decimals-dimadec-52810900b4 = Decimales angulares (DIMADEC)
catalog-angular-unit-dimaunit-fd4f07da8d = Unidad angular (DIMAUNIT)
catalog-angular-zero-supp-dimazin-2a8d799e53 = Supresión de ceros angulares (DIMAZIN)
catalog-annotate-4f5f32d8fd = Anotar
catalog-annotation-de3b78b641 = Anotación
catalog-annotation-viewport-scale-click-to-change-1d574da062 =
Escala de anotación/ventana gráfica
Pulse para cambiar
catalog-annotation-object-scale-a6c5c4ebdc = Escala del objeto anotativo
catalog-annotation-scale-b49ce65069 = Escala anotativa
catalog-annotation-scaling-c4b9cb83e6 = Ajuste de escala anotativa
catalog-annotation-scale-0400c101c7 = Escala anotativa
catalog-annotation-scale-arg-129d46a1ed = Escala anotativa: __ocs_fmt_0__
catalog-annotation-scales-bb77e7eeb2 = Escalas anotativas: __ocs_fmt_0__
catalog-annotative-142ac0bf83 = Anotativo
catalog-annotative-scale-bc6ed32c2b = Escala anotativa
catalog-apparent-intersection-ac8c738027 = Intersección aparente
catalog-append-to-current-selection-faba277dc7 = Añadir a la selección actual
catalog-application-b291beb879 = Aplicación
catalog-apply-cfea419c3b = Aplicar
catalog-apply-cell-11779665e5 = Aplicar celda
catalog-apply-margins-dcae9eafd9 = Aplicar márgenes
catalog-apply-to-aci-6e60658cfb = Aplicar a ACI
catalog-arc-853a983f6b = Arco
catalog-arc-end-deg-eddeebcf5c = Fin del arco (grados)
catalog-arc-start-deg-013eef7448 = Inicio del arco (grados)
catalog-arc-x-1384a11219 = X del arco
catalog-arc-y-aba1c57898 = Y del arco
catalog-arc-z-6189310706 = Z del arco
catalog-arc-length-a3852745d9 = Longitud de arco
catalog-arc-length-symbol-dimarcsym-52f9e2ba9d = Símbolo de longitud de arco (DIMARCSYM)
catalog-arc-aligned-text-c3e108e803 = Texto alineado con arco
catalog-arch-f96ddf323d = Arquitectónico
catalog-architectural-33680ccdb3 = Arquitectónico
catalog-area-2745debaa6 = Área
catalog-area-area-perimeter-perimeter-319368a17d = Área = __ocs_arg_area__, Perímetro = __ocs_arg_perimeter__
catalog-array-insert-cf61c7bf2a = Inserción de matriz
catalog-arrow-3a4758c3cc = Flecha
catalog-arrow-size-593a16dcc8 = Tamaño de flecha
catalog-arrow-size-dimasz-e79def674f = Tamaño de flecha (DIMASZ)
catalog-arrowhead-e6870bf25a = Punta de flecha
catalog-arrowhead-dimblk-ca3d3ffe0e = Punta de flecha (DIMBLK)
catalog-arrowhead-block-f59d3e2221 = Bloque de punta de flecha:
catalog-arrowhead-size-17232209e6 = Tamaño de punta de flecha
catalog-arrowhead-size-7e5cc19172 = Tamaño de punta de flecha:
catalog-arrows-0cf604cb00 = Flechas
catalog-arrows-out-first-34c08696c5 = Flechas fuera primero
catalog-as-displayed-86cb59e670 = Como se muestra
catalog-associative-b7d696bef2 = Asociativo
catalog-at-first-ext-93aa189569 = En la primera extensión
catalog-at-least-2233bfd2f5 = Al menos
catalog-at-second-ext-6ecae855c6 = En la segunda extensión
catalog-attach-1afff0157c = Enlazar
catalog-attach-xref-a010ad7bff = Enlazar referencia externa
catalog-attachment-direction-833e9716b0 = Dirección de enlace:
catalog-attachment-type-4ed4f27cde = Tipo de enlace
catalog-attenuation-56a66dbaef = Atenuación
catalog-attribute-a086d94288 = Atributo
catalog-attribute-definition-baaf399856 = Definición de atributo
catalog-attribute-editor-b41475bb05 = Editor de atributos
catalog-attributedefinition-117ad6ffdf = Definición de atributo
catalog-attributes-a6652617f2 = Atributos
catalog-audit-fa1703dd78 = Auditoría
catalog-auto-c614ba7c45 = Automático
catalog-automatically-add-scales-da9d358823 = Añadir escalas automáticamente
catalog-autosaved-1-drawing-2605935129 = Se guardó automáticamente 1 dibujo
catalog-available-plugins-8d019a58db = Complementos disponibles
catalog-axis-160bbfbc4d = Eje
catalog-axis-point-b50068c6a4 = Punto del eje
catalog-axis-vector-9fa9b9ea2f = Vector del eje
catalog-axis-end-888da7e434 = Eje, extremo
catalog-background-colour-default-black-darkgray-gray-8bc2289b5a = BACKGROUND Color [Predeterminado/Negro/GrisOscuro/Gris/GrisClaro/Blanco] o R G B (0–255):
catalog-base-specify-base-point-bf41e42257 = BASE Especifique el punto base:
catalog-bedit-select-block-reference-to-edit-4ebf3273b9 = BEDIT Seleccione la referencia de bloque que desea editar:
catalog-bedit-block-edit-discarded-6aa417f180 = BEDIT: se descartó la edición del bloque «__ocs_fmt_0__».
catalog-bedit-block-saved-all-references-updated-cd95d9a6be = BEDIT: se guardó el bloque «__ocs_fmt_0__». Se actualizaron todas las referencias.
catalog-bedit-editing-block-use-save-block-or-discard-9adbb41f27 = BEDIT: editando el bloque «__ocs_fmt_0__». Use Guardar bloque o Descartar para terminar.
catalog-bedit-block-not-found-8bc01ffc67 = BEDIT: no se encontró el bloque «__ocs_fmt_0__».
catalog-bedit-block-tab-name-is-not-open-25058e33e0 = BEDIT: la pestaña de bloque «__ocs_fmt_0__» no está abierta.
catalog-bedit-cannot-edit-an-external-reference-xref-5ec4cd50e5 = BEDIT: no se puede editar una referencia externa.
catalog-bedit-finish-the-active-refedit-refclose-first-fe3a5d34be = BEDIT: finalice primero la sesión REFEDIT activa con REFCLOSE.
catalog-bedit-selected-object-is-not-a-block-reference-506eb728f1 = BEDIT: el objeto seleccionado no es una referencia de bloque.
catalog-bedit-discard-invalid-block-editor-state-229f649aa8 = BEDIT_DISCARD: estado no válido del editor de bloques.
catalog-bedit-discard-no-block-editor-is-open-663f2ee328 = BEDIT_DISCARD: no hay ningún editor de bloques abierto.
catalog-bedit-save-invalid-block-editor-state-72f68d1608 = BEDIT_SAVE: estado no válido del editor de bloques.
catalog-bedit-save-no-block-editor-is-open-59bd83df7a = BEDIT_SAVE: no hay ningún editor de bloques abierto.
catalog-blend-enter-continuity-tangent-curvature-c-3059df3a8c = BLEND Indique la continuidad [Tangente/Curvatura] <__ocs_arg_c__>:
catalog-blend-select-first-open-curve-continuity-c-1df991256c = BLEND Seleccione la primera curva abierta [Continuidad=__ocs_arg_c__]:
catalog-blend-select-second-open-curve-continuity-c-07eac40b1f = BLEND Seleccione la segunda curva abierta [Continuidad=__ocs_arg_c__]:
catalog-block-enter-block-name-count-objects-selected-5d1e2ecc8d = BLOCK Introduzca el nombre del bloque [__ocs_arg_count__ objetos seleccionados]:
catalog-block-specify-base-point-for-name-count-object-c13399f982 = BLOCK Especifique el punto base de «__ocs_arg_name__» [__ocs_arg_count__ objetos]:
catalog-boundary-pick-internal-point-miss-ce5a48a278 = BOUNDARY Designe un punto interior:__ocs_arg_miss__
catalog-break-select-object-3c60ed92a5 = BREAK Seleccione un objeto:
catalog-break-specify-first-break-point-5414ba5c05 = BREAK Especifique el primer punto de ruptura:
catalog-break-specify-second-break-point-6612862348 = BREAK Especifique el segundo punto de ruptura:
catalog-break-entity-type-not-supported-4f09c29ce8 = BREAK: tipo de entidad no compatible.
catalog-break-fragment-s-5033646313 = BREAK: __ocs_fmt_0__ fragmento(s).
catalog-breakatpoint-select-object-137ea03c41 = BREAKATPOINT Seleccione un objeto:
catalog-breakatpoint-specify-break-point-e352b5e6d2 = BREAKATPOINT Especifique el punto de ruptura:
catalog-back-b52b36b726 = Atrás
catalog-back-line-3298a89ba0 = Línea posterior
catalog-background-64dd60fe1a = Fondo
catalog-background-color-1a0371e373 = Color de fondo
catalog-background-mask-0ce6ad9115 = Máscara de fondo
catalog-background-reset-to-default-a637948b7a = Se restableció el fondo predeterminado.
catalog-background-transparency-7776075b37 = Transparencia del fondo
catalog-background-rgb-5e99de3743 = Fondo: rgb(__ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__)
catalog-backward-1f5bafe101 = Hacia atrás
catalog-backwards-95e848a0c7 = Hacia atrás
catalog-base-x-4353dc555d = X base
catalog-base-y-87538b4355 = Y base
catalog-base-z-5f5848e0c7 = Z base
catalog-base-point-set-for-space-004db7ea9c = Punto base (__ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__) definido para __ocs_fmt_3__.
catalog-base-point-x-7f45d2aae6 = X del punto base
catalog-base-point-y-5a29cad8fa = Y del punto base
catalog-base-point-z-2089628ed2 = Z del punto base
catalog-base-radius-3f03a89862 = Radio de la base
catalog-basepoint-034aee94d4 = Punto base
catalog-baseline-e6ab7982fa = Línea base
catalog-before-text-068662e1bd = Antes del texto
catalog-below-5ba07a31df = Debajo
catalog-best-fit-5f03a109b6 = Mejor ajuste
catalog-big-font-6ad88f2edb = Tipo de letra grande
catalog-big-font-7da1245560 = Tipo de letra grande:
catalog-blend-creases-94092eb7ec = Suavizar pliegues
catalog-block-82dd2cdf36 = Bloque
catalog-block-name-has-no-entities-24562fef57 = El bloque «__ocs_arg_name__» no tiene entidades.
catalog-block-name-not-found-85e9566b91 = No se encontró el bloque «__ocs_arg_name__».
catalog-block-name-produced-no-exportable-entities-f1f1e71ce4 = El bloque «__ocs_arg_name__» no produjo entidades exportables.
catalog-block-name-created-fe57377fd3 = Se creó el bloque «__ocs_fmt_0__».
catalog-block-orig-new-name-a628362e5d = Bloque «__ocs_fmt_0__» → «__ocs_fmt_1__»
catalog-block-content-6e0fc82b79 = Contenido del bloque
catalog-block-end-fc67a15be8 = Fin de bloque
catalog-block-reference-8e31379f57 = Referencia de bloque
catalog-block-color-04cda52b98 = Color del bloque
catalog-block-color-aci-b23bdc8b28 = Color del bloque (ACI):
catalog-block-connection-67ebca2316 = Conexión del bloque
catalog-block-connection-d3ab2b5450 = Conexión del bloque:
catalog-block-contents-3b8943f1fc = Contenido del bloque
catalog-block-rotation-d0e8ca3899 = Rotación del bloque
catalog-block-rotation-dd01908d3e = Rotación del bloque:
catalog-block-scale-4cb072f35b = Escala del bloque
catalog-block-scale-x-4f2937ce1d = Escala X del bloque:
catalog-block-scale-y-7f59f7f3bb = Escala Y del bloque:
catalog-block-scale-z-02ea455fb3 = Escala Z del bloque:
catalog-block-6d3566e517 = Bloque:
catalog-block-block-15d7d0ffb6 = Bloque: __ocs_arg_block__
catalog-blockend-e828a456c7 = Fin de bloque
catalog-blockextents-86672f15e0 = Extensión del bloque
catalog-blocks-54c45c033f = Bloques
catalog-blocks-insert-with-insert-name-ab85ea5100 = Bloques (__ocs_fmt_0__) — inserte con INSERT <nombre>: __ocs_fmt_1__
catalog-blocks-6186728619 = Bloques (__ocs_fmt_0__): __ocs_fmt_1__
catalog-blue-7d44bc449c = Azul
catalog-body-718a7e8a58 = Cuerpo
catalog-bold-19e07430ee = Negrita
catalog-book-f69f233005 = Libro
catalog-boolean-failed-the-solids-may-not-overlap-8a3181c2e7 = Falló la operación booleana; es posible que los sólidos no se solapen.
catalog-boolean-select-exactly-two-solids-created-this-976fe80f2e = Operación booleana: seleccione exactamente dos sólidos creados en esta sesión.

View file

@ -7,6 +7,10 @@ language-german = Deutsch
language-hindi = हिन्दी
language-russian = Русский
language-chinese-simplified = 简体中文
language-spanish = Español
language-portuguese = Português (Brasil)
language-arabic = العربية
language-japanese = 日本語
action-close = Fermer
action-options = Options
@ -3385,3 +3389,55 @@ style-manager-center-of-text = Centre du texte
style-manager-center-of-text-overline = Centre du texte avec surlignement
style-manager-block-extents = Limites du bloc
style-manager-base-point = Point de base
area-option-objects = Objets
area-option-back = Retour
area-option-add = Ajouter une aire
area-option-subtract = Soustraire une aire
area-prompt-first = AREA Indiquez le premier sommet ou [Objets/Ajouter une aire/Soustraire une aire] <Objets> :
area-prompt-add = AREA AJOUTER Indiquez le premier sommet ou [Objets/Soustraire une aire] <Objets> :
area-prompt-subtract = AREA SOUSTRAIRE Indiquez le premier sommet ou [Objets/Ajouter une aire] <Objets> :
area-prompt-next = AREA Indiquez le point suivant ({ $count } sélectionné(s), Entrée pour calculer) :
area-prompt-objects = AREA Sélectionnez les objets ({ $count } sélectionné(s), Entrée pour calculer) :
area-objects-not-measurable = AREA : aucun des objets sélectionnés ne possède d'aire mesurable.
area-result = Aire = { $area }, Périmètre = { $perimeter }
area-result-area-only = Aire = { $area }
area-running-result =
Aire = { $area }, Périmètre = { $perimeter }
Aire totale = { $total_area }, Périmètre total = { $total_perimeter }
area-running-result-area-only =
Aire = { $area }
Aire totale = { $total_area }
modal-recovery-report = Rapport de récupération du dessin
modal-recovery-prompt = Récupération du dessin
recovery-opened-with-repairs = Dessin ouvert avec un rapport de récupération
recovery-open-failed = Impossible d'ouvrir le dessin
recovery-repaired-description = Des problèmes ont été détectés pendant l'ouverture du dessin. Consultez le rapport ; si des données ont été réparées, enregistrez le dessin dans un nouveau fichier.
recovery-failed-description = La récupération n'a pas produit de données de dessin utilisables. Consultez le rapport et essayez une sauvegarde ou une copie d'enregistrement automatique.
recovery-entities-checked = Entités vérifiées
recovery-issues-found = Problèmes détectés
recovery-entities-removed = Entités supprimées
recovery-references-checked = Références vérifiées
recovery-referenced-entities-removed = Entités référencées supprimées
recovery-references-unavailable = Références indisponibles
recovery-log-path = Journal de récupération
recovery-log-write-failed = Impossible d'écrire le journal de récupération
recovery-log-download-ready = Le rapport de récupération est prêt à être téléchargé.
recovery-save-copy = Enregistrer la copie réparée
recovery-show-log = Afficher le journal de récupération
recovery-save-new-file-required = Un dessin réparé doit être enregistré dans un nouveau fichier.
recovery-prompt-heading = L'ouverture normale a échoué
recovery-prompt-description = Le dessin n'a pas été modifié. Le mode de récupération peut tenter de sauver les données utilisables, mais les enregistrements endommagés peuvent être ignorés. Voulez-vous continuer ?
recovery-attempt = Tenter la récupération
recovery-decline = Annuler
catalog-tcount-select-text-objects-then-press-enter-9b1ee71b4d = TCOUNT Sélectionnez les objets texte, puis appuyez sur Entrée :
catalog-tcount-starting-number-start-7471e47711 = TCOUNT Numéro de départ <__ocs_fmt_0__> :
catalog-tcount-increment-increment-79f9ab6108 = TCOUNT Incrément <__ocs_fmt_0__> :
catalog-tcount-placement-overwrite-prefix-suffix-overw-9214874a72 = TCOUNT Position [Remplacer/Préfixe/Suffixe] <Remplacer> :
catalog-tcount-placement-must-be-overwrite-prefix-or-s-84211d2b75 = TCOUNT : la position doit être Remplacer, Préfixe ou Suffixe.
catalog-tcount-numbered-n-text-object-s-from-start-by-30588884cc = TCOUNT : __ocs_fmt_0__ objet(s) texte numéroté(s) à partir de __ocs_fmt_1__ par pas de __ocs_fmt_2__ (__ocs_fmt_3__).
catalog-prefix-90eceb016c = Préfixe
catalog-suffix-885db975a3 = Suffixe
catalog-opening-a-drawing-by-path-is-not-available-in-be27bd71d4 = L'ouverture d'un dessin par son chemin n'est pas disponible dans le navigateur.

View file

@ -7,6 +7,10 @@ language-german = Deutsch
language-hindi = हिन्दी
language-russian = Русский
language-chinese-simplified = 简体中文
language-spanish = Español
language-portuguese = Português (Brasil)
language-arabic = العربية
language-japanese = 日本語
action-close = बंद करें
action-options = विकल्प
@ -3374,3 +3378,55 @@ style-manager-center-of-text = टेक्स्ट का केंद्र
style-manager-center-of-text-overline = ऊपरी रेखा सहित टेक्स्ट का केंद्र
style-manager-block-extents = ब्लॉक परिमिति
style-manager-base-point = आधार बिंदु
area-option-objects = वस्तुएँ
area-option-back = वापस
area-option-add = क्षेत्रफल जोड़ें
area-option-subtract = क्षेत्रफल घटाएँ
area-prompt-first = AREA पहला कोना बिंदु निर्दिष्ट करें या [वस्तुएँ/क्षेत्रफल जोड़ें/क्षेत्रफल घटाएँ] <वस्तुएँ>:
area-prompt-add = AREA जोड़ें पहला कोना बिंदु निर्दिष्ट करें या [वस्तुएँ/क्षेत्रफल घटाएँ] <वस्तुएँ>:
area-prompt-subtract = AREA घटाएँ पहला कोना बिंदु निर्दिष्ट करें या [वस्तुएँ/क्षेत्रफल जोड़ें] <वस्तुएँ>:
area-prompt-next = AREA अगला बिंदु निर्दिष्ट करें ({ $count } चुने गए, गणना के लिए Enter):
area-prompt-objects = AREA वस्तुएँ चुनें ({ $count } चयनित, गणना के लिए Enter):
area-objects-not-measurable = AREA: चुनी गई किसी भी वस्तु का मापने योग्य क्षेत्रफल नहीं है।
area-result = क्षेत्रफल = { $area }, परिमाप = { $perimeter }
area-result-area-only = क्षेत्रफल = { $area }
area-running-result =
क्षेत्रफल = { $area }, परिमाप = { $perimeter }
कुल क्षेत्रफल = { $total_area }, कुल परिमाप = { $total_perimeter }
area-running-result-area-only =
क्षेत्रफल = { $area }
कुल क्षेत्रफल = { $total_area }
modal-recovery-report = ड्रॉइंग पुनर्प्राप्ति रिपोर्ट
modal-recovery-prompt = ड्रॉइंग पुनर्प्राप्ति
recovery-opened-with-repairs = ड्रॉइंग पुनर्प्राप्ति रिपोर्ट के साथ खोली गई
recovery-open-failed = ड्रॉइंग खोली नहीं जा सकी
recovery-repaired-description = ड्रॉइंग खोलते समय समस्याएँ मिलीं। रिपोर्ट की समीक्षा करें; यदि ड्रॉइंग डेटा सुधारा गया है, तो उसे नई फ़ाइल के रूप में सहेजें।
recovery-failed-description = पुनर्प्राप्ति उपयोग योग्य ड्रॉइंग डेटा नहीं बना सकी। रिपोर्ट की समीक्षा करें और बैकअप या स्वतः-सहेजी गई प्रति आज़माएँ।
recovery-entities-checked = जाँची गई एंटिटियाँ
recovery-issues-found = मिली समस्याएँ
recovery-entities-removed = हटाई गई एंटिटियाँ
recovery-references-checked = जाँचे गए संदर्भ
recovery-referenced-entities-removed = हटाई गई संदर्भित एंटिटियाँ
recovery-references-unavailable = अनुपलब्ध संदर्भ
recovery-log-path = पुनर्प्राप्ति लॉग
recovery-log-write-failed = पुनर्प्राप्ति लॉग लिखा नहीं जा सका
recovery-log-download-ready = पुनर्प्राप्ति रिपोर्ट डाउनलोड के लिए तैयार है।
recovery-save-copy = सुधारी गई प्रति सहेजें
recovery-show-log = पुनर्प्राप्ति लॉग दिखाएँ
recovery-save-new-file-required = सुधारी गई ड्रॉइंग को नई फ़ाइल में सहेजना आवश्यक है।
recovery-prompt-heading = सामान्य तरीके से खोलना विफल रहा
recovery-prompt-description = ड्रॉइंग बदली नहीं गई है। पुनर्प्राप्ति मोड उपयोग योग्य डेटा बचाने का प्रयास कर सकता है, लेकिन क्षतिग्रस्त रिकॉर्ड छोड़े जा सकते हैं। क्या आप जारी रखना चाहते हैं?
recovery-attempt = पुनर्प्राप्ति आज़माएँ
recovery-decline = रद्द करें
catalog-tcount-select-text-objects-then-press-enter-9b1ee71b4d = TCOUNT टेक्स्ट वस्तुएँ चुनें, फिर Enter दबाएँ:
catalog-tcount-starting-number-start-7471e47711 = TCOUNT आरंभिक संख्या <__ocs_fmt_0__>:
catalog-tcount-increment-increment-79f9ab6108 = TCOUNT वृद्धि <__ocs_fmt_0__>:
catalog-tcount-placement-overwrite-prefix-suffix-overw-9214874a72 = TCOUNT स्थान [अधिलेखित/उपसर्ग/प्रत्यय] <अधिलेखित>:
catalog-tcount-placement-must-be-overwrite-prefix-or-s-84211d2b75 = TCOUNT: स्थान अधिलेखित, उपसर्ग या प्रत्यय होना चाहिए।
catalog-tcount-numbered-n-text-object-s-from-start-by-30588884cc = TCOUNT: __ocs_fmt_0__ टेक्स्ट वस्तु(ओं) को __ocs_fmt_1__ से आरंभ करके __ocs_fmt_2__ की वृद्धि से क्रमांकित किया गया (__ocs_fmt_3__)।
catalog-prefix-90eceb016c = उपसर्ग
catalog-suffix-885db975a3 = प्रत्यय
catalog-opening-a-drawing-by-path-is-not-available-in-be27bd71d4 = ब्राउज़र में पथ द्वारा ड्रॉइंग खोलना उपलब्ध नहीं है।

View file

@ -0,0 +1,160 @@
language-system = システムの言語
language-english = English
language-turkish = Türkçe
language-dutch = Nederlands
language-french = Français
language-german = Deutsch
language-hindi = हिन्दी
language-russian = Русский
language-chinese-simplified = 简体中文
language-spanish = Español
language-portuguese = Português (Brasil)
language-arabic = العربية
language-japanese = 日本語
action-close = 閉じる
action-options = オプション
action-plugins = プラグイン
ribbon-tab-draw = 作図
ribbon-tab-annotate = 注釈
ribbon-tab-insert = 挿入
ribbon-tab-model = モデル
ribbon-tab-layout = レイアウト
ribbon-tab-manage = 管理
ribbon-tab-view = 表示
options-language-section = 言語
options-language-label = インターフェイスの言語:
options-open-save-section = 開く/保存
options-default-save-format-label = 既定の保存形式:
options-default-save-format-help = 新しい図面を初めて保存するときに使用します。既存の図面はファイルの種類とバージョンを維持します。
options-theme-section = テーマ
options-theme-label = アプリケーションのテーマ:
options-theme-help = 基本色を変更するとカスタムテーマに切り替わります。すべての部品の色調は、この6色から生成されます。
options-color-background = 背景
options-color-text = テキスト
options-color-primary = プライマリ
options-color-success = 成功
options-color-warning = 警告
options-color-danger = 危険
command-line-ready = Open CAD Studio の準備ができました。
command-line-hint = コマンドを入力するか、リボンを使用してください。OBJ は[挿入]タブから開けます。
command-line-label = コマンド:
command-line-literal-spaces = リテラルスペース: Space キーでコマンドを実行せず、行内に空白を入力します。オフにするまで有効です。
area-option-objects = オブジェクト
area-option-back = 戻る
area-option-add = 面積を加算
area-option-subtract = 面積を減算
area-prompt-first = AREA 最初のコーナー点を指定 または [オブジェクト/面積を加算/面積を減算] <オブジェクト>:
area-prompt-add = AREA 加算 最初のコーナー点を指定 または [オブジェクト/面積を減算] <オブジェクト>:
area-prompt-subtract = AREA 減算 最初のコーナー点を指定 または [オブジェクト/面積を加算] <オブジェクト>:
area-prompt-next = AREA 次の点を指定 ({ $count } 点選択済み、Enter で計算):
area-prompt-objects = AREA オブジェクトを選択 ({ $count } 個選択済み、Enter で計算):
area-objects-not-measurable = AREA: 選択したオブジェクトに計測可能な面積がありません。
area-result = 面積 = { $area }、周長 = { $perimeter }
area-result-area-only = 面積 = { $area }
area-running-result =
面積 = { $area }、周長 = { $perimeter }
合計面積 = { $total_area }、合計周長 = { $total_perimeter }
area-running-result-area-only =
面積 = { $area }
合計面積 = { $total_area }
start-new-drawing = 新しい図面
start-open-file = ファイルを開く…
start-donate = 寄付
start-send-feedback = フィードバックを送信
start-sponsors = スポンサー
start-tutorials = チュートリアル
start-loading-videos = 動画を読み込んでいます…
start-videos-online = 動画はインターネットから読み込まれます。
start-open-playlist = YouTube で再生リストを開く
start-discussions = ディスカッション
start-pinned = 固定済み
start-loading-discussions = ディスカッションを読み込んでいます…
start-discussions-online = ディスカッションは GitHub から読み込まれます。
start-open-discussions = GitHub でディスカッションを開く
start-supporters = サポーター
start-support-on-patreon = Patreon で支援
start-recent-files = 最近使ったファイル
start-videos = 動画
start-welcome = ようこそ
start-recent-documents = 最近使ったドキュメント
start-no-recent-files = 開いたファイルがここに表示されます。
start-browser-storage = ブラウザーストレージ
start-keep-recent-files = 最近使ったファイルを保持
modal-about = バージョン情報
modal-keyboard-shortcuts = キーボードショートカット
modal-command-aliases = コマンドエイリアス
modal-find-replace = 検索と置換
modal-plugin-manager = プラグインマネージャー
modal-update-available = 更新があります
modal-layer-manager = レイヤーマネージャー
modal-layer-state-manager = レイヤー状態マネージャー
modal-edit-layer-state = レイヤー状態を編集
modal-plot = 印刷
modal-layout-manager = レイアウトマネージャー
modal-scale-manager = 尺度マネージャー
modal-annotation-object-scale = 異尺度対応オブジェクトの尺度
modal-plot-style-editor = 印刷スタイルエディター
modal-text-style-manager = 文字スタイルマネージャー
modal-multiline-style-manager = マルチラインスタイルマネージャー
modal-table-style-manager = 表スタイルマネージャー
modal-multileader-style-manager = マルチ引出線スタイルマネージャー
modal-dimension-style-manager = 寸法スタイルマネージャー
modal-default-application = 既定のアプリケーション
modal-save-warning = 保存の警告
modal-unable-save = 図面を保存できません
modal-drawing-changed = ディスク上の図面が変更されました
modal-delete-layer = レイヤーを削除
modal-unsaved-changes = 保存されていない変更
modal-unsaved-save-prompt = 変更を保存しますか?
clipboard-no-supported-content = クリップボードに対応するテキストまたは図面オブジェクトがありません。
clipboard-unavailable = システムのクリップボードを使用できません。
clipboard-occupied = システムのクリップボードは現在使用中です。もう一度お試しください。
clipboard-conversion-failed = クリップボードのテキストを読み取れませんでした。
viewport-split-vertical = ビューポートを縦に分割
viewport-split-horizontal = ビューポートを横に分割
viewport-move = ビューポートを移動
viewport-close = ビューポートを閉じる
modal-point-style = 点スタイル
modal-attribute-editor = 属性エディター
modal-save-drawing-as = 図面に名前を付けて保存
modal-recovery-report = 図面修復レポート
modal-recovery-prompt = 図面の修復
recovery-opened-with-repairs = 修復レポートとともに図面を開きました
recovery-open-failed = 図面を開けませんでした
recovery-repaired-description = 図面を開くときに問題が検出されました。レポートを確認し、図面データが修復された場合は新しいファイルとして保存してください。
recovery-failed-description = 修復処理で使用可能な図面データを生成できませんでした。レポートを確認し、バックアップまたは自動保存コピーをお試しください。
recovery-entities-checked = 確認したエンティティ
recovery-issues-found = 検出した問題
recovery-entities-removed = 削除したエンティティ
recovery-references-checked = 確認した参照
recovery-referenced-entities-removed = 削除した参照エンティティ
recovery-references-unavailable = 使用できない参照
recovery-log-path = 修復ログ
recovery-log-write-failed = 修復ログを書き込めませんでした
recovery-log-download-ready = 修復レポートをダウンロードできます。
recovery-save-copy = 修復済みコピーを保存
recovery-show-log = 修復ログを表示
recovery-save-new-file-required = 修復した図面は新しいファイルに保存する必要があります。
recovery-prompt-heading = 通常の方法で開けませんでした
recovery-prompt-description = 図面は変更されていません。修復モードで使用可能なデータを救出できますが、破損したレコードは省略される場合があります。続行しますか?
recovery-attempt = 修復を試行
recovery-decline = キャンセル
command-move-base =
MOVE 基点を指定 [{ $count } 個のオブジェクト]:
command-move-target = MOVE 移動先を指定 [基点 { $x },{ $y }]:
command-copy-array-count = COPY 配列複写する項目数を入力:
command-copy-base =
COPY 基点を指定 [{ $count } 個のオブジェクト]:
command-copy-array-target = COPY { $count } 項目の配列複写の2点目を指定 [基点 { $x },{ $y }]:
command-copy-target = COPY 移動先を指定 [これまでに { $count } 個複写 | 配列 | Enter=完了 | 基点 { $x },{ $y }]:
# 完全なアプリケーションカタログ

View file

@ -7,6 +7,10 @@ language-german = Deutsch
language-hindi = हिन्दी
language-russian = Русский
language-chinese-simplified = 简体中文
language-spanish = Español
language-portuguese = Português (Brasil)
language-arabic = العربية
language-japanese = 日本語
action-close = Sluiten
action-options = Opties
@ -3386,3 +3390,55 @@ style-manager-center-of-text = Tekstmiddelpunt
style-manager-center-of-text-overline = Tekstmiddelpunt met bovenstreep
style-manager-block-extents = Blokgrenzen
style-manager-base-point = Basispunt
area-option-objects = Objecten
area-option-back = Terug
area-option-add = Oppervlakte optellen
area-option-subtract = Oppervlakte aftrekken
area-prompt-first = AREA Geef het eerste hoekpunt op of [Objecten/Oppervlakte optellen/Oppervlakte aftrekken] <Objecten>:
area-prompt-add = AREA OPTELLEN Geef het eerste hoekpunt op of [Objecten/Oppervlakte aftrekken] <Objecten>:
area-prompt-subtract = AREA AFTREKKEN Geef het eerste hoekpunt op of [Objecten/Oppervlakte optellen] <Objecten>:
area-prompt-next = AREA Geef het volgende punt op ({ $count } gekozen, Enter om te berekenen):
area-prompt-objects = AREA Selecteer objecten ({ $count } geselecteerd, Enter om te berekenen):
area-objects-not-measurable = AREA: geen van de geselecteerde objecten heeft een meetbare oppervlakte.
area-result = Oppervlakte = { $area }, Omtrek = { $perimeter }
area-result-area-only = Oppervlakte = { $area }
area-running-result =
Oppervlakte = { $area }, Omtrek = { $perimeter }
Totale oppervlakte = { $total_area }, Totale omtrek = { $total_perimeter }
area-running-result-area-only =
Oppervlakte = { $area }
Totale oppervlakte = { $total_area }
modal-recovery-report = Tekeningherstelrapport
modal-recovery-prompt = Tekeningherstel
recovery-opened-with-repairs = Tekening geopend met een herstelrapport
recovery-open-failed = Tekening kon niet worden geopend
recovery-repaired-description = Tijdens het openen van de tekening zijn problemen gevonden. Controleer het rapport; sla de tekening als een nieuw bestand op als gegevens zijn hersteld.
recovery-failed-description = Het herstel kon geen bruikbare tekeninggegevens opleveren. Controleer het rapport en probeer een reservekopie of automatisch opgeslagen kopie.
recovery-entities-checked = Gecontroleerde objecten
recovery-issues-found = Gevonden problemen
recovery-entities-removed = Verwijderde objecten
recovery-references-checked = Gecontroleerde verwijzingen
recovery-referenced-entities-removed = Verwijderde objecten waarnaar werd verwezen
recovery-references-unavailable = Niet-beschikbare verwijzingen
recovery-log-path = Herstellogboek
recovery-log-write-failed = Het herstellogboek kon niet worden geschreven
recovery-log-download-ready = Het herstelrapport kan worden gedownload.
recovery-save-copy = Herstelde kopie opslaan
recovery-show-log = Herstellogboek tonen
recovery-save-new-file-required = Een herstelde tekening moet als een nieuw bestand worden opgeslagen.
recovery-prompt-heading = Normaal openen is mislukt
recovery-prompt-description = De tekening is niet gewijzigd. De herstelmodus kan proberen bruikbare gegevens te redden, maar beschadigde records kunnen worden overgeslagen. Wilt u doorgaan?
recovery-attempt = Herstel proberen
recovery-decline = Annuleren
catalog-tcount-select-text-objects-then-press-enter-9b1ee71b4d = TCOUNT Selecteer tekstobjecten en druk daarna op Enter:
catalog-tcount-starting-number-start-7471e47711 = TCOUNT Beginnummer <__ocs_fmt_0__>:
catalog-tcount-increment-increment-79f9ab6108 = TCOUNT Stapgrootte <__ocs_fmt_0__>:
catalog-tcount-placement-overwrite-prefix-suffix-overw-9214874a72 = TCOUNT Plaatsing [Overschrijven/Voorvoegsel/Achtervoegsel] <Overschrijven>:
catalog-tcount-placement-must-be-overwrite-prefix-or-s-84211d2b75 = TCOUNT: plaatsing moet Overschrijven, Voorvoegsel of Achtervoegsel zijn.
catalog-tcount-numbered-n-text-object-s-from-start-by-30588884cc = TCOUNT: __ocs_fmt_0__ tekstobject(en) genummerd vanaf __ocs_fmt_1__ met stappen van __ocs_fmt_2__ (__ocs_fmt_3__).
catalog-prefix-90eceb016c = Voorvoegsel
catalog-suffix-885db975a3 = Achtervoegsel
catalog-opening-a-drawing-by-path-is-not-available-in-be27bd71d4 = Een tekening openen via een pad is niet beschikbaar in de browser.

View file

@ -0,0 +1,170 @@
language-system = Idioma do sistema
language-english = English
language-turkish = Türkçe
language-dutch = Nederlands
language-french = Français
language-german = Deutsch
language-hindi = हिन्दी
language-russian = Русский
language-chinese-simplified = 简体中文
language-spanish = Español
language-portuguese = Português (Brasil)
language-arabic = العربية
language-japanese = 日本語
action-close = Fechar
action-options = Opções
action-plugins = Plugins
ribbon-tab-draw = Desenhar
ribbon-tab-annotate = Anotar
ribbon-tab-insert = Inserir
ribbon-tab-model = Modelo
ribbon-tab-layout = Layout
ribbon-tab-manage = Gerenciar
ribbon-tab-view = Exibir
options-language-section = Idioma
options-language-label = Idioma da interface:
options-open-save-section = Abrir e salvar
options-default-save-format-label = Formato padrão ao salvar:
options-default-save-format-help = Usado ao salvar um desenho novo pela primeira vez. Os desenhos existentes mantêm o tipo e a versão do arquivo.
options-theme-section = Tema
options-theme-label = Tema do aplicativo:
options-theme-help = Alterar uma cor base muda para o tema Personalizado. O tema gera todos os tons dos componentes a partir dessas seis cores.
options-color-background = Fundo
options-color-text = Texto
options-color-primary = Principal
options-color-success = Sucesso
options-color-warning = Aviso
options-color-danger = Perigo
command-line-ready = Open CAD Studio pronto.
command-line-hint = Digite um comando ou use a faixa de opções. Abra OBJ pela guia Inserir.
command-line-label = Comando:
command-line-literal-spaces = Espaços literais: o espaço permanece na linha em vez de executar o comando. Continua ativo até ser desativado.
area-option-objects = Objetos
area-option-back = Voltar
area-option-add = Adicionar área
area-option-subtract = Subtrair área
area-prompt-first = AREA Especifique o primeiro canto ou [Objetos/Adicionar área/Subtrair área] <Objetos>:
area-prompt-add = AREA ADICIONAR Especifique o primeiro canto ou [Objetos/Subtrair área] <Objetos>:
area-prompt-subtract = AREA SUBTRAIR Especifique o primeiro canto ou [Objetos/Adicionar área] <Objetos>:
area-prompt-next = AREA Especifique o próximo ponto ({ $count } selecionados, Enter para calcular):
area-prompt-objects = AREA Selecione objetos ({ $count } selecionados, Enter para calcular):
area-objects-not-measurable = AREA: nenhum dos objetos selecionados possui área mensurável.
area-result = Área = { $area }, Perímetro = { $perimeter }
area-result-area-only = Área = { $area }
area-running-result =
Área = { $area }, Perímetro = { $perimeter }
Área total = { $total_area }, Perímetro total = { $total_perimeter }
area-running-result-area-only =
Área = { $area }
Área total = { $total_area }
start-new-drawing = Novo desenho
start-open-file = Abrir arquivo…
start-donate = Doar
start-send-feedback = Enviar feedback
start-sponsors = Patrocinadores
start-tutorials = Tutoriais
start-loading-videos = Carregando vídeos…
start-videos-online = Os vídeos são carregados da internet.
start-open-playlist = Abrir playlist no YouTube
start-discussions = Discussões
start-pinned = Fixados
start-loading-discussions = Carregando discussões…
start-discussions-online = As discussões são carregadas do GitHub.
start-open-discussions = Abrir Discussões no GitHub
start-supporters = Apoiadores
start-support-on-patreon = Apoiar no Patreon
start-recent-files = Arquivos recentes
start-videos = Vídeos
start-welcome = Bem-vindo
start-recent-documents = Documentos recentes
start-no-recent-files = Os arquivos que você abrir aparecerão aqui.
start-browser-storage = Armazenamento do navegador
start-keep-recent-files = Manter arquivos recentes
modal-about = Sobre
modal-keyboard-shortcuts = Atalhos de teclado
modal-command-aliases = Aliases de comando
modal-find-replace = Localizar e substituir
modal-plugin-manager = Gerenciador de plugins
modal-update-available = Atualização disponível
modal-layer-manager = Gerenciador de camadas
modal-layer-state-manager = Gerenciador de estados de camada
modal-edit-layer-state = Editar estado da camada
modal-plot = Plotar
modal-layout-manager = Gerenciador de layouts
modal-scale-manager = Gerenciador de escalas
modal-annotation-object-scale = Escala do objeto anotativo
modal-plot-style-editor = Editor de estilos de plotagem
modal-text-style-manager = Gerenciador de estilos de texto
modal-multiline-style-manager = Gerenciador de estilos de multilinha
modal-table-style-manager = Gerenciador de estilos de tabela
modal-multileader-style-manager = Gerenciador de estilos de multichamada
modal-dimension-style-manager = Gerenciador de estilos de cota
modal-default-application = Aplicativo padrão
modal-save-warning = Aviso ao salvar
modal-unable-save = Não foi possível salvar o desenho
modal-drawing-changed = Desenho alterado no disco
modal-delete-layer = Excluir camada
modal-unsaved-changes = Alterações não salvas
modal-unsaved-save-prompt = Deseja salvar as alterações?
clipboard-no-supported-content = A área de transferência não contém texto nem objetos de desenho compatíveis.
clipboard-unavailable = A área de transferência do sistema não está disponível.
clipboard-occupied = A área de transferência do sistema está em uso. Tente novamente.
clipboard-conversion-failed = Não foi possível ler o texto da área de transferência.
viewport-split-vertical = Dividir viewport verticalmente
viewport-split-horizontal = Dividir viewport horizontalmente
viewport-move = Mover viewport
viewport-close = Fechar viewport
modal-point-style = Estilo de ponto
modal-attribute-editor = Editor de atributos
modal-save-drawing-as = Salvar desenho como
modal-recovery-report = Relatório de recuperação do desenho
modal-recovery-prompt = Recuperação do desenho
recovery-opened-with-repairs = Desenho aberto com um relatório de recuperação
recovery-open-failed = Não foi possível abrir o desenho
recovery-repaired-description = Foram detectados problemas ao abrir o desenho. Revise o relatório; se os dados foram reparados, salve o desenho como um novo arquivo.
recovery-failed-description = A recuperação não conseguiu produzir dados de desenho utilizáveis. Revise o relatório e tente um backup ou uma cópia de salvamento automático.
recovery-entities-checked = Entidades verificadas
recovery-issues-found = Problemas encontrados
recovery-entities-removed = Entidades removidas
recovery-references-checked = Referências verificadas
recovery-referenced-entities-removed = Entidades referenciadas removidas
recovery-references-unavailable = Referências indisponíveis
recovery-log-path = Log de recuperação
recovery-log-write-failed = Não foi possível gravar o log de recuperação
recovery-log-download-ready = O relatório de recuperação está pronto para download.
recovery-save-copy = Salvar cópia reparada
recovery-show-log = Mostrar log de recuperação
recovery-save-new-file-required = Um desenho reparado deve ser salvo em um novo arquivo.
recovery-prompt-heading = Falha na abertura normal
recovery-prompt-description = O desenho não foi alterado. O modo de recuperação pode tentar resgatar dados utilizáveis, mas registros danificados podem ser ignorados. Deseja continuar?
recovery-attempt = Tentar recuperar
recovery-decline = Cancelar
command-move-base =
MOVE Especifique o ponto base [{ $count ->
[one] { $count } objeto
*[other] { $count } objetos
}]:
command-move-target = MOVE Especifique o destino [base { $x },{ $y }]:
command-copy-array-count = COPY Informe a quantidade de itens da matriz:
command-copy-base =
COPY Especifique o ponto base [{ $count ->
[one] { $count } objeto
*[other] { $count } objetos
}]:
command-copy-array-target = COPY Especifique o segundo ponto da matriz de { $count } itens [base { $x },{ $y }]:
command-copy-target =
COPY Especifique o destino [{ $count ->
[one] { $count } cópia até agora
*[other] { $count } cópias até agora
} | Matriz | Enter=concluir | base { $x },{ $y }]:
# Catálogo completo do aplicativo

View file

@ -7,6 +7,10 @@ language-german = Deutsch
language-hindi = हिन्दी
language-russian = Русский
language-chinese-simplified = 简体中文
language-spanish = Español
language-portuguese = Português (Brasil)
language-arabic = العربية
language-japanese = 日本語
action-close = Замкнуть
action-options = Параметры
@ -3389,3 +3393,55 @@ style-manager-center-of-text = Центр текста
style-manager-center-of-text-overline = Центр текста с надчёркиванием
style-manager-block-extents = Габариты блока
style-manager-base-point = Базовая точка
area-option-objects = Объекты
area-option-back = Назад
area-option-add = Добавить площадь
area-option-subtract = Вычесть площадь
area-prompt-first = AREA Укажите первую угловую точку или [Объекты/Добавить площадь/Вычесть площадь] <Объекты>:
area-prompt-add = AREA ДОБАВИТЬ Укажите первую угловую точку или [Объекты/Вычесть площадь] <Объекты>:
area-prompt-subtract = AREA ВЫЧЕСТЬ Укажите первую угловую точку или [Объекты/Добавить площадь] <Объекты>:
area-prompt-next = AREA Укажите следующую точку (выбрано: { $count }, Enter для вычисления):
area-prompt-objects = AREA Выберите объекты (выбрано: { $count }, Enter для вычисления):
area-objects-not-measurable = AREA: ни один из выбранных объектов не имеет измеряемой площади.
area-result = Площадь = { $area }, Периметр = { $perimeter }
area-result-area-only = Площадь = { $area }
area-running-result =
Площадь = { $area }, Периметр = { $perimeter }
Общая площадь = { $total_area }, Общий периметр = { $total_perimeter }
area-running-result-area-only =
Площадь = { $area }
Общая площадь = { $total_area }
modal-recovery-report = Отчёт о восстановлении чертежа
modal-recovery-prompt = Восстановление чертежа
recovery-opened-with-repairs = Чертёж открыт с отчётом о восстановлении
recovery-open-failed = Не удалось открыть чертёж
recovery-repaired-description = При открытии чертежа обнаружены проблемы. Просмотрите отчёт; если данные чертежа были исправлены, сохраните его в новый файл.
recovery-failed-description = Восстановить пригодные данные чертежа не удалось. Просмотрите отчёт и попробуйте резервную или автоматически сохранённую копию.
recovery-entities-checked = Проверено объектов
recovery-issues-found = Обнаружено проблем
recovery-entities-removed = Удалено объектов
recovery-references-checked = Проверено ссылок
recovery-referenced-entities-removed = Удалено ссылочных объектов
recovery-references-unavailable = Недоступные ссылки
recovery-log-path = Журнал восстановления
recovery-log-write-failed = Не удалось записать журнал восстановления
recovery-log-download-ready = Отчёт о восстановлении готов к загрузке.
recovery-save-copy = Сохранить исправленную копию
recovery-show-log = Показать журнал восстановления
recovery-save-new-file-required = Исправленный чертёж необходимо сохранить в новый файл.
recovery-prompt-heading = Обычное открытие не удалось
recovery-prompt-description = Чертёж не был изменён. Режим восстановления может попытаться извлечь пригодные данные, однако повреждённые записи могут быть пропущены. Продолжить?
recovery-attempt = Попробовать восстановить
recovery-decline = Отмена
catalog-tcount-select-text-objects-then-press-enter-9b1ee71b4d = TCOUNT Выберите текстовые объекты и нажмите Enter:
catalog-tcount-starting-number-start-7471e47711 = TCOUNT Начальный номер <__ocs_fmt_0__>:
catalog-tcount-increment-increment-79f9ab6108 = TCOUNT Шаг <__ocs_fmt_0__>:
catalog-tcount-placement-overwrite-prefix-suffix-overw-9214874a72 = TCOUNT Размещение [Заменить/Префикс/Суффикс] <Заменить>:
catalog-tcount-placement-must-be-overwrite-prefix-or-s-84211d2b75 = TCOUNT: размещение должно быть «Заменить», «Префикс» или «Суффикс».
catalog-tcount-numbered-n-text-object-s-from-start-by-30588884cc = TCOUNT: пронумеровано текстовых объектов: __ocs_fmt_0__; начало: __ocs_fmt_1__; шаг: __ocs_fmt_2__ (__ocs_fmt_3__).
catalog-prefix-90eceb016c = Префикс
catalog-suffix-885db975a3 = Суффикс
catalog-opening-a-drawing-by-path-is-not-available-in-be27bd71d4 = Открытие чертежа по пути недоступно в браузере.

View file

@ -7,6 +7,10 @@ language-german = Deutsch
language-hindi = हिन्दी
language-russian = Русский
language-chinese-simplified = 简体中文
language-spanish = Español
language-portuguese = Português (Brasil)
language-arabic = العربية
language-japanese = 日本語
action-close = Kapat
action-options = Seçenekler
@ -3351,3 +3355,13 @@ style-manager-center-of-text = Metnin merkezi
style-manager-center-of-text-overline = Üst çizgili metin merkezi
style-manager-block-extents = Blok sınırları
style-manager-base-point = Taban noktası
catalog-tcount-select-text-objects-then-press-enter-9b1ee71b4d = TCOUNT Metin nesnelerini seçip Enter'a basın:
catalog-tcount-starting-number-start-7471e47711 = TCOUNT Başlangıç numarası <__ocs_fmt_0__>:
catalog-tcount-increment-increment-79f9ab6108 = TCOUNT Artış miktarı <__ocs_fmt_0__>:
catalog-tcount-placement-overwrite-prefix-suffix-overw-9214874a72 = TCOUNT Yerleştirme [Üzerine yaz/Önek/Sonek] <Üzerine yaz>:
catalog-tcount-placement-must-be-overwrite-prefix-or-s-84211d2b75 = TCOUNT: Yerleştirme Üzerine yaz, Önek veya Sonek olmalıdır.
catalog-tcount-numbered-n-text-object-s-from-start-by-30588884cc = TCOUNT: __ocs_fmt_0__ metin nesnesi, __ocs_fmt_1__ değerinden başlayıp __ocs_fmt_2__ artışla numaralandırıldı (__ocs_fmt_3__).
catalog-prefix-90eceb016c = Önek
catalog-suffix-885db975a3 = Sonek
catalog-opening-a-drawing-by-path-is-not-available-in-be27bd71d4 = Tarayıcıda dosya yoluyla çizim açılamaz.

View file

@ -7,6 +7,10 @@ language-german = Deutsch
language-hindi = हिन्दी
language-russian = Русский
language-chinese-simplified = 简体中文
language-spanish = Español
language-portuguese = Português (Brasil)
language-arabic = العربية
language-japanese = 日本語
action-close = 关闭
action-options = 选项
@ -3371,3 +3375,55 @@ style-manager-center-of-text = 文字中心
style-manager-center-of-text-overline = 带上划线的文字中心
style-manager-block-extents = 块范围
style-manager-base-point = 基点
area-option-objects = 对象
area-option-back = 返回
area-option-add = 添加面积
area-option-subtract = 减去面积
area-prompt-first = AREA 指定第一个角点或 [对象/添加面积/减去面积] <对象>:
area-prompt-add = AREA 添加 指定第一个角点或 [对象/减去面积] <对象>:
area-prompt-subtract = AREA 减去 指定第一个角点或 [对象/添加面积] <对象>:
area-prompt-next = AREA 指定下一点(已选 { $count } 个,按 Enter 计算):
area-prompt-objects = AREA 选择对象(已选 { $count } 个,按 Enter 计算):
area-objects-not-measurable = AREA:所选对象均无可测量面积。
area-result = 面积 = { $area },周长 = { $perimeter }
area-result-area-only = 面积 = { $area }
area-running-result =
面积 = { $area },周长 = { $perimeter }
总面积 = { $total_area },总周长 = { $total_perimeter }
area-running-result-area-only =
面积 = { $area }
总面积 = { $total_area }
modal-recovery-report = 图形修复报告
modal-recovery-prompt = 图形修复
recovery-opened-with-repairs = 图形已打开,并生成修复报告
recovery-open-failed = 无法打开图形
recovery-repaired-description = 打开图形时检测到问题。请查看报告;如果图形数据已修复,请将其另存为新文件。
recovery-failed-description = 修复未能生成可用的图形数据。请查看报告并尝试备份或自动保存的副本。
recovery-entities-checked = 已检查实体
recovery-issues-found = 发现的问题
recovery-entities-removed = 已删除实体
recovery-references-checked = 已检查参照
recovery-referenced-entities-removed = 已删除被参照实体
recovery-references-unavailable = 不可用参照
recovery-log-path = 修复日志
recovery-log-write-failed = 无法写入修复日志
recovery-log-download-ready = 修复报告已可下载。
recovery-save-copy = 保存已修复副本
recovery-show-log = 显示修复日志
recovery-save-new-file-required = 修复后的图形必须保存到新文件。
recovery-prompt-heading = 常规打开失败
recovery-prompt-description = 图形未被修改。修复模式可以尝试挽救可用数据,但可能跳过损坏的记录。是否继续?
recovery-attempt = 尝试修复
recovery-decline = 取消
catalog-tcount-select-text-objects-then-press-enter-9b1ee71b4d = TCOUNT 选择文字对象,然后按 Enter:
catalog-tcount-starting-number-start-7471e47711 = TCOUNT 起始编号 <__ocs_fmt_0__>:
catalog-tcount-increment-increment-79f9ab6108 = TCOUNT 增量 <__ocs_fmt_0__>:
catalog-tcount-placement-overwrite-prefix-suffix-overw-9214874a72 = TCOUNT 放置方式 [覆盖/前缀/后缀] <覆盖>:
catalog-tcount-placement-must-be-overwrite-prefix-or-s-84211d2b75 = TCOUNT:放置方式必须为覆盖、前缀或后缀。
catalog-tcount-numbered-n-text-object-s-from-start-by-30588884cc = TCOUNT:已将 __ocs_fmt_0__ 个文字对象从 __ocs_fmt_1__ 开始按 __ocs_fmt_2__ 的增量编号(__ocs_fmt_3__)。
catalog-prefix-90eceb016c = 前缀
catalog-suffix-885db975a3 = 后缀
catalog-opening-a-drawing-by-path-is-not-available-in-be27bd71d4 = 浏览器不支持通过路径打开图形。

View file

@ -100,6 +100,7 @@ impl OpenCADStudio {
self.ucs_icon_hover = false;
self.tabs[i].pan_mode = false;
self.tabs[i].orbit_mode = false;
self.tabs[i].zoom_dynamic_mode = false;
let _ = self.on_viewport_exit();
}
@ -1939,6 +1940,7 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].scene.remember_current_view();
self.tabs[i]
.scene
.zoom_to_window(p1.as_vec3(), p2.as_vec3());

View file

@ -805,40 +805,94 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"ZOOM EXTENTS ALL" | "ZOOM EXTENTS ALL VIEWPORTS" | "ZEA" => {
"ZOOM EXTENTS ALL" | "ZOOM EXTENTS ALL VIEWPORTS" | "ZOOM EA" | "ZEA" => {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.fit_all_model_viewports();
self.command_line.push_output(crate::t!("Zoom Extents — All Viewports").as_ref());
}
"ZOOM EXTENTS" | "ZOOMEXTENTS" | "ZE" => {
"ZOOM EXTENTS" | "ZOOM E" | "ZOOMEXTENTS" | "ZE" => {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.fit_all();
self.command_line.push_output(crate::t!("Zoom Extents").as_ref());
}
"ZOOM IN" | "ZI" => {
"ZOOM IN" | "ZOOM I" | "ZI" => {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.zoom_camera(1.0 / 1.5);
self.command_line.push_output(crate::t!("Zoom In").as_ref());
}
"ZOOM OUT" | "ZO" => {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.zoom_camera(1.5);
self.command_line.push_output(crate::t!("Zoom Out").as_ref());
}
// ZOOM ALL — fit the configured drawing limits.
"ZOOM ALL" | "ZOOM A" | "ZA" => {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.fit_all_with_limits();
self.command_line.push_output(crate::t!("Zoom All").as_ref());
}
"ZOOM PREVIOUS" | "ZOOM P" | "ZP" => {
if self.tabs[i].scene.restore_previous_view() {
self.command_line.push_output(crate::t!("Zoom Previous").as_ref());
} else {
self.command_line
.push_error(crate::t!("ZOOM: no previous view.").as_ref());
}
}
"ZOOM OBJECT" | "ZOOM O" | "ZOBJ" => {
let handles: Vec<_> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(handle, _)| handle)
.collect();
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
let command = SelectObjectsCommand::new("ZOOM OBJECT");
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
} else {
self.tabs[i].scene.remember_current_view();
if self.tabs[i].scene.zoom_to_entities(&handles) {
self.command_line.push_output(crate::t!("Zoom Object").as_ref());
} else {
self.command_line.push_error(
crate::t!("ZOOM: selected objects have no visible bounds.").as_ref(),
);
}
}
}
"ZOOM DYNAMIC" | "ZOOM D" | "ZD" => {
self.tabs[i].zoom_dynamic_mode = true;
self.clear_navigation_hover(i);
self.command_line.push_output(
crate::t!(
"ZOOM Dynamic: drag horizontally to pan and vertically to zoom. Press Esc to exit."
)
.as_ref(),
);
}
// ZOOM SCALE — set zoom factor (e.g. "ZOOM SCALE 2" or "ZS 0.5")
cmd if cmd.starts_with("ZOOM SCALE ") || cmd.starts_with("ZS ") => {
cmd if cmd.starts_with("ZOOM SCALE ")
|| cmd.starts_with("ZOOM S ")
|| cmd.starts_with("ZS ") =>
{
let rest = cmd
.split_once(' ')
.and_then(|(_, r)| r.split_once(' ').map(|(_, v)| v).or(Some(r)))
.strip_prefix("ZOOM SCALE ")
.or_else(|| cmd.strip_prefix("ZOOM S "))
.or_else(|| cmd.strip_prefix("ZS "))
.unwrap_or("1");
if let Ok(factor) = rest.trim().parse::<f32>() {
if factor > 0.0 {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.zoom_camera(1.0 / factor);
self.command_line
.push_output(crate::tf!("Zoom Scale ×{factor:.3}").as_ref());
@ -866,15 +920,18 @@ impl OpenCADStudio {
use crate::command::KeywordCommand;
let c = KeywordCommand::new(
"ZOOM",
"ZOOM [Window / Extents / Extents All / All / In / Out / Scale]:",
"ZOOM [Window / Extents / Previous / Object / All / Dynamic / Extents All / In / Out / Scale]:",
vec![
("Window", "WINDOW", None),
("Extents", "EXTENTS", None),
("Extents All", "EXTENTS ALL", None),
("All", "ALL", None),
("In", "IN", None),
("Window", "W", None),
("Extents", "E", None),
("Previous", "P", None),
("Object", "O", None),
("All", "A", None),
("Dynamic", "D", None),
("Extents All", "EA", None),
("In", "I", None),
("Out", "OUT", None),
("Scale", "SCALE", Some("ZOOM scale factor (e.g. 2 or 0.5):")),
("Scale", "S", Some("ZOOM scale factor (e.g. 2 or 0.5):")),
],
);
self.command_line.push_info(&c.prompt());

View file

@ -224,6 +224,43 @@ impl OpenCADStudio {
"SNAP" => {
return Some(Task::done(Message::ToggleGridSnap));
}
// ISOPLANE — cycle the isometric drafting axis pair (F5).
"ISOPLANE" => {
return Some(Task::done(Message::CycleIsoPlane));
}
cmd if cmd.starts_with("ISOPLANE ") => {
let plane = match cmd.trim_start_matches("ISOPLANE").trim() {
"LEFT" | "L" => Some(crate::app::settings::IsoPlane::Left),
"TOP" | "T" => Some(crate::app::settings::IsoPlane::Top),
"RIGHT" | "R" => Some(crate::app::settings::IsoPlane::Right),
_ => None,
};
if let Some(plane) = plane {
return Some(Task::done(Message::SetIsoPlane(plane)));
}
self.command_line
.push_error(crate::t!("ISOPLANE: expected Left, Top, or Right.").as_ref());
}
// ISODRAFT — enable or disable isometric drafting.
"ISODRAFT" => {
return Some(Task::done(Message::ToggleIsometricDrafting));
}
cmd if cmd.starts_with("ISODRAFT ") => {
let requested = match cmd.trim_start_matches("ISODRAFT").trim() {
"1" | "ON" => Some(true),
"0" | "OFF" => Some(false),
_ => None,
};
match requested {
Some(value) if value != self.isometric_drafting => {
return Some(Task::done(Message::ToggleIsometricDrafting));
}
Some(_) => {}
None => self
.command_line
.push_error(crate::t!("ISODRAFT: expected On or Off.").as_ref()),
}
}
// POLAR — toggle polar tracking.
"POLAR" => {
return Some(Task::done(Message::TogglePolar));

View file

@ -98,6 +98,7 @@ impl OpenCADStudio {
// command arms below re-enable the selected one).
self.tabs[i].pan_mode = false;
self.tabs[i].orbit_mode = false;
self.tabs[i].zoom_dynamic_mode = false;
// Reset the last committed point so the first click of the new command
// is not constrained by ortho/polar relative to a previous command's endpoint.
self.last_point = None;
@ -300,6 +301,8 @@ inventory::submit!(crate::command::CommandRegistration {
"CUI",
"DSETTINGS",
"GRID",
"ISODRAFT",
"ISOPLANE",
"OSNAP",
"POLAR",
"QUICKPROPERTIES",
@ -445,6 +448,12 @@ inventory::submit!(crate::command::CommandRegistration {
"FITSPLINE",
// System variables (typeable directly).
"MIRRTEXT",
"ZOOMWHEEL",
"ZOOMFACTOR",
"CURSORSIZE",
"PICKBOX",
"CURSORTYPE",
"SNAPANG",
"ATTREQ",
"ATTDIA",
"DIMASSOC",

View file

@ -866,6 +866,12 @@ impl OpenCADStudio {
cmd if matches!(
cmd.split_whitespace().next().unwrap_or(""),
"MIRRTEXT"
| "ZOOMWHEEL"
| "ZOOMFACTOR"
| "CURSORSIZE"
| "PICKBOX"
| "CURSORTYPE"
| "SNAPANG"
| "TEXTFILL"
| "ATTREQ"
| "ATTDIA"
@ -936,7 +942,7 @@ impl OpenCADStudio {
let value = it.next().map(|s| s.trim().to_string());
if name.is_empty() || name == "?" {
self.command_line.push_info(
"SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)",
"SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)",
);
} else {
// Parse a boolean given as 0/1 or ON/OFF.
@ -1028,6 +1034,87 @@ impl OpenCADStudio {
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
None => Ok((format!("MIRRTEXT = {}", h.mirror_text as i32), false)),
},
"ZOOMWHEEL" => match &value {
Some(v) => match parse_bool(v) {
Some(reversed) => {
self.zoom_wheel_reversed = reversed;
Ok((
format!("ZOOMWHEEL = {}", reversed as i32),
true,
))
}
None => Err("SETVAR: 0 or 1 required.".into()),
},
None => Ok((
format!(
"ZOOMWHEEL = {}",
self.zoom_wheel_reversed as i32
),
false,
)),
},
"ZOOMFACTOR" => match &value {
Some(v) => match v.parse::<i32>() {
Ok(factor) if (3..=100).contains(&factor) => {
self.zoom_factor = factor;
Ok((format!("ZOOMFACTOR = {factor}"), true))
}
_ => Err("SETVAR: integer from 3 to 100 required.".into()),
},
None => {
Ok((format!("ZOOMFACTOR = {}", self.zoom_factor), false))
}
},
"CURSORSIZE" => match &value {
Some(v) => match v.parse::<i32>() {
Ok(size) if (1..=100).contains(&size) => {
self.cursor_size = size;
Ok((format!("CURSORSIZE = {size}"), true))
}
_ => Err("SETVAR: integer from 1 to 100 required.".into()),
},
None => Ok((format!("CURSORSIZE = {}", self.cursor_size), false)),
},
"PICKBOX" => match &value {
Some(v) => match v.parse::<i32>() {
Ok(size) if (0..=50).contains(&size) => {
self.pick_box = size;
Ok((format!("PICKBOX = {size}"), true))
}
_ => Err("SETVAR: integer from 0 to 50 required.".into()),
},
None => Ok((format!("PICKBOX = {}", self.pick_box), false)),
},
"CURSORTYPE" => match &value {
Some(v) => match v.as_str() {
"0" => {
self.cursor_type = crate::app::settings::CursorType::Crosshair;
Ok(("CURSORTYPE = 0".to_string(), true))
}
"1" => {
self.cursor_type = crate::app::settings::CursorType::Pointer;
Ok(("CURSORTYPE = 1".to_string(), true))
}
_ => Err("SETVAR: 0 or 1 required.".into()),
},
None => Ok((
format!(
"CURSORTYPE = {}",
i32::from(self.cursor_type == crate::app::settings::CursorType::Pointer)
),
false,
)),
},
"SNAPANG" => match &value {
Some(v) => match v.parse::<f32>() {
Ok(angle) if angle.is_finite() => {
self.snap_angle_deg = angle.rem_euclid(360.0);
Ok((format!("SNAPANG = {}", self.snap_angle_deg), true))
}
_ => Err("SETVAR: finite numeric value required.".into()),
},
None => Ok((format!("SNAPANG = {}", self.snap_angle_deg), false)),
},
// Global (not stored in the drawing): fill vs. hollow
// TrueType text. The active tab re-tessellates below.
"TEXTFILL" => match &value {
@ -1579,7 +1666,19 @@ impl OpenCADStudio {
match outcome {
Ok((msg, changed)) => {
if changed {
if matches!(
name.as_str(),
"ZOOMWHEEL"
| "ZOOMFACTOR"
| "CURSORSIZE"
| "PICKBOX"
| "CURSORTYPE"
| "SNAPANG"
) {
self.persist_settings_if_changed();
} else {
self.tabs[i].dirty = true;
}
self.command_line.push_output(&msg);
} else {
// Queried with no value (`changed == false`):

View file

@ -209,11 +209,11 @@ fn rgb_to_color(rgb: [u8; 3]) -> iced::Color {
iced::Color::from_rgb8(rgb[0], rgb[1], rgb[2])
}
fn rgb_to_hex(rgb: [u8; 3]) -> String {
pub(crate) fn rgb_to_hex(rgb: [u8; 3]) -> String {
format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])
}
fn parse_hex(value: &str) -> Option<[u8; 3]> {
pub(crate) fn parse_hex(value: &str) -> Option<[u8; 3]> {
let value = value.trim().strip_prefix('#').unwrap_or(value.trim());
if value.len() != 6 {
return None;

View file

@ -212,6 +212,9 @@ pub(super) struct DocumentTab {
/// Interactive 3-D orbit mode. While active, a left-button drag follows
/// the same camera-orbit path as Shift + middle-button drag.
pub(super) orbit_mode: bool,
/// Interactive ZOOM Dynamic mode. A left drag pans horizontally and zooms
/// vertically until Escape or another command ends the mode.
pub(super) zoom_dynamic_mode: bool,
/// Per-plugin document state (`plugin::BuiltinPlugin` manifest id → state).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(super) plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
@ -476,6 +479,7 @@ impl DocumentTab {
is_start: false,
pan_mode: false,
orbit_mode: false,
zoom_dynamic_mode: false,
plugin_state: HashMap::new(),
suspended_cmd: None,
}

View file

@ -260,19 +260,56 @@ pub(super) fn ucs_rotated_z(origin: glam::DVec3, angle_z: f32) -> Ucs {
// ── Drawing constraint helpers ─────────────────────────────────────────────
/// Constrain `pt` to the nearest 90° direction from `base`, in the active UCS
/// plane — ortho follows the user's coordinate system, not world axes. `xf` is
/// identity for plain WCS, so the world-XY behaviour is unchanged there.
pub(super) fn ortho_constrain(pt: glam::DVec3, base: glam::DVec3, xf: &UcsXform) -> glam::DVec3 {
/// The two live drafting directions in degrees inside the active UCS plane.
pub(super) fn drafting_angles(
isometric: bool,
iso_plane: super::settings::IsoPlane,
snap_angle_deg: f32,
) -> [f64; 2] {
let base = if isometric {
iso_plane.angles()
} else {
[0.0, 90.0]
};
base.map(|angle| angle + snap_angle_deg as f64)
}
/// Convert the live drafting directions into world-space axes.
pub(super) fn drafting_axes(
x: glam::DVec3,
y: glam::DVec3,
z: glam::DVec3,
isometric: bool,
iso_plane: super::settings::IsoPlane,
snap_angle_deg: f32,
) -> (glam::DVec3, glam::DVec3, glam::DVec3) {
let [a, b] = drafting_angles(isometric, iso_plane, snap_angle_deg);
let direction = |degrees: f64| {
let radians = degrees.to_radians();
(x * radians.cos() + y * radians.sin()).normalize_or(x)
};
(direction(a), direction(b), z.normalize_or(x.cross(y)))
}
/// Constrain `pt` to the nearest live drafting direction from `base`.
pub(super) fn drafting_constrain(
pt: glam::DVec3,
base: glam::DVec3,
xf: &UcsXform,
isometric: bool,
iso_plane: super::settings::IsoPlane,
snap_angle_deg: f32,
) -> glam::DVec3 {
let p = xf.to_ucs(pt);
let b = xf.to_ucs(base);
let dx = (p.x - b.x).abs();
let dy = (p.y - b.y).abs();
let c = if dx >= dy {
glam::DVec3::new(p.x, b.y, p.z)
} else {
glam::DVec3::new(b.x, p.y, p.z)
};
let delta = glam::DVec2::new(p.x - b.x, p.y - b.y);
let [a, c] = drafting_angles(isometric, iso_plane, snap_angle_deg).map(|degrees| {
let radians = degrees.to_radians();
glam::DVec2::new(radians.cos(), radians.sin())
});
let direction = if delta.dot(a).abs() >= delta.dot(c).abs() { a } else { c };
let projected = direction * delta.dot(direction);
let c = glam::DVec3::new(b.x + projected.x, b.y + projected.y, p.z);
xf.to_wcs(c)
}
@ -341,6 +378,9 @@ pub(super) fn axis_lock_capture(
polar: bool,
step_deg: f32,
xf: &UcsXform,
isometric: bool,
iso_plane: super::settings::IsoPlane,
snap_angle_deg: f32,
) -> Option<glam::DVec3> {
let p = xf.to_ucs(cursor);
let b = xf.to_ucs(base);
@ -349,8 +389,20 @@ pub(super) fn axis_lock_capture(
if dx.hypot(dy) < 1e-9 {
return None;
}
let step = (if polar { step_deg as f64 } else { 90.0 }).to_radians();
let ang = (dy.atan2(dx) / step).round() * step;
let ang = if polar {
let base = (snap_angle_deg as f64).to_radians();
let step = (step_deg as f64).to_radians();
((dy.atan2(dx) - base) / step).round() * step + base
} else {
let delta = glam::DVec2::new(dx, dy);
let [a, b] = drafting_angles(isometric, iso_plane, snap_angle_deg).map(|degrees| {
let radians = degrees.to_radians();
glam::DVec2::new(radians.cos(), radians.sin())
});
let direction = if delta.dot(a).abs() >= delta.dot(b).abs() { a } else { b };
let direction = if delta.dot(direction) < 0.0 { -direction } else { direction };
direction.y.atan2(direction.x)
};
let dir_ucs = glam::DVec3::new(ang.cos(), ang.sin(), 0.0);
let dir = xf.to_wcs(b + dir_ucs) - xf.to_wcs(b);
(dir.length_squared() > 1e-12).then(|| dir.normalize())

View file

@ -18,7 +18,7 @@ mod mtext_editor;
pub mod plugin_host;
mod properties;
mod recent;
mod settings;
pub(crate) mod settings;
mod shortcuts;
mod style_ops;
mod text_inline;
@ -444,10 +444,31 @@ pub(super) struct OpenCADStudio {
polar_mode: bool,
/// Polar tracking angle increment in degrees (15 / 30 / 45 / 90).
polar_increment_deg: f32,
/// Reverse the mouse-wheel zoom direction when true (ZOOMWHEEL = 1).
zoom_wheel_reversed: bool,
/// Mouse-wheel zoom sensitivity, clamped to 3..=100 (ZOOMFACTOR).
zoom_factor: i32,
/// Crosshair size setting (CURSORSIZE, 1..=100).
cursor_size: i32,
/// Selection-box size setting (PICKBOX, 0..=50).
pick_box: i32,
/// Drawing viewport cursor style (CURSORTYPE).
cursor_type: settings::CursorType,
/// Explicit crosshair colour; `None` retains automatic contrast.
crosshair_color: Option<[u8; 3]>,
/// Editable Options buffer for the crosshair colour.
crosshair_color_input: String,
/// Isometric drafting state and active axis pair.
isometric_drafting: bool,
iso_plane: settings::IsoPlane,
/// Drafting-grid/crosshair rotation in degrees (SNAPANG).
snap_angle_deg: f32,
/// Show grid lines in the viewport (F7).
show_grid: bool,
/// Dynamic input overlay (F12): show coordinate tooltip near cursor.
dyn_input: bool,
/// Currently visible page in the application Options dialog.
options_tab: crate::ui::window::options::OptionsTab,
/// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single).
pub texteditmode: bool,
/// When true (default), saving over an existing file first writes a `.bak`
@ -1498,6 +1519,7 @@ pub enum ModalKind {
LayerStateManager,
LayerTranslator,
DrawingUnits,
DraftingSettings,
LayerStateEditor,
Plot,
PrintAll,
@ -1764,6 +1786,16 @@ pub enum Message {
SaveDialogPathPicked(Option<std::path::PathBuf>),
/// Open the application-wide Options dialog.
OptionsOpen,
/// Switch the visible page in Options.
OptionsTabChanged(crate::ui::window::options::OptionsTab),
/// Set CURSORSIZE from the Display-page slider.
CursorSizeChanged(i32),
/// Set PICKBOX from the Selection-page slider.
PickBoxChanged(i32),
/// Set CURSORTYPE from Options.
CursorTypeChanged(settings::CursorType),
/// Edit the optional crosshair RGB value; blank restores automatic contrast.
CrosshairColorChanged(String),
/// Set the default type/version used when first saving a new drawing.
DefaultSaveFormatChanged(String),
/// Select one of Iced's built-in themes or the editable Custom theme.
@ -2090,6 +2122,14 @@ pub enum Message {
ToggleSnapEnabled,
/// Toggle grid-snap on/off — F9 / SNAP status-bar button.
ToggleGridSnap,
/// Enable or disable isometric drafting.
ToggleIsometricDrafting,
/// Select one isometric drafting axis pair.
SetIsoPlane(settings::IsoPlane),
/// Advance Left → Top → Right, enabling isometric drafting if necessary.
CycleIsoPlane,
/// Reset SNAPANG and return the active drafting coordinate system to World.
ResetDraftingRotation,
/// Toggle the ViewCube 3D gizmo visibility (NAVVCUBE).
ToggleViewCube,
/// Toggle the Properties panel visibility (PROPERTIES).
@ -3030,8 +3070,19 @@ impl OpenCADStudio {
ortho_mode: false,
polar_mode: false,
polar_increment_deg: 45.0,
zoom_wheel_reversed: false,
zoom_factor: 60,
cursor_size: 5,
pick_box: 3,
cursor_type: settings::CursorType::Crosshair,
crosshair_color: None,
crosshair_color_input: String::new(),
isometric_drafting: false,
iso_plane: settings::IsoPlane::Left,
snap_angle_deg: 0.0,
show_grid: false,
dyn_input: true,
options_tab: crate::ui::window::options::OptionsTab::General,
texteditmode: false,
backup_on_save: true,
file_assoc_enabled: true,

View file

@ -11,6 +11,63 @@
use crate::snap::SnapType;
use serde::{Deserialize, Serialize};
/// Cursor shown over the drawing viewport.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CursorType {
#[default]
Crosshair,
Pointer,
}
impl CursorType {
pub const ALL: [Self; 2] = [Self::Crosshair, Self::Pointer];
pub fn label(self) -> &'static str {
match self {
Self::Crosshair => "Crosshair",
Self::Pointer => "Desktop pointer",
}
}
}
/// Active pair of axes while isometric drafting is enabled.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum IsoPlane {
#[default]
Left,
Top,
Right,
}
impl IsoPlane {
pub const ALL: [Self; 3] = [Self::Left, Self::Top, Self::Right];
pub fn next(self) -> Self {
match self {
Self::Left => Self::Top,
Self::Top => Self::Right,
Self::Right => Self::Left,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Left => "Left",
Self::Top => "Top",
Self::Right => "Right",
}
}
/// The two drafting directions, in degrees in the active UCS plane.
pub fn angles(self) -> [f64; 2] {
match self {
Self::Left => [90.0, 150.0],
Self::Top => [30.0, 150.0],
Self::Right => [30.0, 90.0],
}
}
}
/// Canonical order of the user-toggleable object-snap modes. Drives the
/// deterministic order when decoding the `$OSMODE` bitmask (see
/// [`snaps_from_osmode`]).
@ -97,6 +154,21 @@ pub struct UserSettings {
pub dyn_input: bool,
pub polar: bool,
pub polar_increment_deg: f32,
pub zoom_wheel_reversed: bool,
pub zoom_factor: i32,
/// CURSORSIZE: normalized crosshair reach; 5 retains the original 60 px arms.
pub cursor_size: i32,
/// PICKBOX: normalized visible-box and click-aperture size.
pub pick_box: i32,
/// CURSORTYPE: crosshair or the platform pointer over the drawing.
pub cursor_type: CursorType,
/// Explicit crosshair RGB. `None` keeps automatic background contrast.
pub crosshair_color: Option<[u8; 3]>,
/// Isometric drafting changes the grid and crosshair to the active axis pair.
pub isometric_drafting: bool,
pub iso_plane: IsoPlane,
/// SNAPANG in degrees, applied in the active UCS plane.
pub snap_angle_deg: f32,
pub otrack: bool,
// Ortho ($ORTHOMODE) and the running OSNAP set ($OSMODE) are per-drawing —
// stored in the document header, not here (they used to be persisted app-
@ -166,6 +238,15 @@ impl Default for UserSettings {
dyn_input: true,
polar: false,
polar_increment_deg: 45.0,
zoom_wheel_reversed: false,
zoom_factor: 60,
cursor_size: 5,
pick_box: 3,
cursor_type: CursorType::Crosshair,
crosshair_color: None,
isometric_drafting: false,
iso_plane: IsoPlane::Left,
snap_angle_deg: 0.0,
otrack: false,
default_assoc_prompted: false,
disabled_plugins: Vec::new(),

View file

@ -17,6 +17,7 @@ pub(super) fn default_bindings() -> BTreeMap<String, String> {
("F1".to_string(), "HELP"),
("F2".to_string(), "COMMANDHISTORY"),
("F3".to_string(), "TOGGLEOSNAP"),
("F5".to_string(), "ISOPLANE"),
("F7".to_string(), "GRID"),
("F8".to_string(), "ORTHO"),
("F9".to_string(), "SNAP"),

View file

@ -4,7 +4,7 @@
use super::util::*;
use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind,
};
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
@ -660,16 +660,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
// drag. Orbit exits silently; PAN keeps its existing message.
if self.tabs[self.active_tab].pan_mode
|| self.tabs[self.active_tab].orbit_mode
|| self.tabs[self.active_tab].zoom_dynamic_mode
{
let i = self.active_tab;
let was_pan = self.tabs[i].pan_mode;
self.tabs[i].pan_mode = false;
self.tabs[i].orbit_mode = false;
self.tabs[i].zoom_dynamic_mode = false;
{
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = false;
sel.middle_last_pos = None;
sel.orbit_pivot = None;
sel.box_anchor = None;
sel.box_anchor_world = None;
sel.box_current = None;
sel.box_crossing_locked = false;
}
if was_pan {
self.command_line.push_output(crate::t!("PAN ended.").as_ref());

View file

@ -5,7 +5,7 @@ use super::util::*;
use crate::ui::window::block_palette::BlockPaletteMsg;
use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind,
};
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
@ -143,6 +143,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
&& self.active_modal.is_none()
&& !self.tabs[i].pan_mode
&& !self.tabs[i].orbit_mode
&& !self.tabs[i].zoom_dynamic_mode
{
self.ribbon.deactivate_tool();
}

View file

@ -4,7 +4,7 @@
use super::util::*;
use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind,
};
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};

View file

@ -4,7 +4,7 @@
use super::util::*;
use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind,
};
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
@ -265,6 +265,15 @@ impl OpenCADStudio {
dyn_input: self.dyn_input,
polar: self.polar_mode,
polar_increment_deg: self.polar_increment_deg,
zoom_wheel_reversed: self.zoom_wheel_reversed,
zoom_factor: self.zoom_factor,
cursor_size: self.cursor_size,
pick_box: self.pick_box,
cursor_type: self.cursor_type,
crosshair_color: self.crosshair_color,
isometric_drafting: self.isometric_drafting,
iso_plane: self.iso_plane,
snap_angle_deg: self.snap_angle_deg,
otrack: self.snapper.otrack_enabled,
default_assoc_prompted: self.default_assoc_prompted,
disabled_plugins: {
@ -299,6 +308,23 @@ impl OpenCADStudio {
self.dyn_input = s.dyn_input;
self.polar_mode = s.polar;
self.polar_increment_deg = s.polar_increment_deg;
self.zoom_wheel_reversed = s.zoom_wheel_reversed;
self.zoom_factor = s.zoom_factor.clamp(3, 100);
self.cursor_size = s.cursor_size.clamp(1, 100);
self.pick_box = s.pick_box.clamp(0, 50);
self.cursor_type = s.cursor_type;
self.crosshair_color = s.crosshair_color;
self.crosshair_color_input = s
.crosshair_color
.map(crate::app::config::rgb_to_hex)
.unwrap_or_default();
self.isometric_drafting = s.isometric_drafting;
self.iso_plane = s.iso_plane;
self.snap_angle_deg = if s.snap_angle_deg.is_finite() {
s.snap_angle_deg.rem_euclid(360.0)
} else {
0.0
};
// Ortho + running OSNAP are per-drawing (adopted from the header on
// open / tab switch), not app-global, so they are not applied here.
self.snapper.otrack_enabled = s.otrack;
@ -584,6 +610,9 @@ impl OpenCADStudio {
self.ribbon.set_collapse_mode(cfg.ribbon.collapse);
self.plot_dialog = cfg.plot;
self.shortcut_bindings = cfg.shortcuts.bindings.into_iter().collect();
self.shortcut_bindings
.entry("F5".to_string())
.or_insert_with(|| "ISOPLANE".to_string());
}
/// Write the config only when it changed since the last write, so a toggle

View file

@ -149,6 +149,9 @@ impl OpenCADStudio {
if self.active_modal == Some(ScaleManager) {
self.scale_stage_discard();
}
if self.active_modal == Some(DraftingSettings) {
self.snap_popup_open = false;
}
#[cfg(not(target_arch = "wasm32"))]
if self.active_modal == Some(FileInUse) {
self.pending_save_failure = None;
@ -2831,6 +2834,7 @@ impl OpenCADStudio {
Message::ViewCubeHome => {
let i = self.active_tab;
self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
let r_ucs = self.tabs[i].scene.viewcube_ucs_mat();
if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i]
@ -2847,6 +2851,7 @@ impl OpenCADStudio {
Message::ViewCubeRoll(cw) => {
let i = self.active_tab;
self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
let ang = if cw {
std::f32::consts::FRAC_PI_2
} else {
@ -2873,6 +2878,7 @@ impl OpenCADStudio {
};
let i = self.active_tab;
self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i]
.scene
@ -3011,6 +3017,43 @@ impl OpenCADStudio {
self.sync_vport_display(self.active_tab);
Task::none()
}
Message::ToggleIsometricDrafting => {
self.isometric_drafting = !self.isometric_drafting;
self.persist_settings_if_changed();
Task::none()
}
Message::SetIsoPlane(plane) => {
self.isometric_drafting = true;
self.iso_plane = plane;
self.persist_settings_if_changed();
Task::none()
}
Message::CycleIsoPlane => {
if self.isometric_drafting {
self.iso_plane = self.iso_plane.next();
} else {
self.isometric_drafting = true;
}
self.command_line.push_output(crate::tf!(
"Isometric plane: {}.",
self.iso_plane.label()
).as_ref());
self.persist_settings_if_changed();
Task::none()
}
Message::ResetDraftingRotation => {
self.snap_angle_deg = 0.0;
let i = self.active_tab;
if self.tabs[i].active_ucs.is_some() {
self.tabs[i].active_ucs = None;
self.commit_active_ucs_change(i, "UCS");
self.tabs[i].scene.camera_generation += 1;
}
self.command_line
.push_output(crate::t!("Drafting rotation reset to World at 0°.").as_ref());
self.persist_settings_if_changed();
Task::none()
}
Message::ToggleGrid => {
self.show_grid ^= true;
self.sync_vport_display(self.active_tab);
@ -3651,11 +3694,20 @@ impl OpenCADStudio {
Task::none()
}
Message::ToggleSnapPopup => {
self.snap_popup_open ^= true;
if self.active_modal == Some(super::ModalKind::DraftingSettings) {
self.close_active_modal();
self.snap_popup_open = false;
} else {
self.active_modal = Some(super::ModalKind::DraftingSettings);
self.snap_popup_open = true;
}
Task::none()
}
Message::CloseSnapPopup => {
self.snap_popup_open = false;
if self.active_modal == Some(super::ModalKind::DraftingSettings) {
self.close_active_modal();
}
Task::none()
}
Message::SnapSelectAll => {
@ -5146,6 +5198,41 @@ impl OpenCADStudio {
Task::none()
}
Message::OptionsTabChanged(tab) => {
self.options_tab = tab;
Task::none()
}
Message::CursorSizeChanged(value) => {
self.cursor_size = value.clamp(1, 100);
self.persist_settings_if_changed();
Task::none()
}
Message::PickBoxChanged(value) => {
self.pick_box = value.clamp(0, 50);
self.persist_settings_if_changed();
Task::none()
}
Message::CursorTypeChanged(value) => {
self.cursor_type = value;
self.persist_settings_if_changed();
Task::none()
}
Message::CrosshairColorChanged(value) => {
self.crosshair_color_input = value.clone();
if value.trim().is_empty() {
self.crosshair_color = None;
self.persist_settings_if_changed();
} else if let Some(rgb) = crate::app::config::parse_hex(&value) {
self.crosshair_color = Some(rgb);
self.persist_settings_if_changed();
}
Task::none()
}
Message::DefaultSaveFormatChanged(format) => {
self.default_save_format =
crate::io::canonical_save_format(&format).to_string();

View file

@ -4,7 +4,7 @@
use super::util::*;
use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{
ortho_constrain, parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind,
};
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};

View file

@ -4,7 +4,7 @@
use super::util::*;
use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{
axis_lock_apply, axis_lock_capture, ortho_constrain, parse_coord, polar_constrain_near,
axis_lock_apply, axis_lock_capture, drafting_axes, drafting_constrain, parse_coord, polar_constrain_near,
ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind,
};
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
@ -171,6 +171,26 @@ impl OpenCADStudio {
.set_active_tile_grid_snap(grid_on, snap_on);
}
/// Grid origin plus the rotated/isometric axes used by both drawing and snap.
pub(in crate::app) fn drafting_grid_basis(
&self,
i: usize,
) -> (glam::Vec3, (glam::Vec3, glam::Vec3, glam::Vec3)) {
let (origin, rotation) = self.tabs[i].ucs_grid_basis();
let x = rotation.transform_vector3(glam::Vec3::X).as_dvec3();
let y = rotation.transform_vector3(glam::Vec3::Y).as_dvec3();
let z = rotation.transform_vector3(glam::Vec3::Z).as_dvec3();
let (x, y, z) = drafting_axes(
x,
y,
z,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
);
(origin, (x.as_vec3(), y.as_vec3(), z.as_vec3()))
}
/// Adopt the active viewport's grid *display* into the live toggle. Called
/// on load and whenever the active tab or viewport changes, so the grid
/// drawing follows the active viewport.
@ -739,6 +759,39 @@ impl OpenCADStudio {
if mid_down {
if let Some(last) = mid_last {
let (dx, dy) = (p.x - last.x, p.y - last.y);
if self.tabs[i].zoom_dynamic_mode {
let bounds = self.tabs[i]
.scene
.active_model_tile_bounds(vp_size.0, vp_size.1);
drop(sel);
let zoom_delta = -dy * 0.03;
if self.tabs[i].scene.active_viewport.is_some() {
self.tabs[i].scene.pan_active_viewport(dx, 0.0, bounds);
self.tabs[i]
.scene
.zoom_active_viewport(zoom_delta, None);
} else {
let local = iced::Point {
x: p.x - bounds.x,
y: p.y - bounds.y,
};
let local_bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: bounds.width,
height: bounds.height,
};
let mut camera = self.tabs[i].scene.camera.borrow_mut();
camera.pan_screen(dx, 0.0, bounds.height);
camera.zoom_about_point(local, local_bounds, zoom_delta);
}
self.tabs[i].scene.camera_generation += 1;
self.tabs[i]
.scene
.record_nav_perf(crate::scene::NavPerfOp::Zoom, move_started);
self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p);
return Task::none();
}
// Shift+MMB drag orbits the model view instead of panning
// — the requested Zoom=wheel / Pan=MMB / Rotate=Shift+MMB
// scheme (#229). Floating viewports and paper keep the
@ -981,13 +1034,24 @@ impl OpenCADStudio {
// snapping must drop its foot from this point, including when a
// hot-grip set is moved by the same drag vector.
self.snapper.from_point = Some(grip.origin_world.as_vec3());
let (go, gr) = self.tabs[i].ucs_grid_basis();
self.snapper.from_point = Some(grip.origin_world.as_vec3());
// Keep the updated drafting basis from main so grid/snap follows the
// current drafting axes, including isometric drafting and SNAPANG.
let (go, gr) = self.drafting_grid_basis(i);
let base = grip.origin_world;
let ucs_xf = self.tabs[i].ucs_xform();
let mut construction_cursor = if self.ortho_mode {
Some(ortho_constrain(raw, base, &ucs_xf))
Some(drafting_constrain(
raw,
base,
&ucs_xf,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
))
} else if self.polar_mode {
let pt = polar_constrain_near(
raw,
@ -1000,7 +1064,8 @@ impl OpenCADStudio {
&ucs_xf,
);
// POLAR must actually be engaged on one of its rays.
// POLAR being enabled is not enough: the cursor must actually
// be engaged on one of the polar tracking rays.
let delta = pt - base;
let step = (self.polar_increment_deg as f64).to_radians();
let angle = delta.y.atan2(delta.x);
@ -1023,7 +1088,6 @@ impl OpenCADStudio {
pt.z = base.z;
}
}
// `raw` is already model space (viewport camera or paper→model),
// and the wires are model space, so the snap result is model.
let snap_hit = self.snapper.snap(
@ -1061,7 +1125,7 @@ impl OpenCADStudio {
None
};
let (_, ucs_x, ucs_y, _) = self.tabs[i].ucs_xform().axes();
let (_, (ucs_x, ucs_y, _)) = self.drafting_grid_basis(i);
self.snapper.otrack_snap(
raw,
@ -1071,8 +1135,8 @@ impl OpenCADStudio {
polar_step,
Some(grip.origin_world),
self.ortho_mode,
ucs_x,
ucs_y,
ucs_x.as_dvec3(),
ucs_y.as_dvec3(),
)
} else {
None
@ -1103,6 +1167,9 @@ impl OpenCADStudio {
self.polar_mode,
self.polar_increment_deg,
&ucs_xf,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
);
}
} else {
@ -1116,7 +1183,14 @@ impl OpenCADStudio {
let base = grip.origin_world;
let ucs_xf = self.tabs[i].ucs_xform();
if self.ortho_mode {
snapped = ortho_constrain(snapped, base, &ucs_xf);
snapped = drafting_constrain(
snapped,
base,
&ucs_xf,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
);
} else if self.polar_mode {
snapped = polar_constrain_near(
snapped,
@ -1440,9 +1514,10 @@ impl OpenCADStudio {
bounds,
)
} else {
let (go, gr) = self.tabs[i].ucs_grid_basis();
// The point the active rubber-band starts from.
let (go, gr) = self.drafting_grid_basis(i);
// The snapper is a screen-space (f32) engine; the f64
// base only matters for typed-input precision, so hand it
// the downcast point here.
self.snapper.from_point = self.last_point.map(|p| p.as_vec3());
// Build the temporary construction direction before OSNAP.
@ -1455,7 +1530,14 @@ impl OpenCADStudio {
let ucs_xf = self.tabs[i].ucs_xform();
let mut pt = if self.ortho_mode {
Some(ortho_constrain(snap_cursor, base, &ucs_xf))
Some(drafting_constrain(
snap_cursor,
base,
&ucs_xf,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
))
} else if self.polar_mode {
let pt = polar_constrain_near(
snap_cursor,
@ -1540,7 +1622,7 @@ impl OpenCADStudio {
} else {
None
};
let (_, ucs_x, ucs_y, _) = self.tabs[i].ucs_xform().axes();
let (_, (ucs_x, ucs_y, _)) = self.drafting_grid_basis(i);
self.snapper.otrack_snap(
cursor_world,
view_rot,
@ -1549,8 +1631,8 @@ impl OpenCADStudio {
step,
self.last_point,
self.ortho_mode && !is_window_corner,
ucs_x,
ucs_y,
ucs_x.as_dvec3(),
ucs_y.as_dvec3(),
)
} else {
None
@ -1613,6 +1695,9 @@ impl OpenCADStudio {
self.polar_mode,
self.polar_increment_deg,
&ucs_xf,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
);
}
} else if !self.shift_down {
@ -1636,7 +1721,14 @@ impl OpenCADStudio {
if let Some(base) = self.last_point {
let ucs_xf = self.tabs[i].ucs_xform();
if self.ortho_mode {
pt = ortho_constrain(pt, base, &ucs_xf);
pt = drafting_constrain(
pt,
base,
&ucs_xf,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
);
} else if self.polar_mode {
pt = polar_constrain_near(
pt,
@ -1806,6 +1898,7 @@ impl OpenCADStudio {
eye,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
crate::ui::overlay::pick_box_aperture_px(self.pick_box),
)
.and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| {
@ -2145,7 +2238,7 @@ impl OpenCADStudio {
self.snapper.grid_spacing = grid_spacing;
// No rubber-band origin (perp/extension feet don't apply to a free drag).
self.snapper.from_point = None;
let (go, gr) = self.tabs[i].ucs_grid_basis();
let (go, gr) = self.drafting_grid_basis(i);
let snap_hit = self
.snapper
.snap(raw, p, &snap_candidates, view_rot, eye, bounds, go, gr, None);
@ -2389,11 +2482,21 @@ impl OpenCADStudio {
// Interactive navigation tools reuse the middle-button movement path,
// so no selection/pick logic runs while the left button drives them.
if self.tabs[i].orbit_mode || self.tabs[i].pan_mode {
if self.tabs[i].orbit_mode
|| self.tabs[i].pan_mode
|| self.tabs[i].zoom_dynamic_mode
{
self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = true;
sel.middle_last_pos = Some(p);
if self.tabs[i].zoom_dynamic_mode {
sel.box_anchor = Some(p);
sel.box_current = Some(p);
sel.box_crossing = false;
sel.box_crossing_locked = true;
}
return Task::none();
}
@ -2561,11 +2664,18 @@ impl OpenCADStudio {
// Navigation mode: end this drag but keep the tool armed for the next
// left drag (exit is Esc / another command). Mirror of the press.
if self.tabs[i].orbit_mode || self.tabs[i].pan_mode {
if self.tabs[i].orbit_mode
|| self.tabs[i].pan_mode
|| self.tabs[i].zoom_dynamic_mode
{
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = false;
sel.middle_last_pos = None;
sel.orbit_pivot = None;
sel.box_anchor = None;
sel.box_anchor_world = None;
sel.box_current = None;
sel.box_crossing_locked = false;
drop(sel);
self.arm_hover_after_navigation(i);
return Task::none();
@ -2791,7 +2901,7 @@ impl OpenCADStudio {
bounds,
)
} else {
let (go, gr) = self.tabs[i].ucs_grid_basis();
let (go, gr) = self.drafting_grid_basis(i);
self.snapper.from_point = self.last_point.map(|p| p.as_vec3());
let construction_cursor = if is_window_corner {
@ -2801,7 +2911,14 @@ impl OpenCADStudio {
let ucs_xf = self.tabs[i].ucs_xform();
let mut pt = if self.ortho_mode {
Some(ortho_constrain(snap_cursor, base, &ucs_xf))
Some(drafting_constrain(
snap_cursor,
base,
&ucs_xf,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
))
} else if self.polar_mode {
let pt = polar_constrain_near(
snap_cursor,
@ -2879,7 +2996,7 @@ impl OpenCADStudio {
} else {
None
};
let (_, ucs_x, ucs_y, _) = self.tabs[i].ucs_xform().axes();
let (_, (ucs_x, ucs_y, _)) = self.drafting_grid_basis(i);
self.snapper.otrack_snap(
raw,
view_rot,
@ -2888,8 +3005,8 @@ impl OpenCADStudio {
step,
self.last_point,
self.ortho_mode && !is_window_corner,
ucs_x,
ucs_y,
ucs_x.as_dvec3(),
ucs_y.as_dvec3(),
)
} else {
None
@ -2908,7 +3025,14 @@ impl OpenCADStudio {
if let Some(base) = self.last_point {
let ucs_xf = self.tabs[i].ucs_xform();
if self.ortho_mode {
pt = ortho_constrain(pt, base, &ucs_xf);
pt = drafting_constrain(
pt,
base,
&ucs_xf,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
);
} else if self.polar_mode {
pt = polar_constrain_near(
pt,
@ -2993,7 +3117,7 @@ impl OpenCADStudio {
view_rot2,
eye2,
bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0,
);
let include_fills = self.tabs[i]
.active_cmd
@ -3014,6 +3138,7 @@ impl OpenCADStudio {
eye2,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
crate::ui::overlay::pick_box_aperture_px(self.pick_box),
)
.and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| {
@ -3542,7 +3667,7 @@ impl OpenCADStudio {
view_rot,
eye,
bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0,
);
let candidate_handles = self.tabs[i]
.scene
@ -3562,6 +3687,7 @@ impl OpenCADStudio {
eye,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
crate::ui::overlay::pick_box_aperture_px(self.pick_box),
)
.into_iter()
.filter_map(|s| Scene::handle_from_wire_name(s))
@ -3583,6 +3709,7 @@ impl OpenCADStudio {
eye,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
crate::ui::overlay::pick_box_aperture_px(self.pick_box),
)
.and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| {
@ -3924,7 +4051,7 @@ impl OpenCADStudio {
view_rot,
eye,
bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0,
);
let candidate_handles = self.tabs[i]
.scene
@ -3939,6 +4066,7 @@ impl OpenCADStudio {
eye,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
crate::ui::overlay::pick_box_aperture_px(self.pick_box),
)
.and_then(|s| Scene::handle_from_wire_name(s))
.or_else(|| {
@ -4043,7 +4171,7 @@ impl OpenCADStudio {
view_rot,
eye,
bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0,
);
scene::pick::hit_test::click_hit(
p,
@ -4052,6 +4180,7 @@ impl OpenCADStudio {
eye,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
crate::ui::overlay::pick_box_aperture_px(self.pick_box),
)
.and_then(|s| Scene::handle_from_wire_name(s))
.and_then(|h| {
@ -4157,6 +4286,7 @@ impl OpenCADStudio {
let i = self.active_tab;
self.clear_navigation_hover(i);
self.ribbon.close_dropdown();
self.tabs[i].scene.remember_current_view();
let now = Instant::now();
let is_double = {
let sel = self.tabs[i].scene.selection.borrow();
@ -4185,11 +4315,16 @@ impl OpenCADStudio {
pub(super) fn on_viewport_scroll(&mut self, delta: mouse::ScrollDelta) -> Task<Message> {
let nav_started = Instant::now();
let s = match delta {
let mut s = match delta {
mouse::ScrollDelta::Lines { y, .. } => y,
mouse::ScrollDelta::Pixels { y, .. } => y * 0.01,
};
s *= self.zoom_factor as f32 / 60.0;
if self.zoom_wheel_reversed {
s = -s;
}
let i = self.active_tab;
self.tabs[i].scene.remember_current_view();
self.clear_navigation_hover(i);
let cursor = self.tabs[i].scene.selection.borrow().last_move_pos;
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
@ -4354,6 +4489,7 @@ impl OpenCADStudio {
fn snap_view_region(&mut self, region: CubeRegion, r_ucs: glam::Mat4) -> Task<Message> {
let i = self.active_tab;
self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
let mut region = region;
// "Already there → flip to opposite" check: compare the
// current gaze direction with the region's target gaze.
@ -4450,7 +4586,7 @@ impl OpenCADStudio {
view_rot,
eye,
bounds,
scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0,
crate::ui::overlay::pick_box_aperture_px(self.pick_box) * 2.0,
);
let candidate_ms = candidate_started.elapsed().as_secs_f64() * 1000.0;
let candidate_count = hover_candidates.len();
@ -4470,6 +4606,7 @@ impl OpenCADStudio {
eye,
bounds,
self.tabs[i].scene.document.header.lineweight_display,
crate::ui::overlay::pick_box_aperture_px(self.pick_box),
)
.and_then(Scene::handle_from_wire_name);
let wire_ms = wire_started.elapsed().as_secs_f64() * 1000.0;

View file

@ -335,6 +335,14 @@ impl OpenCADStudio {
let (vw, vh) = tab.scene.selection.borrow().vp_size;
let model_basis = {
let (o, ux, uy, uz) = tab.ucs_xform().axes();
let (ux, uy, uz) = super::helpers::drafting_axes(
ux,
uy,
uz,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
);
(o, (ux.as_vec3(), uy.as_vec3(), uz.as_vec3()))
};
let grid: Vec<crate::ui::overlay::GridParams> = tab
@ -342,7 +350,7 @@ impl OpenCADStudio {
.grid_views(vw, vh)
.into_iter()
.map(|(bounds, cam, handle)| {
let (origin, axes): (glam::DVec3, _) = if is_paper {
let (origin, mut axes): (glam::DVec3, _) = if is_paper {
match tab.ucs_from_viewport(handle) {
Some(u) => {
let (o, ux, uy, uz) =
@ -357,6 +365,17 @@ impl OpenCADStudio {
} else {
model_basis
};
if is_paper {
let (ux, uy, uz) = super::helpers::drafting_axes(
axes.0.as_dvec3(),
axes.1.as_dvec3(),
axes.2.as_dvec3(),
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
);
axes = (ux.as_vec3(), uy.as_vec3(), uz.as_vec3());
}
crate::ui::overlay::GridParams {
view_rot: cam.view_proj_rte(bounds),
eye: cam.eye(),
@ -680,10 +699,19 @@ impl OpenCADStudio {
dividers,
pane_move_rect,
pane_drop_rect,
tab.pan_mode || tab.orbit_mode,
tab.pan_mode || tab.orbit_mode || tab.zoom_dynamic_mode,
self.ribbon.open_dropdown.is_some(),
hover_locked,
crosshair_background(tab, is_paper),
crate::ui::overlay::CrosshairOptions {
size_percent: self.cursor_size,
pick_box: self.pick_box,
cursor_type: self.cursor_type,
color: self.crosshair_color,
isometric: self.isometric_drafting,
iso_plane: self.iso_plane,
snap_angle_deg: self.snap_angle_deg,
},
)
};
@ -1693,6 +1721,8 @@ impl OpenCADStudio {
self.polar_increment_deg,
self.dyn_input,
self.snapper.otrack_enabled,
self.isometric_drafting,
self.iso_plane,
layout_names.clone(),
block_tabs,
layout_names.into_iter().skip(1).collect(),

View file

@ -21,6 +21,7 @@ impl OpenCADStudio {
Some(K::LayerStateManager) => crate::tr!("modal-layer-state-manager"),
Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(),
Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(),
Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(),
Some(K::LayerStateEditor) => crate::tr!("modal-edit-layer-state"),
Some(K::Plot) => crate::tr!("modal-plot"),
Some(K::PrintAll) => t!("Print All").into_owned(),
@ -96,6 +97,31 @@ impl OpenCADStudio {
&self.ui_theme,
&self.theme_color_inputs,
self.language,
self.options_tab,
self.cursor_size,
self.pick_box,
self.cursor_type,
self.crosshair_color,
&self.crosshair_color_input,
flow,
)
},
),
super::super::ModalKind::DraftingSettings => sized_flow(
ex,
520,
560,
|flow| {
crate::ui::window::drafting_settings::view_window(
&self.snapper,
self.show_grid,
self.snapper.grid_snap(),
self.ortho_mode,
self.polar_mode,
self.snapper.otrack_enabled,
self.isometric_drafting,
self.iso_plane,
self.snap_angle_deg,
flow,
)
},

View file

@ -566,8 +566,9 @@ impl CadCommand for KeywordCommand {
// `None` would hand the same text to the command a second time.
None => {
let up = t.to_uppercase();
let Some((_, keyword, value_prompt)) =
self.options.iter().find(|(_, k, _)| k.eq_ignore_ascii_case(&up))
let Some((_, keyword, value_prompt)) = self.options.iter().find(|(label, k, _)| {
k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t)
})
else {
// Unknown verb — keep prompting rather than dispatch garbage.
return Some(CmdResult::NeedPoint);

View file

@ -10,7 +10,7 @@ use crate::entities::common::{
use crate::entities::traits::TruckConvertible;
use crate::scene::convert::acad_to_truck::{extrusion_wall_tris, TruckEntity, TruckObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection};
use crate::scene::model::wire_model::{SnapHint, TangentGeom};
use crate::scene::model::wire_model::TangentGeom;
const TAU: f64 = std::f64::consts::TAU;
@ -26,9 +26,6 @@ fn to_truck(arc: &Arc) -> TruckEntity {
// Compute OCS basis vectors for this entity's normal.
let (ax, ay) = crate::scene::view::transform::ocs_axes(normal);
let ccw_end = if ea >= sa { ea } else { ea + TAU };
let mid_a = sa + (ccw_end - sa) * 0.5;
// Arc centre in WCS.
let (cwx, cwy, cwz) = crate::scene::view::transform::ocs_point_to_wcs((cx, cy, cz), normal);
@ -42,11 +39,11 @@ fn to_truck(arc: &Arc) -> TruckEntity {
)
};
let cv = glam::DVec3::new(cwx, cwy, cwz);
// Arc-length centre — one well-defined midpoint snap. Circles and
// ellipses (closed curves) deliberately don't emit this; see #34.
let mid_pt_3 = arc_pt(mid_a);
let mv = glam::DVec3::new(mid_pt_3.x, mid_pt_3.y, mid_pt_3.z);
// Centre, ends, arc-length midpoint and the quadrants the sweep actually
// covers, all from the entity's own curve. Circles and ellipses (closed
// curves) deliberately emit no midpoint; see #34.
let curve = crate::entities::curve::arc_curve(arc);
let snap = crate::entities::curve::snap_from(&curve);
let tangent = TangentGeom::Circle {
center: [cwx as f32, cwy as f32, cwz as f32],
radius: r as f32,
@ -81,23 +78,28 @@ fn to_truck(arc: &Arc) -> TruckEntity {
return TruckEntity {
pick_tris: extrusion_wall_tris(&base, [t * nx, t * ny, t * nz]),
object: TruckObject::Lines(pts),
snap_pts: vec![(cv, SnapHint::Center), (mv, SnapHint::Midpoint)],
snap_pts: snap.snap_pts.clone(),
tangent_geoms: vec![tangent],
key_vertices: vec![],
fill_tris: vec![],
};
}
// Kept as a truck edge rather than a point list: EXTRUDE, REVOLVE and
// SWEEP take their profile and path from `Curve` / `Contour` and have no
// arm for `Lines`, so handing them one would quietly stop an arc being
// usable as either.
let p_start = arc_pt(sa);
let p_end = arc_pt(ea);
let p_mid = arc_pt(mid_a);
let ccw_end = if ea >= sa { ea } else { ea + TAU };
let p_mid = arc_pt(sa + (ccw_end - sa) * 0.5);
let v_start = builder::vertex(p_start);
let v_end = builder::vertex(p_end);
let edge = builder::circle_arc(&v_start, &v_end, p_mid);
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Curve(edge),
snap_pts: vec![(cv, SnapHint::Center), (mv, SnapHint::Midpoint)],
snap_pts: snap.snap_pts.clone(),
tangent_geoms: vec![tangent],
key_vertices: vec![],
fill_tris: vec![],

View file

@ -8,7 +8,7 @@ use crate::entities::common::{
use crate::entities::traits::TruckConvertible;
use crate::scene::convert::acad_to_truck::{extrusion_wall_tris, TruckEntity, TruckObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection};
use crate::scene::model::wire_model::{SnapHint, TangentGeom};
use crate::scene::model::wire_model::TangentGeom;
fn to_truck(circle: &Circle) -> TruckEntity {
let cx = circle.center.x;
@ -20,18 +20,11 @@ fn to_truck(circle: &Circle) -> TruckEntity {
let (ax, ay) = crate::scene::view::transform::ocs_axes(normal);
let (cwx, cwy, cwz) = crate::scene::view::transform::ocs_point_to_wcs((cx, cy, cz), normal);
let cv = glam::DVec3::new(cwx, cwy, cwz);
let rf = r as f32;
let q = |d: (f64, f64, f64)| {
glam::DVec3::new(cwx + r * d.0, cwy + r * d.1, cwz + r * d.2)
};
let snap_pts = vec![
(cv, SnapHint::Center),
(q(ax), SnapHint::Quadrant),
(q(ay), SnapHint::Quadrant),
(q((-ax.0, -ax.1, -ax.2)), SnapHint::Quadrant),
(q((-ay.0, -ay.1, -ay.2)), SnapHint::Quadrant),
];
// Centre and quadrants come from the entity's own curve, so the same
// definition answers here, in the tessellation and in a trim.
let curve = crate::entities::curve::circle_curve(circle);
let snap_pts = crate::entities::curve::snap_from(&curve).snap_pts;
let tangent = TangentGeom::Circle {
center: [cwx as f32, cwy as f32, cwz as f32],
radius: rf,
@ -81,34 +74,16 @@ fn to_truck(circle: &Circle) -> TruckEntity {
};
}
// Tessellate directly as a cos/sin polyline rather than a truck
// Sampled from the entity's own curve rather than as a truck
// `circle_arc` (arc-through-three-points). At large WCS coordinates
// (e.g. −1.2M UTM) the three-point fit cancels catastrophically — the
// circle comes back with a ~3% radius wobble and uneven segment lengths,
// which then throws off a dashed linetype's dash spacing. Direct
// evaluation only adds a small ±r term to the centre, so it stays precise;
// the `Lines` path RTE-splits the absolute-f64 points into the
// evaluation only adds a small ±r term to the centre, so it stays
// precise; the `Lines` path RTE-splits the absolute-f64 points into the
// double-single the shader reconstructs.
let tol = crate::scene::convert::truck_tess::current_curve_tol();
// Chord-height tolerance → segment count: sag = r·(1 − cos(π/N)).
let n = if r > tol {
(std::f64::consts::PI / (1.0 - tol / r).clamp(-1.0, 1.0).acos())
.ceil()
.clamp(16.0, 4096.0) as usize
} else {
16
};
let tau = std::f64::consts::TAU;
let mut pts: Vec<[f64; 3]> = Vec::with_capacity(n + 1);
for i in 0..=n {
let a = i as f64 * tau / n as f64;
let (s, c) = a.sin_cos();
pts.push([
cwx + r * (c * ax.0 + s * ay.0),
cwy + r * (c * ax.1 + s * ay.1),
cwz + r * (c * ax.2 + s * ay.2),
]);
}
let pts = crate::entities::curve::curve_points(&curve);
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),

751
src/entities/curve.rs Normal file
View file

@ -0,0 +1,751 @@
//! An entity's geometry as a kernel curve.
//!
//! One converter, so that what is drawn, what is snapped to and what is
//! trimmed all read the same definition. Before this each of those grew its
//! own per-type match, and the three drifted: an arc's tessellation, its snap
//! points and its trim parameters were computed by different code from the
//! same fields.
//!
//! # The plane
//!
//! A [`Curve`] is two-dimensional; a drawing's curve lives on a plane in
//! space. The two together are a [`PlanarCurve`], and the plane comes from
//! the entity's **OCS** — its extrusion normal, run through the DXF
//! arbitrary-axis algorithm in [`ocs_axes`]. Not from the UCS: the UCS is the
//! working plane a user drew in, and an entity does not remember it. Draw a
//! circle in a UCS turned 30° about Z and the stored normal is still +Z, so
//! the frame this builds is the world one — which is exactly right, because
//! that is where the circle's stored coordinates are read.
//!
//! Entities that store WCS coordinates rather than OCS ones (LINE, ELLIPSE,
//! SPLINE, RAY, XLINE) still get a plane, derived from the geometry itself.
//! Where they turn out not to be planar at all, the answer is `None` rather
//! than a silent projection onto XY, which would move the geometry.
//!
//! # What is deliberately absent
//!
//! Text, hatch, dimensions, meshes, solids, images and blocks are not
//! curves. They keep their own paths and this returns `None` for them, which
//! is what lets a caller ask about any entity without checking first.
use acadrust::entities::{
Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, LwPolyline as LwPolylineEnt,
Polyline2D, Spline as SplineEnt,
};
use acadrust::kernel::geom2d::{
characteristic_points, Arc, Circle, Curve, Ellipse, EllipseArc, Line, Polyline, PolylineVertex,
Ray, SnapKind, Transform, XLine,
};
use acadrust::kernel::space::{PlanarCurve, Plane, Vec3};
use acadrust::types::Vector3;
use acadrust::EntityType;
use crate::modules::draw::modify::spline_ops::spline_to_nurbs_on;
use crate::scene::model::wire_model::SnapHint;
use crate::scene::view::transform::ocs_axes;
/// The codec's vector as the array the kernel speaks in. The two have no
/// conversion between them by design — neither crate knows the other — so the
/// bridge is here, once.
fn xyz(v: Vector3) -> [f64; 3] {
[v.x, v.y, v.z]
}
/// How far a point may sit off a candidate plane and still be taken to lie on
/// it.
///
/// Relative to the geometry's own size rather than absolute: a spline drawn
/// at survey coordinates carries several orders more rounding in its stored
/// values than one drawn near the origin, and a fixed floor would reject the
/// first or accept anything for the second.
const PLANARITY_TOLERANCE: f64 = 1e-9;
/// The entity's geometry as a curve on the plane it lives on.
///
/// `None` for entities that are not curves, and for the ones that are but
/// whose points do not lie on a plane — a 3D polyline, a spline through
/// points in space.
pub fn entity_curve(entity: &EntityType) -> Option<PlanarCurve> {
match entity {
EntityType::Line(line) => straight_curve(line.start, line.end, Straight::Segment),
EntityType::Ray(ray) => {
straight_curve(ray.base_point, ray.base_point + ray.direction, Straight::Ray)
}
EntityType::XLine(line) => straight_curve(
line.base_point,
line.base_point + line.direction,
Straight::Infinite,
),
EntityType::Circle(circle) => Some(circle_curve(circle)),
EntityType::Arc(arc) => Some(arc_curve(arc)),
EntityType::Ellipse(ellipse) => ellipse_curve(ellipse),
EntityType::LwPolyline(polyline) => lwpolyline_curve(polyline),
EntityType::Polyline2D(polyline) => polyline2d_curve(polyline),
EntityType::Spline(spline) => spline_curve(spline),
_ => None,
}
}
/// The plane an OCS-stored entity's coordinates are read in.
///
/// `elevation` is the entity's third stored coordinate — the distance along
/// the normal its plane sits at — so the plane's origin is where a stored
/// `(0, 0)` actually lands.
///
/// The axes come from [`ocs_axes`] rather than being derived here. That
/// algorithm is a DXF storage convention with its own history of getting the
/// degenerate cases wrong, and having it in one place is what keeps the
/// second copy from drifting.
pub fn ocs_plane(normal: Vector3, elevation: f64) -> Plane {
let normal = normalized(normal);
if is_default_normal(normal) {
// The overwhelming majority. Named rather than left to the general
// path so the axes come out bit-exact, which is what lets consumers
// take the world-XY shortcut.
return Plane::from_axes([0.0, 0.0, elevation], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
}
let (x_axis, y_axis) = ocs_axes((normal.x, normal.y, normal.z));
Plane::from_axes(
(Vec3::from(xyz(normal)) * elevation).to_array(),
[x_axis.0, x_axis.1, x_axis.2],
[y_axis.0, y_axis.1, y_axis.2],
)
}
/// Which of the three straight kinds a pair of points describes.
enum Straight {
Segment,
Ray,
Infinite,
}
/// A LINE, RAY or XLINE, which store world coordinates and may run anywhere.
///
/// A straight curve lies in infinitely many planes, so any of them gives the
/// same points back. The one picked is the world XY plane when the geometry
/// is level, because that is the frame everything else in a drawing shares
/// and a caller that wants to intersect two curves needs them in one frame.
/// Otherwise it is the upright plane through the line, which is the only
/// choice that does not depend on an arbitrary rotation.
fn straight_curve(from: Vector3, to: Vector3, kind: Straight) -> Option<PlanarCurve> {
let along = Vec3::from(xyz(to)) - Vec3::from(xyz(from));
if along.length_squared() <= 0.0 {
return None;
}
let plane = if (to.z - from.z).abs() <= PLANARITY_TOLERANCE * scale_of(&[from, to]) {
Plane::from_axes([0.0, 0.0, from.z], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0])
} else {
// The upright plane through the line: its normal is horizontal, and
// perpendicular to the line. A line that is itself vertical has no
// such normal, and any plane containing it will do — XZ is taken.
let normal = along.cross(Vec3::Z).normalize().unwrap_or(Vec3::Y);
Plane::orthonormal(xyz(from), along.to_array(), normal.to_array())?
};
let start = plane.project(xyz(from))?;
let end = plane.project(xyz(to))?;
let direction = [end[0] - start[0], end[1] - start[1]];
Some(PlanarCurve::new(
plane,
match kind {
Straight::Segment => Curve::Line(Line { start, end }),
Straight::Ray => Curve::Ray(Ray {
origin: start,
direction,
}),
Straight::Infinite => Curve::XLine(XLine {
base: start,
direction,
}),
},
))
}
pub fn circle_curve(circle: &CircleEnt) -> PlanarCurve {
PlanarCurve::new(
ocs_plane(circle.normal, circle.center.z),
Curve::Circle(Circle {
centre: [circle.center.x, circle.center.y],
radius: circle.radius,
}),
)
}
pub fn arc_curve(arc: &ArcEnt) -> PlanarCurve {
PlanarCurve::new(
ocs_plane(arc.normal, arc.center.z),
Curve::Arc(Arc {
centre: [arc.center.x, arc.center.y],
radius: arc.radius,
start_angle: arc.start_angle,
end_angle: arc.end_angle,
}),
)
}
/// An ELLIPSE, whose centre and major axis are stored in world coordinates
/// even though the shape is planar.
///
/// The plane is still the entity's OCS one, so an ellipse and a circle with
/// the same normal end up in the same frame and can be intersected without
/// re-expressing either. Getting there means projecting the two stored
/// vectors rather than reading them off, which is where the major axis picks
/// up its direction *and* its length.
pub fn ellipse_curve(ellipse: &EllipseEnt) -> Option<PlanarCurve> {
let normal = normalized(ellipse.normal);
let elevation = Vec3::from(xyz(ellipse.center)).dot(Vec3::from(xyz(normal)));
let plane = ocs_plane(normal, elevation);
let centre = plane.project(xyz(ellipse.center))?;
let major = plane.project_vector(xyz(ellipse.major_axis))?;
let major_radius = (major[0] * major[0] + major[1] * major[1]).sqrt();
if major_radius <= 0.0 {
return None;
}
Some(PlanarCurve::new(
plane,
Curve::Ellipse(EllipseArc {
ellipse: Ellipse {
centre,
major_radius,
minor_radius: major_radius * ellipse.minor_axis_ratio,
major_axis: [major[0] / major_radius, major[1] / major_radius],
},
start_parameter: ellipse.start_parameter,
end_parameter: ellipse.end_parameter,
}),
))
}
pub fn lwpolyline_curve(polyline: &LwPolylineEnt) -> Option<PlanarCurve> {
let vertices: Vec<PolylineVertex> = polyline
.vertices
.iter()
.map(|v| PolylineVertex {
position: [v.location.x, v.location.y],
bulge: v.bulge,
})
.collect();
(vertices.len() >= 2).then(|| {
PlanarCurve::new(
ocs_plane(polyline.normal, polyline.elevation),
Curve::Polyline(Polyline {
vertices,
closed: polyline.is_closed,
}),
)
})
}
/// A heavy 2D POLYLINE. Its vertices carry a Z, but the entity's own
/// `elevation` is what defines the plane; the two agree in a well-formed
/// drawing and the entity field is the one the writer round-trips.
pub fn polyline2d_curve(polyline: &Polyline2D) -> Option<PlanarCurve> {
let vertices: Vec<PolylineVertex> = polyline
.vertices
.iter()
.map(|v| PolylineVertex {
position: [v.location.x, v.location.y],
bulge: v.bulge,
})
.collect();
(vertices.len() >= 2).then(|| {
PlanarCurve::new(
ocs_plane(polyline.normal, polyline.elevation),
Curve::Polyline(Polyline {
vertices,
closed: polyline.flags.is_closed(),
}),
)
})
}
/// A SPLINE, whose control and fit points are world coordinates that need not
/// lie on any plane.
///
/// The `planar` flag is not trusted on its own — it is routinely wrong in
/// files written by other software — so the points are checked against the
/// plane the normal describes. A spline that genuinely wanders in space gets
/// `None`, which is honest: flattening it to XY would move it.
pub fn spline_curve(spline: &SplineEnt) -> Option<PlanarCurve> {
let points: Vec<Vector3> = spline
.control_points
.iter()
.chain(spline.fit_points.iter())
.copied()
.collect();
let first = points.first()?;
let normal = normalized(spline.normal);
let elevation = Vec3::from(xyz(*first)).dot(Vec3::from(xyz(normal)));
let plane = ocs_plane(normal, elevation);
let tolerance = PLANARITY_TOLERANCE * scale_of(&points);
if !points.iter().all(|p| plane.contains(xyz(*p), tolerance)) {
return None;
}
Some(PlanarCurve::new(
plane,
Curve::Nurbs(spline_to_nurbs_on(spline, &plane)?),
))
}
/// The entity's curve in world XY coordinates.
///
/// The editing commands — TRIM, EXTEND, FILLET, OFFSET — work in plan view,
/// where every boundary has to be expressed in one shared frame before two of
/// them can be intersected. [`entity_curve`] gives each entity its own plane,
/// which is right for describing the geometry and wrong for comparing two
/// pieces of it; this folds the plane away.
///
/// `None` when the entity is not a planar curve, and when its plane stands
/// edge-on to the view: a circle seen exactly from the side is a line
/// segment, and there is no honest curve to trim against.
///
/// This is also where an extruded entity stops being read wrong. Reading an
/// arc's `center.x/.y` straight out of the entity treats OCS coordinates as
/// world ones, so an arc carrying the `(0, 0, −1)` normal a MIRROR leaves
/// behind acted as a boundary on the wrong side of the drawing.
pub fn entity_curve_xy(entity: &EntityType) -> Option<Curve> {
let planar = entity_curve(entity)?;
let plane = planar.plane;
if plane.is_xy_aligned() {
// Already world-aligned, so only the elevation has to be dropped and
// any origin offset carried over.
let [x, y, _] = plane.origin;
return if x == 0.0 && y == 0.0 {
Some(planar.curve)
} else {
planar.curve.transformed(&Transform::translation([x, y]))
};
}
// A plane whose normal has no Z component is edge-on: it projects to a
// line, and a curve on it has no plan-view shape.
let normal = plane.normal()?;
if normal[2].abs() <= 1e-12 {
return None;
}
// Otherwise the drop to XY is affine — each plane axis contributes its own
// X and Y — so the curve can follow it exactly rather than being sampled.
planar.curve.transformed(&Transform {
x_axis: [plane.x_axis[0], plane.x_axis[1]].into(),
y_axis: [plane.y_axis[0], plane.y_axis[1]].into(),
origin: [plane.origin[0], plane.origin[1]].into(),
})
}
/// World-space wire points for a curve, cut to the render pass's current
/// chord tolerance.
///
/// The density comes from how far a chord may sag from the curve, not from a
/// segment count: an arc a metre across and one a kilometre across need
/// different numbers of points to look equally round, and the tolerance the
/// render pass carries is already zoom-adaptive.
///
/// Everything stays `f64` to the end. The narrowing belongs at the GPU
/// boundary, where the residual is kept as the low half of a double-single
/// pair; casting local coordinates on the way in throws away precision the
/// shader was built to reconstruct.
pub fn curve_points(curve: &PlanarCurve) -> Vec<[f64; 3]> {
curve.tessellate_within(crate::scene::convert::truck_tess::current_curve_tol())
}
/// The snap candidates an entity's curve offers, in the two channels the
/// wire model carries them in.
#[derive(Debug, Default, Clone)]
pub struct CurveSnap {
/// Centres, quadrants and midpoints, in world coordinates.
pub snap_pts: Vec<(glam::DVec3, SnapHint)>,
/// The ends of the curve and of each of a chain's segments.
pub key_vertices: Vec<[f64; 3]>,
}
/// Every point the entity's own geometry offers to snap to.
///
/// The point of routing this through [`entity_curve`] rather than reading the
/// fields per type: an arc's quadrants, an ellipse's axis ends and a spline's
/// midpoint are all the same question asked of different shapes, and asking
/// it once means the answer is exact everywhere. Previously an arc offered no
/// quadrants at all and a spline's "endpoints" were its control points, which
/// for a control-point spline are not on the curve.
///
/// `None` for anything that is not a planar curve, which keeps the callers
/// that have their own snap sources — text, blocks, dimensions — untouched.
pub fn curve_snap(entity: &EntityType) -> Option<CurveSnap> {
Some(snap_from(&entity_curve(entity)?))
}
/// [`curve_snap`] for a caller that already has the curve.
///
/// The per-type wire builders take a concrete entity rather than an
/// [`EntityType`], and wrapping one back up would mean cloning it on the
/// render path.
pub fn snap_from(curve: &PlanarCurve) -> CurveSnap {
// A chain of straight segments is what `key_vertices` means: the snap
// engine joins consecutive entries and offers the midpoint of each. Only
// a line or a polyline can promise that, so those two put their ends
// there and let their midpoints be derived; everything else names its own
// ends and middle explicitly.
//
// The same distinction is why a polyline's arc-segment centres are not
// emitted: a wire carrying a centre is treated as round elsewhere, which
// a polyline with one bulge in it is not.
let chain = matches!(curve.curve, Curve::Line(_) | Curve::Polyline(_));
let mut out = CurveSnap::default();
let mut push = |world: [f64; 3], hint: SnapHint| {
out.snap_pts.push((glam::DVec3::from_array(world), hint));
};
for point in characteristic_points(&curve.curve) {
let world = curve.plane.point_at(point.point);
match point.kind {
SnapKind::Endpoint if chain => out.key_vertices.push(world),
SnapKind::Endpoint => push(world, SnapHint::Endpoint),
SnapKind::Midpoint if !chain => push(world, SnapHint::Midpoint),
SnapKind::Centre if !chain => push(world, SnapHint::Center),
SnapKind::Quadrant => push(world, SnapHint::Quadrant),
_ => {}
}
}
out
}
/// The magnitude the geometry's own coordinates sit at, for scaling a
/// relative tolerance. Floored at one so geometry near the origin does not
/// end up with a tolerance of nothing.
fn scale_of(points: &[Vector3]) -> f64 {
points
.iter()
.map(|p| p.x.abs().max(p.y.abs()).max(p.z.abs()))
.fold(1.0, f64::max)
}
fn is_default_normal(normal: Vector3) -> bool {
normal.x == 0.0 && normal.y == 0.0 && normal.z == 1.0
}
/// A unit normal, falling back to +Z for the degenerate vector some files
/// store. Zero would make every axis collapse and put the whole entity at
/// one point.
fn normalized(normal: Vector3) -> Vector3 {
match Vec3::from(xyz(normal)).normalize() {
Some(unit) => Vector3::new(unit.x, unit.y, unit.z),
None => Vector3::new(0.0, 0.0, 1.0),
}
}
#[cfg(test)]
mod tests {
use super::*;
use acadrust::entities::lwpolyline::LwVertex;
use acadrust::entities::{Line as LineEnt, Ray as RayEnt, XLine as XLineEnt};
use acadrust::types::Vector2;
use std::f64::consts::{FRAC_PI_2, PI, TAU};
fn v3(x: f64, y: f64, z: f64) -> Vector3 {
Vector3::new(x, y, z)
}
/// Every point the curve reports, against a direct evaluation of the
/// entity's own fields. The property that matters: the converter must not
/// move anything.
fn assert_on(curve: &PlanarCurve, expected: impl Fn(f64) -> [f64; 3]) {
for i in 0..=8 {
let t = i as f64 / 8.0;
let got = curve.point_at(t);
let want = expected(t);
for axis in 0..3 {
assert!(
(got[axis] - want[axis]).abs() < 1e-9,
"t={t}: {got:?} vs {want:?}"
);
}
}
}
#[test]
fn a_flat_circle_lands_on_the_world_xy_plane() {
let mut circle = CircleEnt::default();
circle.center = v3(10.0, 20.0, 4.0);
circle.radius = 3.0;
circle.normal = v3(0.0, 0.0, 1.0);
let curve = entity_curve(&EntityType::Circle(circle)).unwrap();
assert!(curve.plane.is_xy_aligned());
assert_on(&curve, |t| {
let a = t * TAU;
[10.0 + 3.0 * a.cos(), 20.0 + 3.0 * a.sin(), 4.0]
});
}
#[test]
fn an_extruded_arc_uses_the_arbitrary_axis_frame() {
// Normal along −Z: the frame flips, and a converter that ignored it
// would put the arc on the wrong side of the drawing.
let mut arc = ArcEnt::default();
arc.center = v3(5.0, 0.0, 2.0);
arc.radius = 1.0;
arc.start_angle = 0.0;
arc.end_angle = FRAC_PI_2;
arc.normal = v3(0.0, 0.0, -1.0);
let curve = entity_curve(&EntityType::Arc(arc)).unwrap();
assert!(!curve.plane.is_xy_aligned());
assert_on(&curve, |t| {
let a = t * FRAC_PI_2;
let wcs = crate::scene::view::transform::ocs_point_to_wcs(
(5.0 + a.cos(), a.sin(), 2.0),
(0.0, 0.0, -1.0),
);
[wcs.0, wcs.1, wcs.2]
});
}
#[test]
fn a_level_line_shares_the_world_frame() {
let mut line = LineEnt::default();
line.start = v3(1.0, 2.0, 7.0);
line.end = v3(4.0, 6.0, 7.0);
let curve = entity_curve(&EntityType::Line(line)).unwrap();
assert!(curve.plane.is_xy_aligned(), "{:?}", curve.plane);
assert_on(&curve, |t| [1.0 + 3.0 * t, 2.0 + 4.0 * t, 7.0]);
}
#[test]
fn a_sloping_line_still_reports_its_own_points() {
// No XY frame contains it, so the plane is the upright one through
// the line. The points are what must not change.
let mut line = LineEnt::default();
line.start = v3(0.0, 0.0, 0.0);
line.end = v3(3.0, 4.0, 12.0);
let curve = entity_curve(&EntityType::Line(line)).unwrap();
assert!(!curve.plane.is_xy_aligned());
assert_on(&curve, |t| [3.0 * t, 4.0 * t, 12.0 * t]);
}
#[test]
fn a_vertical_line_is_not_a_degenerate_case() {
// `along × Z` is zero here, which is where an unguarded normal would
// collapse the frame and send every point to the origin.
let mut line = LineEnt::default();
line.start = v3(2.0, 3.0, 0.0);
line.end = v3(2.0, 3.0, 10.0);
let curve = entity_curve(&EntityType::Line(line)).unwrap();
assert_on(&curve, |t| [2.0, 3.0, 10.0 * t]);
}
#[test]
fn a_zero_length_line_has_no_curve() {
let mut line = LineEnt::default();
line.start = v3(1.0, 1.0, 1.0);
line.end = v3(1.0, 1.0, 1.0);
assert!(entity_curve(&EntityType::Line(line)).is_none());
}
#[test]
fn an_ellipse_keeps_its_axis_length_and_direction() {
let mut ellipse = EllipseEnt::default();
ellipse.center = v3(10.0, 5.0, 0.0);
ellipse.major_axis = v3(0.0, 4.0, 0.0); // up, length 4
ellipse.minor_axis_ratio = 0.5;
ellipse.start_parameter = 0.0;
ellipse.end_parameter = TAU;
ellipse.normal = v3(0.0, 0.0, 1.0);
let curve = entity_curve(&EntityType::Ellipse(ellipse)).unwrap();
let Curve::Ellipse(arc) = &curve.curve else {
panic!("expected an ellipse");
};
assert!((arc.ellipse.major_radius - 4.0).abs() < 1e-12);
assert!((arc.ellipse.minor_radius - 2.0).abs() < 1e-12);
assert_on(&curve, |t| {
let a = t * TAU;
// Major axis points along +Y, so the roles of the two axes swap.
[10.0 - 2.0 * a.sin(), 5.0 + 4.0 * a.cos(), 0.0]
});
}
#[test]
fn an_ellipse_on_an_extruded_plane_stays_on_it() {
let mut ellipse = EllipseEnt::default();
ellipse.center = v3(0.0, 0.0, 3.0);
ellipse.major_axis = v3(2.0, 0.0, 0.0);
ellipse.minor_axis_ratio = 0.5;
ellipse.start_parameter = 0.0;
ellipse.end_parameter = PI;
ellipse.normal = v3(0.0, 1.0, 0.0);
// The centre is off that plane, so the entity is inconsistent and the
// projection is what decides. What must hold is that the result is a
// planar curve whose points all sit on the plane it reports.
let curve = entity_curve(&EntityType::Ellipse(ellipse)).unwrap();
for point in curve.tessellate(20.0) {
assert!(curve.plane.contains(point, 1e-9), "{point:?}");
}
}
#[test]
fn a_polyline_carries_its_bulges_and_closure() {
let mut polyline = LwPolylineEnt::default();
polyline.elevation = 2.0;
polyline.is_closed = true;
polyline.normal = v3(0.0, 0.0, 1.0);
polyline.vertices = vec![
LwVertex::with_bulge(Vector2::new(0.0, 0.0), 1.0),
LwVertex::from_coords(10.0, 0.0),
];
let curve = entity_curve(&EntityType::LwPolyline(polyline)).unwrap();
assert!(curve.is_closed());
// A bulge of 1 is a half circle. Left to right it dips below.
let points = curve.tessellate(20.0);
assert!(points.iter().all(|p| (p[2] - 2.0).abs() < 1e-12));
assert!(
points.iter().any(|p| p[1] < -4.9),
"the semicircle should reach y = −5"
);
}
#[test]
fn a_two_point_minimum_is_enforced() {
let mut polyline = LwPolylineEnt::default();
polyline.vertices = vec![LwVertex::from_coords(0.0, 0.0)];
assert!(entity_curve(&EntityType::LwPolyline(polyline)).is_none());
}
#[test]
fn a_flat_spline_converts_and_a_spatial_one_does_not() {
let mut spline = SplineEnt::default();
spline.degree = 3;
spline.fit_points = vec![
v3(0.0, 0.0, 5.0),
v3(1.0, 2.0, 5.0),
v3(3.0, 1.0, 5.0),
v3(5.0, 4.0, 5.0),
];
spline.normal = v3(0.0, 0.0, 1.0);
let curve = entity_curve(&EntityType::Spline(spline.clone())).unwrap();
assert!(curve.plane.is_xy_aligned());
for point in curve.tessellate(20.0) {
assert!((point[2] - 5.0).abs() < 1e-9, "{point:?}");
}
// One point lifted out of the plane. The `planar` flag still says
// nothing; the geometry is what is checked.
spline.fit_points[2].z = 9.0;
assert!(entity_curve(&EntityType::Spline(spline)).is_none());
}
fn hints(snap: &CurveSnap, want: SnapHint) -> Vec<glam::DVec3> {
snap.snap_pts
.iter()
.filter(|(_, hint)| std::mem::discriminant(hint) == std::mem::discriminant(&want))
.map(|&(p, _)| p)
.collect()
}
#[test]
fn an_arc_offers_the_quadrants_its_sweep_covers() {
let mut arc = ArcEnt::default();
arc.center = v3(0.0, 0.0, 0.0);
arc.radius = 2.0;
arc.start_angle = 0.0;
arc.end_angle = PI; // the upper half
arc.normal = v3(0.0, 0.0, 1.0);
let snap = curve_snap(&EntityType::Arc(arc)).unwrap();
let quadrants = hints(&snap, SnapHint::Quadrant);
// 0° and 90° and 180° are on it; 270° is not.
assert_eq!(quadrants.len(), 3, "{quadrants:?}");
assert!(quadrants.iter().all(|q| q.y >= -1e-9));
assert_eq!(hints(&snap, SnapHint::Center), vec![glam::DVec3::ZERO]);
assert_eq!(hints(&snap, SnapHint::Midpoint).len(), 1);
// Ends go through the hint channel, not `key_vertices`: a pair there
// would also offer the chord's midpoint, which is not on the arc.
assert_eq!(hints(&snap, SnapHint::Endpoint).len(), 2);
assert!(snap.key_vertices.is_empty());
}
#[test]
fn a_polyline_puts_its_vertices_in_the_chain_channel() {
let mut polyline = LwPolylineEnt::default();
polyline.normal = v3(0.0, 0.0, 1.0);
polyline.vertices = vec![
LwVertex::from_coords(0.0, 0.0),
LwVertex::from_coords(10.0, 0.0),
LwVertex::from_coords(10.0, 5.0),
];
let snap = curve_snap(&EntityType::LwPolyline(polyline)).unwrap();
assert_eq!(snap.key_vertices.len(), 3);
// Midpoints are derived from those by the snap engine, so emitting
// them here as well would offer every one of them twice.
assert!(snap.snap_pts.is_empty(), "{:?}", snap.snap_pts);
}
#[test]
fn a_closed_curve_has_no_ends_to_offer() {
let mut circle = CircleEnt::default();
circle.radius = 1.0;
circle.normal = v3(0.0, 0.0, 1.0);
let snap = curve_snap(&EntityType::Circle(circle)).unwrap();
assert!(snap.key_vertices.is_empty());
assert!(hints(&snap, SnapHint::Endpoint).is_empty());
assert!(hints(&snap, SnapHint::Midpoint).is_empty());
assert_eq!(hints(&snap, SnapHint::Quadrant).len(), 4);
}
#[test]
fn a_closed_fit_point_spline_comes_back_closed() {
// The interpolation behind a fit-point spline is a clamped solve and
// does not model a wrap, so without closing the point list the curve
// ended somewhere else entirely — and a TRIM against it cut nothing
// along the seam.
let mut spline = SplineEnt::default();
spline.degree = 3;
spline.flags.closed = true;
spline.fit_points = vec![
v3(0.0, 0.0, 0.0),
v3(10.0, 0.0, 0.0),
v3(10.0, 10.0, 0.0),
v3(0.0, 10.0, 0.0),
];
spline.normal = v3(0.0, 0.0, 1.0);
let curve = entity_curve(&EntityType::Spline(spline)).unwrap();
let (start, end) = (curve.point_at(0.0), curve.point_at(1.0));
assert!(
(start[0] - end[0]).abs() < 1e-9 && (start[1] - end[1]).abs() < 1e-9,
"{start:?} vs {end:?}"
);
assert!(curve.is_closed());
}
#[test]
fn entities_that_are_not_curves_say_so() {
assert!(entity_curve(&EntityType::Point(Default::default())).is_none());
assert!(entity_curve(&EntityType::Text(Default::default())).is_none());
assert!(entity_curve(&EntityType::Hatch(Default::default())).is_none());
assert!(entity_curve(&EntityType::MText(Default::default())).is_none());
// A 3D polyline is a curve but not a planar one, so it goes the same
// way rather than being flattened.
assert!(entity_curve(&EntityType::Polyline3D(Default::default())).is_none());
}
#[test]
fn a_zero_normal_falls_back_rather_than_collapsing() {
let mut circle = CircleEnt::default();
circle.center = v3(1.0, 2.0, 0.0);
circle.radius = 1.0;
circle.normal = v3(0.0, 0.0, 0.0);
let curve = entity_curve(&EntityType::Circle(circle)).unwrap();
assert!(curve.plane.is_xy_aligned());
assert_eq!(curve.point_at(0.0), [2.0, 2.0, 0.0]);
}
#[test]
fn a_ray_keeps_its_direction_and_a_construction_line_its_extent() {
let mut ray = RayEnt::default();
ray.base_point = v3(1.0, 1.0, 0.0);
ray.direction = v3(2.0, 0.0, 0.0);
let curve = entity_curve(&EntityType::Ray(ray)).unwrap();
assert_eq!(curve.extent(), acadrust::kernel::geom2d::Extent::Forward);
assert_eq!(curve.point_at(1.0), [3.0, 1.0, 0.0]);
let mut line = XLineEnt::default();
line.base_point = v3(0.0, 0.0, 0.0);
line.direction = v3(0.0, 3.0, 0.0);
let curve = entity_curve(&EntityType::XLine(line)).unwrap();
assert_eq!(curve.extent(), acadrust::kernel::geom2d::Extent::Infinite);
assert_eq!(curve.point_at(-1.0), [0.0, -3.0, 0.0]);
}
}

View file

@ -1,7 +1,9 @@
use acadrust::entities::Ellipse;
use crate::t;
use truck_modeling::{builder, BSplineCurve, Curve, Edge, KnotVec, Point3, Wire};
use crate::entities::curve::CurveSnap;
use crate::t;
use crate::command::EntityTransform;
use crate::entities::common::{
center_grip, edit_prop as edit, parse_f64, ro_prop as ro, square_grip,
@ -9,135 +11,94 @@ use crate::entities::common::{
use crate::entities::traits::TruckConvertible;
use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject};
use crate::scene::model::object::{GripApply, GripDef, PropSection};
use crate::scene::model::wire_model::SnapHint;
const TAU: f64 = std::f64::consts::TAU;
fn to_truck(ell: &Ellipse) -> TruckEntity {
let normal = (ell.normal.x, ell.normal.y, ell.normal.z);
let (nx, ny, nz) = normal;
// ELLIPSE is one of the few WCS entities in DXF: `center` (code 10) and
// `major_axis` (code 11) are world coordinates already — unlike ARC /
// CIRCLE, whose centers are OCS. (This used to run both through the
// arbitrary-axis OCS, which misplaced any ellipse whose normal isn't
// Z-up — e.g. the (0,0,-1) result of a mirrored-block explode.)
let (cwx, cwy, cwz) = (ell.center.x, ell.center.y, ell.center.z);
let wcs_maj = glam::Vec3::new(
ell.major_axis.x as f32,
ell.major_axis.y as f32,
ell.major_axis.z as f32,
);
let r_major = wcs_maj.length() as f64;
let r_minor = r_major * ell.minor_axis_ratio;
let t0 = ell.start_parameter;
let mut t1 = ell.end_parameter;
if t1 <= t0 {
t1 += TAU;
}
let u = if r_major > 1e-9 {
wcs_maj / wcs_maj.length()
} else {
glam::Vec3::X
};
// Minor axis direction: WCS_normal × u (both unit vectors, always perpendicular).
let wcs_normal = glam::Vec3::new(nx as f32, ny as f32, nz as f32);
let v_axis = wcs_normal.cross(u);
let center_v3 = glam::DVec3::new(cwx, cwy, cwz);
let is_closed = (t1 - t0 - TAU).abs() < 1e-6;
// CIRCLE, whose centers are OCS. The converter knows that; this used to
// run both through the arbitrary-axis OCS, which misplaced any ellipse
// whose normal isn't Z-up — e.g. the (0,0,-1) result of a mirrored-block
// explode.
let curve = crate::entities::curve::ellipse_curve(ell);
let snap = curve
.as_ref()
.map(crate::entities::curve::snap_from)
.unwrap_or_default();
if is_closed {
let n = 16usize;
let pts_upper: Vec<Point3> = (0..=n)
.map(|i| {
let t = (i as f64 / n as f64) * std::f64::consts::PI;
let lx = (r_major * t.cos()) as f32;
let lz = (r_minor * t.sin()) as f32;
Point3::new(
cwx + (lx * u.x + lz * v_axis.x) as f64,
cwy + (lx * u.y + lz * v_axis.y) as f64,
cwz + (lx * u.z + lz * v_axis.z) as f64,
)
})
.collect();
let pts_lower: Vec<Point3> = (0..=n)
.map(|i| {
let t = std::f64::consts::PI + (i as f64 / n as f64) * std::f64::consts::PI;
let lx = (r_major * t.cos()) as f32;
let lz = (r_minor * t.sin()) as f32;
Point3::new(
cwx + (lx * u.x + lz * v_axis.x) as f64,
cwy + (lx * u.y + lz * v_axis.y) as f64,
cwz + (lx * u.z + lz * v_axis.z) as f64,
)
})
.collect();
let v_pos = builder::vertex(*pts_upper.first().unwrap());
let v_neg = builder::vertex(*pts_upper.last().unwrap());
let kv_u = KnotVec::uniform_knot(1, n);
let kv_l = KnotVec::uniform_knot(1, n);
let spl_u = BSplineCurve::new(kv_u, pts_upper);
let spl_l = BSplineCurve::new(kv_l, pts_lower);
let edge_upper = Edge::new(&v_pos, &v_neg, Curve::BSplineCurve(spl_u));
let edge_lower = Edge::new(&v_neg, &v_pos, Curve::BSplineCurve(spl_l));
let wire: Wire = [edge_upper, edge_lower].into_iter().collect();
// Quadrant points at ±major and ±minor axis endpoints in WCS.
let q = |lx: f64, lz: f64| {
glam::DVec3::new(
cwx + lx * u.x as f64 + lz * v_axis.x as f64,
cwy + lx * u.y as f64 + lz * v_axis.y as f64,
cwz + lx * u.z as f64 + lz * v_axis.z as f64,
)
// The points come from the entity's own curve, cut to the render pass's
// chord tolerance. The two fixed counts this replaced — sixteen per half
// for a full ellipse, thirty-two for an arc — faceted visibly on
// anything large, and the local coordinates were built in `f32`, which
// threw away precision the double-single vertex path exists to keep.
//
// What does not change is the shape of the object. EXTRUDE, REVOLVE and
// SWEEP read their profile out of `Contour` / `Curve` and have no arm for
// a bare point list, so an ellipse handed over as `Lines` would stop
// being usable as either.
let empty = |snap: CurveSnap| TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(Vec::new()),
snap_pts: snap.snap_pts,
tangent_geoms: vec![],
key_vertices: vec![],
fill_tris: vec![],
};
let snap_pts = vec![
(center_v3, SnapHint::Center),
(q(r_major, 0.0), SnapHint::Quadrant),
(q(-r_major, 0.0), SnapHint::Quadrant),
(q(0.0, r_minor), SnapHint::Quadrant),
(q(0.0, -r_minor), SnapHint::Quadrant),
];
let Some(planar) = curve else {
return empty(snap);
};
let points: Vec<Point3> = crate::entities::curve::curve_points(&planar)
.into_iter()
.map(|p| Point3::new(p[0], p[1], p[2]))
.collect();
if points.len() < 2 {
return empty(snap);
}
// Closure decided on the points rather than on the stored parameters. A
// file that writes an end parameter a hair short of a full turn still
// draws a closed ellipse, and an open edge whose two vertices sit on top
// of each other is not something a plane can be attached to.
let ends_meet = {
let (first, last) = (points[0], *points.last().expect("checked non-empty"));
let span = first.x.abs().max(first.y.abs()).max(first.z.abs()).max(1.0);
let gap = first - last;
(gap.x * gap.x + gap.y * gap.y + gap.z * gap.z).sqrt() <= 1e-9 * span
};
let object = if planar.is_closed() || ends_meet {
// Two half-edges rather than one closed edge: a single edge whose
// two vertices coincide is not a wire truck will attach a plane to.
let split = points.len() / 2;
let upper = points[..=split].to_vec();
let lower = points[split..].to_vec();
let v_pos = builder::vertex(points[0]);
let v_neg = builder::vertex(points[split]);
let edge_upper = Edge::new(&v_pos, &v_neg, polyline_curve(upper));
let edge_lower = Edge::new(&v_neg, &v_pos, polyline_curve(lower));
TruckObject::Contour([edge_upper, edge_lower].into_iter().collect::<Wire>())
} else {
let v_start = builder::vertex(points[0]);
let v_end = builder::vertex(*points.last().expect("checked non-empty"));
TruckObject::Curve(Edge::new(&v_start, &v_end, polyline_curve(points)))
};
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Contour(wire),
snap_pts,
object,
snap_pts: snap.snap_pts,
tangent_geoms: vec![],
key_vertices: vec![],
fill_tris: vec![],
}
} else {
let n = 32usize;
let ctrl_pts: Vec<Point3> = (0..=n)
.map(|i| {
let t = t0 + (t1 - t0) * (i as f64 / n as f64);
let lx = (r_major * t.cos()) as f32;
let lz = (r_minor * t.sin()) as f32;
Point3::new(
cwx + (lx * u.x + lz * v_axis.x) as f64,
cwy + (lx * u.y + lz * v_axis.y) as f64,
cwz + (lx * u.z + lz * v_axis.z) as f64,
)
})
.collect();
let kv = KnotVec::uniform_knot(1, n);
let bspline = BSplineCurve::new(kv, ctrl_pts.clone());
let v_start = builder::vertex(*ctrl_pts.first().unwrap());
let v_end = builder::vertex(*ctrl_pts.last().unwrap());
let edge = Edge::new(&v_start, &v_end, Curve::BSplineCurve(bspline));
let pt_start = ctrl_pts.first().unwrap();
let pt_end = ctrl_pts.last().unwrap();
let key_vertices: Vec<[f64; 3]> = vec![
[pt_start.x, pt_start.y, pt_start.z],
[pt_end.x, pt_end.y, pt_end.z],
];
TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Curve(edge),
snap_pts: vec![(center_v3, SnapHint::Center)],
tangent_geoms: vec![],
key_vertices,
fill_tris: vec![],
}
}
}
/// A truck curve that passes through every one of `points` in order.
///
/// Degree one, so the control polygon *is* the curve — the same thing the
/// hand-built sampling produced, without restating how a B-spline is put
/// together at each site.
fn polyline_curve(points: Vec<Point3>) -> Curve {
let spans = points.len().saturating_sub(1);
Curve::BSplineCurve(BSplineCurve::new(KnotVec::uniform_knot(1, spans), points))
}
fn grips(ell: &Ellipse) -> Vec<GripDef> {

View file

@ -1,4 +1,9 @@
use acadrust::entities::{BoundaryEdge, Hatch};
use acadrust::kernel::geom2d::{
Arc as KernelArc, Curve as KernelCurve, Ellipse as KernelEllipse,
EllipseArc as KernelEllipseArc, Line as KernelLine, NurbsCurve as KernelNurbs,
Parameterization, Polyline as KernelPolyline, PolylineVertex as KernelVertex,
};
use glam::Vec3;
use crate::t;
@ -9,54 +14,128 @@ use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Pr
use crate::scene::convert::tess_util::{arc_segments, arc_signed_span, wire_chord_tol, FallbackGeometry};
use crate::scene::model::wire_model::SnapHint;
/// Signed area of every boundary path, summed via the shoelace formula over
/// the OCS boundary vertices (bulge/curved edges approximated by their end
/// points). Returns the absolute total area.
/// The area the hatch's boundary paths enclose.
///
/// Summed edge by edge through the kernel, which measures what each edge
/// actually encloses rather than what a polygon through some of its points
/// would. The version this replaced pushed an arc's *centre* into the ring
/// and a spline's control points — neither of which is on the boundary — so
/// the number it produced was not the area of anything.
///
/// Outer paths and their holes both contribute; the sign of a loop says
/// which it is, so the magnitude of the sum is the region's own area.
fn boundary_area(h: &Hatch) -> f64 {
let mut area = 0.0;
for path in &h.paths {
let mut ring: Vec<[f64; 2]> = Vec::new();
let mut path_area = 0.0;
let mut ends: Vec<[f64; 2]> = Vec::new();
for edge in &path.edges {
match edge {
BoundaryEdge::Polyline(poly) => {
for v in &poly.vertices {
ring.push([v.x, v.y]);
}
}
BoundaryEdge::Line(l) => {
ring.push([l.start.x, l.start.y]);
ring.push([l.end.x, l.end.y]);
}
BoundaryEdge::CircularArc(a) => ring.push([a.center.x, a.center.y]),
BoundaryEdge::EllipticArc(e) => ring.push([e.center.x, e.center.y]),
BoundaryEdge::Spline(s) => {
let src = if !s.fit_points.is_empty() {
s.fit_points.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>()
} else {
s.control_points
.iter()
.map(|p| [p.x, p.y])
.collect::<Vec<_>>()
};
ring.extend(src);
}
}
}
let n = ring.len();
if n < 3 {
let Some(curve) = edge_curve(edge) else {
continue;
};
path_area += curve.enclosed_area();
ends.push(curve.point_at(0.0));
ends.push(curve.point_at(1.0));
}
let mut acc = 0.0;
for i in 0..n {
let a = ring[i];
let b = ring[(i + 1) % n];
acc += a[0] * b[1] - b[0] * a[1];
// Edges are stored as separate pieces, so the chain has to be closed
// by the chord from the last end back to the first — the same closing
// an open polyline gets.
if let (Some(first), Some(last)) = (ends.first(), ends.last()) {
path_area += 0.5 * (last[0] * first[1] - first[0] * last[1]);
}
area += acc.abs() * 0.5;
area += path_area.abs();
}
area
}
/// A hatch boundary edge as a kernel curve, in the hatch's own OCS.
fn edge_curve(edge: &BoundaryEdge) -> Option<KernelCurve> {
Some(match edge {
BoundaryEdge::Line(l) => KernelCurve::Line(KernelLine {
start: [l.start.x, l.start.y],
end: [l.end.x, l.end.y],
}),
BoundaryEdge::CircularArc(a) => {
// A clockwise edge is the same arc walked the other way, so the
// stored angles swap rather than the sweep going negative.
let (start, end) = if a.counter_clockwise {
(a.start_angle, a.end_angle)
} else {
(a.end_angle, a.start_angle)
};
KernelCurve::Arc(KernelArc {
centre: [a.center.x, a.center.y],
radius: a.radius,
start_angle: start,
end_angle: end,
})
}
BoundaryEdge::EllipticArc(e) => {
let major = (e.major_axis_endpoint.x, e.major_axis_endpoint.y);
let radius = major.0.hypot(major.1);
if radius < 1e-12 {
return None;
}
let (start, end) = if e.counter_clockwise {
(e.start_angle, e.end_angle)
} else {
(e.end_angle, e.start_angle)
};
KernelCurve::Ellipse(KernelEllipseArc {
ellipse: KernelEllipse {
centre: [e.center.x, e.center.y],
major_radius: radius,
minor_radius: radius * e.minor_axis_ratio,
major_axis: [major.0 / radius, major.1 / radius],
},
start_parameter: start,
end_parameter: end,
})
}
BoundaryEdge::Polyline(poly) => {
let vertices: Vec<KernelVertex> = poly
.vertices
.iter()
.map(|v| KernelVertex {
position: [v.x, v.y],
// A boundary polyline stores its bulge in the vertex's
// third component.
bulge: v.z,
})
.collect();
if vertices.len() < 2 {
return None;
}
KernelCurve::Polyline(KernelPolyline {
vertices,
closed: poly.is_closed,
})
}
BoundaryEdge::Spline(s) => {
let control: Vec<[f64; 2]> = s.control_points.iter().map(|p| [p.x, p.y]).collect();
// A boundary spline's control points carry their weight in the
// third component; for a polynomial one it is unset and the
// kernel's own default of all-ones applies.
let weights = s
.rational
.then(|| s.control_points.iter().map(|p| p.z).collect::<Vec<f64>>());
let curve = KernelNurbs::new(
s.degree.max(1) as usize,
control,
s.knots.clone(),
weights,
)
.or_else(|| {
// A fit-point boundary spline, interpolated the same way a
// SPLINE entity's is.
let fit: Vec<[f64; 2]> = s.fit_points.iter().map(|p| [p.x, p.y]).collect();
KernelNurbs::interpolate(&fit, None, None, Parameterization::Chord)
})?;
KernelCurve::Nurbs(curve)
}
})
}
/// Mean of every boundary edge point (OCS) — the centroid used to place the
/// pattern-origin grip on the hatch body. `None` when there are no boundary
/// points.

View file

@ -2,6 +2,7 @@ pub mod arc;
pub mod attribute;
pub mod circle;
pub mod common;
pub mod curve;
pub mod dim_override;
pub mod dimension;
pub mod ellipse;

View file

@ -7,6 +7,7 @@
// the MeshModel vertices to match.
use acadrust::entities::{Body, Region, Solid3D, Surface};
use acadrust::kernel::space::polygon;
use crate::t;
use crate::command::EntityTransform;
use crate::entities::common::{center_grip, edit_prop as edit, parse_f64, ro_prop as ro};
@ -203,30 +204,25 @@ fn position_section(prefix: &str, p: &acadrust::types::Vector3) -> PropSection {
/// holes subtract) and halves its magnitude — exact for a single planar
/// loop, approximate for multi-loop or curved regions. Returns zeros when
/// there is nothing to measure.
/// The area and perimeter of a region's boundary wires.
///
/// Measured in space rather than in projection: a region need not lie in a
/// coordinate plane, and flattening it to XY first would report its shadow.
fn region_area_perimeter(wires: &[acadrust::entities::Wire]) -> (f64, f64) {
let mut area_vector = [0.0f64; 3];
let mut perimeter = 0.0;
let (mut nx, mut ny, mut nz) = (0.0, 0.0, 0.0);
for wire in wires {
let pts = &wire.points;
if pts.len() < 2 {
continue;
}
for seg in pts.windows(2) {
let dx = seg[1].x - seg[0].x;
let dy = seg[1].y - seg[0].y;
let dz = seg[1].z - seg[0].z;
perimeter += (dx * dx + dy * dy + dz * dz).sqrt();
}
let n = pts.len();
for i in 0..n {
let a = &pts[i];
let b = &pts[(i + 1) % n];
nx += (a.y - b.y) * (a.z + b.z);
ny += (a.z - b.z) * (a.x + b.x);
nz += (a.x - b.x) * (a.y + b.y);
let ring: Vec<[f64; 3]> = wire.points.iter().map(|p| [p.x, p.y, p.z]).collect();
// Wires arrive as open chains that the region closes between them, so
// the length is the chain's and the area vectors are summed before
// being measured — one wire's contribution is not an area on its own.
perimeter += polygon::chain_length(&ring);
let piece = polygon::area_vector(&ring);
for axis in 0..3 {
area_vector[axis] += piece[axis];
}
}
let area = 0.5 * (nx * nx + ny * ny + nz * nz).sqrt();
let area = (area_vector[0].powi(2) + area_vector[1].powi(2) + area_vector[2].powi(2)).sqrt();
(area, perimeter)
}

View file

@ -19,23 +19,48 @@ fn to_truck(spl: &Spline) -> TruckEntity {
// smooth Catmull-Rom curve through them and hand it back as a dense
// polyline so it still draws.
if spl.fit_points.len() >= 2 {
let closed = spl.flags.closed || spl.flags.periodic;
// A fit spline is a C² cubic through its fit points honouring the
// stored end tangents. Catmull-Rom ignores those tangents (its ends
// use local slopes), so an open curve peels away from the true shape
// at the ends; interpolate properly instead. Closed splines wrap,
// which the clamped solve doesn't model, so they keep Catmull-Rom.
let pts = if closed {
// Drawn from the same interpolation TRIM and snap use, so that
// what is on screen is the curve those commands cut. The two had
// parted: this drew a C² cubic solved here (or a Catmull-Rom for a
// closed spline), while the kernel interpolated its own — near
// enough to look right and far enough apart that a trim landed
// beside the line it was aimed at.
let planar = crate::entities::curve::spline_curve(spl);
let pts = match &planar {
Some(curve) => crate::entities::curve::curve_points(curve),
// A fit spline through points in space is not a planar curve,
// so the kernel has nothing to say about it and the solve
// here remains the only description of its shape.
None if spl.flags.closed || spl.flags.periodic => {
catmull_rom_polyline(&spl.fit_points, true)
} else {
fit_spline_polyline(spl)
}
None => fit_spline_polyline(spl),
};
let snap = planar
.as_ref()
.map(crate::entities::curve::snap_from)
.unwrap_or_default();
// Fit points are on the curve, so they stay on offer as ends in
// their own right — but never through `key_vertices`, whose
// consecutive entries the snap engine joins and offers the middle
// of. The chord between two fit points of a curvy spline does not
// run along it.
let mut snap_pts = snap.snap_pts;
snap_pts.extend(spl.fit_points.iter().map(|p| {
(
glam::DVec3::new(p.x, p.y, p.z),
crate::scene::model::wire_model::SnapHint::Endpoint,
)
}));
let key_vertices = if planar.is_some() {
Vec::new()
} else {
spl.fit_points.iter().map(|p| [p.x, p.y, p.z]).collect()
};
let key_vertices: Vec<[f64; 3]> =
spl.fit_points.iter().map(|p| [p.x, p.y, p.z]).collect();
return TruckEntity {
pick_tris: Vec::new(),
object: TruckObject::Lines(pts),
snap_pts: vec![],
snap_pts,
tangent_geoms: vec![],
key_vertices,
fill_tris: vec![],
@ -87,12 +112,42 @@ fn to_truck(spl: &Spline) -> TruckEntity {
)
};
let snap_source = if !spl.fit_points.is_empty() {
&spl.fit_points
} else {
// Snap sources from the spline's own curve. A spline is not a chain of
// straight segments, so nothing goes in `key_vertices`: consecutive
// entries there are joined and their midpoints offered, and the chord
// between two fit points of a curvy spline does not run along it.
//
// Fit points are on the curve and stay on offer, as ends in their own
// right. Control points are not on the curve and are dropped — snapping
// to one put the cursor in empty space beside the geometry.
let curve_snap = crate::entities::curve::spline_curve(spl)
.map(|curve| crate::entities::curve::snap_from(&curve));
let (snap_pts, key_vertices) = match curve_snap {
Some(snap) => {
let mut points = snap.snap_pts;
points.extend(spl.fit_points.iter().map(|p| {
(
glam::DVec3::new(p.x, p.y, p.z),
crate::scene::model::wire_model::SnapHint::Endpoint,
)
}));
(points, Vec::new())
}
// A spline through points in space is not a planar curve, so the
// kernel has nothing to say about it. Its stored points remain the
// only thing to offer.
None => {
let source = if spl.fit_points.is_empty() {
&spl.control_points
} else {
&spl.fit_points
};
(
Vec::new(),
source.iter().map(|p| [p.x, p.y, p.z]).collect::<Vec<_>>(),
)
}
};
let key_vertices: Vec<[f64; 3]> = snap_source.iter().map(|p| [p.x, p.y, p.z]).collect();
let is_closed = spl.flags.closed || spl.flags.periodic;
let gap = {
@ -120,7 +175,7 @@ fn to_truck(spl: &Spline) -> TruckEntity {
TruckEntity {
pick_tris: Vec::new(),
object,
snap_pts: vec![],
snap_pts,
tangent_geoms: vec![],
key_vertices,
fill_tris: vec![],

View file

@ -41,10 +41,18 @@ pub enum Language {
RuRu,
#[serde(rename = "zh-CN")]
ZhCn,
#[serde(rename = "es-ES")]
EsEs,
#[serde(rename = "pt-BR")]
PtBr,
#[serde(rename = "ar-SA")]
ArSa,
#[serde(rename = "ja-JP")]
JaJp,
}
impl Language {
pub const ALL: [Language; 9] = [
pub const ALL: [Language; 13] = [
Language::System,
Language::EnUs,
Language::TrTr,
@ -54,6 +62,10 @@ impl Language {
Language::HiIn,
Language::RuRu,
Language::ZhCn,
Language::EsEs,
Language::PtBr,
Language::ArSa,
Language::JaJp,
];
fn requested(self) -> Vec<i18n_embed::unic_langid::LanguageIdentifier> {
@ -67,6 +79,10 @@ impl Language {
Language::HiIn => vec!["hi-IN".parse().expect("valid locale")],
Language::RuRu => vec!["ru-RU".parse().expect("valid locale")],
Language::ZhCn => vec!["zh-CN".parse().expect("valid locale")],
Language::EsEs => vec!["es-ES".parse().expect("valid locale")],
Language::PtBr => vec!["pt-BR".parse().expect("valid locale")],
Language::ArSa => vec!["ar-SA".parse().expect("valid locale")],
Language::JaJp => vec!["ja-JP".parse().expect("valid locale")],
}
}
@ -81,6 +97,10 @@ impl Language {
Language::HiIn => crate::tr!("language-hindi"),
Language::RuRu => crate::tr!("language-russian"),
Language::ZhCn => crate::tr!("language-chinese-simplified"),
Language::EsEs => crate::tr!("language-spanish"),
Language::PtBr => crate::tr!("language-portuguese"),
Language::ArSa => crate::tr!("language-arabic"),
Language::JaJp => crate::tr!("language-japanese"),
}
}
}
@ -131,6 +151,12 @@ fn load_language(
.and_then(|document| document.document_element())
{
let _ = root.set_attribute("lang", &language.to_string());
let direction = if language.to_string().starts_with("ar") {
"rtl"
} else {
"ltr"
};
let _ = root.set_attribute("dir", direction);
}
}
Ok(())

View file

@ -1,9 +1,11 @@
use acadrust::kernel::space::polygon;
use acadrust::{EntityType, Handle};
use glam::DVec3;
use crate::command::{
AreaPreviewRegion, AreaPreviewSource, CadCommand, CmdOption, CmdResult, SelectionEntity,
};
use crate::entities::curve::entity_curve;
use crate::entities::traits::EntityTypeOps;
use crate::scene::model::wire_model::WireModel;
@ -47,100 +49,50 @@ impl AreaCommand {
CmdOption { label, keyword: keyword.to_string() }
}
/// Area and perimeter of a ring of picked points.
///
/// Measured in space rather than in projection — a picked sequence need
/// not lie in a coordinate plane, and flattening it to XY first would
/// report its shadow.
fn point_measurement(points: &[DVec3], close_perimeter: bool) -> AreaMeasurement {
if points.len() < 2 {
return AreaMeasurement { area: 0.0, perimeter: Some(0.0) };
}
let origin = points[0];
let mut area_vector = DVec3::ZERO;
for index in 0..points.len() {
let a = points[index] - origin;
let b = points[(index + 1) % points.len()] - origin;
area_vector += a.cross(b);
let ring: Vec<[f64; 3]> = points.iter().map(|p| [p.x, p.y, p.z]).collect();
let perimeter = if close_perimeter {
polygon::perimeter(&ring)
} else {
polygon::chain_length(&ring)
};
AreaMeasurement {
area: polygon::area(&ring),
perimeter: Some(perimeter),
}
}
let mut perimeter = points
.windows(2)
.map(|pair| (pair[1] - pair[0]).length())
.sum::<f64>();
if close_perimeter {
perimeter += (points[0] - points[points.len() - 1]).length();
}
AreaMeasurement { area: area_vector.length() * 0.5, perimeter: Some(perimeter) }
}
fn bulged_polyline_measurement(
points: &[(f64, f64)],
bulges: &[f64],
closed: bool,
) -> AreaMeasurement {
let count = points.len();
if count < 2 {
return AreaMeasurement { area: 0.0, perimeter: Some(0.0) };
}
let segment_count = if closed { count } else { count - 1 };
let origin = points[0];
let mut signed_area = 0.0;
let mut perimeter = 0.0;
for index in 0..segment_count {
let a = points[index];
let b = points[(index + 1) % count];
let local_a = (a.0 - origin.0, a.1 - origin.1);
let local_b = (b.0 - origin.0, b.1 - origin.1);
signed_area += 0.5 * (local_a.0 * local_b.1 - local_b.0 * local_a.1);
let chord = (b.0 - a.0).hypot(b.1 - a.1);
let bulge = bulges.get(index).copied().unwrap_or(0.0);
if bulge.abs() < 1e-12 || chord <= 1e-12 {
perimeter += chord;
continue;
}
let angle = 4.0 * bulge.atan();
let sine = (angle * 0.5).sin().abs();
if sine <= 1e-12 {
perimeter += chord;
continue;
}
let radius = chord / (2.0 * sine);
signed_area += 0.5 * radius * radius * (angle - angle.sin());
perimeter += radius * angle.abs();
}
AreaMeasurement { area: signed_area.abs(), perimeter: Some(perimeter) }
/// Area and perimeter of an entity whose geometry is a planar curve.
///
/// Both exact: the kernel measures the curve rather than a polygon
/// through some of its points, so a bulged polyline counts each arc's own
/// bite and length, and a spline is integrated rather than sampled.
fn curve_measurement(entity: &EntityType) -> Option<AreaMeasurement> {
let curve = entity_curve(entity)?;
let perimeter = curve.length();
Some(AreaMeasurement {
area: curve.curve.enclosed_area().abs(),
perimeter: perimeter.is_finite().then_some(perimeter),
})
}
fn entity_measurement(entity: &EntityType) -> Option<AreaMeasurement> {
// Anything the converter recognises is measured from its own curve.
if let Some(measurement) = Self::curve_measurement(entity) {
return Some(measurement);
}
match entity {
EntityType::LwPolyline(polyline) => {
let points = polyline
.vertices
.iter()
.map(|vertex| (vertex.location.x, vertex.location.y))
.collect::<Vec<_>>();
let bulges = polyline.vertices.iter().map(|vertex| vertex.bulge).collect::<Vec<_>>();
Some(Self::bulged_polyline_measurement(
&points,
&bulges,
polyline.is_closed,
))
}
EntityType::Polyline2D(polyline) => {
let points = polyline
.vertices
.iter()
.map(|vertex| (vertex.location.x, vertex.location.y))
.collect::<Vec<_>>();
let bulges = polyline.vertices.iter().map(|vertex| vertex.bulge).collect::<Vec<_>>();
Some(Self::bulged_polyline_measurement(
&points,
&bulges,
polyline.is_closed(),
))
}
// What is left is the genuinely spatial: a 3D polyline, and a
// spline whose points wander off any plane. Both are measured as
// rings of points in space.
EntityType::Polyline(polyline) => {
let points = polyline
.vertices

View file

@ -1,12 +1,12 @@
// DIVIDE command — place Point entities at N equal intervals along an entity.
// MEASURE command — place Point entities at fixed-distance intervals along an entity.
use std::f64::consts::PI;
use acadrust::entities::Point as PointEnt;
use acadrust::kernel::space::PlanarCurve;
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use glam::DVec3;
use crate::entities::curve::entity_curve;
use crate::t;
use crate::command::{CadCommand, CmdResult};
@ -164,16 +164,12 @@ pub fn divide_entity(entity: &EntityType, n: usize) -> Vec<EntityType> {
if n < 2 {
return vec![];
}
let total = entity_length(entity);
if total < 1e-10 {
let Some((curve, total)) = measurable(entity) else {
return vec![];
}
};
let step = total / n as f64;
(1..n)
.filter_map(|k| {
let t = step * k as f64;
point_at_distance(entity, t).map(make_point)
})
.map(|k| make_point(curve.point_at_distance(step * k as f64)))
.collect()
}
@ -182,135 +178,30 @@ pub fn measure_entity(entity: &EntityType, segment_length: f64) -> Vec<EntityTyp
if segment_length <= 0.0 {
return vec![];
}
let total = entity_length(entity);
if total < 1e-10 {
let Some((curve, total)) = measurable(entity) else {
return vec![];
}
};
let mut pts = Vec::new();
let mut t = segment_length;
while t < total - 1e-6 {
if let Some(p) = point_at_distance(entity, t) {
pts.push(make_point(p));
}
t += segment_length;
let mut walked = segment_length;
while walked < total - 1e-6 {
pts.push(make_point(curve.point_at_distance(walked)));
walked += segment_length;
}
pts
}
fn make_point(pos: Vector3) -> EntityType {
fn make_point(pos: [f64; 3]) -> EntityType {
let mut p = PointEnt::new();
p.location = pos;
p.location = Vector3::new(pos[0], pos[1], pos[2]);
EntityType::Point(p)
}
fn entity_length(entity: &EntityType) -> f64 {
match entity {
EntityType::Line(l) => {
let dx = l.end.x - l.start.x;
let dy = l.end.y - l.start.y;
let dz = l.end.z - l.start.z;
(dx * dx + dy * dy + dz * dz).sqrt()
}
EntityType::Arc(a) => {
let span = arc_span_rad(a.start_angle, a.end_angle);
a.radius * span
}
EntityType::Circle(c) => 2.0 * PI * c.radius,
EntityType::LwPolyline(p) => {
let n = p.vertices.len();
if n < 2 {
return 0.0;
}
let segs = if p.is_closed { n } else { n - 1 };
(0..segs)
.map(|i| {
let v0 = &p.vertices[i];
let v1 = &p.vertices[(i + 1) % n];
let dx = v1.location.x - v0.location.x;
let dy = v1.location.y - v0.location.y;
(dx * dx + dy * dy).sqrt()
})
.sum()
}
_ => 0.0,
}
}
fn point_at_distance(entity: &EntityType, d: f64) -> Option<Vector3> {
match entity {
EntityType::Line(l) => {
let total = entity_length(entity);
if total < 1e-10 {
return None;
}
let t = (d / total).clamp(0.0, 1.0);
Some(Vector3::new(
l.start.x + t * (l.end.x - l.start.x),
l.start.y + t * (l.end.y - l.start.y),
l.start.z + t * (l.end.z - l.start.z),
))
}
EntityType::Arc(a) => {
let span = arc_span_rad(a.start_angle, a.end_angle);
let t = d / a.radius; // arc_length = r * theta
if t > span {
return None;
}
let angle = a.start_angle + t;
Some(Vector3::new(
a.center.x + a.radius * angle.cos(),
a.center.y + a.radius * angle.sin(),
a.center.z,
))
}
EntityType::Circle(c) => {
let circumference = 2.0 * PI * c.radius;
if circumference < 1e-10 {
return None;
}
let angle = 2.0 * PI * (d / circumference);
Some(Vector3::new(
c.center.x + c.radius * angle.cos(),
c.center.y + c.radius * angle.sin(),
c.center.z,
))
}
EntityType::LwPolyline(p) => {
let n = p.vertices.len();
if n < 2 {
return None;
}
let segs = if p.is_closed { n } else { n - 1 };
let mut acc = 0.0f64;
for i in 0..segs {
let v0 = &p.vertices[i];
let v1 = &p.vertices[(i + 1) % n];
let dx = v1.location.x - v0.location.x;
let dy = v1.location.y - v0.location.y;
let seg_len = (dx * dx + dy * dy).sqrt();
if acc + seg_len >= d - 1e-10 {
let t = (d - acc) / seg_len.max(1e-10);
return Some(Vector3::new(
v0.location.x + t * dx,
v0.location.y + t * dy,
p.elevation,
));
}
acc += seg_len;
}
None
}
_ => None,
}
}
fn arc_span_rad(start: f64, end: f64) -> f64 {
let span = (end - start).rem_euclid(2.0 * PI);
if span < 1e-6 {
2.0 * PI
} else {
span
}
/// The entity's curve and its length, or `None` for anything that cannot be
/// walked along — a hatch, a block, an unbounded ray.
fn measurable(entity: &EntityType) -> Option<(PlanarCurve, f64)> {
let curve = entity_curve(entity)?;
let total = curve.curve.length();
(total.is_finite() && total > 1e-10).then_some((curve, total))
}

View file

@ -422,102 +422,13 @@ impl CadCommand for ArrayPolarCommand {
// → Returns BatchCopy with Translate transforms derived from path samples.
use acadrust::EntityType;
use crate::entities::curve::entity_curve;
use std::f64::consts::PI as FPI;
use std::f64::consts::TAU as FTAU;
// ── Path geometry helpers ──────────────────────────────────────────────────
/// Tessellate an LwPolyline into dense WCS points, matching the renderer:
/// bulge arcs use the canonical `BulgeArc` (correct centre side + signed
/// sweep) and every point is mapped OCS→WCS via the polyline's normal. The
/// previous ad-hoc bulge math placed the arc centre on the wrong side of the
/// chord and ignored the extrusion, so arc-segment paths came out mirrored.
fn lw_dense_pts(p: &acadrust::entities::LwPolyline) -> Vec<DVec3> {
use crate::entities::common::BulgeArc;
use crate::scene::view::transform::ocs_point_to_wcs;
let verts = &p.vertices;
if verts.is_empty() {
return vec![];
}
let normal = (p.normal.x, p.normal.y, p.normal.z);
let elev = p.elevation;
let to_wcs = |x: f64, y: f64| -> DVec3 {
let (wx, wy, wz) = ocs_point_to_wcs((x, y, elev), normal);
DVec3::new(wx, wy, wz)
};
let n = verts.len();
let segs = if p.is_closed { n } else { n.saturating_sub(1) };
let mut out: Vec<DVec3> = vec![];
for i in 0..segs {
let v0 = &verts[i];
let v1 = &verts[(i + 1) % n];
let p0 = [v0.location.x, v0.location.y];
let p1 = [v1.location.x, v1.location.y];
if out.is_empty() {
out.push(to_wcs(p0[0], p0[1]));
}
let bulge = v0.bulge;
match (bulge.abs() >= 1e-9)
.then(|| BulgeArc::from_bulge(p0, p1, bulge))
.flatten()
{
Some(arc) => {
let steps = ((arc.radius * arc.sweep.abs() / 2.0).ceil() as usize).clamp(4, 64);
for j in 1..=steps {
let s = arc.sample(j as f64 / steps as f64);
out.push(to_wcs(s[0], s[1]));
}
}
None => out.push(to_wcs(p1[0], p1[1])),
}
}
out
}
/// Walk `pts` (ordered) and return `count` points at equal arc-length spacing.
fn subsample_equidistant(pts: &[DVec3], count: usize) -> Vec<DVec3> {
if count == 0 {
return vec![];
}
if pts.len() < 2 {
return vec![pts.first().copied().unwrap_or(DVec3::ZERO); count];
}
let mut cum = vec![0.0f64; pts.len()];
for i in 1..pts.len() {
cum[i] = cum[i - 1] + pts[i].distance(pts[i - 1]);
}
let total = *cum.last().unwrap();
if total < 1e-9 {
return vec![pts[0]; count];
}
let mut out = Vec::with_capacity(count);
let mut seg = 0usize;
for i in 0..count {
let target = if count > 1 {
total * i as f64 / (count - 1) as f64
} else {
0.0
};
while seg + 2 < pts.len() && cum[seg + 1] < target - 1e-9 {
seg += 1;
}
let seg_len = cum[seg + 1] - cum[seg];
let t = if seg_len > 1e-9 {
(target - cum[seg]) / seg_len
} else {
0.0
};
out.push(pts[seg].lerp(pts[seg + 1], t.clamp(0.0, 1.0)));
}
out
}
// ── State machine ──────────────────────────────────────────────────────────
@ -570,67 +481,39 @@ impl ArrayPathCommand {
}
/// Sample `count` evenly-spaced points along `entity`.
/// `count` points spaced evenly **by distance** along the path.
///
/// By distance, not by parameter. The two agree on a line, a circle and a
/// circular arc, which is how a per-type version got away with confusing
/// them; on an ellipse or a spline they do not, and copies laid out at
/// even parameters bunch up wherever the parameter runs slow. The path's
/// own OCS is honoured too, so an arc carrying a mirrored normal is
/// walked along the side it is drawn on.
fn sample_path(entity: &EntityType, count: usize) -> Vec<DVec3> {
if count == 0 {
return vec![];
}
match entity {
EntityType::Line(l) => {
let p0 = DVec3::new(l.start.x, l.start.y, 0.0);
let p1 = DVec3::new(l.end.x, l.end.y, 0.0);
let d = (count - 1).max(1) as f64;
(0..count).map(|i| p0.lerp(p1, i as f64 / d)).collect()
let Some(curve) = entity_curve(entity) else {
return vec![DVec3::ZERO; count];
};
let total = curve.length();
if !total.is_finite() || total <= 0.0 {
return vec![DVec3::ZERO; count];
}
EntityType::Arc(a) => {
// Sample in WCS via the arc's OCS basis so flipped-normal arcs
// (normal = -Z) are traced on the same side the renderer draws,
// matching `entities::arc::to_truck`. Sampling the raw centre +
// angle ignores the extrusion and lands on the mirrored arc.
use crate::scene::view::transform::{ocs_axes, ocs_point_to_wcs};
let normal = (a.normal.x, a.normal.y, a.normal.z);
let (ax, ay) = ocs_axes(normal);
let (cx, cy, cz) =
ocs_point_to_wcs((a.center.x, a.center.y, a.center.z), normal);
let r = a.radius;
let (sa, ea) = (a.start_angle, a.end_angle);
let end = if ea >= sa { ea } else { ea + std::f64::consts::TAU };
let span = end - sa;
let d = (count - 1).max(1) as f64;
// A closed path has no far end to stop short of, so the last copy
// must not land on top of the first.
let steps = if curve.is_closed() {
count as f64
} else {
(count - 1).max(1) as f64
};
(0..count)
.map(|i| {
let ang = sa + span * (i as f64 / d);
let (c, s) = (ang.cos(), ang.sin());
DVec3::new(
cx + r * c * ax.0 + r * s * ay.0,
cy + r * c * ax.1 + r * s * ay.1,
cz + r * c * ax.2 + r * s * ay.2,
)
let p = curve.point_at_distance(total * (i as f64 / steps));
DVec3::new(p[0], p[1], p[2])
})
.collect()
}
EntityType::Circle(c) => {
use crate::scene::view::transform::{ocs_axes, ocs_point_to_wcs};
let normal = (c.normal.x, c.normal.y, c.normal.z);
let (ax, ay) = ocs_axes(normal);
let (cx, cy, cz) =
ocs_point_to_wcs((c.center.x, c.center.y, c.center.z), normal);
let r = c.radius;
(0..count)
.map(|i| {
let ang = i as f64 / count as f64 * std::f64::consts::TAU;
let (cs, sn) = (ang.cos(), ang.sin());
DVec3::new(
cx + r * cs * ax.0 + r * sn * ay.0,
cy + r * cs * ax.1 + r * sn * ay.1,
cz + r * cs * ax.2 + r * sn * ay.2,
)
})
.collect()
}
EntityType::LwPolyline(p) => subsample_equidistant(&lw_dense_pts(p), count),
_ => vec![DVec3::ZERO; count],
}
}
/// Continuous path-tangent angle (XY) at each sample, via central
/// differences and unwrapped so the sequence stays smooth across an arc

View file

@ -6,6 +6,7 @@
//! geometric curvature (G2).
use acadrust::entities::{EntityCommon, Spline};
use acadrust::kernel::space::curve as space_curve;
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use glam::DVec3;
@ -628,49 +629,29 @@ fn curve_frame(
})
}
/// The curvature vector at `point` of the circle through the three points.
fn circumcircle_curvature(point: DVec3, next: DVec3, third: DVec3) -> DVec3 {
let u = next - point;
let v = third - point;
let cross = u.cross(v);
let denominator = 2.0 * cross.length_squared();
if denominator <= EPS {
return DVec3::ZERO;
}
let center_offset =
(u.length_squared() * v.cross(cross) + v.length_squared() * cross.cross(u)) / denominator;
let radius_squared = center_offset.length_squared();
if radius_squared <= EPS {
DVec3::ZERO
} else {
center_offset / radius_squared
}
let curvature = space_curve::curvature_through(
[point.x, point.y, point.z],
[next.x, next.y, next.z],
[third.x, third.y, third.z],
);
DVec3::new(curvature[0], curvature[1], curvature[2])
}
/// A Bézier control polygon sampled into a polyline.
///
/// De Casteljau, from the kernel. What was here summed Bernstein terms with
/// binomial coefficients out of a lookup table that had entries for degrees
/// three and five and returned one for everything else — correct only for
/// the two the blend happens to build today, and quietly wrong the moment a
/// third was added.
fn sample_bezier(control_points: &[Vector3], segments: usize) -> Vec<[f64; 3]> {
let degree = control_points.len().saturating_sub(1);
(0..=segments)
.map(|step| {
let t = step as f64 / segments as f64;
let mut point = DVec3::ZERO;
for (index, control) in control_points.iter().enumerate() {
let coefficient = binomial(degree, index) as f64
* t.powi(index as i32)
* (1.0 - t).powi((degree - index) as i32);
point += coefficient * to_dvec(*control);
}
[point.x, point.y, point.z]
})
.collect()
}
fn binomial(n: usize, k: usize) -> usize {
match (n, k) {
(3, 0 | 3) | (5, 0 | 5) => 1,
(3, 1 | 2) => 3,
(5, 1 | 4) => 5,
(5, 2 | 3) => 10,
_ => 1,
}
let control: Vec<[f64; 3]> = control_points
.iter()
.map(|p| [p.x, p.y, p.z])
.collect();
space_curve::bezier_points(&control, segments)
}
fn ocs_to_dvec(x: f64, y: f64, z: f64, normal: DVec3) -> DVec3 {

View file

@ -15,8 +15,11 @@ use acadrust::entities::{Arc as ArcEnt, Line as LineEnt, LwPolyline};
use super::geom;
use super::geom::{arc_points as arc_pts, line_line as ll, normalize_angle as norm_angle};
use acadrust::kernel::geom2d::{
circle_circle_points as circle_circle_pts, fillet_between_rays, line_circle,
circle_circle_points as circle_circle_pts, fillet_between_rays, fillets_between, line_circle,
Arc as KernelArc, Curve as KernelCurve, Fillet as KernelFillet,
Line as KernelLine, Tolerance,
};
use acadrust::entities::EntityCommon;
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use glam::DVec3;
@ -24,6 +27,11 @@ use crate::t;
const TAU: f64 = std::f64::consts::TAU;
/// How close two positions must be to count as one while a fillet is being
/// placed. Loose enough to survive the rounding in a drawing's stored
/// coordinates, tight enough not to merge two distinct candidate centres.
const FILLET_TOLERANCE: f64 = 1.0e-9;
use crate::command::{CadCommand, CmdResult};
use crate::modules::draw::defaults;
use crate::modules::IconKind;
@ -677,6 +685,58 @@ fn rebuild_poly_from_trimmed_line(
}
/// Fillet a Line and an Arc.
/// Fillets of `radius` tangent to both curves, nearest pick first.
///
/// The candidates come from the kernel, which finds them by crossing the two
/// curves' offsets — the construction that works whether or not the curves
/// meet. What is left here is the choice between them, which needs the two
/// pick points and so cannot be made anywhere else.
fn ranked_fillets(
a: &KernelCurve,
click_a: [f64; 2],
b: &KernelCurve,
click_b: [f64; 2],
radius: f64,
) -> Vec<KernelFillet> {
let reach = |from: [f64; 2], to: [f64; 2]| (to[0] - from[0]).hypot(to[1] - from[1]);
let mut found = fillets_between(a, b, radius, Tolerance::new(FILLET_TOLERANCE));
found.sort_by(|first, second| {
let one = reach(click_a, first.tangent1) + reach(click_b, first.tangent2);
let other = reach(click_a, second.tangent1) + reach(click_b, second.tangent2);
one.total_cmp(&other)
});
found
}
/// The entity's shape as a kernel curve.
fn line_curve(l: &LineEnt) -> KernelCurve {
KernelCurve::Line(KernelLine {
start: [l.start.x, l.start.y],
end: [l.end.x, l.end.y],
})
}
fn arc_curve(a: &ArcEnt) -> KernelCurve {
KernelCurve::Arc(KernelArc {
centre: [a.center.x, a.center.y],
radius: a.radius,
start_angle: a.start_angle,
end_angle: a.end_angle,
})
}
/// An arc entity built from a kernel fillet.
fn fillet_entity(fillet: &KernelFillet, radius: f64, z: f64, common: &EntityCommon) -> ArcEnt {
let mut arc = ArcEnt::new();
arc.common = common.clone();
arc.common.handle = Handle::NULL;
arc.center = Vector3::new(fillet.centre[0], fillet.centre[1], z);
arc.radius = radius;
arc.start_angle = fillet.start_angle;
arc.end_angle = fillet.end_angle;
arc
}
fn fillet_line_arc(
line: &LineEnt,
click_line: [f64; 2],
@ -723,116 +783,47 @@ fn fillet_line_arc(
return Some((EntityType::Line(new_line), EntityType::Arc(new_arc), None));
}
// For r>0: find the fillet arc center — tangent to both the line and the circle.
// The fillet circle center lies at distance (radius) from the line
// and at distance |ar ± radius| from the arc center.
// Sign: outside=ar+radius (external), inside=ar-radius (internal).
let perp_x = -u[1];
let perp_y = u[0];
let mut best: Option<(EntityType, EntityType, EntityType)> = None;
let mut best_dist = f64::MAX;
for sign_perp in &[-1.0_f64, 1.0_f64] {
for sign_circle in &[-1.0_f64, 1.0_f64] {
// Candidate fillet center offset from line by ±radius in perp direction.
// Find point on offset line closest to arc center at distance |ar + sign*radius|.
let off_dist = ar + sign_circle * radius;
if off_dist < 1e-9 {
continue;
}
// The fillet center is at distance `off_dist` from the arc center
// and also at distance `radius` from the line (perpendicular).
// Parametrize: fc = p1 + t*u + sign_perp*radius*perp
// |fc - ac|^2 = off_dist^2
// (p1[0] + t*u[0] + sign_perp*radius*perp_x - ac[0])^2 + ...= off_dist^2
let qx = p1[0] + sign_perp * radius * perp_x - ac[0];
let qy = p1[1] + sign_perp * radius * perp_y - ac[1];
// (qx + t*u[0])^2 + (qy + t*u[1])^2 = off_dist^2
let qa = u[0] * u[0] + u[1] * u[1]; // = 1.0
let qb = 2.0 * (qx * u[0] + qy * u[1]);
let qc = qx * qx + qy * qy - off_dist * off_dist;
let disc = qb * qb - 4.0 * qa * qc;
if disc < 0.0 {
continue;
}
let sq = disc.sqrt();
for &sign_t in &[-1.0_f64, 1.0_f64] {
let t_fc = (-qb + sign_t * sq) / (2.0 * qa);
let fc = [
p1[0] + t_fc * u[0] + sign_perp * radius * perp_x,
p1[1] + t_fc * u[1] + sign_perp * radius * perp_y,
];
// Tangent point on the line
let tp_line = [p1[0] + t_fc * u[0], p1[1] + t_fc * u[1]];
// Tangent point on the arc circle
let fd = [(ac[0] - fc[0]), (ac[1] - fc[1])];
let fdl = (fd[0] * fd[0] + fd[1] * fd[1]).sqrt().max(1e-12);
let tp_arc = [ac[0] + fd[0] / fdl * ar, ac[1] + fd[1] / fdl * ar];
// The tangent point on the arc must be within the arc's angular range
let tp_arc_angle = arc_angle_at(ac, tp_arc);
// The candidates come from the kernel: a circle of `radius` tangent to
// both has its centre where an offset of the line crosses an offset of
// the arc's circle. What was here enumerated the same four combinations
// by hand, with its own quadratic and its own sign conventions.
for fillet in ranked_fillets(
&line_curve(line),
click_line,
&arc_curve(arc),
click_arc,
radius,
) {
// The touch on the arc has to be on the drawn part of it, not on the
// rest of the circle it belongs to.
let tp_arc_angle = arc_angle_at(ac, fillet.tangent2);
let tp_arc_clamped = clamp_angle_to_arc(tp_arc_angle, a_start, a_end);
if (norm_angle(tp_arc_angle) - norm_angle(tp_arc_clamped)).abs() > 0.01 {
continue;
}
// The tangent point on the line must be on the correct side of the click
// (prefer the intersection closest to the click)
let dist_to_click_line =
(tp_line[0] - click_line[0]).hypot(tp_line[1] - click_line[1]);
let dist_to_click_arc = (tp_arc[0] - click_arc[0]).hypot(tp_arc[1] - click_arc[1]);
let dist_total = dist_to_click_line + dist_to_click_arc;
if dist_total >= best_dist {
continue;
}
// Build trimmed line
let Some(new_line) = trim_line_to_point(line, tp_line, click_line) else {
let Some(new_line) = trim_line_to_point(line, fillet.tangent1, click_line) else {
continue;
};
// Build trimmed arc
let arc_click_angle = arc_angle_at(ac, click_arc);
let arc_click_rel = (arc_click_angle - a_start).rem_euclid(TAU);
// Keep the side of the arc the click is on.
let arc_click_rel = (arc_angle_at(ac, click_arc) - a_start).rem_euclid(TAU);
let tp_arc_rel = (tp_arc_clamped - a_start).rem_euclid(TAU);
let new_arc = if tp_arc_rel <= arc_click_rel {
trim_arc(arc, tp_arc_clamped, a_end)
} else {
trim_arc(arc, a_start, tp_arc_clamped)
};
// Build fillet arc angles
let fa_line = arc_angle_at(fc, tp_line);
let fa_arc = arc_angle_at(fc, tp_arc);
let cross = (tp_line[0] - fc[0]) * (tp_arc[1] - fc[1])
- (tp_line[1] - fc[1]) * (tp_arc[0] - fc[0]);
let (fstart, fend) = if cross >= 0.0 {
(fa_line, fa_arc)
} else {
(fa_arc, fa_line)
};
let mut fillet_arc = ArcEnt::new();
fillet_arc.common = line.common.clone();
fillet_arc.common.handle = Handle::NULL;
fillet_arc.center = Vector3::new(fc[0], fc[1], z);
fillet_arc.radius = radius;
fillet_arc.start_angle = norm_angle(fstart);
fillet_arc.end_angle = norm_angle(fend);
best_dist = dist_total;
best = Some((
return Some((
EntityType::Line(new_line),
EntityType::Arc(new_arc),
EntityType::Arc(fillet_arc),
Some(EntityType::Arc(fillet_entity(
&fillet,
radius,
z,
&line.common,
))),
));
}
}
}
best.map(|(l, a, f)| (l, a, Some(f)))
None
}
/// Fillet two arcs.
@ -884,52 +875,22 @@ fn fillet_arc_arc(
return Some((EntityType::Arc(new_a1), EntityType::Arc(new_a2), None));
}
// For r>0: find a circle of `radius` tangent to both arc circles.
// Center lies at |r1 ± radius| from c1 and |r2 ± radius| from c2.
let mut best: Option<(EntityType, EntityType, EntityType)> = None;
let mut best_dist = f64::MAX;
for sign1 in &[-1.0_f64, 1.0_f64] {
let d1 = r1 + sign1 * radius;
if d1 < 1e-9 {
continue;
}
for sign2 in &[-1.0_f64, 1.0_f64] {
let d2 = r2 + sign2 * radius;
if d2 < 1e-9 {
continue;
}
for fc in circle_circle_pts(c1, d1, c2, d2) {
// Tangent points on each arc
let fd1 = [(c1[0] - fc[0]), (c1[1] - fc[1])];
let fdl1 = (fd1[0] * fd1[0] + fd1[1] * fd1[1]).sqrt().max(1e-12);
let tp1 = [c1[0] + fd1[0] / fdl1 * r1, c1[1] + fd1[1] / fdl1 * r1];
let fd2 = [(c2[0] - fc[0]), (c2[1] - fc[1])];
let fdl2 = (fd2[0] * fd2[0] + fd2[1] * fd2[1]).sqrt().max(1e-12);
let tp2 = [c2[0] + fd2[0] / fdl2 * r2, c2[1] + fd2[1] / fdl2 * r2];
// Tangent points must lie within respective arc ranges
let tp1a = arc_angle_at(c1, tp1);
let tp2a = arc_angle_at(c2, tp2);
let tc1 = clamp_angle_to_arc(tp1a, s1, e1);
let tc2 = clamp_angle_to_arc(tp2a, s2, e2);
if (norm_angle(tp1a) - norm_angle(tc1)).abs() > 0.01 {
continue;
}
if (norm_angle(tp2a) - norm_angle(tc2)).abs() > 0.01 {
continue;
}
let dist_total = (tp1[0] - click1[0]).hypot(tp1[1] - click1[1])
+ (tp2[0] - click2[0]).hypot(tp2[1] - click2[1]);
if dist_total >= best_dist {
// Same construction as the line-and-arc case, from the same place: the
// centre is where an offset of one circle crosses an offset of the other.
for fillet in ranked_fillets(&arc_curve(a1), click1, &arc_curve(a2), click2, radius) {
// Both touches have to be on the drawn parts of their arcs.
let tp1_angle = arc_angle_at(c1, fillet.tangent1);
let tp2_angle = arc_angle_at(c2, fillet.tangent2);
let tc1 = clamp_angle_to_arc(tp1_angle, s1, e1);
let tc2 = clamp_angle_to_arc(tp2_angle, s2, e2);
if (norm_angle(tp1_angle) - norm_angle(tc1)).abs() > 0.01
|| (norm_angle(tp2_angle) - norm_angle(tc2)).abs() > 0.01
{
continue;
}
let ca1 = clamp_angle_to_arc(arc_angle_at(c1, click1), s1, e1);
let ca2 = clamp_angle_to_arc(arc_angle_at(c2, click2), s2, e2);
let new_a1 = if (tc1 - s1).rem_euclid(TAU) <= (ca1 - s1).rem_euclid(TAU) {
trim_arc(a1, tc1, e1)
} else {
@ -940,31 +901,13 @@ fn fillet_arc_arc(
} else {
trim_arc(a2, s2, tc2)
};
let fa1 = arc_angle_at(fc, tp1);
let fa2 = arc_angle_at(fc, tp2);
let cross =
(tp1[0] - fc[0]) * (tp2[1] - fc[1]) - (tp1[1] - fc[1]) * (tp2[0] - fc[0]);
let (fstart, fend) = if cross >= 0.0 { (fa1, fa2) } else { (fa2, fa1) };
let mut fillet_arc = ArcEnt::new();
fillet_arc.common = a1.common.clone();
fillet_arc.common.handle = Handle::NULL;
fillet_arc.center = Vector3::new(fc[0], fc[1], z);
fillet_arc.radius = radius;
fillet_arc.start_angle = norm_angle(fstart);
fillet_arc.end_angle = norm_angle(fend);
best_dist = dist_total;
best = Some((
return Some((
EntityType::Arc(new_a1),
EntityType::Arc(new_a2),
EntityType::Arc(fillet_arc),
Some(EntityType::Arc(fillet_entity(&fillet, radius, z, &a1.common))),
));
}
}
}
best.map(|(a1, a2, f)| (a1, a2, Some(f)))
None
}
/// Trim a line endpoint nearest to the intersection point, keeping the click side.

View file

@ -51,11 +51,6 @@ pub fn line_circle(px: f64, py: f64, dx: f64, dy: f64, cx: f64, cy: f64, r: f64)
geom2d::line_circle([px, py], [dx, dy], [cx, cy], r)
}
/// Angles **on the first circle** where two circles meet.
pub fn circle_circle_angles(cx1: f64, cy1: f64, r1: f64, cx2: f64, cy2: f64, r2: f64) -> Vec<f64> {
geom2d::circle_circle_angles([cx1, cy1], r1, [cx2, cy2], r2)
}
/// `(s_on_line, t_on_ellipse)` pairs where a line meets an ellipse.
///
/// `nx, ny` is the unit major axis.

View file

@ -11,6 +11,9 @@ use crate::modules::draw::modify::spline_ops::{spline_cut, spline_to_nurbs};
use acadrust::entities::{
Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, Spline as SplineEnt,
};
use acadrust::kernel::geom2d::{
Curve, Ellipse as KernelEllipse, EllipseArc as KernelEllipseArc,
};
use acadrust::types::Vector3;
use acadrust::{EntityType, Handle};
use glam::{DVec3, Vec3};
@ -18,6 +21,8 @@ use crate::t;
use crate::command::{CadCommand, CmdResult};
const TAU: f64 = std::f64::consts::TAU;
pub struct LengthenCommand {
state: LenState,
}
@ -263,71 +268,64 @@ fn lengthen_ellipse(ell: &EllipseEnt, pick_pt: Vec3, mode: &LenMode) -> Option<E
let t0 = ell.start_parameter;
let mut t1 = ell.end_parameter;
if t1 <= t0 {
t1 += std::f64::consts::TAU;
t1 += TAU;
}
let span = t1 - t0;
// Approximate arc length via 128-point Gaussian quadrature estimate.
let arc_len_approx = |span: f64| -> f64 {
let n = 128usize;
let mut len = 0.0;
for i in 0..n {
let ti = t0 + span * (i as f64 / n as f64);
let tip = t0 + span * ((i + 1) as f64 / n as f64);
let xi = a * ti.cos() * nx - b * ti.sin() * ny + ell.center.x;
let yi = a * ti.cos() * ny + b * ti.sin() * nx + ell.center.y;
let xip = a * tip.cos() * nx - b * tip.sin() * ny + ell.center.x;
let yip = a * tip.cos() * ny + b * tip.sin() * nx + ell.center.y;
len += (xip - xi).hypot(yip - yi);
}
len
// Measured by the kernel rather than by a hundred and twenty-eight
// chords: the chord sum reads short, so LENGTHEN's idea of "current" was
// already below the true length before a delta was applied to it.
let shape = KernelEllipse {
centre: [ell.center.x, ell.center.y],
major_radius: a,
minor_radius: b,
major_axis: [nx, ny],
};
let current_len = arc_len_approx(span);
let arc = |from: f64, to: f64| {
Curve::Ellipse(KernelEllipseArc {
ellipse: shape,
start_parameter: from,
end_parameter: to,
})
};
let current_len = arc(t0, t1).length();
if current_len < 1e-10 {
return None;
}
let new_len = apply_mode(current_len, mode)?;
if new_len < 1e-10 {
return None;
}
// Find the new span via bisection so that arc_len_approx(new_span) ≈ new_len.
let max_span = std::f64::consts::TAU;
let mut lo = 0.0f64;
let mut hi = max_span;
for _ in 0..40 {
let mid = (lo + hi) * 0.5;
if arc_len_approx(mid) < new_len {
lo = mid;
} else {
hi = mid;
}
}
let new_span = (lo + hi) * 0.5;
// Determine which end is closer to pick_pt (use DXF XY plane).
let p_x = pick_pt.x as f64;
let p_y = pick_pt.y as f64;
let pt_start_x = ell.center.x + a * t0.cos() * nx - b * t0.sin() * ny;
let pt_start_y = ell.center.y + a * t0.cos() * ny + b * t0.sin() * nx;
let pt_end_x = ell.center.x + a * t1.cos() * nx - b * t1.sin() * ny;
let pt_end_y = ell.center.y + a * t1.cos() * ny + b * t1.sin() * nx;
let dist_start = (p_x - pt_start_x).hypot(p_y - pt_start_y);
let dist_end = (p_x - pt_end_x).hypot(p_y - pt_end_y);
// Which end is closer to the pick, in the DXF XY plane.
let point_at = |t: f64| {
(
ell.center.x + a * t.cos() * nx - b * t.sin() * ny,
ell.center.y + a * t.cos() * ny + b * t.sin() * nx,
)
};
let (p_x, p_y) = (pick_pt.x as f64, pick_pt.y as f64);
let (sx, sy) = point_at(t0);
let (ex, ey) = point_at(t1);
let extend_end = (p_x - ex).hypot(p_y - ey) <= (p_x - sx).hypot(p_y - sy);
let mut result = ell.clone();
result.common.handle = Handle::NULL;
if dist_end <= dist_start {
result.end_parameter = t0 + new_span;
if extend_end {
// Walk `new_len` forward from the fixed start. A whole turn is the
// most there is to walk, and the kernel clamps to it.
let whole = arc(t0, t0 + TAU);
result.end_parameter = t0 + whole.parameter_at_distance(new_len) * TAU;
} else {
result.start_parameter = t1 - new_span;
// The same measured backwards from the fixed end: the last `new_len`
// of a whole turn ending at t1.
let whole = arc(t1 - TAU, t1);
let from_start = whole.length() - new_len;
result.start_parameter = t1 - TAU + whole.parameter_at_distance(from_start) * TAU;
}
Some(EntityType::Ellipse(result))
}
fn apply_mode(current: f64, mode: &LenMode) -> Option<f64> {
match mode {
LenMode::Delta(d) => Some(current + d),
@ -337,9 +335,9 @@ fn apply_mode(current: f64, mode: &LenMode) -> Option<f64> {
}
fn arc_span_rad(start: f64, end: f64) -> f64 {
let span = (end - start).rem_euclid(std::f64::consts::TAU);
let span = (end - start).rem_euclid(TAU);
if span < 1e-6 {
std::f64::consts::TAU
TAU
} else {
span
}
@ -410,103 +408,44 @@ fn lengthen_lwpoly(poly: &LwPolyline, pick_pt: Vec3, mode: &LenMode) -> Option<E
}
fn lengthen_spline(spl: &SplineEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
let curve = spline_to_nurbs(spl)?;
let (t0, t1) = curve.domain();
let nurbs = spline_to_nurbs(spl)?;
let (t0, t1) = nurbs.domain();
if (t1 - t0).abs() < 1e-12 {
return None;
}
// Approximate arc length via 64-point numerical integration.
let arc_len = {
let n = 64usize;
let mut len = 0.0f64;
for i in 0..n {
let ta = t0 + (t1 - t0) * (i as f64 / n as f64);
let tb = t0 + (t1 - t0) * ((i + 1) as f64 / n as f64);
let pa = curve.point_at_knot(ta);
let pb = curve.point_at_knot(tb);
len += (pb[0] - pa[0]).hypot(pb[1] - pa[1]);
}
len
};
let curve = Curve::Nurbs(nurbs.clone());
let arc_len = curve.length();
if arc_len < 1e-10 {
return None;
}
let new_len = apply_mode(arc_len, mode)?;
if new_len < 1e-10 {
if new_len < 1e-10 || new_len >= arc_len {
// A spline is shortened by splitting it, so there is nothing to keep
// if the new length is the whole of it or more. Extending would mean
// continuing the curve past its own control polygon, which is a
// different operation from cutting one.
return None;
}
// Determine which end (start or end) is closer to pick_pt.
let p_start = curve.point_at_knot(t0);
let p_end = curve.point_at_knot(t1);
let dist_start = (p_start[0] - pick_pt.x as f64).hypot(p_start[1] - pick_pt.y as f64);
let dist_end = (p_end[0] - pick_pt.x as f64).hypot(p_end[1] - pick_pt.y as f64);
let extend_end = dist_end <= dist_start;
let p_start = nurbs.point_at_knot(t0);
let p_end = nurbs.point_at_knot(t1);
let (px, py) = (pick_pt.x as f64, pick_pt.y as f64);
let extend_end = (p_end[0] - px).hypot(p_end[1] - py)
<= (p_start[0] - px).hypot(p_start[1] - py);
// Find the parameter `t_new` such that the arc length from the fixed end to t_new = new_len.
// Use bisection on cumulative arc length.
let fixed_t = if extend_end { t0 } else { t1 };
let delta_ratio = new_len / arc_len;
// Find t_new via bisection: cumulative_len(fixed_t..t_new) = new_len.
let cum_len = |t_end_param: f64| -> f64 {
let (lo, hi) = if extend_end {
(t0, t_end_param)
// Where to cut, by distance along the curve rather than by a bisection
// over repeated chord sums. Keeping the head means cutting `new_len` from
// the start; keeping the tail means cutting what is left over.
let along = if extend_end {
new_len
} else {
(t_end_param, t1)
arc_len - new_len
};
if hi <= lo {
return 0.0;
}
let n = 32usize;
let mut len = 0.0f64;
for i in 0..n {
let ta = lo + (hi - lo) * (i as f64 / n as f64);
let tb = lo + (hi - lo) * ((i + 1) as f64 / n as f64);
let pa = curve.point_at_knot(ta);
let pb = curve.point_at_knot(tb);
len += (pb[0] - pa[0]).hypot(pb[1] - pa[1]);
}
len
};
let (mut lo_t, mut hi_t) = if extend_end {
(t0, t0 + delta_ratio * (t1 - t0) * 2.0)
} else {
(t1 - delta_ratio * (t1 - t0) * 2.0, t1)
};
// Clamp to valid range with some buffer for extension.
let buf = (t1 - t0) * 0.5;
lo_t = lo_t.max(t0 - buf);
hi_t = hi_t.min(t1 + buf);
for _ in 0..40 {
let mid = (lo_t + hi_t) * 0.5;
if cum_len(mid) < new_len {
if extend_end {
hi_t = mid;
} else {
lo_t = mid;
}
} else {
if extend_end {
lo_t = mid;
} else {
hi_t = mid;
}
}
}
let t_new = (lo_t + hi_t) * 0.5;
let _ = fixed_t;
// Split the spline at t_new and keep the side the pick asked for.
let cut = t_new.clamp(t0 + 1e-10, t1 - 1e-10);
let at = curve.parameter_at_distance(along);
let cut = (t0 + at * (t1 - t0)).clamp(t0 + 1e-10, t1 - 1e-10);
let (left, right) = spline_cut(spl, cut)?;
Some(EntityType::Spline(if extend_end { left } else { right }))
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["LENGTHEN"] }); // LengthenCommand

View file

@ -10,9 +10,8 @@
// 3. Pick a point on the side to offset toward, or choose Multiple
// to keep offsetting the newly created result at the same distance
use std::f64::consts::TAU;
use crate::modules::draw::modify::spline_ops::{spline_pts_wire, spline_sample_xy};
use crate::entities::curve::entity_curve;
use crate::modules::draw::modify::spline_ops::spline_sample_xy;
use acadrust::entities::LwVertex;
use acadrust::entities::{
Arc as ArcEnt, Circle as CircleEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline,
@ -23,8 +22,7 @@ use acadrust::{EntityType, Handle};
// from the kernel; only the entity conversion stays here.
use acadrust::kernel::geom2d::nurbs::clamped_uniform_knots;
use acadrust::kernel::geom2d::{
normalize_angle as norm_rad, offset_polyline, Polyline as KernelPolyline,
PolylineVertex as KernelVertex,
offset_polyline, Polyline as KernelPolyline, PolylineVertex as KernelVertex,
};
use glam::{DVec3, Vec3};
use crate::t;
@ -362,178 +360,55 @@ fn perp_distance(entity: &EntityType, pt: Vec3) -> f64 {
// ── Wire preview points ─────────────────────────────────────────────────────
/// Preview density, matching the figure the drawn wires use.
const PREVIEW_SEGMENTS_PER_RADIAN: f64 = acadrust::kernel::geom2d::DEFAULT_SEGMENTS_PER_RADIAN;
/// Preview points for an entity, from its own curve.
///
/// One tessellation rather than a case per type. What that case-per-type
/// version had accumulated: an arc and a circle read their `center.x/.y` as
/// world coordinates when the entity stores them in its OCS, so an extruded
/// one previewed on the wrong side of the drawing; every curved kind was cut
/// at a fixed twenty segments per radian whatever its size; and an ellipse
/// rebuilt its own axis frame from the major axis' X and Y alone, dropping
/// the Z the entity actually stores.
///
/// The narrowing to `f32` stays here rather than in the kernel: a preview
/// wire leaves `points_low` empty and accepts the loss, while the resident
/// render path splits the `f64` into a high/low pair instead. Only a caller
/// knows which of the two it is.
fn entity_wire_pts(e: &EntityType) -> Vec<[f32; 3]> {
match e {
EntityType::Line(l) => vec![
[l.start.x as f32, l.start.y as f32, l.start.z as f32],
[l.end.x as f32, l.end.y as f32, l.end.z as f32],
],
EntityType::Circle(c) => {
let steps = 64usize;
(0..=steps)
.map(|i| {
let a = TAU * i as f64 / steps as f64;
[
(c.center.x + c.radius * a.cos()) as f32,
(c.center.y + c.radius * a.sin()) as f32,
c.center.z as f32,
]
})
.collect()
}
EntityType::Arc(a) => {
let a0 = norm_rad(a.start_angle);
let a1 = norm_rad(a.end_angle);
let span = {
let s = a1 - a0;
if s <= 0.0 {
s + TAU
} else {
s
}
};
let steps = ((span.abs() * 20.0).ceil() as usize).max(4);
(0..=steps)
.map(|i| {
let ang = a0 + span * (i as f64 / steps as f64);
[
(a.center.x + a.radius * ang.cos()) as f32,
(a.center.y + a.radius * ang.sin()) as f32,
a.center.z as f32,
]
})
.collect()
}
EntityType::LwPolyline(p) => lwpolyline_pts(p),
EntityType::Ellipse(e) => {
let a = (e.major_axis.x.powi(2) + e.major_axis.y.powi(2)).sqrt();
if a < 1e-9 {
return vec![];
}
let b = a * e.minor_axis_ratio;
let nx = e.major_axis.x / a;
let ny = e.major_axis.y / a;
let t0 = e.start_parameter;
let mut t1 = e.end_parameter;
if t1 <= t0 {
t1 += TAU;
}
let span = t1 - t0;
let steps = ((span.abs() * 20.0).ceil() as usize).max(4);
(0..=steps)
.map(|i| {
let t = t0 + span * (i as f64 / steps as f64);
let lx = a * t.cos();
let ly = b * t.sin();
[
(e.center.x + lx * nx - ly * ny) as f32,
(e.center.y + lx * ny + ly * nx) as f32,
e.center.z as f32,
]
})
.collect()
}
EntityType::Spline(s) => spline_pts_wire(s),
EntityType::XLine(x) => {
// Infinite in both directions — represent it as a very long segment
// for hit-testing and the offset preview. Long enough to read as
// infinite at any working zoom; the committed XLine renders true.
// The two kinds with no finite point list of their own, so how far to run
// them is a decision rather than a measurement. Long enough to read as
// infinite at any working zoom; the committed entity renders true. A ray
// runs one way only.
const HL: f64 = 1.0e6;
let (bx, by, bz) = (x.base_point.x, x.base_point.y, x.base_point.z);
let (dx, dy, dz) = (x.direction.x, x.direction.y, x.direction.z);
vec![
[(bx - dx * HL) as f32, (by - dy * HL) as f32, (bz - dz * HL) as f32],
[(bx + dx * HL) as f32, (by + dy * HL) as f32, (bz + dz * HL) as f32],
let unbounded = match e {
EntityType::XLine(x) => Some((x.base_point, x.direction, -HL)),
EntityType::Ray(r) => Some((r.base_point, r.direction, 0.0)),
_ => None,
};
if let Some((base, direction, back)) = unbounded {
let at = |k: f64| {
[
(base.x + direction.x * k) as f32,
(base.y + direction.y * k) as f32,
(base.z + direction.z * k) as f32,
]
};
return vec![at(back), at(HL)];
}
_ => vec![],
}
}
/// Tessellate a LwPolyline into wire points (straight segments + arc bulges).
fn lwpolyline_pts(p: &LwPolyline) -> Vec<[f32; 3]> {
let n = p.vertices.len();
if n < 2 {
let Some(curve) = entity_curve(e) else {
return vec![];
}
let z = p.elevation as f32;
let n_segs = if p.is_closed { n } else { n - 1 };
let mut pts: Vec<[f32; 3]> = Vec::new();
for i in 0..n_segs {
let v0 = &p.vertices[i];
let v1 = &p.vertices[(i + 1) % n];
let x0 = v0.location.x;
let y0 = v0.location.y;
let x1 = v1.location.x;
let y1 = v1.location.y;
if pts.is_empty() {
pts.push([x0 as f32, y0 as f32, z]);
}
if v0.bulge.abs() < 1e-10 {
pts.push([x1 as f32, y1 as f32, z]);
} else {
// Arc from bulge
let b = v0.bulge;
let chord_x = x1 - x0;
let chord_y = y1 - y0;
let chord_len = (chord_x * chord_x + chord_y * chord_y).sqrt();
if chord_len < 1e-12 {
pts.push([x1 as f32, y1 as f32, z]);
continue;
}
let b2 = b * b;
let r = chord_len * (1.0 + b2) / (4.0 * b.abs());
let d = r * (1.0 - b2) / (1.0 + b2);
let mx = (x0 + x1) * 0.5;
let my = (y0 + y1) * 0.5;
let perp_x = -chord_y / chord_len;
let perp_y = chord_x / chord_len;
let sign = b.signum();
let cx = mx + sign * d * perp_x;
let cy = my + sign * d * perp_y;
let a0 = norm_rad((y0 - cy).atan2(x0 - cx));
let a1 = norm_rad((y1 - cy).atan2(x1 - cx));
let span = if b > 0.0 {
let s = a1 - a0;
if s <= 0.0 {
s + TAU
} else {
s
}
} else {
let s = a0 - a1;
if s <= 0.0 {
s + TAU
} else {
s
}
};
let steps = ((span.abs() * 20.0).ceil() as usize).max(4);
for j in 1..=steps {
let t = j as f64 / steps as f64;
let ang = if b > 0.0 {
a0 + span * t
} else {
a0 - span * t
};
pts.push([(cx + r * ang.cos()) as f32, (cy + r * ang.sin()) as f32, z]);
}
}
}
if p.is_closed {
if let Some(&first) = pts.first() {
pts.push(first);
}
}
pts
curve
.tessellate(PREVIEW_SEGMENTS_PER_RADIAN)
.into_iter()
.map(|p| [p[0] as f32, p[1] as f32, p[2] as f32])
.collect::<Vec<[f32; 3]>>()
}
// ── Command implementation ─────────────────────────────────────────────────
enum Step {

View file

@ -12,6 +12,8 @@
// straightens every segment.
use acadrust::entities::LwVertex;
use acadrust::kernel::geom2d::nurbs::clamped_uniform_knots;
use acadrust::kernel::geom2d::NurbsCurve;
use acadrust::types::Vector2;
use acadrust::{EntityType, Handle};
use glam::{DVec2, DVec3};
@ -535,69 +537,61 @@ fn fit_curve(p: &mut acadrust::LwPolyline) -> bool {
/// PEDIT Spline: replace the shape with a sampled uniform cubic B-spline of
/// the vertex frame (8 samples per span; a closed frame wraps around).
/// Replace the polyline's vertices with a sampling of the cubic B-spline its
/// vertices control.
///
/// Evaluated by the kernel rather than from a hand-written basis. The four
/// blending polynomials that were here are the uniform cubic ones spelled
/// out, with the open case clamped by repeating end points and then the two
/// ends pinned back afterwards to undo the drift that leaves — all of which
/// a clamped knot vector expresses directly and exactly.
fn spline_smooth(p: &mut acadrust::LwPolyline) -> bool {
let n = p.vertices.len();
if n < 3 {
const DEGREE: usize = 3;
const PER_SPAN: usize = 8;
let control: Vec<[f64; 2]> = p.vertices.iter().map(|v| [v.location.x, v.location.y]).collect();
if control.len() < 3 {
return false;
}
let pts: Vec<DVec2> = p.vertices.iter().map(vert_xy).collect();
const S: usize = 8;
let bspline = |p0: DVec2, p1: DVec2, p2: DVec2, p3: DVec2, t: f64| -> DVec2 {
let t2 = t * t;
let t3 = t2 * t;
(p0 * (-t3 + 3.0 * t2 - 3.0 * t + 1.0)
+ p1 * (3.0 * t3 - 6.0 * t2 + 4.0)
+ p2 * (-3.0 * t3 + 3.0 * t2 + 3.0 * t + 1.0)
+ p3 * t3)
/ 6.0
};
let mut out: Vec<DVec2> = Vec::new();
if p.is_closed {
for i in 0..n {
let p0 = pts[(i + n - 1) % n];
let p1 = pts[i];
let p2 = pts[(i + 1) % n];
let p3 = pts[(i + 2) % n];
for s in 0..S {
out.push(bspline(p0, p1, p2, p3, s as f64 / S as f64));
}
}
let curve = if p.is_closed {
// A periodic curve is written by wrapping the first `degree` control
// points onto the end, over a uniform knot vector — which is what
// makes the seam as smooth as everywhere else.
let mut wrapped = control.clone();
wrapped.extend(control.iter().take(DEGREE).copied());
let count = wrapped.len();
let knots: Vec<f64> = (0..count + DEGREE + 1).map(|i| i as f64).collect();
NurbsCurve::new(DEGREE, wrapped, knots, None)
} else {
// Clamp the ends by repeating the end control points so the curve
// starts and finishes on them.
let at = |i: isize| -> DVec2 { pts[i.clamp(0, n as isize - 1) as usize] };
for i in -1..(n as isize - 2) {
let (p0, p1, p2, p3) = (at(i), at(i + 1), at(i + 2), at(i + 3));
let last_span = i == n as isize - 3;
let steps = if last_span { S + 1 } else { S };
for s in 0..steps {
out.push(bspline(p0, p1, p2, p3, s as f64 / S as f64));
}
}
// Pin the exact endpoints.
if let (Some(v), Some(q)) = (out.first_mut(), pts.first()) {
*v = *q;
}
if let (Some(v), Some(q)) = (out.last_mut(), pts.last()) {
*v = *q;
}
}
if out.len() < 2 {
// Clamped: the curve starts and finishes on its outer control points
// without them needing to be repeated.
let knots = clamped_uniform_knots(DEGREE, control.len());
NurbsCurve::new(DEGREE, control.clone(), knots, None)
};
let Some(curve) = curve else {
return false;
};
let steps = PER_SPAN * control.len();
let sampled: Vec<[f64; 2]> = (0..=steps)
.map(|step| curve.point_at(step as f64 / steps as f64))
.collect();
if sampled.len() < 2 {
return false;
}
let w = p.constant_width;
p.vertices = out
let width = p.constant_width;
p.vertices = sampled
.into_iter()
.map(|q| {
let mut v = LwVertex::new(Vector2::new(q.x, q.y));
v.start_width = w;
v.end_width = w;
let mut v = LwVertex::new(Vector2::new(q[0], q[1]));
v.start_width = width;
v.end_width = width;
v
})
.collect();
true
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["PEDIT"] }); // PeditCommand

View file

@ -11,6 +11,7 @@
use acadrust::entities::Spline;
use acadrust::kernel::geom2d::{NurbsCurve, Parameterization};
use acadrust::kernel::space::Plane;
use acadrust::types::Vector3;
use acadrust::Handle;
@ -38,13 +39,36 @@ fn elevation(spl: &Spline) -> f64 {
/// any other instead of failing every command that touches it.
///
/// `None` only when neither form has enough points to describe a curve.
///
/// Reads the stored points as XY directly, which is right for the splines the
/// editing commands work on — they run in the world frame. A spline on an
/// extruded plane needs [`spline_to_nurbs_on`] instead.
pub fn spline_to_nurbs(spl: &Spline) -> Option<NurbsCurve> {
spline_to_nurbs_with(spl, |p| [p.x, p.y], |v| [v.x, v.y])
}
/// [`spline_to_nurbs`] with the points expressed in `plane`'s coordinates.
///
/// The two differ only for a spline whose extrusion normal is not +Z. Where
/// it is, the projection reduces to reading X and Y, so this is the same
/// conversion with the frame made explicit.
///
/// `None` additionally when the plane is degenerate, since there is then no
/// coordinate to express the points in.
pub fn spline_to_nurbs_on(spl: &Spline, plane: &Plane) -> Option<NurbsCurve> {
let point = |p: &Vector3| plane.project([p.x, p.y, p.z]).unwrap_or([p.x, p.y]);
let vector = |v: &Vector3| plane.project_vector([v.x, v.y, v.z]).unwrap_or([v.x, v.y]);
plane.normal()?;
spline_to_nurbs_with(spl, point, vector)
}
fn spline_to_nurbs_with(
spl: &Spline,
point: impl Fn(&Vector3) -> [f64; 2],
vector: impl Fn(&Vector3) -> [f64; 2],
) -> Option<NurbsCurve> {
let degree = (spl.degree.max(1)) as usize;
let control_points: Vec<[f64; 2]> = spl
.control_points
.iter()
.map(|p| [p.x, p.y])
.collect();
let control_points: Vec<[f64; 2]> = spl.control_points.iter().map(&point).collect();
let weights = (!spl.weights.is_empty()).then(|| spl.weights.clone());
if let Some(curve) =
@ -54,9 +78,23 @@ pub fn spline_to_nurbs(spl: &Spline) -> Option<NurbsCurve> {
}
// No usable control polygon, so this is a fit-point spline.
let fit: Vec<[f64; 2]> = spl.fit_points.iter().map(|p| [p.x, p.y]).collect();
let tangent = |v: &acadrust::types::Vector3| {
(v.x * v.x + v.y * v.y > 1e-18).then_some([v.x, v.y])
let mut fit: Vec<[f64; 2]> = spl.fit_points.iter().map(&point).collect();
if spl.flags.closed || spl.flags.periodic {
// The interpolation is a clamped solve and does not model a wrap, so
// a closed spline came back as an open curve that never returned to
// its start — and a TRIM against it then cut nothing along the seam.
// Repeating the first point closes it. The seam is C¹ rather than the
// C² the rest of the curve has, which is the honest limit of a
// clamped solve; the alternative was a curve with a gap in it.
if let (Some(&first), Some(&last)) = (fit.first(), fit.last()) {
if first != last {
fit.push(first);
}
}
}
let tangent = |v: &Vector3| {
let flat = vector(v);
(flat[0] * flat[0] + flat[1] * flat[1] > 1e-18).then_some(flat)
};
NurbsCurve::interpolate(
&fit,

View file

@ -9,9 +9,12 @@
//
// Control-point dragging is already supported via the grip editing system.
use acadrust::types::Vector3;
use acadrust::EntityType;
use glam::DVec3;
use crate::modules::draw::modify::spline_ops::spline_to_nurbs;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
@ -190,12 +193,40 @@ pub fn apply_spline_op(doc: &mut acadrust::CadDocument, handle: acadrust::Handle
}
}
"__SPLINEDIT_REVERSE__" => {
spline.control_points.reverse();
spline.fit_points.reverse();
spline.knots = acadrust::entities::Spline::generate_clamped_knots(
spline.degree as usize,
spline.control_points.len(),
);
let begin = spline.begin_tangent;
spline.begin_tangent = spline.end_tangent;
spline.end_tangent = begin;
// The knots mirror within the domain rather than being
// regenerated. Rebuilding a clamped *uniform* vector here changed
// the shape of any spline whose knots were unevenly spaced —
// which is most of them once a modeller has been near one.
match spline_to_nurbs(spline).map(|curve| curve.reversed()) {
Some(reversed) => {
let elevation = spline
.control_points
.first()
.or_else(|| spline.fit_points.first())
.map(|p| p.z)
.unwrap_or(0.0);
spline.degree = reversed.degree() as i32;
spline.knots = reversed.knots().to_vec();
spline.control_points = reversed
.control_points()
.iter()
.map(|p| Vector3::new(p[0], p[1], elevation))
.collect();
spline.weights = if reversed.is_rational() {
reversed.weights().to_vec()
} else {
Vec::new()
};
}
// Nothing the kernel can read — a spline with too few points
// to describe a curve. Reversing its stored points is still
// the honest answer.
None => spline.control_points.reverse(),
}
}
_ => {}
}

File diff suppressed because it is too large Load diff

View file

@ -1,92 +1,14 @@
use super::*;
use std::collections::{HashMap, HashSet};
use acadrust::kernel::geom2d::{bounded_faces, Line, Tolerance};
const NODE_EPS: f64 = 1.0e-6;
const ORIENTATION_EPS: f64 = 1.0e-12;
const AREA_EPS: f64 = 1.0e-10;
#[derive(Clone, Copy, Debug)]
struct P2 {
x: f64,
y: f64,
}
impl P2 {
fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
fn lerp(self, other: Self, t: f64) -> Self {
Self {
x: self.x + (other.x - self.x) * t,
y: self.y + (other.y - self.y) * t,
}
}
fn distance2(self, other: Self) -> f64 {
let dx = self.x - other.x;
let dy = self.y - other.y;
dx * dx + dy * dy
}
}
#[derive(Clone, Copy, Debug)]
struct Segment {
a: P2,
b: P2,
}
impl Segment {
fn new(a: P2, b: P2) -> Self {
Self { a, b }
}
}
#[derive(Clone, Copy, Debug)]
struct SegmentAabb {
min_x: f64,
max_x: f64,
min_y: f64,
max_y: f64,
}
impl SegmentAabb {
fn new(segment: Segment) -> Self {
Self {
min_x: segment.a.x.min(segment.b.x),
max_x: segment.a.x.max(segment.b.x),
min_y: segment.a.y.min(segment.b.y),
max_y: segment.a.y.max(segment.b.y),
}
}
fn min(self, axis: SweepAxis) -> f64 {
match axis {
SweepAxis::X => self.min_x,
SweepAxis::Y => self.min_y,
}
}
fn max(self, axis: SweepAxis) -> f64 {
match axis {
SweepAxis::X => self.max_x,
SweepAxis::Y => self.max_y,
}
}
}
#[derive(Clone, Copy)]
enum SweepAxis {
X,
Y,
}
enum SegmentIntersection {
None,
Point { a: f64, b: f64 },
Overlap { a: [f64; 2], b: [f64; 2] },
}
/// How far apart two points may be and still be taken for the same one.
///
/// The boundary search runs on already-tessellated wire geometry, so the
/// input is a chord approximation of the drawn curves to begin with; this
/// only has to be coarse enough to close the gaps that leaves and fine
/// enough not to weld genuinely separate corners together.
const WELD_TOLERANCE: f64 = 1.0e-6;
impl Scene {
/// Build closed planar regions from the visible wire geometry.
@ -98,12 +20,17 @@ impl Scene {
/// Curved entities participate through their already-tessellated WireModel
/// geometry, so arcs, circles, ellipses and splines can take part in the
/// boundary search without modifying the source entities.
///
/// The arrangement itself is the kernel's: splitting at crossings, welding
/// coincident ends and tracing the bounded faces is the same problem a
/// B-rep boolean solves in a face's parameter space, and it is solved
/// once. What stays here is reading the wires — which is where the
/// drawing's own conventions live.
pub fn hatch_boundary_outlines(&self) -> Vec<Vec<[f64; 2]>> {
let wires = self.entity_wires();
let mut segments = Vec::<Segment>::new();
let mut segments = Vec::<Line>::new();
for wire in wires.iter() {
let mut previous: Option<P2> = None;
for wire in self.entity_wires().iter() {
let mut previous: Option<[f64; 2]> = None;
for (index, high) in wire.points.iter().copied().enumerate() {
// NaNs delimit independent segments inside some WireModels,
@ -113,16 +40,24 @@ impl Scene {
continue;
}
// The wire carries its coordinates as a double-single pair, so
// both halves are needed to recover the f64 the tessellation
// produced. Reading only the high half would put every vertex
// of a survey-coordinate drawing on a grid coarser than the
// weld tolerance.
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
let current = P2::new(
let current = [
high[0] as f64 + low[0] as f64,
high[1] as f64 + low[1] as f64,
);
];
if let Some(prev) = previous {
if prev.distance2(current) > NODE_EPS * NODE_EPS {
segments.push(Segment::new(prev, current));
if let Some(start) = previous {
let (dx, dy) = (current[0] - start[0], current[1] - start[1]);
if dx.hypot(dy) > WELD_TOLERANCE {
segments.push(Line {
start,
end: current,
});
}
}
@ -130,495 +65,6 @@ impl Scene {
}
}
build_planar_outlines(&segments)
.into_iter()
.map(|ring| ring.into_iter().map(|p| [p.x, p.y]).collect())
.collect()
}
}
/// Build all bounded faces produced by a collection of planar segments.
///
/// Every intersection splits the participating segments virtually. The
/// resulting pieces are assembled into an undirected planar graph and its
/// bounded faces are traced using a half-edge walk.
fn build_planar_outlines(segments: &[Segment]) -> Vec<Vec<P2>> {
if segments.is_empty() {
return Vec::new();
}
// Each original segment starts with its two endpoints as split positions.
let mut cuts: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; segments.len()];
// Sweep the less-congested coordinate axis, then run the exact AABB and
// segment tests only for active candidates. Dense intersections remain
// output-sensitive, while spatially separated geometry avoids all-pairs
// work.
let aabbs: Vec<SegmentAabb> = segments.iter().copied().map(SegmentAabb::new).collect();
let axis = choose_sweep_axis(&aabbs);
let mut order: Vec<usize> = (0..segments.len()).collect();
order.sort_by(|&a, &b| aabbs[a].min(axis).total_cmp(&aabbs[b].min(axis)));
let mut active = Vec::<usize>::new();
for i in order {
let current_min = aabbs[i].min(axis);
active.retain(|&j| aabbs[j].max(axis) + NODE_EPS >= current_min);
for &j in &active {
if !segment_aabbs_overlap(aabbs[i], aabbs[j]) {
continue;
}
match segment_intersection(segments[i], segments[j]) {
SegmentIntersection::None => {}
SegmentIntersection::Point { a, b } => {
cuts[i].push(a.clamp(0.0, 1.0));
cuts[j].push(b.clamp(0.0, 1.0));
}
SegmentIntersection::Overlap { a, b } => {
cuts[i].push(a[0].clamp(0.0, 1.0));
cuts[i].push(a[1].clamp(0.0, 1.0));
cuts[j].push(b[0].clamp(0.0, 1.0));
cuts[j].push(b[1].clamp(0.0, 1.0));
}
}
}
active.push(i);
}
// Split every segment at all of its intersection parameters.
let mut pieces = Vec::<Segment>::new();
for (segment, params) in segments.iter().copied().zip(cuts.iter_mut()) {
let segment_len = segment.a.distance2(segment.b).sqrt();
let param_merge_eps = (NODE_EPS / segment_len).min(1.0);
params.sort_by(|a, b| a.total_cmp(b));
params.dedup_by(|a, b| (*a - *b).abs() <= param_merge_eps);
for pair in params.windows(2) {
let t0 = pair[0];
let t1 = pair[1];
if (t1 - t0) * segment_len <= NODE_EPS {
continue;
}
let a = segment.a.lerp(segment.b, t0);
let b = segment.a.lerp(segment.b, t1);
if a.distance2(b) > NODE_EPS * NODE_EPS {
pieces.push(Segment::new(a, b));
}
}
}
// Convert split segment endpoints to graph nodes.
let mut nodes = Vec::<P2>::new();
let mut node_map = HashMap::<(i64, i64), Vec<usize>>::new();
let mut edges = HashSet::<(usize, usize)>::new();
for piece in pieces {
let a = node_for_point(piece.a, &mut nodes, &mut node_map);
let b = node_for_point(piece.b, &mut nodes, &mut node_map);
if a == b {
continue;
}
let edge = if a < b { (a, b) } else { (b, a) };
edges.insert(edge);
}
if edges.is_empty() {
return Vec::new();
}
// Build adjacency lists.
let mut adjacency = vec![Vec::<usize>::new(); nodes.len()];
for &(a, b) in &edges {
adjacency[a].push(b);
adjacency[b].push(a);
}
// A planar half-edge walk needs the neighbors around every vertex sorted
// by polar angle.
for (vertex, neighbors) in adjacency.iter_mut().enumerate() {
let origin = nodes[vertex];
neighbors.sort_by(|&a, &b| {
let aa = (nodes[a].y - origin.y).atan2(nodes[a].x - origin.x);
let ab = (nodes[b].y - origin.y).atan2(nodes[b].x - origin.x);
aa.total_cmp(&ab)
});
neighbors.dedup();
}
let mut visited = HashSet::<(usize, usize)>::new();
let mut faces = Vec::<Vec<P2>>::new();
// Each undirected edge represents two directed half-edges. Walking each
// unused half-edge while always taking the clockwise neighbor at the next
// vertex traces one face.
for u in 0..nodes.len() {
for &v in &adjacency[u] {
if visited.contains(&(u, v)) {
continue;
}
let start = (u, v);
let mut current = start;
let mut ring = Vec::<P2>::new();
let mut closed = false;
// A valid planar face cannot require more directed edges than exist
// in the complete graph. This also protects against malformed input.
let max_steps = edges.len() * 2 + 1;
for _ in 0..max_steps {
if visited.contains(&current) {
break;
}
visited.insert(current);
let (from, to) = current;
ring.push(nodes[from]);
let neighbors = &adjacency[to];
if neighbors.is_empty() {
break;
}
let Some(incoming_index) = neighbors.iter().position(|&neighbor| neighbor == from)
else {
break;
};
// Neighbors are sorted counter-clockwise. Taking the previous
// one keeps the bounded face on the left side of the half-edge.
let next_index = if incoming_index == 0 {
neighbors.len() - 1
} else {
incoming_index - 1
};
let next = neighbors[next_index];
current = (to, next);
if current == start {
closed = true;
break;
}
}
if !closed || ring.len() < 3 {
continue;
}
let area = signed_area(&ring);
// The reverse traversal produces the unbounded exterior face.
// With the walk rule above, bounded faces are counter-clockwise.
if area > AREA_EPS {
faces.push(ring);
}
}
}
faces
}
fn node_for_point(
p: P2,
nodes: &mut Vec<P2>,
map: &mut HashMap<(i64, i64), Vec<usize>>,
) -> usize {
let key = node_key(p);
for dx in -1_i64..=1 {
for dy in -1_i64..=1 {
let neighbor = (key.0.saturating_add(dx), key.1.saturating_add(dy));
if let Some(indices) = map.get(&neighbor) {
if let Some(&index) = indices
.iter()
.find(|&&index| nodes[index].distance2(p) <= NODE_EPS * NODE_EPS)
{
return index;
}
}
}
}
let index = nodes.len();
nodes.push(p);
map.entry(key).or_default().push(index);
index
}
fn node_key(p: P2) -> (i64, i64) {
(
(p.x / NODE_EPS).floor() as i64,
(p.y / NODE_EPS).floor() as i64,
)
}
fn choose_sweep_axis(aabbs: &[SegmentAabb]) -> SweepAxis {
if normalized_interval_span(aabbs, SweepAxis::X)
<= normalized_interval_span(aabbs, SweepAxis::Y)
{
SweepAxis::X
} else {
SweepAxis::Y
}
}
fn normalized_interval_span(aabbs: &[SegmentAabb], axis: SweepAxis) -> f64 {
let min = aabbs
.iter()
.map(|aabb| aabb.min(axis))
.fold(f64::INFINITY, f64::min);
let max = aabbs
.iter()
.map(|aabb| aabb.max(axis))
.fold(f64::NEG_INFINITY, f64::max);
let extent = max - min;
if !extent.is_finite() || extent <= NODE_EPS {
return f64::INFINITY;
}
let total: f64 = aabbs
.iter()
.map(|aabb| aabb.max(axis) - aabb.min(axis) + NODE_EPS)
.sum();
total / extent
}
fn segment_aabbs_overlap(a: SegmentAabb, b: SegmentAabb) -> bool {
a.max_x + NODE_EPS >= b.min_x
&& b.max_x + NODE_EPS >= a.min_x
&& a.max_y + NODE_EPS >= b.min_y
&& b.max_y + NODE_EPS >= a.min_y
}
/// Intersection parameters of two finite XY segments.
fn segment_intersection(a: Segment, b: Segment) -> SegmentIntersection {
let rx = a.b.x - a.a.x;
let ry = a.b.y - a.a.y;
let sx = b.b.x - b.a.x;
let sy = b.b.y - b.a.y;
let r2 = rx * rx + ry * ry;
let s2 = sx * sx + sy * sy;
if r2 <= f64::EPSILON || s2 <= f64::EPSILON {
return SegmentIntersection::None;
}
let r_len = r2.sqrt();
let s_len = s2.sqrt();
let cross = rx * sy - ry * sx;
let qpx = b.a.x - a.a.x;
let qpy = b.a.y - a.a.y;
if cross.abs() <= ORIENTATION_EPS * r_len * s_len {
if (qpx * ry - qpy * rx).abs() > NODE_EPS * r_len {
return SegmentIntersection::None;
}
let b0_on_a = (qpx * rx + qpy * ry) / r2;
let b1_on_a = b0_on_a + (sx * rx + sy * ry) / r2;
let lo = b0_on_a.min(b1_on_a).max(0.0);
let hi = b0_on_a.max(b1_on_a).min(1.0);
let a_param_eps = NODE_EPS / r_len;
if hi < lo - a_param_eps {
return SegmentIntersection::None;
}
let lo = lo.clamp(0.0, 1.0);
let hi = hi.clamp(0.0, 1.0);
let lo_point = a.a.lerp(a.b, lo);
let hi_point = a.a.lerp(a.b, hi);
let b_lo = ((lo_point.x - b.a.x) * sx + (lo_point.y - b.a.y) * sy) / s2;
let b_hi = ((hi_point.x - b.a.x) * sx + (hi_point.y - b.a.y) * sy) / s2;
if (hi - lo) * r_len <= NODE_EPS {
return SegmentIntersection::Point {
a: (lo + hi) * 0.5,
b: (b_lo + b_hi) * 0.5,
};
}
return SegmentIntersection::Overlap {
a: [lo, hi],
b: [b_lo, b_hi],
};
}
let t = (qpx * sy - qpy * sx) / cross;
let u = (qpx * ry - qpy * rx) / cross;
let t_eps = NODE_EPS / r_len;
let u_eps = NODE_EPS / s_len;
if t >= -t_eps && t <= 1.0 + t_eps && u >= -u_eps && u <= 1.0 + u_eps {
SegmentIntersection::Point { a: t, b: u }
} else {
SegmentIntersection::None
}
}
fn signed_area(poly: &[P2]) -> f64 {
if poly.len() < 3 {
return 0.0;
}
let origin = poly[0];
let mut area = 0.0;
for i in 1..poly.len() - 1 {
let a = poly[i];
let b = poly[i + 1];
area += (a.x - origin.x) * (b.y - origin.y)
- (b.x - origin.x) * (a.y - origin.y);
}
area * 0.5
}
#[cfg(test)]
mod tests {
use super::*;
fn s(ax: f64, ay: f64, bx: f64, by: f64) -> Segment {
Segment::new(P2::new(ax, ay), P2::new(bx, by))
}
#[test]
fn four_unjoined_lines_form_one_face() {
let segments = vec![
s(0.0, 0.0, 10.0, 0.0),
s(10.0, 0.0, 10.0, 10.0),
s(10.0, 10.0, 0.0, 10.0),
s(0.0, 10.0, 0.0, 0.0),
];
let faces = build_planar_outlines(&segments);
assert_eq!(faces.len(), 1);
assert!((signed_area(&faces[0]) - 100.0).abs() < 1.0e-6);
}
#[test]
fn lines_may_extend_past_the_boundary() {
let segments = vec![
s(-5.0, 0.0, 15.0, 0.0),
s(-5.0, 10.0, 15.0, 10.0),
s(0.0, -5.0, 0.0, 15.0),
s(10.0, -5.0, 10.0, 15.0),
];
let faces = build_planar_outlines(&segments);
assert_eq!(faces.len(), 1);
assert!((signed_area(&faces[0]) - 100.0).abs() < 1.0e-6);
}
#[test]
fn open_geometry_does_not_create_a_face() {
let segments = vec![
s(0.0, 0.0, 10.0, 0.0),
s(10.0, 0.0, 10.0, 10.0),
s(10.0, 10.0, 0.0, 10.0),
];
let faces = build_planar_outlines(&segments);
assert!(faces.is_empty());
}
#[test]
fn collinear_overlap_closes_rectangle() {
let segments = vec![
s(0.0, 0.0, 7.0, 0.0),
s(3.0, 0.0, 10.0, 0.0),
s(10.0, 0.0, 10.0, 10.0),
s(10.0, 10.0, 0.0, 10.0),
s(0.0, 10.0, 0.0, 0.0),
];
let faces = build_planar_outlines(&segments);
assert_eq!(faces.len(), 1);
assert!((signed_area(&faces[0]) - 100.0).abs() < 1.0e-6);
}
#[test]
fn node_merge_crosses_bucket_boundary() {
let mut nodes = Vec::new();
let mut map = HashMap::new();
let a = node_for_point(P2::new(NODE_EPS * 0.99, 0.0), &mut nodes, &mut map);
let b = node_for_point(P2::new(NODE_EPS * 1.01, 0.0), &mut nodes, &mut map);
assert_eq!(a, b);
}
#[test]
fn node_merge_rejects_distant_diagonal_points() {
let mut nodes = Vec::new();
let mut map = HashMap::new();
let a = node_for_point(
P2::new(NODE_EPS * 0.51, NODE_EPS * 0.51),
&mut nodes,
&mut map,
);
let b = node_for_point(
P2::new(NODE_EPS * 1.49, NODE_EPS * 1.49),
&mut nodes,
&mut map,
);
assert_ne!(a, b);
}
#[test]
fn signed_area_is_stable_at_large_coordinates() {
let base = 1.0e12;
let poly = vec![
P2::new(base, base),
P2::new(base + 3.0, base),
P2::new(base + 3.0, base + 4.0),
P2::new(base, base + 4.0),
];
assert!((signed_area(&poly) - 12.0).abs() < 1.0e-9);
}
#[test]
fn long_segments_keep_world_scale_cuts() {
let segments = vec![
s(0.0, 0.0, 1.0e12, 0.0),
s(0.0, 1.0, 1.0e12, 1.0),
s(1.0, -1.0, 1.0, 2.0),
s(2.0, -1.0, 2.0, 2.0),
];
let faces = build_planar_outlines(&segments);
assert_eq!(faces.len(), 1);
assert!((signed_area(&faces[0]) - 1.0).abs() < 1.0e-9);
}
#[test]
fn long_near_collinear_segments_overlap_within_node_tolerance() {
let a = s(0.0, 0.0, 1.0e9, 1.0e-3);
let b = s(5.0e8, 5.0e-4 + 5.0e-7, 1.5e9, 1.5e-3 + 5.0e-7);
assert!(matches!(
segment_intersection(a, b),
SegmentIntersection::Overlap { .. }
));
bounded_faces(&segments, Tolerance::new(WELD_TOLERANCE))
}
}

View file

@ -3,7 +3,10 @@
// hit-testing lives in `scene::pick::hit_test`.)
use super::*;
fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option<glam::DVec3> {
fn rendered_wire_bounds(
wires: &[WireModel],
fallback_z: f64,
) -> Option<(glam::DVec3, glam::DVec3)> {
let mut min = glam::DVec3::splat(f64::INFINITY);
let mut max = glam::DVec3::splat(f64::NEG_INFINITY);
let mut include = |point: glam::DVec3| {
@ -55,12 +58,16 @@ fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option<glam::DV
}
if min.is_finite() && max.is_finite() {
Some((min + max) * 0.5)
Some((min, max))
} else {
None
}
}
fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option<glam::DVec3> {
rendered_wire_bounds(wires, fallback_z).map(|(min, max)| (min + max) * 0.5)
}
fn block_entity_transform(
document: &CadDocument,
block_name: &str,
@ -189,6 +196,105 @@ impl Scene {
self.camera_generation += 1;
}
/// Save the active view immediately before a navigation operation. The
/// paper-space camera and an entered floating viewport use different state,
/// so the snapshot records whichever one currently owns navigation.
pub fn remember_current_view(&mut self) {
let layout = self.current_layout.clone();
if let Some(handle) = self.active_viewport {
if let Some(EntityType::Viewport(viewport)) = self.document.get_entity(handle) {
self.previous_view = Some(ViewSnapshot::Floating {
layout,
handle,
viewport: Box::new(viewport.clone()),
});
return;
}
}
self.previous_view = Some(ViewSnapshot::Main {
layout,
tile: self.active_model_tile.get(),
camera: self.camera.borrow().clone(),
});
}
/// Swap the active view with the most recently remembered one. Keeping the
/// displaced current view makes repeated ZOOM Previous calls toggle cleanly
/// between the last two views.
pub fn restore_previous_view(&mut self) -> bool {
let Some(snapshot) = self.previous_view.take() else {
return false;
};
match snapshot {
ViewSnapshot::Main {
layout,
tile,
camera,
} if self.active_viewport.is_none()
&& self.current_layout == layout
&& (layout != "Model" || self.active_model_tile.get() == tile) =>
{
let current = ViewSnapshot::Main {
layout,
tile,
camera: self.camera.borrow().clone(),
};
*self.camera.borrow_mut() = camera;
self.previous_view = Some(current);
self.camera_generation += 1;
true
}
ViewSnapshot::Floating {
layout,
handle,
viewport,
} if self.current_layout == layout && self.active_viewport == Some(handle) => {
let Some(EntityType::Viewport(current)) = self.document.get_entity(handle) else {
self.previous_view = Some(ViewSnapshot::Floating {
layout,
handle,
viewport,
});
return false;
};
if current.status.locked {
self.previous_view = Some(ViewSnapshot::Floating {
layout,
handle,
viewport,
});
return false;
}
let current = Box::new(current.clone());
if let Some(EntityType::Viewport(destination)) =
self.document.get_entity_mut(handle)
{
destination.view_center = viewport.view_center;
destination.view_direction = viewport.view_direction;
destination.view_target = viewport.view_target;
destination.lens_length = viewport.lens_length;
destination.front_clip_z = viewport.front_clip_z;
destination.back_clip_z = viewport.back_clip_z;
destination.view_height = viewport.view_height;
destination.twist_angle = viewport.twist_angle;
destination.custom_scale = viewport.custom_scale;
destination.status.perspective = viewport.status.perspective;
}
self.previous_view = Some(ViewSnapshot::Floating {
layout,
handle,
viewport: current,
});
self.camera_generation += 1;
true
}
snapshot => {
self.previous_view = Some(snapshot);
false
}
}
}
/// Refresh model bounds without moving the live camera. Reusing `fit_all`
/// keeps projection framing on the same visibility and outlier filters as
/// Zoom Extents instead of accepting every finite cached AABB.
@ -270,6 +376,52 @@ impl Scene {
self.camera_generation += 1;
}
/// Fit the active view to the rendered bounds of the supplied entities.
/// Returns false when the selection has no finite visible geometry.
pub fn zoom_to_entities(&mut self, handles: &[Handle]) -> bool {
let fallback_z = self.active_camera_target().z;
let wires = self.wire_models_for(handles);
let mut bounds = rendered_wire_bounds(&wires, fallback_z);
for handle in handles {
let Some(mesh) = self.meshes.get(handle) else {
continue;
};
let [x0, y0, x1, y1] = mesh.world_aabb;
let [z0, z1] = mesh.z_aabb;
let mesh_min = glam::DVec3::new(x0 as f64, y0 as f64, z0 as f64);
let mesh_max = glam::DVec3::new(x1 as f64, y1 as f64, z1 as f64);
if !mesh_min.is_finite() || !mesh_max.is_finite() {
continue;
}
bounds = Some(match bounds {
Some((min, max)) => (min.min(mesh_min), max.max(mesh_max)),
None => (mesh_min, mesh_max),
});
}
let Some((mut min, mut max)) = bounds else {
return false;
};
if min == max {
min -= glam::DVec3::splat(1.0);
max += glam::DVec3::splat(1.0);
}
let min = min.as_vec3();
let max = max.as_vec3();
if !min.is_finite() || !max.is_finite() {
return false;
}
if self.active_viewport.is_some() {
return self.fit_active_viewport_to_bounds(min, max);
}
let aspect = self.active_camera_aspect();
self.camera.borrow_mut().fit_to_bounds(min, max, aspect);
self.projection_bounds_epoch.set(self.geometry_epoch);
self.camera_generation += 1;
true
}
/// Centre the active camera on one rendered entity without changing zoom
/// distance, orientation, or viewport scale.
pub fn center_camera_on_entity(&mut self, handle: Handle) -> bool {

View file

@ -1123,6 +1123,20 @@ pub(crate) struct ModelTile {
pub(crate) snap_on: bool,
}
#[derive(Clone)]
enum ViewSnapshot {
Main {
layout: String,
tile: usize,
camera: Camera,
},
Floating {
layout: String,
handle: Handle,
viewport: Box<acadrust::entities::Viewport>,
},
}
/// Gap (pixels) between Model panes — the `pane_grid` spacing and the visible
/// divider width. The renderer derives tile rects through this same spacing so
/// the drawn viewports line up exactly with the pane_grid layout.
@ -1488,6 +1502,10 @@ struct SceneLight {
pub struct Scene {
pub camera: Rc<RefCell<Camera>>,
/// View saved immediately before the latest navigation operation. ZOOM
/// Previous swaps with this snapshot, allowing the user to toggle between
/// the two most recent views without touching drawing history.
previous_view: Option<ViewSnapshot>,
/// Model-space tiled viewport layout. One full-window tile by default;
/// the split buttons / VPORTS subdivide the active tile.
pub(crate) model_tiles: RefCell<Vec<ModelTile>>,
@ -1932,6 +1950,7 @@ impl Scene {
pub fn new() -> Self {
Self {
camera: Rc::new(RefCell::new(Camera::default())),
previous_view: None,
model_tiles: RefCell::new(vec![ModelTile {
rect: iced::Rectangle {
x: 0.0,

View file

@ -14,6 +14,14 @@ pub enum SnapHint {
/// arc-length centre, a spline's `t = 0.5`). Lines / polylines do
/// not use this — their midpoints are derived from `key_vertices`.
Midpoint,
/// An end of a curve that is not a chain of straight segments — an arc,
/// an elliptical arc, a spline.
///
/// Such a curve cannot put its ends in `key_vertices`: consecutive
/// entries there are taken to be joined by a straight segment, so an
/// arc's two ends would also produce a midpoint snap out on the chord,
/// which is not on the geometry.
Endpoint,
}
/// Geometric primitive used by the tangent-snap engine.

View file

@ -15,13 +15,10 @@ use crate::scene::model::mesh_model::MeshModel;
use crate::scene::model::wire_model::WireModel;
use crate::scene::pick::interaction_index::WireSource;
/// Pixel radius used for single-click wire detection.
pub const CLICK_THRESHOLD_PX: f32 = 8.0;
/// Pick radius for one wire, in screen pixels.
///
/// A wire renders as a band `line_weight_px` wide, so testing every wire at the
/// bare [`CLICK_THRESHOLD_PX`] would leave the outer part of a heavy line
/// configured base radius would leave the outer part of a heavy line
/// unselectable — the cursor would sit on solid ink and miss. Widening to the
/// rendered half-width keeps "looks like I'm on it" and "picks it" the same
/// thing at any zoom: both quantities are screen-space, so the relation holds
@ -35,13 +32,13 @@ pub const CLICK_THRESHOLD_PX: f32 = 8.0;
/// renders 7.97 px half-width), so this only bites for out-of-range weights —
/// and it keeps the two sides from silently drifting apart if the display boost
/// in `view::render::lineweight_to_px` ever changes.
pub fn pick_tolerance_px(wire: &WireModel, lw_display: bool) -> f32 {
pub fn pick_tolerance_px(wire: &WireModel, lw_display: bool, base_radius_px: f32) -> f32 {
let half_width = if lw_display {
wire.line_weight_px * 0.5
} else {
0.5
};
CLICK_THRESHOLD_PX.max(half_width)
base_radius_px.max(1.0).max(half_width)
}
/// Is `aabb` — a wire's world-space XY box — further than `tol` pixels from
@ -199,6 +196,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
eye: glam::DVec3,
bounds: Rectangle,
lw_display: bool,
base_radius_px: f32,
) -> Option<&'a str> {
// A click outside the pane rectangle (e.g. on the paper around a floating
// viewport) must not reach geometry scissored out of the viewport.
@ -243,7 +241,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
bounds,
);
let d = dist_point_to_segment(cursor, p0, p1);
if d < pick_tolerance_px(wire, lw_display) && d < best_dist {
if d < pick_tolerance_px(wire, lw_display, base_radius_px) && d < best_dist {
best_dist = d;
best = Some(&wire.name);
}
@ -251,7 +249,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
} else {
// Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain.
for wire in wires.iter() {
let tol = pick_tolerance_px(wire, lw_display);
let tol = pick_tolerance_px(wire, lw_display, base_radius_px);
// Cheap AABB pre-reject (flat view only; never for the unbounded
// sentinel used by previews / greeked text).
if z_flat
@ -446,6 +444,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
eye: glam::DVec3,
bounds: Rectangle,
lw_display: bool,
base_radius_px: f32,
) -> Vec<&'a str> {
if cursor.x < 0.0 || cursor.x > bounds.width || cursor.y < 0.0 || cursor.y > bounds.height {
return Vec::new();
@ -474,7 +473,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
bounds,
);
let d = dist_point_to_segment(cursor, p0, p1);
if d < pick_tolerance_px(wire, lw_display) {
if d < pick_tolerance_px(wire, lw_display, base_radius_px) {
best_by_wire
.entry(segment.wire)
.and_modify(|best| *best = best.min(d))
@ -488,7 +487,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
}));
} else {
for wire in wires.iter() {
let tol = pick_tolerance_px(wire, lw_display);
let tol = pick_tolerance_px(wire, lw_display, base_radius_px);
let mut prev: Option<Point> = None;
let mut best_for_wire = tol;
let mut hit = false;
@ -543,7 +542,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
)
.is_some()
{
hits.push((CLICK_THRESHOLD_PX, wire.name.as_str()));
hits.push((base_radius_px.max(1.0), wire.name.as_str()));
matched.insert(triangle.wire);
}
}
@ -562,7 +561,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
)
.is_some()
{
hits.push((CLICK_THRESHOLD_PX, wire.name.as_str()));
hits.push((base_radius_px.max(1.0), wire.name.as_str()));
}
}
}
@ -597,7 +596,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
)
.is_some()
{
hits.push((CLICK_THRESHOLD_PX, wire.name.as_str()));
hits.push((base_radius_px.max(1.0), wire.name.as_str()));
matched.insert(triangle.wire);
}
}
@ -616,7 +615,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
)
.is_some()
{
hits.push((CLICK_THRESHOLD_PX, wire.name.as_str()));
hits.push((base_radius_px.max(1.0), wire.name.as_str()));
}
}
}
@ -641,7 +640,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
continue;
};
if text_quad_hit_area(cursor, vertices, view_rot, eye, bounds).is_some() {
hits.push((CLICK_THRESHOLD_PX, wire.name.as_str()));
hits.push((base_radius_px.max(1.0), wire.name.as_str()));
matched.insert(glyph.wire);
}
}
@ -651,7 +650,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
continue;
}
if text_quad_hit_area(cursor, &wire.text_verts, view_rot, eye, bounds).is_some() {
hits.push((CLICK_THRESHOLD_PX, wire.name.as_str()));
hits.push((base_radius_px.max(1.0), wire.name.as_str()));
}
}
}
@ -2038,16 +2037,16 @@ mod aabb_reject_tests {
let eye = glam::DVec3::ZERO;
assert_eq!(
click_hit(cursor, std::slice::from_ref(&near), vp, eye, bounds, true),
click_hit(cursor, std::slice::from_ref(&near), vp, eye, bounds, true, 8.0),
Some("5")
);
assert_eq!(
click_hit(cursor, std::slice::from_ref(&far), vp, eye, bounds, true),
click_hit(cursor, std::slice::from_ref(&far), vp, eye, bounds, true, 8.0),
None
);
// The far wire must be rejected without hiding the near one.
assert_eq!(
click_hit(cursor, &[far, near], vp, eye, bounds, true),
click_hit(cursor, &[far, near], vp, eye, bounds, true, 8.0),
Some("5")
);
}

View file

@ -11,7 +11,7 @@ use iced::{Point, Rectangle};
use crate::command::TangentObject;
use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel};
use crate::scene::pick::interaction_index::WireSource;
use crate::ui::overlay::CROSSHAIR_ARM;
const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0;
// ── Snap type ─────────────────────────────────────────────────────────────
@ -174,7 +174,7 @@ impl Default for Snapper {
enabled,
grid_snap_on: false,
grid_spacing: 1.0,
osnap_radius_px: CROSSHAIR_ARM * 0.25,
osnap_radius_px: DEFAULT_OSNAP_RADIUS_PX,
otrack_enabled: false,
tracking_points: Vec::new(),
tracking_dirs: Vec::new(),
@ -830,7 +830,7 @@ impl Snapper {
eye,
bounds,
Vec3::ZERO,
Mat4::IDENTITY,
(Vec3::X, Vec3::Y, Vec3::Z),
None,
)
}
@ -844,10 +844,10 @@ impl Snapper {
view_rot: Mat4,
eye: glam::DVec3,
bounds: Rectangle,
// Grid origin (render/wire space) and UCS→world rotation, so grid snap
// lands on the UCS grid the user sees. `(ZERO, IDENTITY)` = world grid.
// Grid origin and live drafting axes, so grid snap lands on the same
// rotated or isometric grid the user sees.
grid_origin: Vec3,
grid_rot: Mat4,
grid_axes: (Vec3, Vec3, Vec3),
construction_cursor: Option<glam::DVec3>,
) -> Option<SnapResult> {
// Object-snap selection is priority-then-distance, NOT nearest-wins.
@ -883,13 +883,27 @@ impl Snapper {
let s = self.grid_spacing as f64;
if s.abs() > 1e-9 {
// Round in the UCS grid frame, then map back to world.
let ax = grid_rot.transform_vector3(Vec3::X).as_dvec3();
let ay = grid_rot.transform_vector3(Vec3::Y).as_dvec3();
let az = grid_rot.transform_vector3(Vec3::Z).as_dvec3();
let (ax, ay, az) = grid_axes;
let ax = ax.normalize_or(Vec3::X).as_dvec3();
let ay = ay.normalize_or(Vec3::Y).as_dvec3();
let az = az.normalize_or(Vec3::Z).as_dvec3();
let origin = grid_origin.as_dvec3();
let rel = cursor_world - origin;
let ux = (rel.dot(ax) / s).round() * s;
let uy = (rel.dot(ay) / s).round() * s;
// The isometric pairs are oblique, so dot products alone are
// not coordinates. Invert their 2×2 Gram matrix before rounding.
let aa = ax.dot(ax);
let ab = ax.dot(ay);
let bb = ay.dot(ay);
let det = aa * bb - ab * ab;
let (ux, uy) = if det.abs() > 1e-9 {
let ra = rel.dot(ax);
let rb = rel.dot(ay);
((ra * bb - rb * ab) / det, (rb * aa - ra * ab) / det)
} else {
(rel.dot(ax), rel.dot(ay))
};
let ux = (ux / s).round() * s;
let uy = (uy / s).round() * s;
let uz = (rel.dot(az) / s).round() * s;
let gp = origin + ax * ux + ay * uy + az * uz;
let screen = world_to_screen(gp, view_rot, eye, bounds);
@ -1014,6 +1028,7 @@ impl Snapper {
SnapHint::Quadrant => SnapType::Quadrant,
SnapHint::Insertion => SnapType::Insertion,
SnapHint::Midpoint => SnapType::Midpoint,
SnapHint::Endpoint => SnapType::Endpoint,
};
if self.is_on(snap_type) {
try_pt(world, snap_type);

View file

@ -6,15 +6,68 @@ use iced::widget::canvas;
use iced::{Color, Element, Length, Point, Size, Theme};
use crate::app::Message;
use crate::app::settings::{CursorType, IsoPlane};
use crate::scene::model::object::GripShape;
use crate::scene::SelectionState;
/// Half-size of the crosshair center square in screen pixels (square = SQ*2 × SQ*2).
pub const CROSSHAIR_SQ: f32 = 7.5;
/// Arm length of the crosshair from center — used as the snap aperture radius.
pub const CROSSHAIR_ARM: f32 = 60.0;
use crate::snap::SnapType;
/// Original crosshair geometry retained at the default setting values.
pub const CROSSHAIR_SQ: f32 = 7.5;
pub const CROSSHAIR_ARM: f32 = 60.0;
const DEFAULT_CURSOR_SIZE: i32 = 5;
const DEFAULT_PICK_BOX: i32 = 3;
const DEFAULT_PICK_APERTURE: f32 = 8.0;
/// Convert CURSORSIZE to a screen-space arm length while keeping the original
/// 60 px cursor at the default value and the full-viewport result at 100.
pub(crate) fn crosshair_arm_px(bounds: iced::Rectangle, value: i32) -> f32 {
let value = value.clamp(1, 100);
if value <= DEFAULT_CURSOR_SIZE {
return CROSSHAIR_ARM * value as f32 / DEFAULT_CURSOR_SIZE as f32;
}
let full = bounds.width.hypot(bounds.height).max(CROSSHAIR_ARM);
let scale = (value - DEFAULT_CURSOR_SIZE) as f32 / (100 - DEFAULT_CURSOR_SIZE) as f32;
CROSSHAIR_ARM + (full - CROSSHAIR_ARM) * scale
}
/// Convert PICKBOX to the visible half-size while retaining the original
/// 15 x 15 px center box at the default value.
pub(crate) fn pick_box_half_px(value: i32) -> f32 {
let value = value.clamp(0, 50);
if value <= DEFAULT_PICK_BOX {
return CROSSHAIR_SQ * value as f32 / DEFAULT_PICK_BOX as f32;
}
let scale = (value - DEFAULT_PICK_BOX) as f32 / (50 - DEFAULT_PICK_BOX) as f32;
CROSSHAIR_SQ + (50.0 - CROSSHAIR_SQ) * scale
}
/// Convert PICKBOX to the real click/hover aperture. A zero setting hides the
/// box but retains the one-pixel minimum needed for direct hits.
pub(crate) fn pick_box_aperture_px(value: i32) -> f32 {
let value = value.clamp(0, 50);
let aperture = if value <= DEFAULT_PICK_BOX {
DEFAULT_PICK_APERTURE * value as f32 / DEFAULT_PICK_BOX as f32
} else {
let scale = (value - DEFAULT_PICK_BOX) as f32 / (50 - DEFAULT_PICK_BOX) as f32;
DEFAULT_PICK_APERTURE + (50.0 - DEFAULT_PICK_APERTURE) * scale
};
aperture.max(1.0)
}
#[derive(Clone, Copy)]
pub struct CrosshairOptions {
pub size_percent: i32,
pub pick_box: i32,
pub cursor_type: CursorType,
pub color: Option<[u8; 3]>,
pub isometric: bool,
pub iso_plane: IsoPlane,
pub snap_angle_deg: f32,
}
// ── Grip marker data ──────────────────────────────────────────────────────
/// Describes one grip to be drawn in the viewport overlay.
@ -223,6 +276,7 @@ pub fn selection_overlay<'a>(
suppressed: bool,
hover_locked: bool,
crosshair_bg: [f32; 4],
crosshair: CrosshairOptions,
) -> Element<'a, Message> {
canvas(SelectionCanvas {
selection,
@ -243,6 +297,7 @@ pub fn selection_overlay<'a>(
suppressed,
hover_locked,
crosshair_bg,
crosshair,
})
.width(Length::Fill)
.height(Length::Fill)
@ -297,6 +352,7 @@ struct SelectionCanvas {
/// Background of the active drawing space. Crosshair contrast follows this
/// rather than the UI theme, which may be light over a dark model viewport.
crosshair_bg: [f32; 4],
crosshair: CrosshairOptions,
}
fn draw_grip_marker(frame: &mut canvas::Frame, grip: &GripMarker, theme: &Theme) {
@ -432,7 +488,7 @@ impl canvas::Program<Message> for SelectionCanvas {
// cursor entirely. `Interaction::None` would let the stack fall
// through to a sibling — `Hidden` is the explicit "no cursor"
// signal that actually suppresses the OS arrow.
if cursor.is_over(bounds) {
if cursor.is_over(bounds) && self.crosshair.cursor_type == CursorType::Crosshair {
mouse::Interaction::Hidden
} else {
mouse::Interaction::default()
@ -924,11 +980,28 @@ impl canvas::Program<Message> for SelectionCanvas {
// top of it would double up the visual feedback.
let over_divider = self.divider_under(cursor, bounds);
// PAN mode replaces the crosshair with a hand cursor.
if !over_viewcube && !over_divider && !self.pan_mode && !self.suppressed {
if !over_viewcube
&& !over_divider
&& !self.pan_mode
&& !self.suppressed
&& self.crosshair.cursor_type == CursorType::Crosshair
{
if let Some(cp) = self.selection.last_move_pos {
let [r, g, b, a] = crate::scene::view::render::adapt_to_bg(
let [r, g, b, a] = self.crosshair.color.map_or_else(
|| {
crate::scene::view::render::adapt_to_bg(
[1.0, 1.0, 1.0, 0.90],
self.crosshair_bg,
)
},
|[r, g, b]| {
[
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
0.90,
]
},
);
let color = Color { r, g, b, a };
let stroke = canvas::Stroke {
@ -936,38 +1009,36 @@ impl canvas::Program<Message> for SelectionCanvas {
style: canvas::Style::Solid(color),
..Default::default()
};
let sq = CROSSHAIR_SQ; // square half-size → 15×15
let arm = CROSSHAIR_ARM; // crosshair arm length from center
// Horizontal arms (start at square edge, end at arm length)
let h_left = canvas::Path::new(|b| {
b.move_to(Point::new(cp.x - sq, cp.y));
b.line_to(Point::new(cp.x - arm, cp.y));
let sq = pick_box_half_px(self.crosshair.pick_box);
let arm = crosshair_arm_px(bounds, self.crosshair.size_percent);
let base_angles: [f64; 2] = if self.crosshair.isometric {
self.crosshair.iso_plane.angles()
} else {
[0.0, 90.0]
};
for angle in base_angles {
let rad = (angle + self.crosshair.snap_angle_deg as f64).to_radians();
let dir = Point::new(rad.cos() as f32, -rad.sin() as f32);
let gap = if sq > 0.0 {
sq / dir.x.abs().max(dir.y.abs()).max(1e-6)
} else {
0.0
};
let arms = canvas::Path::new(|path| {
path.move_to(Point::new(cp.x + dir.x * gap, cp.y + dir.y * gap));
path.line_to(Point::new(cp.x + dir.x * arm, cp.y + dir.y * arm));
path.move_to(Point::new(cp.x - dir.x * gap, cp.y - dir.y * gap));
path.line_to(Point::new(cp.x - dir.x * arm, cp.y - dir.y * arm));
});
let h_right = canvas::Path::new(|b| {
b.move_to(Point::new(cp.x + sq, cp.y));
b.line_to(Point::new(cp.x + arm, cp.y));
});
// Vertical arms
let v_top = canvas::Path::new(|b| {
b.move_to(Point::new(cp.x, cp.y - sq));
b.line_to(Point::new(cp.x, cp.y - arm));
});
let v_bot = canvas::Path::new(|b| {
b.move_to(Point::new(cp.x, cp.y + sq));
b.line_to(Point::new(cp.x, cp.y + arm));
});
// Center square
frame.stroke(&arms, stroke.clone());
}
if sq > 0.0 {
let square = canvas::Path::rectangle(
Point::new(cp.x - sq, cp.y - sq),
Size::new(sq * 2.0, sq * 2.0),
);
frame.stroke(&h_left, stroke.clone());
frame.stroke(&h_right, stroke.clone());
frame.stroke(&v_top, stroke.clone());
frame.stroke(&v_bot, stroke.clone());
frame.stroke(&square, stroke);
}
// Locked-layer badge: a small padlock beside the crosshair when
// the hovered object sits on a locked layer (issue: locked

View file

@ -1,14 +1,19 @@
//! OpenCADStudio-style OSNAP status menu.
use iced::widget::{button, container, row, text};
use iced::widget::{button, checkbox, column, container, row, text};
use iced::{Background, Element, Fill, Length, Theme};
use crate::app::Message;
use crate::app::settings::IsoPlane;
use crate::snap::{SnapType, Snapper, ALL_SNAP_MODES};
use crate::ui::statusbar::status_menu::Entry;
use crate::t;
pub fn menu_entries<'a>(snapper: &'a Snapper) -> Vec<Entry<'a>> {
pub fn menu_entries<'a>(
snapper: &'a Snapper,
isometric: bool,
iso_plane: IsoPlane,
) -> Vec<Entry<'a>> {
let all_on = snapper.all_on();
let none_on = snapper.none_on();
@ -30,7 +35,49 @@ pub fn menu_entries<'a>(snapper: &'a Snapper) -> Vec<Entry<'a>> {
.width(Fill)
.padding([0, 4]);
let mut entries = vec![Entry::stay(header), Entry::stay(divider)];
let mut iso_planes = row![].spacing(3);
for plane in IsoPlane::ALL {
iso_planes = iso_planes.push(
button(text(t!(plane.label())).size(10))
.on_press(Message::SetIsoPlane(plane))
.style(if isometric && iso_plane == plane {
button::primary
} else {
button::secondary
})
.padding([3, 8]),
);
}
let drafting = column![
row![
checkbox(isometric)
.on_toggle(|_| Message::ToggleIsometricDrafting)
.size(14),
text(t!("Isometric drafting")).size(11),
]
.spacing(6)
.align_y(iced::Center),
iso_planes,
text(t!("F5 cycles the active plane.")).size(10),
]
.spacing(5)
.padding([5, 8]);
let drafting_divider = container(iced::widget::Space::new().height(1))
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.weak.color,
)),
..Default::default()
})
.width(Fill)
.padding([0, 4]);
let mut entries = vec![
Entry::stay(header),
Entry::stay(divider),
Entry::stay(drafting),
Entry::stay(drafting_divider),
];
for &(snap_type, _glyph, label) in ALL_SNAP_MODES {
entries.push(Entry::stay(snap_row(
snap_type,

View file

@ -71,6 +71,8 @@ impl StatusBar {
polar_increment_deg: f32,
dyn_input: bool,
otrack: bool,
isometric_drafting: bool,
iso_plane: crate::app::settings::IsoPlane,
layouts: Vec<String>,
block_tabs: Vec<String>,
reorderable_layouts: Vec<String>,
@ -297,7 +299,11 @@ impl StatusBar {
osnap_active,
snapper.snap_enabled,
tooltip_hidden,
crate::ui::popup::snap_popup::menu_entries(snapper),
crate::ui::popup::snap_popup::menu_entries(
snapper,
isometric_drafting,
iso_plane,
),
)
.into(),
);

View file

@ -0,0 +1,135 @@
use crate::app::settings::IsoPlane;
use crate::app::Message;
use crate::snap::{Snapper, ALL_SNAP_MODES};
use iced::widget::{button, checkbox, column, container, row, scrollable, text, Space};
use iced::{Element, Fill};
pub fn view_window<'a>(
snapper: &'a Snapper,
grid: bool,
grid_snap: bool,
ortho: bool,
polar: bool,
otrack: bool,
isometric: bool,
iso_plane: IsoPlane,
snap_angle_deg: f32,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let toggle = |value: bool, label: std::borrow::Cow<'a, str>, message: Message| {
row![
checkbox(value).on_toggle(move |_| message.clone()).size(15),
text(label).size(12),
]
.spacing(7)
.align_y(iced::Center)
};
let drafting_modes = column![
text(crate::t!("Drafting modes")).size(15),
Space::new().height(8),
toggle(grid, crate::t!("Grid display"), Message::ToggleGrid),
toggle(grid_snap, crate::t!("Grid snap"), Message::ToggleGridSnap),
toggle(ortho, crate::t!("Ortho"), Message::ToggleOrtho),
toggle(polar, crate::t!("Polar tracking"), Message::TogglePolar),
toggle(otrack, crate::t!("Object snap tracking"), Message::ToggleOTrack),
]
.spacing(6);
let mut planes = row![].spacing(5);
for plane in IsoPlane::ALL {
planes = planes.push(
button(text(crate::t!(plane.label())).size(11))
.on_press(Message::SetIsoPlane(plane))
.style(if isometric && iso_plane == plane {
button::primary
} else {
button::secondary
})
.padding([5, 12]),
);
}
let isometric_controls = column![
text(crate::t!("Isometric drafting")).size(15),
Space::new().height(8),
toggle(
isometric,
crate::t!("Enable isometric drafting"),
Message::ToggleIsometricDrafting,
),
planes,
text(crate::t!("F5 cycles Left, Top, and Right.")).size(11),
Space::new().height(6),
row![
text(crate::t!("Rotation: %{angle}°", angle = snap_angle_deg)).size(11),
button(text(crate::t!("Reset rotation")).size(10))
.on_press(Message::ResetDraftingRotation)
.style(button::secondary)
.padding([4, 10]),
]
.spacing(10)
.align_y(iced::Center),
]
.spacing(7);
let mut snap_modes = column![
text(crate::t!("Object snap modes")).size(15),
Space::new().height(8),
toggle(
snapper.snap_enabled,
crate::t!("Enable object snap"),
Message::ToggleSnapEnabled,
),
row![
button(text(crate::t!("Select All")).size(10))
.on_press(Message::SnapSelectAll)
.style(button::secondary)
.padding([4, 10]),
button(text(crate::t!("Clear All")).size(10))
.on_press(Message::SnapClearAll)
.style(button::secondary)
.padding([4, 10]),
]
.spacing(6),
]
.spacing(5);
for &(snap_type, _, label) in ALL_SNAP_MODES {
snap_modes = snap_modes.push(
row![
checkbox(snapper.is_on(snap_type))
.on_toggle(move |_| Message::ToggleSnap(snap_type))
.size(14),
text(crate::t!(label)).size(11),
]
.spacing(7)
.align_y(iced::Center),
);
}
let close = button(text(crate::tr!("action-close")).size(12))
.on_press(Message::CloseModal)
.padding([6, 18])
.style(button::secondary);
let content = column![
drafting_modes,
Space::new().height(20),
isometric_controls,
Space::new().height(20),
snap_modes,
]
.width(sizing.width);
let body = column![
scrollable(content).spacing(8).height(sizing.height),
Space::new().height(12),
row![Space::new().width(Fill), close],
]
.width(sizing.width)
.height(sizing.height);
container(body)
.style(container::rounded_box)
.padding([16, 18])
.width(sizing.width)
.height(sizing.height)
.into()
}

View file

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

View file

@ -1,11 +1,20 @@
use crate::app::config::UiThemeConfig;
use crate::app::settings::CursorType;
use crate::app::Message;
use iced::widget::{
button, column, container, row, scrollable, text, text_input, Space,
button, column, container, row, scrollable, slider, text, text_input, Space,
};
use iced::{Background, Border, Element, Theme};
use iced::{Background, Border, Element, Fill, Theme};
use std::fmt;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OptionsTab {
#[default]
General,
Display,
Selection,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct Labelled<T> {
value: T,
@ -24,6 +33,12 @@ pub fn view_window<'a>(
ui_theme: &'a UiThemeConfig,
theme_color_inputs: &'a [String; 6],
language: crate::i18n::Language,
active_tab: OptionsTab,
cursor_size: i32,
pick_box: i32,
cursor_type: CursorType,
crosshair_color: Option<[u8; 3]>,
crosshair_color_input: &'a str,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let selected_format = crate::io::SAVE_FORMAT_OPTIONS
@ -62,6 +77,18 @@ pub fn view_window<'a>(
.find(|choice| choice.value == language)
.cloned();
let cursor_options = CursorType::ALL
.into_iter()
.map(|value: CursorType| Labelled {
label: crate::t!(value.label()).into_owned(),
value,
})
.collect::<Vec<_>>();
let selected_cursor = cursor_options
.iter()
.find(|choice| choice.value == cursor_type)
.cloned();
let palette = ui_theme.palette.to_iced();
let colors = [
(crate::tr!("options-color-background"), palette.background),
@ -104,7 +131,7 @@ pub fn view_window<'a>(
.padding([6, 18])
.style(button::secondary);
let content = column![
let general = column![
text(crate::tr!("options-language-section")).size(15),
Space::new().height(10),
row![
@ -154,7 +181,29 @@ pub fn view_window<'a>(
))
.size(11)
.width(sizing.width),
Space::new().height(22),
]
.spacing(0)
.width(sizing.width);
let crosshair_rgb = crosshair_color.unwrap_or([255, 255, 255]);
let crosshair_swatch = container(Space::new())
.width(28)
.height(22)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(iced::Color::from_rgb8(
crosshair_rgb[0],
crosshair_rgb[1],
crosshair_rgb[2],
))),
border: Border {
color: theme.palette().background.strong.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
let display = column![
text(crate::tr!("options-theme-section")).size(15),
Space::new().height(10),
row![
@ -175,11 +224,96 @@ pub fn view_window<'a>(
.width(sizing.width),
Space::new().height(12),
color_controls,
Space::new().height(24),
text(crate::t!("Crosshair")).size(15),
Space::new().height(10),
row![
text(crate::t!("Crosshair size")).size(12).width(140),
slider(1..=100, cursor_size.clamp(1, 100), Message::CursorSizeChanged)
.step(1)
.width(Fill),
text(format!("{}%", cursor_size.clamp(1, 100)))
.size(11)
.width(44),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(10),
row![
text(crate::t!("Cursor type")).size(12).width(140),
iced::widget::pick_list(
selected_cursor,
cursor_options,
|choice| choice.label.clone(),
)
.on_select(|choice| Message::CursorTypeChanged(choice.value))
.width(Fill),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(10),
row![
text(crate::t!("Crosshair color")).size(12).width(140),
crosshair_swatch,
text_input(crate::t!("#RRGGBB or blank").as_ref(), crosshair_color_input)
.on_input(Message::CrosshairColorChanged)
.width(150),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(6),
text(crate::t!("Leave the color blank to keep automatic viewport contrast."))
.size(11)
.width(sizing.width),
]
.spacing(0)
.width(sizing.width);
let selection = column![
text(crate::t!("Selection")).size(15),
Space::new().height(10),
row![
text(crate::t!("Pick box size")).size(12).width(140),
slider(0..=50, pick_box.clamp(0, 50), Message::PickBoxChanged)
.step(1)
.width(Fill),
text(pick_box.clamp(0, 50).to_string()).size(11).width(44),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(8),
text(crate::t!(
"Controls both the visible selection box and the click aperture."
))
.size(11)
.width(sizing.width),
]
.spacing(0)
.width(sizing.width);
let content: Element<'a, Message> = match active_tab {
OptionsTab::General => general.into(),
OptionsTab::Display => display.into(),
OptionsTab::Selection => selection.into(),
};
let tab_button = |label, tab| {
let selected = active_tab == tab;
button(text(label).size(12))
.on_press(Message::OptionsTabChanged(tab))
.padding([6, 14])
.style(if selected { button::primary } else { button::secondary })
};
let tabs = row![
tab_button(crate::t!("General"), OptionsTab::General),
tab_button(crate::t!("Display"), OptionsTab::Display),
tab_button(crate::t!("Selection"), OptionsTab::Selection),
]
.spacing(6);
let body = column![
tabs,
Space::new().height(12),
// Keep the scrollbar in its own lane instead of floating over the
// controls at the trailing edge of the Options content.
scrollable(content).spacing(8).height(sizing.height),