From 2844299462c2641d4e33b8f1b7aabc877fb5ea47 Mon Sep 17 00:00:00 2001 From: neil heuer <> Date: Mon, 20 Apr 2026 17:09:56 -0400 Subject: [PATCH 1/6] Add Bulk Edit Rotate X / Y / Z to Layout tab Right-click with multiple models selected in the Layout tab now exposes three new items under Bulk Edit: "Rotate X", "Rotate Y", "Rotate Z". Each opens a text entry dialog pre-filled with the first unlocked selected model's current rotation for that axis, and applies the entered angle (in degrees, float OK) to every selected non-locked model. Selection is preserved across the reload by saving the tree paths before the dialog and calling ReselectTreeModels() afterward, matching the pattern used by BulkEditPixelSize and the other existing bulk-edit commands. Parsing uses std::strtod rather than std::stod per CLAUDE.md (xLights has effectively no exception handling). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.txt | 3 ++ src-ui-wx/layout/LayoutPanel.cpp | 89 ++++++++++++++++++++++++++++++++ src-ui-wx/layout/LayoutPanel.h | 7 +++ 3 files changed, 99 insertions(+) diff --git a/README.txt b/README.txt index a5ce72cd9a..e7033a6042 100644 --- a/README.txt +++ b/README.txt @@ -11,6 +11,9 @@ Issue Tracker is found here: www.github.com/xLightsSequencer/xLights/issues XLIGHTS/NUTCRACKER RELEASE NOTES --------------------------------- 2026.07 April ??, 2026 + -enh (heffneil) Layout tab: Bulk Edit menu now includes Rotate X / Rotate Y / Rotate Z items so a + rotation can be applied to every selected model in one operation, with the tree + selection preserved after the change. -enh (dkulp) Shape effect: Effect panel now organises its 27 properties into Shape / Size / Motion / Triggers tabs instead of a flat scroll, matching the grouped layout other large effects use. -enh (PB) Value curve Exponential, Logarithmic, and Parabolic types now support Start/End. diff --git a/src-ui-wx/layout/LayoutPanel.cpp b/src-ui-wx/layout/LayoutPanel.cpp index 231ab43684..476c5ed893 100644 --- a/src-ui-wx/layout/LayoutPanel.cpp +++ b/src-ui-wx/layout/LayoutPanel.cpp @@ -394,6 +394,9 @@ const long LayoutPanel::ID_PREVIEW_BULKEDIT_CONTROLLERCONNECTIONINCREMENT = wxNe const long LayoutPanel::ID_PREVIEW_BULKEDIT_SMARTREMOTETYPE = wxNewId(); const long LayoutPanel::ID_PREVIEW_BULKEDIT_PREVIEW = wxNewId(); const long LayoutPanel::ID_PREVIEW_BULKEDIT_DIMMINGCURVES = wxNewId(); +const long LayoutPanel::ID_PREVIEW_BULKEDIT_ROTATEX = wxNewId(); +const long LayoutPanel::ID_PREVIEW_BULKEDIT_ROTATEY = wxNewId(); +const long LayoutPanel::ID_PREVIEW_BULKEDIT_ROTATEZ = wxNewId(); const long LayoutPanel::ID_PREVIEW_ALIGN_TOP = wxNewId(); const long LayoutPanel::ID_PREVIEW_ALIGN_GROUND = wxNewId(); const long LayoutPanel::ID_PREVIEW_ALIGN_BOTTOM = wxNewId(); @@ -2265,6 +2268,75 @@ void LayoutPanel::BulkEditPixelSize() { } } +void LayoutPanel::BulkEditRotateX() { BulkEditRotateAxis('X'); } +void LayoutPanel::BulkEditRotateY() { BulkEditRotateAxis('Y'); } +void LayoutPanel::BulkEditRotateZ() { BulkEditRotateAxis('Z'); } + +void LayoutPanel::BulkEditRotateAxis(char axis) { + std::vector modelsToEdit = GetSelectedModelsForEdit(); + // remember the selected models so we can restore the selection after the reload + std::vector> selectedModelPaths = GetSelectedTreeModelPaths(); + + // Pre-fill the dialog with the first unlocked model's current rotation so + // the user only has to type a value if they want to change it. + float initial = 0.0f; + for (Model* model : modelsToEdit) { + if (model != nullptr && !model->GetBaseObjectScreenLocation().IsLocked()) { + switch (axis) { + case 'X': initial = model->GetBaseObjectScreenLocation().GetRotateX(); break; + case 'Y': initial = model->GetBaseObjectScreenLocation().GetRotateY(); break; + case 'Z': initial = model->GetBaseObjectScreenLocation().GetRotateZ(); break; + } + break; + } + } + + wxString title = wxString::Format("Bulk Edit Rotate %c", axis); + wxString prompt = wxString::Format("Rotate %c (degrees):", axis); + wxTextEntryDialog dlg(this, prompt, title, wxString::Format("%g", initial)); + OptimiseDialogPosition(&dlg); + if (dlg.ShowModal() != wxID_OK) { + return; + } + + // Parse the entered angle. Use strtod (not std::stod) because xLights has + // effectively no exception handling - see CLAUDE.md "Prefer std::* Over wx*". + std::string entered = dlg.GetValue().ToStdString(); + char* endp = nullptr; + double angle = std::strtod(entered.c_str(), &endp); + if (endp == entered.c_str()) { + // Nothing parsed - abort silently rather than setting 0. + return; + } + float newAngle = static_cast(angle); + + int changed = 0; + for (Model* model : modelsToEdit) { + if (model == nullptr) continue; + auto& loc = model->GetBaseObjectScreenLocation(); + if (loc.IsLocked()) continue; + switch (axis) { + case 'X': loc.SetRotateX(newAngle); break; + case 'Y': loc.SetRotateY(newAngle); break; + case 'Z': loc.SetRotateZ(newAngle); break; + } + ++changed; + } + + if (changed == 0) { + return; + } + + // Match the persistence + selection-restore pattern used by BulkEditPixelSize + // and the other bulk-edit commands: clear, reload, then reselect the tree + // rows so the user sees the same models still selected after the redraw. + xlights->GetOutputModelManager()->ClearSelectedModel(); + xlights->GetOutputModelManager()->AddImmediateWork( + OutputModelManager::WORK_RELOAD_ALLMODELS, + wxString::Format("BulkEditRotate%c", axis).ToStdString()); + ReselectTreeModels(selectedModelPaths); +} + void LayoutPanel::BulkEditPixelStyle() { std::vector modelsToEdit = GetSelectedModelsForEdit(); // remember the selected models @@ -5386,6 +5458,11 @@ void LayoutPanel::AddBulkEditOptionsToMenu(wxMenu* mnuBulkEdit) { mnuBulkEdit->Append(ID_PREVIEW_BULKEDIT_BLACKTRANSPARENCY, "Black Transparency"); mnuBulkEdit->Append(ID_PREVIEW_BULKEDIT_SHADOWMODELFOR, "Shadow Model For"); + mnuBulkEdit->AppendSeparator(); + mnuBulkEdit->Append(ID_PREVIEW_BULKEDIT_ROTATEX, "Rotate X"); + mnuBulkEdit->Append(ID_PREVIEW_BULKEDIT_ROTATEY, "Rotate Y"); + mnuBulkEdit->Append(ID_PREVIEW_BULKEDIT_ROTATEZ, "Rotate Z"); + mnuBulkEdit->AppendSeparator(); mnuBulkEdit->Append(ID_PREVIEW_BULKEDIT_CONTROLLERCONNECTION, "Controller Port"); mnuBulkEdit->Append(ID_PREVIEW_BULKEDIT_CONTROLLERCONNECTIONINCREMENT, "Controller Port and Increment"); @@ -5607,6 +5684,12 @@ void LayoutPanel::OnPreviewModelPopup(wxCommandEvent& event) BulkEditTagColour(); } else if (event.GetId() == ID_PREVIEW_BULKEDIT_PIXELSIZE) { BulkEditPixelSize(); + } else if (event.GetId() == ID_PREVIEW_BULKEDIT_ROTATEX) { + BulkEditRotateX(); + } else if (event.GetId() == ID_PREVIEW_BULKEDIT_ROTATEY) { + BulkEditRotateY(); + } else if (event.GetId() == ID_PREVIEW_BULKEDIT_ROTATEZ) { + BulkEditRotateZ(); } else if (event.GetId() == ID_PREVIEW_BULKEDIT_PIXELSTYLE) { BulkEditPixelStyle(); } else if (event.GetId() == ID_PREVIEW_BULKEDIT_TRANSPARENCY) { @@ -8427,6 +8510,12 @@ void LayoutPanel::OnModelsPopup(wxCommandEvent& event) { BulkEditTagColour(); } else if (event.GetId() == ID_PREVIEW_BULKEDIT_PIXELSIZE) { BulkEditPixelSize(); + } else if (event.GetId() == ID_PREVIEW_BULKEDIT_ROTATEX) { + BulkEditRotateX(); + } else if (event.GetId() == ID_PREVIEW_BULKEDIT_ROTATEY) { + BulkEditRotateY(); + } else if (event.GetId() == ID_PREVIEW_BULKEDIT_ROTATEZ) { + BulkEditRotateZ(); } else if (event.GetId() == ID_PREVIEW_BULKEDIT_PIXELSTYLE) { BulkEditPixelStyle(); } else if (event.GetId() == ID_PREVIEW_BULKEDIT_TRANSPARENCY) { diff --git a/src-ui-wx/layout/LayoutPanel.h b/src-ui-wx/layout/LayoutPanel.h index 1949f6e2f9..536727759a 100644 --- a/src-ui-wx/layout/LayoutPanel.h +++ b/src-ui-wx/layout/LayoutPanel.h @@ -211,6 +211,9 @@ class LayoutPanel: public wxPanel static const long ID_PREVIEW_BULKEDIT_SMARTREMOTETYPE; static const long ID_PREVIEW_BULKEDIT_PREVIEW; static const long ID_PREVIEW_BULKEDIT_DIMMINGCURVES; + static const long ID_PREVIEW_BULKEDIT_ROTATEX; + static const long ID_PREVIEW_BULKEDIT_ROTATEY; + static const long ID_PREVIEW_BULKEDIT_ROTATEZ; static const long ID_PREVIEW_ALIGN_TOP; static const long ID_PREVIEW_ALIGN_BOTTOM; static const long ID_PREVIEW_ALIGN_GROUND; @@ -403,6 +406,10 @@ class LayoutPanel: public wxPanel void BulkEditControllerPreview(); void BulkEditGroupControllerPreview(); void BulkEditDimmingCurves(); + void BulkEditRotateX(); + void BulkEditRotateY(); + void BulkEditRotateZ(); + void BulkEditRotateAxis(char axis); void ReplaceModel(); void EditSubModelAlias(); void ShowNodeLayout(); From 192cc5835a16b67f63a794a690980c676f9cd289 Mon Sep 17 00:00:00 2001 From: neil heuer <> Date: Mon, 20 Apr 2026 17:33:33 -0400 Subject: [PATCH 2/6] Add undo support to Bulk Edit Rotate X/Y/Z Snapshot the full models XML via CreateUndoPoint("All", ...) before any rotation is applied, so a single Ctrl-Z reverts the entire batch as one undo step instead of leaving models in a partially-rotated state. The snapshot is deferred until after the dialog is accepted and the value successfully parsed, so Cancel or invalid input doesn't leave a no-op entry on the undo stack. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-ui-wx/layout/LayoutPanel.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src-ui-wx/layout/LayoutPanel.cpp b/src-ui-wx/layout/LayoutPanel.cpp index 476c5ed893..918e4ba791 100644 --- a/src-ui-wx/layout/LayoutPanel.cpp +++ b/src-ui-wx/layout/LayoutPanel.cpp @@ -2310,6 +2310,15 @@ void LayoutPanel::BulkEditRotateAxis(char axis) { } float newAngle = static_cast(angle); + // Snapshot the full models XML before any changes so a single Ctrl-Z + // rolls back the entire bulk rotation as one undo step. "All" type + // captures the complete state - this is the same pattern used by + // multi-model delete / group operations elsewhere in LayoutPanel. + // We defer taking the snapshot until after the dialog is accepted and + // the value parsed, so Cancel / invalid input doesn't leave a no-op + // entry on the undo stack. + CreateUndoPoint("All", wxString::Format("BulkRotate%c", axis).ToStdString(), entered); + int changed = 0; for (Model* model : modelsToEdit) { if (model == nullptr) continue; From c42e10bfcca31a17b115f4f199f4d4ae67ca78c0 Mon Sep 17 00:00:00 2001 From: neil heuer <> Date: Mon, 20 Apr 2026 17:55:06 -0400 Subject: [PATCH 3/6] Fix selection box orientation after bulk rotate Two fixes rolled up: 1. Use WORK_SCREEN_LOCATION_CHANGE (not WORK_RELOAD_ALLMODELS) to persist the rotation change. This is the same work item Nudge() and the property-grid rotation handlers use. It re-computes transforms, redraws the preview, and reloads the property grid without tearing down and rebuilding every model - so selection survives naturally and we don't need the ClearSelectedModel / ReselectTreeModels dance. 2. After SetRotateX/Y/Z, call Reload() + Init() on the screen location. SetRotate*() only writes the raw rotatex/y/z fields; the cached rotate_quat (used by TranslatePoint, which drives the selection bounding box math) is rebuilt only inside Init() when rotation_init==true. Reload() sets that flag but nothing in the draw loop calls Init() to honor it, so the selection wireframe kept using the pre-rotation quaternion until the layout was closed and reopened. Calling both immediately makes the cache consistent so the white selection box rotates with the model on the first apply. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-ui-wx/layout/LayoutPanel.cpp | 37 +++++++++++++++++++------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src-ui-wx/layout/LayoutPanel.cpp b/src-ui-wx/layout/LayoutPanel.cpp index 918e4ba791..e4b92bdd8a 100644 --- a/src-ui-wx/layout/LayoutPanel.cpp +++ b/src-ui-wx/layout/LayoutPanel.cpp @@ -2274,8 +2274,6 @@ void LayoutPanel::BulkEditRotateZ() { BulkEditRotateAxis('Z'); } void LayoutPanel::BulkEditRotateAxis(char axis) { std::vector modelsToEdit = GetSelectedModelsForEdit(); - // remember the selected models so we can restore the selection after the reload - std::vector> selectedModelPaths = GetSelectedTreeModelPaths(); // Pre-fill the dialog with the first unlocked model's current rotation so // the user only has to type a value if they want to change it. @@ -2311,12 +2309,9 @@ void LayoutPanel::BulkEditRotateAxis(char axis) { float newAngle = static_cast(angle); // Snapshot the full models XML before any changes so a single Ctrl-Z - // rolls back the entire bulk rotation as one undo step. "All" type - // captures the complete state - this is the same pattern used by - // multi-model delete / group operations elsewhere in LayoutPanel. - // We defer taking the snapshot until after the dialog is accepted and - // the value parsed, so Cancel / invalid input doesn't leave a no-op - // entry on the undo stack. + // rolls back the entire bulk rotation as one undo step. Deferred until + // after the dialog is accepted and the value parsed, so Cancel / invalid + // input doesn't leave a no-op entry on the undo stack. CreateUndoPoint("All", wxString::Format("BulkRotate%c", axis).ToStdString(), entered); int changed = 0; @@ -2329,6 +2324,16 @@ void LayoutPanel::BulkEditRotateAxis(char axis) { case 'Y': loc.SetRotateY(newAngle); break; case 'Z': loc.SetRotateZ(newAngle); break; } + // SetRotateX/Y/Z only updates the raw rotatex/y/z fields. The cached + // rotate_quat (used by TranslatePoint, which in turn drives the + // selection bounding box math) is rebuilt only inside Init() when + // rotation_init==true. Reload() sets that flag, but nothing in the + // draw loop calls Init() to honor it - so without the explicit Init() + // here the selection wireframe keeps using the pre-rotation quaternion + // until the layout is closed and reopened (which triggers a fresh + // load-time Init). Calling both makes the cache consistent immediately. + loc.Reload(); + loc.Init(); ++changed; } @@ -2336,14 +2341,16 @@ void LayoutPanel::BulkEditRotateAxis(char axis) { return; } - // Match the persistence + selection-restore pattern used by BulkEditPixelSize - // and the other bulk-edit commands: clear, reload, then reselect the tree - // rows so the user sees the same models still selected after the redraw. - xlights->GetOutputModelManager()->ClearSelectedModel(); - xlights->GetOutputModelManager()->AddImmediateWork( - OutputModelManager::WORK_RELOAD_ALLMODELS, + // Use WORK_SCREEN_LOCATION_CHANGE (not WORK_RELOAD_ALLMODELS) to match how + // Nudge() and the property-grid rotation handlers persist position/rotation + // changes: it re-computes transforms, persists to XML, redraws the preview, + // and reloads the property grid - all without tearing down and rebuilding + // the models. That preserves selection naturally (so no ClearSelectedModel / + // ReselectTreeModels dance) and keeps the selection bounding box in sync + // with the rotated model geometry. + xlights->GetOutputModelManager()->AddASAPWork( + OutputModelManager::WORK_SCREEN_LOCATION_CHANGE, wxString::Format("BulkEditRotate%c", axis).ToStdString()); - ReselectTreeModels(selectedModelPaths); } void LayoutPanel::BulkEditPixelStyle() { From fde440d10b58288686f36ccf42fd497b3e1f7220 Mon Sep 17 00:00:00 2001 From: neil heuer <> Date: Mon, 20 Apr 2026 18:00:59 -0400 Subject: [PATCH 4/6] Address Copilot feedback: filter editable models before undo snapshot Copilot noted that CreateUndoPoint("All", ...) was executed before the loop that could determine no models would actually change. When every selected model is locked (or the selection is empty) we would leave a no-op entry on the undo stack and have paid the cost of serializing the full models XML for nothing. Restructured to: 1. Build editableModels (non-null, non-locked) from modelsToEdit 2. Early-return if editableModels is empty 3. Pre-fill the dialog from editableModels.front() directly 4. Parse the entered angle (return on cancel / unparseable input) 5. Only then take the undo snapshot 6. Apply SetRotate* to each editable model in a simple loop Also reverts the README.txt release-note entry per maintainer guidance not to modify the release notes. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.txt | 3 --- src-ui-wx/layout/LayoutPanel.cpp | 44 +++++++++++++++++--------------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/README.txt b/README.txt index e7033a6042..a5ce72cd9a 100644 --- a/README.txt +++ b/README.txt @@ -11,9 +11,6 @@ Issue Tracker is found here: www.github.com/xLightsSequencer/xLights/issues XLIGHTS/NUTCRACKER RELEASE NOTES --------------------------------- 2026.07 April ??, 2026 - -enh (heffneil) Layout tab: Bulk Edit menu now includes Rotate X / Rotate Y / Rotate Z items so a - rotation can be applied to every selected model in one operation, with the tree - selection preserved after the change. -enh (dkulp) Shape effect: Effect panel now organises its 27 properties into Shape / Size / Motion / Triggers tabs instead of a flat scroll, matching the grouped layout other large effects use. -enh (PB) Value curve Exponential, Logarithmic, and Parabolic types now support Start/End. diff --git a/src-ui-wx/layout/LayoutPanel.cpp b/src-ui-wx/layout/LayoutPanel.cpp index e4b92bdd8a..bcfc8d8eab 100644 --- a/src-ui-wx/layout/LayoutPanel.cpp +++ b/src-ui-wx/layout/LayoutPanel.cpp @@ -2275,19 +2275,28 @@ void LayoutPanel::BulkEditRotateZ() { BulkEditRotateAxis('Z'); } void LayoutPanel::BulkEditRotateAxis(char axis) { std::vector modelsToEdit = GetSelectedModelsForEdit(); - // Pre-fill the dialog with the first unlocked model's current rotation so - // the user only has to type a value if they want to change it. - float initial = 0.0f; + // Filter to only the models we can actually modify (non-null, non-locked). + // Doing this up front lets us skip the undo snapshot entirely when nothing + // would change - see Copilot review on PR #6184. + std::vector editableModels; + editableModels.reserve(modelsToEdit.size()); for (Model* model : modelsToEdit) { if (model != nullptr && !model->GetBaseObjectScreenLocation().IsLocked()) { - switch (axis) { - case 'X': initial = model->GetBaseObjectScreenLocation().GetRotateX(); break; - case 'Y': initial = model->GetBaseObjectScreenLocation().GetRotateY(); break; - case 'Z': initial = model->GetBaseObjectScreenLocation().GetRotateZ(); break; - } - break; + editableModels.push_back(model); } } + if (editableModels.empty()) { + return; + } + + // Pre-fill the dialog with the first editable model's current rotation so + // the user only has to type a value if they want to change it. + float initial = 0.0f; + switch (axis) { + case 'X': initial = editableModels.front()->GetBaseObjectScreenLocation().GetRotateX(); break; + case 'Y': initial = editableModels.front()->GetBaseObjectScreenLocation().GetRotateY(); break; + case 'Z': initial = editableModels.front()->GetBaseObjectScreenLocation().GetRotateZ(); break; + } wxString title = wxString::Format("Bulk Edit Rotate %c", axis); wxString prompt = wxString::Format("Rotate %c (degrees):", axis); @@ -2309,16 +2318,14 @@ void LayoutPanel::BulkEditRotateAxis(char axis) { float newAngle = static_cast(angle); // Snapshot the full models XML before any changes so a single Ctrl-Z - // rolls back the entire bulk rotation as one undo step. Deferred until - // after the dialog is accepted and the value parsed, so Cancel / invalid - // input doesn't leave a no-op entry on the undo stack. + // rolls back the entire bulk rotation as one undo step. Taken only after + // we know we have at least one editable model and a parseable angle, so + // Cancel / invalid input / all-locked selections don't leave a no-op + // entry on the undo stack. CreateUndoPoint("All", wxString::Format("BulkRotate%c", axis).ToStdString(), entered); - int changed = 0; - for (Model* model : modelsToEdit) { - if (model == nullptr) continue; + for (Model* model : editableModels) { auto& loc = model->GetBaseObjectScreenLocation(); - if (loc.IsLocked()) continue; switch (axis) { case 'X': loc.SetRotateX(newAngle); break; case 'Y': loc.SetRotateY(newAngle); break; @@ -2334,11 +2341,6 @@ void LayoutPanel::BulkEditRotateAxis(char axis) { // load-time Init). Calling both makes the cache consistent immediately. loc.Reload(); loc.Init(); - ++changed; - } - - if (changed == 0) { - return; } // Use WORK_SCREEN_LOCATION_CHANGE (not WORK_RELOAD_ALLMODELS) to match how From 7c5d2b4a9c764390e2c38376bcb3e6a8401902ca Mon Sep 17 00:00:00 2001 From: neil heuer <> Date: Mon, 20 Apr 2026 18:55:19 -0400 Subject: [PATCH 5/6] Validate rotation input and pass real model name to undo Two Copilot findings from the last review pass: 1. Input validation: std::strtod happily parses "nan" / "inf" and any numeric string including out-of-range values like 720. Non-finite would propagate garbage into the transform matrices; out-of-range values get silently reset to 0 by BoxedScreenLocation::Init() - so a user typing 200 would lose their input without warning. Reject !std::isfinite() and show a clear wxMessageBox for values outside [-180, 180] (the same range enforced by the rotation property grid via Min/Max attributes in ScreenLocationPropertyHelper). 2. Undo selection hint: CreateUndoPoint("All", ...) stores the second argument into undoBuffer[idx].model, which DoUndo() later passes as the "selectedModel" hint to AddASAPWork (LayoutPanel.cpp:8209). A label like "BulkRotateX" is not a real model name and would cause post-undo selection logic to resolve a nonexistent model. Pass editableModels.front()->name instead, and move the operation label to the key field. Added include explicitly for std::isfinite so the build holds up under NO_PCH per CLAUDE.md guidance. Co-Authored-By: Claude Opus 4.7 (1M context) --- src-ui-wx/layout/LayoutPanel.cpp | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src-ui-wx/layout/LayoutPanel.cpp b/src-ui-wx/layout/LayoutPanel.cpp index bcfc8d8eab..d904d5c33e 100644 --- a/src-ui-wx/layout/LayoutPanel.cpp +++ b/src-ui-wx/layout/LayoutPanel.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -2311,18 +2312,38 @@ void LayoutPanel::BulkEditRotateAxis(char axis) { std::string entered = dlg.GetValue().ToStdString(); char* endp = nullptr; double angle = std::strtod(entered.c_str(), &endp); - if (endp == entered.c_str()) { - // Nothing parsed - abort silently rather than setting 0. + if (endp == entered.c_str() || !std::isfinite(angle)) { + // Unparseable or NaN/Inf - abort silently rather than letting garbage + // propagate into transform matrices. + return; + } + // Match the [-180, 180] range enforced by the rotation property grid + // (see ScreenLocationPropertyHelper.cpp RotateX/Y/Z Min/Max). Out-of-range + // values are silently reset to 0 by BoxedScreenLocation::Init(), so reject + // them here with a clear message rather than letting the user lose their + // change without warning. + if (angle < -180.0 || angle > 180.0) { + wxMessageBox( + wxString::Format("Rotation angle must be between -180 and 180 degrees (got %g).", angle), + "Invalid rotation angle", wxOK | wxICON_WARNING, this); return; } float newAngle = static_cast(angle); // Snapshot the full models XML before any changes so a single Ctrl-Z // rolls back the entire bulk rotation as one undo step. Taken only after - // we know we have at least one editable model and a parseable angle, so - // Cancel / invalid input / all-locked selections don't leave a no-op + // we know we have at least one editable model and a valid in-range angle, + // so Cancel / invalid input / all-locked selections don't leave a no-op // entry on the undo stack. - CreateUndoPoint("All", wxString::Format("BulkRotate%c", axis).ToStdString(), entered); + // + // The second CreateUndoPoint argument is used later in DoUndo() as the + // "selectedModel" hint passed to AddASAPWork, so it must be an actual + // model name rather than an operation label - otherwise post-undo + // selection logic would try to resolve a nonexistent model. Operation + // context goes in the key/data fields. + CreateUndoPoint("All", editableModels.front()->name, + wxString::Format("BulkRotate%c", axis).ToStdString(), + entered); for (Model* model : editableModels) { auto& loc = model->GetBaseObjectScreenLocation(); From 9543049304a482ff7a25c6d2894299c556297e40 Mon Sep 17 00:00:00 2001 From: Daryl <4643499+derwin12@users.noreply.github.com> Date: Wed, 20 May 2026 16:31:12 -0400 Subject: [PATCH 6/6] Clean up comments in BulkEditRotateAxis function Removed comments to streamline the BulkEditRotateAxis function. --- src-ui-wx/layout/LayoutPanel.cpp | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/src-ui-wx/layout/LayoutPanel.cpp b/src-ui-wx/layout/LayoutPanel.cpp index d904d5c33e..c8a41e9d93 100644 --- a/src-ui-wx/layout/LayoutPanel.cpp +++ b/src-ui-wx/layout/LayoutPanel.cpp @@ -2276,9 +2276,6 @@ void LayoutPanel::BulkEditRotateZ() { BulkEditRotateAxis('Z'); } void LayoutPanel::BulkEditRotateAxis(char axis) { std::vector modelsToEdit = GetSelectedModelsForEdit(); - // Filter to only the models we can actually modify (non-null, non-locked). - // Doing this up front lets us skip the undo snapshot entirely when nothing - // would change - see Copilot review on PR #6184. std::vector editableModels; editableModels.reserve(modelsToEdit.size()); for (Model* model : modelsToEdit) { @@ -2330,17 +2327,6 @@ void LayoutPanel::BulkEditRotateAxis(char axis) { } float newAngle = static_cast(angle); - // Snapshot the full models XML before any changes so a single Ctrl-Z - // rolls back the entire bulk rotation as one undo step. Taken only after - // we know we have at least one editable model and a valid in-range angle, - // so Cancel / invalid input / all-locked selections don't leave a no-op - // entry on the undo stack. - // - // The second CreateUndoPoint argument is used later in DoUndo() as the - // "selectedModel" hint passed to AddASAPWork, so it must be an actual - // model name rather than an operation label - otherwise post-undo - // selection logic would try to resolve a nonexistent model. Operation - // context goes in the key/data fields. CreateUndoPoint("All", editableModels.front()->name, wxString::Format("BulkRotate%c", axis).ToStdString(), entered); @@ -2352,25 +2338,10 @@ void LayoutPanel::BulkEditRotateAxis(char axis) { case 'Y': loc.SetRotateY(newAngle); break; case 'Z': loc.SetRotateZ(newAngle); break; } - // SetRotateX/Y/Z only updates the raw rotatex/y/z fields. The cached - // rotate_quat (used by TranslatePoint, which in turn drives the - // selection bounding box math) is rebuilt only inside Init() when - // rotation_init==true. Reload() sets that flag, but nothing in the - // draw loop calls Init() to honor it - so without the explicit Init() - // here the selection wireframe keeps using the pre-rotation quaternion - // until the layout is closed and reopened (which triggers a fresh - // load-time Init). Calling both makes the cache consistent immediately. loc.Reload(); loc.Init(); } - // Use WORK_SCREEN_LOCATION_CHANGE (not WORK_RELOAD_ALLMODELS) to match how - // Nudge() and the property-grid rotation handlers persist position/rotation - // changes: it re-computes transforms, persists to XML, redraws the preview, - // and reloads the property grid - all without tearing down and rebuilding - // the models. That preserves selection naturally (so no ClearSelectedModel / - // ReselectTreeModels dance) and keeps the selection bounding box in sync - // with the rotated model geometry. xlights->GetOutputModelManager()->AddASAPWork( OutputModelManager::WORK_SCREEN_LOCATION_CHANGE, wxString::Format("BulkEditRotate%c", axis).ToStdString());