diff --git a/Cargo.lock b/Cargo.lock index 6a6a295b9..6fa08c29a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,7 +80,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=7b4c112bd7895fe9039514c21c5b5aa59eaf2525#7b4c112bd7895fe9039514c21c5b5aa59eaf2525" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=f23ffc873228e5f41b876485c741e4a8ac20a67d#f23ffc873228e5f41b876485c741e4a8ac20a67d" dependencies = [ "ahash 0.8.12", "anyhow", @@ -895,7 +895,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=8efd43cb2f04577d20c3ee33ce5273bb49affa3d#8efd43cb2f04577d20c3ee33ce5273bb49affa3d" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dfd0de176a878c20a1aeb8d23b0b8265197b5f5c#dfd0de176a878c20a1aeb8d23b0b8265197b5f5c" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index a692cec04..85dfe881b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,8 +33,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "8efd43cb2f04577d20c3ee33ce5273bb49affa3d", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dfd0de176a878c20a1aeb8d23b0b8265197b5f5c", 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/crates/ocs_doc_api/ARCHITECTURE.md b/crates/ocs_doc_api/ARCHITECTURE.md index 708f1b77b..7d4a34f73 100644 --- a/crates/ocs_doc_api/ARCHITECTURE.md +++ b/crates/ocs_doc_api/ARCHITECTURE.md @@ -25,3 +25,11 @@ vocabulary, not a reflected binary codec schema. To extend the API, update the DTO, backend, executor, facade and spec together, add a native behavior test, and rebuild the generated reference/schema. New backend methods must reject unsupported behavior explicitly. + +Extension workflow: + +1. Add the operation/query DTO and its name mapping, then update `spec/entities.toml`. +2. Implement validation, backend preparation, executor dispatch and facade methods. +3. Preserve each profile's plane and use cadkernel for geometry. +4. Test native behavior, failed-operation atomicity and the binding transport payload. +5. Run `cargo test -p ocs_doc_api --all-features` and rebuild the generated snapshots. diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 27ea492e4..592effe98 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -14,8 +14,8 @@ bincode = "1.3" thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "8efd43cb2f04577d20c3ee33ce5273bb49affa3d", features = ["acis", "offset"], optional = true } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dfd0de176a878c20a1aeb8d23b0b8265197b5f5c", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index f04715b36..2433e9c6b 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 = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -38,7 +38,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 = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 9ff671577..dbd456778 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 = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index 888cc84eb..68b944b56 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 0a39768ce..fe5d8961f 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1388,6 +1388,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = يتحكم في مربع التحديد المرئي ومنطقة النقر معًا. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = أوضاع التحديد + .use-shift-to-add-to-selection-pickadd = استخدام Shift للإضافة إلى التحديد (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = الضغط والسحب يرسم مستطيلاً بدلاً من التحديد الحر (PICKDRAG) + .show-selection-effect-selectioneffect = إظهار تأثير التحديد (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = معاينة التحديد عندما لا يكون هناك أمر نشط + .preview-selection-during-a-command = معاينة التحديد أثناء تنفيذ أمر + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = يبرز الكائن الموجود تحت المؤشر قبل تحديده (SELECTIONPREVIEW). + .object-limit-for-grips = حد الكائنات لعرض المقابض + .unlimited = بلا حد + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = عند تجاوز هذا العدد من الكائنات المحددة لا تُرسم المقابض؛ القيمة 0 تلغي الحد (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = إظهار منطقة التحديد بتعبئة شفافة (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = شفافية منطقتي التحديد بالنافذة والتقاطع (SELECTIONAREAOPACITY). @@ -1666,7 +1676,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: لا توجد حالات محفوظة. استخدم LAYERSTATE SAVE <الاسم>. .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = الاستخدام: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = الاستخدام: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: يتطلب DONUTID قيمة محدودة أكبر من أو تساوي الصفر. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: يتطلب DONUTOD قيمة محدودة أكبر من الصفر. diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 6e7a791ec..01b8c9683 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1375,6 +1375,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Управлява видимото поле за избор и областта за щракване. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = Режими на избор + .use-shift-to-add-to-selection-pickadd = Използвай Shift за добавяне към избора (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Натискане и влачене чертае правоъгълник вместо ласо (PICKDRAG) + .show-selection-effect-selectioneffect = Показвай ефекта на избора (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Преглед на избора, когато няма активна команда + .preview-selection-during-a-command = Преглед на избора по време на команда + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = Осветява обекта под курсора, преди да бъде избран (SELECTIONPREVIEW). + .object-limit-for-grips = Ограничение за обекти при манипулаторите + .unlimited = Без ограничение + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = Над този брой избрани обекти манипулатори не се чертаят; 0 премахва ограничението (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Показване на областта за избор с прозрачно запълване (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Прозрачност на областите за избор с прозорец и пресичане (SELECTIONAREAOPACITY). @@ -1653,7 +1663,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: няма записани състояния. Използвайте LAYERSTATE SAVE <име>. .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Употреба: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Употреба: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTID изисква крайна стойност, по-голяма или равна на нула. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTOD изисква крайна стойност, по-голяма от нула. diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index fe39582e9..1c4332caa 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1375,6 +1375,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Ovládá viditelný výběrový rámeček i oblast kliknutí. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = Režimy výběru + .use-shift-to-add-to-selection-pickadd = Použít Shift pro přidání do výběru (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Tažení kreslí obdélník místo lasa (PICKDRAG) + .show-selection-effect-selectioneffect = Zobrazit efekt výběru (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Náhled výběru, když není aktivní žádný příkaz + .preview-selection-during-a-command = Náhled výběru během příkazu + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = Zvýrazní objekt pod kurzorem před jeho výběrem (SELECTIONPREVIEW). + .object-limit-for-grips = Limit objektů pro uzly + .unlimited = Bez omezení + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = Nad tímto počtem vybraných objektů se uzly nekreslí; 0 limit ruší (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Zobrazit oblast výběru průhlednou výplní (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Průhlednost oblastí výběru oknem a křížením (SELECTIONAREAOPACITY). @@ -1653,7 +1663,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: žádné uložené stavy. Použijte LAYERSTATE SAVE . .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Použití: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Použití: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTID vyžaduje konečnou hodnotu větší nebo rovnou nule. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTOD vyžaduje konečnou hodnotu větší než nula. diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index e3f676f6a..42781eee3 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1376,6 +1376,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Steuert sowohl das sichtbare Auswahlkästchen als auch den Klickbereich. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = Auswahlmodi + .use-shift-to-add-to-selection-pickadd = Umschalt zum Hinzufügen zur Auswahl verwenden (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Ziehen mit gedrückter Maustaste zeichnet ein Rechteck statt eines Lassos (PICKDRAG) + .show-selection-effect-selectioneffect = Auswahleffekt anzeigen (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Auswahl vorschauen, wenn kein Befehl aktiv ist + .preview-selection-during-a-command = Auswahl während eines Befehls vorschauen + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = Hebt das Objekt unter dem Cursor hervor, bevor es gewählt wird (SELECTIONPREVIEW). + .object-limit-for-grips = Objektgrenze für Griffe + .unlimited = Unbegrenzt + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = Ab dieser Anzahl gewählter Objekte werden keine Griffe gezeichnet; 0 hebt die Grenze auf (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Auswahlbereich mit transparenter Füllung anzeigen (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Transparenz der Fenster- und Kreuzen-Auswahlbereiche (SELECTIONAREAOPACITY). @@ -1654,7 +1664,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: Keine gespeicherten Zustände. LAYERSTATE SAVE verwenden. .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Aufruf: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Aufruf: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTID muss endlich und größer oder gleich null sein. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTOD muss endlich und größer als null sein. diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 5dba873a3..96d170fd6 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1377,6 +1377,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Ελέγχει τόσο το ορατό πλαίσιο επιλογής όσο και την περιοχή κλικ. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = Τρόποι επιλογής + .use-shift-to-add-to-selection-pickadd = Χρήση Shift για προσθήκη στην επιλογή (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Το πάτημα-και-σύρσιμο σχεδιάζει ορθογώνιο αντί για λάσο (PICKDRAG) + .show-selection-effect-selectioneffect = Εμφάνιση εφέ επιλογής (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Προεπισκόπηση επιλογής όταν δεν εκτελείται εντολή + .preview-selection-during-a-command = Προεπισκόπηση επιλογής κατά την εκτέλεση εντολής + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = Τονίζει το αντικείμενο κάτω από τον δείκτη πριν επιλεγεί (SELECTIONPREVIEW). + .object-limit-for-grips = Όριο αντικειμένων για λαβές + .unlimited = Χωρίς όριο + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = Πάνω από τόσα επιλεγμένα αντικείμενα δεν σχεδιάζονται λαβές· το 0 αναιρεί το όριο (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Ένδειξη περιοχής επιλογής με διαφανές γέμισμα (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Διαφάνεια περιοχών επιλογής παραθύρου και τομής (SELECTIONAREAOPACITY). @@ -1655,7 +1665,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: δεν υπάρχουν αποθηκευμένες καταστάσεις. Χρησιμοποιήστε LAYERSTATE SAVE <όνομα>. .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Χρήση: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Χρήση: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: το DONUTID απαιτεί πεπερασμένη τιμή μεγαλύτερη ή ίση του μηδενός. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: το DONUTOD απαιτεί πεπερασμένη τιμή μεγαλύτερη του μηδενός. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 1536065db..0a4d99304 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1377,6 +1377,16 @@ common = .pick-box-size = Pick box size .controls-both-the-visible-selection-box-and-the-click-aperture = Controls both the visible selection box and the click aperture. .visual-effect-settings = Visual Effect Settings + .selection-modes = Selection Modes + .use-shift-to-add-to-selection-pickadd = Use Shift to add to selection (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Press and drag draws a rectangle instead of a lasso (PICKDRAG) + .show-selection-effect-selectioneffect = Show selection effect (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Preview selection when no command is active + .preview-selection-during-a-command = Preview selection during a command + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = Highlights the object under the cursor before it is picked (SELECTIONPREVIEW). + .object-limit-for-grips = Object limit for grips + .unlimited = Unlimited + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = Past this many selected objects no grips are drawn; 0 removes the limit (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Indicate selection area with transparent fill (SELECTIONAREA) .selection-area-opacity = Selection area opacity .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Transparency of the window and crossing selection areas (SELECTIONAREAOPACITY). @@ -1656,7 +1666,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: no saved states. Use LAYERSTATE SAVE . .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Usage: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Usage: CHPROP (props: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE (read-only) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE requires 0 or 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTID requires a finite value greater than or equal to zero. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTOD requires a finite value greater than zero. diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 097d983ba..6941fa1f2 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1376,6 +1376,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Controla tanto el cuadro de selección visible como el área de clic. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = Modos de selección + .use-shift-to-add-to-selection-pickadd = Usar Mayús para añadir a la selección (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Arrastrar dibuja un rectángulo en lugar de un lazo (PICKDRAG) + .show-selection-effect-selectioneffect = Mostrar efecto de selección (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Previsualizar la selección cuando no hay ningún comando activo + .preview-selection-during-a-command = Previsualizar la selección durante un comando + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = Resalta el objeto bajo el cursor antes de seleccionarlo (SELECTIONPREVIEW). + .object-limit-for-grips = Límite de objetos para pinzamientos + .unlimited = Sin límite + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = A partir de este número de objetos seleccionados no se dibujan pinzamientos; 0 elimina el límite (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Indicar el área de selección con relleno transparente (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Transparencia de las áreas de selección por ventana y captura (SELECTIONAREAOPACITY). @@ -1654,7 +1664,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: no hay estados guardados. Use LAYERSTATE SAVE . .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Uso: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Uso: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTID debe ser un valor finito mayor o igual que cero. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTOD debe ser un valor finito mayor que cero. diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index a3c76dc17..f251e2663 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1375,6 +1375,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Säätää sekä näkyvää valintaruutua että napsautusaluetta. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = Valintatilat + .use-shift-to-add-to-selection-pickadd = Käytä Vaihto-näppäintä valintaan lisäämiseen (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Painallus ja veto piirtää suorakulmion lasson sijaan (PICKDRAG) + .show-selection-effect-selectioneffect = Näytä valintatehoste (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Esikatsele valintaa, kun mikään komento ei ole aktiivinen + .preview-selection-during-a-command = Esikatsele valintaa komennon aikana + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = Korostaa kohdistimen alla olevan objektin ennen sen valintaa (SELECTIONPREVIEW). + .object-limit-for-grips = Objektiraja kahvoille + .unlimited = Rajoittamaton + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = Tätä useamman valitun objektin kohdalla kahvoja ei piirretä; 0 poistaa rajan (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Näytä valinta-alue läpinäkyvällä täytöllä (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Ikkuna- ja leikkausvalinnan alueiden läpinäkyvyys (SELECTIONAREAOPACITY). @@ -1653,7 +1663,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: ei tallennettuja tiloja. Käytä LAYERSTATE SAVE . .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Käyttö: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Käyttö: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTID vaatii äärellisen arvon, joka on vähintään nolla. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTOD vaatii äärellisen arvon, joka on suurempi kuin nolla. diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 3e0fa73e0..b04978ac3 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1376,6 +1376,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Contrôle à la fois la case de sélection visible et la zone de clic. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = Modes de sélection + .use-shift-to-add-to-selection-pickadd = Utiliser Maj pour ajouter à la sélection (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Le cliquer-glisser trace un rectangle au lieu d'un lasso (PICKDRAG) + .show-selection-effect-selectioneffect = Afficher l'effet de sélection (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Prévisualiser la sélection quand aucune commande n'est active + .preview-selection-during-a-command = Prévisualiser la sélection pendant une commande + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = Met en évidence l'objet sous le curseur avant qu'il soit sélectionné (SELECTIONPREVIEW). + .object-limit-for-grips = Limite d'objets pour les poignées + .unlimited = Illimité + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = Au-delà de ce nombre d'objets sélectionnés, aucune poignée n'est tracée ; 0 supprime la limite (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Indiquer la zone de sélection par un remplissage transparent (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Transparence des zones de sélection par fenêtre et par capture (SELECTIONAREAOPACITY). @@ -1654,7 +1664,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE : aucun état enregistré. Utilisez LAYERSTATE SAVE . .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Utilisation : COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Utilisation : CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR : DONUTID doit être une valeur finie supérieure ou égale à zéro. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR : DONUTOD doit être une valeur finie supérieure à zéro. diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index ef77a707c..a2a6224b3 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1364,6 +1364,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = दिखने वाले चयन बॉक्स और क्लिक क्षेत्र दोनों को नियंत्रित करता है। .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = चयन मोड + .use-shift-to-add-to-selection-pickadd = चयन में जोड़ने के लिए Shift का उपयोग करें (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = दबाकर खींचने पर लैस्सो के बजाय आयत बनता है (PICKDRAG) + .show-selection-effect-selectioneffect = चयन प्रभाव दिखाएँ (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = कोई कमांड सक्रिय न होने पर चयन का पूर्वावलोकन करें + .preview-selection-during-a-command = कमांड के दौरान चयन का पूर्वावलोकन करें + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = कर्सर के नीचे के ऑब्जेक्ट को चुने जाने से पहले हाइलाइट करता है (SELECTIONPREVIEW)। + .object-limit-for-grips = ग्रिप के लिए ऑब्जेक्ट सीमा + .unlimited = असीमित + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = इतने से अधिक ऑब्जेक्ट चयनित होने पर ग्रिप नहीं बनाए जाते; 0 सीमा हटा देता है (GRIPOBJLIMIT)। .indicate-selection-area-with-transparent-fill-selectionarea = चयन क्षेत्र को पारदर्शी भराव से दिखाएँ (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = विंडो और क्रॉसिंग चयन क्षेत्रों की पारदर्शिता (SELECTIONAREAOPACITY)। @@ -1642,7 +1652,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: कोई सहेजी स्थिति नहीं। LAYERSTATE SAVE <नाम> का उपयोग करें। .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = उपयोग: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = उपयोग: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTID के लिए शून्य से बड़ा या बराबर परिमित मान आवश्यक है। .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTOD के लिए शून्य से बड़ा परिमित मान आवश्यक है। diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index f1d65d13b..373ac32e8 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1375,6 +1375,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = A látható kijelölőnégyzetet és a kattintási területet is szabályozza. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = Kijelölési módok + .use-shift-to-add-to-selection-pickadd = Shift használata a kijelöléshez adáshoz (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = A lenyomva húzás téglalapot rajzol lasszó helyett (PICKDRAG) + .show-selection-effect-selectioneffect = Kijelölési effektus megjelenítése (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Kijelölés előnézete, ha nincs aktív parancs + .preview-selection-during-a-command = Kijelölés előnézete parancs közben + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = Kiemeli a kurzor alatti objektumot, mielőtt kijelölnék (SELECTIONPREVIEW). + .object-limit-for-grips = Objektumkorlát a fogópontokhoz + .unlimited = Korlátlan + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = Ennél több kijelölt objektum esetén nem rajzolódnak fogópontok; a 0 megszünteti a korlátot (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Kijelölési terület jelzése átlátszó kitöltéssel (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Az ablakos és metsző kijelölési területek átlátszósága (SELECTIONAREAOPACITY). @@ -1653,7 +1663,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: nincs mentett állapot. Használja a LAYERSTATE SAVE parancsot. .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Használat: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Használat: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: a DONUTID véges, nullánál nem kisebb értéket igényel. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: a DONUTOD véges, nullánál nagyobb értéket igényel. diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 581b0a6ec..f2fb32860 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1375,6 +1375,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Controlla sia il riquadro di selezione visibile sia l’area di clic. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = Modalità di selezione + .use-shift-to-add-to-selection-pickadd = Usa Maiusc per aggiungere alla selezione (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Premi e trascina disegna un rettangolo invece di un lazo (PICKDRAG) + .show-selection-effect-selectioneffect = Mostra effetto di selezione (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Anteprima della selezione quando nessun comando è attivo + .preview-selection-during-a-command = Anteprima della selezione durante un comando + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = Evidenzia l'oggetto sotto il cursore prima che venga selezionato (SELECTIONPREVIEW). + .object-limit-for-grips = Limite di oggetti per i grip + .unlimited = Illimitato + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = Oltre questo numero di oggetti selezionati non vengono disegnati grip; 0 rimuove il limite (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Indica l’area di selezione con riempimento trasparente (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Trasparenza delle aree di selezione finestra e intersezione (SELECTIONAREAOPACITY). @@ -1653,7 +1663,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: nessuno stato salvato. Usa LAYERSTATE SAVE . .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Uso: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Uso: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTID richiede un valore finito maggiore o uguale a zero. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTOD richiede un valore finito maggiore di zero. diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index f43975364..d6d37b14a 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1366,6 +1366,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = 表示される選択ボックスとクリック範囲の両方を制御します。 .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = 選択モード + .use-shift-to-add-to-selection-pickadd = Shift キーで選択に追加する (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = ドラッグで投げ縄ではなく矩形を描く (PICKDRAG) + .show-selection-effect-selectioneffect = 選択効果を表示 (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = コマンドが実行されていないときに選択をプレビュー + .preview-selection-during-a-command = コマンドの実行中に選択をプレビュー + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = カーソル下のオブジェクトを選択前にハイライトします (SELECTIONPREVIEW)。 + .object-limit-for-grips = グリップのオブジェクト上限 + .unlimited = 無制限 + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = 選択オブジェクトがこの数を超えるとグリップは描画されません。0 で上限を解除します (GRIPOBJLIMIT)。 .indicate-selection-area-with-transparent-fill-selectionarea = 選択領域を透明な塗り潰しで表示(SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = 窓選択と交差選択の領域の透明度(SELECTIONAREAOPACITY)。 @@ -1644,7 +1654,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: 保存済みの状態はありません。LAYERSTATE SAVE <名前>を使用してください。 .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = 使用法: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = 使用法: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTIDには0以上の有限の値が必要です。 .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTODには0より大きい有限の値が必要です。 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index f3f85d334..c4423adb6 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1375,6 +1375,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = 표시되는 선택 상자와 클릭 영역을 모두 제어합니다. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = 선택 모드 + .use-shift-to-add-to-selection-pickadd = Shift 키로 선택에 추가 (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = 누른 채 끌면 올가미 대신 사각형을 그림 (PICKDRAG) + .show-selection-effect-selectioneffect = 선택 효과 표시 (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = 명령이 실행 중이 아닐 때 선택 미리보기 + .preview-selection-during-a-command = 명령 실행 중 선택 미리보기 + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = 커서 아래의 객체를 선택하기 전에 강조 표시합니다 (SELECTIONPREVIEW). + .object-limit-for-grips = 그립 표시 객체 한도 + .unlimited = 무제한 + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = 선택한 객체가 이 수를 넘으면 그립이 표시되지 않습니다. 0은 한도를 해제합니다 (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = 선택 영역을 투명한 채우기로 표시(SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = 윈도우 및 교차 선택 영역의 투명도(SELECTIONAREAOPACITY). @@ -1653,7 +1663,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: 저장된 상태가 없습니다. LAYERSTATE SAVE <이름>을 사용하세요. .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = 사용법: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = 사용법: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTID에는 0 이상의 유한한 값이 필요합니다. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTOD에는 0보다 큰 유한한 값이 필요합니다. diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index ce817e236..1d9c99d55 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1374,6 +1374,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Bepaalt zowel het zichtbare selectievak als het klikgebied. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = Selectiemodi + .use-shift-to-add-to-selection-pickadd = Shift gebruiken om aan de selectie toe te voegen (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Slepen tekent een rechthoek in plaats van een lasso (PICKDRAG) + .show-selection-effect-selectioneffect = Selectie-effect tonen (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Selectie voorvertonen wanneer geen opdracht actief is + .preview-selection-during-a-command = Selectie voorvertonen tijdens een opdracht + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = Markeert het object onder de cursor voordat het wordt geselecteerd (SELECTIONPREVIEW). + .object-limit-for-grips = Objectlimiet voor grepen + .unlimited = Onbeperkt + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = Boven dit aantal geselecteerde objecten worden geen grepen getekend; 0 heft de limiet op (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Selectiegebied met transparante vulling tonen (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Transparantie van venster- en kruisselectiegebieden (SELECTIONAREAOPACITY). @@ -1652,7 +1662,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: geen opgeslagen toestanden. Gebruik LAYERSTATE SAVE . .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Gebruik: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Gebruik: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTID vereist een eindige waarde groter dan of gelijk aan nul. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTOD vereist een eindige waarde groter dan nul. diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 370f2eca9..13bad04bc 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1375,6 +1375,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Steruje widocznym polem wyboru oraz obszarem kliknięcia. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = Tryby zaznaczania + .use-shift-to-add-to-selection-pickadd = Użyj klawisza Shift, aby dodać do zaznaczenia (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Przeciąganie rysuje prostokąt zamiast lassa (PICKDRAG) + .show-selection-effect-selectioneffect = Pokaż efekt zaznaczenia (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Podgląd zaznaczenia, gdy żadne polecenie nie jest aktywne + .preview-selection-during-a-command = Podgląd zaznaczenia podczas polecenia + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = Wyróżnia obiekt pod kursorem, zanim zostanie wskazany (SELECTIONPREVIEW). + .object-limit-for-grips = Limit obiektów dla uchwytów + .unlimited = Bez ograniczeń + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = Powyżej tej liczby zaznaczonych obiektów uchwyty nie są rysowane; 0 znosi limit (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Oznacz obszar wyboru przezroczystym wypełnieniem (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Przezroczystość obszarów wyboru oknem i przecięciem (SELECTIONAREAOPACITY). @@ -1653,7 +1663,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: brak zapisanych stanów. Użyj LAYERSTATE SAVE . .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Użycie: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Użycie: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTID wymaga skończonej wartości większej lub równej zero. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTOD wymaga skończonej wartości większej od zera. diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 2e938bf5e..58cf1b3d3 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1376,6 +1376,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Controla tanto a caixa de seleção visível quanto a área de clique. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = Modos de seleção + .use-shift-to-add-to-selection-pickadd = Usar Shift para adicionar à seleção (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Pressionar e arrastar desenha um retângulo em vez de um laço (PICKDRAG) + .show-selection-effect-selectioneffect = Mostrar efeito de seleção (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Visualizar a seleção quando nenhum comando está ativo + .preview-selection-during-a-command = Visualizar a seleção durante um comando + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = Destaca o objeto sob o cursor antes de ser selecionado (SELECTIONPREVIEW). + .object-limit-for-grips = Limite de objetos para alças + .unlimited = Ilimitado + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = Acima deste número de objetos selecionados, nenhuma alça é desenhada; 0 remove o limite (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Indicar a área de seleção com preenchimento transparente (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Transparência das áreas de seleção por janela e cruzamento (SELECTIONAREAOPACITY). @@ -1654,7 +1664,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: nenhum estado salvo. Use LAYERSTATE SAVE . .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Uso: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Uso: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTID deve ser um valor finito maior ou igual a zero. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTOD deve ser um valor finito maior que zero. diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index f7615b132..13ba008b2 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1379,6 +1379,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Управляет видимым прицелом выбора и областью щелчка. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = Режимы выбора + .use-shift-to-add-to-selection-pickadd = Использовать Shift для добавления к выбору (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Нажатие и перетаскивание рисует прямоугольник вместо лассо (PICKDRAG) + .show-selection-effect-selectioneffect = Показывать эффект выбора (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Предпросмотр выбора, когда нет активной команды + .preview-selection-during-a-command = Предпросмотр выбора во время команды + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = Подсвечивает объект под курсором до его выбора (SELECTIONPREVIEW). + .object-limit-for-grips = Предел объектов для ручек + .unlimited = Без ограничения + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = Свыше этого числа выбранных объектов ручки не отображаются; 0 снимает ограничение (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Показывать область выбора прозрачной заливкой (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Прозрачность областей выбора рамкой и секущей рамкой (SELECTIONAREAOPACITY). @@ -1657,7 +1667,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: нет сохранённых состояний. Используйте LAYERSTATE SAVE <имя>. .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Использование: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Использование: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTID требует конечное значение, большее или равное нулю. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTOD требует конечное значение больше нуля. diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 7e042e70d..0ffa766d1 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1362,6 +1362,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Hem görünür seçim kutusunu hem de tıklama alanını denetler. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = Seçim modları + .use-shift-to-add-to-selection-pickadd = Seçime eklemek için Shift kullan (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Basılı tutup sürükleme kement yerine dikdörtgen çizer (PICKDRAG) + .show-selection-effect-selectioneffect = Seçim efektini göster (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = Hiçbir komut etkin değilken seçimi önizle + .preview-selection-during-a-command = Bir komut sırasında seçimi önizle + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = İmlecin altındaki nesneyi seçilmeden önce vurgular (SELECTIONPREVIEW). + .object-limit-for-grips = Tutamaklar için nesne sınırı + .unlimited = Sınırsız + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = Bu sayıdan fazla nesne seçildiğinde tutamak çizilmez; 0 sınırı kaldırır (GRIPOBJLIMIT). .indicate-selection-area-with-transparent-fill-selectionarea = Seçim alanını saydam dolguyla göster (SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = Pencere ve kesişen seçim alanlarının saydamlığı (SELECTIONAREAOPACITY). @@ -1640,7 +1650,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE: kayıtlı durum yok. LAYERSTATE SAVE kullanın. .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = Kullanım: COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = Kullanım: CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR: DONUTID sonlu ve sıfırdan büyük veya eşit olmalıdır. .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR: DONUTOD sonlu ve sıfırdan büyük olmalıdır. diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 324259609..b737e29f0 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1363,6 +1363,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = 同时控制可见选择框和单击范围。 .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = 选择模式 + .use-shift-to-add-to-selection-pickadd = 使用 Shift 添加到选择集 (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = 按住并拖动时绘制矩形而非套索 (PICKDRAG) + .show-selection-effect-selectioneffect = 显示选择效果 (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = 无命令运行时预览选择 + .preview-selection-during-a-command = 命令执行期间预览选择 + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = 在拾取之前高亮显示光标下的对象 (SELECTIONPREVIEW)。 + .object-limit-for-grips = 夹点的对象数量上限 + .unlimited = 无限制 + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = 选择的对象超过此数量时不绘制夹点;0 表示无上限 (GRIPOBJLIMIT)。 .indicate-selection-area-with-transparent-fill-selectionarea = 使用透明填充显示选择区域(SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = 窗口和交叉选择区域的透明度(SELECTIONAREAOPACITY)。 @@ -1641,7 +1651,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE:没有已保存的状态。请使用 LAYERSTATE SAVE <名称>。 .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = 用法:COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = 用法:CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR:DONUTID 必须为大于或等于零的有限值。 .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR:DONUTOD 必须为大于零的有限值。 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 340ca3c9f..bc7a4f144 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1375,6 +1375,16 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = 同時控制可見選取框與點選範圍。 .visual-effect-settings = { common.visual-effects } · { properties.settings } + .selection-modes = 選取模式 + .use-shift-to-add-to-selection-pickadd = 使用 Shift 加入選取集 (PICKADD) + .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = 按住並拖曳時繪製矩形而非套索 (PICKDRAG) + .show-selection-effect-selectioneffect = 顯示選取效果 (SELECTIONEFFECT) + .preview-selection-when-no-command-is-active = 沒有指令執行時預覽選取 + .preview-selection-during-a-command = 指令執行期間預覽選取 + .highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview = 在點選之前標示游標下的物件 (SELECTIONPREVIEW)。 + .object-limit-for-grips = 掣點的物件數量上限 + .unlimited = 無限制 + .past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit = 選取的物件超過此數量時不繪製掣點;0 表示無上限 (GRIPOBJLIMIT)。 .indicate-selection-area-with-transparent-fill-selectionarea = 以透明填滿顯示選取區域(SELECTIONAREA) .selection-area-opacity = { common.selection }: { properties.opacity } .transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity = 視窗與交叉選取區域的透明度(SELECTIONAREAOPACITY)。 @@ -1653,7 +1663,7 @@ common = .layerstate-no-saved-states-use-layerstate-save-name = LAYERSTATE:沒有已儲存的狀態。請使用 LAYERSTATE SAVE <名稱>。 .usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white = 用法:COLOR .usage-chprop-prop-val-props-layer-color-linetype-ltscale = 用法:CHPROP <{ common.property }> <{ common.value }> ({ common.property }: LAYER COLOR LINETYPE LTSCALE) - .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) + .setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram = SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE ({ common.read-only }) .setvar-dimcontinuemode-requires-0-or-1 = SETVAR: DIMCONTINUEMODE = 0 / 1. .setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero = SETVAR:DONUTID 必須為大於或等於零的有限值。 .setvar-donutod-requires-a-finite-value-greater-than-zero = SETVAR:DONUTOD 必須為大於零的有限值。 diff --git a/scripts/test_web_clipboard.cjs b/scripts/test_web_clipboard.cjs new file mode 100644 index 000000000..44575b718 --- /dev/null +++ b/scripts/test_web_clipboard.cjs @@ -0,0 +1,42 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +(async () => { + const source = fs.readFileSync(path.join(__dirname, '../web/clipboard.js'), 'utf8'); + const { copyHistory } = await import(`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`); + const text = 'LINE\n0,0\n10,10\n\n'; + let copied; + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { + clipboard: { writeText: async value => { copied = value; } }, + }}); + assert.equal(await copyHistory(text, 'Copy manually', 'Close'), true); + assert.equal(copied, text); + const dialogs = []; + globalThis.document = { + body: { append: dialog => dialogs.push(dialog) }, + createElement: tag => ({ + tag, style: {}, events: {}, + setAttribute(key, value) { this[key] = value; }, + append(...children) { this.children = children; }, + addEventListener(event, handler) { this.events[event] = handler; }, + showModal() { this.open = true; }, + focus() { this.focused = true; }, + select() { this.selected = true; }, + close() { this.events.close(); }, + remove() { this.removed = true; }, + }), + }; + for (const clipboard of [{ writeText: async () => { throw Error('Denied'); } }, undefined]) { + navigator.clipboard = clipboard; + assert.equal(await copyHistory(text, 'Copy manually', 'Close'), false); + const dialog = dialogs.at(-1); + const [label, field, close] = dialog.children; + assert.equal(label.textContent, 'Copy manually'); + assert.equal(field.value, text); + assert.ok(field.readOnly && field.focused && field.selected && dialog.open); + close.onclick(); + assert.ok(dialog.removed); + } + console.log('Browser clipboard: success, denied and unavailable paths passed.'); +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/src/app/automation.rs b/src/app/automation.rs index 1343081c4..132690e05 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -300,15 +300,12 @@ impl OpenCADStudio { match req["op"].as_str().unwrap_or("") { "new" => { let i = self.active_tab; - self.tabs[i].scene.document = acadrust::CadDocument::new(); - self.tabs[i].scene.sketch_constraints.clear(); - self.tabs[i].scene.named_parameters = crate::scene::named_parameters::ParameterTable::new(); - self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.clear(); + self.tabs[i].scene.material_base_dir = None; self.tabs[i].current_path = None; // The headless session starts on the welcome (Start) tab, which // blocks drawing commands; turn it into a real drawing. self.tabs[i].is_start = false; - self.tabs[i].scene.bump_geometry(); self.entity_summary() } #[cfg(not(target_arch = "wasm32"))] @@ -321,26 +318,21 @@ impl OpenCADStudio { Ok(b) => b, Err(e) => return err(format!("open: {e}")), }; - // Runs the same finalization (block-origin normalization, - // raster-path resolution, source_path) and corrupt-entity - // purge the UI open path always gets, using the real path — - // not just its basename — so an automation open behaves the - // same as opening the identical file from the UI instead of - // silently skipping that safety/normalization net. match crate::io::load_bytes_finalized(&path_buf, bytes) { Ok((doc, dropped)) => { let i = self.active_tab; + self.tabs[i].scene.clear(); self.tabs[i].scene.document = doc; self.tabs[i].scene.load_sketch_constraints_from_document(); self.tabs[i].scene.load_named_parameters_from_document(); - self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.material_base_dir = path_buf.parent().map(PathBuf::from); crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, ); self.tabs[i].adopt_active_ucs_from_header(); self.tabs[i].current_path = Some(path_buf); self.tabs[i].is_start = false; - self.tabs[i].scene.bump_geometry(); + self.tabs[i].scene.rebuild_derived_caches(); let mut summary = self.entity_summary(); if dropped > 0 { if let Some(obj) = summary.as_object_mut() { @@ -1409,16 +1401,13 @@ mod tests { let _ = std::fs::remove_file(&path); } - /// Audit finding: automation's `"open"` used to call `io::load_bytes` - /// directly, skipping the finalization and corrupt-entity purge every UI - /// open runs — so the same file opened two ways behaved differently. - /// Proves both halves of the fix: `source_path` (only ever set by - /// finalization, never by `load_bytes` alone) is populated, and a - /// zero-radius circle — `io::is_entity_corrupt`'s own rejection case — - /// is purged and reported, exactly like a UI open of the same file. #[test] fn open_finalizes_and_purges_like_the_ui_open_path() { let mut app = OpenCADStudio::new_for_test(); + let stale = acadrust::Handle::from(9999); + app.tabs[app.active_tab].scene.solid_models.insert( + stale, cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(), + ); let path = std::env::temp_dir().join(format!( "ocs_automation_finalize_test_{}.dxf", std::process::id() @@ -1445,11 +1434,20 @@ mod tests { assert_eq!(result["purged"], 1, "the purge count must be reported, matching the UI open path's diagnostics"); let i = app.active_tab; + assert!(!app.tabs[i].scene.solid_models.contains_key(&stale)); + assert_eq!(app.tabs[i].scene.material_base_dir.as_deref(), path.parent()); assert!( app.tabs[i].scene.document.source_path.is_some(), "automation open must run the same finalization as a path-based open, which sets source_path (load_bytes alone never does)" ); + app.tabs[i].scene.solid_models.insert( + stale, cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(), + ); + assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true); + assert!(app.tabs[i].scene.solid_models.is_empty()); + assert!(app.tabs[i].scene.material_base_dir.is_none()); + drop(app); let _ = std::fs::remove_file(&path); } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 28280035d..68c44ca5c 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -353,7 +353,32 @@ impl OpenCADStudio { self.reset_tracking_after_point(); self.push_ucs_to_cmd(i); } - if let StepInput::EntityPick(handle, _) = &input { + if let StepInput::EntityPick(handle, point) = &input { + let solid_pick = matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some( + acadrust::EntityType::Solid3D(_) + | acadrust::EntityType::Surface(_) + | acadrust::EntityType::Region(_) + | acadrust::EntityType::Body(_) + ) + ); + let direction = if solid_pick { + self.tabs[i] + .scene + .solid_planar_face_normal_at(*handle, *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) + }; + if let Some(command) = self.tabs[i].active_cmd.as_mut() { + command.set_entity_pick_direction(direction); + } if self.tabs[i].active_cmd.as_ref() .is_some_and(|command| command.inject_before_entity_pick()) { @@ -4099,6 +4124,102 @@ impl OpenCADStudio { self.refresh_properties(); } + CmdResult::ThickenEntities { handles, distance } => { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + + if handles.is_empty() { + self.command_line + .push_output(crate::t!("No surfaces selected.").as_ref()); + return Task::none(); + } + if !distance.is_finite() { + self.command_line.push_error( + crate::t!("Requires numeric distance or two points.").as_ref(), + ); + return Task::none(); + } + if distance.abs() <= f64::EPSILON { + return Task::none(); + } + + use crate::modules::insert::solid3d_cmds::empty_solid3d; + let pending = self.begin_undo(i, "THICKEN", handles.len(), true); + let mut created = 0usize; + let mut failed = 0usize; + let mut self_intersections = 0usize; + for handle in handles { + let Some(acadrust::EntityType::Surface(surface)) = + self.tabs[i].scene.document.get_entity(handle) + else { + failed += 1; + continue; + }; + let kernel_distance = if surface.kind + == acadrust::entities::SurfaceKind::Revolved + { + -distance + } else { + distance + }; + let body = self.tabs[i] + .scene + .solid_models + .get(&handle) + .cloned() + .or_else(|| { + crate::scene::convert::solid3d_tess::kernel_surface_body(surface) + }); + let Some(body) = body else { + failed += 1; + continue; + }; + let solid = match cadkernel::brep::thicken(&body, kernel_distance) { + Ok(solid) => solid, + Err(cadkernel::brep::ThickenError::SelfIntersection) => { + failed += 1; + self_intersections += 1; + continue; + } + Err(_) => { + failed += 1; + continue; + } + }; + let history = crate::scene::model::solid_history::brep_op(&solid); + if self + .add_solid_model(empty_solid3d(), solid, history) + .is_null() + { + failed += 1; + } else { + created += 1; + } + } + + if created > 0 { + self.tabs[i].dirty = true; + } + if self_intersections > 0 { + self.command_line.push_error(&crate::tf!( + "{} surface(s) cannot be thickened because the offset intersects itself.", + self_intersections + )); + } + if failed > 0 { + self.command_line.push_output(&crate::tf!( + "THICKEN: created {} solid(s); {} surface(s) could not be thickened exactly.", + created, failed + )); + } + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + self.refresh_properties(); + } + CmdResult::PresspullPick { handle, point, offset, multiple: _ } => { self.presspull_pick(handle, point, offset); } @@ -6345,3 +6466,101 @@ mod sketch_constraint_undo_tests { ); } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod thicken_tests { + use super::*; + use acadrust::entities::{Surface, SurfaceKind}; + use cadkernel::geom2d::{Circle, Curve, NurbsCurve}; + use cadkernel::space::{Parameterization, Plane}; + + #[test] + fn thicken_preserves_sources_and_round_trips_results_with_one_undo() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let body = cadkernel::brep::planar_region(Plane::XY, &[ + vec![Curve::Circle(Circle { centre: [0.0; 2], radius: 3.0 })], + ]).unwrap(); + let source = app.add_surface_model(acadrust::EntityType::Surface(Surface::new(SurfaceKind::Plane)), body); + assert!(!source.is_null()); + let i = app.active_tab; + let invalid = app.tabs[i].scene.add_entity(acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic))); + let original = serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(); + let before = app.tabs[i].history.undo_stack.len(); + let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![source], distance: 0.0 }); + assert_eq!(app.tabs[i].history.undo_stack.len(), before); + let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![invalid, source], distance: 2.0 }); + assert_eq!(app.tabs[i].history.undo_stack.len(), before + 1); + let result = app.tabs[i].scene.document.entities().find_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle) + }).unwrap(); + assert!(app.tabs[i].scene.document.solid_history_operation(result).is_some()); + assert_eq!(serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original); + let expected = cadkernel::brep::analytic_mass_properties(&app.tabs[i].scene.solid_models[&result]).unwrap().volume; + let bytes = crate::io::save_to_bytes(&app.tabs[i].scene.document, "dwg", app.tabs[i].scene.document.version).unwrap(); + let document = crate::io::load_bytes("thicken.dwg", bytes).unwrap(); + let mut restored = crate::scene::Scene::new(); + restored.document = document; + restored.restore_solid_models(&[result]); + let actual = cadkernel::brep::analytic_mass_properties(&restored.solid_models[&result]).unwrap().volume; + assert!((expected - actual).abs() < 1e-8); + app.undo_active_tab(); + assert!(app.tabs[i].scene.document.get_entity(result).is_none()); + assert!(app.tabs[i].scene.document.solid_history_operation(result).is_none()); + assert!(app.tabs[i].scene.document.get_entity(source).is_some()); + assert!(app.tabs[i].scene.document.get_entity(invalid).is_some()); + app.redo_active_tab(); + assert!(app.tabs[i].scene.document.get_entity(result).is_some()); + assert!(app.tabs[i].scene.document.solid_history_operation(result).is_some()); + assert_eq!(serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original); + } + + #[test] + fn thicken_creates_a_solid_from_a_free_form_surface() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let profile = NurbsCurve::interpolate( + &[[0.0, 0.0], [0.7, 0.15], [1.3, -0.1], [2.0, 0.0]], + None, + None, + Parameterization::Chord, + ) + .unwrap(); + let body = cadkernel::brep::extrude_surface( + Plane::XY, + &[Curve::Nurbs(profile)], + [0.0, 0.0, 2.0], + ) + .unwrap(); + let source = app.add_surface_model( + acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic)), + body, + ); + let i = app.active_tab; + + let _ = app.apply_cmd_result(CmdResult::ThickenEntities { + handles: vec![source], + distance: 0.15, + }); + + let solids = app.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)) + .then_some(entity.common().handle) + }) + .collect::>(); + assert_eq!(solids.len(), 1); + let solid = &app.tabs[i].scene.solid_models[&solids[0]]; + assert!(solid.validate().is_empty()); + assert!(solid.edges.iter().all(|(_, edge)| edge.coedges.len() == 2)); + assert!(app.tabs[i] + .scene + .document + .solid_history_operation(solids[0]) + .is_some()); + assert!(app.tabs[i].scene.document.get_entity(source).is_some()); + } +} diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 84b0d3a05..908b02960 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -734,7 +734,7 @@ impl OpenCADStudio { } // ── EXTRUDE ──────────────────────────────────────────────────── - "EXTRUDE" | "THICKEN" => { + "EXTRUDE" => { use crate::modules::insert::solid3d_cmds::ExtrudeCommand; // A preselection becomes the complete source set; otherwise // the interactive command gathers any number of profiles. @@ -769,6 +769,19 @@ impl OpenCADStudio { } } + "THICKEN" => { + use crate::modules::insert::solid3d_cmds::ThickenCommand; + let selected = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(handle, entity)| (handle, entity.clone())) + .collect(); + let command = ThickenCommand::new(selected); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "PRESSPULL" => { use crate::modules::insert::solid3d_cmds::PresspullCommand; let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 9e86e2434..6b1bc0ca0 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1400,6 +1400,64 @@ impl OpenCADStudio { } } + "SECTIONPLANE" => { + use crate::modules::model::sectionplane_cmd::SectionPlaneCommand; + let (bounds, next_name) = { + let scene = &mut self.tabs[i].scene; + let solid_handles = scene + .document + .entities() + .filter_map(|entity| { + matches!( + entity, + acadrust::EntityType::Solid3D(_) + | acadrust::EntityType::Surface(_) + | acadrust::EntityType::Region(_) + | acadrust::EntityType::Body(_) + ) + .then_some(entity.common().handle) + }) + .collect::>(); + scene.restore_solid_models(&solid_handles); + let bounds = solid_handles + .iter() + .filter_map(|handle| scene.solid_models.get(handle)) + .filter_map(crate::scene::model::solid_model::extent) + .fold(None::<(glam::DVec3, glam::DVec3)>, |bounds, (low, high)| { + let low = glam::DVec3::from_array(low); + let high = glam::DVec3::from_array(high); + Some(match bounds { + None => (low, high), + Some((min, max)) => (min.min(low), max.max(high)), + }) + }); + let next_name = scene + .document + .entities() + .filter_map(|entity| { + let acadrust::EntityType::Extended(extended) = entity else { + return None; + }; + let acadrust::entities::ExtendedEntityData::SectionObject(data) = + &extended.data + else { + return None; + }; + data.name + .strip_prefix("Section Plane (") + .and_then(|name| name.strip_suffix(')')) + .and_then(|name| name.parse::().ok()) + }) + .max() + .unwrap_or(0) + + 1; + (bounds, next_name) + }; + let command = SectionPlaneCommand::new(bounds, next_name); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + // 3DALIGN <18 numbers> — align the selected solid by 3 source→3 dest points. cmd if cmd == "3DALIGN" || cmd == "ALIGN3D" diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index a5ba30972..929807800 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -465,6 +465,7 @@ inventory::submit!(crate::command::CommandRegistration { "3DALIGN", "ALIGN3D", "SECTION", + "SECTIONPLANE", "PYRAMID", "PYR", "SPLINEFIT", @@ -551,6 +552,7 @@ inventory::submit!(crate::command::CommandRegistration { "GRIPCOLOR", "GRIPHOT", "GRIPHOVER", + "GRIPOBJLIMIT", // Reset selected entities' overrides to follow their layer. "SETBYLAYER", // Remove duplicate objects; set drawing base point; audit integrity; diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index e01267fbc..939eb38fb 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -918,6 +918,7 @@ impl OpenCADStudio { | "GRIPCOLOR" | "GRIPHOT" | "GRIPHOVER" + | "GRIPOBJLIMIT" ) => { return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); @@ -940,7 +941,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - crate::t!("SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), + crate::t!("SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), ); } else { if matches!(name.as_str(), "SHOWHIST" | "SOLIDHIST") { @@ -1444,6 +1445,22 @@ impl OpenCADStudio { }, None => Ok((format!("GRIPSIZE = {}", self.model_space.grip_size), false)), }, + "GRIPOBJLIMIT" => match &value { + Some(v) => match v.parse::() { + Ok(limit @ 0..=32767) => { + self.grip_object_limit = limit; + Ok((format!("GRIPOBJLIMIT = {limit}"), true)) + } + _ => Err( + "SETVAR: integer from 0 to 32767 required (0 = no limit)." + .into(), + ), + }, + None => Ok(( + format!("GRIPOBJLIMIT = {}", self.grip_object_limit), + false, + )), + }, "GRIPCOLOR" => match &value { Some(v) => match v.parse::() { Ok(color) => { @@ -2126,6 +2143,7 @@ impl OpenCADStudio { | "GRIPCOLOR" | "GRIPHOT" | "GRIPHOVER" + | "GRIPOBJLIMIT" ) { self.persist_settings_if_changed(); } else { @@ -2982,6 +3000,13 @@ mod tests { // SELECTIONPREVIEW let _ = app.run_command_line("SETVAR SELECTIONPREVIEW 2"); assert_eq!(app.model_space.selection_preview, 2); + + let _ = app.run_command_line("SETVAR GRIPOBJLIMIT 0"); + assert_eq!(app.grip_object_limit, 0); + let _ = app.run_command_line("GRIPOBJLIMIT 32767"); + assert_eq!(app.grip_object_limit, 32767); + let _ = app.run_command_line("GRIPOBJLIMIT 32768"); + assert_eq!(app.grip_object_limit, 32767); } #[test] diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 6ceb6d67a..5ae81c355 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -1566,6 +1566,16 @@ mod tests { app } + #[test] + fn options_and_its_alias_dispatch_from_the_start_page() { + let mut app = fresh_app(); + let i = app.active_tab; + assert!(app.dispatch_view("OPTIONS", i).is_some()); + assert!(app.dispatch_view("OP", i).is_some()); + assert!(crate::app::commands::start_allowed("OPTIONS")); + assert!(crate::app::commands::start_allowed("OP")); + } + #[test] fn hatchtoback_no_hatches_warns_and_noop() { let mut app = fresh_app(); diff --git a/src/app/config.rs b/src/app/config.rs index 52707f4a1..e33e42b63 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -270,7 +270,7 @@ pub struct ModelSpaceThemeConfig { pub selection_highlight_color: u8, /// SELECTIONEFFECT: whether selected objects glow with solid highlight (true) or dash (false). pub selection_effect: bool, - /// SELECTIONPREVIEW: rollover/hover highlight mode (0 = off, 1 = in cmd, 2 = idle, 3 = both). + /// SELECTIONPREVIEW bitmask: 1 = idle, 2 = during a command, 3 = both. pub selection_preview: u8, /// GRIPSIZE: grip marker half-size in pixels (1–25, default 5). pub grip_size: u8, diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index f6e9df8a9..13ae83069 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -171,6 +171,7 @@ impl OpenCADStudio { | "text_color" | "block_content_color" | "background_fill_color" + | "indicator_fill_color" ) { self.update(Message::PropColorFieldChanged { field: p.field.into(), diff --git a/src/app/history.rs b/src/app/history.rs index 27919f0c7..f18b56720 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -717,6 +717,10 @@ impl OpenCADStudio { entity, EntityType::Block(_) | EntityType::BlockEnd(_) + | EntityType::Extended(acadrust::entities::ExtendedEntity { + data: acadrust::entities::ExtendedEntityData::SectionObject(_), + .. + }) ) { return false; } diff --git a/src/app/mod.rs b/src/app/mod.rs index 90dd635d5..73a73d6fd 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -387,6 +387,10 @@ pub(super) struct OpenCADStudio { /// though the three factors are currently equal — the user unchecked the /// "Uniform scale" box for them (#427). Keyed by entity handle. props_asym_scale: std::collections::HashSet, + /// Collapsed Properties-panel section titles. This belongs to the app, + /// rather than an individual document tab, so the same view preference is + /// used by every currently open drawing/project. + collapsed_property_sections: rustc_hash::FxHashSet, /// Which Start-page section is shown when the page is too narrow for all /// three side by side and falls back to a tab bar. start_section: StartSection, @@ -496,6 +500,13 @@ pub(super) struct OpenCADStudio { cursor_size: i32, /// Selection-box size setting (PICKBOX, 0..=50). pick_box: i32, + /// Use REFEDIT rather than BEDIT when double-clicking an attribute-free block. + double_click_block_refedit: bool, + /// Open ATTEDIT when double-clicking a block with attributes. + double_click_block_attedit: bool, + /// Selected-object count past which grips stop being generated + /// (GRIPOBJLIMIT, 0..=32767; 0 = no limit). + grip_object_limit: i32, /// Drawing viewport cursor style (CURSORTYPE). cursor_type: settings::CursorType, /// Explicit crosshair colour; `None` retains automatic contrast. @@ -1962,6 +1973,10 @@ pub enum Message { CursorSizeChanged(i32), /// Set PICKBOX from the Selection-page slider. PickBoxChanged(i32), + /// Choose whether double-clicking a block starts BEDIT or REFEDIT. + DoubleClickBlockRefeditChanged(bool), + /// Choose whether double-clicking a block with attributes starts ATTEDIT. + DoubleClickBlockAtteditChanged(bool), /// Set CURSORTYPE from Options. CursorTypeChanged(settings::CursorType), /// Set the model-space lineweight preview scale from Options. @@ -2008,6 +2023,19 @@ pub enum Message { GripHotChanged(u8), /// Change Hover/Warm Grip color ACI index (0 = Theme Primary Strong, 1..=255 = ACI, GRIPHOVER). GripHoverChanged(u8), + /// Change the selected-object count past which grips stop being drawn + /// (0..=32767, 0 = no limit, GRIPOBJLIMIT). + GripObjectLimitChanged(i32), + /// Toggle the solid selection highlight (SELECTIONEFFECT). + SelectionEffectToggled(bool), + /// Toggle rollover preview while no command is running (SELECTIONPREVIEW bit 1). + SelectionPreviewIdleToggled(bool), + /// Toggle rollover preview during a command (SELECTIONPREVIEW bit 2). + SelectionPreviewCommandToggled(bool), + /// Toggle "use Shift to add to selection"; this is the inverse of PICKADD. + ShiftToAddToggled(bool), + /// Toggle press-and-drag drawing a rectangle instead of a lasso (PICKDRAG). + PickDragRectToggled(bool), /// Restore Model Space display/canvas appearance to defaults. RestoreModelSpaceDisplayDefaults, /// Restore Selection visual effect settings to defaults. @@ -2214,6 +2242,8 @@ pub enum Message { /// clipboard as plain text — issue #232, so output can be pasted for /// debugging instead of screenshotted. CommandHistoryCopy, + #[cfg(target_arch = "wasm32")] + CommandHistoryCopied(bool), /// Clear every line from the command-line history. CommandHistoryClear, /// Copy every line currently retained by the PERF panel. @@ -2604,6 +2634,8 @@ pub enum Message { /// Toggle a collapsed coordinate group ("Position", "Scale", …) open or /// closed in the Properties panel, keyed `section:base`. PropGroupToggle(String), + /// Toggle an entire Properties-panel section, keyed by its title. + PropSectionToggle(String), /// Toggle the editable-dropdown (block Name) option list open/closed. PropEditChoiceToggle, /// User is typing in a block-attribute value field (live buffer update), @@ -3378,6 +3410,7 @@ impl OpenCADStudio { discussions: Vec::new(), discussions_loading: false, props_asym_scale: std::collections::HashSet::new(), + collapsed_property_sections: rustc_hash::FxHashSet::default(), start_section: StartSection::default(), start_action_w: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)), history_content: iced::widget::text_editor::Content::new(), @@ -3426,6 +3459,9 @@ impl OpenCADStudio { zoom_factor: 60, cursor_size: 5, pick_box: 3, + double_click_block_refedit: false, + double_click_block_attedit: true, + grip_object_limit: settings::DEFAULT_GRIP_OBJECT_LIMIT, cursor_type: settings::CursorType::Crosshair, crosshair_color: None, crosshair_color_input: String::new(), diff --git a/src/app/properties.rs b/src/app/properties.rs index a72daeaf4..8fd1faec1 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -32,6 +32,10 @@ impl OpenCADStudio { /// Rebuild the PropertiesPanel from the current entity selection. /// Preserves UI state (open pickers, edit buffer) across refreshes. pub(super) fn refresh_properties(&mut self) { + // A 186 468-entity selection spends ~112 ms in here. Split it into the + // three phases that can own that: resolving the selected handles, + // building the panel, and syncing the ribbon. + let t_all = crate::perf::enabled().then(iced::time::Instant::now); let i = self.active_tab; if !crate::entities::object_data::cache_is_prepared( &self.tabs[i].scene.object_data_cache, @@ -47,6 +51,10 @@ impl OpenCADStudio { // Expanded coordinate groups persist across rebuilds AND selection // changes — it's a per-user view preference, not per-entity state. let expanded_groups = std::mem::take(&mut self.tabs[i].properties.expanded_groups); + // Section state belongs to the app, not a drawing tab, so the user's + // collapsed sections carry across every currently open drawing/project. + // The empty app-level default keeps a first-run palette fully expanded. + let collapsed_sections = self.collapsed_property_sections.clone(); // Which entities the previous panel was built for — an uncommitted // edit buffer only survives a rebuild for the *same* selection. let prev_handles = std::mem::take(&mut self.tabs[i].properties.source_handles); @@ -101,12 +109,17 @@ impl OpenCADStudio { // Current-Vertex focus survives only while the same object stays // selected; a changed selection resets to the first vertex. Seed the // per-thread focus so the polyline property builder / editor targets it. + // Everything before this is the prelude: the object-data cache, the + // unit context, the layer and linetype lists. + let m_prelude = t_all.map(|t| t.elapsed().as_secs_f64() * 1000.0); + let t_handles = crate::perf::enabled().then(iced::time::Instant::now); let cur_handles: Vec = self.tabs[i] .scene - .selected_entities() - .iter() - .map(|(h, _)| *h) + .selected_handles_in_order() + .into_iter() + .filter(|h| self.tabs[i].scene.document.get_entity(*h).is_some()) .collect(); + let handles_ms = t_handles.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); let prop_vertex = if cur_handles == prev_handles { self.tabs[i].properties.prop_vertex } else { @@ -2175,7 +2188,9 @@ impl OpenCADStudio { ..Default::default() }, _ => { + let t_arm = crate::perf::enabled().then(iced::time::Instant::now); let groups = build_selection_groups(&selected); + let t_groups = t_arm.map(|t| t.elapsed().as_secs_f64() * 1000.0); let active_group = selected_group .and_then(|group| groups.iter().find(|g| g.label == group.label).cloned()) .or_else(|| groups.first().cloned()); @@ -2183,30 +2198,57 @@ impl OpenCADStudio { let filtered: Vec<(Handle, &EntityType)> = active_group .as_ref() .map(|group| { + let wanted: rustc_hash::FxHashSet = + group.handles.iter().copied().collect(); selected .iter() - .filter(|(handle, _)| group.handles.contains(handle)) + .filter(|(handle, _)| wanted.contains(handle)) .copied() .collect() }) .unwrap_or_default(); + let t_filter = t_arm.map(|t| t.elapsed().as_secs_f64() * 1000.0); let plane = if self.tabs[i].editing_model_space() { self.tabs[i].ucs_xform().working_plane() } else { crate::command::WorkingPlane::default() }; - let local_entities: Vec<(Handle, EntityType)> = filtered - .iter() - .map(|(handle, entity)| { - (*handle, dispatch::entity_in_working_plane(entity, plane)) - }) - .collect(); + let local_entities: Vec<(Handle, std::borrow::Cow<'_, EntityType>)> = + filtered + .iter() + .map(|(handle, entity)| { + let local = if plane.is_identity() { + std::borrow::Cow::Borrowed(*entity) + } else { + std::borrow::Cow::Owned( + dispatch::entity_in_working_plane(entity, plane), + ) + }; + (*handle, local) + }) + .collect(); let local_refs: Vec<(Handle, &EntityType)> = local_entities .iter() - .map(|(handle, entity)| (*handle, entity)) + .map(|(handle, entity)| (*handle, entity.as_ref())) .collect(); + let t_local = t_arm.map(|t| t.elapsed().as_secs_f64() * 1000.0); let mut sections = aggregate_sections(&local_refs, &text_style_names); + if let (Some(t), Some(groups_ms), Some(filter_ms), Some(local_ms)) = + (t_arm, t_groups, t_filter, t_local) + { + let total = t.elapsed().as_secs_f64() * 1000.0; + if total >= 5.0 { + crate::perf_record!( + "[perf] properties-panel {total:>7.1}ms groups={groups_ms:.1} \ +filter={:.1} local={:.1} aggregate={:.1} entities={}", + filter_ms - groups_ms, + local_ms - filter_ms, + total - local_ms, + local_refs.len(), + ); + } + } if local_refs.iter().all(|(handle, _)| { crate::scene::model::solid_history::has_compact_solid_properties( &self.tabs[i].scene.document, @@ -2278,6 +2320,7 @@ impl OpenCADStudio { None }; panel.expanded_groups = expanded_groups; + panel.collapsed_sections = collapsed_sections; panel.source_handles = new_handles; panel.prop_vertex = prop_vertex; panel.prop_vertex_indicator_active = prop_vertex_indicator_active; @@ -2310,7 +2353,24 @@ impl OpenCADStudio { self.tabs[i].properties = new_panel; self.refresh_selected_grips(); + let m_panel = t_all.map(|t| t.elapsed().as_secs_f64() * 1000.0); + let t_ribbon = crate::perf::enabled().then(iced::time::Instant::now); self.sync_ribbon_from_selection(); + if let Some(t) = t_all { + let ribbon_ms = t_ribbon.map_or(0.0, |r| r.elapsed().as_secs_f64() * 1000.0); + let total_ms = t.elapsed().as_secs_f64() * 1000.0; + if total_ms >= 5.0 { + let prelude = m_prelude.unwrap_or(0.0); + let panel = m_panel.unwrap_or(0.0); + crate::perf_record!( + "[perf] properties-detail {total_ms:>7.1}ms prelude={prelude:.1} \ +handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={}", + panel - prelude - handles_ms, + total_ms - panel - ribbon_ms, + cur_handles.len(), + ); + } + } } /// Drive the Home-ribbon Layer / Color / Linetype / Lineweight dropdowns @@ -2371,11 +2431,16 @@ impl OpenCADStudio { let mut lineweight_mixed = false; for (_h, e) in &selected { + if layer_mixed && color_mixed && linetype_mixed && lineweight_mixed { + break; + } let c = e.common(); + // Borrowed, not cloned: this only ever gets compared, and cloning + // it here was one heap allocation per selected entity. let lt = if c.linetype.is_empty() { - "ByLayer".to_string() + "ByLayer" } else { - c.linetype.clone() + c.linetype.as_str() }; match &layer { None => layer = Some(c.layer.clone()), @@ -2388,8 +2453,8 @@ impl OpenCADStudio { _ => {} } match &linetype { - None => linetype = Some(lt), - Some(prev) if prev != < => linetype_mixed = true, + None => linetype = Some(lt.to_string()), + Some(prev) if prev.as_str() != lt => linetype_mixed = true, _ => {} } match &lineweight { @@ -2421,6 +2486,10 @@ impl OpenCADStudio { } /// Rebuild the cached selected_grips from the current entity selection. + /// + /// Selections past `GRIPOBJLIMIT` get no grips at all. That used to be a + /// constant here; it is now [`crate::app::settings::UserSettings::grip_object_limit`], + /// reachable from Options and from the sysvar of the same name. pub(super) fn refresh_selected_grips(&mut self) { let i = self.active_tab; let locked_active_grip = self.tabs[i].active_grip.as_ref().is_some_and(|grip| { @@ -2448,6 +2517,14 @@ impl OpenCADStudio { .then(|| selected[0].0); let mut grips = Vec::new(); let mut handles = Vec::new(); + // Avoid rebuilding every entity's grip markers above the configured + // limit. Zero disables the limit. + let limit = self.grip_object_limit; + let selected = if limit > 0 && selected.len() > limit as usize { + Vec::new() + } else { + selected + }; for (handle, entity) in selected { if self.tabs[i].scene.is_layer_locked(handle) { continue; @@ -3068,13 +3145,18 @@ pub(super) fn aggregate_sections( return vec![]; } - let mut all_sections: Vec> = selected - .iter() - .map(|(handle, entity)| dispatch::properties_sectioned(*handle, entity, text_style_names)) - .collect(); - - let mut result = all_sections.remove(0); - for sections in all_sections { + let mut entities = selected.iter(); + let Some((handle, entity)) = entities.next() else { + return vec![]; + }; + let mut result = dispatch::properties_sectioned(*handle, entity, text_style_names); + for (handle, entity) in entities { + // Nothing in common left to narrow: every later entity can only + // intersect against an empty set. + if result.is_empty() { + break; + } + let sections = dispatch::properties_sectioned(*handle, entity, text_style_names); result = merge_sections(&result, §ions); } // Sum the filled area while individual Area rows may still vary. @@ -3135,8 +3217,7 @@ fn retain_compact_solid_sections( | "transparency" | "hyperlink" | "material" - ) || property.field.starts_with("mesh_") - || crate::scene::model::solid_history::is_specialized_property(property.field) + ) || crate::scene::model::solid_history::is_specialized_property(property.field) }); }); sections.retain(|section| !section.props.is_empty()); @@ -4022,3 +4103,168 @@ mod chprop_integration_tests { assert_eq!(app.ribbon.active_lineweight, new_lw); } } + +#[cfg(test)] +mod grip_limit_tests { + use super::*; + + #[test] + fn a_selection_past_the_limit_gets_no_grips() { + use acadrust::entities::{EntityType, Line}; + use acadrust::types::Vector3; + + let select_n = |n: usize, limit: Option| -> usize { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + if let Some(limit) = limit { + app.grip_object_limit = limit; + } + let i = app.active_tab; + let handles: Vec<_> = (0..n) + .map(|k| { + let x = k as f64; + app.tabs[i].scene.add_entity(EntityType::Line( + Line::from_points( + Vector3::new(x, 0.0, 0.0), + Vector3::new(x + 1.0, 1.0, 0.0), + ), + )) + }) + .collect(); + for handle in handles { + app.tabs[i].scene.select_entity(handle, false); + } + app.refresh_selected_grips(); + app.tabs[i].selected_grips.len() + }; + + assert!( + select_n(crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize, None) > 0, + "a selection at the limit must still show its grips", + ); + assert_eq!( + select_n(crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize + 1, None), + 0, + "one past the limit must show none at all", + ); + } + + /// The Options card writes these four straight onto the app and relies on + /// the snapshot/restore pair to carry them across a restart. Three of them + /// had no UI until now and so no reason for anyone to notice if the pair + /// missed one. + #[test] + fn the_selection_settings_survive_a_save_and_load() { + let mut app = OpenCADStudio::new_for_test(); + app.pick_add = false; + app.pick_drag_rect = true; + app.pick_box = 11; + app.grip_object_limit = 0; + + let saved = app.current_settings(); + let mut restored = OpenCADStudio::new_for_test(); + restored.apply_settings(&saved); + + assert!(!restored.pick_add); + assert!(restored.pick_drag_rect); + assert_eq!(restored.pick_box, 11); + assert_eq!( + restored.grip_object_limit, 0, + "zero is a real value here, not an unset field", + ); + } + + /// `GRIPOBJLIMIT 0` means no limit, and the old comparison — a bare `>` + /// against the stored number — could not express that: zero would have + /// suppressed every grip instead of allowing all of them. + #[test] + fn a_limit_of_zero_means_no_limit() { + use acadrust::entities::{EntityType, Line}; + use acadrust::types::Vector3; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.grip_object_limit = 0; + let i = app.active_tab; + let over = crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize * 3; + let handles: Vec<_> = (0..over) + .map(|k| { + let x = k as f64; + app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(x, 0.0, 0.0), + Vector3::new(x + 1.0, 1.0, 0.0), + ))) + }) + .collect(); + for handle in handles { + app.tabs[i].scene.select_entity(handle, false); + } + app.refresh_selected_grips(); + assert!( + app.tabs[i].selected_grips.len() > 0, + "zero must read as unlimited, not as a limit of none", + ); + } +} + +#[cfg(test)] +mod aggregation_tests { + use super::*; + use acadrust::EntityType; + + fn line(layer: &str, color: i16) -> EntityType { + let mut line = acadrust::entities::line::Line::default(); + line.common.layer = layer.to_string(); + line.common.color = acadrust::types::Color::from_index(color); + EntityType::Line(line) + } + + fn row<'a>( + sections: &'a [crate::scene::model::object::PropSection], + field: &str, + ) -> Option<&'a crate::scene::model::object::Property> { + sections + .iter() + .flat_map(|section| section.props.iter()) + .find(|property| property.field == field) + } + + #[test] + fn shared_values_survive_the_fold_and_differing_ones_do_not() { + let entities = [line("WALLS", 1), line("WALLS", 3), line("WALLS", 1)]; + let selected: Vec<(Handle, &EntityType)> = entities + .iter() + .enumerate() + .map(|(i, entity)| (Handle::new(i as u64 + 1), entity)) + .collect(); + + let sections = aggregate_sections(&selected, &[]); + assert!(!sections.is_empty(), "three lines share their layer rows"); + + // A colour the entities disagree on has its own variant rather than + // the text label, so the two rows are checked on their own terms. + let layer = row(§ions, "layer").expect("a layer row"); + assert!( + format!("{:?}", layer.value).contains("WALLS"), + "one layer across all three: {:?}", + layer.value, + ); + let color = row(§ions, "color").expect("a color row"); + assert!( + format!("{:?}", color.value).contains("Varies"), + "two colours across three lines: {:?}", + color.value, + ); + } + + // One entity folds to its own properties, and the fold must not depend on + // there being a second one to merge against. + #[test] + fn a_single_entity_aggregates_to_its_own_rows() { + let entity = line("WALLS", 1); + let selected = [(Handle::new(1), &entity)]; + let sections = aggregate_sections(&selected, &[]); + let layer = row(§ions, "layer").expect("a layer row"); + assert!(format!("{:?}", layer.value).contains("WALLS")); + } +} diff --git a/src/app/settings.rs b/src/app/settings.rs index b342766f9..7e244126d 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -145,6 +145,9 @@ pub(crate) fn snaps_from_osmode(osmode: i32) -> (Vec, bool) { (modes, osmode & OSMODE_SUPPRESS == 0) } +/// GRIPOBJLIMIT default: past this many selected objects, no grips are drawn. +pub const DEFAULT_GRIP_OBJECT_LIMIT: i32 = 100; + /// The "settings" section of the consolidated config ([`crate::app::config`]). /// Field defaults mirror the app's in-code defaults so a missing key restores /// the value the app boots with. @@ -160,6 +163,13 @@ pub struct UserSettings { pub cursor_size: i32, /// PICKBOX: normalized visible-box and click-aperture size. pub pick_box: i32, + /// When true, double-clicking a block reference starts REFEDIT instead of BEDIT. + pub double_click_block_refedit: bool, + /// When true, double-clicking a block with attributes opens ATTEDIT. + pub double_click_block_attedit: bool, + /// GRIPOBJLIMIT: past this many selected objects, no grips are drawn at + /// all. 0 means no limit. The drawing header carries no slot for it. + pub grip_object_limit: i32, /// CURSORTYPE: crosshair or the platform pointer over the drawing. pub cursor_type: CursorType, /// Explicit crosshair RGB. `None` keeps automatic background contrast. @@ -324,6 +334,9 @@ impl Default for UserSettings { zoom_factor: 60, cursor_size: 5, pick_box: 3, + double_click_block_refedit: false, + double_click_block_attedit: true, + grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT, cursor_type: CursorType::Crosshair, crosshair_color: None, lineweight_display_scale: 100, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index a4f61f3d2..9c5aaab3b 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -365,6 +365,9 @@ impl OpenCADStudio { zoom_factor: self.zoom_factor, cursor_size: self.cursor_size, pick_box: self.pick_box, + double_click_block_refedit: self.double_click_block_refedit, + double_click_block_attedit: self.double_click_block_attedit, + grip_object_limit: self.grip_object_limit, cursor_type: self.cursor_type, crosshair_color: self.crosshair_color, lineweight_display_scale: self.lineweight_display_scale, @@ -420,6 +423,9 @@ impl OpenCADStudio { self.zoom_factor = s.zoom_factor.clamp(3, 100); self.cursor_size = s.cursor_size.clamp(1, 100); self.pick_box = s.pick_box.clamp(0, 50); + self.double_click_block_refedit = s.double_click_block_refedit; + self.double_click_block_attedit = s.double_click_block_attedit; + self.grip_object_limit = s.grip_object_limit.clamp(0, 32767); self.cursor_type = s.cursor_type; self.crosshair_color = s.crosshair_color; self.crosshair_color_input = s diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 5ea85a579..40b167542 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1839,10 +1839,32 @@ impl OpenCADStudio { Message::CommandHistoryCopy => { let text = self.command_line.history_plain_text(); if text.is_empty() { - Task::none() - } else { - iced::clipboard::write(text).discard() + return Task::none(); } + #[cfg(target_arch = "wasm32")] + { + let promise = crate::sys::copy_history_text( + &text, + crate::t!("Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history.").as_ref(), + crate::t!("Close").as_ref(), + ); + Task::perform(async move { + wasm_bindgen_futures::JsFuture::from(promise).await + .ok().and_then(|value| value.as_bool()).unwrap_or(false) + }, Message::CommandHistoryCopied) + } + #[cfg(not(target_arch = "wasm32"))] + iced::clipboard::write(text).discard() + } + + #[cfg(target_arch = "wasm32")] + Message::CommandHistoryCopied(copied) => { + if copied { + self.command_line.push_info(crate::t!("Copied").as_ref()); + } else { + self.command_line.push_error(crate::t!("Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history.").as_ref()); + } + Task::none() } Message::CommandHistoryClear => { @@ -5108,6 +5130,20 @@ impl OpenCADStudio { Task::none() } + Message::PropSectionToggle(title) => { + let sections = &mut self.collapsed_property_sections; + if !sections.remove(&title) { + sections.insert(title); + } + // Keep already-built panels in other open documents in sync, + // rather than waiting for each one to rebuild on selection. + let sections = self.collapsed_property_sections.clone(); + for tab in &mut self.tabs { + tab.properties.collapsed_sections = sections.clone(); + } + Task::none() + } + Message::PropEditChoiceToggle => { let panel = &mut self.tabs[self.active_tab].properties; panel.edit_choice_open = !panel.edit_choice_open; @@ -5216,6 +5252,21 @@ impl OpenCADStudio { Message::PropColorFieldChanged { field, color } => { let i = self.active_tab; let handles = self.property_target_handles(i); + if field == "indicator_fill_color" { + self.apply_property_op(i, "CHPROP", &handles, |app, handle| { + if let Some(acadrust::EntityType::Extended(extended)) = + app.tabs[i].scene.document.get_entity_mut(handle) + { + if let acadrust::entities::ExtendedEntityData::SectionObject(data) = + &mut extended.data + { + data.indicator_color = color; + } + } + }); + self.tabs[i].properties.open_color_field = None; + return Task::none(); + } // Dim-line colour override (Leader / Dimension): write it as an // ACAD_DSTYLE code-176 override (an ACI index) so it round-trips // through DWG and DXF. RGB picks collapse to the nearest ACI, in @@ -5921,6 +5972,18 @@ impl OpenCADStudio { Task::none() } + Message::DoubleClickBlockRefeditChanged(enabled) => { + self.double_click_block_refedit = enabled; + self.persist_settings_if_changed(); + Task::none() + } + + Message::DoubleClickBlockAtteditChanged(enabled) => { + self.double_click_block_attedit = enabled; + self.persist_settings_if_changed(); + Task::none() + } + Message::CursorTypeChanged(value) => { self.cursor_type = value; self.persist_settings_if_changed(); @@ -6148,6 +6211,48 @@ impl OpenCADStudio { Task::none() } + Message::GripObjectLimitChanged(limit) => { + self.grip_object_limit = limit.clamp(0, 32767); + self.persist_settings_if_changed(); + Task::none() + } + + Message::SelectionEffectToggled(enabled) => { + self.model_space.selection_effect = enabled; + self.sync_model_space_theme(false); + self.persist_settings_if_changed(); + Task::none() + } + + // SELECTIONPREVIEW is a bitmask and the two checkboxes own one bit + // each: 1 = rollover while idle, 2 = rollover during a command. + Message::SelectionPreviewIdleToggled(enabled) => { + self.model_space.selection_preview = + set_preview_bit(self.model_space.selection_preview, 1, enabled); + self.persist_settings_if_changed(); + Task::none() + } + + Message::SelectionPreviewCommandToggled(enabled) => { + self.model_space.selection_preview = + set_preview_bit(self.model_space.selection_preview, 2, enabled); + self.persist_settings_if_changed(); + Task::none() + } + + // "Use Shift to add" is the inverse of PICKADD. + Message::ShiftToAddToggled(shift_to_add) => { + self.pick_add = !shift_to_add; + self.persist_settings_if_changed(); + Task::none() + } + + Message::PickDragRectToggled(rectangle) => { + self.pick_drag_rect = rectangle; + self.persist_settings_if_changed(); + Task::none() + } + Message::RestoreSelectionVisualDefaults => { self.model_space.selection_area = true; self.model_space.selection_opacity = 12; @@ -6160,6 +6265,10 @@ impl OpenCADStudio { self.model_space.grip_color = 0; self.model_space.grip_hot = 0; self.model_space.grip_hover = 0; + // The button restores the *visual* defaults, which is why the + // grip limit is here and PICKADD / PICKDRAG are not — those are + // how selection behaves, not how it looks. + self.grip_object_limit = crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT; self.sync_model_space_theme(false); self.persist_settings_if_changed(); Task::none() @@ -8677,3 +8786,12 @@ mod free_text_entry_tests { assert_eq!(app.text_entry_mode(), TextEntryMode::Command); } } + +/// Set or clear one bit of `SELECTIONPREVIEW`. +fn set_preview_bit(current: u8, bit: u8, enabled: bool) -> u8 { + if enabled { + current | bit + } else { + current & !bit + } +} diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 8bfafa85d..6ddc15b3c 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4124,6 +4124,10 @@ impl OpenCADStudio { aabb }, ); + // A 186 k-entity box selection sits at ~800 ms in this + // handler and two guesses about which step owns it have + // both been wrong. Split it. + let t_sel = crate::perf::enabled().then(Instant::now); let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb( all_wires, world_aabb, @@ -4132,6 +4136,9 @@ impl OpenCADStudio { eye, bounds, ); + let cand_ms = t_sel + .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let t_hit = crate::perf::enabled().then(Instant::now); let candidate_handles = self.tabs[i] .scene .interaction_candidate_handles(&area_candidates); @@ -4192,20 +4199,31 @@ impl OpenCADStudio { // selection, Shift+box removes the boxed // entities. Esc / empty-space click still clears. // PICKADD 0 (#226): a plain box REPLACES. + let hit_ms = t_hit + .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let t_apply = crate::perf::enabled().then(Instant::now); if self.shift_down || self.select_remove_mode { - for h in &handles { - self.tabs[i].scene.deselect_entity(*h); - } + self.tabs[i].scene.deselect_entities(&handles); } else { if !selection_pick_add && !handles.is_empty() { self.tabs[i].scene.deselect_all(); } - for h in &handles { - self.tabs[i].scene.select_entity(*h, false); - } + self.tabs[i].scene.select_entities(&handles); self.tabs[i].scene.expand_selection_for_groups(&handles); } + let apply_ms = t_apply + .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let t_props = crate::perf::enabled().then(Instant::now); self.refresh_properties(); + if crate::perf::enabled() { + crate::perf_record!( + "[perf] select-commit kind=drag-box crossing={crossing} \ +candidates={cand_ms:.1}ms hit={hit_ms:.1}ms apply={apply_ms:.1}ms \ +properties={:.1}ms picked={}", + t_props.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0), + handles.len(), + ); + } selection_just_completed = true; } } else { @@ -4239,9 +4257,7 @@ impl OpenCADStudio { // leaves the current selection untouched so a stray // drag never discards hard-won picks. if self.shift_down || self.select_remove_mode { - for h in &handles { - self.tabs[i].scene.deselect_entity(*h); - } + self.tabs[i].scene.deselect_entities(&handles); } else { // PICKADD 0 (#226): a plain marquee REPLACES // the selection (empty results still leave it @@ -4249,9 +4265,7 @@ impl OpenCADStudio { if !selection_pick_add && !handles.is_empty() { self.tabs[i].scene.deselect_all(); } - for h in &handles { - self.tabs[i].scene.select_entity(*h, false); - } + self.tabs[i].scene.select_entities(&handles); self.tabs[i].scene.expand_selection_for_groups(&handles); } self.refresh_properties(); @@ -4268,6 +4282,8 @@ impl OpenCADStudio { if box_anchor.is_none() { let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); let click_world = self.cursor_model_point(i, &edit_cam, p, bounds); + let t_arm = crate::perf::enabled().then(Instant::now); + let prior_selection = self.tabs[i].scene.selected.len(); let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near( all_wires, click_world, @@ -4402,6 +4418,16 @@ impl OpenCADStudio { // (#234). Computed before the selection // borrow so the &self projection can't clash. let anchor_world = self.cursor_model_point(i, &edit_cam, p, bounds); + if let Some(t) = t_arm { + let arm_ms = t.elapsed().as_secs_f64() * 1000.0; + if arm_ms >= 5.0 { + crate::perf_record!( + "[perf] select-arm {arm_ms:>7.1}ms pick+clear, \ +was_selected={}", + prior_selection, + ); + } + } let mut sel = self.tabs[i].scene.selection.borrow_mut(); // Full-canvas space: ViewportMove updates // box_current in canvas coords and the overlay @@ -4485,6 +4511,10 @@ impl OpenCADStudio { aabb }, ); + // This is the path a click-move-click window takes, as + // opposed to a press-drag; it is the one a large selection + // actually goes through. + let t_sel = crate::perf::enabled().then(Instant::now); let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb( all_wires, world_aabb, @@ -4493,9 +4523,16 @@ impl OpenCADStudio { eye, bounds, ); + let cand_ms = + t_sel.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let t_hit = crate::perf::enabled().then(Instant::now); let candidate_handles = self.tabs[i] .scene .interaction_candidate_handles(&area_candidates); + // `hit` covers five things, and which of them owns it has + // never been separated: resolving candidate handles, the wire + // box test, the two hatch tests and the two mesh tests. + let m_handles = t_hit.map(|t| t.elapsed().as_secs_f64() * 1000.0); let mut handles: Vec = scene::pick::hit_test::box_hit( a, p, @@ -4508,6 +4545,7 @@ impl OpenCADStudio { .into_iter() .filter_map(|s| Scene::handle_from_wire_name(s)) .collect(); + let m_wires = t_hit.map(|t| t.elapsed().as_secs_f64() * 1000.0); handles.extend(scene::pick::hit_test::box_hit_hatch( a, p, @@ -4530,6 +4568,7 @@ impl OpenCADStudio { bounds, candidate_handles.as_ref(), )); + let m_hatch = t_hit.map(|t| t.elapsed().as_secs_f64() * 1000.0); handles.extend(self.tabs[i].scene.mesh_box_hit( a, p, @@ -4548,27 +4587,45 @@ impl OpenCADStudio { bounds, candidate_handles.as_ref(), )); + let hit_ms = t_hit.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let t_filter = crate::perf::enabled().then(Instant::now); // Selection filter: keep only allowed types. handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h)); + let filter_ms = + t_filter.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let t_apply = crate::perf::enabled().then(Instant::now); // Accumulate (issue #83): a plain box adds to the // current selection, Shift+box removes the boxed // entities. An empty box leaves the selection alone // so an accidental empty drag never discards it. // PICKADD 0 (#226): a plain box REPLACES instead. if self.shift_down || self.select_remove_mode { - for h in &handles { - self.tabs[i].scene.deselect_entity(*h); - } + self.tabs[i].scene.deselect_entities(&handles); } else { if !selection_pick_add && !handles.is_empty() { self.tabs[i].scene.deselect_all(); } - for h in &handles { - self.tabs[i].scene.select_entity(*h, false); - } + self.tabs[i].scene.select_entities(&handles); self.tabs[i].scene.expand_selection_for_groups(&handles); } + let apply_ms = + t_apply.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let t_props = crate::perf::enabled().then(Instant::now); self.refresh_properties(); + if crate::perf::enabled() { + crate::perf_record!( + "[perf] select-commit kind=window crossing={crossing} \ +candidates={cand_ms:.1}ms hit={hit_ms:.1}ms [handles={:.1} wires={:.1} \ +hatch={:.1} mesh={:.1}] filter={filter_ms:.1}ms apply={apply_ms:.1}ms \ +properties={:.1}ms picked={}", + m_handles.unwrap_or(0.0), + m_wires.unwrap_or(0.0) - m_handles.unwrap_or(0.0), + m_hatch.unwrap_or(0.0) - m_wires.unwrap_or(0.0), + hit_ms - m_hatch.unwrap_or(0.0), + t_props.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0), + handles.len(), + ); + } let mut sel = self.tabs[i].scene.selection.borrow_mut(); sel.box_last = Some((a, p)); sel.box_last_crossing = crossing; @@ -4704,15 +4761,17 @@ impl OpenCADStudio { return self.begin_text_edit(handle); } // Double-clicking a block with attributes opens the - // attribute editor (edit its values); a block with - // no attributes opens the block editor (BEDIT) — a - // space tab scoped to the block's own geometry, so - // edits reflect in every instance. (#136, #192, #261) + // attribute editor (edit its values). For blocks without + // attributes, Options > Drawing chooses BEDIT (default) + // or in-place REFEDIT. (#136, #192, #261) let insert_has_attrs = matches!( self.tabs[i].scene.document.get_entity(handle), Some(AcadEntityType::Insert(ins)) if !ins.attributes.is_empty() ); - if insert_has_attrs && self.tabs[i].active_block_edit.is_none() { + if insert_has_attrs + && self.double_click_block_attedit + && self.tabs[i].active_block_edit.is_none() + { return Task::done(Message::AttrEditorOpen(handle)); } let is_insert = matches!( @@ -4720,8 +4779,14 @@ impl OpenCADStudio { Some(AcadEntityType::Insert(_)) ); if is_insert && self.tabs[i].refedit_session.is_none() { + let command = if self.double_click_block_refedit { + "REFEDIT_BEGIN" + } else { + "BEDIT_BEGIN" + }; return Task::done(Message::Command(format!( - "BEDIT_BEGIN:{}", + "{}:{}", + command, handle.value() ))); } @@ -5744,3 +5809,57 @@ impl OpenCADStudio { Task::none() } } + +#[cfg(test)] +mod selection_preview_tests { + use super::*; + use crate::app::{HoverDwell, OpenCADStudio, HOVER_DWELL_MS}; + + /// Drive one settled rollover pick over a line and report what the scene + /// ended up highlighting. + fn rollover_hits(preview: u8) -> bool { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.model_space.selection_preview = preview; + let _ = app.run_command_line("LINE 0,0 10,10"); + app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0); + let _ = app.run_command_line("ZOOM EXTENTS"); + + // The pick only runs once the cursor has been still for the dwell + // window, so the arming timestamp is backdated past it. + app.hover_dwell = Some(HoverDwell { + last_move_at: Instant::now() + - std::time::Duration::from_millis(HOVER_DWELL_MS as u64 * 2), + point: iced::Point::new(400.0, 300.0), + tile_size: (800.0, 600.0), + tab: i, + }); + let _ = app.on_hover_dwell_tick(); + app.tabs[i].scene.hover_highlight.is_some() + } + + /// Bit 1 of `SELECTIONPREVIEW` is the rollover that runs with no command + /// active, and the Options card gives it a checkbox. The config comment + /// used to describe the bits the wrong way round, so which bit does what + /// is worth asserting rather than reading. + #[test] + fn the_idle_rollover_follows_bit_one() { + assert!( + rollover_hits(1), + "bit 1 set: the line under the cursor must be highlighted", + ); + assert!( + rollover_hits(3), + "both bits set: still highlighted", + ); + assert!( + !rollover_hits(0), + "preview off: nothing may be highlighted", + ); + assert!( + !rollover_hits(2), + "only the in-command bit: the idle rollover stays off", + ); + } +} diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 2c82dce24..05d1abbde 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2977,7 +2977,7 @@ pub(super) fn doc_tab_bar<'a>( let new_btn = button(text("+").size(14)) .on_press(Message::TabNew) .height(iced::Length::Fixed(28.0)) - .padding([5, 10]) + .padding([4, 8]) .style(|theme: &Theme, status| { let palette = theme.palette(); let hovered = matches!( @@ -3007,7 +3007,7 @@ pub(super) fn doc_tab_bar<'a>( top: 0.0, right: 0.0, bottom: 0.0, - left: 6.0, + left: 2.0, }) .into(), ); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 42f399036..bf450e75f 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -201,7 +201,14 @@ impl OpenCADStudio { self.language, self.options_tab, self.cursor_size, - self.pick_box, + crate::ui::window::options::SelectionPrefs { + pick_box: self.pick_box, + pick_add: self.pick_add, + pick_drag_rect: self.pick_drag_rect, + grip_object_limit: self.grip_object_limit, + }, + self.double_click_block_refedit, + self.double_click_block_attedit, self.cursor_type, self.crosshair_color, &self.crosshair_color_input, diff --git a/src/command.rs b/src/command.rs index b40bbcc68..c380356a2 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1578,6 +1578,11 @@ pub enum CmdResult { taper_angle: f64, color: [f32; 4], }, + /// Thicken one or more persistent surface bodies without consuming them. + ThickenEntities { + handles: Vec, + distance: f64, + }, /// Resolve a profile, bounded area, or solid face for PRESSPULL. PresspullPick { handle: Option, diff --git a/src/entities/extended.rs b/src/entities/extended.rs index d5740ec2a..38fe46ddd 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -3,6 +3,7 @@ use acadrust::entities::{ PointCloudData, PointCloudExData, RemoteTextData, SectionObjectData, }; use acadrust::types::{Handle, Transform, Vector3}; +use acadrust::xdata::{ExtendedDataRecord, XDataValue}; use crate::t; use crate::command::EntityTransform; @@ -17,6 +18,69 @@ use crate::scene::model::object::{ use crate::scene::model::wire_model::SnapHint; const NAN: [f64; 3] = [f64::NAN; 3]; +const SECTION_SLICE_APP: &str = "IsSlice"; +const SECTION_THICKNESS_APP: &str = "ThicknessDepth"; + +pub(crate) fn section_is_slice(entity: &ExtendedEntity) -> bool { + entity + .common + .extended_data + .get_record(SECTION_SLICE_APP) + .is_some_and(|record| { + record + .values + .iter() + .rev() + .find_map(|value| match value { + XDataValue::Integer16(value) => Some(*value != 0), + XDataValue::Integer32(value) => Some(*value != 0), + _ => None, + }) + .unwrap_or(false) + }) +} + +pub(crate) fn section_slice_depth(entity: &ExtendedEntity) -> Option { + entity + .common + .extended_data + .get_record(SECTION_THICKNESS_APP)? + .values + .iter() + .find_map(|value| match value { + XDataValue::Real(value) | XDataValue::Distance(value) => Some(*value), + _ => None, + }) + .filter(|value| value.is_finite()) + .map(f64::abs) +} + +pub(crate) fn set_section_slice_metadata( + entity: &mut ExtendedEntity, + enabled: bool, + depth: f64, +) { + if !enabled { + entity.common.extended_data.remove_record(SECTION_SLICE_APP); + entity + .common + .extended_data + .remove_record(SECTION_THICKNESS_APP); + return; + } + + let mut slice = ExtendedDataRecord::new(SECTION_SLICE_APP); + slice.values = vec![XDataValue::Integer16(0), XDataValue::Integer16(1)]; + entity.common.extended_data.upsert_record(slice); + + let mut thickness = ExtendedDataRecord::new(SECTION_THICKNESS_APP); + thickness.values = vec![XDataValue::Real(if depth.is_finite() { + depth.abs() + } else { + 0.0 + })]; + entity.common.extended_data.upsert_record(thickness); +} fn vector_text(value: Vector3) -> String { format!("{:.6}, {:.6}, {:.6}", value.x, value.y, value.z) @@ -46,6 +110,22 @@ fn bool_prop(label: &str, field: &'static str, value: bool) -> Property { } } +fn choice_prop( + label: &str, + field: &'static str, + selected: &str, + options: &[&str], +) -> Property { + Property { + label: label.into(), + field, + value: PropValue::Choice { + selected: selected.to_string(), + options: options.iter().map(|option| (*option).to_string()).collect(), + }, + } +} + fn push_segment(points: &mut Vec<[f64; 3]>, first: [f64; 3], second: [f64; 3]) { if !points.is_empty() { points.push(NAN); @@ -211,14 +291,73 @@ fn append_arc_aligned_text(points: &mut Vec<[f64; 3]>, data: &ArcAlignedTextData fn section_lines(data: &SectionObjectData) -> Vec<[f64; 3]> { let mut points = Vec::new(); + let vertical = normalized(data.vertical_direction, Vector3::UNIT_Z); + let offset = |point: &Vector3, distance: f64| { + [ + point.x + vertical.x * distance, + point.y + vertical.y * distance, + point.z + vertical.z * distance, + ] + }; push_chain( &mut points, data.vertices.iter().map(|p| [p.x, p.y, p.z]), ); + push_chain( + &mut points, + data.vertices.iter().map(|point| offset(point, data.top_height)), + ); + push_chain( + &mut points, + data.vertices.iter().map(|point| offset(point, -data.bottom_height)), + ); + for point in &data.vertices { + push_segment( + &mut points, + offset(point, -data.bottom_height), + offset(point, data.top_height), + ); + } push_chain( &mut points, data.back_line_vertices.iter().map(|p| [p.x, p.y, p.z]), ); + if !data.back_line_vertices.is_empty() { + push_chain( + &mut points, + data.back_line_vertices + .iter() + .map(|point| offset(point, data.top_height)), + ); + push_chain( + &mut points, + data.back_line_vertices + .iter() + .map(|point| offset(point, -data.bottom_height)), + ); + for point in &data.back_line_vertices { + push_segment( + &mut points, + offset(point, -data.bottom_height), + offset(point, data.top_height), + ); + } + if let (Some(front), Some(back)) = ( + data.vertices.first().zip(data.vertices.last()), + data.back_line_vertices + .first() + .zip(data.back_line_vertices.last()), + ) { + for (a, b) in [(front.0, back.0), (front.1, back.1)] { + push_segment(&mut points, offset(a, data.top_height), offset(b, data.top_height)); + push_segment( + &mut points, + offset(a, -data.bottom_height), + offset(b, -data.bottom_height), + ); + } + } + } points } @@ -483,56 +622,220 @@ fn to_render(entity: &ExtendedEntity, document: &acadrust::CadDocument) -> Optio }) } -fn section_properties(data: &SectionObjectData) -> Vec { +fn section_properties(entity: &ExtendedEntity, data: &SectionObjectData) -> Vec { let vertices = data .vertices .iter() - .enumerate() - .map(|(index, point)| format!("{}: {}", index + 1, vector_text(*point))) + .map(|point| vector_text(*point)) .collect::>() - .join("\n"); - let back_vertices = data - .back_line_vertices + .join("; "); + let kind = section_kind(entity, data); + let viewing = section_viewing_direction(data); + let offset = section_plane_offset(data); + let depth = section_slice_depth(entity).unwrap_or_else(|| section_depth(data)); + vec![PropSection { + title: t!("Section Plane").into_owned(), + props: vec![ + text_prop(t!("Name").as_ref(), "ext_section_name", &data.name), + choice_prop( + t!("State").as_ref(), + "ext_section_state", + kind, + &["Plane", "Slice", "Boundary", "Volume"], + ), + text_prop( + t!("Viewing Direction").as_ref(), + "ext_section_viewing", + &vector_text(viewing), + ), + text_prop( + t!("Vertical Direction").as_ref(), + "ext_section_vertical", + &vector_text(data.vertical_direction), + ), + ro_prop(t!("Normal").as_ref(), "ext_section_normal", vector_text(viewing)), + bool_prop( + t!("Live Section Enabled").as_ref(), + "ext_section_live", + data.flags & 1 != 0, + ), + edit_prop( + t!("Indicator Transparency").as_ref(), + "ext_section_alpha", + f64::from(data.indicator_alpha), + ), + Property { + label: t!("Indicator Fill Color").into_owned(), + field: "indicator_fill_color", + value: PropValue::ColorChoice(data.indicator_color), + }, + edit_prop(t!("Elevation").as_ref(), "ext_section_elevation", offset), + edit_prop(t!("Top Height").as_ref(), "ext_section_top", data.top_height), + edit_prop(t!("Bottom Height").as_ref(), "ext_section_bottom", data.bottom_height), + ro_prop( + t!("Number of Vertices").as_ref(), + "ext_section_vertex_count", + data.vertices.len().to_string(), + ), + text_prop(t!("Vertices").as_ref(), "ext_section_vertices", &vertices), + ro_prop( + t!("Settings").as_ref(), + "ext_section_settings", + handle_text(data.settings_handle), + ), + choice_prop( + t!("State2").as_ref(), + "ext_section_state2", + kind, + &["Plane", "Slice", "Boundary", "Volume"], + ), + edit_prop(t!("Slice Depth").as_ref(), "ext_section_depth", depth), + edit_prop( + t!("Section Plane Offset").as_ref(), + "ext_section_offset", + offset, + ), + ], + }] +} + +fn section_tangent(data: &SectionObjectData) -> Vector3 { + let Some((first, last)) = data.vertices.first().zip(data.vertices.last()) else { + return Vector3::UNIT_X; + }; + normalized(*last - *first, Vector3::UNIT_X) +} + +fn section_viewing_direction(data: &SectionObjectData) -> Vector3 { + let tangent = section_tangent(data); + let vertical = normalized(data.vertical_direction, Vector3::UNIT_Z); + let base = normalized(vertical.cross(&tangent), Vector3::new(0.0, 0.0, -1.0)); + if data.flags & 4 != 0 { base } else { -base } +} + +fn section_depth(data: &SectionObjectData) -> f64 { + data.vertices + .first() + .zip(data.back_line_vertices.first()) + .map_or(0.0, |(front, back)| (*back - *front).length()) +} + +fn section_kind(entity: &ExtendedEntity, data: &SectionObjectData) -> &'static str { + if section_is_slice(entity) { + return "Slice"; + } + match data.state { + 1 => "Plane", + 4 => "Volume", + 2 => "Boundary", + _ => "Plane", + } +} + +fn section_plane_offset(data: &SectionObjectData) -> f64 { + let Some(first) = data.vertices.first() else { + return 0.0; + }; + -section_viewing_direction(data).dot(first) +} + +fn parse_vector(value: &str) -> Option { + let values = value + .split(|character: char| character == ',' || character == ';' || character.is_whitespace()) + .filter(|part| !part.is_empty()) + .map(str::parse::) + .collect::, _>>() + .ok()?; + (values.len() == 3).then(|| Vector3::new(values[0], values[1], values[2])) +} + +fn parse_vertices(value: &str) -> Option> { + let values = value + .split(|character: char| { + character == ',' || character == ';' || character == ':' || character.is_whitespace() + }) + .filter(|part| !part.trim().is_empty()) + .map(str::parse::) + .collect::, _>>() + .ok()?; + if values.len() < 6 || values.len() % 3 != 0 { + return None; + } + let vertices = values + .chunks_exact(3) + .map(|point| Vector3::new(point[0], point[1], point[2])) + .collect::>(); + let valid = vertices + .first() + .zip(vertices.last()) + .is_some_and(|(first, last)| (*last - *first).length() > 1e-12); + valid.then_some(vertices) +} + +fn set_section_depth(data: &mut SectionObjectData, depth: f64) { + let depth = depth.max(0.0); + if depth <= 1e-12 || data.vertices.is_empty() { + data.back_line_vertices.clear(); + return; + } + let viewing = section_viewing_direction(data); + data.back_line_vertices = data + .vertices .iter() - .enumerate() - .map(|(index, point)| format!("{}: {}", index + 1, vector_text(*point))) - .collect::>() - .join("\n"); - vec![ - PropSection { - title: t!("Section Object").into_owned(), - props: vec![ - text_prop(t!("Name").as_ref(), "ext_section_name", &data.name), - ro_prop(t!("State").as_ref(), "ext_section_state", data.state.to_string()), - ro_prop(t!("Flags").as_ref(), "ext_section_flags", data.flags.to_string()), - ro_prop(t!("Vertical Direction").as_ref(), - "ext_section_vertical", - vector_text(data.vertical_direction), - ), - edit_prop(t!("Top Height").as_ref(), "ext_section_top", data.top_height), - edit_prop(t!("Bottom Height").as_ref(), "ext_section_bottom", data.bottom_height), - ro_prop(t!("Indicator Alpha").as_ref(), - "ext_section_alpha", - data.indicator_alpha.to_string(), - ), - ro_prop(t!("Indicator Color").as_ref(), - "ext_section_color", - format!("{:?}", data.indicator_color), - ), - ro_prop(t!("Settings").as_ref(), - "ext_section_settings", - handle_text(data.settings_handle), - ), - ], - }, - PropSection { - title: t!("Section Vertices").into_owned(), - props: vec![ - ro_prop(t!("Cutting Line").as_ref(), "ext_section_vertices", vertices), - ro_prop(t!("Back Line").as_ref(), "ext_section_back_vertices", back_vertices), - ], - }, - ] + .map(|point| *point + viewing * depth) + .collect(); +} + +fn set_section_kind(data: &mut SectionObjectData, value: &str) { + let span = data + .vertices + .first() + .zip(data.vertices.last()) + .map_or(1.0, |(first, last)| (*last - *first).length().max(1e-4)); + match value.trim().to_ascii_uppercase().as_str() { + "SLICE" => { + data.state = 1; + data.back_line_vertices.clear(); + } + "BOUNDARY" => { + data.state = 2; + set_section_depth(data, span); + } + "VOLUME" => { + data.state = 4; + set_section_depth(data, span); + } + _ => { + data.state = 1; + data.back_line_vertices.clear(); + } + } +} + +fn move_section_to_offset(data: &mut SectionObjectData, desired: f64) { + let current = section_plane_offset(data); + let delta = section_viewing_direction(data) * (current - desired); + for point in data.vertices.iter_mut().chain(data.back_line_vertices.iter_mut()) { + *point = *point + delta; + } +} + +fn set_viewing_direction(data: &mut SectionObjectData, value: Vector3) { + if value.length() <= 1e-12 || data.vertices.len() < 2 { + return; + } + let desired = normalized(value, section_viewing_direction(data)); + let tangent = section_tangent(data); + let vertical = tangent.cross(&desired); + if vertical.length() <= 1e-12 { + return; + } + data.vertical_direction = normalized(vertical, data.vertical_direction); + data.flags |= 4; + let depth = section_depth(data); + if depth > 0.0 { + set_section_depth(data, depth); + } } fn arc_text_properties(data: &ArcAlignedTextData) -> Vec { @@ -892,7 +1195,7 @@ fn properties(entity: &ExtendedEntity) -> Vec { title: t!("Camera").into_owned(), props: vec![ro_prop(t!("View").as_ref(), "ext_camera_view", handle_text(*view_handle))], }], - ExtendedEntityData::SectionObject(data) => section_properties(data), + ExtendedEntityData::SectionObject(data) => section_properties(entity, data), ExtendedEntityData::ArcAlignedText(data) => arc_text_properties(data), ExtendedEntityData::RemoteText(data) => remote_text_properties(data), ExtendedEntityData::GeoPositionMarker(data) => geo_marker_properties(data), @@ -1039,14 +1342,97 @@ fn set_f64(value: &str, target: &mut f64) { } } +fn apply_section_prop(entity: &mut ExtendedEntity, field: &str, value: &str) { + let was_slice = section_is_slice(entity); + let stored_slice_depth = section_slice_depth(entity).unwrap_or(0.0); + let ExtendedEntityData::SectionObject(data) = &mut entity.data else { + return; + }; + let mut slice_update = None; + match field { + "ext_section_name" => data.name = value.to_string(), + "ext_section_state" | "ext_section_state2" => { + let kind = value.trim().to_ascii_uppercase(); + let span = data + .vertices + .first() + .zip(data.vertices.last()) + .map_or(1.0, |(first, last)| (*last - *first).length().max(1e-4)); + set_section_kind(data, &kind); + slice_update = Some((kind == "SLICE", (span / 60.0).max(1e-4))); + } + "ext_section_viewing" => { + if let Some(direction) = parse_vector(value) { + set_viewing_direction(data, direction); + } + } + "ext_section_vertical" => { + if let Some(direction) = parse_vector(value) { + let depth = section_depth(data); + let direction = normalized(direction, data.vertical_direction); + if direction.dot(§ion_tangent(data)).abs() < 1.0 - 1e-9 { + data.vertical_direction = direction; + if depth > 0.0 { + set_section_depth(data, depth); + } + } + } + } + "ext_section_live" => data.flags ^= 1, + "ext_section_alpha" => { + if let Ok(alpha) = value.trim().parse::() { + data.indicator_alpha = alpha.clamp(0, 100); + } + } + "ext_section_elevation" | "ext_section_offset" => { + if let Some(offset) = parse_f64(value) { + move_section_to_offset(data, offset); + } + } + "ext_section_top" => { + if let Some(height) = parse_f64(value).filter(|height| *height >= 0.0) { + data.top_height = height; + } + } + "ext_section_bottom" => { + if let Some(height) = parse_f64(value).filter(|height| *height >= 0.0) { + data.bottom_height = height; + } + } + "ext_section_vertices" => { + if let Some(vertices) = parse_vertices(value) { + let depth = section_depth(data); + data.vertices = vertices; + if depth > 0.0 { + set_section_depth(data, depth); + } + } + } + "ext_section_depth" => { + if let Some(depth) = parse_f64(value) { + if was_slice { + slice_update = Some((true, depth)); + } else { + set_section_depth(data, depth); + } + } + } + _ => {} + } + + if let Some((enabled, depth)) = slice_update { + set_section_slice_metadata(entity, enabled, depth); + } else if was_slice && field != "ext_section_depth" { + set_section_slice_metadata(entity, true, stored_slice_depth); + } +} + fn apply_geom_prop(entity: &mut ExtendedEntity, field: &str, value: &str) { + if matches!(entity.data, ExtendedEntityData::SectionObject(_)) { + apply_section_prop(entity, field, value); + return; + } match &mut entity.data { - ExtendedEntityData::SectionObject(data) => match field { - "ext_section_name" => data.name = value.to_string(), - "ext_section_top" => set_f64(value, &mut data.top_height), - "ext_section_bottom" => set_f64(value, &mut data.bottom_height), - _ => {} - }, ExtendedEntityData::ArcAlignedText(data) => match field { "ext_arc_text" => data.text = value.to_string(), "ext_arc_font" => data.font_name = value.to_string(), @@ -1398,6 +1784,8 @@ fn transformed_planar_angle( fn apply_transform(entity: &mut ExtendedEntity, requested: &EntityTransform) { let transform = entity_transform(requested); let scale = scalar_scale(requested); + let slice_depth = section_is_slice(entity) + .then(|| section_slice_depth(entity).unwrap_or(0.0) * scale); match &mut entity.data { ExtendedEntityData::SectionObject(data) => { for point in data @@ -1536,6 +1924,9 @@ fn apply_transform(entity: &mut ExtendedEntity, requested: &EntityTransform) { } _ => {} } + if let Some(depth) = slice_depth { + set_section_slice_metadata(entity, true, depth); + } } impl RenderConvertible for ExtendedEntity { diff --git a/src/entities/names.rs b/src/entities/names.rs index 9a00c9ffe..c26b252f2 100644 --- a/src/entities/names.rs +++ b/src/entities/names.rs @@ -66,6 +66,11 @@ pub fn ui_name(e: &EntityType) -> &'static str { EntityType::Light(_) => "Light", EntityType::SectionSymbol(_) => "Section Symbol", EntityType::ViewBorder(_) => "View Border", + EntityType::Extended(extended) + if matches!( + extended.data, + acadrust::entities::ExtendedEntityData::SectionObject(_) + ) => "Section Plane", EntityType::Extended(_) => "Extended Entity", EntityType::Seqend(_) => "Seqend", EntityType::Unknown(_) => "Unknown", @@ -79,6 +84,12 @@ pub fn ui_name(e: &EntityType) -> &'static str { /// (the numeric `DWG_TYPE_` placeholder). pub fn ui_name_or_class(e: &EntityType) -> String { if let EntityType::Extended(entity) = e { + if matches!( + entity.data, + acadrust::entities::ExtendedEntityData::SectionObject(_) + ) { + return "Section Plane".to_string(); + } return entity.class_name().to_string(); } if let EntityType::Unknown(u) = e { @@ -148,6 +159,11 @@ pub fn dxf_name(e: &EntityType) -> &'static str { EntityType::Light(_) => "LIGHT", EntityType::SectionSymbol(_) => "SECTIONLINE", EntityType::ViewBorder(_) => "DRAWINGVIEW", + EntityType::Extended(extended) + if matches!( + extended.data, + acadrust::entities::ExtendedEntityData::SectionObject(_) + ) => "SECTIONOBJECT", _ => "ENTITY", } } diff --git a/src/io/mod.rs b/src/io/mod.rs index 0a49833ac..04e8de258 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -744,18 +744,6 @@ pub fn load_bytes(name: &str, bytes: Vec) -> Result { } } -/// [`load_bytes`] plus the same finalization and corruption handling the -/// canonical path-based open (`load_file_for_open`/`finalize_loaded_outcome`) -/// already runs — for a byte-based open that nonetheless has a real -/// filesystem path behind it (automation's `"open"` op reads the file into -/// memory itself, e.g. to go through an edit lease, but the drawing still -/// lives at `path`). Without this, automation-opened documents kept -/// legacy non-zero block origins un-normalized, left relative raster/material -/// paths unresolved, carried no `source_path`, and skipped the corrupt-entity -/// purge the UI open path always runs — silently behaving differently from a -/// UI open of the exact same file. `load_bytes` itself is unchanged (and -/// still used directly by the byte round-trip tests, which have no real path -/// and don't want this finalization). #[cfg(not(target_arch = "wasm32"))] pub fn load_bytes_finalized(path: &Path, bytes: Vec) -> Result<(CadDocument, usize), String> { let name = path @@ -2061,17 +2049,7 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool { // rarely use this many — and parser desync produces exactly-100_000-vertex // junk records. const MAX_VERTS: usize = 100_000; - // A MINSERT's row/column counts are parsed `u16`s whose unchecked product - // (`Insert::instance_count`) drives a per-instance allocation in - // `render_graph::array_offsets` and a full per-instance render-graph walk - // for every one — a corrupt or adversarial 65535x65535 pair requests over - // four billion instances (tens of GiB) long before any GPU-side chunking - // limit could help. Kept in sync with the identical failsafe clamp in - // `render_graph::array_offsets`, which guards inserts that reach the - // render graph some other way (e.g. built at runtime, not loaded). - const MAX_MINSERT_INSTANCES: usize = 20_000; match e { - E::Insert(i) => i.instance_count() > MAX_MINSERT_INSTANCES, E::LwPolyline(p) => { !finite_unit_normal(&p.normal) || p.vertices.len() >= MAX_VERTS @@ -2451,11 +2429,11 @@ mod corrupt_guard_tests { // product can reach into the billions) must be rejected before it can // drive the render graph's per-instance allocation and expansion. #[test] - fn rejects_pathological_minsert_counts() { + fn preserves_large_minsert_data_while_rendering_is_bounded() { let mut i = acadrust::entities::Insert::new("BLOCK", Vector3::ZERO); i.row_count = u16::MAX; i.column_count = u16::MAX; - assert!(is_entity_corrupt(&EntityType::Insert(i))); + assert!(!is_entity_corrupt(&EntityType::Insert(i))); } // An ordinary array insert, well under the budget, is valid source data. diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index de350e331..1e09a7e20 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4120,6 +4120,16 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Pick box size" => Some(("common", "pick-box-size")), "Controls both the visible selection box and the click aperture." => Some(("common", "controls-both-the-visible-selection-box-and-the-click-aperture")), "Visual Effect Settings" => Some(("common", "visual-effect-settings")), + "Selection Modes" => Some(("common", "selection-modes")), + "Use Shift to add to selection (PICKADD)" => Some(("common", "use-shift-to-add-to-selection-pickadd")), + "Press and drag draws a rectangle instead of a lasso (PICKDRAG)" => Some(("common", "press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag")), + "Show selection effect (SELECTIONEFFECT)" => Some(("common", "show-selection-effect-selectioneffect")), + "Preview selection when no command is active" => Some(("common", "preview-selection-when-no-command-is-active")), + "Preview selection during a command" => Some(("common", "preview-selection-during-a-command")), + "Highlights the object under the cursor before it is picked (SELECTIONPREVIEW)." => Some(("common", "highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview")), + "Object limit for grips" => Some(("common", "object-limit-for-grips")), + "Unlimited" => Some(("common", "unlimited")), + "Past this many selected objects no grips are drawn; 0 removes the limit (GRIPOBJLIMIT)." => Some(("common", "past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit")), "Indicate selection area with transparent fill (SELECTIONAREA)" => Some(("common", "indicate-selection-area-with-transparent-fill-selectionarea")), "Selection area opacity" => Some(("common", "selection-area-opacity")), "Transparency of the window and crossing selection areas (SELECTIONAREAOPACITY)." => Some(("common", "transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity")), @@ -4406,7 +4416,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "LAYERSTATE: no saved states. Use LAYERSTATE SAVE ." => Some(("common", "layerstate-no-saved-states-use-layerstate-save-name")), "Usage: COLOR " => Some(("common", "usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white")), "Usage: CHPROP (props: LAYER COLOR LINETYPE LTSCALE)" => Some(("common", "usage-chprop-prop-val-props-layer-color-linetype-ltscale")), - "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER | CLAYER CELTYPE TEXTSTYLE (read-only)" => Some(("common", "setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram")), + "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)" => Some(("common", "setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram")), "SETVAR: DIMCONTINUEMODE requires 0 or 1." => Some(("common", "setvar-dimcontinuemode-requires-0-or-1")), "SETVAR: DONUTID requires a finite value greater than or equal to zero." => Some(("common", "setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero")), "SETVAR: DONUTOD requires a finite value greater than zero." => Some(("common", "setvar-donutod-requires-a-finite-value-greater-than-zero")), diff --git a/src/modules/draw/draw/donut.rs b/src/modules/draw/draw/donut.rs index fe1fb8412..12d552ba1 100644 --- a/src/modules/draw/draw/donut.rs +++ b/src/modules/draw/draw/donut.rs @@ -253,7 +253,7 @@ fn donut_wire( WireModel::solid_f64("rubber_band".into(), points, WireModel::CYAN, false) } -fn make_donut(cx: f64, cy: f64, elevation: f64, inner_r: f64, outer_r: f64) -> EntityType { +pub(crate) fn make_donut(cx: f64, cy: f64, elevation: f64, inner_r: f64, outer_r: f64) -> EntityType { use acadrust::types::Vector2; let r_avg = (inner_r + outer_r) / 2.0; let width = outer_r - inner_r; diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index e5cfce0fb..3f5788237 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -513,6 +513,172 @@ impl CadCommand for ExtrudeCommand { } } +// ── THICKEN command ──────────────────────────────────────────────────────── + +pub struct ThickenCommand { + step: ThickenStep, + handles: Vec, + first_point: Option, + last_distance: f64, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ThickenStep { + Pick, + Distance, + SecondPoint, +} + +fn thicken_default() -> &'static Mutex { + static DEFAULT: OnceLock> = OnceLock::new(); + DEFAULT.get_or_init(|| Mutex::new(0.0)) +} + +impl ThickenCommand { + pub fn new(preselection: Vec<(Handle, EntityType)>) -> Self { + let handles = preselection + .into_iter() + .filter_map(|(handle, entity)| { + matches!(entity, EntityType::Surface(_)).then_some(handle) + }) + .collect::>(); + Self { + step: if handles.is_empty() { + ThickenStep::Pick + } else { + ThickenStep::Distance + }, + handles, + first_point: None, + last_distance: *thicken_default() + .lock() + .unwrap_or_else(|error| error.into_inner()), + } + } + + fn finish(&mut self, distance: f64) -> CmdResult { + self.last_distance = distance; + *thicken_default() + .lock() + .unwrap_or_else(|error| error.into_inner()) = distance; + CmdResult::ThickenEntities { + handles: self.handles.clone(), + distance, + } + } +} + +impl CadCommand for ThickenCommand { + fn name(&self) -> &'static str { + "THICKEN" + } + + fn prompt(&self) -> String { + match self.step { + ThickenStep::Pick => t!("Select surfaces to thicken:").into_owned(), + ThickenStep::Distance => format!( + "{} <{}>:", + t!("Specify thickness"), + crate::entities::common::format_length(self.last_distance) + ), + ThickenStep::SecondPoint => { + t!("Specify second point:").into_owned() + } + } + } + + fn wants_text_input(&self) -> bool { + self.step == ThickenStep::Distance + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.step != ThickenStep::Distance { + return None; + } + let value = text.trim(); + if value.is_empty() { + return None; + } + Some(match crate::entities::common::parse_typed_length(value) { + Some(distance) if distance.is_finite() => self.finish(distance), + _ => CmdResult::ReportError( + t!("Requires numeric distance or two points.").into_owned(), + ), + }) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + ThickenStep::Distance => { + self.first_point = Some(point); + self.step = ThickenStep::SecondPoint; + CmdResult::NeedPoint + } + ThickenStep::SecondPoint => { + let distance = point.distance(self.first_point.unwrap_or(point)); + if distance.is_finite() { + self.finish(distance) + } else { + CmdResult::ReportError( + t!("Requires numeric distance or two points.").into_owned(), + ) + } + } + ThickenStep::Pick => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + ThickenStep::Pick if self.handles.is_empty() => { + CmdResult::Measurement(t!("No surfaces selected.").into_owned()) + } + ThickenStep::Pick => { + self.step = ThickenStep::Distance; + CmdResult::NeedPoint + } + ThickenStep::Distance => self.finish(self.last_distance), + ThickenStep::SecondPoint => CmdResult::ReportError( + t!("Requires numeric distance or two points.").into_owned(), + ), + } + } + + fn is_selection_gathering(&self) -> bool { + self.step == ThickenStep::Pick + } + + fn selection_forces_add(&self) -> bool { + self.step == ThickenStep::Pick + } + + fn inject_selection_entities(&mut self, entities: Vec) { + if self.step != ThickenStep::Pick { + return; + } + self.handles = entities + .into_iter() + .filter_map(|entry| { + matches!(entry.entity, EntityType::Surface(_)).then_some(entry.handle) + }) + .collect(); + } + + fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_undo_step(&mut self) -> Option { + if self.step == ThickenStep::SecondPoint { + self.first_point = None; + self.step = ThickenStep::Distance; + Some(CmdResult::NeedPoint) + } else { + None + } + } +} + // ── PRESSPULL command ───────────────────────────────────────────────────── pub struct PresspullCommand { @@ -2701,3 +2867,29 @@ mod revolve_tests { } } inventory::submit!(crate::command::CommandRegistration { names: &["SWEEP"] }); + +#[cfg(test)] +mod thicken_command_tests { + use super::*; + + #[test] + fn thicken_filters_selection_recovers_from_invalid_text_and_measures_two_points() { + let handle = Handle::new(42); + let mut command = ThickenCommand::new(vec![ + (Handle::new(1), EntityType::Line(acadrust::entities::Line::default())), + (handle, EntityType::Surface(acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Plane))), + ]); + assert!(!command.is_selection_gathering()); + assert_eq!(command.handles, vec![handle]); + assert!(matches!(command.on_text_input("bad"), Some(CmdResult::ReportError(_)))); + assert!(command.input_kind().wants_text()); + assert!(matches!(command.on_text_input("-2"), Some(CmdResult::ThickenEntities { distance: -2.0, .. }))); + assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint)); + assert!(command.on_undo_step().is_some()); + let _ = command.on_point(DVec3::ZERO); + assert!(matches!(command.on_point(DVec3::new(3.0, 4.0, 0.0)), CmdResult::ThickenEntities { distance: 5.0, .. })); + let mut empty = ThickenCommand::new(Vec::new()); + assert!(empty.is_selection_gathering()); + assert!(matches!(empty.on_enter(), CmdResult::Measurement(_))); + } +} diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index 546342d00..8c18f7486 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -5,6 +5,7 @@ pub mod cylinder_cmd; pub mod edge_cmd; pub mod polysolid_cmd; pub mod primitive_cmd; +pub mod sectionplane_cmd; pub mod shell_cmd; pub mod slice_cmd; diff --git a/src/modules/model/sectionplane_cmd.rs b/src/modules/model/sectionplane_cmd.rs new file mode 100644 index 000000000..150e8ef12 --- /dev/null +++ b/src/modules/model/sectionplane_cmd.rs @@ -0,0 +1,537 @@ +use acadrust::entities::{EntityCommon, ExtendedEntity, ExtendedEntityData, SectionObjectData}; +use acadrust::types::{Color, Handle, Vector3}; +use acadrust::EntityType; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; +use crate::scene::model::wire_model::WireModel; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SectionKind { + Plane, + Slice, + Boundary, + Volume, +} + +impl SectionKind { + fn label(self) -> &'static str { + match self { + Self::Plane => "Plane", + Self::Slice => "Slice", + Self::Boundary => "Boundary", + Self::Volume => "Volume", + } + } + + fn state(self) -> i32 { + match self { + Self::Plane | Self::Slice => 1, + Self::Boundary => 2, + Self::Volume => 4, + } + } +} + +#[derive(Clone, Copy)] +enum Orthographic { + Front, + Back, + Top, + Bottom, + Left, + Right, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Step { + Locate, + Through, + DrawStart, + DrawNext, + DrawDirection, + Orthographic, + Type, +} + +/// Creates persistent SECTIONOBJECT entities without modifying source solids. +pub struct SectionPlaneCommand { + step: Step, + kind: SectionKind, + first: Option, + draw_points: Vec, + picked_direction: Option, + working: WorkingPlane, + bounds: (DVec3, DVec3), + empty_extents: bool, + next_name: usize, + notice: Option<&'static str>, +} + +impl SectionPlaneCommand { + pub fn new(bounds: Option<(DVec3, DVec3)>, next_name: usize) -> Self { + let empty_extents = bounds.is_none(); + let bounds = bounds.unwrap_or((DVec3::ZERO, DVec3::splat(4000.0))); + Self { + step: Step::Locate, + kind: SectionKind::Plane, + first: None, + draw_points: Vec::new(), + picked_direction: None, + working: WorkingPlane::default(), + bounds, + empty_extents, + next_name: next_name.max(1), + notice: None, + } + } + + fn corners(&self) -> [DVec3; 8] { + let (lo, hi) = self.bounds; + [ + DVec3::new(lo.x, lo.y, lo.z), + DVec3::new(hi.x, lo.y, lo.z), + DVec3::new(lo.x, hi.y, lo.z), + DVec3::new(hi.x, hi.y, lo.z), + DVec3::new(lo.x, lo.y, hi.z), + DVec3::new(hi.x, lo.y, hi.z), + DVec3::new(lo.x, hi.y, hi.z), + DVec3::new(hi.x, hi.y, hi.z), + ] + } + + fn frame_extents(&self, centre: DVec3, tangent: DVec3, vertical: DVec3) -> (f64, f64, f64, f64) { + let mut t_min = f64::INFINITY; + let mut t_max = f64::NEG_INFINITY; + let mut v_min = f64::INFINITY; + let mut v_max = f64::NEG_INFINITY; + for corner in self.corners() { + let delta = corner - centre; + let t = delta.dot(tangent); + let v = delta.dot(vertical); + t_min = t_min.min(t); + t_max = t_max.max(t); + v_min = v_min.min(v); + v_max = v_max.max(v); + } + let t_span = t_max - t_min; + let v_span = v_max - v_min; + let t_margin = if t_span > 1e-12 { t_span * 0.1 } else { 1.0 }; + let v_margin = if v_span > 1e-12 { + v_span * if self.empty_extents { 0.1 } else { 0.15 } + } else { + 1.0 + }; + (t_min - t_margin, t_max + t_margin, v_min - v_margin, v_max + v_margin) + } + + fn entity(&self, vertices: Vec, vertical: DVec3, positive_view: bool) -> EntityType { + let first = vertices.first().copied().unwrap_or(DVec3::ZERO); + let last = vertices.last().copied().unwrap_or(DVec3::X); + let tangent = (last - first).normalize_or(DVec3::X); + let vertical = (vertical - tangent * vertical.dot(tangent)).normalize_or(DVec3::Z); + let viewing = vertical.cross(tangent).normalize_or(-DVec3::Z) + * if positive_view { 1.0 } else { -1.0 }; + let centre = (first + last) * 0.5; + let (_, _, v_min, v_max) = self.frame_extents(centre, tangent, vertical); + let span = (last - first).length().max(1.0); + let depth = match self.kind { + SectionKind::Plane => 0.0, + SectionKind::Slice => (span / 60.0).max(1e-4), + SectionKind::Boundary | SectionKind::Volume => span, + }; + let back_line_vertices = if matches!(self.kind, SectionKind::Boundary | SectionKind::Volume) { + vertices.iter().map(|point| *point + viewing * depth).collect() + } else { + Vec::new() + }; + let mut entity = ExtendedEntity { + common: EntityCommon::new(), + data: ExtendedEntityData::SectionObject(SectionObjectData { + state: self.kind.state(), + flags: 1 | if positive_view { 4 } else { 0 }, + name: format!("Section Plane ({})", self.next_name), + vertical_direction: Vector3::new(vertical.x, vertical.y, vertical.z), + top_height: v_max.max(0.0), + bottom_height: (-v_min).max(0.0), + indicator_alpha: 70, + indicator_color: Color::from_index(9), + vertices: vertices + .into_iter() + .map(|point| Vector3::new(point.x, point.y, point.z)) + .collect(), + back_line_vertices: back_line_vertices + .into_iter() + .map(|point| Vector3::new(point.x, point.y, point.z)) + .collect(), + settings_handle: Handle::NULL, + }), + }; + crate::entities::extended::set_section_slice_metadata( + &mut entity, + self.kind == SectionKind::Slice, + depth, + ); + EntityType::Extended(entity) + } + + fn line_at(&self, point: DVec3, viewing: DVec3, vertical_hint: DVec3) -> (Vec, DVec3) { + let viewing = viewing.normalize_or(-DVec3::Z); + let vertical_candidate = vertical_hint - viewing * vertical_hint.dot(viewing); + let vertical = if vertical_candidate.length_squared() > 1e-18 { + vertical_candidate.normalize() + } else { + viewing.any_orthonormal_vector() + }; + let tangent = viewing.cross(vertical).normalize_or(DVec3::X); + let (t_min, t_max, _, _) = self.frame_extents(point, tangent, vertical); + (vec![point + tangent * t_min, point + tangent * t_max], vertical) + } + + fn orthographic(&self, kind: Orthographic) -> EntityType { + let (lo, hi) = self.bounds; + let centre = (lo + hi) * 0.5; + let (viewing, vertical, positive) = match kind { + Orthographic::Front => (DVec3::Y, DVec3::Z, true), + Orthographic::Back => (DVec3::Y, DVec3::Z, false), + Orthographic::Top => (-DVec3::Z, DVec3::Y, true), + Orthographic::Bottom => (-DVec3::Z, DVec3::Y, false), + Orthographic::Left => (-DVec3::X, DVec3::Z, false), + Orthographic::Right => (-DVec3::X, DVec3::Z, true), + }; + let (vertices, vertical) = self.line_at(centre, viewing, vertical); + self.entity(vertices, vertical, positive) + } + + fn finish_through(&mut self, through: DVec3) -> CmdResult { + let Some(first) = self.first else { + return CmdResult::NeedPoint; + }; + let tangent = through - first; + if tangent.length_squared() <= 1e-18 { + self.notice = Some("SECTIONPLANE The through point must differ from the first point:"); + return CmdResult::NeedPoint; + } + let tangent = tangent.normalize(); + let vertical_hint = if tangent.dot(self.working.z).abs() < 0.98 { + self.working.z + } else { + self.working.y + }; + let vertical = (vertical_hint - tangent * vertical_hint.dot(tangent)).normalize(); + CmdResult::CommitAndExit(self.entity(vec![first, through], vertical, true)) + } + + fn finish_draw(&mut self, direction: DVec3) -> CmdResult { + if self.draw_points.len() < 2 { + self.notice = Some("SECTIONPLANE Specify at least two different section points:"); + self.step = Step::DrawNext; + return CmdResult::NeedPoint; + } + let first = self.draw_points[0]; + let last = *self.draw_points.last().unwrap_or(&first); + let tangent = (last - first).normalize_or(DVec3::X); + let vertical_hint = if tangent.dot(self.working.z).abs() < 0.98 { + self.working.z + } else { + self.working.y + }; + let vertical = (vertical_hint - tangent * vertical_hint.dot(tangent)).normalize(); + let base_view = vertical.cross(tangent).normalize_or(-DVec3::Z); + let centre = (first + last) * 0.5; + let positive = (direction - centre).dot(base_view) >= 0.0; + let vertices = std::mem::take(&mut self.draw_points); + CmdResult::CommitAndExit(self.entity(vertices, vertical, positive)) + } + + fn choose_kind(&mut self, kind: SectionKind) -> CmdResult { + self.kind = kind; + self.step = Step::Locate; + self.notice = None; + CmdResult::NeedPoint + } +} + +impl CadCommand for SectionPlaneCommand { + fn name(&self) -> &'static str { + "SECTIONPLANE" + } + + fn prompt(&self) -> String { + if let Some(notice) = self.notice { + return crate::t!(notice).into_owned(); + } + match self.step { + Step::Locate => format!( + "SECTIONPLANE Type = {}\n{}", + self.kind.label(), + crate::t!("Select face or any point to locate section line or [Draw section/Orthographic/Type]:") + ), + Step::Through => crate::t!("SECTIONPLANE Specify through point:").into_owned(), + Step::DrawStart => crate::t!("SECTIONPLANE Specify start point:").into_owned(), + Step::DrawNext if self.draw_points.len() == 1 => { + crate::t!("SECTIONPLANE Specify next point:").into_owned() + } + Step::DrawNext => crate::t!("SECTIONPLANE Specify next point or ENTER to complete:").into_owned(), + Step::DrawDirection => crate::t!("SECTIONPLANE Specify point in direction of section view:").into_owned(), + Step::Orthographic => crate::t!("SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] :").into_owned(), + Step::Type => crate::t!("SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] :").into_owned(), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::Locate => vec![ + CmdOption::new(crate::t!("Draw section").as_ref(), "D"), + CmdOption::new(crate::t!("Orthographic").as_ref(), "O"), + CmdOption::new(crate::t!("Type").as_ref(), "T"), + ], + Step::Orthographic => vec![ + CmdOption::new(crate::t!("Front").as_ref(), "F"), + CmdOption::new(crate::t!("Back").as_ref(), "A"), + CmdOption::new(crate::t!("Top").as_ref(), "T"), + CmdOption::new(crate::t!("Bottom").as_ref(), "B"), + CmdOption::new(crate::t!("Left").as_ref(), "L"), + CmdOption::new(crate::t!("Right").as_ref(), "R"), + ], + Step::Type => vec![ + CmdOption::new(crate::t!("Plane").as_ref(), "P"), + CmdOption::new(crate::t!("Slice").as_ref(), "S"), + CmdOption::new(crate::t!("Boundary").as_ref(), "B"), + CmdOption::new(crate::t!("Volume").as_ref(), "V"), + ], + _ => Vec::new(), + } + } + + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.working = plane; + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + self.notice = None; + match self.step { + Step::Locate => { + self.first = Some(point); + self.step = Step::Through; + CmdResult::NeedPoint + } + Step::Through => self.finish_through(point), + Step::DrawStart => { + self.draw_points.clear(); + self.draw_points.push(point); + self.step = Step::DrawNext; + CmdResult::NeedPoint + } + Step::DrawNext => { + if self.draw_points.last().is_some_and(|last| last.distance(point) <= 1e-9) { + self.notice = Some("SECTIONPLANE Consecutive section points must differ:"); + } else { + self.draw_points.push(point); + } + CmdResult::NeedPoint + } + Step::DrawDirection => self.finish_draw(point), + _ => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + self.notice = None; + match self.step { + Step::DrawNext if self.draw_points.len() >= 2 => { + self.step = Step::DrawDirection; + CmdResult::NeedPoint + } + Step::Orthographic => CmdResult::CommitAndExit(self.orthographic(Orthographic::Top)), + Step::Type => self.choose_kind(SectionKind::Plane), + _ => CmdResult::NeedPoint, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().to_ascii_uppercase(); + Some(match self.step { + Step::Locate => match keyword.as_str() { + "D" | "DRAW" | "DRAWSECTION" => { + self.step = Step::DrawStart; + CmdResult::NeedPoint + } + "O" | "ORTHO" | "ORTHOGRAPHIC" => { + self.step = Step::Orthographic; + CmdResult::NeedPoint + } + "T" | "TYPE" => { + self.step = Step::Type; + CmdResult::NeedPoint + } + _ => { + self.notice = Some("SECTIONPLANE Unknown option. Choose Draw section, Orthographic, or Type:"); + CmdResult::NeedPoint + } + }, + Step::Orthographic => { + let kind = match keyword.as_str() { + "F" | "FRONT" => Some(Orthographic::Front), + "A" | "BACK" => Some(Orthographic::Back), + "T" | "TOP" => Some(Orthographic::Top), + "B" | "BOTTOM" => Some(Orthographic::Bottom), + "L" | "LEFT" => Some(Orthographic::Left), + "R" | "RIGHT" => Some(Orthographic::Right), + _ => None, + }; + match kind { + Some(kind) => CmdResult::CommitAndExit(self.orthographic(kind)), + None => { + self.notice = Some("SECTIONPLANE Unknown alignment. Choose Front, Back, Top, Bottom, Left, or Right:"); + CmdResult::NeedPoint + } + } + } + Step::Type => match keyword.as_str() { + "P" | "PLANE" => self.choose_kind(SectionKind::Plane), + "S" | "SLICE" => self.choose_kind(SectionKind::Slice), + "B" | "BOUNDARY" => self.choose_kind(SectionKind::Boundary), + "V" | "VOLUME" => self.choose_kind(SectionKind::Volume), + _ => { + self.notice = Some("SECTIONPLANE Unknown type. Choose Plane, Slice, Boundary, or Volume:"); + CmdResult::NeedPoint + } + }, + _ => CmdResult::NeedPoint, + }) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::Locate | Step::Orthographic | Step::Type) + } + + fn needs_entity_pick(&self) -> bool { + self.step == Step::Locate + } + + fn entity_pick_accepts_points(&self) -> bool { + true + } + + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn set_entity_pick_direction(&mut self, direction: Option) { + self.picked_direction = direction; + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if handle.is_null() { + self.first = Some(point); + self.step = Step::Through; + return CmdResult::NeedPoint; + } + let Some(face_normal) = self.picked_direction.take().filter(|normal| normal.length_squared() > 1e-18) else { + self.notice = Some("SECTIONPLANE Select a planar face or specify a point:"); + return CmdResult::NeedPoint; + }; + let viewing = -face_normal.normalize(); + let vertical = if viewing.dot(self.working.z).abs() < 0.98 { + self.working.z + } else { + self.working.y + }; + let (vertices, vertical) = self.line_at(point, viewing, vertical); + CmdResult::CommitAndExit(self.entity(vertices, vertical, true)) + } + + fn on_undo_step(&mut self) -> Option { + if self.step != Step::DrawNext { + return None; + } + self.draw_points.pop(); + self.step = if self.draw_points.is_empty() { Step::DrawStart } else { Step::DrawNext }; + Some(CmdResult::NeedPoint) + } + + fn on_preview_wires(&mut self, point: DVec3) -> Vec { + let mut points = self.draw_points.iter().map(|p| p.to_array()).collect::>(); + match self.step { + Step::Through => { + if let Some(first) = self.first { + points = vec![first.to_array(), point.to_array()]; + } + } + Step::DrawNext => points.push(point.to_array()), + Step::DrawDirection if self.draw_points.len() >= 2 => { + let centre = (self.draw_points[0] + *self.draw_points.last().unwrap()) * 0.5; + points.push([f64::NAN; 3]); + points.push(centre.to_array()); + points.push(point.to_array()); + } + _ => {} + } + if points.len() < 2 { + Vec::new() + } else { + vec![WireModel::solid_f64( + "sectionplane_preview".into(), + points, + WireModel::CYAN, + false, + )] + } + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["SECTIONPLANE"] +}); + +#[cfg(test)] +mod tests { + use super::*; + + fn data(entity: &EntityType) -> (&ExtendedEntity, &SectionObjectData) { + let EntityType::Extended(entity) = entity else { + panic!("expected extended entity"); + }; + let ExtendedEntityData::SectionObject(data) = &entity.data else { + panic!("expected section object"); + }; + (entity, data) + } + + #[test] + fn slice_uses_persistent_thickness_without_boundary_geometry() { + let mut command = SectionPlaneCommand::new( + Some((DVec3::ZERO, DVec3::splat(10.0))), + 3, + ); + command.kind = SectionKind::Slice; + let entity = command.entity(vec![DVec3::ZERO, DVec3::Y * 6.0], DVec3::Z, true); + let (entity, section) = data(&entity); + + assert_eq!(section.state, 1); + assert!(section.back_line_vertices.is_empty()); + assert!(crate::entities::extended::section_is_slice(entity)); + assert!((crate::entities::extended::section_slice_depth(entity).unwrap() - 0.1).abs() + < 1e-12); + } + + #[test] + fn boundary_keeps_a_real_back_line_and_no_slice_marker() { + let mut command = SectionPlaneCommand::new( + Some((DVec3::ZERO, DVec3::splat(10.0))), + 1, + ); + command.kind = SectionKind::Boundary; + let entity = command.entity(vec![DVec3::ZERO, DVec3::Y * 6.0], DVec3::Z, true); + let (entity, section) = data(&entity); + + assert_eq!(section.state, 2); + assert_eq!(section.back_line_vertices.len(), section.vertices.len()); + assert!(!crate::entities::extended::section_is_slice(entity)); + } +} diff --git a/src/par.rs b/src/par.rs index 737bf2b90..4be897e7a 100644 --- a/src/par.rs +++ b/src/par.rs @@ -75,3 +75,11 @@ pub mod prelude { impl IterParExt for I {} } + +#[cfg(not(target_arch = "wasm32"))] +pub use rayon::join; + +#[cfg(target_arch = "wasm32")] +pub fn join(a: impl FnOnce() -> A, b: impl FnOnce() -> B) -> (A, B) { + (a(), b()) +} diff --git a/src/scene/annotative.rs b/src/scene/annotative.rs index 1e0a3b4cd..557f178b7 100644 --- a/src/scene/annotative.rs +++ b/src/scene/annotative.rs @@ -11,7 +11,7 @@ use acadrust::entities::{EntityCommon, EntityType}; use acadrust::objects::{ - Dictionary, DimContext, DimSubtype, EmbeddedMTextContext, HatchScaleContext, + Dictionary, DimContext, DimSubtype, EmbeddedMTextContext, HatchLoopContext, HatchScaleContext, MTextAttributeContext, MTextContext, ObjectContextData, ObjectContextKind, ObjectType, }; use acadrust::types::{Vector2, Vector3}; @@ -289,12 +289,15 @@ fn context_kind_for( pattern_lines: hatch.pattern.lines.clone(), pattern_scale: hatch.pattern_scale, pattern_base: Vector3::ZERO, - loop_types: hatch + loops: hatch .paths .iter() - .map(|path| path.flags.bits() as i32) + .map(|path| HatchLoopContext { + loop_type: path.flags.bits() as i32, + supports_context: true, + boundary: None, + }) .collect(), - supports_context: true, }), )), _ => None, @@ -891,9 +894,43 @@ fn apply_hatch_context(hatch: &mut acadrust::entities::Hatch, context: &HatchSca line.base_point.x += context.pattern_base.x; line.base_point.y += context.pattern_base.y; } - for (path, bits) in hatch.paths.iter_mut().zip(&context.loop_types) { - path.flags = acadrust::entities::BoundaryPathFlags::from_bits(*bits as u32); - } + let base_paths = std::mem::take(&mut hatch.paths); + hatch.paths = context + .loops + .iter() + .enumerate() + .map(|(index, loop_data)| { + let flags = + acadrust::entities::BoundaryPathFlags::from_bits(loop_data.loop_type as u32); + let mut path = if loop_data.supports_context { + base_paths.get(index).cloned() + } else { + loop_data.boundary.clone() + } + .unwrap_or_else(|| acadrust::entities::BoundaryPath::with_flags(flags)); + path.flags = flags; + path + }) + .collect(); +} + +fn sync_hatch_loops(loops: &mut Vec, paths: &[acadrust::entities::BoundaryPath]) { + let previous = std::mem::take(loops); + *loops = paths + .iter() + .enumerate() + .map(|(index, path)| { + let supports_context = previous + .get(index) + .map(|loop_data| loop_data.supports_context) + .unwrap_or(true); + HatchLoopContext { + loop_type: path.flags.bits() as i32, + supports_context, + boundary: (!supports_context).then(|| path.clone()), + } + }) + .collect(); } pub fn entity_for_annotation_context<'a>( @@ -1235,11 +1272,7 @@ pub fn sync_annotation_context_from_entity( line.base_point.y -= context.pattern_base.y; } context.pattern_scale = hatch.pattern_scale; - context.loop_types = hatch - .paths - .iter() - .map(|path| path.flags.bits() as i32) - .collect(); + sync_hatch_loops(&mut context.loops, &hatch.paths); } (EntityType::Hatch(hatch), ObjectContextKind::HatchView(context)) => { context.hatch.pattern_lines.clone_from(&hatch.pattern.lines); @@ -1248,11 +1281,7 @@ pub fn sync_annotation_context_from_entity( line.base_point.y -= context.hatch.pattern_base.y; } context.hatch.pattern_scale = hatch.pattern_scale; - context.hatch.loop_types = hatch - .paths - .iter() - .map(|path| path.flags.bits() as i32) - .collect(); + sync_hatch_loops(&mut context.hatch.loops, &hatch.paths); context.view_normal = hatch.normal; } _ => {} diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 44da23656..4c0118256 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -9,26 +9,37 @@ fn rendered_wire_bounds( ) -> Option<(glam::DVec3, glam::DVec3)> { let mut min = glam::DVec3::splat(f64::INFINITY); let mut max = glam::DVec3::splat(f64::NEG_INFINITY); - let mut include = |point: glam::DVec3| { - if point.is_finite() { - min = min.min(point); - max = max.max(point); - } - }; + macro_rules! include_bound { + ($p:expr) => { + let point: glam::DVec3 = $p; + if point.is_finite() { + min = min.min(point); + max = max.max(point); + } + }; + } for wire in wires { let mut has_precise_position = false; + let hw = wire.world_width as f64 * 0.5; + for (index, &[x, y, z]) in wire.points.iter().enumerate() { let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); - include(glam::DVec3::new( + let p = glam::DVec3::new( x as f64 + low[0] as f64, y as f64 + low[1] as f64, z as f64 + low[2] as f64, - )); + ); + if hw > 0.0 { + include_bound!(p - glam::DVec3::splat(hw)); + include_bound!(p + glam::DVec3::splat(hw)); + } else { + include_bound!(p); + } has_precise_position = true; } for vertex in &wire.text_verts { - include(glam::DVec3::new( + include_bound!(glam::DVec3::new( vertex.pos[0] as f64 + vertex.pos_low[0] as f64, vertex.pos[1] as f64 + vertex.pos_low[1] as f64, vertex.pos[2] as f64 + vertex.pos_low[2] as f64, @@ -36,11 +47,17 @@ fn rendered_wire_bounds( has_precise_position = true; } for &[x, y, z] in &wire.key_vertices { - include(glam::DVec3::new(x, y, z)); + let p = glam::DVec3::new(x, y, z); + if hw > 0.0 { + include_bound!(p - glam::DVec3::splat(hw)); + include_bound!(p + glam::DVec3::splat(hw)); + } else { + include_bound!(p); + } has_precise_position = true; } for &(point, _) in &wire.snap_pts { - include(point); + include_bound!(point); has_precise_position = true; } @@ -51,8 +68,8 @@ fn rendered_wire_bounds( && x0 <= x1 && y0 <= y1 { - include(glam::DVec3::new(x0 as f64, y0 as f64, fallback_z)); - include(glam::DVec3::new(x1 as f64, y1 as f64, fallback_z)); + include_bound!(glam::DVec3::new(x0 as f64 - hw, y0 as f64 - hw, fallback_z - hw)); + include_bound!(glam::DVec3::new(x1 as f64 + hw, y1 as f64 + hw, fallback_z + hw)); } } } @@ -1455,12 +1472,18 @@ impl Scene { continue; } let wire = &wires[c.idx]; + let hw = (wire.world_width * 0.5) as f32; for &[x, y, z] in &wire.points { if !x.is_finite() || !y.is_finite() || !z.is_finite() { continue; } - min = min.min(glam::Vec3::new(x, y, z)); - max = max.max(glam::Vec3::new(x, y, z)); + if hw > 0.0 { + min = min.min(glam::Vec3::new(x - hw, y - hw, z - hw)); + max = max.max(glam::Vec3::new(x + hw, y + hw, z + hw)); + } else { + min = min.min(glam::Vec3::new(x, y, z)); + max = max.max(glam::Vec3::new(x, y, z)); + } } } // Fold in 3D-solid mesh AABBs (not subject to the wire IQR reject). diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 5095dd680..598627e8c 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -167,7 +167,7 @@ pub fn kernel_surface_body(surface: &Surface) -> Option { kernel_acis_body(&surface.acis_data) } -fn kernel_acis_body(acis: &acadrust::entities::AcisData) -> Option { +pub(crate) fn kernel_acis_body(acis: &acadrust::entities::AcisData) -> Option { let sat = parse_acis( || acis.parse(), acis.is_binary, diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index cbbfacd7a..8c9a22af5 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -202,6 +202,66 @@ pub(crate) fn points_to_ds( (high, low) } +fn polyline_segment_widths(entity: &EntityType) -> Vec<(f32, f32)> { + match entity { + EntityType::LwPolyline(p) => { + let count = p.vertices.len(); + let seg_count = if p.is_closed { + count + } else { + count.saturating_sub(1) + }; + let c = p.constant_width; + (0..seg_count) + .map(|i| { + let v = &p.vertices[i]; + let sw = if v.start_width > 1e-9 { + v.start_width + } else { + c + } as f32; + let ew = if v.end_width > 1e-9 { + v.end_width + } else { + c + } as f32; + (sw, ew) + }) + .collect() + } + EntityType::Polyline2D(p) => { + let filtered = crate::entities::polyline::drawn_vertices2d(p); + let verts: &[acadrust::entities::Vertex2D] = + filtered.as_deref().unwrap_or(&p.vertices); + let count = verts.len(); + let seg_count = if p.is_closed() { + count + } else { + count.saturating_sub(1) + }; + let def_start = p.start_width; + let def_end = p.end_width; + (0..seg_count) + .map(|i| { + let v = &verts[i]; + let sw = if v.start_width > 1e-9 { + v.start_width + } else { + def_start + } as f32; + let ew = if v.end_width > 1e-9 { + v.end_width + } else { + def_end + } as f32; + (sw, ew) + }) + .collect() + } + _ => Vec::new(), + } +} + fn split_mixed_polyline( tangent_geoms: &[TangentGeom], key_vertices: &[[f64; 3]], @@ -214,11 +274,15 @@ fn split_mixed_polyline( snap_pts: Vec<(glam::DVec3, SnapHint)>, point_marker: Option, plinegen: bool, + seg_widths: &[(f32, f32)], + global_world_width: f32, + mut pick_tris: Vec<[f32; 3]>, + mut pick_tris_low: Vec<[f32; 3]>, ) -> Vec { let mut out = Vec::new(); // Emit each arc segment as an analytical wire (CircleGpu target) - for tg in tangent_geoms { + for (i, tg) in tangent_geoms.iter().enumerate() { if let TangentGeom::Arc { center, axis_x, @@ -246,20 +310,48 @@ fn split_mixed_polyline( arc_pts.push(p); } let (points, points_low) = points_to_ds(arc_pts); + + let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) { + if w0 > 1e-9 || w1 > 1e-9 { + (w0, w1) + } else if global_world_width > 1e-9 { + (global_world_width, global_world_width) + } else { + (0.0, 0.0) + } + } else if global_world_width > 1e-9 { + (global_world_width, global_world_width) + } else { + (0.0, 0.0) + }; + + let taper_widths = if (sw - ew).abs() > 1e-6 { + vec![sw, ew] + } else { + Vec::new() + }; + let world_width = sw.max(ew); + + let (arc_pt, arc_ptl) = if out.is_empty() && !pick_tris.is_empty() { + (std::mem::take(&mut pick_tris), std::mem::take(&mut pick_tris_low)) + } else { + (Vec::new(), Vec::new()) + }; + out.push(WireModel { bg_adapt: None, point_marker: None, - taper_widths: Vec::new(), + taper_widths, pattern_stations: Vec::new(), - world_width: 0.0, + world_width, depth_override: None, display_visible: true, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, - pick_tris: Vec::new(), - pick_tris_low: Vec::new(), + pick_tris: arc_pt, + pick_tris_low: arc_ptl, dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), @@ -330,20 +422,25 @@ fn split_mixed_polyline( let (line_pts, line_pts_low) = points_to_ds(straight_pts); if !line_pts.is_empty() { + let (pt, ptl) = if !pick_tris.is_empty() { + (pick_tris, pick_tris_low) + } else { + (Vec::new(), Vec::new()) + }; out.push(WireModel { bg_adapt: None, point_marker, taper_widths: Vec::new(), pattern_stations: Vec::new(), - world_width: 0.0, + world_width: global_world_width, depth_override: None, display_visible: true, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, - pick_tris: Vec::new(), - pick_tris_low: Vec::new(), + pick_tris: pt, + pick_tris_low: ptl, dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), @@ -367,6 +464,10 @@ fn split_mixed_polyline( } else if let Some(first_arc) = out.first_mut() { first_arc.snap_pts = snap_pts; first_arc.key_vertices = key_vertices.to_vec(); + if first_arc.pick_tris.is_empty() && !pick_tris.is_empty() { + first_arc.pick_tris = pick_tris; + first_arc.pick_tris_low = pick_tris_low; + } } out @@ -1744,17 +1845,13 @@ pub fn tessellate( .tangent_geoms .iter() .any(|tg| matches!(tg, TangentGeom::Arc { .. })); - let has_line = te - .tangent_geoms - .iter() - .any(|tg| matches!(tg, TangentGeom::Line { .. })); - let is_thin_polyline = !is_thick_extrusion - && polyline_band_width(entity, document.header.fill_mode) <= 1e-9 + let can_split = !is_thick_extrusion && fill_tris.is_empty() - && pick_tris.is_empty() && matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)); - if has_arc && has_line && is_thin_polyline { + if has_arc && can_split { + let seg_widths = polyline_segment_widths(entity); + let ww = polyline_band_width(entity, document.header.fill_mode); out.extend(split_mixed_polyline( &te.tangent_geoms, &keys, @@ -1767,6 +1864,10 @@ pub fn tessellate( snap, point_marker, true, + &seg_widths, + ww, + pick_tris, + pick_tris_low, )); } else { out.push(WireModel { @@ -1966,20 +2067,14 @@ pub fn tessellate( entity, EntityType::Polyline2D(p) if p.thickness.abs() > 1e-10 ); - let is_thin_polyline = !is_thick_extrusion - && polyline_band_width(entity, document.header.fill_mode) <= 1e-9 - && pick_tris.is_empty() + let can_split = !is_thick_extrusion && matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)); let has_arc = te .tangent_geoms .iter() .any(|tg| matches!(tg, TangentGeom::Arc { .. })); - let has_line = te - .tangent_geoms - .iter() - .any(|tg| matches!(tg, TangentGeom::Line { .. })); - if has_arc && has_line && is_thin_polyline { + if has_arc && can_split { let edge_color = if is_thick_extrusion { [0.0, 0.0, 0.0, 1.0] } else { @@ -1987,6 +2082,8 @@ pub fn tessellate( }; let point_marker = crate::entities::point::relative_marker_spec(entity, document); + let seg_widths = polyline_segment_widths(entity); + let ww = polyline_band_width(entity, document.header.fill_mode); return split_mixed_polyline( &te.tangent_geoms, &key_vertices, @@ -1999,6 +2096,10 @@ pub fn tessellate( snap_pts, point_marker, false, + &seg_widths, + ww, + pick_tris, + pick_tris_low, ); } return vec![WireModel { @@ -2038,6 +2139,40 @@ pub fn tessellate( } RenderObject::TaperedLines(points, widths) => { + let has_arc = te + .tangent_geoms + .iter() + .any(|tg| matches!(tg, TangentGeom::Arc { .. })); + let can_split = matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)); + let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); + let world_width = widths.iter().copied().fold(0.0f32, f32::max); + let key_vertices: Vec<[f64; 3]> = te + .key_vertices + .into_iter() + .map(|[x, y, z]| [x, y, z]) + .collect(); + + if has_arc && can_split { + let seg_widths = polyline_segment_widths(entity); + return split_mixed_polyline( + &te.tangent_geoms, + &key_vertices, + &name, + color, + selected, + pattern_length, + pattern, + line_weight_px, + te.snap_pts, + None, + true, + &seg_widths, + world_width, + pick_tris, + pick_tris_low, + ); + } + // A wide polyline whose width varies: one continuous band wire // carrying a per-point width; the shader interpolates each // segment's two endpoint widths. `world_width` (the widest edge) @@ -2045,13 +2180,6 @@ pub fn tessellate( // floor when zoomed out. let (local_pts, local_pts_low) = points_to_ds(points); let snap_pts = te.snap_pts; - let key_vertices: Vec<[f64; 3]> = te - .key_vertices - .into_iter() - .map(|[x, y, z]| [x, y, z]) - .collect(); - let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); - let world_width = widths.iter().copied().fold(0.0f32, f32::max); return vec![WireModel { bg_adapt: None, point_marker: None, diff --git a/src/scene/entity.rs b/src/scene/entity.rs index ca4609047..497e6df2b 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -143,6 +143,106 @@ impl Scene { let _ = self.document.app_ids.add(app_id); } + fn prepare_section_objects(&mut self, entity: &mut EntityType) -> bool { + use acadrust::entities::ExtendedEntityData; + use acadrust::objects::{ + ClassObject, ClassObjectData, SectionManager, SectionSettings, + SectionTypeSettings, + }; + + let EntityType::Extended(extended) = entity else { + return false; + }; + let ExtendedEntityData::SectionObject(data) = &mut extended.data else { + return false; + }; + + if extended.common.handle.is_null() { + extended.common.handle = self.document.allocate_handle(); + } + let entity_handle = extended.common.handle; + + let valid_settings = matches!( + self.document.objects.get(&data.settings_handle), + Some(ObjectType::ClassObject(object)) + if matches!(&object.data, ClassObjectData::SectionSettings(_)) + ); + if !valid_settings { + let settings_handle = self.document.allocate_handle(); + let mut settings = ClassObject::new(ClassObjectData::SectionSettings( + SectionSettings { + current_type: 4, + types: vec![SectionTypeSettings { + section_type: 4, + generation: 17, + ..SectionTypeSettings::default() + }], + }, + )); + settings.handle = settings_handle; + settings.owner = entity_handle; + self.document + .objects + .insert(settings_handle, ObjectType::ClassObject(settings)); + data.settings_handle = settings_handle; + } else if let Some(ObjectType::ClassObject(settings)) = + self.document.objects.get_mut(&data.settings_handle) + { + settings.owner = entity_handle; + } + + let root = crate::scene::annotative::root_named_dict_handle(&mut self.document); + let manager_handle = self + .document + .objects + .get(&root) + .and_then(|object| match object { + ObjectType::Dictionary(dictionary) => dictionary + .entries + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER")) + .map(|(_, handle)| *handle), + _ => None, + }) + .filter(|handle| { + matches!( + self.document.objects.get(handle), + Some(ObjectType::ClassObject(object)) + if matches!(&object.data, ClassObjectData::SectionManager(_)) + ) + }) + .unwrap_or_else(|| { + let handle = self.document.allocate_handle(); + let mut manager = + ClassObject::new(ClassObjectData::SectionManager(SectionManager::default())); + manager.handle = handle; + manager.owner = root; + self.document + .objects + .insert(handle, ObjectType::ClassObject(manager)); + if let Some(ObjectType::Dictionary(dictionary)) = + self.document.objects.get_mut(&root) + { + dictionary.entries.retain(|(name, _)| { + !name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER") + }); + dictionary.add_entry("ACAD_SECTION_MANAGER", handle); + } + handle + }); + if let Some(ObjectType::ClassObject(manager)) = + self.document.objects.get_mut(&manager_handle) + { + manager.owner = root; + if let ClassObjectData::SectionManager(manager) = &mut manager.data { + if !manager.sections.contains(&entity_handle) { + manager.sections.push(entity_handle); + } + } + } + true + } + pub fn add_entity(&mut self, entity: EntityType) -> Handle { self.add_entity_internal(entity, true) } @@ -195,6 +295,7 @@ impl Scene { // bookkeeping and remains in place while an entity delta is undone. let mutates_block_structure = matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_)); + let creates_section_structure = self.prepare_section_objects(&mut entity); let hatch_seed = if let EntityType::Hatch(dxf) = &entity { let color = self.render_style(&entity).0; Self::hatch_model_from_dxf(dxf, color) @@ -298,6 +399,9 @@ impl Scene { }; if !handle.is_null() { + if let Some(keep) = self.refedit_keep.as_mut() { + keep.insert(handle); + } self.invalidate_dependency_index(); if let Some(model) = hatch_seed { self.hatches.insert(handle, model); @@ -334,7 +438,11 @@ impl Scene { // state (a new layer, application ID, or block). if self.is_recording_undo() { self.record_undo_before(handle, None); - if creates_layer || creates_app_id || mutates_block_structure { + if creates_layer + || creates_app_id + || mutates_block_structure + || creates_section_structure + { self.poison_undo_recording(); } } @@ -2583,6 +2691,12 @@ impl Scene { self.solid_models = HashMap::default(); *self.camera.borrow_mut() = Camera::default(); self.camera_generation += 1; + // A brand-new/replaced document has none of these yet — without + // resetting them, a "new"/"open" (e.g. via automation, which reuses + // this same reset) would leak the previous document's constraints + // and named parameters into the fresh one. + self.sketch_constraints.clear(); + self.named_parameters = crate::scene::named_parameters::ParameterTable::new(); self.bump_geometry(); } } diff --git a/src/scene/group_layer.rs b/src/scene/group_layer.rs index 2c3aa78e3..e636bde8f 100644 --- a/src/scene/group_layer.rs +++ b/src/scene/group_layer.rs @@ -183,21 +183,14 @@ impl Scene { handles: &[Handle], ) -> HashSet { let mut expanded: HashSet = handles.iter().copied().collect(); - expanded.extend( - self.document - .objects - .values() - .filter_map(|obj| match obj { - ObjectType::Group(g) - if g.selectable - && handles.iter().any(|handle| g.contains(*handle)) => - { - Some(g.entities.clone()) - } - _ => None, - }) - .flatten(), - ); + let wanted: HashSet = handles.iter().copied().collect(); + for obj in self.document.objects.values() { + if let ObjectType::Group(g) = obj { + if g.selectable && g.entities.iter().any(|e| wanted.contains(e)) { + expanded.extend(g.entities.iter().copied()); + } + } + } expanded } @@ -211,3 +204,47 @@ impl Scene { } } } + +#[cfg(test)] +mod group_expansion_tests { + use super::*; + use crate::scene::Scene; + + #[test] + fn group_expansion_pulls_in_siblings_and_nothing_else() { + use acadrust::entities::{EntityType, Line}; + use acadrust::types::Vector3; + + let mut scene = Scene::new(); + let mut line = |x: f64| { + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(x, 0.0, 0.0), + Vector3::new(x + 1.0, 0.0, 0.0), + ))) + }; + let (a, b, c) = (line(0.0), line(1.0), line(2.0)); + let (d, e) = (line(3.0), line(4.0)); + let lonely = line(9.0); + scene.create_group("GRUPPO".to_string(), vec![a, b, c]); + scene.create_group("ALTRO".to_string(), vec![d, e]); + + // One member pulls in its siblings, and only its own group's. + let from_a = scene.handles_expanded_for_selectable_groups(&[a]); + assert_eq!( + from_a, + [a, b, c].into_iter().collect::>(), + "selecting a member must select that group and no other", + ); + + // An entity in no group expands to itself. + assert_eq!( + scene.handles_expanded_for_selectable_groups(&[lonely]), + [lonely].into_iter().collect::>(), + ); + + // Touching both groups pulls in both, and nothing outside them. + let both = scene.handles_expanded_for_selectable_groups(&[a, e]); + assert_eq!(both, [a, b, c, d, e].into_iter().collect::>()); + assert!(!both.contains(&lonely)); + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index cba0c7885..0ee848dd1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1901,6 +1901,7 @@ pub struct Scene { entity_block_map_cache: RefCell)>>, /// Candidate handles per block, in document order and keyed by geometry epoch. block_members_cache: RefCell>, + leaders_by_annotation_cache: RefCell>)>>, /// Insert/Viewport/Block/BlockEnd handles omitted by the spatial index. unindexable_cache: RefCell)>>, /// `(epoch, layout block, present type names, list exposed to the UI)`. @@ -2069,6 +2070,90 @@ pub struct Scene { undo_recording: Option, } +fn section_frame( + data: &acadrust::entities::SectionObjectData, +) -> Option<(glam::DVec3, glam::DVec3, glam::DVec3, glam::DVec3)> { + let first = data.vertices.first()?; + let last = data.vertices.last()?; + let origin = glam::DVec3::new(first.x, first.y, first.z); + let tangent = (glam::DVec3::new(last.x, last.y, last.z) - origin).try_normalize()?; + let raw_vertical = glam::DVec3::new( + data.vertical_direction.x, + data.vertical_direction.y, + data.vertical_direction.z, + ); + let vertical = (raw_vertical - tangent * raw_vertical.dot(tangent)).try_normalize()?; + let base_view = vertical.cross(tangent).try_normalize()?; + let viewing = if data.flags & 4 != 0 { base_view } else { -base_view }; + Some((origin, tangent, vertical, viewing)) +} + +#[derive(Clone)] +struct LiveSection { + data: acadrust::entities::SectionObjectData, + slice_depth: Option, +} + +fn section_plane( + origin: glam::DVec3, + tangent: glam::DVec3, + normal: glam::DVec3, + body_to_world: Option<&acadrust::types::Transform>, +) -> Option { + let Some(transform) = body_to_world else { + return cadkernel::space::Plane::orthonormal( + origin.to_array(), + tangent.to_array(), + normal.to_array(), + ); + }; + let matrix = transform.matrix.m; + let linear = acadrust::types::Matrix3::from_rows( + [matrix[0][0], matrix[0][1], matrix[0][2]], + [matrix[1][0], matrix[1][1], matrix[1][2]], + [matrix[2][0], matrix[2][1], matrix[2][2]], + ); + let inverse = linear.inverse()?; + let translation = acadrust::types::Vector3::new( + matrix[0][3], + matrix[1][3], + matrix[2][3], + ); + let origin = inverse.transform_point( + acadrust::types::Vector3::new(origin.x, origin.y, origin.z) - translation, + ); + let tangent = inverse.transform_point(acadrust::types::Vector3::new( + tangent.x, tangent.y, tangent.z, + )); + let normal = linear + .transpose() + .transform_point(acadrust::types::Vector3::new(normal.x, normal.y, normal.z)); + cadkernel::space::Plane::orthonormal( + [origin.x, origin.y, origin.z], + [tangent.x, tangent.y, tangent.z], + [normal.x, normal.y, normal.z], + ) +} + +fn keep_section_positive( + body: &cadkernel::brep::Body, + plane: cadkernel::space::Plane, +) -> Result, ()> { + match cadkernel::brep::slice_by_plane(body, plane) { + Ok(Some(result)) => Ok(Some(result.positive)), + Ok(None) => { + let bounds = cadkernel::brep::body_bounds(body).ok_or(())?; + let centre = [ + (bounds.min[0] + bounds.max[0]) * 0.5, + (bounds.min[1] + bounds.max[1]) * 0.5, + (bounds.min[2] + bounds.max[2]) * 0.5, + ]; + Ok((plane.distance_to(centre).ok_or(())? >= 0.0).then(|| body.clone())) + } + Err(_) => Err(()), + } +} + impl Scene { pub fn new() -> Self { Self { @@ -2176,6 +2261,7 @@ impl Scene { model_extents_cache: RefCell::new(None), entity_block_map_cache: RefCell::new(None), block_members_cache: RefCell::new(None), + leaders_by_annotation_cache: RefCell::new(None), unindexable_cache: RefCell::new(None), layout_type_names_cache: RefCell::new(None), dependency_index_cache: RefCell::new(None), @@ -2481,7 +2567,17 @@ impl Scene { bits |= CACHE_CATEGORY_IMAGE; } let is_insert = matches!(entity, EntityType::Insert(_)); - if self.meshes.contains_key(&handle) || self.block_meshes.contains_key(&handle) || is_insert + let is_section = matches!( + entity, + EntityType::Extended(acadrust::entities::ExtendedEntity { + data: acadrust::entities::ExtendedEntityData::SectionObject(_), + .. + }) + ); + if self.meshes.contains_key(&handle) + || self.block_meshes.contains_key(&handle) + || is_insert + || is_section { bits |= CACHE_CATEGORY_MESH | CACHE_CATEGORY_INTERACTION; } @@ -6671,6 +6767,12 @@ impl Scene { || matches!( self.document.get_entity(h), Some(EntityType::Insert(_)) + | Some(EntityType::Extended( + acadrust::entities::ExtendedEntity { + data: acadrust::entities::ExtendedEntityData::SectionObject(_), + .. + } + )) ) }, ) => @@ -6857,6 +6959,7 @@ impl Scene { all_visible: bool, viewport: Option, ) -> Vec { + let live_section = self.active_live_section(target_block); // Top-level solids: drop those whose layer is off/frozen or that are // flagged invisible / isolated-hidden, mirroring the 2D wire path, plus // any whose layer is frozen in the requesting viewport. @@ -6878,8 +6981,19 @@ impl Scene { }) .unwrap_or(false) }) - .map(|(&handle, set)| { - let mut set = set.clone(); + .filter_map(|(&handle, set)| { + let mut set = if let Some(section) = live_section.as_ref() { + match self.sectioned_body(handle, section) { + Ok(Some(body)) => self + .prepare_solid_model_display(handle, &body) + .map(|display| display.0) + .unwrap_or_else(|| set.clone()), + Ok(None) => return None, + Err(()) => set.clone(), + } + } else { + set.clone() + }; if let Some(entity) = self.document.get_entity(handle) { let style = crate::scene::view::render::render_style_for_viewport( &self.document, @@ -6903,7 +7017,7 @@ impl Scene { material.diffuse[3] = style.0[3]; } } - set + Some(set) }) .collect(); // Block-definition solids are instanced per INSERT of the ACTIVE space's @@ -6916,10 +7030,179 @@ impl Scene { annotation_scale_handle, all_visible, viewport, + live_section.as_ref(), )); all } + fn active_live_section( + &self, + target_block: Handle, + ) -> Option { + self.document + .entities() + .filter_map(|entity| { + let EntityType::Extended(extended) = entity else { + return None; + }; + let acadrust::entities::ExtendedEntityData::SectionObject(data) = &extended.data else { + return None; + }; + let owner = extended.common.owner_handle; + (data.flags & 1 != 0 + && !extended.common.invisible + && (owner.is_null() || owner == target_block)) + .then(|| LiveSection { + data: data.clone(), + slice_depth: crate::entities::extended::section_is_slice(extended) + .then(|| { + crate::entities::extended::section_slice_depth(extended) + .unwrap_or(0.0) + }), + }) + }) + .last() + } + + fn sectioned_body( + &self, + handle: Handle, + section: &LiveSection, + ) -> Result, ()> { + let body = self.section_source_body(handle).ok_or(())?; + Self::section_body(&body, §ion.data, section.slice_depth, None) + } + + fn section_source_body( + &self, + handle: Handle, + ) -> Option> { + if let Some(body) = self.solid_models.get(&handle) { + return Some(std::borrow::Cow::Borrowed(body)); + } + let body = match self.document.get_entity(handle) { + Some(EntityType::Solid3D(solid)) => { + crate::scene::convert::solid3d_tess::kernel_body(solid) + } + Some(EntityType::Region(region)) => { + crate::scene::convert::solid3d_tess::kernel_region_body(region) + } + Some(EntityType::Body(body)) => { + crate::scene::convert::solid3d_tess::kernel_acis_body(&body.acis_data) + } + Some(EntityType::Surface(surface)) => { + crate::scene::convert::solid3d_tess::kernel_surface_body(surface) + } + _ => None, + }?; + Some(std::borrow::Cow::Owned(body)) + } + + fn section_body( + body: &cadkernel::brep::Body, + data: &acadrust::entities::SectionObjectData, + slice_depth: Option, + body_to_world: Option<&acadrust::types::Transform>, + ) -> Result, ()> { + let Some((origin, tangent, vertical, viewing)) = section_frame(data) else { + return Err(()); + }; + let front = section_plane(origin, tangent, viewing, body_to_world).ok_or(())?; + let mut clipped = keep_section_positive(body, front)?; + let Some(mut current) = clipped.take() else { + return Ok(None); + }; + if data.state == 1 && slice_depth.is_none() { + return Ok(Some(current)); + } + + let depth = slice_depth.unwrap_or_else(|| { + data.vertices + .first() + .zip(data.back_line_vertices.first()) + .map_or(0.0, |(front, back)| { + (glam::DVec3::new(back.x, back.y, back.z) + - glam::DVec3::new(front.x, front.y, front.z)) + .dot(viewing) + .abs() + }) + }); + if depth <= 1e-12 { + return Ok(Some(current)); + } + let back = section_plane( + origin + viewing * depth, + tangent, + -viewing, + body_to_world, + ) + .ok_or(())?; + let Some(next) = keep_section_positive(¤t, back)? else { + return Ok(None); + }; + current = next; + + if data.state == 1 { + return Ok(Some(current)); + } + + let span = data + .vertices + .first() + .zip(data.vertices.last()) + .map_or(0.0, |(first, last)| { + (glam::DVec3::new(last.x, last.y, last.z) + - glam::DVec3::new(first.x, first.y, first.z)) + .length() + }); + if span <= 1e-12 { + return Ok(Some(current)); + } + + let first = data.vertices.first().map(|point| glam::DVec3::new(point.x, point.y, point.z)).ok_or(())?; + let last = data.vertices.last().map(|point| glam::DVec3::new(point.x, point.y, point.z)).ok_or(())?; + for plane in [ + section_plane(first, vertical, tangent, body_to_world), + section_plane(last, vertical, -tangent, body_to_world), + ] { + let Some(plane) = plane else { + return Err(()); + }; + let Some(next) = keep_section_positive(¤t, plane)? else { + return Ok(None); + }; + current = next; + } + + if data.state != 4 { + return Ok(Some(current)); + } + + for plane in [ + section_plane( + origin - vertical * data.bottom_height, + tangent, + vertical, + body_to_world, + ), + section_plane( + origin + vertical * data.top_height, + tangent, + -vertical, + body_to_world, + ), + ] { + let Some(plane) = plane else { + return Err(()); + }; + let Some(next) = keep_section_positive(¤t, plane)? else { + return Ok(None); + }; + current = next; + } + Ok(Some(current)) + } + /// True when `layer` is turned off or frozen — entities on it never render. fn layer_hidden(&self, layer: &str) -> bool { self.document @@ -7199,6 +7482,7 @@ impl Scene { annotation_scale_handle: Option, all_visible: bool, viewport: Option, + live_section: Option<&LiveSection>, ) -> Vec { if self.block_meshes.is_empty() { return Vec::new(); @@ -7225,9 +7509,37 @@ impl Scene { return; } let handle = entity.common().handle; - let Some(set) = self.block_meshes.get(&handle) else { + let Some(original) = self.block_meshes.get(&handle) else { return; }; + let sectioned; + let set = if let Some((section, body)) = live_section.and_then(|section| { + self.section_source_body(handle) + .map(|body| (section, body)) + }) { + match Self::section_body( + &body, + §ion.data, + section.slice_depth, + Some(&context.transform), + ) { + Ok(Some(body)) => { + if let Some((mut mesh, _, _)) = + self.prepare_solid_model_display(handle, &body) + { + mesh.prepare_instance_source(handle); + sectioned = mesh; + §ioned + } else { + original + } + } + Ok(None) => return, + Err(()) => original, + } + } else { + original + }; let inherit = self.mesh_inherit_for_path(&context.insert_path); let own_alpha = set.display_color().map_or(1.0, |color| color[3]); let mut transformed = @@ -7939,6 +8251,7 @@ impl Scene { wires: Arc>, aabb: [f64; 4], allow_pending_empty: bool, + area_only: bool, ) -> crate::scene::pick::interaction_index::InteractionCandidates { if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() { let perf = crate::perf::enabled(); @@ -7957,11 +8270,20 @@ impl Scene { ); let local_ms = t_local.elapsed().as_secs_f64() * 1000.0; let t_remap = iced::time::Instant::now(); - let mut result = - base.query_remapped_xy(Arc::clone(&local), &base_slots, aabb); - result.extend_indexed( - changed_index.query_remapped_xy(local, &changed_slots, aabb), - ); + let mut result = if area_only { + base.query_remapped_xy_area(Arc::clone(&local), &base_slots, aabb) + } else { + base.query_remapped_xy(Arc::clone(&local), &base_slots, aabb) + }; + if area_only { + result.extend_indexed_area( + changed_index.query_remapped_xy_area(local, &changed_slots, aabb), + ); + } else { + result.extend_indexed( + changed_index.query_remapped_xy(local, &changed_slots, aabb), + ); + } let remap_ms = t_remap.elapsed().as_secs_f64() * 1000.0; let total_ms = t0.elapsed().as_secs_f64() * 1000.0; if perf && total_ms >= 50.0 { @@ -7979,7 +8301,11 @@ impl Scene { return result; } if let Some(index) = self.cached_interaction_index(&wires) { - index.query_xy(wires, aabb) + if area_only { + index.query_xy_area(wires, aabb) + } else { + index.query_xy(wires, aabb) + } } else if allow_pending_empty && self.interaction_index_pending_key.get() == Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize)) @@ -8302,12 +8628,18 @@ impl Scene { cursor.x + radius, cursor.y + radius, ]; - self.indexed_interaction_candidates_xy(wires, query, allow_pending_empty) + self.indexed_interaction_candidates_xy(wires, query, allow_pending_empty, false) } /// Shared rectangular broad phase for box/lasso/fence and command windows. /// Flat orthographic views query world XY; tilted/perspective views query /// projected 3D bounds using the supplied screen rectangle. + /// Candidates for an area selection: box, crossing, fence and lasso. + /// + /// Every caller feeds the result to the box/fence hit tests and to + /// `interaction_candidate_handles`, none of which snap, so the flat-ortho + /// path asks for the reduced category set. A caller that needs snapping + /// belongs on `interaction_candidates_near` instead. pub fn interaction_candidates_in_aabb( &self, wires: Arc>, @@ -8344,6 +8676,7 @@ impl Scene { wires, [aabb[0] - pad, aabb[1] - pad, aabb[2] + pad, aabb[3] + pad], false, + true, ) } else { self.indexed_interaction_candidates_screen( @@ -8399,7 +8732,7 @@ impl Scene { pub fn interaction_handles_in_world_aabb(&self, aabb: [f64; 4]) -> HashSet { let wires = self.hit_test_wires(); - let candidates = self.indexed_interaction_candidates_xy(wires, aabb, false); + let candidates = self.indexed_interaction_candidates_xy(wires, aabb, false, false); let mut handles: HashSet = candidates .iter() .filter_map(|wire| Self::handle_from_wire_name(&wire.name)) @@ -10577,6 +10910,169 @@ impl Default for Scene { } } +#[cfg(test)] +mod section_tests { + use super::*; + use acadrust::entities::{ + EntityCommon, ExtendedEntity, ExtendedEntityData, SectionObjectData, Solid3D, + }; + use acadrust::objects::ClassObjectData; + use acadrust::types::{Color, Vector3}; + + fn section(state: i32, depth: f64) -> SectionObjectData { + let vertices = vec![Vector3::new(5.0, 2.0, 5.0), Vector3::new(5.0, 8.0, 5.0)]; + SectionObjectData { + state, + flags: 5, + name: "Section Plane (1)".to_string(), + vertical_direction: Vector3::UNIT_Z, + top_height: 1.0, + bottom_height: 2.0, + indicator_alpha: 70, + indicator_color: Color::from_index(9), + back_line_vertices: (depth > 0.0) + .then(|| { + vertices + .iter() + .map(|point| *point + Vector3::new(-depth, 0.0, 0.0)) + .collect() + }) + .unwrap_or_default(), + vertices, + settings_handle: Handle::NULL, + } + } + + fn bounds(body: Option) -> ([f64; 3], [f64; 3]) { + let body = body.expect("section should retain material"); + assert!(body.validate().is_empty()); + let bounds = cadkernel::brep::body_bounds(&body).expect("sectioned body should be bounded"); + (bounds.min, bounds.max) + } + + fn assert_near(actual: [f64; 3], expected: [f64; 3]) { + for (actual, expected) in actual.into_iter().zip(expected) { + assert!((actual - expected).abs() < 1e-8, "{actual} != {expected}"); + } + } + + #[test] + fn kernel_clipping_obeys_plane_slice_boundary_and_volume_limits() { + let body = cadkernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap(); + + let (min, max) = bounds(Scene::section_body(&body, §ion(1, 0.0), None, None).unwrap()); + assert_near(min, [0.0, 0.0, 0.0]); + assert_near(max, [5.0, 10.0, 10.0]); + + let (min, max) = bounds( + Scene::section_body(&body, §ion(1, 0.0), Some(2.0), None).unwrap(), + ); + assert_near(min, [3.0, 0.0, 0.0]); + assert_near(max, [5.0, 10.0, 10.0]); + + let (min, max) = bounds(Scene::section_body(&body, §ion(2, 4.0), None, None).unwrap()); + assert_near(min, [1.0, 2.0, 0.0]); + assert_near(max, [5.0, 8.0, 10.0]); + + let (min, max) = bounds(Scene::section_body(&body, §ion(4, 4.0), None, None).unwrap()); + assert_near(min, [1.0, 2.0, 3.0]); + assert_near(max, [5.0, 8.0, 6.0]); + } + + #[test] + fn instance_transform_pulls_the_section_plane_into_body_space() { + let body = cadkernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap(); + let transform = acadrust::types::Transform::from_scaling( + acadrust::types::Vector3::new(2.0, 1.0, 1.0), + ) + .then(&acadrust::types::Transform::from_translation( + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + )); + let mut data = section(1, 0.0); + for point in &mut data.vertices { + point.x = 20.0; + } + + let (min, max) = bounds( + Scene::section_body(&body, &data, None, Some(&transform)).unwrap(), + ); + assert_near(min, [0.0, 0.0, 0.0]); + assert_near(max, [5.0, 10.0, 10.0]); + } + + #[test] + fn adding_and_erasing_a_section_keeps_its_object_graph_consistent() { + let mut scene = Scene::new(); + let handle = scene.add_entity(EntityType::Extended(ExtendedEntity { + common: EntityCommon::new(), + data: ExtendedEntityData::SectionObject(section(1, 0.0)), + })); + let settings_handle = match scene.document.get_entity(handle).unwrap() { + EntityType::Extended(ExtendedEntity { + data: ExtendedEntityData::SectionObject(data), + .. + }) => data.settings_handle, + _ => unreachable!(), + }; + assert!(matches!( + scene.document.objects.get(&settings_handle), + Some(ObjectType::ClassObject(object)) + if object.owner == handle + && matches!(&object.data, ClassObjectData::SectionSettings(_)) + )); + + let root = scene.document.header.named_objects_dict_handle; + let manager_handle = match scene.document.objects.get(&root).unwrap() { + ObjectType::Dictionary(dictionary) => dictionary + .entries + .iter() + .find(|(name, _)| name == "ACAD_SECTION_MANAGER") + .map(|(_, handle)| *handle) + .unwrap(), + _ => panic!("root must be a dictionary"), + }; + assert!(matches!( + scene.document.objects.get(&manager_handle), + Some(ObjectType::ClassObject(object)) + if matches!(&object.data, ClassObjectData::SectionManager(manager) + if manager.sections == vec![handle]) + )); + + scene.erase_entities(&[handle]); + assert!(!scene.document.objects.contains_key(&settings_handle)); + assert!(matches!( + scene.document.objects.get(&manager_handle), + Some(ObjectType::ClassObject(object)) + if matches!(&object.data, ClassObjectData::SectionManager(manager) + if manager.sections.is_empty()) + )); + } + + #[test] + fn live_section_rebuilds_an_uncached_persisted_solid() { + let body = cadkernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap(); + let sat = crate::scene::convert::acis_export::solid_to_sat(&body).unwrap(); + let mut solid = Solid3D::new(); + solid.set_sat_document(&sat); + let mut scene = Scene::new(); + let handle = scene.add_entity(EntityType::Solid3D(solid)); + assert!(!scene.solid_models.contains_key(&handle)); + + let clipped = scene + .sectioned_body( + handle, + &LiveSection { + data: section(1, 0.0), + slice_depth: None, + }, + ) + .unwrap(); + let (min, max) = bounds(clipped); + assert_near(min, [0.0, 0.0, 0.0]); + assert_near(max, [5.0, 10.0, 10.0]); + } +} + #[cfg(test)] mod journal_tests { use super::*; @@ -10874,6 +11370,8 @@ mod journal_tests { assert_eq!(folded.len(), before.len() + 1); } + // Differential oracle: the incrementally-patched entity index must always + // equal a from-scratch rebuild after any add / move / erase. #[test] fn block_member_index_matches_the_full_scan() { use acadrust::entities::Line; @@ -12090,4 +12588,129 @@ mod layout_cache_tests { println!("Zoom Frame Overhead: {:.3} ms ({:.0} FPS)", per_zoom_ms, 1000.0 / per_zoom_ms.max(0.001)); println!("============================================================================\n"); } + + #[test] + #[ignore = "benchmark"] + fn bench_wide_and_tapered_arc_rendering() { + use std::time::Instant; + use acadrust::entities::LwPolyline; + use acadrust::types::Vector2; + + let n_wide_arcs = 2500; + let n_tapered_arcs = 2500; + let n_donuts = 2500; + let total_entities = n_wide_arcs + n_tapered_arcs + n_donuts; + + let mut scene = Scene::new(); + + // 1. Wide polyline arcs (constant width 6.0) + for i in 0..n_wide_arcs { + let x = (i % 50) as f64 * 80.0; + let y = (i / 50) as f64 * 80.0; + let mut pline = LwPolyline::new(); + pline.constant_width = 6.0; + pline.vertices = vec![ + acadrust::entities::LwVertex { + location: Vector2::new(x, y), + bulge: 0.5, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + acadrust::entities::LwVertex { + location: Vector2::new(x + 40.0, y), + bulge: 0.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, + ]; + scene.add_entity(EntityType::LwPolyline(pline)); + } + + // 2. Tapered arcs (width: 2.0 -> 12.0) + for i in 0..n_tapered_arcs { + let x = (i % 50) as f64 * 80.0; + let y = (i / 50) as f64 * 80.0 + 4500.0; + let mut pline = LwPolyline::new(); + pline.vertices = vec![ + acadrust::entities::LwVertex { + location: Vector2::new(x, y), + bulge: 0.7, + start_width: 2.0, + end_width: 12.0, + vertex_id: 0, + }, + acadrust::entities::LwVertex { + location: Vector2::new(x + 35.0, y + 10.0), + bulge: 0.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, + ]; + scene.add_entity(EntityType::LwPolyline(pline)); + } + + // 3. Donuts (2 semicircular arc segments, constant width 16.0) + for i in 0..n_donuts { + let cx = (i % 50) as f64 * 80.0; + let cy = (i / 50) as f64 * 80.0 + 9000.0; + let donut = crate::modules::draw::draw::donut::make_donut(cx, cy, 0.0, 10.0, 26.0); + scene.add_entity(donut); + } + + let cam = Camera::default(); + let t_tess_start = Instant::now(); + let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0); + let tess_duration = t_tess_start.elapsed(); + + let total_wires = wires.len(); + let mut chord_points = 0usize; + let mut analytical_tangents = 0usize; + for w in wires.iter() { + chord_points += w.points.len(); + analytical_tangents += w.tangent_geoms.len(); + } + + let depths = rustc_hash::FxHashMap::default(); + let t_part_start = Instant::now(); + let iters = 100; + let mut part = None; + for _ in 0..iters { + part = Some(crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths)); + } + let part_duration = t_part_start.elapsed() / (iters as u32); + let p = part.unwrap(); + + // Zoom simulation + let zoom_levels = [0.1f32, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0, 100.0, 1000.0]; + let t_zoom_start = Instant::now(); + let mut dynamic_cam = Camera::default(); + for &zoom in zoom_levels.iter().cycle().take(100) { + dynamic_cam.distance = 1000.0 / zoom; + let _w = scene.model_tile_wires_arc(0, &dynamic_cam, 1.0, 1000.0); + } + let zoom_100_duration = t_zoom_start.elapsed(); + let per_zoom_ms = zoom_100_duration.as_secs_f64() * 1000.0 / 100.0; + + let line_vram_mb = (chord_points * 36) as f64 / (1024.0 * 1024.0); + let inst_vram_mb = (p.circle_instances.len() * 128) as f64 / (1024.0 * 1024.0); + + println!("\n=== BENCHMARK REPORT: THICK & TAPERED ANALYTICAL ARCS ==="); + println!("Thick & Tapered Entities: {total_entities}"); + println!("Initial Wire Build Time: {:.2?}", tess_duration); + println!("Total Wires: {total_wires}"); + println!("Tessellated Chord Vertices: {chord_points}"); + println!("Analytical Tangent Geometries: {analytical_tangents}"); + println!("Partitioning Time (per frame): {:.3?}", part_duration); + println!("Regular Line Wires: {}", p.regular.len()); + println!("GPU Circle/Arc Instances: {}", p.circle_instances.len()); + println!("Line Arena VRAM (chord lines): {:.2} MB", line_vram_mb); + println!("GPU Analytical VRAM: {:.2} MB", inst_vram_mb); + println!("VRAM Reduction Ratio: {:.1}x", if inst_vram_mb > 0.0 { line_vram_mb / inst_vram_mb } else { 0.0 }); + println!("100 Camera Zoom Navigations: {:.2?}", zoom_100_duration); + println!("Zoom Frame Overhead: {:.3} ms ({:.0} FPS)", per_zoom_ms, 1000.0 / per_zoom_ms.max(0.001)); + println!("========================================================\n"); + } } diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 1718313ee..f9cc88bb6 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1013,44 +1013,9 @@ fn brep_properties( history_flags(document, handle).unwrap_or((false, false, 1)); let (show_history, show_history_editable) = displayed_history_state(object_show_history, show_history_mode); - let mut geometry = vec![Property { - label: t!("Solid type").into_owned(), - field: "solid_history_type", - value: PropValue::ReadOnly(t!("3D Solid").into_owned()), - }]; - if let Some(position) = document - .solid_history_operation(handle) - .and_then(|operation| match operation { - SolidHistoryOperation::Brep(value) => world_point(value.base.transform, [0.0; 3]), - _ => None, - }) - { - geometry.extend([ - crate::entities::common::edit_prop( - t!("Position X").as_ref(), - PROP_POSITION_X, - position.x, - ), - crate::entities::common::edit_prop( - t!("Position Y").as_ref(), - PROP_POSITION_Y, - position.y, - ), - crate::entities::common::edit_prop( - t!("Position Z").as_ref(), - PROP_POSITION_Z, - position.z, - ), - ]); - } - vec![ - PropSection { - title: t!("Geometry").into_owned(), - props: geometry, - }, - PropSection { - title: t!("Solid History").into_owned(), - props: vec![ + vec![PropSection { + title: t!("Solid History").into_owned(), + props: vec![ Property { label: t!("History").into_owned(), field: PROP_HISTORY, @@ -1072,8 +1037,7 @@ fn brep_properties( }, }, ], - }, - ] + }] } fn cylinder_properties( diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index f9520c760..0a71ca48d 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -13,7 +13,7 @@ use iced::{Point, Rectangle}; use crate::scene::model::hatch_model::HatchModel; use crate::scene::model::mesh_model::MeshModel; use crate::scene::model::wire_model::WireModel; -use crate::scene::pick::interaction_index::WireSource; +use crate::scene::pick::interaction_index::{SegmentRef, WireSource}; /// Pick radius for one wire, in screen pixels. /// @@ -1247,13 +1247,14 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( || (0..4).any(|edge| segments_intersect(a, b, corners[edge], corners[(edge + 1) % 4])) }; - for segment in wires.segments().unwrap_or_default() { - let Some(wire) = wires.source_wire(segment.wire) else { - continue; - }; + // One projection pair per segment, reading nothing but the wire, the view + // and the box. Hoisted into a closure so the sequential and the parallel + // path below run identical code. + let hit_name = |segment: &SegmentRef| -> Option<&'a str> { + let wire = wires.source_wire(segment.wire)?; let start = segment.start as usize; if start + 1 >= wire.points.len() { - continue; + return None; } let a = world_to_screen( wire_point_world(wire, start, view_rot, eye), @@ -1267,8 +1268,30 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( eye, bounds, ); - if segment_hits(a, b) && seen.insert(wire.name.as_str()) { - out.push(wire.name.as_str()); + segment_hits(a, b).then_some(wire.name.as_str()) + }; + + let mut wire_hit: Vec = Vec::new(); + fn mark(wire_hit: &mut Vec, wire: u32) { + let index = wire as usize; + if index >= wire_hit.len() { + wire_hit.resize(index + 1, false); + } + wire_hit[index] = true; + } + fn already(wire_hit: &[bool], wire: u32) -> bool { + wire_hit.get(wire as usize).copied().unwrap_or(false) + } + + for segment in wires.segments().unwrap_or_default() { + if already(&wire_hit, segment.wire) { + continue; + } + if let Some(name) = hit_name(segment) { + mark(&mut wire_hit, segment.wire); + if seen.insert(name) { + out.push(name); + } } } for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { @@ -1287,7 +1310,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if seen.contains(wire.name.as_str()) { + if already(&wire_hit, triangle.wire) { continue; } if projected_wire_triangle( @@ -1299,9 +1322,11 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( bounds, ) .is_some_and(|triangle| triangle_crosses_box(triangle, corners)) - && seen.insert(wire.name.as_str()) { - out.push(wire.name.as_str()); + mark(&mut wire_hit, triangle.wire); + if seen.insert(wire.name.as_str()) { + out.push(wire.name.as_str()); + } } } } @@ -1309,7 +1334,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(glyph.wire) else { continue; }; - if seen.contains(wire.name.as_str()) { + if already(&wire_hit, glyph.wire) { continue; } let Some(screen) = @@ -1330,11 +1355,11 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( // Degenerate point-only wires have no indexed segment or surface primitive. for wire in wires.iter() { - if seen.contains(wire.name.as_str()) - || wire.points.len() >= 2 + if wire.points.len() >= 2 || !wire.fill_tris.is_empty() || !wire.pick_tris.is_empty() || !wire.text_verts.is_empty() + || seen.contains(wire.name.as_str()) { continue; } @@ -1540,11 +1565,11 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( } } for wire in wires.iter() { - if seen.contains(wire.name.as_str()) - || wire.points.len() >= 2 + if wire.points.len() >= 2 || !wire.fill_tris.is_empty() || !wire.pick_tris.is_empty() || !wire.text_verts.is_empty() + || seen.contains(wire.name.as_str()) { continue; } @@ -2712,3 +2737,79 @@ mod aabb_reject_tests { } } +#[cfg(test)] +mod parallel_selection_tests { + use super::*; + use crate::scene::pick::interaction_index::{InteractionCandidates, InteractionIndex}; + use std::sync::Arc; + + fn many_wires_with_spans(count: usize, spans: usize) -> Vec { + (0..count) + .map(|i| { + let t = i as f32 / count as f32; + let x = -0.95 + 1.9 * t; + let y = -0.95 + 1.9 * t; + let points: Vec<[f32; 3]> = (0..=spans) + .map(|s| { + let f = s as f32 / spans as f32; + [x + 0.01 * f, y + 0.01 * f, 0.0] + }) + .collect(); + let mut w = WireModel::solid( + (i as u64 + 1).to_string(), + points, + [1.0; 4], + false, + ); + w.aabb = [x, y, x + 0.01, y + 0.01]; + w + }) + .collect() + } + + #[test] + #[ignore = "measurement, not a check"] + fn selection_scaling_numbers() { + use std::time::Instant; + let wires = many_wires_with_spans(186_468, 24); + let index = InteractionIndex::build(&wires); + let arc = Arc::new(wires); + let aabb = [-2.0, -2.0, 2.0, 2.0]; + + let t = Instant::now(); + let full = index.query_xy(Arc::clone(&arc), aabb); + let full_ms = t.elapsed().as_secs_f64() * 1000.0; + + let t = Instant::now(); + let area = index.query_xy_area(Arc::clone(&arc), aabb); + let area_ms = t.elapsed().as_secs_f64() * 1000.0; + + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 200.0, + height: 200.0, + }; + let run = |candidates: &InteractionCandidates| { + let t = Instant::now(); + let hits = box_hit( + Point::new(0.0, 0.0), + Point::new(200.0, 200.0), + true, + candidates, + Mat4::IDENTITY, + glam::DVec3::ZERO, + bounds, + ); + (t.elapsed().as_secs_f64() * 1000.0, hits.len()) + }; + let (scan_ms, scan_n) = run(&area); + + println!( + "candidates: full={full_ms:.1}ms ({} wires) area={area_ms:.1}ms ({} wires)", + full.len(), + area.len(), + ); + println!("crossing scan: {scan_ms:.1}ms ({scan_n} hits)"); + } +} diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs index 4e1f6987a..5a2cd30fe 100644 --- a/src/scene/pick/interaction_index.rs +++ b/src/scene/pick/interaction_index.rs @@ -458,6 +458,62 @@ impl SpatialGrid { } } + fn query_unordered(&self, entries: &[Entry3], query: [f64; 4]) -> Vec { + let mut indices = Vec::new(); + self.oversized.query(entries, query, &mut indices); + let mut out: Vec = indices + .into_iter() + .filter_map(|idx| { + let entry = entries.get(idx as usize)?; + aabb_overlaps(entry_aabb2(entry), query).then_some(entry.value) + }) + .collect(); + + let grid_max_x = self.min[0] + self.cols as f64 * self.cell; + let grid_max_y = self.min[1] + self.rows as f64 * self.cell; + if query[2] < self.min[0] + || query[3] < self.min[1] + || query[0] > grid_max_x + || query[1] > grid_max_y + { + return out; + } + let col = |x: f64| { + (((x - self.min[0]) / self.cell).floor()).clamp(0.0, (self.cols - 1) as f64) as u32 + }; + let row = |y: f64| { + (((y - self.min[1]) / self.cell).floor()).clamp(0.0, (self.rows - 1) as f64) as u32 + }; + let (row_lo, row_hi) = (row(query[1]), row(query[3])); + let (col_lo, col_hi) = (col(query[0]), col(query[2])); + + // One growth instead of a chain of reallocations: a box over the whole + // drawing lands nearly every entry here. + let spanned: usize = (row_lo..=row_hi) + .map(|r| { + let base = r as usize * self.cols as usize; + let start = self.cell_offsets[base + col_lo as usize] as usize; + let end = self.cell_offsets[base + col_hi as usize + 1] as usize; + end.saturating_sub(start) + }) + .sum(); + out.reserve(spanned); + + for r in row_lo..=row_hi { + let base = r as usize * self.cols as usize; + for c in col_lo..=col_hi { + let cell_idx = base + c as usize; + let start = self.cell_offsets[cell_idx] as usize; + let end = self.cell_offsets[cell_idx + 1] as usize; + out.extend(self.cell_entries[start..end].iter().filter_map(|&idx| { + let entry = entries.get(idx as usize)?; + aabb_overlaps(entry_aabb2(entry), query).then_some(entry.value) + })); + } + } + out + } + fn query(&self, entries: &[Entry3], query: [f64; 4]) -> Vec { let mut entry_indices = Vec::new(); self.oversized.query(entries, query, &mut entry_indices); @@ -520,6 +576,11 @@ impl SpatialSet { self.xy.query(&self.entries, aabb) } + /// See [`SpatialGrid::query_unordered`] — same set, no sort, duplicates in. + fn query_xy_unordered(&self, aabb: [f64; 4]) -> Vec { + self.xy.query_unordered(&self.entries, aabb) + } + fn prepare_screen(&self) { self.xyz .get_or_init(|| SpatialBvh3::build(&self.entries)); @@ -839,17 +900,17 @@ impl InteractionIndex { let ( ((segment_entries, snap_point_entries), (key_vertex_entries, key_segment_entries)), ((fill_triangle_entries, pick_triangle_entries), glyph_entries), - ) = rayon::join( + ) = crate::par::join( || { - rayon::join( + crate::par::join( || { - rayon::join( + crate::par::join( || flatten_entry_parts(segment_parts), || flatten_entry_parts(snap_point_parts), ) }, || { - rayon::join( + crate::par::join( || flatten_entry_parts(key_vertex_parts), || flatten_entry_parts(key_segment_parts), ) @@ -857,7 +918,7 @@ impl InteractionIndex { ) }, || { - let (fill, pick) = rayon::join( + let (fill, pick) = crate::par::join( || flatten_entry_parts(fill_triangle_parts), || flatten_entry_parts(pick_triangle_parts), ); @@ -891,17 +952,17 @@ impl InteractionIndex { let ( ((wires, segments), (snap_points, key_vertices)), ((key_segments, fill_triangles), (pick_triangles, glyphs)), - ) = rayon::join( + ) = crate::par::join( || { - rayon::join( + crate::par::join( || { - rayon::join( + crate::par::join( || SpatialSet::build(wire_entries), || SpatialSet::build(segment_entries), ) }, || { - rayon::join( + crate::par::join( || SpatialSet::build(snap_point_entries), || SpatialSet::build(key_vertex_entries), ) @@ -909,15 +970,15 @@ impl InteractionIndex { ) }, || { - rayon::join( + crate::par::join( || { - rayon::join( + crate::par::join( || SpatialSet::build(key_segment_entries), || SpatialSet::build(fill_triangle_entries), ) }, || { - rayon::join( + crate::par::join( || SpatialSet::build(pick_triangle_entries), || SpatialSet::build(glyph_entries), ) @@ -979,12 +1040,12 @@ impl InteractionIndex { /// thread. Perspective/orbit hover must never pay this one-time cost. pub fn prepare_screen(&self) { #[cfg(not(target_arch = "wasm32"))] - rayon::join( + crate::par::join( || { - rayon::join( - || rayon::join(|| self.wires.prepare_screen(), || self.segments.prepare_screen()), + crate::par::join( + || crate::par::join(|| self.wires.prepare_screen(), || self.segments.prepare_screen()), || { - rayon::join( + crate::par::join( || self.snap_points.prepare_screen(), || self.key_vertices.prepare_screen(), ) @@ -992,15 +1053,15 @@ impl InteractionIndex { ) }, || { - rayon::join( + crate::par::join( || { - rayon::join( + crate::par::join( || self.key_segments.prepare_screen(), || self.fill_triangles.prepare_screen(), ) }, || { - rayon::join( + crate::par::join( || self.pick_triangles.prepare_screen(), || self.glyphs.prepare_screen(), ) @@ -1136,6 +1197,52 @@ impl InteractionIndex { .collect() } + pub(crate) fn query_remapped_xy_area( + &self, + wires: Arc>, + slots: &rustc_hash::FxHashMap<(u64, u32), u32>, + aabb: [f64; 4], + ) -> InteractionCandidates { + InteractionCandidates { + wires, + wire_indices: Some(self.remap_wire_indices( + self.wires.query_xy_unordered(aabb), + slots, + false, + )), + segments: Some(self.remap_refs( + self.segments.query_xy_unordered(aabb), + slots, + |value| value.wire, + |value, wire| value.wire = wire, + )), + snap_points: None, + key_vertices: None, + key_segments: None, + fill_triangles: Some(self.remap_refs( + self.fill_triangles.query_xy_unordered(aabb), + slots, + |value| value.wire, + |value, wire| value.wire = wire, + )), + pick_triangles: Some(self.remap_refs( + self.pick_triangles.query_xy_unordered(aabb), + slots, + |value| value.wire, + |value, wire| value.wire = wire, + )), + glyphs: Some(self.remap_refs( + self.glyphs.query_xy_unordered(aabb), + slots, + |value| value.wire, + |value, wire| value.wire = wire, + )), + query_aabb: Some(aabb), + screen_rect: None, + screen_view: None, + } + } + pub(crate) fn query_remapped_xy( &self, wires: Arc>, @@ -1269,6 +1376,53 @@ impl InteractionIndex { } } + pub fn query_xy_area( + &self, + wires: Arc>, + aabb: [f64; 4], + ) -> InteractionCandidates { + let ((wire_indices, segments), ((fill_triangles, pick_triangles), glyphs)) = + crate::par::join( + || { + crate::par::join( + || { + let mut indices = self.wires.query_xy_unordered(aabb); + indices.extend_from_slice(&self.unbounded_wires); + indices.sort_unstable(); + indices.dedup(); + indices + }, + || self.segments.query_xy_unordered(aabb), + ) + }, + || { + crate::par::join( + || { + crate::par::join( + || self.fill_triangles.query_xy_unordered(aabb), + || self.pick_triangles.query_xy_unordered(aabb), + ) + }, + || self.glyphs.query_xy_unordered(aabb), + ) + }, + ); + InteractionCandidates { + wires, + wire_indices: Some(wire_indices), + segments: Some(segments), + snap_points: None, + key_vertices: None, + key_segments: None, + fill_triangles: Some(fill_triangles), + pick_triangles: Some(pick_triangles), + glyphs: Some(glyphs), + query_aabb: Some(aabb), + screen_rect: None, + screen_view: None, + } + } + pub fn query_xy(&self, wires: Arc>, aabb: [f64; 4]) -> InteractionCandidates { let mut wire_indices = self.wires.query_xy(aabb); wire_indices.extend_from_slice(&self.unbounded_wires); @@ -1505,6 +1659,29 @@ impl InteractionCandidates { .map(|(rect, (view, eye, bounds))| (rect, view, eye, bounds)) } + pub(crate) fn extend_indexed_area(&mut self, other: Self) { + debug_assert!(Arc::ptr_eq(&self.wires, &other.wires)); + + fn append(target: &mut Option>, incoming: Option>) { + let (Some(target), Some(incoming)) = (target.as_mut(), incoming) else { + return; + }; + target.extend(incoming); + } + + if let (Some(target), Some(incoming)) = + (self.wire_indices.as_mut(), other.wire_indices) + { + target.extend(incoming); + target.sort_unstable(); + target.dedup(); + } + append(&mut self.segments, other.segments); + append(&mut self.fill_triangles, other.fill_triangles); + append(&mut self.pick_triangles, other.pick_triangles); + append(&mut self.glyphs, other.glyphs); + } + pub(crate) fn extend_indexed(&mut self, other: Self) { debug_assert!(Arc::ptr_eq(&self.wires, &other.wires)); @@ -1830,3 +2007,138 @@ fn aabb3_projects_into( fn aabb_overlaps(a: [f64; 4], b: [f64; 4]) -> bool { a[2] >= b[0] && a[0] <= b[2] && a[3] >= b[1] && a[1] <= b[3] } + +#[cfg(test)] +mod area_query_tests { + use super::*; + use iced::{Point, Rectangle}; + + fn wire(name: &str, pts: Vec<[f32; 3]>, aabb: [f32; 4]) -> WireModel { + let mut w = WireModel::solid(name.to_string(), pts, [1.0; 4], false); + w.aabb = aabb; + w + } + + fn sample() -> Vec { + vec![ + wire("5", vec![[-0.5, -0.5, 0.0], [0.5, 0.5, 0.0]], [-0.5, -0.5, 0.5, 0.5]), + wire("9", vec![[0.1, -0.9, 0.0], [0.9, -0.1, 0.0]], [0.1, -0.9, 0.9, -0.1]), + wire("13", vec![[-0.9, 0.2, 0.0], [-0.2, 0.9, 0.0]], [-0.9, 0.2, -0.2, 0.9]), + ] + } + + #[test] + fn area_query_matches_full_query_where_it_is_read() { + let wires = sample(); + let index = InteractionIndex::build(&wires); + let arc = Arc::new(wires); + let aabb = [-2.0, -2.0, 2.0, 2.0]; + + let full = index.query_xy(Arc::clone(&arc), aabb); + let area = index.query_xy_area(Arc::clone(&arc), aabb); + + fn normalise(v: &Option>) -> Option> { + v.as_ref().map(|items| { + let mut items = items.clone(); + items.sort_unstable(); + items.dedup(); + items + }) + } + assert_eq!(area.wire_indices, full.wire_indices); + assert_eq!(normalise(&area.segments), normalise(&full.segments)); + assert_eq!( + normalise(&area.fill_triangles), + normalise(&full.fill_triangles), + ); + assert_eq!( + normalise(&area.pick_triangles), + normalise(&full.pick_triangles), + ); + assert_eq!(normalise(&area.glyphs), normalise(&full.glyphs)); + assert_eq!(area.query_aabb, full.query_aabb); + + // The sample carries no hatches or text, so the equality above is + // thin for those three. What matters is that they stay indexed at all: + // dropping one to `None` is what would silently unselect a hatch. + assert!(area.fill_triangles.is_some()); + assert!(area.pick_triangles.is_some()); + assert!(area.glyphs.is_some()); + + assert!(area.snap_points.is_none()); + assert!(area.key_vertices.is_none()); + assert!(area.key_segments.is_none()); + assert!(full.snap_points.is_some(), "the full query still snaps"); + } + + #[test] + fn the_area_merge_dedups_wire_indices_and_nothing_else() { + let arc = Arc::new(sample()); + let base = |indices: Vec, segments: Vec| InteractionCandidates { + wires: Arc::clone(&arc), + wire_indices: Some(indices), + segments: Some(segments), + snap_points: None, + key_vertices: None, + key_segments: None, + fill_triangles: Some(Vec::new()), + pick_triangles: Some(Vec::new()), + glyphs: Some(Vec::new()), + query_aabb: Some([-2.0, -2.0, 2.0, 2.0]), + screen_rect: None, + screen_view: None, + }; + let seg = |wire: u32| SegmentRef { wire, start: 0 }; + + let mut merged = base(vec![2, 0], vec![seg(2), seg(0)]); + merged.extend_indexed_area(base(vec![1, 0], vec![seg(1), seg(0)])); + + assert_eq!(merged.wire_indices, Some(vec![0, 1, 2])); + assert_eq!( + merged.segments, + Some(vec![seg(2), seg(0), seg(1), seg(0)]), + "the other categories are appended as they come", + ); + } + + // World (x,y) → screen ((x+1)*100, (1-y)*100) over a 200×200 viewport, the + // same identity ortho view the hit-test tests use. + #[test] + fn box_selection_picks_the_same_handles_from_either_query() { + let wires = sample(); + let index = InteractionIndex::build(&wires); + let arc = Arc::new(wires); + let aabb = [-2.0, -2.0, 2.0, 2.0]; + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 200.0, + height: 200.0, + }; + let eye = glam::DVec3::ZERO; + + for crossing in [false, true] { + let full = index.query_xy(Arc::clone(&arc), aabb); + let area = index.query_xy_area(Arc::clone(&arc), aabb); + let hit = |c: &InteractionCandidates| { + let mut names = crate::scene::pick::hit_test::box_hit( + Point::new(0.0, 0.0), + Point::new(200.0, 200.0), + crossing, + c, + glam::Mat4::IDENTITY, + eye, + bounds, + ) + .into_iter() + .map(str::to_string) + .collect::>(); + names.sort(); + names + }; + let from_full = hit(&full); + assert!(!from_full.is_empty(), "the box covers the whole sample"); + assert_eq!(hit(&area), from_full, "crossing={crossing}"); + } + } +} diff --git a/src/scene/pipeline/circle_gpu.rs b/src/scene/pipeline/circle_gpu.rs index d62902a64..f38b46605 100644 --- a/src/scene/pipeline/circle_gpu.rs +++ b/src/scene/pipeline/circle_gpu.rs @@ -11,7 +11,7 @@ use iced::wgpu; #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] pub struct CircleInstance { pub center_high: [f32; 3], - pub _pad0: f32, + pub start_width: f32, pub center_low: [f32; 3], pub radius: f32, pub axis_x: [f32; 3], @@ -19,7 +19,7 @@ pub struct CircleInstance { pub axis_y: [f32; 3], pub end_angle: f32, pub color: [f32; 4], - /// `[half_width, pattern_length, draw_depth, padding]` + /// `[half_width, pattern_length, draw_depth, end_width]` pub params: [f32; 4], pub pat0: [f32; 4], pub pat1: [f32; 4], @@ -145,7 +145,7 @@ pub fn create_pipelines( }, depth_stencil: Some(wgpu::DepthStencilState { format: wgpu::TextureFormat::Depth24PlusStencil8, - depth_write_enabled: Some(true), + depth_write_enabled: Some(false), depth_compare: Some(wgpu::CompareFunction::LessEqual), stencil: content_stencil.clone(), bias: wgpu::DepthBiasState::default(), @@ -204,6 +204,16 @@ pub fn extract_circle_instance_from_geom( geom: &crate::scene::model::wire_model::TangentGeom, wire: &crate::scene::WireModel, draw_depth: f32, +) -> Option { + extract_circle_instance_from_geom_indexed(geom, wire, draw_depth, 0) +} + +/// Helper to extract an analytical circle or circular arc instance at a specific segment index. +pub fn extract_circle_instance_from_geom_indexed( + geom: &crate::scene::model::wire_model::TangentGeom, + wire: &crate::scene::WireModel, + draw_depth: f32, + geom_index: usize, ) -> Option { let (center, axis_x, axis_y, radius, start_angle, end_angle) = match *geom { crate::scene::model::wire_model::TangentGeom::Circle { center, radius } => ( @@ -246,9 +256,25 @@ pub fn extract_circle_instance_from_geom( let pat0 = [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]]; let pat1 = [wire.pattern[4], wire.pattern[5], wire.pattern[6], wire.pattern[7]]; + let (start_width, end_width) = if !wire.taper_widths.is_empty() { + if wire.taper_widths.len() >= 2 && wire.tangent_geoms.len() == 1 { + (wire.taper_widths[0], wire.taper_widths[1]) + } else if wire.taper_widths.len() > geom_index { + let sw = wire.taper_widths[geom_index]; + let ew = wire.taper_widths.get(geom_index + 1).copied().unwrap_or(sw); + (sw, ew) + } else { + (wire.world_width, wire.world_width) + } + } else if wire.world_width > 0.0 { + (wire.world_width, wire.world_width) + } else { + (0.0, 0.0) + }; + Some(CircleInstance { center_high: ch, - _pad0: 0.0, + start_width, center_low: cl, radius: radius as f32, axis_x: [axis_x[0] as f32, axis_x[1] as f32, axis_x[2] as f32], @@ -256,7 +282,7 @@ pub fn extract_circle_instance_from_geom( axis_y: [axis_y[0] as f32, axis_y[1] as f32, axis_y[2] as f32], end_angle, color: wire.color, - params: [hw, wire.pattern_length, draw_depth, 0.0], + params: [hw, wire.pattern_length, draw_depth, end_width], pat0, pat1, }) @@ -265,14 +291,14 @@ pub fn extract_circle_instance_from_geom( /// Helper to extract all analytical circle/arc instances from a `WireModel`. /// /// Returns `Some(instances)` if the wire is non-empty, contains solely analytical -/// circle/arc tangent geometries, and has no mesh/pick fills, text, or block instance. +/// circle/arc tangent geometries, and has no mesh fills, text, or block instance. pub fn extract_circle_instances( wire: &crate::scene::WireModel, draw_depth: f32, ) -> Option> { if wire.tangent_geoms.is_empty() || !wire.fill_tris.is_empty() - || !wire.pick_tris.is_empty() + || wire.fill_is_3d || !wire.text_verts.is_empty() || wire.render_instance.is_some() { @@ -280,8 +306,8 @@ pub fn extract_circle_instances( } let mut instances = Vec::with_capacity(wire.tangent_geoms.len()); - for geom in &wire.tangent_geoms { - if let Some(inst) = extract_circle_instance_from_geom(geom, wire, draw_depth) { + for (i, geom) in wire.tangent_geoms.iter().enumerate() { + if let Some(inst) = extract_circle_instance_from_geom_indexed(geom, wire, draw_depth, i) { instances.push(inst); } else { return None; @@ -410,4 +436,100 @@ mod tests { }; assert!(extract_circle_instance(&wire, 0.0).is_none()); } + + #[test] + fn extract_circle_instance_constant_wide_arc() { + let mut wire = crate::scene::WireModel::default(); + wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc { + center: [100.0, 50.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 40.0, + start_angle: 0.0, + end_angle: std::f64::consts::PI, + }); + wire.world_width = 12.0; + wire.color = [1.0, 0.5, 0.0, 1.0]; + + let inst = extract_circle_instance(&wire, 0.0).expect("extract constant wide arc"); + assert_eq!(inst.start_width, 12.0); + assert_eq!(inst.params[3], 12.0); // end_width + assert_eq!(inst.radius, 40.0); + } + + #[test] + fn extract_circle_instance_tapered_arc() { + let mut wire = crate::scene::WireModel::default(); + wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc { + center: [0.0, 0.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 30.0, + start_angle: 0.2, + end_angle: 1.8, + }); + wire.world_width = 20.0; + wire.taper_widths = vec![4.0, 20.0]; + wire.color = [0.2, 0.8, 1.0, 1.0]; + + let inst = extract_circle_instance(&wire, 0.0).expect("extract tapered arc"); + assert_eq!(inst.start_width, 4.0); + assert_eq!(inst.params[3], 20.0); // end_width + assert_eq!(inst.radius, 30.0); + } + + #[test] + fn extract_circle_instance_accepts_pick_tris() { + let mut wire = crate::scene::WireModel::default(); + wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Circle { + center: [5.0, 5.0, 0.0], + radius: 15.0, + }); + wire.world_width = 6.0; + wire.pick_tris = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; + wire.pick_tris_low = vec![[0.0; 3]; 3]; + + let insts = extract_circle_instances(&wire, 0.0).expect("wide curve with pick_tris should extract"); + assert_eq!(insts.len(), 1); + assert_eq!(insts[0].start_width, 6.0); + assert_eq!(insts[0].params[3], 6.0); + } + + #[test] + fn test_donut_polyline_tessellates_to_analytical_arcs() { + use acadrust::CadDocument; + let mut doc = CadDocument::new(); + let donut = crate::modules::draw::draw::donut::make_donut(10.0, 20.0, 0.0, 10.0, 30.0); + let h = doc.add_entity(donut).unwrap(); + let entity = doc.get_entity(h).unwrap(); + let selected = rustc_hash::FxHashSet::default(); + let wires = crate::scene::convert::tess::tessellate_entity( + &doc, + &selected, + None, + [0.0, 0.0, 0.0, 1.0], + 1.0, + None, + entity, + None, + None, + None, + false, + ); + assert_eq!(wires.len(), 2, "donut should split into 2 analytical arc wires"); + for w in &wires { + assert_eq!(w.world_width, 20.0); + assert_eq!(w.tangent_geoms.len(), 1); + assert!(matches!(w.tangent_geoms[0], crate::scene::model::wire_model::TangentGeom::Arc { .. })); + } + + let depth_map = rustc_hash::FxHashMap::default(); + let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depth_map); + assert_eq!(partitioned.circle_instances.len(), 2); + assert_eq!(partitioned.regular.len(), 0); + assert_eq!(partitioned.circle_instances[0].start_width, 20.0); + assert_eq!(partitioned.circle_instances[0].params[3], 20.0); + assert_eq!(partitioned.circle_instances[1].start_width, 20.0); + assert_eq!(partitioned.circle_instances[1].params[3], 20.0); + } } diff --git a/src/scene/pipeline/ellipse_gpu.rs b/src/scene/pipeline/ellipse_gpu.rs index 50bc2e59d..0ed65407e 100644 --- a/src/scene/pipeline/ellipse_gpu.rs +++ b/src/scene/pipeline/ellipse_gpu.rs @@ -145,7 +145,7 @@ pub fn create_pipelines( }, depth_stencil: Some(wgpu::DepthStencilState { format: wgpu::TextureFormat::Depth24PlusStencil8, - depth_write_enabled: Some(true), + depth_write_enabled: Some(false), depth_compare: Some(wgpu::CompareFunction::LessEqual), stencil: content_stencil.clone(), bias: wgpu::DepthBiasState::default(), @@ -254,7 +254,7 @@ pub fn extract_ellipse_instances( ) -> Option> { if wire.tangent_geoms.is_empty() || !wire.fill_tris.is_empty() - || !wire.pick_tris.is_empty() + || wire.fill_is_3d || !wire.text_verts.is_empty() || wire.render_instance.is_some() { diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index 6ff34c4d0..c2dfac225 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -521,6 +521,199 @@ fn test_selected_circle_arc_ellipse_highlight_overlay() { device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); } +#[test] +#[ignore = "requires a GPU adapter"] +fn test_thick_and_tapered_arc_gpu_rendering() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + + // 1. Wide circular arc with pick triangles + let mut wide_arc = WireModel::default(); + wide_arc.name = "wide_arc".into(); + wide_arc.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc { + center: [50.0, 50.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 30.0, + start_angle: 0.0, + end_angle: std::f64::consts::PI, + }); + wide_arc.world_width = 10.0; + wide_arc.color = [1.0, 0.2, 0.2, 1.0]; + wide_arc.pick_tris = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; + wide_arc.pick_tris_low = vec![[0.0; 3]; 3]; + + // 2. Tapered circular arc + let mut tapered_arc = WireModel::default(); + tapered_arc.name = "tapered_arc".into(); + tapered_arc.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc { + center: [150.0, 50.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 40.0, + start_angle: 0.2, + end_angle: 2.8, + }); + tapered_arc.world_width = 16.0; + tapered_arc.taper_widths = vec![2.0, 16.0]; + tapered_arc.color = [0.2, 0.8, 1.0, 1.0]; + + let wires = vec![wide_arc, tapered_arc]; + let depth_map = rustc_hash::FxHashMap::default(); + let circles = pipeline.upload_circles(&device, &queue, &wires, &depth_map); + + // Both curves must be routed to GPU analytical circle instances! + assert!(!circles.is_empty(), "analytical circles must be uploaded"); + assert_eq!(circles[0].instance_count, 2); + pipeline.gpu_circles = std::sync::Arc::new(circles); + assert_eq!(pipeline.gpu_circles.len(), 1); + assert_eq!(pipeline.gpu_circles[0].instance_count, 2); + + pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512)); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("thick_arc_target"), + size: wgpu::Extent3d { + width: 512, + height: 512, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let target = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("thick_arc_encoder"), + }); + pipeline.render( + &mut encoder, + &target, + iced::Size::new(512, 512), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, + [0.0, 0.0, 0.0, 1.0], + false, + false, + false, + ); + queue.submit(Some(encoder.finish())); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); +} + +#[test] +#[ignore = "requires a GPU adapter"] +fn test_tilted_3d_donut_and_thick_arc_gpu_rendering() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + + // Setup 3D perspective camera with acute tilt + let mut camera = crate::scene::view::camera::Camera::default(); + camera.projection = crate::scene::view::camera::Projection::Perspective; + camera.distance = 200.0; + camera.target = glam::DVec3::new(50.0, 50.0, 0.0); + camera.rotation = glam::Quat::from_axis_angle(glam::Vec3::X, 1.1); // ~63 degree tilt (acute perspective) + let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(512.0, 512.0)); + let uniforms = Uniforms::new(&camera, bounds, false); + pipeline.upload_uniforms(&device, &queue, &uniforms); + + // Donut circular arc segments + let mut donut = WireModel::default(); + donut.name = "donut".into(); + donut.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc { + center: [50.0, 50.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 30.0, + start_angle: 0.0, + end_angle: std::f64::consts::PI, + }); + donut.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc { + center: [50.0, 50.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 30.0, + start_angle: std::f64::consts::PI, + end_angle: std::f64::consts::TAU, + }); + donut.world_width = 12.0; + donut.color = [0.2, 0.9, 0.3, 1.0]; + + let wires = vec![donut]; + let depth_map = rustc_hash::FxHashMap::default(); + let circles = pipeline.upload_circles(&device, &queue, &wires, &depth_map); + + assert_eq!(circles.len(), 1); + assert_eq!(circles[0].instance_count, 2); + pipeline.gpu_circles = std::sync::Arc::new(circles); + + // Wide straight polyline segment on the 3D plane + let mut straight_wide = WireModel::default(); + straight_wide.name = "wide_straight".into(); + straight_wide.points = vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]]; + straight_wide.world_width = 10.0; + straight_wide.color = [0.9, 0.2, 0.3, 1.0]; + + let wide_wires = vec![straight_wide]; + let gpu_wires = wire_gpu::WireGpu::from_run( + &device, + &queue, + &wide_wires, + &depth_map, + false, + pipeline.wire_const_bgl.as_ref(), + ); + assert!(!gpu_wires.is_empty()); + pipeline.gpu_wires = std::sync::Arc::new(gpu_wires); + + pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512)); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("tilted_donut_target"), + size: wgpu::Extent3d { + width: 512, + height: 512, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let target = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("tilted_donut_encoder"), + }); + pipeline.render( + &mut encoder, + &target, + iced::Size::new(512, 512), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, + [0.0, 0.0, 0.0, 1.0], + false, + false, + false, + ); + queue.submit(Some(encoder.finish())); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); +} + #[test] #[ignore = "requires a GPU adapter"] fn test_selected_ellipse_overlay() { diff --git a/src/scene/pipeline/mesh_gpu.rs b/src/scene/pipeline/mesh_gpu.rs index 391cb139e..cdd55f428 100644 --- a/src/scene/pipeline/mesh_gpu.rs +++ b/src/scene/pipeline/mesh_gpu.rs @@ -568,12 +568,15 @@ struct MeshSurfaceParams { flags: [u32; 4], } -/// Downscale a decoded RGBA buffer so neither dimension exceeds `limit`, -/// preserving aspect ratio (scaled by whichever dimension overshoots more). -/// `None` only for genuinely malformed input (`rgba`'s length doesn't match -/// `width * height * 4`) — the caller falls back to the 1x1 material color -/// in that case, same as it already does for a missing image entirely. +fn valid_rgba_size(width: u32, height: u32, pixels: &[u8]) -> bool { + width > 0 && height > 0 + && (width as usize).checked_mul(height as usize).and_then(|size| size.checked_mul(4)) == Some(pixels.len()) +} + fn downscale_rgba_to_limit(width: u32, height: u32, rgba: &[u8], limit: u32) -> Option<(u32, u32, Vec)> { + if limit == 0 || !valid_rgba_size(width, height, rgba) { + return None; + } let buffer = image::RgbaImage::from_raw(width, height, rgba.to_vec())?; let scale = (limit as f64 / width.max(height) as f64).min(1.0); let new_width = ((width as f64 * scale).round() as u32).clamp(1, limit); @@ -590,14 +593,9 @@ fn upload_rgba_texture( fallback: [u8; 4], srgb: bool, ) -> wgpu::TextureView { - // A material map's decoded dimensions come straight from the source file - // (`material_model::load_map_image`), uncapped — an oversized image would - // otherwise fail `wgpu` texture-size validation and leave the material - // unusable (audit: raster-image rendering already tiles to this same - // limit in `image_gpu.rs`; a mesh material sampled by UV can't use that - // per-quad tiling trick, so downscale instead of tiling). let limit = device.limits().max_texture_dimension_2d.max(1); let resized; + let image = image.filter(|image| valid_rgba_size(image.width, image.height, &image.rgba)); let (width, height, pixels): (u32, u32, &[u8]) = match image { Some(image) if image.width > limit || image.height > limit => { match downscale_rgba_to_limit(image.width, image.height, &image.rgba, limit) { @@ -2494,4 +2492,13 @@ mod texture_limit_tests { let rgba = vec![0u8; 10]; assert!(downscale_rgba_to_limit(1000, 1000, &rgba, 512).is_none()); } + + #[test] + fn malformed_material_images_are_rejected_even_below_the_texture_limit() { + for (width, height, pixels) in [(0, 1, vec![]), (1, 0, vec![]), (1, 1, vec![0; 3]), (1, 1, vec![0; 5])] { + assert!(!super::valid_rgba_size(width, height, &pixels)); + assert!(downscale_rgba_to_limit(width, height, &pixels, 16).is_none()); + } + assert!(downscale_rgba_to_limit(1, 1, &[0; 4], 0).is_none()); + } } diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 880464b12..930de4ba6 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -2449,11 +2449,17 @@ impl Pipeline { if instances.is_empty() { vec![] } else { + let mut sorted = instances.to_vec(); + sorted.sort_by(|a, b| { + a.params[2] + .partial_cmp(&b.params[2]) + .unwrap_or(std::cmp::Ordering::Equal) + }); vec![CircleGpu::from_instances( device, queue, "viewer.circles", - instances, + &sorted, )] } } @@ -2489,11 +2495,17 @@ impl Pipeline { if instances.is_empty() { vec![] } else { + let mut sorted = instances.to_vec(); + sorted.sort_by(|a, b| { + a.params[2] + .partial_cmp(&b.params[2]) + .unwrap_or(std::cmp::Ordering::Equal) + }); vec![EllipseGpu::from_instances( device, queue, "viewer.ellipses", - instances, + &sorted, )] } } @@ -2612,6 +2624,72 @@ impl Pipeline { /// refreshes just this overlay instead of re-tessellating the model. The /// xray pass applies neither scissor nor mesh-edge skip, so everything /// merges into one order-preserving run. + /// Split highlighted wires into analytic instances and the wires that need + /// real geometry, in one extraction pass per wire. + #[allow(clippy::type_complexity)] + fn classify_highlight_wires<'a>( + wires: &[&'a WireModel], + color: Option<[f32; 4]>, + depth_map: &rustc_hash::FxHashMap, + ) -> ( + Vec, + Vec, + Vec<&'a WireModel>, + Vec<&'a WireModel>, + ) { + use crate::par::prelude::*; + // Pure per-wire work, so it fans out; `collect` on an indexed + // parallel iterator keeps the order, and the order is what decides + // the instance layout. + let per: Vec<(Option>, Option>, bool)> = + wires + .par_iter() + .map(|&wire| { + if wire.render_instance.is_some() { + return (None, None, true); + } + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + let mut circles = circle_gpu::extract_circle_instances(wire, depth); + let mut ellipses = ellipse_gpu::extract_ellipse_instances(wire, depth); + if let Some(color) = color { + if let Some(instances) = circles.as_mut() { + for instance in instances { + instance.color = color; + } + } + if let Some(instances) = ellipses.as_mut() { + for instance in instances { + instance.color = color; + } + } + } + (circles, ellipses, false) + }) + .collect(); + + let mut circles: Vec = Vec::new(); + let mut ellipses: Vec = Vec::new(); + let mut regular: Vec<&'a WireModel> = Vec::new(); + let mut blocks: Vec<&'a WireModel> = Vec::new(); + for (&wire, (wire_circles, wire_ellipses, is_block)) in wires.iter().zip(per) { + if is_block { + blocks.push(wire); + continue; + } + let shaped = wire_circles.is_some() || wire_ellipses.is_some(); + if let Some(instances) = wire_circles { + circles.extend(instances); + } + if let Some(instances) = wire_ellipses { + ellipses.extend(instances); + } + if !shaped { + regular.push(wire); + } + } + (circles, ellipses, regular, blocks) + } + pub fn upload_selected_wires( &mut self, device: &wgpu::Device, @@ -2637,9 +2715,11 @@ impl Pipeline { let mut hover_wires: Vec<&WireModel> = Vec::new(); for h in selected { if let Some(idxs) = self.wire_handle_index.get(&h.value()) { - let mut slots = idxs.clone(); - slots.sort_unstable(); - for &i in &slots { + debug_assert!( + idxs.windows(2).all(|pair| pair[0] <= pair[1]), + "wire_handle_index slots must stay ascending", + ); + for &i in idxs { if let Some(w) = wires.get(i as usize) { if !w.display_visible { continue; @@ -2651,9 +2731,7 @@ impl Pipeline { } for h in hovered.iter().filter(|handle| !selected.contains(handle)) { if let Some(idxs) = self.wire_handle_index.get(&h.value()) { - let mut slots = idxs.clone(); - slots.sort_unstable(); - for &i in &slots { + for &i in idxs { if let Some(w) = wires.get(i as usize) { if !w.display_visible { continue; @@ -2673,26 +2751,23 @@ impl Pipeline { hover_wires.push(wire); } } - let mut selected_circles: Vec = Vec::new(); - let circle_sel_tint = selected_tint.unwrap_or(WireModel::SELECTED); - for &wire in &selected_wires { - let depth = wire_gpu::wire_draw_depth(wire, depth_map); - if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) { - for mut inst in insts { - inst.color = circle_sel_tint; - selected_circles.push(inst); - } - } - } - for &wire in &hover_wires { - let depth = wire_gpu::wire_draw_depth(wire, depth_map); - if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) { - for mut inst in insts { - inst.color = WireModel::HOVER; - selected_circles.push(inst); - } - } - } + let t_gather = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0); + let (mut selected_circles, mut selected_ellipses, selected_regular, selected_blocks) = + Self::classify_highlight_wires( + &selected_wires, + Some(selected_tint.unwrap_or(WireModel::SELECTED)), + depth_map, + ); + let (hover_circles, hover_ellipses, hover_regular, hover_blocks) = + Self::classify_highlight_wires( + &hover_wires, + Some(WireModel::HOVER), + depth_map, + ); + selected_circles.extend(hover_circles); + selected_ellipses.extend(hover_ellipses); + + let t_shapes = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0); self.gpu_selected_circles = if selected_circles.is_empty() { vec![] } else { @@ -2703,27 +2778,6 @@ impl Pipeline { &selected_circles, )] }; - - let mut selected_ellipses: Vec = Vec::new(); - let ellipse_sel_tint = selected_tint.unwrap_or(WireModel::SELECTED); - for &wire in &selected_wires { - let depth = wire_gpu::wire_draw_depth(wire, depth_map); - if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) { - for mut inst in insts { - inst.color = ellipse_sel_tint; - selected_ellipses.push(inst); - } - } - } - for &wire in &hover_wires { - let depth = wire_gpu::wire_draw_depth(wire, depth_map); - if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) { - for mut inst in insts { - inst.color = WireModel::HOVER; - selected_ellipses.push(inst); - } - } - } self.gpu_selected_ellipses = if selected_ellipses.is_empty() { vec![] } else { @@ -2734,35 +2788,7 @@ impl Pipeline { &selected_ellipses, )] }; - - let selected_regular: Vec<&WireModel> = selected_wires - .iter() - .copied() - .filter(|wire| { - wire.render_instance.is_none() - && circle_gpu::extract_circle_instances(wire, 0.0).is_none() - && ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none() - }) - .collect(); - let hover_regular: Vec<&WireModel> = hover_wires - .iter() - .copied() - .filter(|wire| { - wire.render_instance.is_none() - && circle_gpu::extract_circle_instances(wire, 0.0).is_none() - && ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none() - }) - .collect(); - let selected_blocks: Vec<&WireModel> = selected_wires - .iter() - .copied() - .filter(|wire| wire.render_instance.is_some()) - .collect(); - let hover_blocks: Vec<&WireModel> = hover_wires - .iter() - .copied() - .filter(|wire| wire.render_instance.is_some()) - .collect(); + let t_split = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0); let mut gpu = if let Some(tint) = selected_tint { WireGpu::from_highlight_refs( device, @@ -2783,6 +2809,7 @@ impl Pipeline { depth_map, self.wire_const_bgl.as_ref(), )); + let t_regular = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0); self.gpu_selected_wires = gpu; let mut block_gpu = if let Some(tint) = selected_tint { BlockWireGpu::from_wires( @@ -2816,6 +2843,18 @@ impl Pipeline { if let Some(started) = perf_started { let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; if elapsed_ms >= 1.0 { + let gather = t_gather.unwrap_or(0.0); + let classify = t_shapes.unwrap_or(0.0); + let analytic = t_split.unwrap_or(0.0); + let regular = t_regular.unwrap_or(0.0); + crate::perf_record!( + "[perf] wire-highlight-detail gather={gather:.1} classify={:.1} \ +analytic={:.1} regular={:.1} blocks={:.1}", + classify - gather, + analytic - classify, + regular - analytic, + elapsed_ms - regular, + ); crate::perf_record!( "[perf] wire-highlight {:>7.1}ms handles={} wires={}", elapsed_ms, @@ -5627,3 +5666,91 @@ impl iced::widget::shader::Pipeline for MultiPipeline { } } } + +#[cfg(test)] +mod highlight_classification_tests { + use super::*; + use crate::scene::model::wire_model::TangentGeom; + + fn plain(name: &str) -> WireModel { + WireModel::solid( + name.to_string(), + vec![[0.0, 0.0, 0.0], [1.0, 1.0, 0.0]], + [1.0; 4], + false, + ) + } + + fn circle(name: &str) -> WireModel { + let mut wire = plain(name); + wire.tangent_geoms.push(TangentGeom::PlanarCircle { + center: [0.0, 0.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 2.0, + }); + wire + } + + fn ellipse(name: &str) -> WireModel { + let mut wire = plain(name); + wire.tangent_geoms.push(TangentGeom::PlanarEllipse { + center: [0.0, 0.0, 0.0], + major_axis: [2.0, 0.0, 0.0], + normal: [0.0, 0.0, 1.0], + minor_axis_ratio: 0.5, + start_param: 0.0, + end_param: std::f64::consts::TAU, + }); + wire + } + + #[test] + fn each_wire_lands_in_the_bucket_the_old_predicate_chose() { + let wires = vec![plain("1"), circle("2"), ellipse("3"), plain("4")]; + let refs: Vec<&WireModel> = wires.iter().collect(); + let depth_map = rustc_hash::FxHashMap::default(); + + let (circles, ellipses, regular, blocks) = + Pipeline::classify_highlight_wires(&refs, None, &depth_map); + + for wire in &refs { + let was_regular = wire.render_instance.is_none() + && circle_gpu::extract_circle_instances(wire, 0.0).is_none() + && ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none(); + assert_eq!( + was_regular, + regular.iter().any(|kept| std::ptr::eq(*kept, *wire)), + "wire {} disagrees with the old predicate", + wire.name, + ); + } + + assert_eq!(regular.len(), 2, "two plain lines"); + assert_eq!(circles.len(), 1, "one circle instance"); + assert_eq!(ellipses.len(), 1, "one ellipse instance"); + assert!(blocks.is_empty(), "no block wires in this sample"); + } + + // Hover recolours every instance; a selection only recolours when a tint is + // configured. Both go through the same argument, so it has to actually + // reach the instances. + #[test] + fn the_colour_override_reaches_the_instances() { + let wires = vec![circle("2"), ellipse("3")]; + let refs: Vec<&WireModel> = wires.iter().collect(); + let depth_map = rustc_hash::FxHashMap::default(); + + let (tinted_circles, tinted_ellipses, _, _) = + Pipeline::classify_highlight_wires(&refs, Some(WireModel::HOVER), &depth_map); + assert_eq!(tinted_circles[0].color, WireModel::HOVER); + assert_eq!(tinted_ellipses[0].color, WireModel::HOVER); + + let (plain_circles, _, _, _) = + Pipeline::classify_highlight_wires(&refs, None, &depth_map); + assert_ne!( + plain_circles[0].color, WireModel::HOVER, + "without a tint the extractor's own colour stands", + ); + } +} diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 265ef257d..752072b99 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -146,7 +146,7 @@ pub fn classify_wire(wire: &WireModel) -> WireKind { } if !wire.tangent_geoms.is_empty() && wire.fill_tris.is_empty() - && wire.pick_tris.is_empty() + && !wire.fill_is_3d && wire.text_verts.is_empty() { if super::circle_gpu::extract_circle_instances(wire, 0.0).is_some() { @@ -219,7 +219,7 @@ pub fn partition_wires<'a>( // Resolve draw depth only for wires eligible for analytical extraction. if !wire.tangent_geoms.is_empty() && wire.fill_tris.is_empty() - && wire.pick_tris.is_empty() + && !wire.fill_is_3d && wire.text_verts.is_empty() { let depth = super::wire_gpu::wire_draw_depth(wire, depth_map); @@ -1709,4 +1709,47 @@ mod tests { assert_eq!(partitioned.ellipse_instances.len(), 1); assert_eq!(partitioned.regular.len(), 1); } + + #[test] + fn partition_wires_partitions_thick_and_tapered_arcs() { + use crate::scene::model::wire_model::TangentGeom; + + // Wide arc with pick triangles + let mut wide_arc = WireModel::default(); + wide_arc.tangent_geoms.push(TangentGeom::Arc { + center: [10.0, 20.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 25.0, + start_angle: 0.0, + end_angle: std::f64::consts::PI, + }); + wide_arc.world_width = 8.0; + wide_arc.pick_tris = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; + wide_arc.pick_tris_low = vec![[0.0; 3]; 3]; + + // Tapered arc + let mut tapered_arc = WireModel::default(); + tapered_arc.tangent_geoms.push(TangentGeom::Arc { + center: [50.0, 60.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 15.0, + start_angle: 0.5, + end_angle: 2.5, + }); + tapered_arc.world_width = 10.0; + tapered_arc.taper_widths = vec![2.0, 10.0]; + + let wires = vec![wide_arc, tapered_arc]; + let depth_map = rustc_hash::FxHashMap::default(); + let partitioned = partition_wires(&wires, &depth_map); + + assert_eq!(partitioned.circle_instances.len(), 2); + assert_eq!(partitioned.regular.len(), 0); + assert_eq!(partitioned.circle_instances[0].start_width, 8.0); + assert_eq!(partitioned.circle_instances[0].params[3], 8.0); + assert_eq!(partitioned.circle_instances[1].start_width, 2.0); + assert_eq!(partitioned.circle_instances[1].params[3], 10.0); + } } diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index ab1fd455b..e3901ee3d 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -532,7 +532,35 @@ fn finite3(p: [f32; 3]) -> bool { fn marker_metadata(wire: &WireModel) -> ([f32; 4], [f32; 4], [f32; 4]) { let Some(marker) = wire.point_marker else { - return ([0.0; 4], [0.0; 4], [0.0; 4]); + for tg in &wire.tangent_geoms { + match tg { + crate::scene::model::wire_model::TangentGeom::Arc { axis_x, axis_y, .. } + | crate::scene::model::wire_model::TangentGeom::PlanarCircle { axis_x, axis_y, .. } => { + let ax = glam::DVec3::from_array(*axis_x); + let ay = glam::DVec3::from_array(*axis_y); + let n = ax.cross(ay).normalize_or(glam::DVec3::Z); + return ([0.0; 4], [0.0; 4], [n.x as f32, n.y as f32, n.z as f32, 0.0]); + } + crate::scene::model::wire_model::TangentGeom::PlanarEllipse { normal, .. } => { + let n = glam::DVec3::from_array(*normal).normalize_or(glam::DVec3::Z); + return ([0.0; 4], [0.0; 4], [n.x as f32, n.y as f32, n.z as f32, 0.0]); + } + _ => {} + } + } + if wire.points.len() >= 3 { + let p0 = glam::Vec3::from_array(wire.points[0]); + for i in 1..wire.points.len() - 1 { + let v1 = glam::Vec3::from_array(wire.points[i]) - p0; + let v2 = glam::Vec3::from_array(wire.points[i + 1]) - p0; + let cross = v1.cross(v2); + if cross.length_squared() > 1e-6 { + let n = cross.normalize(); + return ([0.0; 4], [0.0; 4], [n.x, n.y, n.z, 0.0]); + } + } + } + return ([0.0; 4], [0.0; 4], [0.0, 0.0, 1.0, 0.0]); }; let origin = marker.origin; let (hx, lx) = WireModel::split_ds(origin.x); @@ -1148,8 +1176,9 @@ impl WireGpu { let _ = const_bgl; let max_packed_instances = super::gpu_budget::max_elements::(device); + use crate::par::prelude::*; let per: Vec> = wires - .iter() + .par_iter() .map(|wire| { emit_wire_packed(wire, color, wire_draw_depth(wire, depth_map)) }) @@ -1463,4 +1492,30 @@ mod block_wire_storage_tests { }; assert_eq!(names(&packed), names(&storage)); } + + #[test] + fn wire_shader_validates_with_naga() { + let source = include_str!("../../shaders/wire.wgsl"); + let module = naga::front::wgsl::parse_str(source).expect("wire.wgsl parses cleanly"); + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + validator + .validate(&module) + .expect("wire.wgsl validates cleanly with naga"); + } + + #[test] + fn wire_indexed_shader_validates_with_naga() { + let source = include_str!("../../shaders/wire_indexed.wgsl"); + let module = naga::front::wgsl::parse_str(source).expect("wire_indexed.wgsl parses cleanly"); + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + validator + .validate(&module) + .expect("wire_indexed.wgsl validates cleanly with naga"); + } } diff --git a/src/scene/render_graph.rs b/src/scene/render_graph.rs index 5ecbc67d0..0f8c7abb5 100644 --- a/src/scene/render_graph.rs +++ b/src/scene/render_graph.rs @@ -1042,15 +1042,6 @@ pub fn document_block_uses(document: &CadDocument) -> Vec { uses } -/// A MINSERT's row/column counts are parsed `u16`s whose unchecked product -/// (`Insert::instance_count`) would otherwise drive this function's -/// allocation and a full per-instance render-graph walk after it — a corrupt -/// or adversarial 65535x65535 pair requests over four billion instances (tens -/// of GiB) long before any GPU-side chunking limit could help. The primary -/// defense is the load-time filter in `io::is_entity_corrupt` (kept in sync -/// with this constant), which drops such an insert before it ever reaches a -/// `Scene`; this is the failsafe for an insert built or edited some other way -/// (API/scripting) that skips that path. const MAX_MINSERT_INSTANCES: usize = 20_000; pub fn array_offsets(insert: &Insert) -> Vec<[f64; 3]> { @@ -1093,16 +1084,6 @@ pub fn insert_instance_transform( if offset == [0.0; 3] { transform } else { - // Row/column spacing is defined in the insert's rotated/OCS-oriented - // space but is NOT subject to the block's x/y/z scale — this matches - // the pinned CAD model's own `Insert::array_points`, which rotates - // the offset but never scales it. Composing the raw offset ahead of - // the full (scaled) transform, as this used to do, let the block - // scale multiply the spacing too (e.g. an x-scale-2 insert with - // column spacing 10 would render 20 units between columns). Instead, - // orient the offset by rotation + OCS only, then translate the - // already-composed (scaled) instance transform by that world-space - // vector, applied after — never scaled. let orientation = Transform::from_matrix(Matrix4::rotation_z(insert.rotation)) .then(&Transform::from_matrix(Matrix4::from_matrix3(Matrix3::arbitrary_axis(insert.normal)))); let world_offset = orientation.apply_rotation(Vector3::new(offset[0], offset[1], offset[2])); @@ -1201,10 +1182,6 @@ mod tests { #[test] fn array_offsets_clamps_pathological_minsert_counts() { - // A corrupt or adversarial row/column pair (parsed as u16, so this is - // reachable from a malformed file) must not drive a multi-billion- - // element allocation; the primary defense is the load-time filter in - // `io::is_entity_corrupt`, this is the render-graph-side failsafe. let mut insert = Insert::new("BLOCK", Vector3::ZERO); insert.row_count = u16::MAX; insert.column_count = u16::MAX; @@ -1230,10 +1207,6 @@ mod tests { #[test] fn insert_instance_transform_does_not_scale_the_row_column_spacing() { - // github discussion-derived report: an INSERT with x-scale 2 and - // column spacing 10 was rendering 20 drawing units between columns - // instead of 10, because the offset translation was composed ahead - // of (and thus multiplied by) the block scale. let document = CadDocument::new(); let mut insert = Insert::new("BLOCK", Vector3::new(100.0, 0.0, 0.0)); insert.set_x_scale(2.0); diff --git a/src/scene/selection.rs b/src/scene/selection.rs index d77e6d88a..42e6e8888 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -4,32 +4,96 @@ impl Scene { // ── Selection ───────────────────────────────────────────────────────── /// Treat a classic LEADER and its attached annotation as one logical object. /// Clicking/copying/deleting either side expands to the complete pair. - pub(crate) fn handles_expanded_for_leader_annotations( + /// Every LEADER that points at `annotation`, resolved once per + /// `geometry_epoch` rather than by walking the document per handle. + fn leaders_by_annotation( &self, - handles: &[Handle], - ) -> Vec { - let mut expanded = handles.to_vec(); + ) -> std::cell::Ref<'_, (u64, HashMap>)> { + { + let cache = self.leaders_by_annotation_cache.borrow(); + if cache.as_ref().is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) { + drop(cache); + return std::cell::Ref::map( + self.leaders_by_annotation_cache.borrow(), + |c| c.as_ref().unwrap(), + ); + } + } + let mut by_annotation: HashMap> = HashMap::default(); + for entity in self.document.entities() { + if let EntityType::Leader(leader) = entity { + if !leader.annotation_handle.is_null() { + by_annotation + .entry(leader.annotation_handle) + .or_default() + .push(entity.common().handle); + } + } + } + *self.leaders_by_annotation_cache.borrow_mut() = + Some((self.geometry_epoch, by_annotation)); + std::cell::Ref::map(self.leaders_by_annotation_cache.borrow(), |c| { + c.as_ref().unwrap() + }) + } + fn expanded_with_leaders(&self, handles: &[Handle]) -> Vec { + let leaders = self.leaders_by_annotation(); + let mut expanded = Vec::with_capacity(handles.len()); for &handle in handles { - // LEADER -> annotation. + let start = expanded.len(); + expanded.push(handle); if let Some(EntityType::Leader(leader)) = self.document.get_entity(handle) { if !leader.annotation_handle.is_null() { expanded.push(leader.annotation_handle); } } - - // Annotation -> LEADER. - expanded.extend(self.document.entities().filter_map(|entity| match entity { - EntityType::Leader(leader) - if !leader.annotation_handle.is_null() - && leader.annotation_handle == handle => - { - Some(entity.common().handle) - } - _ => None, - })); + if let Some(pointing) = leaders.1.get(&handle) { + expanded.extend(pointing.iter().copied()); + } + expanded[start..].sort_unstable_by_key(Handle::value); } + expanded + } + pub fn select_entities(&mut self, handles: &[Handle]) { + if handles.is_empty() { + return; + } + let expanded = self.expanded_with_leaders(handles); + let mut changed = false; + for handle in expanded { + if self.selected.insert(handle) { + self.selected_order.push(handle); + changed = true; + } + } + if changed { + self.bump_selection_set(); + } + } + + pub fn deselect_entities(&mut self, handles: &[Handle]) { + if handles.is_empty() { + return; + } + let doomed: HashSet = + self.expanded_with_leaders(handles).into_iter().collect(); + let mut changed = false; + for handle in &doomed { + changed |= self.selected.remove(handle); + } + if changed { + self.selected_order.retain(|handle| !doomed.contains(handle)); + self.bump_selection_set(); + } + } + + pub(crate) fn handles_expanded_for_leader_annotations( + &self, + handles: &[Handle], + ) -> Vec { + let mut expanded = self.expanded_with_leaders(handles); expanded.sort_unstable_by_key(|handle| handle.value()); expanded.dedup(); expanded @@ -874,12 +938,57 @@ impl Scene { if respect_layer_locks && self.is_layer_locked(h) { continue; } + let settings_handle = self.document.get_entity(h).and_then(|entity| match entity { + EntityType::Extended(acadrust::entities::ExtendedEntity { + data: acadrust::entities::ExtendedEntityData::SectionObject(data), + .. + }) if !data.settings_handle.is_null() => Some(data.settings_handle), + _ => None, + }); + let section_managers: Vec = self + .document + .objects + .iter() + .filter_map(|(handle, object)| match object { + ObjectType::ClassObject(object) => match &object.data { + acadrust::objects::ClassObjectData::SectionManager(manager) + if manager.sections.contains(&h) => + { + Some(*handle) + } + _ => None, + }, + _ => None, + }) + .collect(); // Delta-undo: capture the removed entity so an undo can re-insert it. if self.is_recording_undo() { let before = self.document.get_entity_arc(h); self.record_undo_before(h, before); + if let Some(settings_handle) = settings_handle { + let before = self.document.objects.get(&settings_handle).cloned(); + self.record_undo_object_before(settings_handle, before); + } + for &manager_handle in §ion_managers { + let before = self.document.objects.get(&manager_handle).cloned(); + self.record_undo_object_before(manager_handle, before); + } } self.delete_solid_history(h); + if let Some(settings_handle) = settings_handle { + self.document.objects.remove(&settings_handle); + } + for manager_handle in section_managers { + if let Some(ObjectType::ClassObject(object)) = + self.document.objects.get_mut(&manager_handle) + { + if let acadrust::objects::ClassObjectData::SectionManager(manager) = + &mut object.data + { + manager.sections.retain(|section| *section != h); + } + } + } self.remember_removed_cache_categories(h); self.document.remove_entity_arc(h); selection_changed |= self.selected.remove(&h); @@ -1003,6 +1112,130 @@ impl Scene { mod tests { use super::*; + #[test] + fn bulk_selection_matches_selecting_one_at_a_time() { + use acadrust::entities::Line; + use acadrust::types::Vector3; + + let build = || { + let mut scene = Scene::new(); + let handles: Vec<_> = (0..40) + .map(|k| { + let x = k as f64; + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(x, 0.0, 0.0), + Vector3::new(x + 1.0, 0.0, 0.0), + ))) + }) + .collect(); + (scene, handles) + }; + + // Picked in an order that is neither creation nor handle order. + let picked = |handles: &[Handle]| -> Vec { + let mut order: Vec<_> = handles.iter().copied().collect(); + order.reverse(); + order.retain(|h| h.value() % 3 != 0); + order + }; + + let (mut one_at_a_time, handles) = build(); + let order = picked(&handles); + for &handle in &order { + one_at_a_time.select_entity(handle, false); + } + + let (mut in_bulk, handles) = build(); + in_bulk.select_entities(&picked(&handles)); + + assert_eq!( + in_bulk.selected_handles_in_order(), + one_at_a_time.selected_handles_in_order(), + "the bulk path must select the same entities in the same order", + ); + + // And removing a subset must agree too. + let drop: Vec<_> = order.iter().copied().take(7).collect(); + for &handle in &drop { + one_at_a_time.deselect_entity(handle); + } + in_bulk.deselect_entities(&drop); + assert_eq!( + in_bulk.selected_handles_in_order(), + one_at_a_time.selected_handles_in_order(), + "the bulk removal must leave the same selection in the same order", + ); + } + + #[test] + fn the_leader_index_matches_a_document_walk() { + use acadrust::entities::Leader; + use acadrust::types::Vector3; + + let mut scene = Scene::new(); + let line = |x: f64| { + EntityType::Line(acadrust::entities::Line::from_points( + Vector3::new(x, 0.0, 0.0), + Vector3::new(x + 1.0, 0.0, 0.0), + )) + }; + let annotation = scene.add_entity(line(0.0)); + let unrelated = scene.add_entity(line(5.0)); + let mut leader_on = |target: Handle| { + let mut leader = Leader::default(); + leader.annotation_handle = target; + scene.add_entity(EntityType::Leader(leader)) + }; + // Two leaders share one annotation; a third points nowhere. + let first = leader_on(annotation); + let second = leader_on(annotation); + let dangling = leader_on(Handle::NULL); + + // The walk this replaced, written out so the index is compared against + // behaviour rather than against itself. + let by_walk = |handle: Handle| -> Vec { + let mut out = vec![handle]; + if let Some(EntityType::Leader(leader)) = scene.document.get_entity(handle) { + if !leader.annotation_handle.is_null() { + out.push(leader.annotation_handle); + } + } + out.extend(scene.document.entities().filter_map(|entity| match entity { + EntityType::Leader(leader) + if !leader.annotation_handle.is_null() + && leader.annotation_handle == handle => + { + Some(entity.common().handle) + } + _ => None, + })); + out.sort_unstable_by_key(Handle::value); + out.dedup(); + out + }; + + for handle in [annotation, unrelated, first, second, dangling] { + assert_eq!( + scene.handles_expanded_for_leader_annotations(&[handle]), + by_walk(handle), + "the index must answer exactly what the walk answered", + ); + } + let expanded = scene.handles_expanded_for_leader_annotations(&[annotation]); + assert!( + expanded.contains(&first) && expanded.contains(&second), + "both leaders on one annotation must come back, not just one", + ); + for handle in [first, annotation, second] { + scene.deselect_all(); + scene.select_entity(handle, false); + let expected = scene.selected_handles_in_order(); + scene.deselect_all(); + scene.select_entities(&[handle]); + assert_eq!(scene.selected_handles_in_order(), expected); + } + } + #[test] fn adding_a_type_already_present_reuses_the_list() { use acadrust::entities::{Circle, EntityType, Line}; diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index a5ae72516..e34b550ba 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -246,23 +246,16 @@ impl Camera { /// A symmetric range gives the bias half the depth buffer of headroom on /// each side; ortho permits a negative near. fn ortho_depth_range(&self) -> (f32, f32) { - // Prefer a drawing-sized, zoom-independent half-range so depth-buffer - // precision stays constant as the user zooms. A distance-scaled range - // (the `else`) balloons the near/far span when zoomed out — at large - // `distance` the f32 depth buffer can no longer separate coincident - // solids / meshes / wires, so they flip draw order (issue: meshes drew - // in front of solids only when zoomed out). + // Generous headroom based on the current screen size so that rotating any + // geometry visible on screen in 3D never penetrates the near/far planes. + let view_extent = (self.ortho_size() * 3.0).max(10.0); + let r = if let Some((min, max)) = self.model_bounds { - // Depth extent along the CURRENT eye direction, recomputed each - // frame so orbiting a 3-D drawing never clips it (#473): as the - // view tilts off top, the box's width rotates onto the eye axis and - // the span grows to match. Same tight, zoom-independent precision as - // a fitted scalar, but always oriented to the live view. - self.depth_extent_in_view(min, max) + self.depth_extent_in_view(min, max).max(view_extent) } else if self.depth_half_range > 0.0 { - self.depth_half_range + self.depth_half_range.max(view_extent) } else { - (self.distance * 1000.0).max(1.0) + (self.distance * 1000.0).max(view_extent).max(10.0) }; (self.distance - r, self.distance + r) } @@ -590,7 +583,11 @@ impl Camera { .bounds_in_view(min, max) .iter() .fold(0.0_f32, |m, c| m.max(c.z.abs())); - (depth_r * 1.05).max(1.0) + let diag = (max - min).length() as f32; + // Generous headroom (1.5x depth extent + 25% of model diagonal + minimum clearance) + // so wide strokes, lineweights, camera orbit dynamics, and draw-order depth bias + // never penetrate the near clipping plane in orthographic 3D view. + (depth_r * 1.5 + diag * 0.25).max(10.0) } /// Fit the camera to `min..max` — pose and depth both. diff --git a/src/shaders/block_wire.wgsl b/src/shaders/block_wire.wgsl index a8500bddc..d70301806 100644 --- a/src/shaders/block_wire.wgsl +++ b/src/shaders/block_wire.wgsl @@ -121,35 +121,83 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut let rel_a = marker_relative(in.pos_a, in.pos_a_low, in.translation, in.translation_low); let rel_b = marker_relative(in.pos_b, in.pos_b_low, in.translation, in.translation_low); - let clip_a = u.view_rot * vec4(rel_a, 1.0); - let clip_b = u.view_rot * vec4(rel_b, 1.0); - let screen_a = clip_a.xy / clip_a.w * u.viewport_size * 0.5; - let screen_b = clip_b.xy / clip_b.w * u.viewport_size * 0.5; - let segment = screen_b - screen_a; - let segment_length = length(segment); - var direction = vec2(1.0, 0.0); - if segment_length > 1e-4 { - direction = segment / segment_length; + let is_world = wire_const.world_half_width > 0.0 || in.taper_ratio.x > 0.0 || in.taper_ratio.y > 0.0; + + var final_clip: vec4; + var out_dist: f32; + var out_cap: vec2; + var out_cap_ends: vec3; + + if is_world { + // Wide polylines have physical world-unit width (world_half_width or taper > 0). + // Expand the quad in 3D world space on the entity's plane (perpendicular to + // the segment and the plane normal), so the rectangle remains hosted rigidly + // on the 3D plane when the camera rotates, orbits, or tilts, matching CAD + // behavior and circle.wgsl planar arcs. + let world_hw_a = select(wire_const.world_half_width, in.taper_ratio.x * wire_const.world_half_width, in.taper_ratio.x > 0.0); + let world_hw_b = select(wire_const.world_half_width, in.taper_ratio.y * wire_const.world_half_width, in.taper_ratio.y > 0.0); + let cur_world_hw = mix(world_hw_a, world_hw_b, which_end); + let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel); + + let world_delta = rel_b - rel_a; + let world_len = length(world_delta); + var world_dir = vec3(1.0, 0.0, 0.0); + if world_len > 1e-6 { + world_dir = world_delta / world_len; + } + + var norm = vec3(0.0, 0.0, 1.0); + if length(wire_const.marker_normal_scale.xyz) > 1e-4 { + norm = normalize(wire_const.marker_normal_scale.xyz); + } + + var perp_world = cross(norm, world_dir); + if length(perp_world) < 1e-4 { + perp_world = cross(vec3(0.0, 1.0, 0.0), world_dir); + if length(perp_world) < 1e-4 { + perp_world = cross(vec3(1.0, 0.0, 0.0), world_dir); + } + } + perp_world = normalize(perp_world); + + let pos_rel = mix(rel_a, rel_b, which_end); + let world_pos = pos_rel + perp_world * (eff_hw * side); + var clip_pos = u.view_rot * vec4(world_pos, 1.0); + clip_pos.z = clip_pos.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos.w; + + final_clip = clip_pos; + out_dist = mix(in.distances.x, in.distances.y, which_end); + out_cap = vec2(which_end * world_len, eff_hw * side); + out_cap_ends = vec3(world_len, world_hw_a, world_hw_b); + } else { + // Thin wires expand in screen pixels facing the camera with rounded end caps / joints. + let clip_a = u.view_rot * vec4(rel_a, 1.0); + let clip_b = u.view_rot * vec4(rel_b, 1.0); + let screen_a = clip_a.xy / clip_a.w * u.viewport_size * 0.5; + let screen_b = clip_b.xy / clip_b.w * u.viewport_size * 0.5; + let segment = screen_b - screen_a; + let segment_length = length(segment); + var direction = vec2(1.0, 0.0); + if segment_length > 1e-4 { + direction = segment / segment_length; + } + let perpendicular = vec2(-direction.y, direction.x); + let clip_position = mix(clip_a, clip_b, which_end); + + let half_width = resolve_hw(0.0, 0.0, wire_const.half_width); + let extension = which_end * 2.0 - 1.0; + let offset_px = perpendicular * half_width * side + + direction * half_width * extension; + let ndc_offset = offset_px / (u.viewport_size * 0.5); + var clip_pos_out = clip_position + vec4(ndc_offset * clip_position.w, 0.0, 0.0); + clip_pos_out.z = clip_pos_out.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos_out.w; + + final_clip = clip_pos_out; + out_dist = mix(in.distances.x, in.distances.y, which_end) + + extension * half_width * u.world_per_pixel; + out_cap = vec2(which_end * segment_length + extension * half_width, half_width * side); + out_cap_ends = vec3(segment_length, half_width, half_width); } - let perpendicular = vec2(-direction.y, direction.x); - let clip_position = mix(clip_a, clip_b, which_end); - let half_width_a = resolve_hw( - in.taper_ratio.x, - wire_const.world_half_width, - wire_const.half_width, - ); - let half_width_b = resolve_hw( - in.taper_ratio.y, - wire_const.world_half_width, - wire_const.half_width, - ); - let half_width = mix(half_width_a, half_width_b, which_end); - let extension = which_end * 2.0 - 1.0; - let offset_px = perpendicular * half_width * side - + direction * half_width * extension; - let ndc_offset = offset_px / (u.viewport_size * 0.5); - let final_clip = clip_position - + vec4(ndc_offset * clip_position.w, 0.0, 0.0); let scale = u.linetype_scale; var min_element = wire_const.pattern_length * scale; @@ -172,21 +220,16 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut var out: VertexOut; out.clip_pos = final_clip; - out.clip_pos.z -= in.depth.x * DRAW_ORDER_BIAS * out.clip_pos.w; out.color = wire_const.color; - out.distance = mix(in.distances.x, in.distances.y, which_end) - + extension * half_width * u.world_per_pixel; + out.distance = out_dist; + out.cap = out_cap; + out.cap_ends = out_cap_ends; out.pattern_length = wire_const.pattern_length * scale; out.pat0 = wire_const.pat0 * scale; out.pat1 = wire_const.pat1 * scale; out.min_elem = min_element; out.align_end = wire_const.align_end * scale; out.align_total = wire_const.align_total; - out.cap = vec2( - which_end * segment_length + extension * half_width, - half_width * side, - ); - out.cap_ends = vec3(segment_length, half_width_a, half_width_b); return out; } diff --git a/src/shaders/block_wire_storage.wgsl b/src/shaders/block_wire_storage.wgsl index 98cda4857..3b3544e22 100644 --- a/src/shaders/block_wire_storage.wgsl +++ b/src/shaders/block_wire_storage.wgsl @@ -152,35 +152,83 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut let rel_a = marker_relative(seg_pos_a, seg_pos_a_low, in.translation, in.translation_low); let rel_b = marker_relative(seg_pos_b, seg_pos_b_low, in.translation, in.translation_low); - let clip_a = u.view_rot * vec4(rel_a, 1.0); - let clip_b = u.view_rot * vec4(rel_b, 1.0); - let screen_a = clip_a.xy / clip_a.w * u.viewport_size * 0.5; - let screen_b = clip_b.xy / clip_b.w * u.viewport_size * 0.5; - let segment = screen_b - screen_a; - let segment_length = length(segment); - var direction = vec2(1.0, 0.0); - if segment_length > 1e-4 { - direction = segment / segment_length; + let is_world = wire_const.world_half_width > 0.0 || seg_taper_ratio.x > 0.0 || seg_taper_ratio.y > 0.0; + + var final_clip: vec4; + var out_dist: f32; + var out_cap: vec2; + var out_cap_ends: vec3; + + if is_world { + // Wide polylines have physical world-unit width (world_half_width or taper > 0). + // Expand the quad in 3D world space on the entity's plane (perpendicular to + // the segment and the plane normal), so the rectangle remains hosted rigidly + // on the 3D plane when the camera rotates, orbits, or tilts, matching CAD + // behavior and circle.wgsl planar arcs. + let world_hw_a = select(wire_const.world_half_width, seg_taper_ratio.x * wire_const.world_half_width, seg_taper_ratio.x > 0.0); + let world_hw_b = select(wire_const.world_half_width, seg_taper_ratio.y * wire_const.world_half_width, seg_taper_ratio.y > 0.0); + let cur_world_hw = mix(world_hw_a, world_hw_b, which_end); + let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel); + + let world_delta = rel_b - rel_a; + let world_len = length(world_delta); + var world_dir = vec3(1.0, 0.0, 0.0); + if world_len > 1e-6 { + world_dir = world_delta / world_len; + } + + var norm = vec3(0.0, 0.0, 1.0); + if length(wire_const.marker_normal_scale.xyz) > 1e-4 { + norm = normalize(wire_const.marker_normal_scale.xyz); + } + + var perp_world = cross(norm, world_dir); + if length(perp_world) < 1e-4 { + perp_world = cross(vec3(0.0, 1.0, 0.0), world_dir); + if length(perp_world) < 1e-4 { + perp_world = cross(vec3(1.0, 0.0, 0.0), world_dir); + } + } + perp_world = normalize(perp_world); + + let pos_rel = mix(rel_a, rel_b, which_end); + let world_pos = pos_rel + perp_world * (eff_hw * side); + var clip_pos = u.view_rot * vec4(world_pos, 1.0); + clip_pos.z = clip_pos.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos.w; + + final_clip = clip_pos; + out_dist = mix(seg_distances.x, seg_distances.y, which_end); + out_cap = vec2(which_end * world_len, eff_hw * side); + out_cap_ends = vec3(world_len, world_hw_a, world_hw_b); + } else { + // Thin wires expand in screen pixels facing the camera with rounded end caps / joints. + let clip_a = u.view_rot * vec4(rel_a, 1.0); + let clip_b = u.view_rot * vec4(rel_b, 1.0); + let screen_a = clip_a.xy / clip_a.w * u.viewport_size * 0.5; + let screen_b = clip_b.xy / clip_b.w * u.viewport_size * 0.5; + let segment = screen_b - screen_a; + let segment_length = length(segment); + var direction = vec2(1.0, 0.0); + if segment_length > 1e-4 { + direction = segment / segment_length; + } + let perpendicular = vec2(-direction.y, direction.x); + let clip_position = mix(clip_a, clip_b, which_end); + + let half_width = resolve_hw(0.0, 0.0, wire_const.half_width); + let extension = which_end * 2.0 - 1.0; + let offset_px = perpendicular * half_width * side + + direction * half_width * extension; + let ndc_offset = offset_px / (u.viewport_size * 0.5); + var clip_pos_out = clip_position + vec4(ndc_offset * clip_position.w, 0.0, 0.0); + clip_pos_out.z = clip_pos_out.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos_out.w; + + final_clip = clip_pos_out; + out_dist = mix(seg_distances.x, seg_distances.y, which_end) + + extension * half_width * u.world_per_pixel; + out_cap = vec2(which_end * segment_length + extension * half_width, half_width * side); + out_cap_ends = vec3(segment_length, half_width, half_width); } - let perpendicular = vec2(-direction.y, direction.x); - let clip_position = mix(clip_a, clip_b, which_end); - let half_width_a = resolve_hw( - seg_taper_ratio.x, - wire_const.world_half_width, - wire_const.half_width, - ); - let half_width_b = resolve_hw( - seg_taper_ratio.y, - wire_const.world_half_width, - wire_const.half_width, - ); - let half_width = mix(half_width_a, half_width_b, which_end); - let extension = which_end * 2.0 - 1.0; - let offset_px = perpendicular * half_width * side - + direction * half_width * extension; - let ndc_offset = offset_px / (u.viewport_size * 0.5); - let final_clip = clip_position - + vec4(ndc_offset * clip_position.w, 0.0, 0.0); let scale = u.linetype_scale; var min_element = wire_const.pattern_length * scale; @@ -203,21 +251,16 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut var out: VertexOut; out.clip_pos = final_clip; - out.clip_pos.z -= in.depth.x * DRAW_ORDER_BIAS * out.clip_pos.w; out.color = wire_const.color; - out.distance = mix(seg_distances.x, seg_distances.y, which_end) - + extension * half_width * u.world_per_pixel; + out.distance = out_dist; + out.cap = out_cap; + out.cap_ends = out_cap_ends; out.pattern_length = wire_const.pattern_length * scale; out.pat0 = wire_const.pat0 * scale; out.pat1 = wire_const.pat1 * scale; out.min_elem = min_element; out.align_end = wire_const.align_end * scale; out.align_total = wire_const.align_total; - out.cap = vec2( - which_end * segment_length + extension * half_width, - half_width * side, - ); - out.cap_ends = vec3(segment_length, half_width_a, half_width_b); return out; } diff --git a/src/shaders/circle.wgsl b/src/shaders/circle.wgsl index 3e88c88aa..22b0973f8 100644 --- a/src/shaders/circle.wgsl +++ b/src/shaders/circle.wgsl @@ -32,12 +32,12 @@ const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; const TAU: f32 = 6.283185307179586; struct InstanceIn { - @location(0) center_high: vec4, // xyz = center_high, w = unused + @location(0) center_high: vec4, // xyz = center_high, w = start_width @location(1) center_low: vec4, // xyz = center_low, w = radius @location(2) axis_x: vec4, // xyz = axis_x, w = start_angle @location(3) axis_y: vec4, // xyz = axis_y, w = end_angle @location(4) color: vec4, // rgba - @location(5) params: vec4, // half_width_px, pattern_length, draw_depth, unused + @location(5) params: vec4, // half_width_px, pattern_length, draw_depth, end_width @location(6) pat0: vec4, @location(7) pat1: vec4, } @@ -46,7 +46,7 @@ struct VertexOut { @builtin(position) clip_pos: vec4, @location(0) local_pos: vec2, @location(1) radius: f32, - @location(2) hw_px: f32, + @location(2) @interpolate(flat) hw_ends: vec2, @location(3) color: vec4, @location(4) pattern_length: f32, @location(5) pat0: vec4, @@ -54,9 +54,10 @@ struct VertexOut { @location(7) @interpolate(flat) min_elem: f32, @location(8) @interpolate(flat) start_angle: f32, @location(9) @interpolate(flat) end_angle: f32, + @location(10) @interpolate(flat) is_world: f32, } -fn resolve_hw(px_hw: f32) -> f32 { +fn resolve_px_hw(px_hw: f32) -> f32 { var display_hw = max(px_hw * u.lineweight_scale, 0.5); if u.lineweight_scale < 0.0 { let scale = -u.lineweight_scale; @@ -85,14 +86,32 @@ fn resolve_hw(px_hw: f32) -> f32 { let axis_x = in.axis_x.xyz; let axis_y = in.axis_y.xyz; let color = in.color; - let hw_px = resolve_hw(in.params.x); + let world_w_a = in.center_high.w; + let world_w_b = in.params.w; + let is_world = select(0.0, 1.0, world_w_a > 0.0 || world_w_b > 0.0); + + var hw_a: f32; + var hw_b: f32; + var ext: f32; + + if is_world > 0.5 { + hw_a = world_w_a * 0.5; + hw_b = world_w_b * 0.5; + let max_hw = max(hw_a, hw_b); + // Expand bounding quad so anti-aliasing margin and physical width never clip under 3D tilt + let margin_world = max_hw + 16.0 * max(u.world_per_pixel, 1e-6); + ext = radius + margin_world; + } else { + let px_hw = resolve_px_hw(in.params.x); + hw_a = px_hw; + hw_b = px_hw; + let margin_world = (px_hw + 2.0) * max(u.world_per_pixel, 1e-6); + ext = radius + margin_world; + } + let pattern_length = in.params.y; let draw_depth = in.params.z; - // Expand bounding quad so anti-aliasing margin and lineweight never clip - let margin_world = (hw_px + 2.0) * max(u.world_per_pixel, 1e-6); - let ext = radius + margin_world; - let center_rel = (center_high - u.eye_high) + (center_low - u.eye_low); let world_pos_rel = center_rel + (u_val * axis_x + v_val * axis_y) * ext; @@ -103,7 +122,7 @@ fn resolve_hw(px_hw: f32) -> f32 { out.clip_pos = clip_pos; out.local_pos = vec2(u_val * ext, v_val * ext); out.radius = radius; - out.hw_px = hw_px; + out.hw_ends = vec2(hw_a, hw_b); out.color = color; out.pattern_length = pattern_length; out.pat0 = in.pat0; @@ -122,6 +141,7 @@ fn resolve_hw(px_hw: f32) -> f32 { out.start_angle = in.axis_x.w; out.end_angle = in.axis_y.w; + out.is_world = is_world; return out; } @@ -181,30 +201,60 @@ fn in_dash(dist: f32, pat_len: f32, p0: vec4, p1: vec4) -> bool { let theta = select(angle, angle + TAU, angle < 0.0); let d_theta = mod_tau(theta - sa); - var d_world: f32; - var arc_dist: f32; + var alpha_cov: f32 = 0.0; + var arc_dist: f32 = 0.0; - if sweep >= TAU - 1e-5 || d_theta <= sweep { - // Inside arc sweep or full circle - d_world = abs(r - in.radius); - arc_dist = in.radius * d_theta; - } else { - // Outside sweep: measure distance to start and end endpoints for round end caps - let p_start = in.radius * vec2(cos(sa), sin(sa)); - let p_end = in.radius * vec2(cos(ea), sin(ea)); - let d_start = length(in.local_pos - p_start); - let d_end = length(in.local_pos - p_end); - if d_start < d_end { - d_world = d_start; - arc_dist = 0.0; + let is_full_or_in_sweep = (sweep >= TAU - 1e-5) || (d_theta <= sweep); + + if in.is_world > 0.5 { + // Physical world-unit width (thick analytical circular arcs, donuts, tapered segments). + // Distance-to-edge calculations are strictly performed in the circle plane's world units + // so that 3D perspective projection yields exact concentric ellipses with invariant + // physical stroke widths across any camera tilt or acute perspective angle. + if is_full_or_in_sweep { + let t = select(0.0, clamp(d_theta / sweep, 0.0, 1.0), sweep > 1e-5); + let world_hw = mix(in.hw_ends.x, in.hw_ends.y, t); + let eff_hw = max(world_hw, 0.5 * fw); + let d_world = abs(r - in.radius); + let delta_px = (eff_hw - d_world) / fw; + let cov = clamp(0.5 + delta_px, 0.0, 1.0); + let subpixel_fade = min(1.0, (2.0 * world_hw) / fw); + alpha_cov = cov * subpixel_fade; + arc_dist = in.radius * d_theta; } else { - d_world = d_end; - arc_dist = in.radius * sweep; + // Outside sweep: evaluate distance to start and end endpoints for smooth round end caps + let p_start = in.radius * vec2(cos(sa), sin(sa)); + let p_end = in.radius * vec2(cos(ea), sin(ea)); + let d_start = length(in.local_pos - p_start); + let d_end = length(in.local_pos - p_end); + let eff_hw_a = max(in.hw_ends.x, 0.5 * fw); + let eff_hw_b = max(in.hw_ends.y, 0.5 * fw); + let cov_start = clamp(0.5 + (eff_hw_a - d_start) / fw, 0.0, 1.0) * min(1.0, (2.0 * in.hw_ends.x) / fw); + let cov_end = clamp(0.5 + (eff_hw_b - d_end) / fw, 0.0, 1.0) * min(1.0, (2.0 * in.hw_ends.y) / fw); + alpha_cov = max(cov_start, cov_end); + arc_dist = select(in.radius * sweep, 0.0, cov_start >= cov_end); + } + } else { + // Lineweight stroke (screen-pixel width) + if is_full_or_in_sweep { + let t = select(0.0, clamp(d_theta / sweep, 0.0, 1.0), sweep > 1e-5); + let cur_hw = mix(in.hw_ends.x, in.hw_ends.y, t); + let d_world = abs(r - in.radius); + let d_px = d_world / fw; + alpha_cov = clamp(0.5 + cur_hw - d_px, 0.0, 1.0); + arc_dist = in.radius * d_theta; + } else { + let p_start = in.radius * vec2(cos(sa), sin(sa)); + let p_end = in.radius * vec2(cos(ea), sin(ea)); + let d_start_px = length(in.local_pos - p_start) / fw; + let d_end_px = length(in.local_pos - p_end) / fw; + let cov_start = clamp(0.5 + in.hw_ends.x - d_start_px, 0.0, 1.0); + let cov_end = clamp(0.5 + in.hw_ends.y - d_end_px, 0.0, 1.0); + alpha_cov = max(cov_start, cov_end); + arc_dist = select(in.radius * sweep, 0.0, cov_start >= cov_end); } } - let d_px = d_world / fw; - let alpha_cov = clamp(0.5 + in.hw_px - d_px, 0.0, 1.0); if alpha_cov <= 0.0 { discard; } diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index c41abd617..afa7a0d75 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -156,50 +156,89 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: // UTM-scale coordinates and after a cross-drawing paste, with no jitter. let rel_a = marker_relative(in.pos_a, in.pos_a_low, in); let rel_b = marker_relative(in.pos_b, in.pos_b_low, in); - let clip_a = u.view_rot * vec4(rel_a, 1.0); - let clip_b = u.view_rot * vec4(rel_b, 1.0); + let is_world = in.misc.w > 0.0 || in.taper.x > 0.0 || in.taper.y > 0.0; - // NDC of both endpoints. - let ndc_a = clip_a.xy / clip_a.w; - let ndc_b = clip_b.xy / clip_b.w; + var final_clip: vec4; + var out_dist: f32; + var out_cap: vec2; + var out_cap_ends: vec3; - // Screen-space pixel positions. - let screen_a = ndc_a * u.viewport_size * 0.5; - let screen_b = ndc_b * u.viewport_size * 0.5; + if is_world { + // Wide polylines have physical world-unit width (in.misc.w or in.taper > 0). + // Expand the quad in 3D world space on the entity's plane (perpendicular to + // the segment and the plane normal), so the rectangle remains hosted rigidly + // on the 3D plane when the camera rotates, orbits, or tilts, matching CAD + // behavior and circle.wgsl planar arcs. + let world_hw_a = select(in.misc.w, in.taper.x, in.taper.x > 0.0); + let world_hw_b = select(in.misc.w, in.taper.y, in.taper.y > 0.0); + let cur_world_hw = mix(world_hw_a, world_hw_b, which_end); + let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel); - // Screen-space direction / perpendicular of the segment. - let seg = screen_b - screen_a; - let seg_len = length(seg); - var dir: vec2; - if seg_len > 1e-4 { - dir = seg / seg_len; + let world_delta = rel_b - rel_a; + let world_len = length(world_delta); + var world_dir = vec3(1.0, 0.0, 0.0); + if world_len > 1e-6 { + world_dir = world_delta / world_len; + } + + var norm = vec3(0.0, 0.0, 1.0); + if length(in.marker_normal_scale.xyz) > 1e-4 { + norm = normalize(in.marker_normal_scale.xyz); + } + + var perp_world = cross(norm, world_dir); + if length(perp_world) < 1e-4 { + perp_world = cross(vec3(0.0, 1.0, 0.0), world_dir); + if length(perp_world) < 1e-4 { + perp_world = cross(vec3(1.0, 0.0, 0.0), world_dir); + } + } + perp_world = normalize(perp_world); + + let pos_rel = mix(rel_a, rel_b, which_end); + let world_pos = pos_rel + perp_world * (eff_hw * side); + var clip_pos = u.view_rot * vec4(world_pos, 1.0); + clip_pos.z = clip_pos.z - in.misc.x * DRAW_ORDER_BIAS * clip_pos.w; + + final_clip = clip_pos; + out_dist = mix(in.dists.x, in.dists.y, which_end); + out_cap = vec2(which_end * world_len, eff_hw * side); + out_cap_ends = vec3(world_len, world_hw_a, world_hw_b); } else { - dir = vec2(1.0, 0.0); + // Thin wires expand in screen pixels facing the camera with rounded end caps / joints. + let clip_a = u.view_rot * vec4(rel_a, 1.0); + let clip_b = u.view_rot * vec4(rel_b, 1.0); + + let ndc_a = clip_a.xy / clip_a.w; + let ndc_b = clip_b.xy / clip_b.w; + + let screen_a = ndc_a * u.viewport_size * 0.5; + let screen_b = ndc_b * u.viewport_size * 0.5; + + let seg = screen_b - screen_a; + let seg_len = length(seg); + var dir: vec2; + if seg_len > 1e-4 { + dir = seg / seg_len; + } else { + dir = vec2(1.0, 0.0); + } + let perp = vec2(-dir.y, dir.x); + + let clip_pos = mix(clip_a, clip_b, which_end); + + let hw = resolve_hw(0.0, 0.0, in.dists.z); + let ext = which_end * 2.0 - 1.0; + let offset_px = perp * hw * side + dir * hw * ext; + let ndc_offset = offset_px / (u.viewport_size * 0.5); + var clip_pos_out = clip_pos + vec4(ndc_offset * clip_pos.w, 0.0, 0.0); + clip_pos_out.z = clip_pos_out.z - in.misc.x * DRAW_ORDER_BIAS * clip_pos_out.w; + + final_clip = clip_pos_out; + out_dist = mix(in.dists.x, in.dists.y, which_end) + ext * hw * u.world_per_pixel; + out_cap = vec2(which_end * seg_len + ext * hw, hw * side); + out_cap_ends = vec3(seg_len, hw, hw); } - let perp = vec2(-dir.y, dir.x); - - // Select the clip-space position for this vertex's endpoint. - let clip_pos = mix(clip_a, clip_b, which_end); - - // A wide polyline carries its band width in world units: expand the quad by - // `world_half_width / world_per_pixel` (pixels) so the band tracks zoom. A - // normal wire (world_half_width == 0) uses the screen-pixel half-width, - // honouring the LWDISPLAY toggle (off → collapse to a 1-pixel line). - // A tapered band interpolates a per-endpoint world half-width across the - // segment; a constant band uses `world_half_width`. Both clamp to a - // half-pixel so a zoomed-out band stays a hairline instead of vanishing. - let hw_a = resolve_hw(in.taper.x, in.misc.w, in.dists.z); - let hw_b = resolve_hw(in.taper.y, in.misc.w, in.dists.z); - let hw = mix(hw_a, hw_b, which_end); - - // Extend the quad longitudinally by the end half-width and let the - // fragment stage round the overhang off: adjoining segments then meet in - // overlapping round joints, closing the wedge gaps a perpendicular-only - // expansion leaves on the outside of corners and along tessellated arcs. - let ext = which_end * 2.0 - 1.0; // -1 at the A end, +1 at the B end - let offset_px = perp * hw * side + dir * hw * ext; - let ndc_offset = offset_px / (u.viewport_size * 0.5); - let final_clip = clip_pos + vec4(ndc_offset * clip_pos.w, 0.0, 0.0); // Smallest non-zero dash / gap element, in world units. Used by // the fragment stage to decide when the pattern's finest feature @@ -219,14 +258,10 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: var out: VertexOut; out.clip_pos = final_clip; - out.clip_pos.z = out.clip_pos.z - in.misc.x * DRAW_ORDER_BIAS * out.clip_pos.w; out.color = in.color; - // Dash arc-length, extrapolated over the cap overhang so the pattern - // stays continuous through a joint. - out.distance = mix(in.dists.x, in.dists.y, which_end) - + ext * hw * u.world_per_pixel; - out.cap = vec2(which_end * seg_len + ext * hw, hw * side); - out.cap_ends = vec3(seg_len, hw_a, hw_b); + out.distance = out_dist; + out.cap = out_cap; + out.cap_ends = out_cap_ends; out.pattern_length = in.dists.w * lt_scale; out.pat0 = in.pat0 * lt_scale; out.pat1 = in.pat1 * lt_scale; diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index c62b3e189..d739d6e15 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -128,47 +128,89 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon let rel_a = marker_relative(in.pos_a, in.pos_a_low, c); let rel_b = marker_relative(in.pos_b, in.pos_b_low, c); - let clip_a = u.view_rot * vec4(rel_a, 1.0); - let clip_b = u.view_rot * vec4(rel_b, 1.0); + let is_world = c.world_half_width > 0.0 || in.taper_ratio.x > 0.0 || in.taper_ratio.y > 0.0; - let ndc_a = clip_a.xy / clip_a.w; - let ndc_b = clip_b.xy / clip_b.w; + var final_clip: vec4; + var out_dist: f32; + var out_cap: vec2; + var out_cap_ends: vec3; - let screen_a = ndc_a * u.viewport_size * 0.5; - let screen_b = ndc_b * u.viewport_size * 0.5; + if is_world { + // Wide polylines have physical world-unit width (world_half_width or taper > 0). + // Expand the quad in 3D world space on the entity's plane (perpendicular to + // the segment and the plane normal), so the rectangle remains hosted rigidly + // on the 3D plane when the camera rotates, orbits, or tilts, matching CAD + // behavior and circle.wgsl planar arcs. + let world_hw_a = select(c.world_half_width, in.taper_ratio.x * c.world_half_width, in.taper_ratio.x > 0.0); + let world_hw_b = select(c.world_half_width, in.taper_ratio.y * c.world_half_width, in.taper_ratio.y > 0.0); + let cur_world_hw = mix(world_hw_a, world_hw_b, which_end); + let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel); - let seg = screen_b - screen_a; - let seg_len = length(seg); - var dir: vec2; - if seg_len > 1e-4 { - dir = seg / seg_len; + let world_delta = rel_b - rel_a; + let world_len = length(world_delta); + var world_dir = vec3(1.0, 0.0, 0.0); + if world_len > 1e-6 { + world_dir = world_delta / world_len; + } + + var norm = vec3(0.0, 0.0, 1.0); + if length(c.marker_normal_scale.xyz) > 1e-4 { + norm = normalize(c.marker_normal_scale.xyz); + } + + var perp_world = cross(norm, world_dir); + if length(perp_world) < 1e-4 { + perp_world = cross(vec3(0.0, 1.0, 0.0), world_dir); + if length(perp_world) < 1e-4 { + perp_world = cross(vec3(1.0, 0.0, 0.0), world_dir); + } + } + perp_world = normalize(perp_world); + + let pos_rel = mix(rel_a, rel_b, which_end); + let world_pos = pos_rel + perp_world * (eff_hw * side); + var clip_pos = u.view_rot * vec4(world_pos, 1.0); + clip_pos.z = clip_pos.z - c.draw_depth * DRAW_ORDER_BIAS * clip_pos.w; + + final_clip = clip_pos; + out_dist = mix(in.distance_a, in.distance_b, which_end); + out_cap = vec2(which_end * world_len, eff_hw * side); + out_cap_ends = vec3(world_len, world_hw_a, world_hw_b); } else { - dir = vec2(1.0, 0.0); + // Thin wires expand in screen pixels facing the camera with rounded end caps / joints. + let clip_a = u.view_rot * vec4(rel_a, 1.0); + let clip_b = u.view_rot * vec4(rel_b, 1.0); + + let ndc_a = clip_a.xy / clip_a.w; + let ndc_b = clip_b.xy / clip_b.w; + + let screen_a = ndc_a * u.viewport_size * 0.5; + let screen_b = ndc_b * u.viewport_size * 0.5; + + let seg = screen_b - screen_a; + let seg_len = length(seg); + var dir: vec2; + if seg_len > 1e-4 { + dir = seg / seg_len; + } else { + dir = vec2(1.0, 0.0); + } + let perp = vec2(-dir.y, dir.x); + + let clip_pos = mix(clip_a, clip_b, which_end); + + let hw = resolve_hw(0.0, 0.0, c.half_width); + let ext = which_end * 2.0 - 1.0; + let offset_px = perp * hw * side + dir * hw * ext; + let ndc_offset = offset_px / (u.viewport_size * 0.5); + var clip_pos_out = clip_pos + vec4(ndc_offset * clip_pos.w, 0.0, 0.0); + clip_pos_out.z = clip_pos_out.z - c.draw_depth * DRAW_ORDER_BIAS * clip_pos_out.w; + + final_clip = clip_pos_out; + out_dist = mix(in.distance_a, in.distance_b, which_end) + ext * hw * u.world_per_pixel; + out_cap = vec2(which_end * seg_len + ext * hw, hw * side); + out_cap_ends = vec3(seg_len, hw, hw); } - let perp = vec2(-dir.y, dir.x); - - let clip_pos = mix(clip_a, clip_b, which_end); - - // A wide polyline carries its band width in world units: expand the quad - // by `world_half_width / world_per_pixel` (screen pixels) so the band grows - // and shrinks with zoom. A normal wire (world_half_width == 0) uses the - // screen-pixel half-width, honouring the LWDISPLAY toggle. - // A tapered band carries normalized endpoint widths on the instance: - // interpolate across the segment so the band narrows/widens smoothly. A - // constant band uses the per-wire `world_half_width`. Both clamp to a - // half-pixel so a zoomed-out band stays a hairline instead of vanishing. - let hw_a = resolve_hw(in.taper_ratio.x, c.world_half_width, c.half_width); - let hw_b = resolve_hw(in.taper_ratio.y, c.world_half_width, c.half_width); - let hw = mix(hw_a, hw_b, which_end); - - // Extend the quad longitudinally by the end half-width and let the - // fragment stage round the overhang off: adjoining segments then meet in - // overlapping round joints, closing the wedge gaps a perpendicular-only - // expansion leaves on the outside of corners and along tessellated arcs. - let ext = which_end * 2.0 - 1.0; // -1 at the A end, +1 at the B end - let offset_px = perp * hw * side + dir * hw * ext; - let ndc_offset = offset_px / (u.viewport_size * 0.5); - let final_clip = clip_pos + vec4(ndc_offset * clip_pos.w, 0.0, 0.0); let lt_scale = u.linetype_scale; var min_elem: f32 = c.pattern_length * lt_scale; @@ -185,14 +227,10 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon var out: VertexOut; out.clip_pos = final_clip; - out.clip_pos.z = out.clip_pos.z - c.draw_depth * DRAW_ORDER_BIAS * out.clip_pos.w; out.color = c.color; - // Dash arc-length, extrapolated over the cap overhang so the pattern - // stays continuous through a joint. - out.distance = mix(in.distance_a, in.distance_b, which_end) - + ext * hw * u.world_per_pixel; - out.cap = vec2(which_end * seg_len + ext * hw, hw * side); - out.cap_ends = vec3(seg_len, hw_a, hw_b); + out.distance = out_dist; + out.cap = out_cap; + out.cap_ends = out_cap_ends; out.pattern_length = c.pattern_length * lt_scale; out.pat0 = c.pat0 * lt_scale; out.pat1 = c.pat1 * lt_scale; diff --git a/src/snap.rs b/src/snap.rs index 9d0f9db90..b3021cc22 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1369,31 +1369,20 @@ impl Snapper { && (local_segments.is_some() || allow_unindexed_pairwise) { if let Some(segments) = &local_segments { - // Curved pairs (circle/arc/ellipse, and a line paired with - // one) are solved exactly up front (bug #1052 — see - // `exact_curve_intersections`'s doc comment); the segment - // sweep below then skips any pair already resolved this - // way, so the stale tessellated approximation never - // competes with the true point as a candidate. Every - // distinct wire pair touching the cursor-local segment set - // is tried — cheap even for a plain line-vs-line pair, - // which `exact_curve_intersections` rejects immediately — - // rather than pre-filtering to "known curved" wires, so a - // line paired with a circle/arc/ellipse is covered too. - let mut local_wires: Vec = segments.iter().map(|s| s.wire).collect(); - local_wires.sort_unstable(); - local_wires.dedup(); - let mut resolved_pairs: Vec<(u32, u32)> = Vec::new(); - for (idx, &wi) in local_wires.iter().enumerate() { - for &wj in &local_wires[idx + 1..] { - let (Some(wire_i), Some(wire_j)) = (wires.source_wire(wi), wires.source_wire(wj)) else { - continue; - }; - if let Some(pts) = exact_curve_intersections(wire_i, wire_j) { - for pt in pts { - try_pt(pt, SnapType::Intersection); + let local_wires: Vec<_> = wires.iter().filter(|wire| wire_in_range(wire)).collect(); + let mut resolved_pairs = rustc_hash::FxHashSet::default(); + let pair_key = |a: &WireModel, b: &WireModel| { + let a = a as *const WireModel as usize; + let b = b as *const WireModel as usize; + (a.min(b), a.max(b)) + }; + for (idx, &wire_i) in local_wires.iter().enumerate() { + for &wire_j in &local_wires[idx + 1..] { + if let Some(points) = exact_curve_intersections(wire_i, wire_j) { + for point in points { + try_pt(point, SnapType::Intersection); } - resolved_pairs.push((wi, wj)); + resolved_pairs.insert(pair_key(wire_i, wire_j)); } } } @@ -1413,7 +1402,8 @@ impl Snapper { if a.wire == b.wire || a.max_y() < b.min_y() || a.min_y() > b.max_y() { continue; } - if resolved_pairs.contains(&(a.wire.min(b.wire), a.wire.max(b.wire))) { + if wires.source_wire(a.wire).zip(wires.source_wire(b.wire)) + .is_some_and(|(a, b)| resolved_pairs.contains(&pair_key(a, b))) { continue; } if let Some(pt) = seg_intersect_3d(a.a, a.b, b.a, b.b) { @@ -2343,164 +2333,7 @@ fn ray_segment_intersect_3d( )) } -/// A full circle recovered from a wire's `tangent_geoms` — the entity's real -/// definition, in world space — used to solve Intersection snap exactly for -/// circle/arc pairs instead of falling back to `seg_intersect_3d` against -/// their tessellated approximation. -/// -/// Why this exists (GitHub discussion #1052): a chord of *any* tessellated -/// circle sits strictly inside the true circle, so the "intersection" of two -/// tessellated circles is never quite where the two real circles actually -/// cross — the error is small but real, and does not shrink with zoom: the -/// resident wire set OSNAP searches is built once per geometry edit with a -/// fixed 48-segment tessellation for every circle regardless of radius, by -/// design (`Scene::resident_wires_for`'s own doc comment: zoom-independent, -/// so hit-testing/snap don't re-tessellate on every camera move). A r=100 -/// circle's chord sagitta at 48 segments is already ≈0.21 world units. -struct WireCircle { - center: DVec3, - radius: f64, - /// Unit normal of the plane the circle lies in. - normal: DVec3, - /// `Some((axis_x, axis_y, start_angle, end_angle))` for an arc — used to - /// keep only the full circle's crossing points that actually fall - /// within the arc's own sweep. `None` for a full circle: every point - /// the circle-circle solve finds is valid. Angles follow this - /// codebase's own convention (`src/entities/arc.rs`): measured from - /// `axis_x` toward `axis_y`, equal start/end meaning a full turn. - arc_span: Option<(DVec3, DVec3, f64, f64)>, -} - -/// The wire's underlying circle, if it's simple enough to have one: a single -/// Circle or Arc entity as a whole, not a compound chain. Ellipses and lines -/// return `None` here — they're handled by the more general -/// `exact_curve_intersections` below, which this function's caller falls -/// through to. A polyline with a bulge segment still goes through the -/// tessellated sweep: it isn't a single recognized curve, and splitting it -/// into per-segment exact geometry is a real follow-up, not a known-and- -/// skipped case (there's no single closed form for "the rest of a compound -/// chain" the way there is for one circle, arc, ellipse, or line). -fn wire_circle(wire: &WireModel) -> Option { - let [geom] = wire.tangent_geoms.as_slice() else { - return None; - }; - match geom { - TangentGeom::PlanarCircle { center, axis_x, axis_y, radius } => { - let (axis_x, axis_y) = (DVec3::new(axis_x[0], axis_x[1], axis_x[2]), DVec3::new(axis_y[0], axis_y[1], axis_y[2])); - Some(WireCircle { - center: DVec3::new(center[0], center[1], center[2]), - radius: *radius, - normal: axis_x.cross(axis_y).normalize(), - arc_span: None, - }) - } - TangentGeom::Circle { center, radius } => Some(WireCircle { - center: DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64), - radius: *radius as f64, - normal: DVec3::Z, - arc_span: None, - }), - TangentGeom::Arc { center, axis_x, axis_y, radius, start_angle, end_angle } => { - let (axis_x, axis_y) = (DVec3::new(axis_x[0], axis_x[1], axis_x[2]), DVec3::new(axis_y[0], axis_y[1], axis_y[2])); - Some(WireCircle { - center: DVec3::new(center[0], center[1], center[2]), - radius: *radius, - normal: axis_x.cross(axis_y).normalize(), - arc_span: Some((axis_x, axis_y, *start_angle, *end_angle)), - }) - } - TangentGeom::Line { .. } | TangentGeom::PlanarEllipse { .. } => None, - } -} - -impl WireCircle { - /// Whether world point `p` — assumed already on this circle — falls - /// within the arc's actual angular sweep. Always true for a full circle. - fn contains_point(&self, p: DVec3) -> bool { - let Some((axis_x, axis_y, start, end)) = self.arc_span else { - return true; - }; - let rel = p - self.center; - let tau = std::f64::consts::TAU; - let angle = rel.dot(axis_y).atan2(rel.dot(axis_x)).rem_euclid(tau); - let span = (end - start).rem_euclid(tau); - let from_start = (angle - start).rem_euclid(tau); - // Equal start/end means a full turn (this codebase's own Arc - // convention), not zero sweep. - span == 0.0 || from_start <= span + 1e-9 - } -} - -/// Exact circle/arc intersection for one wire pair, bypassing their -/// tessellated approximation entirely. `None` when neither side is a circle -/// or arc (the segment sweep is already exact for pure lines/polylines), the -/// two aren't coplanar (a genuinely-3D case the sweep already handles no -/// worse than before), or they don't actually meet — in every `None` case -/// the caller falls back to the ordinary segment sweep unchanged. -/// -/// Correct for any circle/arc-vs-circle/arc pair regardless of whether the -/// two sides' own local frames agree in orientation: the candidate points -/// come from a frame-agnostic 3D construction (`a.normal`/`dir`/`perp` -/// below), and each side's `WireCircle::contains_point` then checks a -/// candidate against *that side's own* axis_x/axis_y independently — there -/// is no step that re-expresses one arc's angle range in the other's frame, -/// so this handles arc-vs-arc (even two arcs with differently-rotated -/// frames — see the test below) exactly as directly as circle-vs-circle. -/// -/// Deliberately scoped to circle/arc pairs, not every curved type: a -/// line-vs-circle/arc pair and anything involving an ellipse are left to -/// the existing sweep for now — line-circle needs its own (simple, but -/// separate) closed form, and an ellipse's parametrisation is genuinely -/// orientation-sensitive in a way a circle's isn't, so it doesn't fall out -/// of this same construction for free. Not silently dropped — a natural -/// follow-up once this lands. -fn exact_circle_intersections(wire_a: &WireModel, wire_b: &WireModel) -> Option> { - let a = wire_circle(wire_a)?; - let b = wire_circle(wire_b)?; - - let d_vec = b.center - a.center; - let d = d_vec.length(); - if d < 1e-9 { - return None; // concentric: coincident or no isolated crossing either way - } - - // Coplanarity: both circles' planes must agree (normals parallel, up to - // sign), and the vector between centers must actually lie in that plane - // — otherwise this is a genuinely-3D case the tessellated sweep already - // handles (via `seg_intersect_3d`'s own height-match check) no worse - // than an exact-but-wrong-plane answer would. - const PLANE_TOL: f64 = 1e-7; - if a.normal.cross(b.normal).length() > PLANE_TOL { - return None; - } - if d_vec.dot(a.normal).abs() > PLANE_TOL * d.max(1.0) { - return None; - } - - let (r1, r2) = (a.radius, b.radius); - if d > r1 + r2 + PLANE_TOL || d < (r1 - r2).abs() - PLANE_TOL { - return None; // circles don't meet - } - - let dir = d_vec / d; - let a_dist = ((d * d + r1 * r1 - r2 * r2) / (2.0 * d)).clamp(-r1, r1); - let h = (r1 * r1 - a_dist * a_dist).max(0.0).sqrt(); - let mid = a.center + dir * a_dist; - let perp = a.normal.cross(dir).normalize(); - - let candidates = if h < 1e-9 { vec![mid] } else { vec![mid + perp * h, mid - perp * h] }; - let points: Vec = candidates.into_iter().filter(|&p| a.contains_point(p) && b.contains_point(p)).collect(); - - (!points.is_empty()).then_some(points) -} - -/// A world-space working plane for embedding curves into -/// `cadkernel::geom2d`'s 2D representation — the general-case fix for -/// GitHub discussion #1052, covering every pairing `exact_circle_ -/// intersections`'s fast path doesn't: a line paired with a circle/arc/ -/// ellipse, and anything involving an ellipse (an ellipse's parametrisation -/// is orientation-sensitive in a way a circle's isn't, so it doesn't fall -/// out of that fast path's frame-independent construction for free). +// Coordinates for adapting wire geometry to the kernel intersection solver. struct WirePlane { origin: DVec3, axis_x: DVec3, @@ -2529,63 +2362,6 @@ impl WirePlane { } } -/// A stable orthonormal in-plane basis for a given normal, used only to -/// seed a shared working plane when neither wire's own native frame is -/// preferred over the other's (both curved, or the "primary" role needs a -/// deterministic pick) — the specific in-plane rotation chosen doesn't -/// matter for correctness, only that both curves embed into the *same* one. -fn stable_plane_basis(normal: DVec3) -> (DVec3, DVec3) { - let seed = if normal.x.abs() <= normal.y.abs() && normal.x.abs() <= normal.z.abs() { - DVec3::X - } else if normal.y.abs() <= normal.z.abs() { - DVec3::Y - } else { - DVec3::Z - }; - let axis_x = (seed - normal * normal.dot(seed)).normalize(); - let axis_y = normal.cross(axis_x); - (axis_x, axis_y) -} - -/// A world point on an ellipse at native parameter `t`, using the ellipse's -/// *own* normal for the minor-axis direction (not any externally-chosen -/// plane's) — the correct, orientation-independent reference for computing -/// where a boundary parameter actually sits in space, mirroring how -/// `src/entities/curve.rs::ellipse_curve` already treats this convention as -/// established (`normal` determines the minor-axis rotation sense). -fn ellipse_world_point(center: DVec3, major_axis_world: DVec3, ell_normal: DVec3, minor_radius: f64, t: f64) -> DVec3 { - let major_radius = major_axis_world.length(); - let major_unit = major_axis_world / major_radius; - let minor_unit = ell_normal.cross(major_unit); - center + major_unit * (major_radius * t.cos()) + minor_unit * (minor_radius * t.sin()) -} - -/// The parameter, in `frame`'s own 2D ellipse embedding (`major_axis_2d` -/// already resolved there, `cadkernel`'s own "minor is major rotated 90° -/// CCW in 2D" convention supplying the rest), that a world point already -/// known to lie on the ellipse corresponds to. Inverting through the actual -/// point — rather than assuming a native parameter value carries over -/// unchanged — is what makes this correct even when the ellipse's own -/// normal and `frame`'s normal are coplanar but *anti*-parallel (a plain -/// "reuse start/end parameter" would silently pick the complementary arc in -/// that case: 2D-CCW-in-`frame` and 2D-CCW-in-the-ellipse's-own-frame would -/// disagree on which way is "forward"). -fn ellipse_param_in_frame(world_point: DVec3, centre: DVec3, major_axis_2d: [f64; 2], major_radius: f64, minor_radius: f64, frame: &WirePlane) -> f64 { - let rel = frame.to_2d(world_point); - let c = frame.to_2d(centre); - let d = [rel[0] - c[0], rel[1] - c[1]]; - let minor_axis_2d = [-major_axis_2d[1], major_axis_2d[0]]; - let cos_t = (d[0] * major_axis_2d[0] + d[1] * major_axis_2d[1]) / major_radius; - let sin_t = (d[0] * minor_axis_2d[0] + d[1] * minor_axis_2d[1]) / minor_radius; - sin_t.atan2(cos_t) -} - -/// This wire's own natural working plane — only a Circle/PlanarCircle/Arc/ -/// PlanarEllipse has one; a line's two points don't define a unique plane, -/// and a compound chain (a polyline with a bulge) isn't a single curve to -/// begin with (out of scope for this pass, not because it's hard, but -/// because a *chain* needs its own per-segment bookkeeping this pass -/// doesn't add). fn wire_plane(wire: &WireModel) -> Option { let [geom] = wire.tangent_geoms.as_slice() else { return None; @@ -2611,13 +2387,6 @@ fn wire_plane(wire: &WireModel) -> Option { Some(WirePlane { origin, axis_x, axis_y, normal: axis_x.cross(axis_y).normalize() }) } -/// `wire`'s curve, expressed in `frame`'s 2D coordinates — `None` if `wire` -/// isn't a single recognized curve (see `wire_plane`'s doc comment for what -/// that excludes) or doesn't actually lie in `frame`'s plane. Correct -/// regardless of whether `wire`'s own native frame agrees in orientation -/// with `frame`: an Arc's/Ellipse's boundary parameters are re-derived from -/// their actual world points (`ellipse_param_in_frame`, the closure below -/// for Arc), never assumed to carry over unchanged. fn curve_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option { use cadkernel::geom2d::{Arc as KArc, Circle as KCircle, Ellipse as KEllipse, EllipseArc as KEllipseArc, Line as KLine}; @@ -2639,13 +2408,16 @@ fn curve_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option { - let p1 = DVec3::new(p1[0] as f64, p1[1] as f64, p1[2] as f64); - let p2 = DVec3::new(p2[0] as f64, p2[1] as f64, p2[2] as f64); + let (p1, p2) = if wire.points.len() == 2 { + (wp_f64(wire, 0), wp_f64(wire, 1)) + } else { + (Vec3::from_array(*p1).as_dvec3(), Vec3::from_array(*p2).as_dvec3()) + }; (frame.contains(p1, tol) && frame.contains(p2, tol)).then(|| Curve::Line(KLine { start: frame.to_2d(p1), end: frame.to_2d(p2) })) } TangentGeom::Circle { center, radius } => { let c = DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64); - frame.contains(c, tol).then(|| Curve::Circle(KCircle { centre: frame.to_2d(c), radius: *radius as f64 })) + (DVec3::Z.cross(frame.normal).length() <= tol && frame.contains(c, tol)).then(|| Curve::Circle(KCircle { centre: frame.to_2d(c), radius: *radius as f64 })) } TangentGeom::PlanarCircle { center, axis_x, axis_y, radius } => { let c = DVec3::new(center[0], center[1], center[2]); @@ -2659,16 +2431,16 @@ fn curve_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option tol || !frame.contains(c, tol) { return None; } - let boundary = |angle: f64| c + *radius * (angle.cos() * ax + angle.sin() * ay); - let (raw_start, raw_end) = (angle_in_frame(boundary(*start_angle), c), angle_in_frame(boundary(*end_angle), c)); - // The arc's own (ax, ay, n) frame and the target frame's (axis_x, axis_y, normal) - // agree on the plane but may disagree on which way is "up": when n is anti-parallel - // to frame.normal, mapping into the frame's basis is a reflection, which reverses - // the CCW sweep direction. Swap start/end so the swept range still traces the same - // physical arc instead of its complement. - let (start_angle, end_angle) = if n.dot(frame.normal) < 0.0 { (raw_end, raw_start) } else { (raw_start, raw_end) }; - Some(Curve::Arc(KArc { centre: frame.to_2d(c), radius: *radius, start_angle, end_angle })) + let arc = KArc { centre: frame.to_2d(c), radius: *radius, start_angle: *start_angle, end_angle: *end_angle }; + let sweep = arc.sweep(); + if (sweep - std::f64::consts::TAU).abs() <= 1e-12 { + return Some(Curve::Circle(KCircle { centre: arc.centre, radius: *radius })); + } + let boundary = if n.dot(frame.normal) < 0.0 { *end_angle } else { *start_angle }; + let start_angle = angle_in_frame(c + *radius * (boundary.cos() * ax + boundary.sin() * ay), c); + Some(Curve::Arc(KArc { start_angle, end_angle: start_angle + sweep, ..arc })) } + TangentGeom::PlanarEllipse { center, major_axis, normal, minor_axis_ratio, start_param, end_param } => { let c = DVec3::new(center[0], center[1], center[2]); let ell_normal = DVec3::new(normal[0], normal[1], normal[2]).normalize(); @@ -2687,13 +2459,11 @@ fn curve_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option Option Option> { - if let Some(pts) = exact_circle_intersections(wire_a, wire_b) { - return Some(pts); - } - - // Prefer whichever side already has a natural working plane so a - // circle/arc paired with a line needs no reprojection of its own shape - // at all; when both are curved (at least one an ellipse, since a pure - // circle/arc pair would already have returned above) or neither is, - // fall back to a plane seeded from whichever side offers one. - let frame = wire_plane(wire_a).or_else(|| wire_plane(wire_b)).or_else(|| { - let normal = curve_normal_only(wire_a).or_else(|| curve_normal_only(wire_b))?; - let origin = wp_f64_first(wire_a).or_else(|| wp_f64_first(wire_b))?; - let (axis_x, axis_y) = stable_plane_basis(normal); - Some(WirePlane { origin, axis_x, axis_y, normal }) - })?; + let frame = wire_plane(wire_a).or_else(|| wire_plane(wire_b))?; const PLANE_TOL: f64 = 1e-7; let curve_a = curve_in_frame(wire_a, &frame, PLANE_TOL)?; @@ -2743,20 +2488,7 @@ fn exact_curve_intersections(wire_a: &WireModel, wire_b: &WireModel) -> Option = crossings.into_iter().map(|c| frame.to_3d(c.point)).collect(); - (!points.is_empty()).then_some(points) -} - -/// Fallback plane-seeding helpers for `exact_curve_intersections`' rare -/// "neither side has its own natural plane" branch (in practice: unreached -/// today, since that only happens when both sides are lines, which returns -/// `None` a step earlier — kept for robustness against a future caller that -/// invokes `exact_curve_intersections` with weaker preconditions). -fn curve_normal_only(wire: &WireModel) -> Option { - wire_plane(wire).map(|p| p.normal) -} - -fn wp_f64_first(wire: &WireModel) -> Option { - (!wire.points.is_empty()).then(|| wp_f64(wire, 0)) + Some(points) } /// XY-plane segment-segment intersection. Returns `None` if parallel or outside. @@ -3339,14 +3071,8 @@ mod ext_tests { ); } - /// GitHub discussion #1052's own reproduction: a 3-unit base line, a - /// radius-4 circle at one end, a radius-5 circle at the other. The true - /// intersection is exactly (0, 4, 0) (a 3-4-5 right triangle) — the - /// tessellated segment sweep misses this by a small but real amount - /// (confirmed separately against `seg_intersect_3d` on the actual - /// tessellated wire); the exact solver must hit it exactly. #[test] - fn exact_circle_intersections_matches_the_3_4_5_report() { + fn exact_curve_intersections_matches_the_3_4_5_report() { let c1 = WireModel { tangent_geoms: vec![TangentGeom::PlanarCircle { center: [0.0, 0.0, 0.0], @@ -3365,7 +3091,7 @@ mod ext_tests { }], ..Default::default() }; - let pts = exact_circle_intersections(&c1, &c2).expect("two overlapping circles must intersect"); + let pts = exact_curve_intersections(&c1, &c2).expect("two overlapping circles must intersect"); assert_eq!(pts.len(), 2, "two distinct circles crossing at two points"); let upper = pts.iter().copied().find(|p| p.y > 0.0).expect("an upper intersection"); assert!((upper - DVec3::new(0.0, 4.0, 0.0)).length() < 1e-9, "expected exactly (0,4,0), got {upper:?}"); @@ -3376,11 +3102,7 @@ mod ext_tests { } #[test] - fn exact_circle_intersections_respects_an_arcs_own_sweep() { - // A quarter-circle arc from 0° to 90° (so it only covers the upper- - // right quadrant) against a full circle whose two true intersection - // points straddle that boundary: only the one actually on the arc's - // sweep should come back. + fn exact_curve_intersections_respects_an_arcs_own_sweep() { let arc = WireModel { tangent_geoms: vec![TangentGeom::Arc { center: [0.0, 0.0, 0.0], @@ -3403,13 +3125,13 @@ mod ext_tests { }; // Full-circle math gives (0,4,0) [on the 0..90° arc] and (0,-4,0) // [not on it]. - let pts = exact_circle_intersections(&arc, &circle).expect("the circles still cross"); + let pts = exact_curve_intersections(&arc, &circle).expect("the circles still cross"); assert_eq!(pts.len(), 1, "only the point on the arc's own sweep"); assert!((pts[0] - DVec3::new(0.0, 4.0, 0.0)).length() < 1e-9); } #[test] - fn exact_circle_intersections_is_none_for_non_coplanar_circles() { + fn exact_curve_intersections_is_none_for_non_coplanar_circles() { let flat = WireModel { tangent_geoms: vec![TangentGeom::PlanarCircle { center: [0.0, 0.0, 0.0], @@ -3429,13 +3151,13 @@ mod ext_tests { ..Default::default() }; assert!( - exact_circle_intersections(&flat, &tilted).is_none(), + exact_curve_intersections(&flat, &tilted).is_none(), "a genuinely-3D pair must fall back to the ordinary sweep, not guess a plane" ); } #[test] - fn exact_circle_intersections_is_none_when_circles_dont_meet() { + fn exact_curve_intersections_resolves_disjoint_circles() { let near = WireModel { tangent_geoms: vec![TangentGeom::PlanarCircle { center: [0.0, 0.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 1.0 }], ..Default::default() @@ -3444,37 +3166,11 @@ mod ext_tests { tangent_geoms: vec![TangentGeom::PlanarCircle { center: [100.0, 0.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 1.0 }], ..Default::default() }; - assert!(exact_circle_intersections(&near, &far).is_none()); + assert!(exact_curve_intersections(&near, &far).unwrap().is_empty()); } #[test] - fn wire_circle_is_none_for_a_plain_line_wire() { - // A line wire carries no `TangentGeom::Circle`/`Arc` entry, so - // `wire_circle` (and the fast `exact_circle_intersections` path built - // on it) must not panic or, worse, silently misidentify a line as a - // circle — it should just decline, leaving the line-vs-circle case to - // the more general `exact_curve_intersections`, covered separately by - // `exact_curve_intersections_handles_a_line_against_a_circle`. - let line = WireModel { - points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]], - ..Default::default() - }; - assert!(wire_circle(&line).is_none()); - let circle = WireModel { - tangent_geoms: vec![TangentGeom::PlanarCircle { center: [0.0, 0.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 4.0 }], - ..Default::default() - }; - assert!(exact_circle_intersections(&line, &circle).is_none(), "line-vs-circle isn't this fast path's job — it's handled by exact_curve_intersections"); - } - - /// Each side's `contains_point` checks the candidate world point against - /// *that side's own* native axis_x/axis_y independently — there is no - /// step that re-expresses one arc's angle range in the other's frame — - /// so two arcs whose local frames are rotated relative to each other - /// must still resolve correctly, not just the common case where both - /// happen to share the same axis_x. - #[test] - fn exact_circle_intersections_handles_two_arcs_in_differently_rotated_frames() { + fn exact_curve_intersections_handles_two_arcs_in_differently_rotated_frames() { // Arc A: quarter circle 0..90°, axis_x along world +X. let arc_a = WireModel { tangent_geoms: vec![TangentGeom::Arc { @@ -3487,12 +3183,6 @@ mod ext_tests { }], ..Default::default() }; - // Arc B: same true geometry as the earlier "full circle" partner, - // but described in a frame rotated 90° from world axes (axis_x - // along world +Y, axis_y along world -X) — its own start/end angles - // are re-expressed accordingly so the arc still covers the same - // true half of the circle (the lower half, i.e. world angle - // 180..360°, which in this rotated frame is local angle 90..270°). let arc_b = WireModel { tangent_geoms: vec![TangentGeom::Arc { center: [3.0, 0.0, 0.0], @@ -3504,14 +3194,7 @@ mod ext_tests { }], ..Default::default() }; - // True full-circle crossings are (0,4,0) [world angle 90° on B, so - // NOT on B's lower-half sweep] and (0,-4,0) [world angle 270°, IS on - // B's sweep, and also not on A's 0..90° sweep]. Independently - // filtering each side's own frame should therefore find nothing — - // proving the two arcs' angle checks aren't accidentally sharing or - // confusing each other's frame (a bug here would likely either - // wrongly accept one of the two points or wrongly accept both). - assert!(exact_circle_intersections(&arc_a, &arc_b).is_none()); + assert!(exact_curve_intersections(&arc_a, &arc_b).unwrap().is_empty()); // Flip A to cover the lower-right quadrant (270..360°) instead: now // (0,-4,0) is on both A's and B's own sweep, independently checked @@ -3527,7 +3210,7 @@ mod ext_tests { }], ..Default::default() }; - let pts = exact_circle_intersections(&arc_a_lower, &arc_b).expect("both sweeps cover (0,-4,0)"); + let pts = exact_curve_intersections(&arc_a_lower, &arc_b).expect("both sweeps cover (0,-4,0)"); assert_eq!(pts.len(), 1); assert!((pts[0] - DVec3::new(0.0, -4.0, 0.0)).length() < 1e-9, "got {:?}", pts[0]); } @@ -3555,10 +3238,6 @@ mod ext_tests { #[test] fn exact_curve_intersections_handles_a_line_against_an_arcs_own_sweep() { - // Same arc as `exact_circle_intersections_respects_an_arcs_own_sweep` - // (0..90° of a radius-4 circle at the origin), crossed by a vertical - // line at x=0: the full circle would cross it at (0,4,0) and - // (0,-4,0), but only the first is on the arc's own sweep. let arc = WireModel { tangent_geoms: vec![TangentGeom::Arc { center: [0.0, 0.0, 0.0], @@ -3629,13 +3308,6 @@ mod ext_tests { } } - /// The critical case for `ellipse_param_in_frame`: two elliptical arcs - /// (here degenerate to circles via `minor_axis_ratio: 1.0`, so the - /// *expected* crossing angles are plain circle trigonometry, independently - /// computed) sharing the SAME numeric start/end parameter range but with - /// ANTI-PARALLEL normals. Reusing a native parameter value unchanged - /// across a normal flip would silently pick the mirror-image point - /// instead — this proves it doesn't. #[test] fn exact_curve_intersections_ellipse_arc_respects_normal_direction() { // Full ellipse x^2/25 + y^2/9 = 1, and a radius-3 "ellipse" (ratio @@ -3681,12 +3353,6 @@ mod ext_tests { assert!((minus[0] - DVec3::new(3.75, -(63.0_f64).sqrt() / 4.0, 0.0)).length() < 1e-6); } - /// Same reflection hazard as the ellipse test above, but for `curve_in_frame`'s - /// `TangentGeom::Arc` branch: this only runs when the *other* side of the pair - /// isn't itself a circle/arc (so `exact_circle_intersections`'s own, already - /// normal-agnostic `WireCircle` path doesn't intercept it first) — paired here - /// against the same full ellipse so the frame comes from the ellipse's plane - /// (normal = +Z) while the arc's own normal is anti-parallel to it. #[test] fn exact_curve_intersections_arc_respects_normal_direction_against_an_ellipse_frame() { let ellipse = WireModel { @@ -3719,4 +3385,62 @@ mod ext_tests { assert!(pts[0].y < 0.0, "expected the lower point (normal flip must change which physical arc this is), got {:?}", pts[0]); assert!((pts[0] - DVec3::new(3.75, -(63.0_f64).sqrt() / 4.0, 0.0)).length() < 1e-6); } + #[test] + fn indexed_intersection_uses_true_curves_even_when_chords_miss_the_aperture() { + use crate::scene::pick::interaction_index::InteractionIndex; + use std::sync::Arc; + let circle = |name: &str, x: f64, radius: f64| { + let points = (0..=48).map(|i| { + let angle = i as f64 * std::f64::consts::TAU / 48.0 + if x == 0.0 { 0.03 } else { 0.0 }; + [(x + radius * angle.cos()) as f32, (radius * angle.sin()) as f32, 0.0] + }).collect(); + let mut wire = WireModel::solid(name.to_owned(), points, [1.0; 4], false); + wire.aabb = [(x - radius) as f32, -radius as f32, (x + radius) as f32, radius as f32]; + wire.tangent_geoms = vec![TangentGeom::PlanarCircle { + center: [x, 0.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius, + }]; + wire + }; + let wires = Arc::new(vec![circle("1", 0.0, 4.0), circle("2", 3.0, 5.0)]); + let index = InteractionIndex::build(&wires); + let candidates = index.query_xy(Arc::clone(&wires), [-0.0001, 3.9999, 0.0001, 4.0001]); + assert_eq!(candidates.len(), 2); + assert!(candidates.segments().unwrap().iter().all(|segment| segment.wire == 1)); + let point = DVec3::new(0.0, 4.0, 0.0); + let mut snapper = Snapper::default(); + snapper.snap_enabled = true; + snapper.enabled = [SnapType::Intersection].into_iter().collect(); + let result = snapper.snap( + point, Point::new(500.0, 500.0), &candidates, + Mat4::from_scale(Vec3::splat(100.0)), point, + Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 }, + Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None, + ).expect("the true intersection remains available at high zoom"); + assert!((result.world - point).length() < 1e-9); + } + + #[test] + fn line_circle_intersection_retains_low_coordinate_bits() { + let origin = 1_000_000_000.0; + let a = [origin - 10.0, origin, 0.0]; + let b = [origin + 10.0, origin, 0.0]; + let points = [a, b].map(|point| point.map(|value| value as f32)); + let low = [a, b].into_iter().zip(points).map(|(point, high)| { + std::array::from_fn(|axis| (point[axis] - high[axis] as f64) as f32) + }).collect(); + let line = WireModel { + points: points.to_vec(), points_low: low, + tangent_geoms: vec![TangentGeom::Line { p1: points[0], p2: points[1] }], + ..Default::default() + }; + let circle = WireModel { + tangent_geoms: vec![TangentGeom::PlanarCircle { + center: [origin, origin, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 3.0, + }], ..Default::default() + }; + let points = exact_curve_intersections(&line, &circle).unwrap(); + assert_eq!(points.len(), 2); + assert!(points.iter().all(|point| (point.x - origin).abs() == 3.0 && point.y == origin)); + } + } diff --git a/src/sys.rs b/src/sys.rs index 7fecdf171..f53671640 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -226,6 +226,13 @@ pub async fn read_clipboard_text() -> Option { value.as_string() } +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen(module = "/web/clipboard.js")] +extern "C" { + #[wasm_bindgen::prelude::wasm_bindgen(js_name = copyHistory)] + pub fn copy_history_text(text: &str, fallback_label: &str, close_label: &str) -> js_sys::Promise; +} + /// Web: write text to the system clipboard (fire-and-forget). Backs Ctrl+C in /// plain text_input fields, whose iced-internal copy is a no-op on the web /// (#346). The triggering keypress is a user gesture, so the write is allowed. diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 85a7660ab..7bf65623a 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -849,10 +849,10 @@ impl CommandLine { .align_y(iced::Center), ) .width(Length::Fill) - .padding([2, 6]); + .padding([2, 8]); let panel = container(column![header, log]) .width(Length::Fill) - .padding([4, 8]); + .padding(Padding { top: 2.0, right: 8.0, bottom: 4.0, left: 8.0 }); let resize = iced::widget::mouse_area( container(crate::ui::icons::themed_primary( crate::ui::icons::RESIZE, diff --git a/src/ui/properties.rs b/src/ui/properties.rs index fb0660c52..9033adb99 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -447,6 +447,10 @@ pub struct PropertiesPanel { /// carried across panel rebuilds so the state survives edits and selection /// changes. pub expanded_groups: HashSet, + /// Property sections the user collapsed. This is a rendered copy of the + /// app-wide preference, allowing every open document panel to share it. + /// Empty by default, meaning every section is initially shown. + pub collapsed_sections: HashSet, /// Whether the editable-dropdown (block Name) option list is open. pub edit_choice_open: bool, /// Field key of the value row currently being edited, or `None`. Marked when @@ -485,6 +489,7 @@ impl Default for PropertiesPanel { prop_vertex: 0, prop_vertex_indicator_active: false, expanded_groups: HashSet::default(), + collapsed_sections: HashSet::default(), edit_choice_open: false, active_field: None, field_key_by_id: HashMap::default(), @@ -710,8 +715,32 @@ impl PropertiesPanel { // ── Section renderer ────────────────────────────────────────────────── fn render_section<'a>(&'a self, section: &'a PropSection) -> Element<'a, Message> { - // Section header - let hdr = container(text(§ion.title).size(10)) + let collapsed = self.collapsed_sections.contains(§ion.title); + let toggle = button(if collapsed { + crate::ui::icons::themed_arrow_right(10.0) + } else { + crate::ui::icons::themed_arrow_down(10.0) + }) + .on_press(Message::PropSectionToggle(section.title.clone())) + .style(button::text) + .padding([0, 3]); + + // The arrow intentionally sits at the far end of the header. The rest + // of the header responds to a double-click, leaving a single click on + // the arrow as the precise, discoverable toggle target. + let title = mouse_area( + container(text(§ion.title).size(10)) + .width(Length::Fill) + .padding([3, 8]), + ) + .on_double_click(Message::PropSectionToggle(section.title.clone())); + let hdr = container( + row![ + title, + toggle, + ] + .align_y(iced::Center), + ) .style(|theme: &Theme| { let palette = theme.palette(); container::Style { @@ -724,10 +753,12 @@ impl PropertiesPanel { ..Default::default() } }) - .width(Length::Fill) - .padding([3, 8]); + .width(Length::Fill); let mut col = column![hdr].spacing(0); + if collapsed { + return col.into(); + } // Consecutive " X / Y [/ Z]" text rows collapse // into one clickable summary row; clicking expands the components. @@ -890,6 +921,7 @@ impl PropertiesPanel { | "text_color" | "block_content_color" | "background_fill_color" + | "indicator_fill_color" ) { let open = self.open_color_field.as_deref() == Some(field); let fsel = field.to_string(); diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 873749af5..1aacbcb93 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -13,6 +13,25 @@ pub enum OptionsTab { General, Display, Selection, + Drawing, +} + +/// The Selection-card settings that live on `UserSettings` rather than on the +/// model-space theme. +/// +/// Gathered into one value instead of four more positional parameters: the +/// function already takes seventeen, and `pick_add` / `pick_drag_rect` are two +/// adjacent `bool`s that would swap silently at the call site. +#[derive(Clone, Copy)] +pub struct SelectionPrefs { + /// PICKBOX, 0..=50. + pub pick_box: i32, + /// PICKADD: true = a click adds to the selection. + pub pick_add: bool, + /// PICKDRAG: true = press-drag draws a rectangle instead of a lasso. + pub pick_drag_rect: bool, + /// GRIPOBJLIMIT, 0..=32767; 0 = no limit. + pub grip_object_limit: i32, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -37,7 +56,9 @@ pub fn view_window<'a>( language: crate::i18n::Language, active_tab: OptionsTab, cursor_size: i32, - pick_box: i32, + selection: SelectionPrefs, + double_click_block_refedit: bool, + double_click_block_attedit: bool, cursor_type: CursorType, crosshair_color: Option<[u8; 3]>, crosshair_color_input: &'a str, @@ -634,10 +655,12 @@ pub fn view_window<'a>( Space::new().height(10), row![ text(crate::t!("Pick box size")).size(12).width(140), - slider(0..=50, pick_box.clamp(0, 50), Message::PickBoxChanged) + slider(0..=50, selection.pick_box.clamp(0, 50), Message::PickBoxChanged) .step(1) .width(Fill), - text(pick_box.clamp(0, 50).to_string()).size(11).width(44), + text(selection.pick_box.clamp(0, 50).to_string()) + .size(11) + .width(44), ] .spacing(10) .align_y(iced::Center), @@ -648,6 +671,30 @@ pub fn view_window<'a>( .size(11) .width(sizing.width), Space::new().height(24), + text(crate::t!("Selection Modes")).size(15), + Space::new().height(10), + row![ + // Checked is the inverse of PICKADD: plain clicks replace and Shift adds. + iced::widget::checkbox(!selection.pick_add) + .on_toggle(Message::ShiftToAddToggled) + .size(15), + text(crate::t!("Use Shift to add to selection (PICKADD)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(10), + row![ + iced::widget::checkbox(selection.pick_drag_rect) + .on_toggle(Message::PickDragRectToggled) + .size(15), + text(crate::t!( + "Press and drag draws a rectangle instead of a lasso (PICKDRAG)" + )) + .size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(24), row![ text(crate::t!("Visual Effect Settings")).size(15), Space::new().width(Fill), @@ -716,6 +763,15 @@ pub fn view_window<'a>( .spacing(10) .align_y(iced::Center), Space::new().height(10), + row![ + iced::widget::checkbox(model_space.selection_effect) + .on_toggle(Message::SelectionEffectToggled) + .size(15), + text(crate::t!("Show selection effect (SELECTIONEFFECT)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(10), row![ text(crate::t!("Selection highlight color")).size(12).width(140), iced::widget::pick_list( @@ -729,6 +785,34 @@ pub fn view_window<'a>( .spacing(10) .align_y(iced::Center), Space::new().height(24), + text(crate::t!("Preview")).size(15), + Space::new().height(10), + row![ + // SELECTIONPREVIEW is a bitmask; bit 1 is the idle rollover and + // bit 2 the one that runs while a command is gathering objects. + iced::widget::checkbox(model_space.selection_preview & 1 != 0) + .on_toggle(Message::SelectionPreviewIdleToggled) + .size(15), + text(crate::t!("Preview selection when no command is active")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(10), + row![ + iced::widget::checkbox(model_space.selection_preview & 2 != 0) + .on_toggle(Message::SelectionPreviewCommandToggled) + .size(15), + text(crate::t!("Preview selection during a command")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "Highlights the object under the cursor before it is picked (SELECTIONPREVIEW)." + )) + .size(11) + .width(sizing.width), + Space::new().height(24), text(crate::t!("Grip Settings")).size(15), Space::new().height(10), row![ @@ -747,6 +831,33 @@ pub fn view_window<'a>( .spacing(10) .align_y(iced::Center), Space::new().height(10), + row![ + text(crate::t!("Object limit for grips")).size(12).width(140), + // Values above the slider range remain available through SETVAR. + slider( + 0..=1000, + selection.grip_object_limit.clamp(0, 1000), + Message::GripObjectLimitChanged, + ) + .step(1) + .width(Fill), + text(if selection.grip_object_limit == 0 { + crate::t!("Unlimited").into_owned() + } else { + selection.grip_object_limit.to_string() + }) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "Past this many selected objects no grips are drawn; 0 removes the limit (GRIPOBJLIMIT)." + )) + .size(11) + .width(sizing.width), + Space::new().height(10), row![ text(crate::t!("Unselected grip color")).size(12).width(140), iced::widget::pick_list( @@ -789,10 +900,35 @@ pub fn view_window<'a>( .spacing(0) .width(sizing.width); + let drawing = column![ + text(crate::t!("Block Edit")).size(15), + Space::new().height(10), + row![ + iced::widget::checkbox(double_click_block_refedit) + .on_toggle(Message::DoubleClickBlockRefeditChanged) + .size(15), + text(crate::t!("Double-click to edit block in-place (REFEDIT)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(18), + row![ + iced::widget::checkbox(double_click_block_attedit) + .on_toggle(Message::DoubleClickBlockAtteditChanged) + .size(15), + text(crate::t!("Double-click attributed blocks to edit attributes (ATTEDIT)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + ] + .spacing(0) + .width(sizing.width); + let content: Element<'a, Message> = match active_tab { OptionsTab::General => general.into(), OptionsTab::Display => display_element.into(), OptionsTab::Selection => selection.into(), + OptionsTab::Drawing => drawing.into(), }; let tab_button = |label, tab| { @@ -806,6 +942,7 @@ pub fn view_window<'a>( tab_button(crate::t!("General"), OptionsTab::General), tab_button(crate::t!("Display"), OptionsTab::Display), tab_button(crate::t!("Selection"), OptionsTab::Selection), + tab_button(crate::t!("Drawing"), OptionsTab::Drawing), ] .spacing(6); @@ -821,10 +958,11 @@ pub fn view_window<'a>( .width(sizing.width) .height(sizing.height); + let intrinsic = sizing.width == crate::ui::modal::ModalSizing::INTRINSIC.width; container(body) .style(container::rounded_box) .padding([16, 18]) - .width(sizing.width) - .height(sizing.height) + .width(if intrinsic { iced::Length::Fixed(540.0) } else { sizing.width }) + .height(if intrinsic { iced::Length::Fixed(560.0) } else { sizing.height }) .into() } diff --git a/tests/section_plane_roundtrip.rs b/tests/section_plane_roundtrip.rs new file mode 100644 index 000000000..2f5307815 --- /dev/null +++ b/tests/section_plane_roundtrip.rs @@ -0,0 +1,110 @@ +use acadrust::entities::{EntityCommon, ExtendedEntity, ExtendedEntityData, SectionObjectData}; +use acadrust::objects::{ClassObjectData, ObjectType}; +use acadrust::types::{Color, Handle, Vector3}; +use acadrust::xdata::{ExtendedDataRecord, XDataValue}; +use acadrust::EntityType; +use OpenCADStudio::scene::Scene; + +fn add_slice(scene: &mut Scene) -> Handle { + let mut common = EntityCommon::new(); + let mut slice = ExtendedDataRecord::new("IsSlice"); + slice.values = vec![XDataValue::Integer16(0), XDataValue::Integer16(1)]; + common.extended_data.add_record(slice); + let mut thickness = ExtendedDataRecord::new("ThicknessDepth"); + thickness.values = vec![XDataValue::Real(0.75)]; + common.extended_data.add_record(thickness); + + scene.add_entity(EntityType::Extended(ExtendedEntity { + common, + data: ExtendedEntityData::SectionObject(SectionObjectData { + state: 1, + flags: 5, + name: "Section Plane (1)".to_string(), + vertical_direction: Vector3::UNIT_Z, + top_height: 5.0, + bottom_height: 5.0, + indicator_alpha: 70, + indicator_color: Color::from_index(9), + vertices: vec![Vector3::ZERO, Vector3::new(10.0, 0.0, 0.0)], + back_line_vertices: Vec::new(), + settings_handle: Handle::NULL, + }), + })) +} + +fn assert_section_graph(document: &acadrust::CadDocument, extension: &str) { + let (handle, section) = document + .entities() + .find_map(|entity| match entity { + EntityType::Extended(extended) => match &extended.data { + ExtendedEntityData::SectionObject(_) => Some((extended.common.handle, extended)), + _ => None, + }, + _ => None, + }) + .unwrap_or_else(|| panic!("section entity should round-trip through {extension}")); + let ExtendedEntityData::SectionObject(data) = §ion.data else { + unreachable!(); + }; + assert_eq!(data.state, 1); + assert!(data.back_line_vertices.is_empty()); + assert!(matches!( + section + .common + .extended_data + .get_record("IsSlice") + .map(|record| record.values.as_slice()), + Some([XDataValue::Integer16(0), XDataValue::Integer16(1)]) + )); + assert!(matches!( + section + .common + .extended_data + .get_record("ThicknessDepth") + .and_then(|record| record.values.first()), + Some(XDataValue::Real(value)) if (*value - 0.75).abs() < 1e-12 + )); + assert!(matches!( + document.objects.get(&data.settings_handle), + Some(ObjectType::ClassObject(object)) + if object.owner == handle + && matches!(&object.data, ClassObjectData::SectionSettings(_)) + )); + + let root = document + .objects + .get(&document.header.named_objects_dict_handle) + .expect("root dictionary should round-trip"); + let ObjectType::Dictionary(root) = root else { + panic!("root should be a dictionary"); + }; + let manager_handle = root + .entries + .iter() + .find(|(name, _)| name == "ACAD_SECTION_MANAGER") + .map(|(_, handle)| *handle) + .expect("section manager should be registered"); + let Some(ObjectType::ClassObject(manager)) = document.objects.get(&manager_handle) else { + panic!("section manager handle should resolve"); + }; + let ClassObjectData::SectionManager(manager) = &manager.data else { + panic!("registered object should be a section manager"); + }; + assert_eq!(manager.sections, vec![handle]); +} + +#[test] +fn section_plane_graph_survives_dxf_and_dwg_roundtrips() { + let mut scene = Scene::new(); + add_slice(&mut scene); + + for extension in ["dxf", "dwg"] { + let bytes = + OpenCADStudio::io::save_to_bytes(&scene.document, extension, scene.document.version) + .expect("section document should save"); + let document = + OpenCADStudio::io::load_bytes(&format!("section-roundtrip.{extension}"), bytes) + .expect("section document should reload"); + assert_section_graph(&document, extension); + } +} diff --git a/web/clipboard.js b/web/clipboard.js new file mode 100644 index 000000000..91ae11fb5 --- /dev/null +++ b/web/clipboard.js @@ -0,0 +1,25 @@ +export async function copyHistory(text, fallbackLabel, closeLabel) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + const dialog = document.createElement('dialog'); + const label = document.createElement('p'); + label.textContent = fallbackLabel; + const field = document.createElement('textarea'); + field.value = text; + field.readOnly = true; + field.setAttribute('aria-label', fallbackLabel); + field.style.cssText = 'width:70vw;height:50vh;display:block'; + const close = document.createElement('button'); + close.textContent = closeLabel; + close.onclick = () => dialog.close(); + dialog.append(label, field, close); + dialog.addEventListener('close', () => dialog.remove(), { once: true }); + document.body.append(dialog); + dialog.showModal(); + field.focus(); + field.select(); + return false; + } +}