diff --git a/Cargo.lock b/Cargo.lock index bbc4f1b5a..bdc515374 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index f6372fba8..93dc999b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/assets/ocad.pgp b/assets/ocad.pgp index 2a2dd8479..c2be1de94 100644 --- a/assets/ocad.pgp +++ b/assets/ocad.pgp @@ -120,6 +120,7 @@ QS, *QSELECT FI, *FILTER ; ── View / display ──────────────────────────────────────────────────────── +Z, *ZOOM P, *PAN U, *UNDO PR, *PROPERTIES diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index bcb564cfd..e20d48c29 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -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 } diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index a693f1ca3..03dda7e9f 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { 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"] } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl new file mode 100644 index 000000000..afde60bd8 --- /dev/null +++ b/locales/ar-SA/opencadstudio.ftl @@ -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 }]: + +# كتالوج التطبيق الكامل diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 793460551..fdf6e4ae4 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -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] : +area-prompt-add = AREA ADDIEREN Ersten Eckpunkt angeben oder [Objekte/Fläche subtrahieren] : +area-prompt-subtract = AREA SUBTRAHIEREN Ersten Eckpunkt angeben oder [Objekte/Fläche addieren] : +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. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 8763f703a..503a9d482 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -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 diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl new file mode 100644 index 000000000..8a4cadae3 --- /dev/null +++ b/locales/es-ES/opencadstudio.ftl @@ -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] : +area-prompt-add = AREA AÑADIR Especifique el primer punto de esquina o [Objetos/Restar área] : +area-prompt-subtract = AREA RESTAR Especifique el primer punto de esquina o [Objetos/Añadir área] : +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 ) +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 : +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 = +catalog-previous-40bf65fa86 = +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 : __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. diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index d676f9796..d4e2eb826 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -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] : +area-prompt-add = AREA AJOUTER Indiquez le premier sommet ou [Objets/Soustraire une aire] : +area-prompt-subtract = AREA SOUSTRAIRE Indiquez le premier sommet ou [Objets/Ajouter une aire] : +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] : +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. diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index fc039232b..33b34dc2a 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -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 = ब्राउज़र में पथ द्वारा ड्रॉइंग खोलना उपलब्ध नहीं है। diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl new file mode 100644 index 000000000..221ec8bfa --- /dev/null +++ b/locales/ja-JP/opencadstudio.ftl @@ -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 }]: + +# 完全なアプリケーションカタログ diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index d3bd24189..8f1ac879b 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -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] : +area-prompt-add = AREA OPTELLEN Geef het eerste hoekpunt op of [Objecten/Oppervlakte aftrekken] : +area-prompt-subtract = AREA AFTREKKEN Geef het eerste hoekpunt op of [Objecten/Oppervlakte optellen] : +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] : +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. diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl new file mode 100644 index 000000000..84ed69b9b --- /dev/null +++ b/locales/pt-BR/opencadstudio.ftl @@ -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] : +area-prompt-add = AREA ADICIONAR Especifique o primeiro canto ou [Objetos/Subtrair área] : +area-prompt-subtract = AREA SUBTRAIR Especifique o primeiro canto ou [Objetos/Adicionar área] : +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 diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 5de474276..6da58df8f 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -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 = Открытие чертежа по пути недоступно в браузере. diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 2c37fa600..76fba87ec 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -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. diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 92be54201..3320b8c94 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -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 = 浏览器不支持通过路径打开图形。 diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index fcfaaf8ca..206ff8f09 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -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()); diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index e8665b41f..1556d988d 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -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::() { 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()); diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 9303281a4..f6f23ef93 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -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)); diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 094cea430..6843a5cda 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -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", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 721c1e227..e18432c25 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -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::() { + 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::() { + 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::() { + 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::() { + 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 { - self.tabs[i].dirty = true; + 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`): diff --git a/src/app/config.rs b/src/app/config.rs index 7e5791800..2e4d5e796 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -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; diff --git a/src/app/document.rs b/src/app/document.rs index abc2ea656..702d20296 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -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>, @@ -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, } diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 0c010b5f9..821d25262 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -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 { 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()) diff --git a/src/app/mod.rs b/src/app/mod.rs index f7ae64bf6..e582987e1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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), /// 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, diff --git a/src/app/settings.rs b/src/app/settings.rs index 13906d3d4..4e4ad329c 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -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(), diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs index a5c9e8861..6c888fa40 100644 --- a/src/app/shortcuts.rs +++ b/src/app/shortcuts.rs @@ -17,6 +17,7 @@ pub(super) fn default_bindings() -> BTreeMap { ("F1".to_string(), "HELP"), ("F2".to_string(), "COMMANDHISTORY"), ("F3".to_string(), "TOGGLEOSNAP"), + ("F5".to_string(), "ISOPLANE"), ("F7".to_string(), "GRID"), ("F8".to_string(), "ORTHO"), ("F9".to_string(), "SNAP"), diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 390d48a06..f735c6d82 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -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 { // 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()); diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 4e82f7f58..c72f0f7d8 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -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(); } diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 1d88daabd..72a44921b 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -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}; diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 30eb93c0f..fa58d2fcf 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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 diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 969866370..62e382f2e 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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(); diff --git a/src/app/update/style.rs b/src/app/update/style.rs index bff4cd52d..0274b140e 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -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}; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index e57df995b..228d25b4e 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -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 { 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 { 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; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 363cfb635..d104c0027 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -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 = 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(), diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 2b1c1ffb9..409c23ebc 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -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, ) }, diff --git a/src/command.rs b/src/command.rs index 0c9233f4e..dca1fc0b3 100644 --- a/src/command.rs +++ b/src/command.rs @@ -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); diff --git a/src/entities/arc.rs b/src/entities/arc.rs index 2e08abcef..f55aa8e46 100644 --- a/src/entities/arc.rs +++ b/src/entities/arc.rs @@ -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![], diff --git a/src/entities/circle.rs b/src/entities/circle.rs index 3c1a93f70..cfc65bcb1 100644 --- a/src/entities/circle.rs +++ b/src/entities/circle.rs @@ -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), diff --git a/src/entities/curve.rs b/src/entities/curve.rs new file mode 100644 index 000000000..7ce9d8490 --- /dev/null +++ b/src/entities/curve.rs @@ -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 { + 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 { + 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 { + 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 { + let vertices: Vec = 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 { + let vertices: Vec = 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 { + let points: Vec = 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 { + 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 { + 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 { + 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]); + } +} diff --git a/src/entities/ellipse.rs b/src/entities/ellipse.rs index cde496da1..8363d0da8 100644 --- a/src/entities/ellipse.rs +++ b/src/entities/ellipse.rs @@ -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 = (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 = (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, - ) - }; - 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), - ]; - TruckEntity { - pick_tris: Vec::new(), - object: TruckObject::Contour(wire), - snap_pts, - tangent_geoms: vec![], - key_vertices: vec![], - fill_tris: vec![], - } - } else { - let n = 32usize; - let ctrl_pts: Vec = (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![], - } + // 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 Some(planar) = curve else { + return empty(snap); + }; + let points: Vec = 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::()) + } 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, + snap_pts: snap.snap_pts, + tangent_geoms: vec![], + key_vertices: vec![], + 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) -> Curve { + let spans = points.len().saturating_sub(1); + Curve::BSplineCurve(BSplineCurve::new(KnotVec::uniform_knot(1, spans), points)) } fn grips(ell: &Ellipse) -> Vec { diff --git a/src/entities/hatch.rs b/src/entities/hatch.rs index 2e9223ef7..022fc3f9c 100644 --- a/src/entities/hatch.rs +++ b/src/entities/hatch.rs @@ -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::>() - } else { - s.control_points - .iter() - .map(|p| [p.x, p.y]) - .collect::>() - }; - ring.extend(src); - } - } + 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 n = ring.len(); - if n < 3 { - continue; + // 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]); } - 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]; - } - 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 { + 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 = 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::>()); + 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. diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 69e81b14e..8d1d02182 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -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; diff --git a/src/entities/solid3d.rs b/src/entities/solid3d.rs index 4bb6c9216..e5abbafec 100644 --- a/src/entities/solid3d.rs +++ b/src/entities/solid3d.rs @@ -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) } diff --git a/src/entities/spline.rs b/src/entities/spline.rs index e644bc91f..a72824162 100644 --- a/src/entities/spline.rs +++ b/src/entities/spline.rs @@ -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 { - catmull_rom_polyline(&spl.fit_points, true) + // 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) + } + 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 { - fit_spline_polyline(spl) + 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 { - &spl.control_points + // 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::>(), + ) + } }; - 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![], diff --git a/src/i18n.rs b/src/i18n.rs index a501ba97b..4281c5146 100644 --- a/src/i18n.rs +++ b/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 { @@ -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(()) diff --git a/src/modules/draw/inquiry/area.rs b/src/modules/draw/inquiry/area.rs index 2c961c096..78ee1dc0a 100644 --- a/src/modules/draw/inquiry/area.rs +++ b/src/modules/draw/inquiry/area.rs @@ -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::(); - 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 { + 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 { + // 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::>(); - let bulges = polyline.vertices.iter().map(|vertex| vertex.bulge).collect::>(); - 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::>(); - let bulges = polyline.vertices.iter().map(|vertex| vertex.bulge).collect::>(); - 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 diff --git a/src/modules/draw/inquiry/divide.rs b/src/modules/draw/inquiry/divide.rs index 5c9fabe90..6ce799c80 100644 --- a/src/modules/draw/inquiry/divide.rs +++ b/src/modules/draw/inquiry/divide.rs @@ -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 { 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 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 { - 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)) } diff --git a/src/modules/draw/modify/array.rs b/src/modules/draw/modify/array.rs index 4960a2d12..d0d7a83b6 100644 --- a/src/modules/draw/modify/array.rs +++ b/src/modules/draw/modify/array.rs @@ -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 { - 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 = 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 { - 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,66 +481,38 @@ 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 { 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() - } - 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; - (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, - ) - }) - .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], + 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]; } + // 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 p = curve.point_at_distance(total * (i as f64 / steps)); + DVec3::new(p[0], p[1], p[2]) + }) + .collect() } /// Continuous path-tangent angle (XY) at each sample, via central diff --git a/src/modules/draw/modify/blend.rs b/src/modules/draw/modify/blend.rs index 5636e32ed..faadd4a3c 100644 --- a/src/modules/draw/modify/blend.rs +++ b/src/modules/draw/modify/blend.rs @@ -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 { diff --git a/src/modules/draw/modify/fillet.rs b/src/modules/draw/modify/fillet.rs index 67509ea41..0d1466c41 100644 --- a/src/modules/draw/modify/fillet.rs +++ b/src/modules/draw/modify/fillet.rs @@ -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 { + 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); - 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 { - 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); - 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(( - EntityType::Line(new_line), - EntityType::Arc(new_arc), - EntityType::Arc(fillet_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; } + let Some(new_line) = trim_line_to_point(line, fillet.tangent1, click_line) else { + continue; + }; + // 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) + }; + return Some(( + EntityType::Line(new_line), + EntityType::Arc(new_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,87 +875,39 @@ 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 { + // 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; } - 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 { - 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 { - trim_arc(a1, s1, tc1) - }; - let new_a2 = if (tc2 - s2).rem_euclid(TAU) <= (ca2 - s2).rem_euclid(TAU) { - trim_arc(a2, tc2, e2) - } 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(( - EntityType::Arc(new_a1), - EntityType::Arc(new_a2), - EntityType::Arc(fillet_arc), - )); - } - } + 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 { + trim_arc(a1, s1, tc1) + }; + let new_a2 = if (tc2 - s2).rem_euclid(TAU) <= (ca2 - s2).rem_euclid(TAU) { + trim_arc(a2, tc2, e2) + } else { + trim_arc(a2, s2, tc2) + }; + return Some(( + EntityType::Arc(new_a1), + EntityType::Arc(new_a2), + 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. diff --git a/src/modules/draw/modify/geom.rs b/src/modules/draw/modify/geom.rs index ca8eacb87..64569cc05 100644 --- a/src/modules/draw/modify/geom.rs +++ b/src/modules/draw/modify/geom.rs @@ -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 { - 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. diff --git a/src/modules/draw/modify/lengthen.rs b/src/modules/draw/modify/lengthen.rs index 3044a2dd2..a8ece8057 100644 --- a/src/modules/draw/modify/lengthen.rs +++ b/src/modules/draw/modify/lengthen.rs @@ -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 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 { match mode { LenMode::Delta(d) => Some(current + d), @@ -337,9 +335,9 @@ fn apply_mode(current: f64, mode: &LenMode) -> Option { } 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 Option { - 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) - } else { - (t_end_param, t1) - }; - 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) + // 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 { - (t1 - delta_ratio * (t1 - t0) * 2.0, t1) + arc_len - new_len }; - // 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 diff --git a/src/modules/draw/modify/offset.rs b/src/modules/draw/modify/offset.rs index 3438fd9c2..232eb6565 100644 --- a/src/modules/draw/modify/offset.rs +++ b/src/modules/draw/modify/offset.rs @@ -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. - 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], + // 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 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, ] - } - _ => vec![], + }; + return vec![at(back), at(HL)]; } -} - -/// 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::>() } + // ── Command implementation ───────────────────────────────────────────────── enum Step { diff --git a/src/modules/draw/modify/pedit.rs b/src/modules/draw/modify/pedit.rs index dec9c4b73..b5c2703a5 100644 --- a/src/modules/draw/modify/pedit.rs +++ b/src/modules/draw/modify/pedit.rs @@ -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 = 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 = 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 = (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 diff --git a/src/modules/draw/modify/spline_ops.rs b/src/modules/draw/modify/spline_ops.rs index e13ef55e9..184a0fccc 100644 --- a/src/modules/draw/modify/spline_ops.rs +++ b/src/modules/draw/modify/spline_ops.rs @@ -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 { + 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 { + 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 { 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 { } // 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, diff --git a/src/modules/draw/modify/splinedit.rs b/src/modules/draw/modify/splinedit.rs index 75f1173ce..6a5852f73 100644 --- a/src/modules/draw/modify/splinedit.rs +++ b/src/modules/draw/modify/splinedit.rs @@ -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(), + } } _ => {} } diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index d43214ae6..ccf16c37f 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -15,7 +15,7 @@ use std::f64::consts::TAU; use super::geom; use super::geom::{ angle_within_arc as in_arc, arc_parameter as arc_t, arc_points as arc_pts, - circle_circle_angles as cc_angles, ellipse_points as ellipse_pts, lerp as lerp2, + ellipse_points as ellipse_pts, lerp as lerp2, line_circle as lc, line_ellipse as le, line_line as ll, normalize_angle as norm, }; @@ -29,11 +29,18 @@ use acadrust::types::Vector3; use acadrust::{EntityType, Handle}; use glam::DVec3; use acadrust::kernel::geom2d::nurbs::clamped_uniform_knots; +use acadrust::kernel::geom2d::{ + intersect as kernel_intersect, Arc as KernelArc, Circle as KernelCircle, Curve, + Extent as KernelExtent, + Ellipse as KernelEllipse, EllipseArc as KernelEllipseArc, Line as KernelLine, + NurbsCurve, Ray as KernelRay, Tolerance as KernelTolerance, XLine as KernelXLine, +}; + +use crate::entities::curve::entity_curve_xy; use crate::command::{CadCommand, CmdResult}; use crate::modules::draw::modify::spline_ops::{ - spline_cut, spline_nearest_t, spline_pts_wire, spline_range, spline_sample_xy, - spline_to_nurbs, t_to_rel, + spline_cut, spline_nearest_t, spline_pts_wire, spline_range, spline_to_nurbs, t_to_rel, }; use crate::modules::IconKind; use crate::scene::model::wire_model::WireModel; @@ -67,8 +74,12 @@ pub const DROPDOWN_ITEMS: &[(&str, &str, IconKind)] = &[ /// Virtual extent used to represent infinite ends of Ray / XLine. const TRIM_EXTENT: f64 = 1_000_000.0; + +/// Sampling density for the plan-view point lists the fence and preview +/// passes walk. The renderer's own figure, so a preview cut lands where the +/// drawn geometry is rather than a chord away from it. +const SAMPLE_SEGMENTS_PER_RADIAN: f64 = acadrust::kernel::geom2d::DEFAULT_SEGMENTS_PER_RADIAN; /// If a trim interval endpoint is beyond this threshold it is treated as "infinite". -const INF_T: f64 = 0.9999; #[derive(Clone)] enum Geo { @@ -119,9 +130,14 @@ enum Geo { t0: f64, // start parameter t1: f64, // end parameter (may be > 2π if wrapped) }, - /// Spline represented as sampled polyline segments (DXF XY). + /// A NURBS boundary, carried exactly. + /// + /// `segs` is a sampling of the same curve, kept only for the two + /// hand-rolled ray walks in the polyline-extend paths. Every crossing + /// that decides a trim uses `curve`. Spline { handle: Handle, + curve: Box, segs: Vec<([f64; 2], [f64; 2])>, }, } @@ -173,211 +189,210 @@ fn build_geos(entities: &[EntityType]) -> Vec { /// Ellipse / Spline) into a `Geo`, tagged with `h`. Returns `None` for types /// that do not act as trim boundaries. fn geo_from_entity(h: Handle, e: &EntityType) -> Option { - match e { - EntityType::Line(l) => Some(Geo::Line { - handle: h, - p1: [l.start.x, l.start.y], - p2: [l.end.x, l.end.y], - }), - EntityType::Arc(a) => Some(Geo::Arc { - handle: h, - cx: a.center.x, - cy: a.center.y, - r: a.radius, - a0: a.start_angle, - a1: a.end_angle, - }), - EntityType::Circle(c) => Some(Geo::Circle { - handle: h, - cx: c.center.x, - cy: c.center.y, - r: c.radius, - }), - EntityType::Ray(r) => Some(Geo::Ray { - handle: h, - bx: r.base_point.x, - by: r.base_point.y, - dx: r.direction.x, - dy: r.direction.y, - }), - EntityType::XLine(x) => Some(Geo::InfLine { - handle: h, - bx: x.base_point.x, - by: x.base_point.y, - dx: x.direction.x, - dy: x.direction.y, - }), - EntityType::Ellipse(e) => { - let mx = e.major_axis.x; - let my = e.major_axis.y; - let a = (mx * mx + my * my).sqrt(); - if a < 1e-9 { - return None; - } - let (nx, ny) = (mx / a, my / a); - let b = a * e.minor_axis_ratio; - let t0 = e.start_parameter; - let mut t1 = e.end_parameter; - if t1 <= t0 { - t1 += TAU; - } - Some(Geo::Ellipse { - handle: h, - cx: e.center.x, - cy: e.center.y, - a, - b, - nx, - ny, - t0, - t1, - }) - } - EntityType::Spline(s) => { - let (_, pts) = spline_sample_xy(s, 64); - if pts.len() < 2 { - return None; - } - let segs = pts - .windows(2) - .map(|w| ([w[0][0], w[0][1]], [w[1][0], w[1][1]])) - .collect(); - Some(Geo::Spline { handle: h, segs }) - } - _ => None, + // The geometry itself comes from the one converter, in plan-view + // coordinates. What is left here is only the shape of the boundary + // record, which the edit options need to mutate — `imply_edge_geos` turns + // a segment into an infinite line and opens an arc into a full circle, + // neither of which a bare curve says. + match entity_curve_xy(e)? { + Curve::Line(line) => Some(Geo::Line { + handle: h, + p1: line.start, + p2: line.end, + }), + Curve::Arc(arc) => Some(Geo::Arc { + handle: h, + cx: arc.centre[0], + cy: arc.centre[1], + r: arc.radius, + a0: arc.start_angle, + a1: arc.end_angle, + }), + Curve::Circle(circle) => Some(Geo::Circle { + handle: h, + cx: circle.centre[0], + cy: circle.centre[1], + r: circle.radius, + }), + Curve::Ray(ray) => Some(Geo::Ray { + handle: h, + bx: ray.origin[0], + by: ray.origin[1], + dx: ray.direction[0], + dy: ray.direction[1], + }), + Curve::XLine(line) => Some(Geo::InfLine { + handle: h, + bx: line.base[0], + by: line.base[1], + dx: line.direction[0], + dy: line.direction[1], + }), + Curve::Ellipse(arc) => { + let ellipse = arc.ellipse; + if ellipse.major_radius < 1e-9 { + return None; + } + let t0 = arc.start_parameter; + let mut t1 = arc.end_parameter; + if t1 <= t0 { + t1 += TAU; + } + Some(Geo::Ellipse { + handle: h, + cx: ellipse.centre[0], + cy: ellipse.centre[1], + a: ellipse.major_radius, + b: ellipse.minor_radius, + nx: ellipse.major_axis[0], + ny: ellipse.major_axis[1], + t0, + t1, + }) + } + Curve::Nurbs(curve) => { + // Carried exactly. The sampled polyline beside it is only for the + // two hand-rolled ray walks in the polyline-extend paths; every + // crossing that decides a trim goes through the curve itself. + let sampled = curve.tessellate(16); + (sampled.len() >= 2).then(|| Geo::Spline { + handle: h, + curve: Box::new(curve), + segs: sampled.windows(2).map(|w| (w[0], w[1])).collect(), + }) + } + // A polyline reaches here already taken apart into its segments. + Curve::Polyline(_) => None, } } // ── Intersection helpers ────────────────────────────────────────────────── -/// Sorted, deduped t-params ∈ [0,1] where LINE segment (ax,ay)→(bx,by) intersects boundaries. -fn line_seg_ts(ax: f64, ay: f64, bx: f64, by: f64, target: Handle, geos: &[Geo]) -> Vec { - let (dx, dy) = (bx - ax, by - ay); - let mut ts = vec![]; - for geo in geos { - match geo { - Geo::Line { handle, p1, p2 } => { - if *handle == target { - continue; - } - let (ex, ey) = (p2[0] - p1[0], p2[1] - p1[1]); - if let Some((t, u)) = ll(ax, ay, dx, dy, p1[0], p1[1], ex, ey) { - if (-1e-9..=1.0 + 1e-9).contains(&u) && (-1e-9..=1.0 + 1e-9).contains(&t) { - ts.push(t.clamp(0.0, 1.0)); - } - } - } - Geo::Arc { - handle, - cx, - cy, - r, - a0, - a1, - } => { - if *handle == target { - continue; - } - for t in lc(ax, ay, dx, dy, *cx, *cy, *r) { - if !(-1e-9..=1.0 + 1e-9).contains(&t) { - continue; - } - let ix = ax + t * dx; - let iy = ay + t * dy; - if in_arc((iy - cy).atan2(ix - cx), *a0, *a1) { - ts.push(t.clamp(0.0, 1.0)); - } - } - } - Geo::Circle { handle, cx, cy, r } => { - if *handle == target { - continue; - } - for t in lc(ax, ay, dx, dy, *cx, *cy, *r) { - if (-1e-9..=1.0 + 1e-9).contains(&t) { - ts.push(t.clamp(0.0, 1.0)); - } - } - } - Geo::Ray { - handle, - bx: rbx, - by: rby, - dx: rdx, - dy: rdy, - } => { - if *handle == target { - continue; - } - if let Some((t, u)) = ll(ax, ay, dx, dy, *rbx, *rby, *rdx, *rdy) { - // Ray: u >= 0 (semi-infinite) - if u >= -1e-9 && (-1e-9..=1.0 + 1e-9).contains(&t) { - ts.push(t.clamp(0.0, 1.0)); - } - } - } - Geo::InfLine { - handle, - bx: ibx, - by: iby, - dx: idx, - dy: idy, - } => { - if *handle == target { - continue; - } - if let Some((t, _u)) = ll(ax, ay, dx, dy, *ibx, *iby, *idx, *idy) { - // XLine: any u accepted - if (-1e-9..=1.0 + 1e-9).contains(&t) { - ts.push(t.clamp(0.0, 1.0)); - } - } - } - Geo::Ellipse { - handle, - cx, - cy, - a, - b, - nx, - ny, - t0, - t1, - } => { - if *handle == target { - continue; - } - for (s, t_ell) in le(ax, ay, dx, dy, *cx, *cy, *a, *b, *nx, *ny) { - if !(-1e-9..=1.0 + 1e-9).contains(&s) { - continue; - } - if in_arc(t_ell, *t0, *t1) { - ts.push(s.clamp(0.0, 1.0)); - } - } - } - Geo::Spline { handle, segs } => { - if *handle == target { - continue; - } - for (p1, p2) in segs { - let ex = p2[0] - p1[0]; - let ey = p2[1] - p1[1]; - if let Some((t, u)) = ll(ax, ay, dx, dy, p1[0], p1[1], ex, ey) { - if (-1e-9..=1.0 + 1e-9).contains(&u) && (-1e-9..=1.0 + 1e-9).contains(&t) { - ts.push(t.clamp(0.0, 1.0)); - } - } - } - } - } +// ── Boundary crossings ──────────────────────────────────────────────────── +// +// Every cut a trim or extend makes comes from one question: where does the +// entity being edited meet the boundaries picked as cutting edges. The kernel +// answers it for any pair of curves, so these four just describe the target as +// a `Curve` and hand it over. + +/// The entity a boundary came from, so a curve is never cut by itself. +fn geo_handle(geo: &Geo) -> Handle { + match geo { + Geo::Line { handle, .. } + | Geo::Arc { handle, .. } + | Geo::Circle { handle, .. } + | Geo::Ray { handle, .. } + | Geo::InfLine { handle, .. } + | Geo::Ellipse { handle, .. } + | Geo::Spline { handle, .. } => *handle, } - ts.sort_by(|a, b| a.partial_cmp(b).unwrap()); +} + +/// A boundary as a kernel curve. +fn geo_to_curve(geo: &Geo) -> Option { + Some(match geo { + Geo::Line { p1, p2, .. } => Curve::Line(KernelLine { + start: *p1, + end: *p2, + }), + Geo::Arc { cx, cy, r, a0, a1, .. } => Curve::Arc(KernelArc { + centre: [*cx, *cy], + radius: *r, + start_angle: *a0, + end_angle: *a1, + }), + Geo::Circle { cx, cy, r, .. } => Curve::Circle(KernelCircle { + centre: [*cx, *cy], + radius: *r, + }), + Geo::Ray { bx, by, dx, dy, .. } => Curve::Ray(KernelRay { + origin: [*bx, *by], + direction: [*dx, *dy], + }), + Geo::InfLine { bx, by, dx, dy, .. } => Curve::XLine(KernelXLine { + base: [*bx, *by], + direction: [*dx, *dy], + }), + Geo::Ellipse { + cx, cy, a, b, nx, ny, t0, t1, .. + } => Curve::Ellipse(KernelEllipseArc { + ellipse: KernelEllipse { + centre: [*cx, *cy], + major_radius: *a, + minor_radius: *b, + major_axis: [*nx, *ny], + }, + start_parameter: *t0, + end_parameter: *t1, + }), + // Exactly, not through its sampling. The kernel intersects a NURBS by + // subdivision against a bounding box, so a boundary spline cuts where + // it actually runs rather than where a 64-chord approximation of it + // does. + Geo::Spline { curve, .. } => Curve::Nurbs((**curve).clone()), + }) +} + +/// Where `target` is cut by every boundary except its own entity, as +/// parameters along it in `0..=1`. +fn cut_params(target: &Curve, handle: Handle, geos: &[Geo]) -> Vec { + let tolerance = KernelTolerance::new(CUT_TOLERANCE); + // A bounded target's parameters are pinned to its own span, as every + // caller here expects. An unbounded one — a ray shot out to find the + // nearest boundary — has to keep whatever it comes back with, since the + // whole point is how far away the crossing is. + let bounded = matches!(target.extent(), KernelExtent::Bounded); + let mut ts: Vec = geos + .iter() + .filter(|geo| geo_handle(geo) != handle) + .filter_map(geo_to_curve) + .flat_map(|boundary| { + kernel_intersect(target, &boundary, tolerance) + .into_iter() + .map(|hit| if bounded { hit.t_a.clamp(0.0, 1.0) } else { hit.t_a }) + .collect::>() + }) + .collect(); + ts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); ts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); ts } -/// Sorted, deduped t-params ∈ [0,1] where ARC (cx,cy,r,a0→a1) intersects boundaries. +/// A Ray entity as the kernel curve it is, so its parameter is the ray's own +/// rather than a fraction of some invented length. +fn ray_curve(r: &RayEnt) -> Curve { + Curve::Ray(KernelRay { + origin: [r.base_point.x, r.base_point.y], + direction: [r.direction.x, r.direction.y], + }) +} + +/// An XLine entity as the kernel curve it is. +fn xline_curve(x: &XLineEnt) -> Curve { + Curve::XLine(KernelXLine { + base: [x.base_point.x, x.base_point.y], + direction: [x.direction.x, x.direction.y], + }) +} + +/// How close two points have to be to count as a crossing. +/// +/// Loose enough that a boundary drawn to meet an entity still registers when +/// its endpoint is a rounding step away, which is the usual case in a drawing +/// that was snapped together rather than computed. +const CUT_TOLERANCE: f64 = 1e-7; + +fn line_seg_ts(ax: f64, ay: f64, bx: f64, by: f64, target: Handle, geos: &[Geo]) -> Vec { + cut_params( + &Curve::Line(KernelLine { + start: [ax, ay], + end: [bx, by], + }), + target, + geos, + ) +} + fn arc_seg_ts( cx: f64, cy: f64, @@ -387,213 +402,19 @@ fn arc_seg_ts( target: Handle, geos: &[Geo], ) -> Vec { - let mut ts = vec![]; - for geo in geos { - let angles: Vec = match geo { - Geo::Line { handle, p1, p2 } => { - if *handle == target { - continue; - } - let (ldx, ldy) = (p2[0] - p1[0], p2[1] - p1[1]); - lc(p1[0], p1[1], ldx, ldy, cx, cy, r) - .into_iter() - .filter(|&u| (-1e-9..=1.0 + 1e-9).contains(&u)) - .map(|u| (p1[1] + u * ldy - cy).atan2(p1[0] + u * ldx - cx)) - .collect() - } - Geo::Arc { - handle, - cx: cx2, - cy: cy2, - r: r2, - a0: a02, - a1: a12, - } => { - if *handle == target { - continue; - } - cc_angles(cx, cy, r, *cx2, *cy2, *r2) - .into_iter() - .filter(|&a| { - // `a` lies on the TARGET circle — re-express the - // intersection point as an angle on the BOUNDARY - // circle before testing that arc's span (#370). - let px = cx + r * a.cos(); - let py = cy + r * a.sin(); - in_arc((py - cy2).atan2(px - cx2), *a02, *a12) - }) - .collect() - } - Geo::Circle { - handle, - cx: cx2, - cy: cy2, - r: r2, - } => { - if *handle == target { - continue; - } - cc_angles(cx, cy, r, *cx2, *cy2, *r2) - } - Geo::Ray { - handle, - bx: rbx, - by: rby, - dx: rdx, - dy: rdy, - } => { - if *handle == target { - continue; - } - // Intersect arc circle with the Ray direction - lc(*rbx, *rby, *rdx, *rdy, cx, cy, r) - .into_iter() - .filter(|&u| u >= -1e-9) // Ray: u >= 0 - .map(|u| (rby + u * rdy - cy).atan2(rbx + u * rdx - cx)) - .collect() - } - Geo::InfLine { - handle, - bx: ibx, - by: iby, - dx: idx, - dy: idy, - } => { - if *handle == target { - continue; - } - // XLine: any u accepted - lc(*ibx, *iby, *idx, *idy, cx, cy, r) - .into_iter() - .map(|u| (iby + u * idy - cy).atan2(ibx + u * idx - cx)) - .collect() - } - Geo::Ellipse { - handle, - cx: ecx, - cy: ecy, - a: ea, - b: eb, - nx, - ny, - t0: et0, - t1: et1, - } => { - if *handle == target { - continue; - } - // Sample the arc and find where it crosses the ellipse boundary. - ellipse_boundary_angles_for_arc( - cx, cy, r, a0, a1, *ecx, *ecy, *ea, *eb, *nx, *ny, *et0, *et1, - ) - } - Geo::Spline { handle, segs } => { - if *handle == target { - continue; - } - // Intersect arc circle with each spline segment. - let mut hit_angles = vec![]; - for (p1, p2) in segs { - let ldx = p2[0] - p1[0]; - let ldy = p2[1] - p1[1]; - for u in lc(p1[0], p1[1], ldx, ldy, cx, cy, r) { - if !(-1e-9..=1.0 + 1e-9).contains(&u) { - continue; - } - let ix = p1[0] + u * ldx; - let iy = p1[1] + u * ldy; - hit_angles.push((iy - cy).atan2(ix - cx)); - } - } - hit_angles - } - }; - for a in angles { - if in_arc(a, a0, a1) { - ts.push(arc_t(a, a0, a1)); - } - } - } - ts.sort_by(|a, b| a.partial_cmp(b).unwrap()); - ts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); - ts + cut_params( + &Curve::Arc(KernelArc { + centre: [cx, cy], + radius: r, + start_angle: a0, + end_angle: a1, + }), + target, + geos, + ) } -/// Find angles on a circular arc where it crosses an ellipse-arc boundary. -/// Uses 64-sample sign-change detection + bisection. -fn ellipse_boundary_angles_for_arc( - cx: f64, - cy: f64, - r: f64, - a0: f64, - a1: f64, - ecx: f64, - ecy: f64, - ea: f64, - eb: f64, - nx: f64, - ny: f64, - et0: f64, - et1: f64, -) -> Vec { - // f(α) = (x_local/ea)² + (y_local/eb)² – 1 where (x_local, y_local) is the arc - // point projected onto ellipse local axes. - let f = |alpha: f64| { - let px = cx + r * alpha.cos() - ecx; - let py = cy + r * alpha.sin() - ecy; - let xl = px * nx + py * ny; - let yl = -px * ny + py * nx; - (xl / ea).powi(2) + (yl / eb).powi(2) - 1.0 - }; - let span = { - let s = norm(a1) - norm(a0); - if s <= 0.0 { - s + TAU - } else { - s - } - }; - let n = 128usize; - let mut hits = vec![]; - let mut prev = f(norm(a0)); - for i in 1..=n { - let alpha = norm(a0) + span * (i as f64 / n as f64); - let cur = f(alpha); - if prev * cur <= 0.0 { - // Bisect - let alpha_lo = norm(a0) + span * ((i - 1) as f64 / n as f64); - let alpha_hi = alpha; - let mut lo = alpha_lo; - let mut hi = alpha_hi; - let mut flo = prev; - for _ in 0..32 { - let mid = (lo + hi) * 0.5; - let fm = f(mid); - if flo * fm <= 0.0 { - hi = mid; - } else { - lo = mid; - flo = fm; - } - } - let alpha_hit = (lo + hi) * 0.5; - // Check that the intersection point is on the ellipse ARC (not outside t0..t1) - let px = cx + r * alpha_hit.cos() - ecx; - let py = cy + r * alpha_hit.sin() - ecy; - let xl = px * nx + py * ny; - let yl = -px * ny + py * nx; - let t_ell = yl.atan2(xl); - if in_arc(t_ell, et0, et1) { - hits.push(alpha_hit); - } - } - prev = cur; - } - hits -} - -/// Sorted t-params ∈ [0,1] where an ELLIPSE arc intersects boundary geometries. -/// t is the normalised eccentric-anomaly parameter along [t0, t1]. +#[allow(clippy::too_many_arguments)] fn ellipse_seg_ts( cx: f64, cy: f64, @@ -606,235 +427,30 @@ fn ellipse_seg_ts( target: Handle, geos: &[Geo], ) -> Vec { - let span = t1 - t0; // always positive (build_geos ensures t1 > t0) - let ellipse_pt = |t: f64| -> [f64; 2] { - [ - cx + a * t.cos() * nx - b * t.sin() * ny, - cy + a * t.cos() * ny + b * t.sin() * nx, - ] - }; - // f_boundary(t) > 0 means "outside this boundary segment" - let mut ts = vec![]; + cut_params( + &Curve::Ellipse(KernelEllipseArc { + ellipse: KernelEllipse { + centre: [cx, cy], + major_radius: a, + minor_radius: b, + major_axis: [nx, ny], + }, + start_parameter: t0, + end_parameter: t1, + }), + target, + geos, + ) +} - for geo in geos { - match geo { - Geo::Line { handle, p1, p2 } => { - if *handle == target { - continue; - } - // Find t values where ellipse crosses the infinite line p1→p2, - // then filter to the finite segment [p1,p2]. - let ldx = p2[0] - p1[0]; - let ldy = p2[1] - p1[1]; - for (s, t_ell) in le(p1[0], p1[1], ldx, ldy, cx, cy, a, b, nx, ny) { - if !(-1e-9..=1.0 + 1e-9).contains(&s) { - continue; - } - if in_arc(t_ell, t0, t1) { - let t_norm = arc_t(t_ell, t0, t0 + span); - ts.push(t_norm); - } - } - } - Geo::Arc { - handle, - cx: acx, - cy: acy, - r, - a0: aa0, - a1: aa1, - } => { - if *handle == target { - continue; - } - // 64-sample sign-change on (dist_to_arc_circle - r) - let n = 64usize; - let mut prev_sign = { - let [px, py] = ellipse_pt(t0); - (px - acx).hypot(py - acy) - r - }; - for i in 1..=n { - let t_ell = t0 + span * (i as f64 / n as f64); - let [px, py] = ellipse_pt(t_ell); - let cur_sign = (px - acx).hypot(py - acy) - r; - if prev_sign * cur_sign <= 0.0 { - let t_lo = t0 + span * ((i - 1) as f64 / n as f64); - let t_hi = t_ell; - let mut lo = t_lo; - let mut hi = t_hi; - let mut flo = prev_sign; - for _ in 0..32 { - let mid = (lo + hi) * 0.5; - let [px2, py2] = ellipse_pt(mid); - let fm = (px2 - acx).hypot(py2 - acy) - r; - if flo * fm <= 0.0 { - hi = mid; - } else { - lo = mid; - flo = fm; - } - } - let t_hit = (lo + hi) * 0.5; - let [phx, phy] = ellipse_pt(t_hit); - let ang = (phy - acy).atan2(phx - acx); - if in_arc(ang, *aa0, *aa1) { - ts.push(arc_t(t_hit, t0, t0 + span)); - } - } - prev_sign = cur_sign; - } - } - Geo::Circle { - handle, - cx: acx, - cy: acy, - r, - } => { - if *handle == target { - continue; - } - let n = 64usize; - let mut prev_sign = { - let [px, py] = ellipse_pt(t0); - (px - acx).hypot(py - acy) - r - }; - for i in 1..=n { - let t_ell = t0 + span * (i as f64 / n as f64); - let [px, py] = ellipse_pt(t_ell); - let cur_sign = (px - acx).hypot(py - acy) - r; - if prev_sign * cur_sign <= 0.0 { - let t_lo = t0 + span * ((i - 1) as f64 / n as f64); - let t_hi = t_ell; - let mut lo = t_lo; - let mut hi = t_hi; - let mut flo = prev_sign; - for _ in 0..32 { - let mid = (lo + hi) * 0.5; - let [px2, py2] = ellipse_pt(mid); - let fm = (px2 - acx).hypot(py2 - acy) - r; - if flo * fm <= 0.0 { - hi = mid; - } else { - lo = mid; - flo = fm; - } - } - ts.push(arc_t((lo + hi) * 0.5, t0, t0 + span)); - } - prev_sign = cur_sign; - } - } - Geo::Ray { - handle, - bx: rbx, - by: rby, - dx: rdx, - dy: rdy, - } => { - if *handle == target { - continue; - } - for (s, t_ell) in le(*rbx, *rby, *rdx, *rdy, cx, cy, a, b, nx, ny) { - if s >= -1e-9 && in_arc(t_ell, t0, t1) { - ts.push(arc_t(t_ell, t0, t0 + span)); - } - } - } - Geo::InfLine { - handle, - bx: ibx, - by: iby, - dx: idx, - dy: idy, - } => { - if *handle == target { - continue; - } - for (_s, t_ell) in le(*ibx, *iby, *idx, *idy, cx, cy, a, b, nx, ny) { - if in_arc(t_ell, t0, t1) { - ts.push(arc_t(t_ell, t0, t0 + span)); - } - } - } - Geo::Ellipse { handle, .. } => { - if *handle == target { - continue; - } - // Ellipse-ellipse: numerical 64-sample - if let Geo::Ellipse { - cx: ecx2, - cy: ecy2, - a: ea2, - b: eb2, - nx: nx2, - ny: ny2, - t0: et02, - t1: et12, - .. - } = geo - { - let n = 64usize; - let f = |t: f64| -> f64 { - let [px, py] = ellipse_pt(t); - let xl = (px - ecx2) * nx2 + (py - ecy2) * ny2; - let yl = -(px - ecx2) * ny2 + (py - ecy2) * nx2; - (xl / ea2).powi(2) + (yl / eb2).powi(2) - 1.0 - }; - let mut prev_f = f(t0); - for i in 1..=n { - let t_ell = t0 + span * (i as f64 / n as f64); - let cur_f = f(t_ell); - if prev_f * cur_f <= 0.0 { - let t_lo = t0 + span * ((i - 1) as f64 / n as f64); - let mut lo = t_lo; - let mut hi = t_ell; - let mut flo = prev_f; - for _ in 0..32 { - let mid = (lo + hi) * 0.5; - let fm = f(mid); - if flo * fm <= 0.0 { - hi = mid; - } else { - lo = mid; - flo = fm; - } - } - let t_hit = (lo + hi) * 0.5; - let [phx, phy] = ellipse_pt(t_hit); - let xl = (phx - ecx2) * nx2 + (phy - ecy2) * ny2; - let yl = -(phx - ecx2) * ny2 + (phy - ecy2) * nx2; - let t_ell2 = yl.atan2(xl); - if in_arc(t_ell2, *et02, *et12) { - ts.push(arc_t(t_hit, t0, t0 + span)); - } - } - prev_f = cur_f; - } - } - } - Geo::Spline { handle, segs } => { - if *handle == target { - continue; - } - // Ellipse × Spline: sign-change detection on each spline segment - for (p1, p2) in segs { - let ldx = p2[0] - p1[0]; - let ldy = p2[1] - p1[1]; - for (s, t_ell) in le(p1[0], p1[1], ldx, ldy, cx, cy, a, b, nx, ny) { - if !(-1e-9..=1.0 + 1e-9).contains(&s) { - continue; - } - if in_arc(t_ell, t0, t1) { - ts.push(arc_t(t_ell, t0, t0 + span)); - } - } - } - } - } - } - ts.sort_by(|a, b| a.partial_cmp(b).unwrap()); - ts.dedup_by(|a, b| (*a - *b).abs() < 1e-6); - ts +/// Cuts on a spline, solved against the true curve rather than a sampling of +/// it. The kernel subdivides and converges, so a cut lands where the boundary +/// actually crosses instead of where a fixed number of samples said it did. +fn spline_seg_ts(spl: &SplineEnt, target: Handle, geos: &[Geo]) -> Vec { + let Some(curve) = spline_to_nurbs(spl) else { + return vec![]; + }; + cut_params(&Curve::Nurbs(curve), target, geos) } /// Trim an Ellipse entity. Returns the surviving ellipse-arc segments. @@ -940,36 +556,6 @@ fn extend_ellipse(orig: &EllipseEnt, t_click: f64, geos: &[Geo]) -> Option Vec { - let Some((t0, t1)) = spline_range(spl) else { - return vec![]; - }; - let range = t1 - t0; - if range < 1e-12 { - return vec![]; - } - - let (ts_spl, pts) = spline_sample_xy(spl, 64); - let mut out = vec![]; - for i in 0..pts.len().saturating_sub(1) { - let ax = pts[i][0]; - let ay = pts[i][1]; - let bx = pts[i + 1][0]; - let by = pts[i + 1][1]; - let seg_ts = line_seg_ts(ax, ay, bx, by, target, geos); - for u in seg_ts { - // u is a t-param on this polyline segment; map to spline knot range, then normalise. - let t_spline = ts_spl[i] + u * (ts_spl[i + 1] - ts_spl[i]); - out.push(t_to_rel(t_spline, t0, t1)); - } - } - out.sort_by(|a, b| a.partial_cmp(b).unwrap()); - out.dedup_by(|a, b| (*a - *b).abs() < 1e-4); - out -} - /// Trim a Spline entity. Returns surviving spline pieces (one or two). fn trim_spline(spl: &SplineEnt, ts: &[f64], t_click: f64) -> Vec { let Some((t0, t1)) = spline_range(spl) else { @@ -1031,15 +617,20 @@ fn extend_spline(spl: &SplineEnt, t_click: f64, geos: &[Geo]) -> Option 1e-6) + .reduce(f64::min)?; - let best_t = seg_ts.into_iter().filter(|&t| t > 1e-6).reduce(f64::min)?; - - let hit_x = ep[0] + best_t * (ray_end_x - ep[0]) * TRIM_EXTENT; - let hit_y = ep[1] + best_t * (ray_end_y - ep[1]) * TRIM_EXTENT; + let hit_x = ep[0] + best_t * dx; + let hit_y = ep[1] + best_t * dy; // Add a new control point at the hit location by appending/prepending. let z = spl.control_points.first().map(|v| v.z).unwrap_or(0.0); @@ -1066,9 +657,24 @@ fn extend_spline(spl: &SplineEnt, t_click: f64, geos: &[Geo]) -> Option Vec<(f64, f64)> { - let mut bounds = vec![0.0f64]; + cut_intervals(ts, t_click, KernelExtent::Bounded) +} + +/// The stretches a target keeps after the one holding the click is removed. +/// +/// The outer bounds come from how far the target's parameter runs, so a ray +/// keeps an interval that reaches infinity rather than one that stops at an +/// invented far end. Those infinities are what tell the caller a surviving +/// piece is still unbounded. +fn cut_intervals(ts: &[f64], t_click: f64, extent: KernelExtent) -> Vec<(f64, f64)> { + let (first, last) = match extent { + KernelExtent::Bounded => (0.0, 1.0), + KernelExtent::Forward => (0.0, f64::INFINITY), + KernelExtent::Infinite => (f64::NEG_INFINITY, f64::INFINITY), + }; + let mut bounds = vec![first]; bounds.extend_from_slice(ts); - bounds.push(1.0); + bounds.push(last); bounds.dedup_by(|a, b| (*a - *b).abs() < 1e-6); let remove = bounds @@ -1522,7 +1128,7 @@ fn extend_lwpoly( } } } - Geo::Spline { handle, segs } => { + Geo::Spline { handle, segs, .. } => { if *handle == target { continue; } @@ -1700,7 +1306,7 @@ fn extend_line(orig: &LineEnt, t_click: f64, geos: &[Geo]) -> Option } } } - Geo::Spline { handle, segs } => { + Geo::Spline { handle, segs, .. } => { if *handle == target { continue; } @@ -1739,8 +1345,10 @@ fn extend_line(orig: &LineEnt, t_click: f64, geos: &[Geo]) -> Option } /// Trim a Ray entity. -/// Virtual t ∈ [0,1]: t=0 → base_point, t=1 → base + TRIM_EXTENT * dir. -/// Surviving pieces become Lines (finite) or Rays (still semi-infinite). +/// +/// The parameter is the ray's own: `t = 0` at the base and advancing by one +/// `direction` per unit, unbounded above. A surviving piece that reaches +/// infinity stays a Ray; one with two ends becomes a Line. fn trim_ray(orig: &RayEnt, ts: &[f64], t_click: f64) -> Vec { let bx = orig.base_point.x; let by = orig.base_point.y; @@ -1748,24 +1356,18 @@ fn trim_ray(orig: &RayEnt, ts: &[f64], t_click: f64) -> Vec { let dx = orig.direction.x; let dy = orig.direction.y; let dz = orig.direction.z; - let pt = |t: f64| { - [ - bx + t * dx * TRIM_EXTENT, - by + t * dy * TRIM_EXTENT, - bz + t * dz * TRIM_EXTENT, - ] - }; + let pt = |t: f64| [bx + t * dx, by + t * dy, bz + t * dz]; - trim_intervals(ts, t_click) + cut_intervals(ts, t_click, KernelExtent::Forward) .into_iter() .filter_map(|(ta, tb)| { let pa = pt(ta); let pb = pt(tb); - if (pb[0] - pa[0]).hypot(pb[1] - pa[1]) < 1e-6 { + if tb.is_finite() && (pb[0] - pa[0]).hypot(pb[1] - pa[1]) < 1e-6 { return None; } - if tb > INF_T { + if tb.is_infinite() { // Still extends to infinity → remains a Ray with new base let r = RayEnt::new(Vector3::new(pa[0], pa[1], pa[2]), Vector3::new(dx, dy, dz)); let mut r = r; @@ -1788,8 +1390,11 @@ fn trim_ray(orig: &RayEnt, ts: &[f64], t_click: f64) -> Vec { } /// Trim an XLine entity. -/// Virtual t ∈ [0,1]: t=0 → base - dir*TRIM_EXTENT, t=0.5 → base, t=1 → base + dir*TRIM_EXTENT. -/// Surviving pieces become Lines (finite), Rays (one infinite end), or the original XLine (both ends). +/// +/// The parameter is the line's own: `t = 0` at the base point, advancing by +/// one `direction` per unit and running both ways without limit. A surviving +/// piece keeps whichever ends reach infinity — both, and it is still an XLine; +/// one, and it is a Ray; neither, and it is a Line. fn trim_xline(orig: &XLineEnt, ts: &[f64], t_click: f64) -> Vec { let bx = orig.base_point.x; let by = orig.base_point.y; @@ -1797,23 +1402,15 @@ fn trim_xline(orig: &XLineEnt, ts: &[f64], t_click: f64) -> Vec { let dx = orig.direction.x; let dy = orig.direction.y; let dz = orig.direction.z; - // Point at virtual t: scale factor s = 2t - 1 ∈ [-1, +1] - let pt = |t: f64| { - let s = 2.0 * t - 1.0; - [ - bx + s * dx * TRIM_EXTENT, - by + s * dy * TRIM_EXTENT, - bz + s * dz * TRIM_EXTENT, - ] - }; + let pt = |t: f64| [bx + t * dx, by + t * dy, bz + t * dz]; - trim_intervals(ts, t_click) + cut_intervals(ts, t_click, KernelExtent::Infinite) .into_iter() .filter_map(|(ta, tb)| { let pa = pt(ta); let pb = pt(tb); - let ext_neg = ta < 1.0 - INF_T; // extends toward -infinity - let ext_pos = tb > INF_T; // extends toward +infinity + let ext_neg = ta.is_infinite(); + let ext_pos = tb.is_infinite(); match (ext_neg, ext_pos) { (true, true) => { @@ -2195,43 +1792,21 @@ fn pick_trim_at( Some(survivors) } Some(EntityType::Ray(r)) => { - // Virtual segment: base → base + dir * TRIM_EXTENT (t ∈ [0,1]) - let bx = r.base_point.x; - let by = r.base_point.y; - let ex = bx + r.direction.x * TRIM_EXTENT; - let ey = by + r.direction.y * TRIM_EXTENT; - let ts = line_seg_ts(bx, by, ex, ey, handle, geos); + let curve = ray_curve(r); + let ts = cut_params(&curve, handle, geos); if ts.is_empty() { return None; } - let dx = r.direction.x * TRIM_EXTENT; - let dy = r.direction.y * TRIM_EXTENT; - let len2 = dx * dx + dy * dy; - let t_click = if len2 > 1e-12 { - ((px - bx) * dx + (py - by) * dy) / len2 - } else { - 0.5 - }; + let t_click = curve.parameter_at([px, py]); Some(trim_ray(r, &ts, t_click)) } Some(EntityType::XLine(x)) => { - // Virtual segment: base - dir*TRIM_EXTENT → base + dir*TRIM_EXTENT - let bx = x.base_point.x - x.direction.x * TRIM_EXTENT; - let by = x.base_point.y - x.direction.y * TRIM_EXTENT; - let ex = x.base_point.x + x.direction.x * TRIM_EXTENT; - let ey = x.base_point.y + x.direction.y * TRIM_EXTENT; - let ts = line_seg_ts(bx, by, ex, ey, handle, geos); + let curve = xline_curve(x); + let ts = cut_params(&curve, handle, geos); if ts.is_empty() { return None; } - let dx = ex - bx; - let dy = ey - by; - let len2 = dx * dx + dy * dy; - let t_click = if len2 > 1e-12 { - ((px - bx) * dx + (py - by) * dy) / len2 - } else { - 0.5 - }; + let t_click = curve.parameter_at([px, py]); Some(trim_xline(x, &ts, t_click)) } Some(EntityType::Ellipse(e)) => { @@ -2354,13 +1929,21 @@ fn pick_extend_at( fn imply_edge_geos(geos: &mut [Geo]) { for g in geos.iter_mut() { match g { - Geo::Line { p1, p2, .. } => { + Geo::Line { handle, p1, p2 } => { let (dx, dy) = (p2[0] - p1[0], p2[1] - p1[1]); - let len = dx.hypot(dy); - if len > 1e-9 { - let (ux, uy) = (dx / len, dy / len); - *p1 = [p1[0] - ux * TRIM_EXTENT, p1[1] - uy * TRIM_EXTENT]; - *p2 = [p2[0] + ux * TRIM_EXTENT, p2[1] + uy * TRIM_EXTENT]; + if dx.hypot(dy) > 1e-9 { + // An implied edge cuts wherever the boundary's line would + // reach, which is what an infinite line is. Stretching the + // segment to a stand-in length instead both invents a + // limit and, at survey coordinates, is no larger than the + // coordinates themselves. + *g = Geo::InfLine { + handle: *handle, + bx: p1[0], + by: p1[1], + dx, + dy, + }; } } Geo::Arc { a0, a1, .. } => { @@ -2376,15 +1959,8 @@ fn imply_edge_geos(geos: &mut [Geo]) { /// supports (adds Circle / Ray / XLine over `sample_entity_xy`). fn fence_sample_xy(e: &EntityType) -> Vec<[f64; 2]> { match e { - 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(), c.center.y + c.radius * a.sin()] - }) - .collect() - } + // The two unbounded kinds, which `sample_entity_xy` declines to + // guess a length for. Here there is one: as far as a trim reaches. EntityType::Ray(r) => vec![ [r.base_point.x, r.base_point.y], [ @@ -3301,20 +2877,12 @@ impl CadCommand for TrimCommand { Some(EntityType::Ray(r)) => { let bx = r.base_point.x; let by = r.base_point.y; - let ex = bx + r.direction.x * TRIM_EXTENT; - let ey = by + r.direction.y * TRIM_EXTENT; - let ts = line_seg_ts(bx, by, ex, ey, handle, geos); + let curve = ray_curve(r); + let ts = cut_params(&curve, handle, geos); if ts.is_empty() { return vec![]; } - let dx = r.direction.x * TRIM_EXTENT; - let dy = r.direction.y * TRIM_EXTENT; - let len2 = dx * dx + dy * dy; - let t_click = if len2 > 1e-12 { - ((pt.x as f64 - bx) * dx + (pt.y as f64 - by) * dy) / len2 - } else { - 0.5 - }; + let t_click = curve.parameter_at([pt.x as f64, pt.y as f64]); let survivors = trim_ray(r, &ts, t_click); // Show a finite preview section (20 units) for the original ray let far = [ @@ -3339,22 +2907,12 @@ impl CadCommand for TrimCommand { Some(EntityType::XLine(x)) => { let bx = x.base_point.x; let by = x.base_point.y; - let ex_start = bx - x.direction.x * TRIM_EXTENT; - let ey_start = by - x.direction.y * TRIM_EXTENT; - let ex_end = bx + x.direction.x * TRIM_EXTENT; - let ey_end = by + x.direction.y * TRIM_EXTENT; - let ts = line_seg_ts(ex_start, ey_start, ex_end, ey_end, handle, geos); + let curve = xline_curve(x); + let ts = cut_params(&curve, handle, geos); if ts.is_empty() { return vec![]; } - let dx = ex_end - ex_start; - let dy = ey_end - ey_start; - let len2 = dx * dx + dy * dy; - let t_click = if len2 > 1e-12 { - ((pt.x as f64 - ex_start) * dx + (pt.y as f64 - ey_start) * dy) / len2 - } else { - 0.5 - }; + let t_click = curve.parameter_at([pt.x as f64, pt.y as f64]); let survivors = trim_xline(x, &ts, t_click); let neg = [ (bx - x.direction.x * 20.0) as f32, @@ -4152,14 +3710,18 @@ mod tests { /// drawing (EXTRIM treats a line boundary as infinite). fn extend_line_geos(geos: &mut [Geo]) { for g in geos.iter_mut() { - if let Geo::Line { p1, p2, .. } = g { + if let Geo::Line { handle, p1, p2 } = g { let (dx, dy) = (p2[0] - p1[0], p2[1] - p1[1]); - let len = dx.hypot(dy); - if len > 1e-9 { - let (ux, uy) = (dx / len, dy / len); - let mid = [(p1[0] + p2[0]) * 0.5, (p1[1] + p2[1]) * 0.5]; - *p1 = [mid[0] - ux * TRIM_EXTENT, mid[1] - uy * TRIM_EXTENT]; - *p2 = [mid[0] + ux * TRIM_EXTENT, mid[1] + uy * TRIM_EXTENT]; + if dx.hypot(dy) > 1e-9 { + // Same as `imply_edge_geos`: the boundary reaches as far as its + // line does, so say so rather than picking a length. + *g = Geo::InfLine { + handle: *handle, + bx: p1[0], + by: p1[1], + dx, + dy, + }; } } } @@ -4277,69 +3839,45 @@ fn extrim_circle(orig: &CircleEnt, ts: &[f64], side: &dyn Fn([f64; 2]) -> bool) } /// Sample any entity to a dense XY polyline for the sampled trim path. +/// Plan-view sampling of any entity, at the density the editing previews +/// have always used. +/// +/// One tessellation rather than a case per type. The per-type version this +/// replaced had drifted from the drawing's own: it sampled an arc at 16 +/// segments per radian against the renderer's 20, sampled a spline at a +/// fixed 96 points however long it was, and read an arc's centre as world +/// coordinates when the entity stores it in its OCS. +/// +/// Unbounded curves come back empty — a ray has no finite point list, and +/// how far to run it is the caller's decision. [`fence_sample_xy`] makes it. fn sample_entity_xy(e: &EntityType) -> Vec<[f64; 2]> { - match e { - EntityType::Line(l) => vec![[l.start.x, l.start.y], [l.end.x, l.end.y]], - EntityType::Arc(a) => { - let span = { - let s = norm(a.end_angle) - norm(a.start_angle); - if s <= 0.0 { - s + TAU - } else { - s - } - }; - let steps = (span.abs() * 16.0).ceil().max(4.0) as usize; - (0..=steps) - .map(|i| { - let ang = norm(a.start_angle) + span * (i as f64 / steps as f64); - [ - a.center.x + a.radius * ang.cos(), - a.center.y + a.radius * ang.sin(), - ] - }) - .collect() - } + // A polyline is sampled through its exploded segments so that a caller + // walking the result sees the same seams the rest of TRIM does. + if matches!( + e, EntityType::LwPolyline(_) - | EntityType::Polyline(_) - | EntityType::Polyline2D(_) - | EntityType::Polyline3D(_) => { - let mut pts: Vec<[f64; 2]> = Vec::new(); - for seg in crate::modules::draw::modify::explode::explode_polyline_segments(e) { - let sp = sample_entity_xy(&seg); - if pts.last() == sp.first() { - pts.extend_from_slice(&sp[1..]); - } else { - pts.extend(sp); - } + | EntityType::Polyline(_) + | EntityType::Polyline2D(_) + | EntityType::Polyline3D(_) + ) { + let mut pts: Vec<[f64; 2]> = Vec::new(); + for seg in crate::modules::draw::modify::explode::explode_polyline_segments(e) { + let sp = sample_entity_xy(&seg); + if pts.last() == sp.first() { + pts.extend_from_slice(&sp[1..]); + } else { + pts.extend(sp); } - pts } - EntityType::Ellipse(el) => { - let mx = el.major_axis.x; - let my = el.major_axis.y; - let a = (mx * mx + my * my).sqrt(); - if a < 1e-9 { - return vec![]; - } - let (nx, ny) = (mx / a, my / a); - let b = a * el.minor_axis_ratio; - let t0 = el.start_parameter; - let mut t1 = el.end_parameter; - if t1 <= t0 { - t1 += TAU; - } - ellipse_pts(el.center.x, el.center.y, a, b, nx, ny, t0, t1, el.center.z) - .into_iter() - .map(|p| [p[0] as f64, p[1] as f64]) - .collect() - } - EntityType::Spline(s) => { - let (_, pts) = spline_sample_xy(s, 96); - pts.into_iter().map(|p| [p[0], p[1]]).collect() - } - _ => vec![], + return pts; } + let Some(curve) = entity_curve_xy(e) else { + return vec![]; + }; + if curve.extent() != KernelExtent::Bounded { + return vec![]; + } + curve.tessellate(SAMPLE_SEGMENTS_PER_RADIAN) } /// Dense XY sampling of any entity for the removal preview (lines are diff --git a/src/scene/boundary.rs b/src/scene/boundary.rs index 054ca4f38..ddf4f0e1d 100644 --- a/src/scene/boundary.rs +++ b/src/scene/boundary.rs @@ -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> { - let wires = self.entity_wires(); - let mut segments = Vec::::new(); + let mut segments = Vec::::new(); - for wire in wires.iter() { - let mut previous: Option = 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> { - if segments.is_empty() { - return Vec::new(); - } - - // Each original segment starts with its two endpoints as split positions. - let mut cuts: Vec> = 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 = segments.iter().copied().map(SegmentAabb::new).collect(); - let axis = choose_sweep_axis(&aabbs); - let mut order: Vec = (0..segments.len()).collect(); - order.sort_by(|&a, &b| aabbs[a].min(axis).total_cmp(&aabbs[b].min(axis))); - let mut active = Vec::::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::::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::::new(); - let mut node_map = HashMap::<(i64, i64), Vec>::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::::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::>::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::::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, - map: &mut HashMap<(i64, i64), Vec>, -) -> 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)) } } diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 50c7a344d..dce2bbac6 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -3,7 +3,10 @@ // hit-testing lives in `scene::pick::hit_test`.) use super::*; -fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option { +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 Option { + 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 { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 52acf6d89..67cfa4154 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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, + }, +} + /// 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>, + /// 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, /// Model-space tiled viewport layout. One full-window tile by default; /// the split buttons / VPORTS subdivide the active tile. pub(crate) model_tiles: RefCell>, @@ -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, diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index 50e9467c0..3ee671e94 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -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. diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index b7f5329e3..464247abc 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -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 = 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") ); } diff --git a/src/snap.rs b/src/snap.rs index a81d8d9d3..b18f55624 100644 --- a/src/snap.rs +++ b/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,8 +830,8 @@ impl Snapper { eye, bounds, Vec3::ZERO, - Mat4::IDENTITY, - None, + (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, ) -> Option { // 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); diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 5995b322d..c0eef63c1 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -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 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 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( - [1.0, 1.0, 1.0, 0.90], - self.crosshair_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 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 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 - 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); + 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)); + }); + 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(&square, stroke); + } // Locked-layer badge: a small padlock beside the crosshair when // the hovered object sits on a locked layer (issue: locked diff --git a/src/ui/popup/snap_popup.rs b/src/ui/popup/snap_popup.rs index bc6b674c6..c5a36c852 100644 --- a/src/ui/popup/snap_popup.rs +++ b/src/ui/popup/snap_popup.rs @@ -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> { +pub fn menu_entries<'a>( + snapper: &'a Snapper, + isometric: bool, + iso_plane: IsoPlane, +) -> Vec> { 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> { .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, diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index 6a3a9630b..7736d3028 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -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, block_tabs: Vec, reorderable_layouts: Vec, @@ -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(), ); diff --git a/src/ui/window/drafting_settings.rs b/src/ui/window/drafting_settings.rs new file mode 100644 index 000000000..714225ec9 --- /dev/null +++ b/src/ui/window/drafting_settings.rs @@ -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() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 4e8cd139d..e1ae0b229 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -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; diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index cb27fd43c..02eabff93 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -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 { 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::>(); + 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),