diff --git a/CMakeLists.txt b/CMakeLists.txt index 07a982e..75b8475 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -652,7 +652,7 @@ source_group("web" FILES ${WEB_FILES_REL}) # Root documentation (README.md, CHANGELOG.md) and docs/ folder contents are made # visible in the IDE via the modern glob + source_group block near the end of this -# file (consistent with agents/, github-workflows, res/, scripts/). See below. +# file (consistent with agents/, github-workflows, res/, scripts/, tools/). See below. # Add Google Test only for non-Emscripten builds if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "Emscripten") @@ -735,6 +735,22 @@ if(EZYCAD_SCRIPT_FILES) set_source_files_properties(${EZYCAD_SCRIPT_FILES_REL} PROPERTIES HEADER_FILE_ONLY TRUE) endif() +# Dev tools: show tools/ in the IDE (codegen, asset helpers; not compiled) +file(GLOB EZYCAD_TOOL_FILES CONFIGURE_DEPENDS + "${CMAKE_SOURCE_DIR}/tools/*.py" + "${CMAKE_SOURCE_DIR}/tools/*.ps1") +if(EZYCAD_TOOL_FILES) + source_group(TREE "${CMAKE_SOURCE_DIR}/tools" PREFIX "tools" FILES ${EZYCAD_TOOL_FILES}) + set(EZYCAD_TOOL_FILES_REL) + foreach(f ${EZYCAD_TOOL_FILES}) + file(RELATIVE_PATH f_rel "${CMAKE_SOURCE_DIR}" "${f}") + list(APPEND EZYCAD_TOOL_FILES_REL "${f_rel}") + endforeach() + target_sources(${PROJECT_NAME}_lib PRIVATE ${EZYCAD_TOOL_FILES_REL}) + target_sources(${PROJECT_NAME} PRIVATE ${EZYCAD_TOOL_FILES_REL}) + set_source_files_properties(${EZYCAD_TOOL_FILES_REL} PROPERTIES HEADER_FILE_ONLY TRUE) +endif() + # GitHub workflow files: show .github/workflows/*.yml in the IDE under 'github-workflows' folder # (so they are visible in Visual Studio Solution Explorer etc. without being compiled) file(GLOB_RECURSE GITHUB_WORKFLOW_FILES CONFIGURE_DEPENDS @@ -772,7 +788,7 @@ endif() # Documentation folder: show docs/ (and the moved usage guides, style guides, building-occt.md etc.) # in the IDE under a 'docs' folder for both the library and executable targets. -# (non-compiled; uses the same pattern as agents/, github-workflows, res/, scripts/) +# (non-compiled; uses the same pattern as agents/, github-workflows, res/, scripts/, tools/) file(GLOB_RECURSE EZYCAD_DOCS_FILES CONFIGURE_DEPENDS LIST_DIRECTORIES false "${CMAKE_SOURCE_DIR}/docs/*") diff --git a/agents/issues/017-sketch-undo-stable-ids.md b/agents/issues/017-sketch-undo-stable-ids.md new file mode 100644 index 0000000..c924b18 --- /dev/null +++ b/agents/issues/017-sketch-undo-stable-ids.md @@ -0,0 +1,88 @@ +# Sketch undo: stable ids, operation-axis undo, stack budget + +**Suggested labels:** `bug`, `sketch`, `enhancement` + +**Branch:** `Trailcode/sketch-undo-stable-ids` (base: `main`) + +--- + +## Title (GitHub) + +Sketch undo: stable sketch ids for delta resolve, operation-axis undo, and undo stack byte budget + +## Body (GitHub) + +### Summary + +Harden sketch delta undo/redo on branch **`Trailcode/sketch-undo-stable-ids`**: assign each sketch a stable persisted **id** for `Sketch_delta` lookup (display names may duplicate), fix operation-axis undo/clear and OCCT handle teardown, add undo-stack hygiene (entry kind + byte budget), and regression tests including a GUI-driven undo/redo path. + +### Problem + +- **Release crash on redo:** `Sketch_delta` cached a raw `Sketch*` and returned it without validation; after JSON reload or sketch recreation the pointer was dangling (`add_edge_impl_` read access violation). +- **Name-based resolve was fragile:** duplicate sketch names (rename UI, `"Sketch from face"`, load) could target the wrong sketch silently. +- **Operation axis undo incomplete:** Clear axis was not undoable; redo-after-clear could leave a stale axis; OCCT `Standard_ProgramError` when clearing axis if AIS handle was not nulled after `Remove`. +- **Undo stack memory:** 50 full JSON snapshots could dominate memory; no byte budget or debug footprint summary. + +### Implemented scope + +**Stable sketch ids** + +- `Sketch::get_id()` / `uint64_t m_id`; allocated via `Occt_view::allocate_sketch_id()`. +- Persisted in sketch JSON as `"id"`; backward compatible when field missing. +- `Sketch_delta` resolves target sketch by **id**, not name. + +**Operation axis undo** + +- `clear_operation_axis_undoable()` for Options **Clear axis**; records prev axis in delta. +- `finalize_operation_axis_` records axis endpoint nodes for undo tombstone. +- `apply_forward_` clears axis when redoing a clear step. +- `clear_operation_axis()`: `Nullify()` AIS handle after `Remove`. + +**Undo stack hygiene** + +- `Undo_entry_kind`: `Sketch_delta` vs `Document_snapshot`. +- `k_max_undo_bytes` (32 MB): trim oldest document snapshots first when over budget. +- Debug panel shows undo/redo step count and total MB (Debug builds only). + +**Tests** + +- `Gui_UndoRedo_edge_then_operation_axis_four_undo_two_redo` (GUI path). +- `Undo_delta_resolves_sketch_by_id_after_reload`. +- Existing undo tests use `curr_sketch()` (document-owned sketches). + +### Acceptance criteria + +- [ ] Add edge + operation axis, undo/redo repeatedly — no crash (Release). +- [ ] Clear axis is undoable; redo clear works. +- [ ] JSON save/load preserves sketch `id`; redo after reload applies to correct sketch. +- [ ] Duplicate sketch display names do not break delta undo. +- [ ] `EzyCad_tests`: `Sketch_test.Undo*`, `Sketch_test.Gui_UndoRedo*`. + +### Files touched + +- `src/delta.h`, `src/sketch.h`, `src/sketch.cpp`, `src/sketch_delta.h`, `src/sketch_delta.cpp`, `src/sketch_json.cpp` +- `src/occt_view.h`, `src/occt_view.cpp`, `src/gui.cpp`, `src/gui_mode.cpp` +- `tests/sketch_tests.cpp`, `docs/bugs.md` +- `agents/issues/017-sketch-undo-stable-ids.md`, `agents/prs/012-sketch-undo-stable-ids.md` + +### Related + +- Builds on sketch delta undo (#144 / #145). +- Compare: https://github.com/trailcode/EzyCad/compare/main...Trailcode/sketch-undo-stable-ids +- GitHub issue: https://github.com/trailcode/EzyCad/issues/150 +- GitHub PR: https://github.com/trailcode/EzyCad/pull/151 + +### Test plan + +- [ ] Build Release: `EzyCad`, `EzyCad_tests`. +- [ ] Run: `EzyCad_tests.exe --gtest_filter=Sketch_test.Undo*:Sketch_test.Gui_UndoRedo*` +- [ ] Manual: edge + operation axis; undo 4x, redo 2x (former Release crash). +- [ ] Manual: Clear axis → undo → redo. +- [ ] Manual: save `.ezy`, reload, redo sketch delta step. + +--- + +**Post-creation notes:** + +- GitHub issue: https://github.com/trailcode/EzyCad/issues/150 +- GitHub PR: https://github.com/trailcode/EzyCad/pull/151 diff --git a/agents/prs/012-sketch-undo-stable-ids.md b/agents/prs/012-sketch-undo-stable-ids.md new file mode 100644 index 0000000..8935668 --- /dev/null +++ b/agents/prs/012-sketch-undo-stable-ids.md @@ -0,0 +1,38 @@ +# PR - `Trailcode/sketch-undo-stable-ids` + +## Title + +Sketch undo: stable sketch ids for delta resolve, operation-axis undo, and undo stack byte budget + +## Summary + +- **Stable sketch ids:** Each sketch gets a monotonic `uint64_t` id (persisted in JSON). `Sketch_delta` resolves the target sketch by id, not display name — fixes Release redo crash from dangling `Sketch*` after reload/recreation. +- **Operation axis undo:** `clear_operation_axis_undoable()` for Options **Clear axis**; axis node recording on finalize; redo-clear via `apply_forward_`. OCCT handle `Nullify()` after `Remove` in `clear_operation_axis()`. +- **Undo stack hygiene:** `Undo_entry_kind`, per-entry `footprint_bytes`, 32 MB cap trimming oldest document snapshots first; debug panel shows stack MB (Debug builds). +- **Tests:** GUI undo/redo regression; reload-by-id redo test; undo tests on `curr_sketch()`. + +Closes sketch undo stability work tracked in `agents/issues/017-sketch-undo-stable-ids.md`. + +## Files Changed + +12 files (+325 / -64 vs `main`): `src/sketch*.cpp/h`, `src/sketch_delta.*`, `src/sketch_json.cpp`, `src/occt_view.*`, `src/delta.h`, `src/gui.cpp`, `src/gui_mode.cpp`, `tests/sketch_tests.cpp`, `docs/bugs.md`. + +## Related + +- Issue: https://github.com/trailcode/EzyCad/issues/150 +- PR: https://github.com/trailcode/EzyCad/pull/151 +- Compare: https://github.com/trailcode/EzyCad/compare/main...Trailcode/sketch-undo-stable-ids +- Prior: #145 sketch delta undo + +## Test Plan + +- [x] Build Release: `EzyCad_tests`. +- [x] Run: `EzyCad_tests.exe --gtest_filter=Sketch_test.Undo*:Sketch_test.Gui_UndoRedo*` +- [ ] Manual: edge + operation axis; undo 4x, redo 2x (Release — former crash). +- [ ] Manual: Clear axis → undo → redo. +- [ ] Manual: save document, reload, redo sketch delta. + +## Notes + +- Old `.ezy` files without `"id"` allocate new ids on load (backward compatible). +- `approximate_undo_bytes()` is internal; user sees summed MB only in Debug **dbg** window. diff --git a/docs/usage-settings.md b/docs/usage-settings.md index d349280..e312a22 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -47,7 +47,7 @@ Between those, the pane has **six** collapsible sections. Expand a section to se 3. **View presentation** — **Background color 1** and **Background color 2** (float RGB fields and swatches). **Gradient blend** — combo: **Horizontal**, **Vertical**, **Diagonal 1**, **Diagonal 2**, **Corner 1** … **Corner 4**. **Element hover color** — highlight for rows hovered in the **Shape List** or **Sketch List** (**Dimensions** table); stored as **`gui.elm_list_hover_color`**. -4. **3D view grid** — **Show grid** (checkbox; grid is drawn on the **active sketch** plane). **Fine grid lines** and **Major grid lines** (passed to Open CASCADE `Aspect_Grid::SetColors`: dense lines vs every-tenth emphasis lines). **Grid step**, **Grid extent X / Y** (full span edge-to-edge), and **Grid display Z offset** in the Settings pane use the **same length scale as sketch length dimensions** (display value = model value / internal `dimension_scale`, default **100**). Saved JSON (`occt_view`) stores **half-extent** in model units for OCCT (`grid_graphic_*`); Settings shows **full** extent (twice the stored half-extent). +4. **3D view grid** — **Show grid** (checkbox; grid is drawn on the **active sketch** plane, behind coplanar geometry). **Fine grid lines** and **Major grid lines** (dense lines vs every-tenth emphasis). **Grid step**, **Grid padding** (margin around sketch content when sizing the grid), and **Grid display Z offset** in the Settings pane use the **same length scale as sketch length dimensions** (display value = model value / internal `dimension_scale`, default **100**). Saved JSON (`occt_view`) stores padding in model units (`grid_padding`). 5. **Sketch** — Expand **Dimensions** (nested, open by default) for length-dimension appearance and behavior (most rows have **?** help). Other sketch rows stay in the parent **Sketch** section: - **Dimension line width** — slider **0.5** to **8.0** @@ -148,9 +148,8 @@ String: ImGui `.ini` text for window positions and docking saved with **SaveIniS | `grid_color1` | array of 3 numbers | Fine (dense) grid lines (`Aspect_Grid` main color). | | `grid_color2` | array of 3 numbers | Major (sparse / every-tenth) grid lines (`Aspect_Grid` tenth-line color). | | `grid_visible` | boolean | When **true**, the OCCT reference grid is drawn in the 3D view (default **true**). | -| `grid_step` | number | Grid line spacing in **model** units (`Aspect_RectangularGrid`; default **10**). Legacy `grid_x_step` / `grid_y_step` load as this value (X preferred). | -| `grid_graphic_x_size` | number | Grid half-extent on X in **model** units (OCCT `SetGraphicValues`; default **1000**). Settings **Grid extent X** shows full span (**20** with those defaults). | -| `grid_graphic_y_size` | number | Grid half-extent on Y in **model** units. Settings **Grid extent Y** shows full span. | +| `grid_step` | number | Grid line spacing in **model** units (default **10**). Legacy `grid_x_step` / `grid_y_step` load as this value (X preferred). | +| `grid_padding` | number | Margin around active sketch content when sizing the grid, in **model** units (default **1000**; Settings shows display units). Legacy `grid_graphic_x_size` loads as padding if `grid_padding` is absent. | | `grid_graphic_z_offset` | number | Grid plane offset along Z in **model** units. | ### `gui` diff --git a/res/ezycad_settings.json b/res/ezycad_settings.json index bf24d0b..ea7933c 100644 --- a/res/ezycad_settings.json +++ b/res/ezycad_settings.json @@ -74,8 +74,7 @@ 0.11791712790727615, 0.13513511419296265 ], - "grid_graphic_x_size": 1000, - "grid_graphic_y_size": 1000, + "grid_padding": 1000, "grid_graphic_z_offset": 0, "grid_step": 10, "grid_visible": true diff --git a/src/gui_settings.cpp b/src/gui_settings.cpp index d52455c..5e81d80 100644 --- a/src/gui_settings.cpp +++ b/src/gui_settings.cpp @@ -38,8 +38,7 @@ nlohmann::json build_occt_view_settings_object(const Occt_view& view) {"grid_color1", {g1[0], g1[1], g1[2]}}, {"grid_color2", {g2[0], g2[1], g2[2]}}, {"grid_step", grid_rect.step}, - {"grid_graphic_x_size", grid_rect.graphic_x_size}, - {"grid_graphic_y_size", grid_rect.graphic_y_size}, + {"grid_padding", grid_rect.grid_padding}, {"grid_graphic_z_offset", grid_rect.graphic_z_offset}, {"grid_visible", view.get_grid_visible()}, }; @@ -228,16 +227,20 @@ void GUI::parse_occt_view_settings_(const std::string& content) else if (ov.contains("grid_y_step")) apply_num("grid_y_step", grid_rect.step); } - apply_num("grid_graphic_x_size", grid_rect.graphic_x_size); - apply_num("grid_graphic_y_size", grid_rect.graphic_y_size); + apply_num("grid_padding", grid_rect.grid_padding); + if (!ov.contains("grid_padding") && ov.contains("grid_graphic_x_size")) + { + // Legacy: treat old half-extent as padding margin around sketch content. + apply_num("grid_graphic_x_size", grid_rect.grid_padding); + } apply_num("grid_graphic_z_offset", grid_rect.graphic_z_offset); m_view->set_occt_grid_rect_params(grid_rect); bool grid_visible = m_view->get_grid_visible(); if (ov.contains("grid_visible") && ov["grid_visible"].is_boolean()) grid_visible = ov["grid_visible"].get(); - // Always apply: default m_grid_visible may already match JSON, but the OCCT grid - // is not activated until apply_grid_visibility_() runs. + // Always apply: default m_grid_visible may already match JSON, but the shader grid + // is not displayed until apply_grid_visibility_() runs. m_view->set_grid_visible(grid_visible); } catch (...) @@ -796,10 +799,8 @@ void GUI::settings_() m_view->get_occt_grid_rect_params(gr); const double dim_scale = m_view->get_dimension_scale(); // Settings show the same length units as sketch dimensions (model / dimension_scale). - // Grid extent UI is full span; OCCT SetGraphicValues uses half-extent (x0.5 on apply). double step_ui = gr.step / dim_scale; - double graphic_x_ui = (gr.graphic_x_size * 2.0) / dim_scale; - double graphic_y_ui = (gr.graphic_y_size * 2.0) / dim_scale; + double padding_ui = gr.grid_padding / dim_scale; double graphic_z_off_ui = gr.graphic_z_offset / dim_scale; bool grid_changed = false; bool geom_changed = false; @@ -848,31 +849,23 @@ void GUI::settings_() ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted("Grid step"); ImGui::TableSetColumnIndex(1); - if (ImGui::DragScalar("##gstep", ImGuiDataType_Double, &step_ui, spd_s, nullptr, nullptr, "%.8g")) - geom_changed = true; - - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Grid extent X"); - ImGui::TableSetColumnIndex(1); - if (ImGui::DragScalar("##ggx", ImGuiDataType_Double, &graphic_x_ui, spd_extent, nullptr, nullptr, "%.8g")) + // Step must stay positive; grid extent is derived from sketch bounds + padding. + const double step_min_ui = 1e-6; + if (ImGui::DragScalar("##gstep", ImGuiDataType_Double, &step_ui, spd_s, &step_min_ui, nullptr, "%.8g")) geom_changed = true; - ImGui::SameLine(0.f, ImGui::GetStyle().ItemInnerSpacing.x); - GUI_DOC_HELP_("Full width of the drawn grid on X (edge to edge through the center). Same length scale as sketch " - "dimensions. OCCT uses half this value internally. Click ? to open the user guide.", - doc_urls::k_occt_view); ImGui::TableNextRow(); ImGui::TableSetColumnIndex(0); ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Grid extent Y"); + ImGui::TextUnformatted("Grid padding"); ImGui::TableSetColumnIndex(1); - if (ImGui::DragScalar("##ggy", ImGuiDataType_Double, &graphic_y_ui, spd_extent, nullptr, nullptr, "%.8g")) + const double padding_min_ui = 0.0; + if (ImGui::DragScalar("##gpad", ImGuiDataType_Double, &padding_ui, spd_extent, &padding_min_ui, nullptr, "%.8g")) geom_changed = true; ImGui::SameLine(0.f, ImGui::GetStyle().ItemInnerSpacing.x); - GUI_DOC_HELP_("Full height of the drawn grid on Y (edge to edge through the center). OCCT uses half this value " - "internally. Click ? to open the user guide.", + GUI_DOC_HELP_("Margin added around the active sketch when sizing the grid (same length scale as sketch " + "dimensions). The grid extent follows sketch geometry plus this padding. Click ? to open the user " + "guide.", doc_urls::k_occt_view); ImGui::TableNextRow(); @@ -890,10 +883,13 @@ void GUI::settings_() if (geom_changed) { gr.step = step_ui * dim_scale; - gr.graphic_x_size = graphic_x_ui * dim_scale * 0.5; - gr.graphic_y_size = graphic_y_ui * dim_scale * 0.5; + gr.grid_padding = padding_ui * dim_scale; gr.graphic_z_offset = graphic_z_off_ui * dim_scale; m_view->set_occt_grid_rect_params(gr); + m_view->get_occt_grid_rect_params(gr); + step_ui = gr.step / dim_scale; + padding_ui = gr.grid_padding / dim_scale; + graphic_z_off_ui = gr.graphic_z_offset / dim_scale; } if (grid_changed || geom_changed) save_occt_view_settings(); diff --git a/src/occt_view.cpp b/src/occt_view.cpp index 3455b36..0aa340e 100644 --- a/src/occt_view.cpp +++ b/src/occt_view.cpp @@ -3,8 +3,8 @@ #include #include #include +#include #include -#include #include #include #include @@ -221,7 +221,7 @@ void Occt_view::init_viewer() // m_view->SetScale(39.3701); capture_occt_grid_rect_from_viewer_(aViewer); - // Grid visibility/colors set in apply_grid_visibility_() / update_view_background_() + // Grid visibility/colors: shader background grid via apply_grid_visibility_() / apply_shader_grid_display_() // aViewer->SetGridEcho(true); // aViewer->Grid()->SetDrawMode(Aspect_GridDrawMode::Aspect_GDM_Points); @@ -1163,6 +1163,130 @@ void Occt_view::delete_(std::vector& to_delete) remove(to_delete); } +namespace +{ + +Aspect_GridParams build_shader_grid_params_(const Occt_grid_rect_params& g, const glm::vec3& color1, const glm::vec3& color2, + const double size_x, const double size_y) +{ + Aspect_GridParams params; + const double step = g.step > 0.0 ? g.step : 10.0; + params.SetColor(Quantity_Color(color1.x, color1.y, color1.z, Quantity_TOC_RGB)); + params.SetAccentColor(Quantity_Color(color2.x, color2.y, color2.z, Quantity_TOC_RGB)); + + // Fixed metric spacing (cells per model unit). Every 10th line uses the accent color. + params.SetScale(1.0 / step); + params.SetScaleY(0.0); // isotropic (mirror Scale) + params.SetAccentScaleX(1.0 / (step * 10.0)); + params.SetAccentScaleY(1.0 / (step * 10.0)); + + // Bounded grid on the sketch plane (full extent along each axis; OCCT uses half in the shader). + params.SetSizeX(size_x); + params.SetSizeY(size_y); + + // Overlay on the sketch plane (not view-space background). Slight negative offset along the + // plane normal so coplanar sketch geometry wins the depth test (OCCT default is step / 50). + params.SetZOffset(g.graphic_z_offset - step / 50.0); + params.SetDrawMode(Aspect_GDM_Lines); + params.SetIsBackground(false); + params.SetIsDrawAxis(false); + params.SetIsViewAdaptive(false); + return params; +} + +} // namespace + +Occt_view::Shader_grid_layout Occt_view::compute_shader_grid_layout_() const +{ + Shader_grid_layout layout{}; + const gp_Pln ref_pln = m_cur_sketch ? m_cur_sketch->get_plane() : gp_Pln(grid_display_plane_()); + const gp_Ax3 base_ax = ref_pln.Position(); + + const double step = m_occt_grid_rect.step > 0.0 ? m_occt_grid_rect.step : 10.0; + const double pad = m_occt_grid_rect.grid_padding >= 0.0 ? m_occt_grid_rect.grid_padding : 0.0; + + auto expand_pt = [](double& min_u, double& min_v, double& max_u, double& max_v, bool& any, const gp_Pnt2d& pt) + { + const double u = pt.X(); + const double v = pt.Y(); + if (!any) + { + min_u = max_u = u; + min_v = max_v = v; + any = true; + } + else + { + min_u = std::min(min_u, u); + max_u = std::max(max_u, u); + min_v = std::min(min_v, v); + max_v = std::max(max_v, v); + } + }; + + bool has_bounds = false; + double min_u = 0., min_v = 0., max_u = 0., max_v = 0.; + + if (m_cur_sketch) + { + for (const Sketch_nodes::Node& node : m_cur_sketch->get_nodes()) + { + if (node.deleted) + continue; + expand_pt(min_u, min_v, max_u, max_v, has_bounds, node); + } + } + + if (!has_bounds && !is_headless()) + { + const ImGuiIO& io = ImGui::GetIO(); + if (sketch_plane_view_aabb_2d(ref_pln, static_cast(io.DisplaySize.x), static_cast(io.DisplaySize.y), + min_u, min_v, max_u, max_v)) + has_bounds = true; + } + + if (!has_bounds) + { + const double half = std::max(step * 5.0, 1.0); + min_u = -half; + max_u = half; + min_v = -half; + max_v = half; + } + + min_u -= pad; + max_u += pad; + min_v -= pad; + max_v += pad; + + // Snap outer bounds to grid lines so edges align with major/minor spacing. + min_u = std::floor(min_u / step) * step; + max_u = std::ceil(max_u / step) * step; + min_v = std::floor(min_v / step) * step; + max_v = std::ceil(max_v / step) * step; + + constexpr double k_min_span = 1e-6; + if (max_u - min_u < k_min_span) + { + min_u -= step; + max_u += step; + } + if (max_v - min_v < k_min_span) + { + min_v -= step; + max_v += step; + } + + const double center_u = (min_u + max_u) * 0.5; + const double center_v = (min_v + max_v) * 0.5; + layout.size_x = max_u - min_u; + layout.size_y = max_v - min_v; + + const gp_Pnt center_3d = to_3d(ref_pln, gp_Pnt2d(center_u, center_v)); + layout.plane = gp_Ax3(center_3d, base_ax.Direction(), base_ax.XDirection()); + return layout; +} + static Aspect_GradientFillMethod gradient_method_from_int(int i) { static const Aspect_GradientFillMethod methods[] = {Aspect_GFM_HOR, Aspect_GFM_VER, Aspect_GFM_DIAG1, @@ -1182,14 +1306,6 @@ void Occt_view::update_view_background_() m_view->SetBgGradientColors(Quantity_Color(m_bg_color1.x, m_bg_color1.y, m_bg_color1.z, Quantity_TOC_RGB), Quantity_Color(m_bg_color2.x, m_bg_color2.y, m_bg_color2.z, Quantity_TOC_RGB), gradient_method_from_int(m_bg_gradient_method), true); - - Handle(V3d_Viewer) viewer = m_view->Viewer(); - if (!viewer.IsNull() && !viewer->Grid().IsNull()) - { - Quantity_Color cc(m_grid_color1.x, m_grid_color1.y, m_grid_color1.z, Quantity_TOC_RGB); - Quantity_Color cd(m_grid_color2.x, m_grid_color2.y, m_grid_color2.z, Quantity_TOC_RGB); - viewer->Grid()->SetColors(cc, cd); - } } void Occt_view::get_bg_gradient_colors(float color1[3], float color2[3]) const @@ -1235,11 +1351,23 @@ void Occt_view::set_grid_colors(float r1, float g1, float b1, float r2, float g2 Occt_grid_rect_params Occt_view::clamp_occt_grid_rect_params_(Occt_grid_rect_params g) { - constexpr double min_step = 1e-9; - constexpr double min_graphic = 1e-6; - g.step = std::max(g.step, min_step); - g.graphic_x_size = std::max(g.graphic_x_size, min_graphic); - g.graphic_y_size = std::max(g.graphic_y_size, min_graphic); + constexpr double min_padding = 0.0; + constexpr double min_step = 1e-6; + constexpr double default_step = 10.0; + constexpr double default_padding = 1000.0; + + if (!std::isfinite(g.grid_padding) || g.grid_padding < 0.0) + g.grid_padding = default_padding; + if (!std::isfinite(g.graphic_z_offset)) + g.graphic_z_offset = 0.0; + + g.grid_padding = std::max(g.grid_padding, min_padding); + + if (!std::isfinite(g.step) || g.step <= 0.0) + g.step = default_step; + + g.step = std::max(g.step, min_step); + return g; } @@ -1250,22 +1378,42 @@ void Occt_view::capture_occt_grid_rect_from_viewer_(const V3d_Viewer_ptr& viewer Handle(Aspect_Grid) ag = viewer->Grid(); Handle(Aspect_RectangularGrid) rg = Handle(Aspect_RectangularGrid)::DownCast(ag); - Handle(V3d_RectangularGrid) vrg = Handle(V3d_RectangularGrid)::DownCast(ag); if (rg.IsNull()) return; m_occt_grid_rect.step = rg->XStep(); + m_occt_grid_rect.graphic_z_offset = rg->ZOffset(); +} + +gp_Ax3 Occt_view::grid_display_plane_() const +{ + if (m_cur_sketch) + return m_cur_sketch->get_plane().Position(); - if (!vrg.IsNull()) + if (!m_view.IsNull()) { - double gx{}, gy{}, gz{}; - vrg->GraphicValues(gx, gy, gz); - m_occt_grid_rect.graphic_x_size = gx; - m_occt_grid_rect.graphic_y_size = gy; - m_occt_grid_rect.graphic_z_offset = gz; + Handle(V3d_Viewer) viewer = m_view->Viewer(); + if (!viewer.IsNull()) + return viewer->PrivilegedPlane(); } + + return gp_Ax3(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0)); } +void Occt_view::apply_shader_grid_display_() +{ + if (is_headless() || m_view.IsNull() || !m_grid_visible) + return; + + const Shader_grid_layout layout = compute_shader_grid_layout_(); + const Aspect_GridParams params = + build_shader_grid_params_(m_occt_grid_rect, m_grid_color1, m_grid_color2, layout.size_x, layout.size_y); + m_view->GridDisplay(params, layout.plane); + m_view->Invalidate(); +} + +void Occt_view::refresh_active_sketch_grid() { refresh_viewer_grid_(); } + bool Occt_view::get_grid_visible() const { return m_grid_visible; } void Occt_view::set_grid_visible(const bool visible) @@ -1292,22 +1440,7 @@ void Occt_view::refresh_viewer_grid_() return; sync_grid_plane_to_active_sketch_(); - if (!m_grid_visible) - return; - - Handle(V3d_Viewer) viewer = m_view->Viewer(); - if (viewer.IsNull() || !viewer->IsGridActive()) - return; - apply_occt_grid_rect_to_viewer_(); - if (!viewer->Grid().IsNull()) - { - Quantity_Color cc(m_grid_color1.x, m_grid_color1.y, m_grid_color1.z, Quantity_TOC_RGB); - Quantity_Color cd(m_grid_color2.x, m_grid_color2.y, m_grid_color2.z, Quantity_TOC_RGB); - viewer->Grid()->SetColors(cc, cd); - } - - m_view->Invalidate(); m_view->Redraw(); } @@ -1316,30 +1449,15 @@ void Occt_view::apply_grid_visibility_() if (is_headless() || m_view.IsNull()) return; - Handle(V3d_Viewer) viewer = m_view->Viewer(); - if (viewer.IsNull()) - return; - if (m_grid_visible) { sync_grid_plane_to_active_sketch_(); - if (!viewer->IsGridActive()) - { - viewer->ActivateGrid(Aspect_GT_Rectangular, Aspect_GDM_Lines); - viewer->SetGridEcho(false); - } - - apply_occt_grid_rect_to_viewer_(); - - if (!viewer->Grid().IsNull()) - { - Quantity_Color cc(m_grid_color1.x, m_grid_color1.y, m_grid_color1.z, Quantity_TOC_RGB); - Quantity_Color cd(m_grid_color2.x, m_grid_color2.y, m_grid_color2.z, Quantity_TOC_RGB); - viewer->Grid()->SetColors(cc, cd); - } + apply_shader_grid_display_(); + } + else + { + m_view->GridErase(); } - else if (viewer->IsGridActive()) - viewer->DeactivateGrid(); m_view->Invalidate(); m_view->Redraw(); @@ -1352,27 +1470,11 @@ void Occt_view::apply_occt_grid_rect_to_viewer_() sync_grid_plane_to_active_sketch_(); - Occt_grid_rect_params g = clamp_occt_grid_rect_params_(m_occt_grid_rect); - m_occt_grid_rect = g; - - Handle(V3d_Viewer) viewer = m_view->Viewer(); - if (viewer.IsNull() || viewer->Grid().IsNull()) - return; + m_occt_grid_rect = clamp_occt_grid_rect_params_(m_occt_grid_rect); - Handle(Aspect_Grid) ag = viewer->Grid(); - Handle(Aspect_RectangularGrid) rg = Handle(Aspect_RectangularGrid)::DownCast(ag); - Handle(V3d_RectangularGrid) vrg = Handle(V3d_RectangularGrid)::DownCast(ag); - if (rg.IsNull()) - return; - - rg->SetGridValues(0., 0., static_cast(m_occt_grid_rect.step), static_cast(m_occt_grid_rect.step), 0.); - - if (!vrg.IsNull()) - vrg->SetGraphicValues(static_cast(m_occt_grid_rect.graphic_x_size), - static_cast(m_occt_grid_rect.graphic_y_size), - static_cast(m_occt_grid_rect.graphic_z_offset)); - - m_view->Invalidate(); + // EzyCad does its own sketch snapping, so the legacy CPU grid (V3d_Viewer::ActivateGrid) is not + // needed. Drive the GL shader grid directly from our params - this is the OCCT 8 GPU grid path. + apply_shader_grid_display_(); } void Occt_view::get_occt_grid_rect_params(Occt_grid_rect_params& out) const { out = m_occt_grid_rect; } @@ -2365,5 +2467,6 @@ void Occt_view::new_file() clear_all(m_shps, m_sketches, m_cur_sketch); create_default_sketch_(); + refresh_viewer_grid_(); m_gui.set_mode(Mode::Normal); } \ No newline at end of file diff --git a/src/occt_view.h b/src/occt_view.h index c2537b0..606d5b0 100644 --- a/src/occt_view.h +++ b/src/occt_view.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -57,13 +58,12 @@ enum class Set_parent_mode No }; -/// Persisted rectangular OCCT viewer grid geometry (same units as the model scene; uniform line step and V3d graphic -/// half-extent on X/Y for OCCT). Settings UI edits full extent (2x these values). Origin and rotation stay at 0. +/// Persisted OCCT viewer grid geometry (model units). The drawn grid is sized to the active sketch bounds plus +/// `grid_padding`; extent is not stored manually. struct Occt_grid_rect_params { double step{10.}; - double graphic_x_size{1000.}; - double graphic_y_size{1000.}; + double grid_padding{1000.}; double graphic_z_offset{}; }; @@ -259,6 +259,8 @@ class Occt_view : protected AIS_ViewController void set_occt_grid_rect_params(const Occt_grid_rect_params& p); bool get_grid_visible() const; void set_grid_visible(bool visible); + /// Recompute grid bounds from the active sketch (call after sketch geometry changes). + void refresh_active_sketch_grid(); bool is_headless() const; @@ -298,7 +300,16 @@ class Occt_view : protected AIS_ViewController void sync_grid_plane_to_active_sketch_(); void refresh_viewer_grid_(); void apply_occt_grid_rect_to_viewer_(); + void apply_shader_grid_display_(); void apply_grid_visibility_(); + struct Shader_grid_layout + { + gp_Ax3 plane; + double size_x{0.}; + double size_y{0.}; + }; + [[nodiscard]] Shader_grid_layout compute_shader_grid_layout_() const; + [[nodiscard]] gp_Ax3 grid_display_plane_() const; //! GLFW callback redirecting messages into Message::DefaultMessenger(). // static void errorCallback(int theError, const char* theDescription); diff --git a/src/sketch.cpp b/src/sketch.cpp index 667e86a..1181f1b 100644 --- a/src/sketch.cpp +++ b/src/sketch.cpp @@ -2406,6 +2406,9 @@ void Sketch::update_faces_() purge_stale_length_dimensions_(); sync_permanent_node_annos_(); refresh_all_length_dimensions_(); + + if (is_current()) + m_view.refresh_active_sketch_grid(); } void Sketch::rebuild_dim_classifier_face_cache_() diff --git a/src/sketch.h b/src/sketch.h index a7c6466..d6b7bc3 100644 --- a/src/sketch.h +++ b/src/sketch.h @@ -248,6 +248,7 @@ class Sketch /// Add a single node (no new edge); splits any linear edge the node lies on in its interior. void add_node_pt_(const ScreenCoords& screen_coords); void move_add_node_pt_(const ScreenCoords& screen_coords); + void split_linear_edges_at_node_if_interior_(size_t node_idx); void split_linear_edges_at_node_if_interior_(size_t node_idx, Sketch_op_recorder& rec); void split_linear_edges_at_node_if_interior_(size_t node_idx, Sketch_op_recorder* rec); diff --git a/src/utl_geom.h b/src/utl_geom.h index a7c8e0e..764f7c5 100644 --- a/src/utl_geom.h +++ b/src/utl_geom.h @@ -1,9 +1,9 @@ #pragma once -#include +//#include #include #include -#include +//#include #include #include #include @@ -24,42 +24,7 @@ class TopoDS_Face; class TopoDS_Shape; class Geom_TrimmedCurve; -// Pure C++ implementation of the polygon/ring functionality (for OCCT face to -// 2D polygon conversion, shoelace-based clockwise/area checks, and basic WKT -// output for tests). No external geometry library dependency. -namespace ezy_geom -{ -struct point_2d -{ - double x_ = 0.0, y_ = 0.0; - double x() const { return x_; } - double y() const { return y_; } - point_2d() = default; - point_2d(double xx, double yy) - : x_(xx) - , y_(yy) - { - } -}; - -using ring_2d = std::vector; - -struct polygon_2d -{ - ring_2d outer_; - std::vector inners_; - ring_2d& outer() { return outer_; } - const ring_2d& outer() const { return outer_; } - std::vector& inners() { return inners_; } - const std::vector& inners() const { return inners_; } -}; - -// Basic validity (the OCCT construction code ensures well-formed polygons). -bool is_valid(const polygon_2d& poly); - -// Shoelace area (outer minus holes). -double area(const polygon_2d& poly); -} // namespace ezy_geom +#include "utl_geom_boost.inl" gp_Pnt2d to_pnt2d(const ezy_geom::point_2d& pt); ezy_geom::point_2d to_boost(const gp_Pln& plane, const gp_Pnt& point_3d); diff --git a/src/utl_geom_boost.inl b/src/utl_geom_boost.inl new file mode 100644 index 0000000..b8d87d5 --- /dev/null +++ b/src/utl_geom_boost.inl @@ -0,0 +1,36 @@ +// Pure C++ implementation of the polygon/ring functionality (for OCCT face to +// 2D polygon conversion, shoelace-based clockwise/area checks, and basic WKT +// output for tests). No external geometry library dependency. +namespace ezy_geom +{ +struct point_2d +{ + double x_ = 0.0, y_ = 0.0; + double x() const { return x_; } + double y() const { return y_; } + point_2d() = default; + point_2d(double xx, double yy) + : x_(xx) + , y_(yy) + { + } +}; + +using ring_2d = std::vector; + +struct polygon_2d +{ + ring_2d outer_; + std::vector inners_; + ring_2d& outer() { return outer_; } + const ring_2d& outer() const { return outer_; } + std::vector& inners() { return inners_; } + const std::vector& inners() const { return inners_; } +}; + +// Basic validity (the OCCT construction code ensures well-formed polygons). +bool is_valid(const polygon_2d& poly); + +// Shoelace area (outer minus holes). +double area(const polygon_2d& poly); +} // namespace ezy_geom diff --git a/tools/__pycache__/pimpl_gen.cpython-312.pyc b/tools/__pycache__/pimpl_gen.cpython-312.pyc new file mode 100644 index 0000000..a0d0e1c Binary files /dev/null and b/tools/__pycache__/pimpl_gen.cpython-312.pyc differ diff --git a/scripts/pbf-to-png.ps1 b/tools/pbf-to-png.ps1 similarity index 100% rename from scripts/pbf-to-png.ps1 rename to tools/pbf-to-png.ps1 diff --git a/scripts/pbf-to-png.py b/tools/pbf-to-png.py similarity index 100% rename from scripts/pbf-to-png.py rename to tools/pbf-to-png.py diff --git a/tools/pimpl_gen.py b/tools/pimpl_gen.py new file mode 100644 index 0000000..fe07323 --- /dev/null +++ b/tools/pimpl_gen.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +"""Generate PIMPL forwarding stubs from a C++ interface header. + +Reads public method declarations from a class/struct and writes an .inl file +with out-of-line definitions that delegate to m_impl->method(...). + +Example: + python tools/pimpl_gen.py src/sketch_delta.h --class Sketch_op_recorder + -> sketch_delta_impl_000.inl + +The output is a starting point: review, rename, and merge into your .cpp. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + + +def definition_suffix(suffix: str) -> str: + """Suffix tokens valid on a declaration but not on an out-of-line definition.""" + suffix = re.sub(r"\boverride\b", "", suffix) + suffix = re.sub(r"\bfinal\b", "", suffix) + suffix = re.sub(r"=\s*0\b", "", suffix) + return normalize_ws(suffix) + + +@dataclass +class MethodDecl: + return_type: str + name: str + params: str + suffix: str # const, override, noexcept, etc. after the closing paren + + @property + def definition_suffix(self) -> str: + return definition_suffix(self.suffix) + + @property + def qualified_signature_tail(self) -> str: + """Everything after ClassName:: in a definition.""" + parts = [f"({self.params})"] + if self.definition_suffix: + parts.append(self.definition_suffix) + return " ".join(parts) + + @property + def forward_args(self) -> str: + return ", ".join(extract_param_names(self.params)) + + +def strip_comments(text: str) -> str: + # Block comments + text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL) + # Line comments + text = re.sub(r"//[^\n]*", "", text) + return text + + +def find_matching_brace(text: str, open_index: int) -> int: + depth = 0 + i = open_index + while i < len(text): + ch = text[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return i + i += 1 + raise ValueError("unbalanced braces in class body") + + +def split_top_level(text: str, sep: str) -> list[str]: + parts: list[str] = [] + depth_angle = depth_paren = depth_bracket = 0 + start = 0 + for i, ch in enumerate(text): + if ch == "<": + depth_angle += 1 + elif ch == ">": + depth_angle = max(0, depth_angle - 1) + elif ch == "(": + depth_paren += 1 + elif ch == ")": + depth_paren = max(0, depth_paren - 1) + elif ch == "[": + depth_bracket += 1 + elif ch == "]": + depth_bracket = max(0, depth_bracket - 1) + elif ch == sep and depth_angle == depth_paren == depth_bracket == 0: + parts.append(text[start:i].strip()) + start = i + 1 + parts.append(text[start:].strip()) + return [p for p in parts if p] + + +def extract_param_names(params: str) -> list[str]: + if not params.strip(): + return [] + names: list[str] = [] + for part in split_top_level(params, ","): + part = part.strip() + if not part or part == "...": + continue + # Drop default value + if "=" in part: + part = part[: part.index("=")].strip() + # Name is the last token (handles Type& name, Type* name, Type name) + tokens = part.split() + if not tokens: + continue + name = tokens[-1] + # Strip *, &, && from the name side + name = name.lstrip("*&") + if name: + names.append(name) + return names + + +def normalize_ws(text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + +def is_skipped_member(name: str, class_name: str, decl: str) -> bool: + if name == class_name: + return True # constructor + if name.startswith("~"): + return True # destructor + if "= delete" in decl or "= default" in decl: + return True + if decl.strip().endswith("{") or "{" in decl: + return True # inline body in header + if name.startswith("operator"): + return True + return False + + +def parse_method_decl(decl: str, class_name: str) -> MethodDecl | None: + decl = normalize_ws(decl) + if not decl.endswith(";"): + return None + decl = decl[:-1].strip() + + # friend, using, enum, nested type, static data member + skip_prefixes = ( + "friend ", + "using ", + "enum ", + "struct ", + "class ", + "typedef ", + "static_assert", + ) + if decl.startswith(skip_prefixes): + return None + + paren = decl.find("(") + if paren < 0: + return None + + before = decl[:paren].strip() + after = decl[paren + 1 :] + close = find_matching_paren(after, 0, already_open=True) + if close < 0: + return None + params = after[:close].strip() + suffix = after[close + 1 :].strip() + + # static member function + if before.startswith("static "): + before = before[7:].strip() + + name_match = re.search(r"([~]?[\w:]+)\s*$", before) + if not name_match: + return None + name = name_match.group(1) + return_type = before[: name_match.start()].strip() + + if is_skipped_member(name, class_name, decl + ";"): + return None + if not return_type and name != class_name: + # Might be a ctor we missed, or malformed + if name == class_name: + return None + + return MethodDecl(return_type=return_type, name=name, params=params, suffix=suffix) + + +def find_matching_paren(text: str, start: int, *, already_open: bool = False) -> int: + depth = 1 if already_open else 0 + depth_angle = 0 + i = start + while i < len(text): + ch = text[i] + if ch == "<": + depth_angle += 1 + elif ch == ">": + depth_angle = max(0, depth_angle - 1) + elif ch == "(" and depth_angle == 0: + depth += 1 + elif ch == ")" and depth_angle == 0: + depth -= 1 + if depth == 0: + return i + i += 1 + return -1 + + +def class_body_span(text: str, match: re.Match[str]) -> tuple[int, int, str] | None: + """Return (open_brace, close_brace, kind) or None for forward declarations.""" + kind = match.group(1) + tail = text[match.end() :] + semi = tail.find(";") + brace = tail.find("{") + if brace < 0 or (semi >= 0 and semi < brace): + return None + open_brace = match.end() + brace + close_brace = find_matching_brace(text, open_brace) + return open_brace, close_brace, kind + + +def extract_class_body(text: str, class_name: str) -> tuple[str, str]: + pattern = re.compile(rf"\b(class|struct)\s+{re.escape(class_name)}\b") + match = pattern.search(text) + if not match: + raise ValueError(f"class/struct '{class_name}' not found") + + span = class_body_span(text, match) + if not span: + raise ValueError(f"no body for '{class_name}' (forward declaration?)") + open_brace, close_brace, kind = span + return text[open_brace + 1 : close_brace], kind + + +def strip_leading_access(chunk: str) -> tuple[str, str | None]: + m = re.match(r"^(public|protected|private)\s*:\s*", chunk) + if not m: + return chunk, None + return chunk[m.end() :].strip(), m.group(1) + + +def collect_public_methods(class_body: str, class_name: str, kind: str) -> list[MethodDecl]: + default_public = kind == "struct" + access = "public" if default_public else "private" + methods: list[MethodDecl] = [] + + chunks = re.split(r";\s*", class_body) + for chunk in chunks: + chunk = normalize_ws(chunk) + if not chunk: + continue + + chunk, maybe_access = strip_leading_access(chunk) + if maybe_access: + access = maybe_access + if not chunk: + continue + + access_only = re.fullmatch(r"(public|protected|private)", chunk) + if access_only: + access = access_only.group(1) + continue + + if access != "public": + continue + + # Nested type forward declarations / definitions + if re.match(r"(class|struct)\s+\w+\s*$", chunk): + continue + if "{" in chunk: + continue + + method = parse_method_decl(chunk + ";", class_name) + if method: + methods.append(method) + + return methods + + +def render_method(class_name: str, method: MethodDecl, impl_var: str) -> str: + sig = f"{method.return_type} {class_name}::{method.name}{method.qualified_signature_tail}".strip() + args = method.forward_args + call = f"{impl_var}->{method.name}({args})" + + if not method.return_type or method.return_type == "void": + return f"{sig}\n{{\n {call};\n}}" + + return f"{sig}\n{{\n return {call};\n}}" + + +def default_out_path(header: Path, index: int = 0) -> Path: + stem = header.stem + return header.parent / f"{stem}_impl_{index:03d}.inl" + + +def generate_inl( + header_path: Path, + class_name: str, + impl_var: str = "m_impl", + out_path: Path | None = None, +) -> str: + raw = header_path.read_text(encoding="utf-8") + text = strip_comments(raw) + body, kind = extract_class_body(text, class_name) + methods = collect_public_methods(body, class_name, kind) + + if not methods: + raise ValueError(f"no public methods found for '{class_name}' in {header_path}") + + out_path = out_path or default_out_path(header_path) + lines = [ + f"// Generated by tools/pimpl_gen.py from {header_path.name}", + f"// Class: {class_name} impl: {impl_var}", + "// Review and merge into your .cpp; not included automatically.", + "", + ] + + for method in methods: + lines.append(render_method(class_name, method, impl_var)) + lines.append("") + + content = "\n".join(lines).rstrip() + "\n" + out_path.write_text(content, encoding="utf-8", newline="\n") + return str(out_path) + + +def list_pimpl_classes(text: str) -> list[str]: + text = strip_comments(text) + names: list[str] = [] + for match in re.finditer(r"\b(class|struct)\s+(\w+)", text): + name = match.group(2) + span = class_body_span(text, match) + if not span: + continue + open_brace, close_brace, _ = span + body = text[open_brace + 1 : close_brace] + if re.search(rf"\bstd::unique_ptr\s*<\s*Impl\s*>\s+m_impl\b", body): + names.append(name) + return names + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Generate PIMPL forwarding .inl stubs from a C++ header." + ) + parser.add_argument("header", type=Path, help="Interface .h file") + parser.add_argument( + "--class", + dest="class_name", + help="Class/struct name (default: first type with unique_ptr m_impl)", + ) + parser.add_argument( + "--impl-var", + default="m_impl", + help="Implementation pointer member name (default: m_impl)", + ) + parser.add_argument( + "--out", + type=Path, + help="Output .inl path (default: _impl_000.inl next to header)", + ) + parser.add_argument( + "--list", + action="store_true", + help="List classes in the header that declare unique_ptr m_impl", + ) + args = parser.parse_args() + + if not args.header.is_file(): + print(f"error: not a file: {args.header}", file=sys.stderr) + return 1 + + raw = args.header.read_text(encoding="utf-8") + text = strip_comments(raw) + + if args.list: + for name in list_pimpl_classes(text): + print(name) + return 0 + + class_name = args.class_name + if not class_name: + candidates = list_pimpl_classes(text) + if not candidates: + print( + "error: no class with 'unique_ptr m_impl' found; pass --class", + file=sys.stderr, + ) + return 1 + if len(candidates) > 1: + print( + "error: multiple PIMPL classes found; pass --class:\n " + + "\n ".join(candidates), + file=sys.stderr, + ) + return 1 + class_name = candidates[0] + + try: + out = generate_inl(args.header, class_name, args.impl_var, args.out) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + print(out) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())