Merge upstream/main into fix/704-active-intersection-snap
This commit is contained in:
commit
17b4eafa3b
71 changed files with 4886 additions and 2861 deletions
93
Cargo.lock
generated
93
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ QS, *QSELECT
|
|||
FI, *FILTER
|
||||
|
||||
; ── View / display ────────────────────────────────────────────────────────
|
||||
Z, *ZOOM
|
||||
P, *PAN
|
||||
U, *UNDO
|
||||
PR, *PROPERTIES
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
182
locales/ar-SA/opencadstudio.ftl
Normal file
182
locales/ar-SA/opencadstudio.ftl
Normal 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 }]:
|
||||
|
||||
# كتالوج التطبيق الكامل
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
594
locales/es-ES/opencadstudio.ftl
Normal file
594
locales/es-ES/opencadstudio.ftl
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = ब्राउज़र में पथ द्वारा ड्रॉइंग खोलना उपलब्ध नहीं है।
|
||||
|
|
|
|||
160
locales/ja-JP/opencadstudio.ftl
Normal file
160
locales/ja-JP/opencadstudio.ftl
Normal 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 }]:
|
||||
|
||||
# 完全なアプリケーションカタログ
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
170
locales/pt-BR/opencadstudio.ftl
Normal file
170
locales/pt-BR/opencadstudio.ftl
Normal 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
|
||||
|
|
@ -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 = Открытие чертежа по пути недоступно в браузере.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = 浏览器不支持通过路径打开图形。
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`):
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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![],
|
||||
|
|
|
|||
|
|
@ -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
751
src/entities/curve.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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![],
|
||||
|
|
|
|||
28
src/i18n.rs
28
src/i18n.rs
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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(¤t) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
|
|
|||
37
src/snap.rs
37
src/snap.rs
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
135
src/ui/window/drafting_settings.rs
Normal file
135
src/ui/window/drafting_settings.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue