From 06cd0ec6a9665f67f86ed7d835172fc0e3d8bb91 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 23 Aug 2026 22:53:27 +0300 Subject: [PATCH] feat(model): add exact solid modelling tools --- Cargo.lock | 4 +- Cargo.toml | 4 +- assets/icons/extrude.svg | 4 + assets/icons/loft.svg | 4 + assets/icons/polysolid.svg | 4 + assets/icons/presspull.svg | 4 + assets/icons/pyramid3d.svg | 4 + assets/icons/revolve.svg | 4 + assets/icons/sweep.svg | 4 + crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_web_worker/Cargo.toml | 2 +- locales/ar-SA/opencadstudio.ftl | 11 +- locales/bg-BG/opencadstudio.ftl | 11 +- locales/cs-CZ/opencadstudio.ftl | 11 +- locales/de-DE/opencadstudio.ftl | 11 +- locales/en-US/opencadstudio.ftl | 11 +- locales/es-ES/opencadstudio.ftl | 11 +- locales/fi-FI/opencadstudio.ftl | 11 +- locales/fr-FR/opencadstudio.ftl | 11 +- locales/hi-IN/opencadstudio.ftl | 11 +- locales/hu-HU/opencadstudio.ftl | 11 +- locales/it-IT/opencadstudio.ftl | 11 +- locales/ja-JP/opencadstudio.ftl | 11 +- locales/ko-KR/opencadstudio.ftl | 11 +- locales/nl-NL/opencadstudio.ftl | 11 +- locales/pl-PL/opencadstudio.ftl | 11 +- locales/pt-BR/opencadstudio.ftl | 11 +- locales/ru-RU/opencadstudio.ftl | 11 +- locales/tr-TR/opencadstudio.ftl | 11 +- locales/zh-CN/opencadstudio.ftl | 11 +- locales/zh-TW/opencadstudio.ftl | 11 +- src/app/command_driver.rs | 277 ++++++++++++-------------- src/app/commands/dim.rs | 38 ++++ src/app/commands/display.rs | 44 +++- src/app/commands/draw.rs | 17 +- src/app/model_ops.rs | 283 ++++++++++++++++++-------- src/app/update/file.rs | 2 +- src/app/update/viewport.rs | 38 +++- src/command.rs | 30 ++- src/locale_catalog.rs | 9 + src/modules/insert/solid3d_cmds.rs | 202 ++++++++++++++++--- src/modules/model/boolean_cmd.rs | 4 +- src/modules/model/edge_cmd.rs | 146 ++++++++++++++ src/modules/model/mod.rs | 54 +++-- src/modules/model/primitive_cmd.rs | 100 ++++++---- src/scene/convert/acis_export.rs | 15 +- src/scene/mod.rs | 63 ++++-- src/scene/model/solid_history.rs | 78 +------- src/scene/model/solid_model.rs | 85 ++++++-- src/scene/model/sweep_model.rs | 310 ++++++++++++++++++++--------- src/scene/modify.rs | 4 +- 51 files changed, 1487 insertions(+), 574 deletions(-) create mode 100644 assets/icons/extrude.svg create mode 100644 assets/icons/loft.svg create mode 100644 assets/icons/polysolid.svg create mode 100644 assets/icons/presspull.svg create mode 100644 assets/icons/pyramid3d.svg create mode 100644 assets/icons/revolve.svg create mode 100644 assets/icons/sweep.svg create mode 100644 src/modules/model/edge_cmd.rs diff --git a/Cargo.lock b/Cargo.lock index 32a1413b..189c1b22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=db73dab#db73dab540dbfb690c93f4416014262e6cf7bf42" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=467c3fd#467c3fd02f419ab5af4030691699b34b25d2f51a" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=76de3b3#76de3b35e55a45f3ffb813c499cf3affe176351d" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index ec1734bb..4cc5ae75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db73dab", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "467c3fd", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "94df2c3", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "76de3b3", features = ["acis", "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/icons/extrude.svg b/assets/icons/extrude.svg new file mode 100644 index 00000000..d864bce3 --- /dev/null +++ b/assets/icons/extrude.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/loft.svg b/assets/icons/loft.svg new file mode 100644 index 00000000..99e9cf62 --- /dev/null +++ b/assets/icons/loft.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/polysolid.svg b/assets/icons/polysolid.svg new file mode 100644 index 00000000..8532c376 --- /dev/null +++ b/assets/icons/polysolid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/presspull.svg b/assets/icons/presspull.svg new file mode 100644 index 00000000..189e6909 --- /dev/null +++ b/assets/icons/presspull.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/pyramid3d.svg b/assets/icons/pyramid3d.svg new file mode 100644 index 00000000..a7d26f91 --- /dev/null +++ b/assets/icons/pyramid3d.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/revolve.svg b/assets/icons/revolve.svg new file mode 100644 index 00000000..48073ba9 --- /dev/null +++ b/assets/icons/revolve.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/sweep.svg b/assets/icons/sweep.svg new file mode 100644 index 00000000..4db7d373 --- /dev/null +++ b/assets/icons/sweep.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index bba21d9c..6f6feee5 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # 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 = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db73dab", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "94df2c3", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -37,7 +37,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db73dab", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "94df2c3", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index ff034dd7..b3a052b0 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 = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db73dab", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "94df2c3", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 62a03272..5c48a3ce 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = رسم .annotate = تعليقات توضيحية .insert = إدراج - .model = نموذج + .model = النمذجة .layout = تخطيط .manage = إدارة .view = عرض @@ -2047,6 +2047,15 @@ insert = .object-s-cut-to-clipboard = قُص __ocs_fmt_0__ كائن إلى الحافظة. model = + .create = إنشاء + .boolean = عمليات منطقية + .pyramid = هرم + .polysolid = مجسّم متعدد + .extrude = بثق + .revolve = تدوير + .loft = تشكيل انتقالي + .sweep = سحب على مسار + .presspull = دفع/سحب .n-specify-center-point = __ocs_arg_n__ حدد نقطة المركز: .n-specify-first-corner = __ocs_arg_n__ حدد الزاوية الأولى: .n-specify-height-enter-for-default = __ocs_arg_n__ حدد الارتفاع : diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index fe6994b8..2df4f555 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Чертане .annotate = Анотиране .insert = Вмъкване - .model = Модел + .model = Моделиране .layout = Оформление .manage = Управление .view = Изглед @@ -2034,6 +2034,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ обект(а) изрязани в клипборда. model = + .create = Създаване + .boolean = Булеви операции + .pyramid = Пирамида + .polysolid = Полисолид + .extrude = Екструдиране + .revolve = Завъртане + .loft = Лофт + .sweep = Изтегляне по траектория + .presspull = Натискане/издърпване .n-specify-center-point = __ocs_arg_n__ Посочи централна точка: .n-specify-first-corner = __ocs_arg_n__ Посочи първи ъгъл: .n-specify-height-enter-for-default = __ocs_arg_n__ Посочи височина : diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 92c95855..f3a9b7f4 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Kreslení .annotate = Poznámky .insert = Vložit - .model = Model + .model = Modelování .layout = Rozvržení .manage = Správa .view = Zobrazení @@ -2034,6 +2034,15 @@ insert = .object-s-cut-to-clipboard = Objekt(y) __ocs_fmt_0__ vyříznutý do schránky. model = + .create = Vytvořit + .boolean = Booleovské operace + .pyramid = Jehlan + .polysolid = Polysolid + .extrude = Vysunutí + .revolve = Rotace + .loft = Přechod + .sweep = Tažení + .presspull = Tlačit/táhnout .n-specify-center-point = __ocs_arg_n__ Zadejte středový bod: .n-specify-first-corner = __ocs_arg_n__ Určete první roh: .n-specify-height-enter-for-default = __ocs_arg_n__ Zadejte výšku : diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 87f3b152..ac4eb8dc 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Zeichnen .annotate = Beschriften .insert = Einsatz - .model = Modell + .model = Modellierung .layout = Layout .manage = Verwalten .view = Ansicht @@ -2033,6 +2033,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ Objekt(e) in die Zwischenablage geschnitten. model = + .create = Erstellen + .boolean = Boolesche Operationen + .pyramid = Pyramide + .polysolid = Polykörper + .extrude = Extrudieren + .revolve = Drehen + .loft = Loft + .sweep = Austragen + .presspull = Drücken/Ziehen .n-specify-center-point = __ocs_arg_n__ Mittelpunkt angeben: .n-specify-first-corner = __ocs_arg_n__ Erste Ecke angeben: .n-specify-height-enter-for-default = __ocs_arg_n__ Höhe angeben : diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 6386d0d6..71bd8ff2 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Draw .annotate = Annotate .insert = Insert - .model = Model + .model = Modelling .layout = Layout .manage = Manage .view = View @@ -2063,6 +2063,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ object(s) cut to clipboard. model = + .create = Create + .boolean = Boolean + .pyramid = Pyramid + .polysolid = Polysolid + .extrude = Extrude + .revolve = Revolve + .loft = Loft + .sweep = Sweep + .presspull = Presspull .n-specify-center-point = __ocs_arg_n__ Specify center point: .n-specify-first-corner = __ocs_arg_n__ Specify first corner: .n-specify-height-enter-for-default = __ocs_arg_n__ Specify height : diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 3f4a04fe..a929a1f0 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Dibujo .annotate = Anotar .insert = Insertar - .model = Modelo + .model = Modelado .layout = Presentación .manage = Administrar .view = Vista @@ -2035,6 +2035,15 @@ insert = .object-s-cut-to-clipboard = Se cortaron __ocs_fmt_0__ objetos al portapapeles. model = + .create = Crear + .boolean = Booleanas + .pyramid = Pirámide + .polysolid = Polisólido + .extrude = Extruir + .revolve = Revolucionar + .loft = Transición + .sweep = Barrido + .presspull = Presionar/tirar .n-specify-center-point = __ocs_arg_n__ Especifique el centro: .n-specify-first-corner = __ocs_arg_n__ Especifique la primera esquina: .n-specify-height-enter-for-default = __ocs_arg_n__ Especifique la altura : diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 08717ec7..8399b91f 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Piirrä .annotate = Merkinnät .insert = Lisää - .model = Malli + .model = Mallinnus .layout = Asettelu .manage = Hallinta .view = Näkymä @@ -2034,6 +2034,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ objekti(t) leikattu leikepöydälle. model = + .create = Luo + .boolean = Boolen operaatiot + .pyramid = Pyramidi + .polysolid = Monikappale + .extrude = Pursota + .revolve = Pyöräytä + .loft = Loftaus + .sweep = Pyyhkäisy + .presspull = Työnnä/vedä .n-specify-center-point = __ocs_arg_n__ Määritä keskipiste: .n-specify-first-corner = __ocs_arg_n__ Määritä ensimmäinen kulma: .n-specify-height-enter-for-default = __ocs_arg_n__ Määritä korkeus : diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index d2dbe5e9..f0ced22a 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Dessin .annotate = Annoter .insert = Insérer - .model = Modèle + .model = Modélisation .layout = Mise en page .manage = Gérer .view = Affichage @@ -2035,6 +2035,15 @@ insert = .object-s-cut-to-clipboard = Objets coupés vers le presse-papiers : __ocs_fmt_0__. model = + .create = Créer + .boolean = Opérations booléennes + .pyramid = Pyramide + .polysolid = Polysolide + .extrude = Extruder + .revolve = Révolution + .loft = Lissage + .sweep = Balayage + .presspull = Tirer-pousser .n-specify-center-point = __ocs_arg_n__ Spécifier le point central: .n-specify-first-corner = __ocs_arg_n__ Spécifiez le premier coin : .n-specify-height-enter-for-default = __ocs_arg_n__ Spécifiez la hauteur : diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index d8f40a6c..4356de5c 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = ड्रॉ .annotate = एनोटेट .insert = सम्मिलित करें - .model = मॉडल + .model = मॉडलिंग .layout = लेआउट .manage = प्रबंधित करें .view = देखें @@ -2023,6 +2023,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ ऑब्जेक्ट क्लिपबोर्ड पर काटे गए। model = + .create = बनाएँ + .boolean = बूलियन संक्रियाएँ + .pyramid = पिरामिड + .polysolid = बहुखंड ठोस + .extrude = एक्सट्रूड + .revolve = घुमाएँ + .loft = लॉफ्ट + .sweep = स्वीप + .presspull = दबाएँ/खींचें .n-specify-center-point = __ocs_arg_n__ केंद्र बिंदु निर्दिष्ट करें: .n-specify-first-corner = __ocs_arg_n__ पहले कोने को निर्दिष्ट करें: .n-specify-height-enter-for-default = __ocs_arg_n__ ऊंचाई निर्दिष्ट करें के लिए दर्ज करें>: diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 0b7a8f7b..af07478f 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Rajz .annotate = Feliratozás .insert = Beszúrás - .model = Modell + .model = Modellezés .layout = Elrendezés .manage = Kezelés .view = Nézet @@ -2034,6 +2034,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ objektum(ok) vágólapra vágva. model = + .create = Létrehozás + .boolean = Logikai műveletek + .pyramid = Gúla + .polysolid = Poliszilárdtest + .extrude = Kihúzás + .revolve = Forgatás + .loft = Átmenet + .sweep = Seprés + .presspull = Nyomás/húzás .n-specify-center-point = __ocs_arg_n__ Középpont megadása: .n-specify-first-corner = __ocs_arg_n__ Adja meg az első sarkot: .n-specify-height-enter-for-default = __ocs_arg_n__ Adja meg a magasságot: diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 8c915bb8..6ab5b504 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Disegno .annotate = Annotazione .insert = Inserisci - .model = Modello + .model = Modellazione .layout = Disposizione .manage = Gestisci .view = Vista @@ -2034,6 +2034,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ oggetto/i tagliato negli appunti. model = + .create = Crea + .boolean = Operazioni booleane + .pyramid = Piramide + .polysolid = Polisolido + .extrude = Estrudi + .revolve = Rivoluzione + .loft = Loft + .sweep = Scorrimento + .presspull = Premi/tira .n-specify-center-point = __ocs_arg_n__ Specificare il punto centrale: .n-specify-first-corner = __ocs_arg_n__ Specificare il primo angolo: .n-specify-height-enter-for-default = __ocs_arg_n__ Specificare l'altezza : diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 4abf157c..7531bc56 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = 作図 .annotate = 注釈 .insert = 挿入 - .model = モデル + .model = モデリング .layout = レイアウト .manage = 管理 .view = 表示 @@ -2023,6 +2023,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ 個のオブジェクトを切り取ってクリップボードに保存しました。 model = + .create = 作成 + .boolean = ブール演算 + .pyramid = ピラミッド + .polysolid = ポリソリッド + .extrude = 押し出し + .revolve = 回転 + .loft = ロフト + .sweep = スイープ + .presspull = プレスプル .n-specify-center-point = __ocs_arg_n__ 中心点を指定: .n-specify-first-corner = __ocs_arg_n__ 最初のコーナーを指定: .n-specify-height-enter-for-default = __ocs_arg_n__ 高さを指定 : diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 9c1b5738..0fd664f8 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = 그리기 .annotate = 주석 .insert = 삽입 - .model = 모델 + .model = 모델링 .layout = 배치 .manage = 관리 .view = 보기 @@ -2034,6 +2034,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ 개체를 클립보드로 잘라냈습니다. model = + .create = 생성 + .boolean = 불리언 연산 + .pyramid = 피라미드 + .polysolid = 폴리솔리드 + .extrude = 돌출 + .revolve = 회전 + .loft = 로프트 + .sweep = 스윕 + .presspull = 누르기/당기기 .n-specify-center-point = __ocs_arg_n__ 중심점 지정: .n-specify-first-corner = __ocs_arg_n__ 첫 번째 구석 지정: .n-specify-height-enter-for-default = __ocs_arg_n__ 높이 지정 : diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 876a6cbf..a2abe740 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Tekenen .annotate = Annoteren .insert = Invoegen - .model = Model + .model = Modelleren .layout = Indeling .manage = Beheren .view = Beeld @@ -2035,6 +2035,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ object(en) naar het klembord geknipt. model = + .create = Maken + .boolean = Booleaanse bewerkingen + .pyramid = Piramide + .polysolid = Polysolid + .extrude = Extruderen + .revolve = Omwentelen + .loft = Loft + .sweep = Vegen + .presspull = Duwen/trekken .n-specify-center-point = __ocs_arg_n__ Centrumpunt opgeven: .n-specify-first-corner = __ocs_arg_n__ Geef eerste hoek: .n-specify-height-enter-for-default = __ocs_arg_n__ Hoogte opgeven: diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 9ac73584..9f3d339d 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Rysowanie .annotate = Opisy .insert = Wstaw - .model = Model + .model = Modelowanie .layout = Układ .manage = Zarządzaj .view = Widok @@ -2034,6 +2034,15 @@ insert = .object-s-cut-to-clipboard = Obiekt(y) __ocs_fmt_0__ wycięty do schowka. model = + .create = Utwórz + .boolean = Operacje boolowskie + .pyramid = Ostrosłup + .polysolid = Bryła wielosegmentowa + .extrude = Wyciągnij + .revolve = Obróć + .loft = Loft + .sweep = Przeciągnij + .presspull = Naciśnij/wyciągnij .n-specify-center-point = __ocs_arg_n__ Określ punkt środkowy: .n-specify-first-corner = __ocs_arg_n__ Określ pierwszy narożnik: .n-specify-height-enter-for-default = __ocs_arg_n__ Podaj wysokość : diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 4b70122d..0c8d9886 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Desenhar .annotate = Anotar .insert = Inserir - .model = Modelo + .model = Modelagem .layout = Layout .manage = Gerenciar .view = Exibir @@ -2035,6 +2035,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ objetos recortados para a área de transferência. model = + .create = Criar + .boolean = Operações booleanas + .pyramid = Pirâmide + .polysolid = Polissólido + .extrude = Extrusão + .revolve = Revolução + .loft = Loft + .sweep = Varredura + .presspull = Pressionar/puxar .n-specify-center-point = __ocs_arg_n__ Especifique o ponto central: .n-specify-first-corner = __ocs_arg_n__ Especifique o primeiro canto: .n-specify-height-enter-for-default = __ocs_arg_n__ Especifique a altura : diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 957709db..37223a15 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Начертить .annotate = Аннотации .insert = Вставка - .model = Модель + .model = Моделирование .layout = Расположение .manage = Управление .view = Просмотр @@ -2038,6 +2038,15 @@ insert = .object-s-cut-to-clipboard = В буфер обмена вырезано объектов: __ocs_fmt_0__. model = + .create = Создание + .boolean = Булевы операции + .pyramid = Пирамида + .polysolid = Политело + .extrude = Выдавливание + .revolve = Вращение + .loft = Лофт + .sweep = Сдвиг по траектории + .presspull = Нажать/вытянуть .n-specify-center-point = __ocs_arg_n__ Укажите центральную точку: .n-specify-first-corner = __ocs_arg_n__ Укажите первый угол: .n-specify-height-enter-for-default = __ocs_arg_n__ Укажите высоту <Введите по умолчанию>: diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 4f2d10e7..2f44e370 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Çizim .annotate = Açıklama .insert = Ekle - .model = Model + .model = Modelleme .layout = Yerleşim .manage = Yönet .view = Görünüm @@ -2023,6 +2023,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ nesne panoya kesildi. model = + .create = Oluştur + .boolean = Boole işlemleri + .pyramid = Piramit + .polysolid = Çoklu katı + .extrude = Katılaştır + .revolve = Döndür + .loft = Kesitler arası + .sweep = Yol boyunca süpür + .presspull = İt/Çek .n-specify-center-point = __ocs_arg_n__ Merkez noktasını belirtin: .n-specify-first-corner = __ocs_arg_n__ İlk köşeyi belirtin: .n-specify-height-enter-for-default = __ocs_arg_n__ Yüksekliği belirtin : diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 0fb98a5a..daec9ff0 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = 绘制 .annotate = 注释 .insert = 插入 - .model = 模型 + .model = 建模 .layout = 布局 .manage = 管理 .view = 视图 @@ -2018,6 +2018,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ 对象切入剪贴板。 model = + .create = 创建 + .boolean = 布尔运算 + .pyramid = 棱锥 + .polysolid = 多段体 + .extrude = 拉伸 + .revolve = 旋转 + .loft = 放样 + .sweep = 扫掠 + .presspull = 按拉 .n-specify-center-point = __ocs_arg_n__ 指定中心点: .n-specify-first-corner = __ocs_arg_n__ 指定第一个角点: .n-specify-height-enter-for-default = __ocs_arg_n__ 指定高度(按 Enter 使用默认值): diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 21f275b3..460ed3f3 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = 繪製 .annotate = 註解 .insert = 插入 - .model = 模型 + .model = 建模 .layout = 配置 .manage = 管理 .view = 視圖 @@ -2030,6 +2030,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ 物件切入剪貼簿。 model = + .create = 建立 + .boolean = 布林運算 + .pyramid = 角錐體 + .polysolid = 多段實體 + .extrude = 擠出 + .revolve = 旋轉 + .loft = 放樣 + .sweep = 掃掠 + .presspull = 按拉 .n-specify-center-point = __ocs_arg_n__ 指定中心點: .n-specify-first-corner = __ocs_arg_n__ 指定第一個角點: .n-specify-height-enter-for-default = __ocs_arg_n__ 指定高度(按 Enter 使用預設值): diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index e84005b7..68fbe0fe 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1204,15 +1204,17 @@ impl OpenCADStudio { } => { let label = self.history_label_from_active_cmd(i, "SOLID"); let pending = self.begin_undo(i, label, 1, true); - self.add_solid_model(entity, *solid, history); - self.tabs[i].dirty = true; + let handle = self.add_solid_model(entity, *solid, history); + if !handle.is_null() { + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } } CmdResult::CommitAndEditText(entity) => { let label = self.history_label_from_active_cmd(i, "ENTITY"); @@ -3001,80 +3003,32 @@ impl OpenCADStudio { self.commit_undo_delta(i, pending); } } - // ── Solid3D creation (BOX / SPHERE / CYLINDER) ──────────────── - CmdResult::CommitSolid3D { mesh_fn } => { - use crate::modules::insert::solid3d_cmds::empty_solid3d; - let pending = self.begin_undo(i, "SOLID3D", 1, true); - let entity = empty_solid3d(); - let handle = self.tabs[i].scene.add_entity(entity); - if !handle.is_null() { - let name = format!("{}", handle.value()); - let color = [0.6f32, 0.6, 0.8, 1.0]; // default colour; command embedded it - let _ = color; // color is captured inside mesh_fn - if let Some(mesh) = mesh_fn(name) { - let set = crate::scene::MeshLodSet::from_single(mesh); - if let Some(acadrust::EntityType::Solid3D(entity)) = - self.tabs[i].scene.document.get_entity_mut(handle) - { - let center = set.metrics.centroid; - entity.point_of_reference = acadrust::types::Vector3::new( - center[0], center[1], center[2], - ); - } - self.tabs[i].scene.meshes.insert(handle, set); - } - self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("Solid created.").as_ref()); - } - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - } - // ── EXTRUDE ──────────────────────────────────────────────────── CmdResult::ExtrudeEntity { handle, height, - color, + color: _, } => { if self.reject_locked_edit(i, handle) { self.tabs[i].active_cmd = None; return Task::none(); } use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::{solid_model, sweep_model}; + use crate::scene::model::sweep_model; let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned(); if let Some(entity) = entity_opt { - // The kernel sweeps the profile into analytic surfaces — - // a straight run becomes a plane and an arc a cylinder — - // so the solid saves as exact ACIS rather than facets. - let result = sweep_model::extruded(&entity, height as f64) - .and_then(|body| Some((solid_model::mesh_from_solid(&body, color)?, body))); - if let Some((mesh, solid)) = result { - let history = crate::scene::model::solid_history::extrusion_op( - &entity, - height as f64, - ); + if let Some(solid) = sweep_model::extruded(&entity, height) { + let history = crate::scene::model::solid_history::brep_op(&solid); let pending = self.begin_undo(i, "EXTRUDE", 1, true); - let mut s3d = empty_solid3d(); - if let acadrust::EntityType::Solid3D(inner) = &mut s3d { - inner.wires = solid_model::edge_wires(&solid); - } - let new_handle = self.tabs[i].scene.add_entity(s3d); - self.tabs[i] - .scene - .create_solid_history(new_handle, history); - self.tabs[i].scene.register_solid_model(new_handle, solid); - let _ = mesh; - self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("EXTRUDE: solid created.").as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + let created = self.add_solid_model(empty_solid3d(), solid, history); + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("EXTRUDE: solid created.").as_ref()); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } else { self.command_line.push_error(crate::t!("EXTRUDE: could not build profile. Select a closed 2D entity (Circle, LwPolyline, etc.).").as_ref()); @@ -3088,26 +3042,80 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); } + CmdResult::PresspullEntity { + handle, + pick, + distance, + drag, + color, + } => { + if matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Solid3D(_)) + ) { + let task = self.solid_face_presspull(handle, pick, distance, drag); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + if self.reject_locked_edit(i, handle) { + self.tabs[i].active_cmd = None; + return Task::none(); + } + use crate::modules::insert::solid3d_cmds::empty_solid3d; + use crate::scene::model::{solid_history, sweep_model}; + + let result = self.tabs[i].scene.document.get_entity(handle).and_then(|entity| { + let distance = match drag { + Some(point) => sweep_model::projected_drag(entity, pick, point)?, + None => distance, + }; + sweep_model::extruded(entity, distance) + }); + if let Some(solid) = result { + let pending = self.begin_undo(i, "PRESSPULL", 1, true); + let history = solid_history::brep_op(&solid); + let created = self.add_solid_model(empty_solid3d(), solid, history); + let _ = color; + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("PRESSPULL: solid created.").as_ref()); + if let Some(delta) = pending { + self.commit_undo_delta(i, delta); + } + } + } else { + self.command_line.push_error( + crate::t!("PRESSPULL: select a closed profile or planar solid face.") + .as_ref(), + ); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } + // ── REVOLVE ──────────────────────────────────────────────────── CmdResult::RevolveEntity { handle, axis_start, axis_end, angle_deg, - color, + color: _, } => { if self.reject_locked_edit(i, handle) { self.tabs[i].active_cmd = None; return Task::none(); } use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::{solid_model, sweep_model}; + use crate::scene::model::sweep_model; let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned(); if let Some(entity) = entity_opt { - // A line turned about the axis sweeps into a plane, a - // cylinder or a cone, and an arc into a sphere or a - // torus, so a revolved solid keeps exact geometry too. let result = sweep_model::revolved( &entity, [ @@ -3117,31 +3125,19 @@ impl OpenCADStudio { ], [axis_end.x as f64, axis_end.y as f64, axis_end.z as f64], (angle_deg as f64).to_radians(), - ) - .and_then(|body| Some((solid_model::mesh_from_solid(&body, color)?, body))); - if let Some((mesh, solid)) = result { - let history = crate::scene::model::solid_history::revolve_op( - &entity, - axis_start.to_array(), - axis_end.to_array(), - (angle_deg as f64).to_radians(), - ); + ); + if let Some(solid) = result { + let history = crate::scene::model::solid_history::brep_op(&solid); let pending = self.begin_undo(i, "REVOLVE", 1, true); - let mut s3d = empty_solid3d(); - if let acadrust::EntityType::Solid3D(inner) = &mut s3d { - inner.wires = solid_model::edge_wires(&solid); - } - let new_handle = self.tabs[i].scene.add_entity(s3d); - self.tabs[i] - .scene - .create_solid_history(new_handle, history); - self.tabs[i].scene.register_solid_model(new_handle, solid); - let _ = mesh; - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("REVOLVE: solid created ({:.0}°).", angle_deg).as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + let created = self.add_solid_model(empty_solid3d(), solid, history); + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line.push_output( + crate::tf!("REVOLVE: solid created ({:.0}°).", angle_deg).as_ref(), + ); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } else { self.command_line @@ -3160,7 +3156,7 @@ impl OpenCADStudio { CmdResult::SweepEntity { profile_handle, path_handle, - color, + color: _, } => { if self.reject_locked_edit(i, profile_handle) || self.reject_locked_edit(i, path_handle) @@ -3169,7 +3165,7 @@ impl OpenCADStudio { return Task::none(); } use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::sweep_model; + use crate::scene::model::{solid_history, sweep_model}; let profile_ent = self.tabs[i] .scene @@ -3177,39 +3173,21 @@ impl OpenCADStudio { .get_entity(profile_handle) .cloned(); let path_ent = self.tabs[i].scene.document.get_entity(path_handle).cloned(); - let history = profile_ent - .as_ref() - .zip(path_ent.as_ref()) - .map(|(profile, path)| { - crate::scene::model::solid_history::sweep_op(profile, path) - }); let result = profile_ent .zip(path_ent) - .and_then(|(profile, path)| sweep_model::swept(&profile, &path, color)); + .and_then(|(profile, path)| sweep_model::swept(&profile, &path)); - if let Some(mut set) = result { + if let Some(solid) = result { let pending = self.begin_undo(i, "SWEEP", 1, true); - let mut entity = empty_solid3d(); - if let acadrust::EntityType::Solid3D(solid) = &mut entity { - let center = set.metrics.centroid; - solid.point_of_reference = acadrust::types::Vector3::new( - center[0], center[1], center[2], - ); - } - let new_handle = self.tabs[i].scene.add_entity(entity); - if let Some(history) = history { - self.tabs[i] - .scene - .create_solid_history(new_handle, history); - } - for mesh in &mut set.lods { - mesh.name = format!("{}", new_handle.value()); - } - self.tabs[i].scene.meshes.insert(new_handle, set); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("SWEEP: solid created.").as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + let history = solid_history::brep_op(&solid); + let created = self.add_solid_model(empty_solid3d(), solid, history); + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("SWEEP: solid created.").as_ref()); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } else { self.command_line.push_error(crate::t!("SWEEP: could not sweep the profile along the path.").as_ref()); @@ -3221,7 +3199,7 @@ impl OpenCADStudio { } // ── LOFT ────────────────────────────────────────────────────── - CmdResult::LoftEntities { handles, color } => { + CmdResult::LoftEntities { handles, color: _ } => { if let Some(handle) = handles .iter() .find(|handle| self.tabs[i].scene.is_layer_locked(**handle)) @@ -3232,34 +3210,23 @@ impl OpenCADStudio { return Task::none(); } use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::sweep_model; + use crate::scene::model::{solid_history, sweep_model}; let profiles: Vec = handles .iter() .filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle).cloned()) .collect(); - if let Some(mut set) = sweep_model::lofted(&profiles, color) { - let history = crate::scene::model::solid_history::loft_op(&profiles); + if let Some(solid) = sweep_model::lofted(&profiles) { let pending = self.begin_undo(i, "LOFT", 1, true); - let mut entity = empty_solid3d(); - if let acadrust::EntityType::Solid3D(solid) = &mut entity { - let center = set.metrics.centroid; - solid.point_of_reference = acadrust::types::Vector3::new( - center[0], center[1], center[2], - ); - } - let new_handle = self.tabs[i].scene.add_entity(entity); - self.tabs[i] - .scene - .create_solid_history(new_handle, history); - for mesh in &mut set.lods { - mesh.name = format!("{}", new_handle.value()); - } - self.tabs[i].scene.meshes.insert(new_handle, set); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("LOFT: solid created.").as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + let history = solid_history::brep_op(&solid); + let created = self.add_solid_model(empty_solid3d(), solid, history); + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("LOFT: solid created.").as_ref()); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } else { self.command_line.push_error(crate::t!("LOFT: select at least two closed profiles.").as_ref()); @@ -3270,6 +3237,20 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); } + CmdResult::SolidEdgeBlend { + handle, + pick, + value, + fillet, + } => { + let task = self.solid_edge_blend(handle, pick, value, fillet); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + CmdResult::HatcheditApply { handle, name, diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 1f86f876..efc19f56 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -1,5 +1,15 @@ use super::*; +fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { + let selected = app.tabs[tab].scene.selected_handles_in_order(); + (selected.len() == 1 + && matches!( + app.tabs[tab].scene.document.get_entity(selected[0]), + Some(acadrust::EntityType::Solid3D(_)) + )) + .then_some(selected[0]) +} + impl OpenCADStudio { pub(super) fn dispatch_dim(&mut self, cmd: &str, i: usize) -> Option> { match cmd { @@ -950,6 +960,20 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "SOLIDFILLET" => { + use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; + let command = SolidEdgeCommand::new(EdgeOperation::Fillet, selected_solid(self, i)); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + + "FILLET" if selected_solid(self, i).is_some() => { + use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; + let command = SolidEdgeCommand::new(EdgeOperation::Fillet, selected_solid(self, i)); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "FILLET" => { use crate::modules::draw::modify::fillet::FilletCommand; let entities: Vec<_> = self.tabs[i] @@ -1083,6 +1107,20 @@ impl OpenCADStudio { } } + "SOLIDCHAMFER" => { + use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; + let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i)); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + + "CHAMFER" if selected_solid(self, i).is_some() => { + use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; + let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i)); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "CHAMFER" => { use crate::modules::draw::modify::fillet::ChamferCommand; let entities: Vec<_> = self.tabs[i] diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 0b3a06e9..ccf235a1 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -466,31 +466,55 @@ impl OpenCADStudio { } } - // BOX / SPHERE / CYLINDER / CONE / WEDGE / TORUS are handled by the - // Model-tab primitive command above (with the kernel boolean caching). - // ── EXTRUDE ──────────────────────────────────────────────────── - // PRESSPULL on a closed boundary creates a solid by extruding it to a - // height — the same operation as EXTRUDE. THICKEN turns a closed planar - // profile into a solid of the given thickness, which is also an extrude. - "EXTRUDE" | "PRESSPULL" | "THICKEN" => { + "EXTRUDE" | "THICKEN" => { use crate::modules::insert::solid3d_cmds::ExtrudeCommand; // If a single entity is already selected, skip the pick step. let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); if selected.len() == 1 { let handle = selected[0].0; - let mut cmd = ExtrudeCommand::new(color); - cmd.on_entity_pick(handle, glam::DVec3::ZERO); + let mut cmd = ExtrudeCommand::new_named(cmd, color); + if let Some(curve) = crate::entities::curve::entity_curve(selected[0].1) { + cmd.set_entity_pick_direction( + curve.plane.normal().map(glam::DVec3::from_array), + ); + cmd.on_entity_pick( + handle, + glam::DVec3::from_array(curve.plane.origin), + ); + } self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { - let cmd = ExtrudeCommand::new(color); + let cmd = ExtrudeCommand::new_named(cmd, color); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } } + "PRESSPULL" => { + use crate::modules::insert::solid3d_cmds::PresspullCommand; + let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); + let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); + let mut command = PresspullCommand::new(color); + if selected.len() == 1 + && !matches!(selected[0].1, acadrust::EntityType::Solid3D(_)) + { + if let Some(curve) = crate::entities::curve::entity_curve(selected[0].1) { + command.set_entity_pick_direction( + curve.plane.normal().map(glam::DVec3::from_array), + ); + command.on_entity_pick( + selected[0].0, + glam::DVec3::from_array(curve.plane.origin), + ); + } + } + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + // ── REVOLVE ──────────────────────────────────────────────────── "REVOLVE" => { use crate::modules::insert::solid3d_cmds::RevolveCommand; diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 085cce33..1a12febf 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -925,14 +925,15 @@ impl OpenCADStudio { } // ── Model commands (3D primitives) ───────────────────────────── - "BOX" | "WEDGE" | "CYLINDER" | "CONE" | "SPHERE" | "TORUS" => { + "BOX" | "WEDGE" | "CYLINDER" | "CONE" | "SPHERE" | "PYRAMID" | "PYR" + | "TORUS" => { use crate::modules::model::primitive_cmd::PrimitiveCommand; let new_cmd = PrimitiveCommand::new(cmd); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - // ── Design commands (solid booleans) ─────────────────────────── + // ── Solid booleans ───────────────────────────────────────────── "UNION" | "SUBTRACT" | "INTERSECT" => { use crate::modules::model::boolean_cmd::BoolOp; if let Some(op) = BoolOp::from_id(cmd) { @@ -1033,17 +1034,7 @@ impl OpenCADStudio { } } - // PYRAMID [sides] — create an n-sided pyramid mesh. - "PYRAMID" | "PYR" => { - use crate::command::TwoValuePromptCommand; - let c = TwoValuePromptCommand::new( - "PYRAMID", - "PYRAMID base radius:", - "PYRAMID height (add sides by typing a 3rd number):", - ); - self.command_line.push_info(&c.prompt()); - self.tabs[i].active_cmd = Some(Box::new(c)); - } + // PYRAMID [sides] keeps the direct numeric form. cmd if cmd.starts_with("PYRAMID ") || cmd.starts_with("PYR ") => { let nums: Vec = cmd .split_whitespace() diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index a279e5e7..1be644f6 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -1,15 +1,9 @@ -// 3D solid modelling support on the App: committing Model-tab primitives, -// and the Design-group boolean operations over the scene's cached -// B-reps. -// -// The bodies come from the geometry kernel, so every operation here is on -// analytic surfaces rather than on facets — a turned cylinder is still a -// cylinder afterwards, and saves as one. +// Kernel B-rep solid modelling and exact ACIS persistence. -use acadrust::entities::Solid3D; -use acadrust::objects::SolidHistoryOperation; +use acadrust::{ + entities::Solid3D, objects::SolidHistoryOperation, EntityType, Handle, +}; use cadkernel::brep::Body; -use acadrust::{EntityType, Handle}; use iced::Task; use super::Message; @@ -18,16 +12,25 @@ use crate::scene::model::solid_history; use crate::scene::model::solid_model::{self, Bool}; impl super::OpenCADStudio { - /// Commit a Model-tab solid: add its acadrust entity to the document, then - /// register the B-rep (caches it for booleans + tessellates it into the - /// shaded mesh pipeline). Returns the new entity handle. + /// Add a solid and register its persistent B-rep. pub(super) fn add_solid_model( &mut self, - entity: EntityType, + mut entity: EntityType, solid: Body, history: SolidHistoryOperation, ) -> Handle { let i = self.active_tab; + let EntityType::Solid3D(inner) = &mut entity else { + return Handle::NULL; + }; + inner.wires = solid_model::edge_wires(&solid); + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&solid) + else { + self.command_line + .push_error(crate::t!("The solid could not be encoded as ACIS.").as_ref()); + return Handle::NULL; + }; + inner.set_sat_document(&document); let Some(handle) = self.commit_entity_handle(entity) else { return Handle::NULL; }; @@ -55,35 +58,183 @@ impl super::OpenCADStudio { handles } - /// Run a boolean (`union` / `subtract` / `intersect`) on exactly two - /// selected solids whose B-reps can be restored by the kernel. + fn replace_solid_body(&mut self, handle: Handle, result: Body, label: &str) -> bool { + let i = self.active_tab; + let Some(document) = + crate::scene::convert::acis_export::solid_to_sat(&result) + else { + self.command_line + .push_error(crate::t!("The result could not be encoded as ACIS.").as_ref()); + return false; + }; + let Some(EntityType::Solid3D(mut entity)) = + self.tabs[i].scene.document.get_entity(handle).cloned() + else { + return false; + }; + entity.wires = solid_model::edge_wires(&result); + entity.silhouettes.clear(); + entity.history_handle = None; + entity.set_sat_document(&document); + + self.push_undo_snapshot(i, label); + self.tabs[i].scene.delete_solid_history(handle); + if !self.tabs[i] + .scene + .update_entity(EntityType::Solid3D(entity)) + { + self.command_line + .push_error(crate::t!("The solid could not be updated.").as_ref()); + return false; + } + let history = solid_history::brep_op(&result); + self.tabs[i].scene.create_solid_history(handle, history); + self.tabs[i].scene.register_solid_model(handle, result); + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].dirty = true; + self.refresh_properties(); + true + } + + pub(super) fn solid_edge_blend( + &mut self, + handle: Handle, + pick: glam::DVec3, + value: f64, + fillet: bool, + ) -> Task { + let i = self.active_tab; + if self.reject_locked_edit(i, handle) { + return Task::none(); + } + if !matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(EntityType::Solid3D(_)) + ) { + self.command_line + .push_error(crate::t!("Select a 3D solid edge.").as_ref()); + return Task::none(); + } + self.tabs[i].scene.restore_solid_models(&[handle]); + let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else { + self.command_line + .push_error(crate::t!("The solid geometry could not be restored.").as_ref()); + return Task::none(); + }; + let Some(edge) = solid_model::nearest_edge(&body, pick.to_array()) else { + self.command_line + .push_error(crate::t!("Select a solid edge.").as_ref()); + return Task::none(); + }; + let result = if fillet { + cadkernel::brep::fillet(&body, edge, value) + } else { + cadkernel::brep::chamfer(&body, edge, value) + }; + let Some(result) = result else { + self.command_line.push_error( + crate::t!("Edge operation failed. Use a convex planar solid and a smaller value.") + .as_ref(), + ); + return Task::none(); + }; + let label = if fillet { "SOLIDFILLET" } else { "SOLIDCHAMFER" }; + if self.replace_solid_body(handle, result, label) { + self.command_line + .push_output(crate::tf!("{label}: solid updated.").as_ref()); + } + Task::none() + } + + pub(super) fn solid_face_presspull( + &mut self, + handle: Handle, + pick: glam::DVec3, + distance: f64, + drag: Option, + ) -> Task { + let i = self.active_tab; + if self.reject_locked_edit(i, handle) { + return Task::none(); + } + self.tabs[i].scene.restore_solid_models(&[handle]); + let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else { + self.command_line + .push_error(crate::t!("The solid geometry could not be restored.").as_ref()); + return Task::none(); + }; + let Some(face) = solid_model::nearest_planar_face(&body, pick.to_array()) else { + self.command_line + .push_error(crate::t!("Select a planar solid face.").as_ref()); + return Task::none(); + }; + let distance = match drag { + Some(point) => { + let Some(normal) = solid_model::planar_face_normal(&body, face) else { + self.command_line + .push_error(crate::t!("Select a planar solid face.").as_ref()); + return Task::none(); + }; + (point - pick).dot(glam::DVec3::from_array(normal)) + } + None => distance, + }; + if !distance.is_finite() || distance.abs() <= 1e-6 { + self.command_line.push_error( + crate::t!("PRESSPULL: drag along the selected face normal.").as_ref(), + ); + return Task::none(); + } + let Some(result) = cadkernel::brep::presspull(&body, face, distance) else { + self.command_line.push_error( + crate::t!("PRESSPULL: the face move would collapse or invalidate the solid.") + .as_ref(), + ); + return Task::none(); + }; + if self.replace_solid_body(handle, result, "PRESSPULL") { + self.command_line + .push_output(crate::t!("PRESSPULL: solid updated.").as_ref()); + } + Task::none() + } + + /// Run a boolean over the selected solids in selection order. pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task { let i = self.active_tab; let handles = self.selected_solid_handles(); - if handles.len() != 2 { + if handles.len() < 2 { self.command_line - .push_error(crate::t!("Boolean: select exactly two solids created this session.").as_ref()); + .push_error(crate::t!("Boolean: select at least two solids.").as_ref()); return Task::none(); } - let a = self.tabs[i].scene.solid_models[&handles[0]].clone(); - let b = self.tabs[i].scene.solid_models[&handles[1]].clone(); let kind = match op { BoolOp::Union => Bool::Union, BoolOp::Subtract => Bool::Subtract, BoolOp::Intersect => Bool::Intersect, }; - let Some(result) = solid_model::boolean(kind, &a, &b) else { + let mut operands = handles + .iter() + .filter_map(|handle| self.tabs[i].scene.solid_models.get(handle).cloned()); + let mut result = operands.next().expect("at least two restored solids"); + for operand in operands { + let Some(combined) = solid_model::boolean(kind, &result, &operand) else { + self.command_line.push_error( + crate::t!("Boolean failed while combining the selected solids.").as_ref(), + ); + return Task::none(); + }; + result = combined; + } + if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() { self.command_line - .push_error(crate::t!("Boolean failed — the solids may not overlap.").as_ref()); + .push_error(crate::t!("The boolean result could not be encoded as ACIS.").as_ref()); return Task::none(); - }; + } self.push_undo_snapshot(i, "BOOLEAN"); - // Remove the two operands (entity + mesh + cached B-rep). self.tabs[i].scene.erase_entities(&handles); - // The result is freshly combined geometry with no ACIS parametrisation, - // so it lives as a Solid3D whose render/boolean data is the injected - // mesh + cached B-rep; its edge wires make it pickable. let mut s3d = Solid3D::new(); s3d.wires = solid_model::edge_wires(&result); let history = solid_history::brep_op(&result); @@ -100,7 +251,7 @@ impl super::OpenCADStudio { /// Slice the one selected solid with an axis-aligned plane (axis 0/1/2 = /// X/Y/Z at `value`), keeping the lower side when `keep_low` is true. The /// kept half is the intersection of the solid with a half-space box, reusing - /// the same boolean path the Design-group tools use. + /// the same boolean path as the modelling tools. pub(super) fn solid_slice(&mut self, axis: usize, value: f64, keep_low: bool) -> Task { let i = self.active_tab; let handles = self.selected_solid_handles(); @@ -243,63 +394,34 @@ impl super::OpenCADStudio { Task::none() } - /// POLYSOLID — build a wall-like solid from a selected polyline: one box per - /// segment (oriented along it, `width` wide, `height` tall) unioned together, - /// reusing the box primitive + the boolean union path. + /// POLYSOLID — build a wall solid around an exact polyline offset. pub(super) fn solid_polysolid(&mut self, width: f64, height: f64) -> Task { let i = self.active_tab; - let found: Option<(Handle, Vec<[f64; 3]>)> = self.tabs[i] + let found: Option<(Handle, EntityType)> = self.tabs[i] .scene .selected_entities() .iter() .filter(|(h, _)| !self.tabs[i].scene.is_layer_locked(*h)) .find_map(|(h, e)| match e { - EntityType::LwPolyline(_) => crate::entities::curve::entity_curve(e) - .map(|curve| (*h, crate::entities::curve::curve_points(&curve))), + EntityType::LwPolyline(_) => Some((*h, (*e).clone())), _ => None, }); - let Some((handle, pts)) = found else { + let Some((handle, entity)) = found else { self.command_line .push_error(crate::t!("POLYSOLID: select a polyline first.").as_ref()); return Task::none(); }; - let segs: Vec<([f64; 3], [f64; 3])> = pts.windows(2).map(|w| (w[0], w[1])).collect(); - let mut acc: Option = None; - let mut used = 0usize; - for (a, b) in &segs { - let (dx, dy) = (b[0] - a[0], b[1] - a[1]); - let len = (dx * dx + dy * dy).sqrt(); - if len < 1e-9 { - continue; - } - let angle = dy.atan2(dx); - // Local box: X 0..len, Y -w/2..w/2, Z 0..h, then turned to lie - // along the segment and moved onto its start. - let (sin, cos) = angle.sin_cos(); - let Some(bx) = solid_model::box_solid([len / 2.0, 0.0, height / 2.0], len, width, height) - .and_then(|bx| { - solid_model::placed( - &bx, - [cos, sin, 0.0], - [-sin, cos, 0.0], - [0.0, 0.0, 1.0], - [a[0], a[1], a[2]], - ) - }) - else { - continue; - }; - acc = Some(match acc { - None => bx, - Some(prev) => solid_model::boolean(Bool::Union, &prev, &bx).unwrap_or(prev), - }); - used += 1; - } - let Some(result) = acc else { + let Some(result) = crate::scene::model::sweep_model::polysolid(&entity, width, height) + else { self.command_line - .push_error(crate::t!("POLYSOLID: the polyline has no usable segments.").as_ref()); + .push_error(crate::t!("POLYSOLID: the kernel could not offset the polyline.").as_ref()); return Task::none(); }; + if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() { + self.command_line + .push_error(crate::t!("POLYSOLID: the result could not be encoded as ACIS.").as_ref()); + return Task::none(); + } self.push_undo_snapshot(i, "POLYSOLID"); self.tabs[i].scene.erase_entities(&[handle]); let mut s3d = Solid3D::new(); @@ -310,11 +432,14 @@ impl super::OpenCADStudio { if !h.is_null() { self.tabs[i].scene.select_entity(h, false); } - self.tabs[i].dirty = true; - self.refresh_properties(); - self.command_line.push_output(crate::tf!( - "POLYSOLID: built a wall solid from {used} segment(s) (width {width}, height {height})." - ).as_ref()); + if !h.is_null() { + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + crate::tf!("POLYSOLID: created a wall solid (width {width}, height {height}).") + .as_ref(), + ); + } Task::none() } @@ -507,12 +632,12 @@ impl super::OpenCADStudio { self.tabs[i].scene.deselect_all(); if !handle.is_null() { self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output(crate::tf!( + "PYRAMID: created a {n}-sided pyramid (radius {radius}, height {height})." + ).as_ref()); } - self.tabs[i].dirty = true; - self.refresh_properties(); - self.command_line.push_output(crate::tf!( - "PYRAMID: created a {n}-sided pyramid (radius {radius}, height {height})." - ).as_ref()); Task::none() } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 5ea4f4e4..8615be0e 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -258,7 +258,7 @@ impl OpenCADStudio { let body = scene.solid_models.get(&h); let sat = needs_acis .then(|| { - body.and_then(crate::scene::convert::acis_export::planar_solid_to_sat) + body.and_then(crate::scene::convert::acis_export::solid_to_sat) }) .flatten(); let wires = needs_wires.then(|| { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 2409b6a2..379eddbf 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3454,6 +3454,38 @@ impl OpenCADStudio { })? }); if let Some(handle) = hit { + let uses_surface_point = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.entity_pick_uses_surface_point()); + let entity_pick_point = if uses_surface_point { + self.tabs[i] + .scene + .solid_click_point_for(p, view_rot2, eye2, bounds, handle) + .unwrap_or(pick_wcs) + } else { + pick_wcs + }; + let entity_pick_direction = if uses_surface_point { + if matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Solid3D(_)) + ) { + self.tabs[i] + .scene + .solid_planar_face_normal_at(handle, entity_pick_point) + } else { + self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(crate::entities::curve::entity_curve) + .and_then(|curve| curve.plane.normal()) + .map(glam::DVec3::from_array) + } + } else { + None + }; // Some commands (e.g. SS_CATCHMENT) need the entity // body before `on_entity_pick` can advance. let inject_first = self.tabs[i] @@ -3484,7 +3516,8 @@ impl OpenCADStudio { let result = self.tabs[i].active_cmd.as_mut().map(|c| { // Shift-swap state for TRIM/EXTEND (#336). c.set_shift(shift); - c.on_entity_pick(handle, pick_wcs) + c.set_entity_pick_direction(entity_pick_direction); + c.on_entity_pick(handle, entity_pick_point) }); // HATCHEDIT: after pick, inject hatch model data into the command. if self.tabs[i] @@ -4011,8 +4044,7 @@ impl OpenCADStudio { ) }) .or_else(|| { - // Block-internal hatch: resolve to the - // parent Insert (AutoCAD behaviour). + // Block-internal hatch: resolve to the parent Insert. scene::pick::hit_test::click_hit_insert_hatch( p, self.tabs[i].scene.insert_hatches_for_click().as_ref(), diff --git a/src/command.rs b/src/command.rs index 7cc6382e..bf5b43d3 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1425,17 +1425,20 @@ pub enum CmdResult { /// Translation vector applied once to every selected point. delta: DVec3, }, - /// Create a Solid3D placeholder entity + associated MeshModel. - /// `mesh_fn` is called with the entity's handle string to build the mesh. - CommitSolid3D { - mesh_fn: Box Option + Send>, - }, - /// Extrude the profile entity `handle` by `height` along Z. + /// Extrude the profile entity `handle` along its plane normal. ExtrudeEntity { handle: Handle, height: f64, color: [f32; 4], }, + /// Pull a closed profile or a planar solid face by a signed distance. + PresspullEntity { + handle: Handle, + pick: DVec3, + distance: f64, + drag: Option, + color: [f32; 4], + }, /// Revolve the profile entity `handle` around the given axis by `angle_deg`. RevolveEntity { handle: Handle, @@ -1455,6 +1458,13 @@ pub enum CmdResult { handles: Vec, color: [f32; 4], }, + /// Round or bevel the straight edge nearest `pick` on a solid. + SolidEdgeBlend { + handle: Handle, + pick: DVec3, + value: f64, + fillet: bool, + }, /// INSERT landed on a block that has AttributeDefinitions. /// The host should look up the attdefs for `block_name` from the document /// and call `attreq_set_attdefs()` on the command, then loop on text input. @@ -1767,6 +1777,14 @@ pub trait CadCommand: Send { false } + /// Supply the mesh surface hit instead of the working-plane projection. + fn entity_pick_uses_surface_point(&self) -> bool { + false + } + + /// Supply the picked surface or profile direction when available. + fn set_entity_pick_direction(&mut self, _direction: Option) {} + /// Render the entity under the cursor through the normal rollover /// highlight while this command is waiting for an entity pick. fn entity_pick_highlights_hover(&self) -> bool { diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index a2902bd6..3a7fce6f 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3,6 +3,15 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static str)> { match source { + "Boolean" => Some(("model", "boolean")), + "Create" => Some(("model", "create")), + "Extrude" => Some(("model", "extrude")), + "Loft" => Some(("model", "loft")), + "Polysolid" => Some(("model", "polysolid")), + "Presspull" => Some(("model", "presspull")), + "Pyramid" => Some(("model", "pyramid")), + "Revolve" => Some(("model", "revolve")), + "Sweep" => Some(("model", "sweep")), " {:?}" => Some(("view", "message")), " (none — use: SHORTCUTS SET )" => Some(("common", "none-use-shortcuts-set-key-command")), " Alignment:" => Some(("common", "alignment-indented-label-title-case")), diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 95c9a619..db2898da 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -1,27 +1,19 @@ -// 2D→3D modelling commands — EXTRUDE, REVOLVE, SWEEP, LOFT. -// -// Each picks profile/path entities and emits a `CmdResult` whose handler -// builds a solid and inserts its MeshModel into scene.meshes under a -// minimal placeholder Solid3D entity (empty ACIS — saving will not restore -// the mesh). The standalone primitives (BOX/CYLINDER/CONE/SPHERE/WEDGE/TORUS) -// live in the Model tab (`modules::model::primitive_cmd`). +// Profile-based kernel solid commands stored as exact ACIS data. use acadrust::{entities::Solid3D, EntityType}; use glam::DVec3; -use crate::t; use crate::command::{CadCommand, CmdResult}; - -// BOX / SPHERE / CYLINDER (and CONE / WEDGE / TORUS) now live in the Model tab -// (`modules::model::primitive_cmd`), which builds them as B-reps cached -// for the Design-group boolean tools. EXTRUDE / REVOLVE / SWEEP / LOFT remain -// here as 2D→3D operations. +use crate::t; // ── EXTRUDE command ──────────────────────────────────────────────────────── pub struct ExtrudeCommand { + command_name: &'static str, step: ExtrudeStep, pub target_handle: acadrust::Handle, + anchor: DVec3, + direction: Option, color: [f32; 4], } @@ -32,10 +24,16 @@ enum ExtrudeStep { } impl ExtrudeCommand { - pub fn new(color: [f32; 4]) -> Self { + pub fn new_named(name: &str, color: [f32; 4]) -> Self { Self { + command_name: match name { + "THICKEN" => "THICKEN", + _ => "EXTRUDE", + }, step: ExtrudeStep::Pick, target_handle: acadrust::Handle::NULL, + anchor: DVec3::ZERO, + direction: None, color, } } @@ -43,7 +41,7 @@ impl ExtrudeCommand { impl CadCommand for ExtrudeCommand { fn name(&self) -> &'static str { - "EXTRUDE" + self.command_name } fn prompt(&self) -> String { match self.step { @@ -55,19 +53,33 @@ impl CadCommand for ExtrudeCommand { fn needs_entity_pick(&self) -> bool { self.step == ExtrudeStep::Pick } - fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: DVec3) -> CmdResult { + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + fn set_entity_pick_direction(&mut self, direction: Option) { + self.direction = direction.and_then(DVec3::try_normalize); + } + fn on_entity_pick(&mut self, handle: acadrust::Handle, point: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } self.target_handle = handle; + self.anchor = point; self.step = ExtrudeStep::Height; CmdResult::NeedPoint } fn on_point(&mut self, pt: DVec3) -> CmdResult { if self.step == ExtrudeStep::Height { + let height = self + .direction + .map(|direction| (pt - self.anchor).dot(direction)) + .unwrap_or_else(|| pt.distance(self.anchor)); + if !height.is_finite() || height.abs() <= 1e-6 { + return CmdResult::NeedPoint; + } return CmdResult::ExtrudeEntity { handle: self.target_handle, - height: pt.y.abs().max(1e-4), + height, color: self.color, }; } @@ -81,13 +93,151 @@ impl CadCommand for ExtrudeCommand { .filter(|&h| h.abs() > 1e-6) .map(|h| CmdResult::ExtrudeEntity { handle: self.target_handle, - height: h.abs(), + height: h, color: self.color, }) } fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } + fn cursor_axis(&self) -> Option<(DVec3, DVec3)> { + (self.step == ExtrudeStep::Height).then_some((self.anchor, self.direction?)) + } + fn dyn_spec(&self) -> Option { + (self.step == ExtrudeStep::Height).then_some(crate::command::DynSpec { + anchor: crate::command::DynAnchor::Point(self.anchor), + fields: vec![crate::command::DynFieldSpec::new( + crate::command::DynRole::Distance, + )], + guide: crate::command::DynGuide::Radius, + ref_point: None, + }) + } + fn dyn_live_value(&self, cursor: DVec3) -> Option { + Some((cursor - self.anchor).dot(self.direction?)) + } +} + +// ── PRESSPULL command ───────────────────────────────────────────────────── + +pub struct PresspullCommand { + picked: Option<(acadrust::Handle, DVec3)>, + direction: Option, + color: [f32; 4], +} + +impl PresspullCommand { + pub fn new(color: [f32; 4]) -> Self { + Self { + picked: None, + direction: None, + color, + } + } +} + +impl CadCommand for PresspullCommand { + fn name(&self) -> &'static str { + "PRESSPULL" + } + + fn prompt(&self) -> String { + if self.picked.is_none() { + t!("PRESSPULL Select a closed profile or planar solid face:").into_owned() + } else { + t!("PRESSPULL Signed distance:").into_owned() + } + } + + fn needs_entity_pick(&self) -> bool { + self.picked.is_none() + } + + fn entity_pick_includes_fills(&self) -> bool { + true + } + + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + + fn set_entity_pick_direction(&mut self, direction: Option) { + self.direction = direction.and_then(|value| value.try_normalize()); + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: acadrust::Handle, point: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + self.picked = Some((handle, point)); + CmdResult::NeedPoint + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let Some((handle, pick)) = self.picked else { + return CmdResult::NeedPoint; + }; + let distance = self + .direction + .map(|direction| (point - pick).dot(direction)) + .unwrap_or_else(|| point.distance(pick)); + if !distance.is_finite() || distance.abs() <= 1e-6 { + return CmdResult::NeedPoint; + } + CmdResult::PresspullEntity { + handle, + pick, + distance, + drag: Some(point), + color: self.color, + } + } + + fn wants_text_input(&self) -> bool { + self.picked.is_some() + } + + fn on_text_input(&mut self, text: &str) -> Option { + let (handle, pick) = self.picked?; + crate::entities::common::parse_typed_length(text) + .filter(|distance| distance.abs() > 1e-6) + .map(|distance| CmdResult::PresspullEntity { + handle, + pick, + distance, + drag: None, + color: self.color, + }) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn cursor_axis(&self) -> Option<(DVec3, DVec3)> { + Some((self.picked?.1, self.direction?)) + } + + fn dyn_spec(&self) -> Option { + let (_, anchor) = self.picked?; + Some(crate::command::DynSpec { + anchor: crate::command::DynAnchor::Point(anchor), + fields: vec![crate::command::DynFieldSpec::new( + crate::command::DynRole::Distance, + )], + guide: crate::command::DynGuide::Radius, + ref_point: None, + }) + } + + fn dyn_live_value(&self, cursor: DVec3) -> Option { + let (_, anchor) = self.picked?; + Some((cursor - anchor).dot(self.direction?)) + } } // ── REVOLVE command ──────────────────────────────────────────────────────── @@ -171,7 +321,7 @@ impl CadCommand for RevolveCommand { .ok() .filter(|&a| a.abs() > 1e-3)? }; - Some(self.make_revolve(angle.abs())) + Some(self.make_revolve(angle)) } fn on_enter(&mut self) -> CmdResult { if self.step == RevolveStep::Angle { @@ -323,16 +473,16 @@ impl CadCommand for LoftCommand { } } -// ── Placeholder Solid3D entity construction ──────────────────────────────── +// ── Solid3D entity construction ──────────────────────────────────────────── -/// Create a minimal Solid3D entity with empty ACIS data (placeholder only). pub fn empty_solid3d() -> EntityType { EntityType::Solid3D(Solid3D::new()) } - // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["EXTRUDE"] }); // ExtrudeCommand -inventory::submit!(crate::command::CommandRegistration { names: &["LOFT"] }); // LoftCommand -inventory::submit!(crate::command::CommandRegistration { names: &["REVOLVE"] }); // RevolveCommand -inventory::submit!(crate::command::CommandRegistration { names: &["SWEEP"] }); // SweepCommand +inventory::submit!(crate::command::CommandRegistration { + names: &["EXTRUDE", "THICKEN", "PRESSPULL"] +}); +inventory::submit!(crate::command::CommandRegistration { names: &["LOFT"] }); +inventory::submit!(crate::command::CommandRegistration { names: &["REVOLVE"] }); +inventory::submit!(crate::command::CommandRegistration { names: &["SWEEP"] }); diff --git a/src/modules/model/boolean_cmd.rs b/src/modules/model/boolean_cmd.rs index 44a29a08..da1bb10b 100644 --- a/src/modules/model/boolean_cmd.rs +++ b/src/modules/model/boolean_cmd.rs @@ -1,8 +1,8 @@ -// Boolean operations on 3D solids (Design group). The actual CSG runs in +// Boolean operations on 3D solids. The actual CSG runs in // `App::solid_boolean` (src/app/model_ops.rs) using the kernel on the // cached B-reps; this module just names the operations. -/// Which boolean a Design-group tool performs on the two selected solids. +/// Which boolean operation to apply to selected solids. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum BoolOp { Union, diff --git a/src/modules/model/edge_cmd.rs b/src/modules/model/edge_cmd.rs new file mode 100644 index 00000000..65ee2d31 --- /dev/null +++ b/src/modules/model/edge_cmd.rs @@ -0,0 +1,146 @@ +use acadrust::Handle; +use glam::DVec3; + +use crate::command::{ + CadCommand, CmdResult, DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec, +}; + +#[derive(Clone, Copy)] +pub enum EdgeOperation { + Fillet, + Chamfer, +} + +pub struct SolidEdgeCommand { + operation: EdgeOperation, + target: Option, + picked: Option<(Handle, DVec3)>, + default_value: f64, +} + +impl SolidEdgeCommand { + pub fn new(operation: EdgeOperation, target: Option) -> Self { + Self { + operation, + target, + picked: None, + default_value: 1.0, + } + } + + fn finish(&self, value: f64) -> CmdResult { + let Some((handle, pick)) = self.picked else { + return CmdResult::Cancel; + }; + CmdResult::SolidEdgeBlend { + handle, + pick, + value, + fillet: matches!(self.operation, EdgeOperation::Fillet), + } + } +} + +impl CadCommand for SolidEdgeCommand { + fn name(&self) -> &'static str { + match self.operation { + EdgeOperation::Fillet => "SOLIDFILLET", + EdgeOperation::Chamfer => "SOLIDCHAMFER", + } + } + + fn prompt(&self) -> String { + if self.picked.is_none() { + return crate::t!("Select a solid edge:").into_owned(); + } + match self.operation { + EdgeOperation::Fillet => { + crate::tf!("Specify fillet radius <{:.3}>:", self.default_value).into_owned() + } + EdgeOperation::Chamfer => { + crate::tf!("Specify chamfer distance <{:.3}>:", self.default_value).into_owned() + } + } + } + + fn needs_entity_pick(&self) -> bool { + self.picked.is_none() + } + + fn entity_pick_includes_fills(&self) -> bool { + true + } + + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if handle.is_null() || self.target.is_some_and(|target| target != handle) { + return CmdResult::NeedPoint; + } + self.picked = Some((handle, point)); + CmdResult::NeedPoint + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let Some((_, pick)) = self.picked else { + return CmdResult::NeedPoint; + }; + let value = point.distance(pick); + if value > 0.0 && value.is_finite() { + self.finish(value) + } else { + CmdResult::NeedPoint + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.picked.is_none() { + return None; + } + let value = text.trim().parse::().ok()?; + (value > 0.0 && value.is_finite()).then(|| self.finish(value)) + } + + fn on_enter(&mut self) -> CmdResult { + if self.picked.is_some() { + self.finish(self.default_value) + } else { + CmdResult::Cancel + } + } + + fn wants_text_input(&self) -> bool { + self.picked.is_some() + } + + fn dyn_commit_as_text(&self) -> bool { + self.picked.is_some() + } + + fn dyn_spec(&self) -> Option { + let (_, pick) = self.picked?; + Some(DynSpec { + anchor: DynAnchor::Point(pick), + fields: vec![DynFieldSpec::new(match self.operation { + EdgeOperation::Fillet => DynRole::Radius, + EdgeOperation::Chamfer => DynRole::Distance, + })], + guide: DynGuide::Radius, + ref_point: None, + }) + } + + fn dyn_live_value(&self, cursor: DVec3) -> Option { + self.picked.map(|(_, pick)| cursor.distance(pick)) + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["SOLIDFILLET", "SOLIDCHAMFER"] +}); diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index 08f6f6f5..5272161a 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -1,11 +1,7 @@ -// Model module — 3D solid modelling. -// -// Model group : create primitive solids (box, cylinder, cone, sphere, …) -// as ACIS Solid3D entities via acadrust's primitive builders. -// Design group : combine solids with the kernel boolean operations -// (union / subtract / intersect). +// Solid creation and kernel modelling tools. pub mod boolean_cmd; +pub mod edge_cmd; pub mod primitive_cmd; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; @@ -16,11 +12,20 @@ const BOX_ICON: &[u8] = include_bytes!("../../../assets/icons/box3d.svg"); const CYLINDER_ICON: &[u8] = include_bytes!("../../../assets/icons/cylinder3d.svg"); const CONE_ICON: &[u8] = include_bytes!("../../../assets/icons/cone3d.svg"); const SPHERE_ICON: &[u8] = include_bytes!("../../../assets/icons/sphere3d.svg"); +const PYRAMID_ICON: &[u8] = include_bytes!("../../../assets/icons/pyramid3d.svg"); const WEDGE_ICON: &[u8] = include_bytes!("../../../assets/icons/wedge3d.svg"); const TORUS_ICON: &[u8] = include_bytes!("../../../assets/icons/torus3d.svg"); +const POLYSOLID_ICON: &[u8] = include_bytes!("../../../assets/icons/polysolid.svg"); +const EXTRUDE_ICON: &[u8] = include_bytes!("../../../assets/icons/extrude.svg"); +const REVOLVE_ICON: &[u8] = include_bytes!("../../../assets/icons/revolve.svg"); +const LOFT_ICON: &[u8] = include_bytes!("../../../assets/icons/loft.svg"); +const SWEEP_ICON: &[u8] = include_bytes!("../../../assets/icons/sweep.svg"); +const PRESSPULL_ICON: &[u8] = include_bytes!("../../../assets/icons/presspull.svg"); const UNION_ICON: &[u8] = include_bytes!("../../../assets/icons/union.svg"); const SUBTRACT_ICON: &[u8] = include_bytes!("../../../assets/icons/subtract.svg"); const INTERSECT_ICON: &[u8] = include_bytes!("../../../assets/icons/intersect.svg"); +const FILLET_ICON: &[u8] = include_bytes!("../../../assets/icons/fillet.svg"); +const CHAMFER_ICON: &[u8] = include_bytes!("../../../assets/icons/chamfer.svg"); /// Helper to declare a ribbon tool that fires a named command. fn tool(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef { @@ -37,7 +42,7 @@ impl CadModule for ModelModule { "model" } fn title(&self) -> &'static str { - "Model" + "Modelling" } fn ribbon_groups(&self) -> &[RibbonGroup] { @@ -45,31 +50,46 @@ impl CadModule for ModelModule { GROUPS.get_or_init(|| { vec![ RibbonGroup { - title: "Model", + title: "Create", tools: vec![ - RibbonItem::LargeTool(tool("BOX", "Box", BOX_ICON)), - RibbonItem::LargeTool(tool("CYLINDER", "Cylinder", CYLINDER_ICON)), - RibbonItem::LargeTool(tool("CONE", "Cone", CONE_ICON)), - RibbonItem::LargeTool(tool("SPHERE", "Sphere", SPHERE_ICON)), - RibbonItem::Dropdown { - id: "MODEL_MORE", - icon: IconKind::Svg(WEDGE_ICON), + RibbonItem::LargeDropdown { + id: "MODEL_PRIMITIVES", + label: "Box", + icon: IconKind::Svg(BOX_ICON), items: vec![ + ("BOX", "Box", IconKind::Svg(BOX_ICON)), + ("CYLINDER", "Cylinder", IconKind::Svg(CYLINDER_ICON)), + ("CONE", "Cone", IconKind::Svg(CONE_ICON)), + ("SPHERE", "Sphere", IconKind::Svg(SPHERE_ICON)), + ("PYRAMID", "Pyramid", IconKind::Svg(PYRAMID_ICON)), ("WEDGE", "Wedge", IconKind::Svg(WEDGE_ICON)), ("TORUS", "Torus", IconKind::Svg(TORUS_ICON)), + ("POLYSOLID", "Polysolid", IconKind::Svg(POLYSOLID_ICON)), ], - default: "WEDGE", + default: "BOX", }, + RibbonItem::LargeTool(tool("EXTRUDE", "Extrude", EXTRUDE_ICON)), + RibbonItem::LargeTool(tool("REVOLVE", "Revolve", REVOLVE_ICON)), + RibbonItem::LargeTool(tool("LOFT", "Loft", LOFT_ICON)), + RibbonItem::LargeTool(tool("SWEEP", "Sweep", SWEEP_ICON)), + RibbonItem::LargeTool(tool("PRESSPULL", "Presspull", PRESSPULL_ICON)), ], }, RibbonGroup { - title: "Design", + title: "Boolean", tools: vec![ RibbonItem::LargeTool(tool("UNION", "Union", UNION_ICON)), RibbonItem::LargeTool(tool("SUBTRACT", "Subtract", SUBTRACT_ICON)), RibbonItem::LargeTool(tool("INTERSECT", "Intersect", INTERSECT_ICON)), ], }, + RibbonGroup { + title: "Edges", + tools: vec![ + RibbonItem::LargeTool(tool("SOLIDFILLET", "Fillet", FILLET_ICON)), + RibbonItem::LargeTool(tool("SOLIDCHAMFER", "Chamfer", CHAMFER_ICON)), + ], + }, ] }) } diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index 66941b70..0d43d22b 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -1,22 +1,15 @@ -// 3D primitive creation — BOX / CYLINDER / CONE / SPHERE / WEDGE / PYRAMID / -// TORUS. Each is placed CAD-style with a few clicks (planar footprint first, -// then a height value), then built as a real ACIS `Solid3D` via acadrust's -// `acis::primitives` builders. `Scene::add_entity` tessellates the SAT B-rep -// into the 3D mesh pipeline, so the solid renders, selects, and saves to DXF. -// -// A matching the kernel `Solid` is cached on the scene (see model/mod.rs) when the -// entity is committed, so the Design-group boolean tools can combine it. +// Interactive kernel primitives stored as ACIS Solid3D entities. use acadrust::entities::Solid3D; use acadrust::objects::SolidHistoryOperation; -use acadrust::{primitives, EntityType}; -use glam::DVec3; -use crate::t; +use acadrust::EntityType; use cadkernel::brep::Body; +use glam::DVec3; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::scene::model::solid_model; use crate::scene::model::wire_model::WireModel; +use crate::t; /// Which primitive a `PrimitiveCommand` builds. #[derive(Clone, Copy, PartialEq, Eq)] @@ -26,6 +19,7 @@ pub enum Shape { Cylinder, Cone, Sphere, + Pyramid, Torus, } @@ -37,6 +31,7 @@ impl Shape { "CYLINDER" => Shape::Cylinder, "CONE" => Shape::Cone, "SPHERE" => Shape::Sphere, + "PYRAMID" | "PYR" => Shape::Pyramid, "TORUS" => Shape::Torus, _ => return None, }) @@ -48,6 +43,7 @@ impl Shape { Shape::Cylinder => "CYLINDER", Shape::Cone => "CONE", Shape::Sphere => "SPHERE", + Shape::Pyramid => "PYRAMID", Shape::Torus => "TORUS", } } @@ -129,12 +125,11 @@ impl PrimitiveCommand { .to_cols_array() } - /// Build both the acadrust `Solid3D` (ACIS, for persistence) and the - /// kernel `Body` (rendering + booleans) from the footprint + `height`. - fn build(&self, height: f64) -> Option<(EntityType, Body, SolidHistoryOperation)> { + /// Build the persistent kernel body and its history node. + fn build(&self, height: f64) -> Option<(Body, SolidHistoryOperation)> { use crate::scene::model::solid_history; - let (doc, solid, history) = match self.shape { + let (solid, history) = match self.shape { Shape::Box | Shape::Wedge => { let (a, b) = (self.pts[0], self.pts[1]); let length = (b.x - a.x).abs(); @@ -150,7 +145,6 @@ impl PrimitiveCommand { a.z + height / 2.0, ]; ( - primitives::build_box(center, length, width, height), solid_model::box_solid(center, length, width, height), solid_history::box_op( self.history_transform(origin), @@ -162,7 +156,6 @@ impl PrimitiveCommand { } else { let origin = [a.x.min(b.x), a.y.min(b.y), a.z]; ( - primitives::build_wedge(origin, length, width, height), solid_model::wedge_solid(origin, length, width, height), solid_history::wedge_op( self.history_transform(DVec3::from_array(origin)), @@ -182,7 +175,6 @@ impl PrimitiveCommand { let center = [c.x, c.y, c.z]; if self.shape == Shape::Cylinder { ( - primitives::build_cylinder(center, r, height), solid_model::cylinder_solid(center, r, height), solid_history::cylinder_op( self.history_transform(c), @@ -192,7 +184,6 @@ impl PrimitiveCommand { ) } else { ( - primitives::build_cone(center, r, height), solid_model::cone_solid(center, r, height), solid_history::cone_op( self.history_transform(c), @@ -210,11 +201,21 @@ impl PrimitiveCommand { } let center = [c.x, c.y, c.z]; ( - primitives::build_sphere(center, r), solid_model::sphere_solid(center, r), solid_history::sphere_op(self.history_transform(c), r), ) } + Shape::Pyramid => { + let c = self.pts[0]; + let r = (self.pts[1] - c).length(); + if r < 1e-6 || height < 1e-6 { + return None; + } + ( + solid_model::pyramid_solid([c.x, c.y, c.z], r, height, 4), + solid_history::pyramid_op(self.history_transform(c), r, height, 4), + ) + } Shape::Torus => { let c = self.pts[0]; let first = (self.pts[1] - c).length(); @@ -228,7 +229,6 @@ impl PrimitiveCommand { let minor = (outer - inner) * 0.5; let center = [c.x, c.y, c.z]; ( - primitives::build_torus(center, major, minor), solid_model::torus_solid(center, major, minor), solid_history::torus_op( self.history_transform(c), @@ -239,14 +239,12 @@ impl PrimitiveCommand { } }; let solid = solid?; - let mut s3d = Solid3D::new(); - s3d.set_sat_document(&doc); - Some((EntityType::Solid3D(s3d), solid, history)) + Some((solid, history)) } fn commit(&self, height: f64) -> CmdResult { match self.build(height) { - Some((entity, solid, history)) => { + Some((solid, history)) => { // Built upright in its own frame, then put on the working // plane — the same move `place_entity` makes for the ACIS // copy, so the two stay on top of each other. @@ -262,11 +260,21 @@ impl PrimitiveCommand { ], ); match placed { - Some(placed) => CmdResult::CommitSolid { - entity: self.plane.place_entity(entity), - solid: Box::new(placed), - history, - }, + Some(placed) => { + let Some(document) = + crate::scene::convert::acis_export::solid_to_sat(&placed) + else { + return CmdResult::Cancel; + }; + let mut entity = Solid3D::new(); + entity.set_sat_document(&document); + entity.wires = solid_model::edge_wires(&placed); + CmdResult::CommitSolid { + entity: EntityType::Solid3D(entity), + solid: Box::new(placed), + history, + } + } None => CmdResult::Cancel, } } @@ -390,7 +398,15 @@ impl CadCommand for PrimitiveCommand { fn footprint_wire(shape: Shape, pts: &[DVec3]) -> WireModel { let mut points: Vec<[f32; 3]> = Vec::new(); - if shape.radial() { + if shape == Shape::Pyramid { + let center = pts[0]; + let radius = (pts[1] - center).length(); + let corners: [DVec3; 4] = std::array::from_fn(|index| { + let angle = index as f64 * std::f64::consts::FRAC_PI_2; + center + DVec3::new(radius * angle.cos(), radius * angle.sin(), 0.0) + }); + push_loop(&mut points, &corners); + } else if shape.radial() { let c = pts[0]; let r = (pts[1] - c).length(); circle_points(&mut points, c, r); @@ -446,9 +462,21 @@ fn height_wire(shape: Shape, pts: &[DVec3], height: f64) -> WireModel { push_segment(&mut points, low[i], high[i]); } } - Shape::Cylinder | Shape::Cone => { + Shape::Cylinder | Shape::Cone | Shape::Pyramid => { let center = pts[0]; let radius = (pts[1] - center).length(); + if shape == Shape::Pyramid { + let base: [DVec3; 4] = std::array::from_fn(|index| { + let angle = index as f64 * std::f64::consts::FRAC_PI_2; + center + DVec3::new(radius * angle.cos(), radius * angle.sin(), 0.0) + }); + push_loop(&mut points, &base); + let apex = center + DVec3::Z * height; + for corner in base { + push_segment(&mut points, corner, apex); + } + return wire("primitive_height_preview", points); + } push_circle(&mut points, center, radius); if shape == Shape::Cylinder { push_circle(&mut points, center + DVec3::Z * height, radius); @@ -506,7 +534,7 @@ fn circle_points(out: &mut Vec<[f32; 3]>, c: DVec3, r: f64) { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { - names: &["BOX", "WEDGE", "CYLINDER", "CONE", "SPHERE", "TORUS"] + names: &["BOX", "WEDGE", "CYLINDER", "CONE", "SPHERE", "PYRAMID", "PYR", "TORUS"] }); fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel { @@ -522,9 +550,9 @@ fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel { render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), name: name.into(), points, points_low: Vec::new(), diff --git a/src/scene/convert/acis_export.rs b/src/scene/convert/acis_export.rs index 0f522478..48e3730c 100644 --- a/src/scene/convert/acis_export.rs +++ b/src/scene/convert/acis_export.rs @@ -3,12 +3,21 @@ //! Analytic surfaces remain analytic instead of becoming facets. use cadkernel::acis::append; -use acadrust::entities::acis::SatDocument; +use acadrust::entities::acis::{SabReader, SabWriter, SatDocument}; use cadkernel::brep::Body; /// Returns `None` when the body contains an unsupported record form. -pub fn planar_solid_to_sat(body: &Body) -> Option { +pub fn solid_to_sat(body: &Body) -> Option { let mut document = SatDocument::new(); append(body, &mut document).ok()?; - Some(document) + let document = SatDocument::parse(&document.to_sat_string()).ok()?; + let valid = |candidate: &SatDocument| { + let (restored, loss) = cadkernel::acis::lift(candidate); + loss.is_empty() && restored.len() == 1 && restored[0].validate().is_empty() + }; + if !valid(&document) { + return None; + } + let binary = SabWriter::write(&document); + valid(&SabReader::read(&binary).ok()?).then_some(document) } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d887e77e..b22dde38 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -518,10 +518,8 @@ pub(crate) fn wire_gpu_patch_enabled() -> bool { /// Resolve a viewport's paper-to-model scale ratio from its two /// DXF-derived sources. /// -/// `view_height` (model-space view extent) is the canonical source — it -/// is what AutoCAD actually uses to draw, and what we keep in sync on -/// every write. `custom_scale` is consulted only when `view_height` is -/// missing or zero (some third-party exporters omit it). +/// `view_height` is canonical. `custom_scale` is the fallback when it is +/// missing or zero. #[inline] pub fn vp_effective_scale(custom_scale: f64, view_height: f64, vp_height: f64) -> f64 { if view_height.abs() > 1e-9 { @@ -2945,8 +2943,8 @@ impl Scene { } /// Guarantee that a paper layout has its full-screen overall (`id == 1`) - /// sheet viewport. AutoCAD always writes one, and `add_layout` creates it, - /// but this is a safety net for layouts that arrive without it. The sheet + /// sheet viewport. `add_layout` creates it; this is a safety net for layouts + /// that arrive without it. The sheet /// viewport is the authoritative paper-space view and the canvas every /// floating viewport overlays. pub fn ensure_sheet_viewport(&mut self, layout_name: &str) { @@ -3016,9 +3014,8 @@ impl Scene { let mut vp = acadrust::entities::Viewport::new(); vp.id = 1; vp.status = acadrust::entities::ViewportStatusFlags::default_on(); - // Paper-space center is a 2D (x, y) point with z = 0 — the same - // convention MVIEW uses for floating viewports. AutoCAD/TrueView read - // the viewport center as (x, y); putting the paper-height midpoint in z + // Paper-space center is a 2D (x, y) point with z = 0. Putting the + // paper-height midpoint in z // (with y = 0) left the sheet view centered at y = 0, shifting the whole // layout half a page down. See issue #156. vp.center = acadrust::types::Vector3::new( @@ -3242,8 +3239,7 @@ impl Scene { } /// Dashed rectangle marking the printable area — the paper inset by the - /// layout's plot margins. AutoCAD draws this guide on every layout; with the - /// margins now preserved we can reflect it too. `None` in model space, when + /// layout's plot margins. `None` in model space, when /// the layout has no margins, or when the inset would be degenerate. pub(super) fn printable_area_wire(&self) -> Option { if self.current_layout == "Model" { @@ -3311,8 +3307,7 @@ impl Scene { /// The effective plot settings for the current layout: a standalone /// PlotSettings page setup if one exists, otherwise the settings embedded in /// the LAYOUT object (paper size, margins, origin, rotation, scale). Loaded - /// AutoCAD files keep their settings embedded, so without this fallback the - /// plot/PDF path would ignore the file's rotation, origin and scale. + /// The fallback preserves rotation, origin and scale from loaded files. pub fn effective_plot_settings(&self) -> Option { self.plot_settings_for(&self.current_layout) } @@ -7974,6 +7969,44 @@ impl Scene { ) } + pub fn solid_click_point_for( + &self, + cursor: iced::Point, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + target: Handle, + ) -> Option { + let meshes = self.interaction_meshes_arc(); + pick::hit_test::mesh_click_point( + cursor, + meshes.iter().filter_map(|set| { + (set.entity_handle()? == target).then_some(())?; + let mesh = set.geometry_lods().first()?; + Some(( + target, + mesh, + set.instance_transform, + mesh_interaction_aabb(set)?, + )) + }), + view_rot, + eye, + bounds, + ) + } + + pub fn solid_planar_face_normal_at( + &mut self, + handle: Handle, + pick: glam::DVec3, + ) -> Option { + self.restore_solid_models(&[handle]); + let body = self.solid_models.get(&handle)?; + let face = model::solid_model::nearest_planar_face(body, pick.to_array())?; + model::solid_model::planar_face_normal(body, face).map(glam::DVec3::from_array) + } + pub fn view_center_surface_pivot( &self, bounds: iced::Rectangle, @@ -9374,9 +9407,7 @@ impl Scene { if any { return Some((min, max)); } - // Last-resort: the header's saved EXTMIN/EXTMAX. AutoCAD writes these - // on save so opening a file gives ZOOM EXTENTS a useful answer before - // the wire cache is built. + // Last resort: saved EXTMIN/EXTMAX before the wire cache is built. const SANE_EXTENT: f64 = 1.0e16; let h = &self.document.header; let hmin = h.model_space_extents_min; diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index bd143587..9879bd2d 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1,11 +1,9 @@ -use acadrust::entities::{EmbeddedEntity, Solid3D}; +use acadrust::entities::Solid3D; use acadrust::objects::{ - SolidHistoryBox, SolidHistoryBrep, SolidHistoryCylinder, SolidHistoryLoft, + SolidHistoryBox, SolidHistoryBrep, SolidHistoryCylinder, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryPyramid, - SolidHistoryRevolve, SolidHistorySphere, SolidHistorySweep, SolidHistoryTorus, + SolidHistorySphere, SolidHistoryTorus, }; -use acadrust::types::{Vector2, Vector3}; -use acadrust::EntityType; use cadkernel::brep::Body; use crate::command::EntityTransform; @@ -474,21 +472,6 @@ fn base(transform: [f64; 16]) -> SolidHistoryNodeBase { base } -fn embedded(entity: &EntityType) -> Option { - Some(match entity { - EntityType::Point(value) => EmbeddedEntity::Point(value.clone()), - EntityType::Line(value) => EmbeddedEntity::Line(value.clone()), - EntityType::Arc(value) => EmbeddedEntity::Arc(value.clone()), - EntityType::Circle(value) => EmbeddedEntity::Circle(value.clone()), - EntityType::Ellipse(value) => EmbeddedEntity::Ellipse(value.clone()), - EntityType::Spline(value) => EmbeddedEntity::Spline(value.clone()), - EntityType::LwPolyline(value) => EmbeddedEntity::LwPolyline(value.clone()), - EntityType::Ray(value) => EmbeddedEntity::Ray(value.clone()), - EntityType::XLine(value) => EmbeddedEntity::XLine(value.clone()), - _ => return None, - }) -} - pub fn box_op( transform: [f64; 16], length: f64, @@ -593,7 +576,7 @@ pub fn pyramid_op( } pub fn brep_op(body: &Body) -> SolidHistoryOperation { - let acis_data = crate::scene::convert::acis_export::planar_solid_to_sat(body) + let acis_data = crate::scene::convert::acis_export::solid_to_sat(body) .map(|document| { let mut solid = Solid3D::new(); solid.set_sat_document(&document); @@ -607,56 +590,3 @@ pub fn brep_op(body: &Body) -> SolidHistoryOperation { ..SolidHistoryBrep::default() }) } - -pub fn extrusion_op(profile: &EntityType, height: f64) -> SolidHistoryOperation { - SolidHistoryOperation::Extrusion(SolidHistorySweep { - base: base(glam::DMat4::IDENTITY.to_cols_array()), - operation_major: 1, - direction: Vector3::new(0.0, 0.0, height), - sweep_entity: embedded(profile), - scale_factor: 1.0, - sweep_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), - path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), - ..SolidHistorySweep::default() - }) -} - -pub fn sweep_op(profile: &EntityType, path: &EntityType) -> SolidHistoryOperation { - SolidHistoryOperation::Sweep(SolidHistorySweep { - base: base(glam::DMat4::IDENTITY.to_cols_array()), - operation_major: 1, - sweep_entity: embedded(profile), - path_entity: embedded(path), - scale_factor: 1.0, - sweep_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), - path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), - ..SolidHistorySweep::default() - }) -} - -pub fn loft_op(profiles: &[EntityType]) -> SolidHistoryOperation { - SolidHistoryOperation::Loft(SolidHistoryLoft { - base: base(glam::DMat4::IDENTITY.to_cols_array()), - operation_major: 1, - cross_sections: profiles.iter().filter_map(embedded).collect(), - ..SolidHistoryLoft::default() - }) -} - -pub fn revolve_op( - profile: &EntityType, - axis_start: [f64; 3], - axis_end: [f64; 3], - angle: f64, -) -> SolidHistoryOperation { - let direction = glam::DVec3::from_array(axis_end) - glam::DVec3::from_array(axis_start); - SolidHistoryOperation::Revolve(SolidHistoryRevolve { - base: base(glam::DMat4::IDENTITY.to_cols_array()), - operation_major: 1, - axis_point: Vector3::new(axis_start[0], axis_start[1], axis_start[2]), - direction: Vector2::new(direction.x, direction.y), - revolve_angle: angle, - sweep_entity: embedded(profile), - ..SolidHistoryRevolve::default() - }) -} diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 15d2fa11..b229981c 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -1,19 +1,6 @@ -// B-rep construction for the Model tab's primitives, plus tessellation into -// the renderer's `MeshLodSet`. -// -// The bodies come from the geometry kernel, which builds each primitive the -// way ACIS records it: an analytic surface with singular vertices where the -// surface has them, rather than a mesh or a spline that happens to look like -// one. That is what lets a solid built here be written back out as exact -// geometry instead of a facetted approximation — see `acis_bridge`. -// -// Everything is oriented Z-up with the footprint on the z = base plane, to -// match acadrust's `acis::primitives`. -// -// The resulting `Body` is cached per entity handle on the Scene so the -// Design-group boolean tools can run on it. +// Kernel B-rep construction and display tessellation. -use cadkernel::brep::{self, Body}; +use cadkernel::brep::{self, Body, Curve3, EdgeKey, FaceKey, Surface}; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; @@ -223,6 +210,65 @@ pub fn edge_wires(body: &Body) -> Vec { .collect() } +/// B-rep edge nearest a world-space surface pick. +pub fn nearest_edge(body: &Body, pick: [f64; 3]) -> Option { + let pick = cadkernel::space::Vec3::from(pick); + body.edge_keys() + .filter_map(|key| { + let edge = body.edges.get(key)?; + let curve = body.curves.get(edge.curve)?; + let nearest = if matches!(curve, Curve3::Line(_)) { + let start = cadkernel::space::Vec3::from(curve.point_at(edge.start_parameter)); + let end = cadkernel::space::Vec3::from(curve.point_at(edge.end_parameter)); + let span = end - start; + let length2 = span.dot(span); + let along = if length2 > 0.0 { + (pick - start).dot(span) / length2 + } else { + 0.0 + } + .clamp(0.0, 1.0); + pick.distance(start + span * along) + } else { + (0..=64) + .map(|step| { + let t = edge.start_parameter + + (edge.end_parameter - edge.start_parameter) * step as f64 / 64.0; + pick.distance(cadkernel::space::Vec3::from(curve.point_at(t))) + }) + .fold(f64::INFINITY, f64::min) + }; + Some((key, nearest)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(key, _)| key) +} + +/// Planar face nearest a world-space surface pick. +pub fn nearest_planar_face(body: &Body, pick: [f64; 3]) -> Option { + let face = body + .face_keys() + .filter_map(|key| { + let face = body.faces.get(key)?; + let surface = body.surfaces.get(face.surface)?; + Some((key, surface.distance_to(pick).abs())) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(key, _)| key)?; + let node = body.faces.get(face)?; + matches!(body.surfaces.get(node.surface)?, Surface::Plane(_)).then_some(face) +} + +/// Outward normal of a planar face. +pub fn planar_face_normal(body: &Body, face: FaceKey) -> Option<[f64; 3]> { + let face = body.faces.get(face)?; + let Surface::Plane(plane) = body.surfaces.get(face.surface)? else { + return None; + }; + let normal = cadkernel::space::Vec3::from(plane.normal()?); + Some(if face.forward { normal } else { -normal }.to_array()) +} + // ── Boolean operations ────────────────────────────────────────────────────── /// Which CSG to apply. Mirrors `model::boolean_cmd::BoolOp` but kept local so @@ -246,17 +292,12 @@ pub fn boolean(op: Bool, a: &Body, b: &Body) -> Option { Bool::Subtract => brep::Operation::Difference, Bool::Intersect => brep::Operation::Intersection, }; - brep::combine(a.clone(), b.clone(), how, TOL).ok() + let tolerance = brep::operation_tolerance(&[a, b]); + brep::combine(a.clone(), b.clone(), how, tolerance).ok() } // ── Tessellation ──────────────────────────────────────────────────────────── -/// Tessellate a `Body` into a single-LOD `MeshLodSet` (world-space, before -/// world_offset is applied by the caller). -pub fn mesh_from_solid(body: &Body, color: [f32; 4]) -> Option { - mesh_from_tessellation(tessellation(body), color) -} - fn mesh_from_tessellation( tessellation: brep::mesh::BodyMesh, color: [f32; 4], diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index f79e048c..124e53fd 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -1,20 +1,8 @@ -// EXTRUDE / REVOLVE: turning a drawn profile into a solid. -// -// The profile comes from `entities::curve`, which is where every entity's -// geometry is already defined once, so a circle, an arc-bulged polyline and a -// closed spline all arrive as the same thing: a plane plus a chain of curves -// in that plane's coordinates. The kernel sweeps that chain into analytic -// surfaces — a straight run into a plane, an arc into a cylinder, a profile -// turned about an axis into a cone, a sphere or a torus — so the result can -// be written back out as ACIS rather than as facets. -// -// A spline profile has no analytic side wall, so it is refused rather than -// approximated into a surface nobody asked for. That is the kernel's answer -// and this module passes it on. +// Exact profile sweeps stored as kernel B-reps and ACIS. use cadkernel::brep::{self, Body}; -use cadkernel::geom2d::Curve; -use cadkernel::space::{PlanarCurve, Plane}; +use cadkernel::geom2d::{offset_polyline, Arc, Curve, EllipseArc, Line, Polyline}; +use cadkernel::space::{PlanarCurve, Plane, Vec3}; use acadrust::EntityType; use crate::entities::curve::entity_curve; @@ -40,13 +28,17 @@ pub fn profile_of(entity: &EntityType) -> Option { return None; } let pieces = match &planar.curve { - // A polyline is already a chain. Its own segments carry the bulges, - // so an arc stays an arc rather than becoming a run of chords. Curve::Polyline(_) => planar.curve.segments(), - // Everything else closed on itself is cut into quarters, which is - // both the fewest pieces a chain may have and the fewest that leave - // each one unambiguous about which way round it goes. Curve::Circle(circle) => quarters(circle.centre, circle.radius), + Curve::Arc(arc) => split_arc(*arc), + Curve::Ellipse(arc) => split_ellipse(*arc), + Curve::Nurbs(curve) => (0..4) + .map(|part| { + curve + .trimmed(part as f64 / 4.0, (part + 1) as f64 / 4.0) + .map(Curve::Nurbs) + }) + .collect::>>()?, _ => return None, }; (pieces.len() >= 3).then_some(Profile { @@ -57,7 +49,6 @@ pub fn profile_of(entity: &EntityType) -> Option { /// A circle as four arcs. fn quarters(centre: [f64; 2], radius: f64) -> Vec { - use cadkernel::geom2d::Arc; use std::f64::consts::FRAC_PI_2; (0..4) .map(|quarter| { @@ -72,6 +63,34 @@ fn quarters(centre: [f64; 2], radius: f64) -> Vec { .collect() } +fn split_arc(arc: Arc) -> Vec { + let start = arc.start_angle; + let step = arc.sweep() / 4.0; + (0..4) + .map(|part| { + Curve::Arc(Arc { + start_angle: start + step * part as f64, + end_angle: start + step * (part + 1) as f64, + ..arc + }) + }) + .collect() +} + +fn split_ellipse(arc: EllipseArc) -> Vec { + let start = arc.start_parameter; + let step = arc.sweep() / 4.0; + (0..4) + .map(|part| { + Curve::Ellipse(EllipseArc { + ellipse: arc.ellipse, + start_parameter: start + step * part as f64, + end_parameter: start + step * (part + 1) as f64, + }) + }) + .collect() +} + /// EXTRUDE: drag the profile `height` along its own plane's normal. /// /// `None` for a profile that does not close, encloses nothing, or holds a @@ -86,6 +105,13 @@ pub fn extruded(entity: &EntityType, height: f64) -> Option { ) } +/// Signed distance of a drag along a profile's normal. +pub fn projected_drag(entity: &EntityType, from: glam::DVec3, to: glam::DVec3) -> Option { + let normal = entity_curve(entity)?.plane.normal()?; + let distance = (to - from).dot(glam::DVec3::from_array(normal)); + (distance.is_finite() && distance.abs() > 1e-6).then_some(distance) +} + /// REVOLVE: turn the profile about the axis from `from` to `to` by `angle` /// radians. /// @@ -108,85 +134,191 @@ pub fn revolved( ) } -use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; - -/// SWEEP through the kernel's tolerance-driven mesh API. -pub fn swept(profile: &EntityType, path: &EntityType, color: [f32; 4]) -> Option { - let max_angle = cadkernel::tessellation::DEFAULT_ANGLE; - let surface = brep::mesh::sweep_surface( - &entity_curve(profile)?, - &entity_curve(path)?, - max_angle, - )?; - mesh_set(surface, color, 1e-9) +/// SWEEP as an exact B-rep along straight and circular path pieces. +pub fn swept(profile: &EntityType, path: &EntityType) -> Option { + let profile = profile_of(profile)?; + let path = entity_curve(path)?; + let pieces = match &path.curve { + Curve::Line(line) => vec![Curve::Line(*line)], + Curve::Arc(arc) => vec![Curve::Arc(*arc)], + Curve::Polyline(_) => path.curve.segments(), + _ => return None, + }; + brep::sweep_along(profile.plane, &profile.pieces, path.plane, &pieces) } -/// LOFT through the kernel's tolerance-driven mesh API. -pub fn lofted(profiles: &[EntityType], color: [f32; 4]) -> Option { - let curves: Vec = profiles.iter().filter_map(entity_curve).collect(); - let max_angle = cadkernel::tessellation::DEFAULT_ANGLE; - mesh_set(brep::mesh::loft_surface(&curves, max_angle)?, color, 1e-9) +/// LOFT as an exact B-rep through compatible closed sections. +pub fn lofted(profiles: &[EntityType]) -> Option { + if let Some(body) = circular_loft(profiles) { + return Some(body); + } + let sections = profiles + .iter() + .map(|entity| { + let profile = profile_of(entity)?; + Some((profile.plane, profile.pieces)) + }) + .collect::>>()?; + brep::loft(§ions) } -fn mesh_set( - surface: brep::mesh::SurfaceMesh, - color: [f32; 4], - precision: f64, -) -> Option { - if surface.mesh.is_empty() { +/// Builds an exact wall solid around a polyline centreline. +pub fn polysolid(entity: &EntityType, width: f64, height: f64) -> Option { + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height.abs() <= 1e-12 { return None; } - let mut verts = Vec::with_capacity(surface.mesh.positions.len()); - let mut verts_low = Vec::with_capacity(surface.mesh.positions.len()); - for point in &surface.mesh.positions { - push_point(&mut verts, &mut verts_low, *point); + let planar = entity_curve(entity)?; + let Curve::Polyline(source) = planar.curve else { + return None; + }; + let (left_pick, right_pick) = offset_picks(&source, width)?; + let mut left = offset_polyline(&source, width * 0.5, left_pick); + let mut right = offset_polyline(&source, width * 0.5, right_pick); + if left.len() != 1 || right.len() != 1 { + return None; } - let silhouette = surface.silhouette_source(precision); - let mut set = MeshLodSet::from_single(MeshModel { - name: String::new(), - verts, - verts_low, - normals: surface - .mesh - .normals - .iter() - .map(|normal| [normal[0] as f32, normal[1] as f32, normal[2] as f32]) - .collect(), - indices: surface - .mesh - .triangles - .iter() - .flatten() - .map(|index| *index as u32) - .collect(), - triangle_material_handles: Vec::new(), - triangle_colors: Vec::new(), - color, - selected: false, - }); - { - let (high, low) = (&mut set.edge_verts, &mut set.edge_verts_low); - for edge in surface.edges { - for segment in edge.windows(2) { - for point in segment { - push_point(high, low, *point); - } - } + let left = left.remove(0); + let right = right.remove(0); + let normal = Vec3::from(planar.plane.normal()?); + let direction = (normal * height).to_array(); + + if source.closed { + if !left.closed || !right.closed { + return None; } + let mut profiles = [left, right] + .into_iter() + .map(|polyline| { + let area = Curve::Polyline(polyline.clone()).enclosed_area().abs(); + (area, Curve::Polyline(polyline).segments()) + }) + .collect::>(); + profiles.sort_by(|first, second| second.0.total_cmp(&first.0)); + if profiles[0].0 <= profiles[1].0 || profiles[1].0 <= 1e-12 { + return None; + } + return brep::extrude_region( + planar.plane, + &[profiles.remove(0).1, profiles.remove(0).1], + direction, + ); } - set.curved_gens - .push(super::mesh_model::CurvedGen { source: silhouette }); - Some(set) + + if left.closed || right.closed { + return None; + } + let left_start = left.vertices.first()?.position; + let left_end = left.vertices.last()?.position; + let right_start = right.vertices.first()?.position; + let right_end = right.vertices.last()?.position; + let mut outline = Curve::Polyline(left).segments(); + outline.push(Curve::Line(Line { + start: left_end, + end: right_end, + })); + let mut back = Curve::Polyline(right).segments(); + back.reverse(); + outline.extend(back); + outline.push(Curve::Line(Line { + start: right_start, + end: left_start, + })); + brep::extrude(planar.plane, &outline, direction) } -fn push_point(high: &mut Vec<[f32; 3]>, low: &mut Vec<[f32; 3]>, point: [f64; 3]) { - let coarse = [point[0] as f32, point[1] as f32, point[2] as f32]; - high.push(coarse); - low.push([ - (point[0] - coarse[0] as f64) as f32, - (point[1] - coarse[1] as f64) as f32, - (point[2] - coarse[2] as f64) as f32, - ]); +fn offset_picks(polyline: &Polyline, width: f64) -> Option<([f64; 2], [f64; 2])> { + let start = polyline.vertices.first()?.position; + let next = polyline.vertices.get(1)?.position; + let along = if let Some(arc) = polyline.segment_arc(0) { + let near = arc.sample(1e-6); + [near[0] - start[0], near[1] - start[1]] + } else { + [next[0] - start[0], next[1] - start[1]] + }; + let length = along[0].hypot(along[1]); + if length <= 1e-12 { + return None; + } + let side = [-along[1] / length * width, along[0] / length * width]; + Some(( + [start[0] + side[0], start[1] + side[1]], + [start[0] - side[0], start[1] - side[1]], + )) +} + +fn circular_loft(profiles: &[EntityType]) -> Option { + if profiles.len() < 2 { + return None; + } + let sections = profiles + .iter() + .map(|entity| { + let planar = entity_curve(entity)?; + let Curve::Circle(circle) = planar.curve else { + return None; + }; + Some(( + Vec3::from(planar.plane.point_at(circle.centre)), + circle.radius, + Vec3::from(planar.plane.normal()?), + Vec3::from(planar.plane.x_axis), + )) + }) + .collect::>>()?; + let first = sections.first()?; + let direction = (sections.last()?.0 - first.0).normalize()?; + let scale = sections + .iter() + .map(|(centre, radius, _, _)| centre.length().max(*radius)) + .fold(1.0_f64, f64::max); + let tolerance = scale * 1e-9; + let mut heights = Vec::with_capacity(sections.len()); + for (centre, radius, normal, _) in §ions { + if !radius.is_finite() + || *radius <= tolerance + || normal.dot(direction).abs() < 1.0 - 1e-9 + { + return None; + } + let offset = *centre - first.0; + if (offset - direction * offset.dot(direction)).length() > tolerance { + return None; + } + heights.push(offset.dot(direction)); + } + if heights + .windows(2) + .any(|pair| pair[1] <= pair[0] + tolerance) + { + return None; + } + let radial_seed = first.3 - direction * first.3.dot(direction); + let radial = radial_seed.normalize()?; + let profile_plane = Plane::from_axes(first.0.to_array(), radial.to_array(), direction.to_array()); + let mut points = Vec::with_capacity(sections.len() + 2); + points.push([0.0, 0.0]); + points.extend( + sections + .iter() + .zip(&heights) + .map(|((_, radius, _, _), height)| [*radius, *height]), + ); + points.push([0.0, *heights.last()?]); + let profile = (0..points.len()) + .map(|index| { + Curve::Line(Line { + start: points[index], + end: points[(index + 1) % points.len()], + }) + }) + .collect::>(); + brep::revolve( + profile_plane, + &profile, + first.0.to_array(), + direction.to_array(), + std::f64::consts::TAU, + ) } #[cfg(test)] diff --git a/src/scene/modify.rs b/src/scene/modify.rs index c215ef12..95892900 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -807,7 +807,7 @@ impl Scene { let Ok(body) = cadkernel::acis::rebuild_body(&operation) else { return false; }; - let Some(document) = crate::scene::convert::acis_export::planar_solid_to_sat(&body) else { + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { return false; }; self.record_solid_history_before(handle); @@ -833,7 +833,7 @@ impl Scene { let Ok(body) = cadkernel::acis::rebuild_body(&operation) else { return false; }; - let Some(document) = crate::scene::convert::acis_export::planar_solid_to_sat(&body) else { + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { return false; }; let Some(EntityType::Solid3D(entity)) = self.document.get_entity_mut(handle) else {