From c3a50d17be5bd0f5767faa684c73283877dd2f7a Mon Sep 17 00:00:00 2001 From: Trailcode Date: Sun, 19 Jul 2026 11:48:42 -0600 Subject: [PATCH 1/8] WIP --- CHANGELOG.md | 5 + CMakeLists.txt | 4 +- .../issues/active/gh-212-project-units.md | 52 ++ .../drafts/prs/active/gh-213-project-units.md | 35 ++ docs/usage.md | 27 +- src/doc/gui.md | 5 +- src/doc/utility.md | 15 +- src/gui.cpp | 147 ++++- src/gui.h | 15 +- src/gui_occt_view.cpp | 66 +- src/gui_occt_view.h | 3 +- src/utl_cad_file_info.cpp | 586 ++++++++++++++++++ src/utl_cad_file_info.h | 49 ++ src/utl_occt.cpp | 45 ++ src/utl_occt.h | 5 + src/utl_types.h | 8 + 16 files changed, 1017 insertions(+), 50 deletions(-) create mode 100644 agents/drafts/issues/active/gh-212-project-units.md create mode 100644 agents/drafts/prs/active/gh-213-project-units.md create mode 100644 src/utl_cad_file_info.cpp create mode 100644 src/utl_cad_file_info.h diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a2de1..5f0a438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Import / export + +- **File -> Import** opens an **Import** dialog with file metadata, optional **Union shapes** for STEP, then **Import into project**. The dialog closes after a successful import. Multi-body STEP assemblies import as separate Shape List entries unless **Union shapes** is checked. STEP product/part names are used in the Shape List when present (with `.001` suffixes if needed). +- Removed the **Tools** menu (**Inspector** lives under **File -> Import**). Removed **File -> Sketch underlay image...** (import underlays from **Sketch properties**). + ### Project units - **File -> Project units:** Choose **Inches** or **Millimeters** for sketch/display lengths (dimensions, Tab distance, add-primitive dialogs, chamfer/fillet, grid Settings fields). Stored in the `.ezy` as `projectUnit`. Changing units remaps the UI only; geometry is not rewritten. Export units remain a separate dialog. diff --git a/CMakeLists.txt b/CMakeLists.txt index 54d49d0..49c4867 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -278,7 +278,7 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "Emscripten") -s USE_WEBGL2=1 \ -s USE_GLFW=3 \ -s USE_ZLIB=1 \ - -s EXPORTED_FUNCTIONS=\"['_main', '_on_file_selected', '_on_import_file_selected', '_on_sketch_underlay_selected', '_on_save_file_selected', '_on_save_file_failed', '_emscripten_save_settings_on_unload']\" \ + -s EXPORTED_FUNCTIONS=\"['_main', '_on_file_selected', '_on_import_file_selected', '_on_inspector_file_selected', '_on_sketch_underlay_selected', '_on_save_file_selected', '_on_save_file_failed', '_emscripten_save_settings_on_unload']\" \ -s EXPORTED_RUNTIME_METHODS=\"['ccall']\" \ -s FETCH=1 \ --preload-file ${CMAKE_SOURCE_DIR}/third_party/imgui/fonts/DroidSans.ttf@/DroidSans.ttf \ @@ -312,7 +312,7 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "Emscripten") -s USE_WEBGL2=1 \ -s USE_GLFW=3 \ -s USE_ZLIB=1 \ - -s EXPORTED_FUNCTIONS=\"['_main', '_on_file_selected', '_on_import_file_selected', '_on_sketch_underlay_selected', '_on_save_file_selected', '_on_save_file_failed', '_emscripten_save_settings_on_unload']\" \ + -s EXPORTED_FUNCTIONS=\"['_main', '_on_file_selected', '_on_import_file_selected', '_on_inspector_file_selected', '_on_sketch_underlay_selected', '_on_save_file_selected', '_on_save_file_failed', '_emscripten_save_settings_on_unload']\" \ -s EXPORTED_RUNTIME_METHODS=\"['ccall']\" \ -s FETCH=1 \ --preload-file ${CMAKE_SOURCE_DIR}/third_party/imgui/fonts/DroidSans.ttf@/DroidSans.ttf \ diff --git a/agents/drafts/issues/active/gh-212-project-units.md b/agents/drafts/issues/active/gh-212-project-units.md new file mode 100644 index 0000000..8c3caf4 --- /dev/null +++ b/agents/drafts/issues/active/gh-212-project-units.md @@ -0,0 +1,52 @@ +# Project units: Inch/Millimeter display for sketches and New defaults + +**Suggested labels:** `enhancement`, `ui` + +--- + +## Title (GitHub) + +Project units: Inch/Millimeter display for sketches and New defaults + +## Body (GitHub) + +### Summary + +Add selectable project length units (Inches / Millimeters) for display and input, without rewriting stored geometry. Include Settings defaults for File -> New (unit + 2D view framing). + +### Problem + +- Sketch and display lengths were hard-coded as inches. +- Users designing metric parts (or switching between house-scale and small parts) had no way to work in millimeters. +- Default 2D view framing lived under 3D view navigation and was easy to mis-set on a wide slider. + +### Implemented scope (for drafts after work begins) + +**Code changes:** + +- `src/utl_types.h`: `Project_unit`, `k_mm_per_inch` +- `src/gui_occt_view.*`: `to_model` / `to_display` / `get_display_to_model_scale`, persist `projectUnit`, New applies default unit +- Length I/O call sites use display scale; import/export keep inch-based `get_dimension_scale()` +- `src/gui_settings.cpp`: **New project defaults** section; drag fields for 2D framing +- `tests/skt_tests.cpp`: `ProjectUnit_displayConversionAndJsonRoundTrip` + +**Documentation:** + +- `docs/usage.md`, `usage-settings.md`, `usage-sketch.md`, `scripting.md`, `CHANGELOG.md` +- `src/doc/gui.md`, `src/doc/utility.md` + +### Acceptance criteria + +- [x] File -> Project units switches Inch/Millimeter without rewriting geometry +- [x] `.ezy` persists `projectUnit`; older files default to Inches +- [x] Settings -> New project defaults for unit + framing +- [x] File -> New applies those defaults +- [x] Grid Settings show project unit suffixes +- [x] Export unit picker independent +- [x] Unit test passes +- [x] User docs / CHANGELOG updated + +## Related + +- Issue: https://github.com/trailcode/EzyCad/issues/212 +- PR: (see `agents/drafts/prs/active/`) diff --git a/agents/drafts/prs/active/gh-213-project-units.md b/agents/drafts/prs/active/gh-213-project-units.md new file mode 100644 index 0000000..664a3e6 --- /dev/null +++ b/agents/drafts/prs/active/gh-213-project-units.md @@ -0,0 +1,35 @@ +# PR - Project units and New project defaults + +## Title + +Project units and New project defaults + +## Summary + +- Add **File -> Project units** (Inches / Millimeters) for on-the-fly display/input; model space stays inch-scaled. +- Persist `projectUnit` in `.ezy`; **Settings -> New project defaults** for New unit + 2D framing. +- Route length I/O through `get_display_to_model_scale()`; keep import/export on inch-based scale. + +## Files Changed + +- `src/utl_types.h`, `src/gui_occt_view.*`, `src/gui*.cpp`, `src/skt_*.cpp`, `src/shp_*.cpp`, `src/scr_*.cpp` +- `tests/skt_tests.cpp` +- `docs/usage*.md`, `docs/scripting.md`, `CHANGELOG.md`, `src/doc/gui.md`, `src/doc/utility.md` +- `res/ezycad_settings.json` +- `agents/drafts/issues/active/gh-212-project-units.md` +- `agents/drafts/prs/active/gh-213-project-units.md` + +## Related + +- Issue: https://github.com/trailcode/EzyCad/issues/212 +- PR: https://github.com/trailcode/EzyCad/pull/213 + +## Test Plan + +- [ ] Build `EzyCad` / `EzyCad_tests` (Debug) +- [ ] `EzyCad_tests.exe --gtest_filter=Sketch_test.ProjectUnit_displayConversionAndJsonRoundTrip` +- [ ] Switch File -> Project units; confirm dims remap, geometry unchanged +- [ ] Save/reload and old-file default to Inches +- [ ] New project defaults + File -> New +- [ ] Grid Settings unit suffixes; Export picker independent +- [ ] Docs / CHANGELOG spot-check diff --git a/docs/usage.md b/docs/usage.md index dfb666c..c5683be 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -239,6 +239,23 @@ The typical modeling workflow in EzyCad follows these steps: | **Shapes** | 3D solid objects created from extruded sketch faces | | **Feature Operations** | Transform sketches into 3D geometry or modify existing 3D shapes | +### Import dialog + +**File -> Import** opens an **Import** window for STEP or PLY. Review metadata, optionally enable **Union shapes** (STEP), then click **Import into project**. The window closes after a successful import. + +| Format | What the Import dialog shows | +| -------------------------- | -------------------------------------------------------------- | +| **STEP** (`.step`, `.stp`) | Roots/shapes, import bodies, named bodies, topology, bbox | +| **PLY** (`.ply`) | Encoding, vertex and face counts from the header | + +**How to use:** +1. Choose **File -> Import** +2. Pick a `.step`, `.stp`, or `.ply` file +3. Review the label/value table; for STEP, optionally enable **Union shapes** +4. Click **Import into project** + +For in-document topology of an already-loaded solid, use [Shape info](#shape-info) from the Shape List. + ### Importing 3D Geometries In addition to creating 3D shapes from sketches, EzyCad supports importing existing 3D geometry from external CAD files. This allows you to: @@ -258,9 +275,10 @@ In addition to creating 3D shapes from sketches, EzyCad supports importing exist **How to import:** 1. Use **File -> Import** -2. Pick a `.step`, `.stp`, or `.ply` file (the dialog lists these types) -3. Geometry is added as 3D shape(s) in the document, scaled to project units (see below) -4. You can move, rotate, scale, and use imported bodies in [boolean operations](#boolean-operations) like native solids where the geometry allows it +2. Pick a `.step`, `.stp`, or `.ply` file +3. Review metadata in the [Import dialog](#import-dialog); for STEP assemblies, optionally check **Union shapes** +4. Click **Import into project** - geometry is added as 3D shape(s) in the document, scaled to project units (see below) +5. You can move, rotate, scale, and use imported bodies in [boolean operations](#boolean-operations) like native solids where the geometry allows it **Units:** Sketch and display lengths follow **File -> Project units** (**Inches** or **Millimeters**). Internal model coordinates stay inch-scaled (`model = inches * dimension_scale`, default **100**). Changing project units only remaps the UI; it does not rewrite geometry. Import scaling: @@ -277,6 +295,9 @@ In addition to creating 3D shapes from sketches, EzyCad supports importing exist **STEP import notes:** - If the file cannot be read or contains no transferable geometry, a **message** explains the failure (invalid data, empty transfer, etc.). +- Assemblies often arrive as a single compound containing many solids. Import expands those into separate Shape List entries. The Import dialog **Import bodies** count shows how many shapes will be added. +- When the STEP file includes product or part names, those names appear in the Shape List (duplicate names get `.001`, `.002`, ...). Unnamed bodies stay as `Shape`. +- **Union shapes** (Import dialog) fuses those bodies into one solid before adding to the document. If union fails, the import is aborted and nothing is added. **Note:** **IGES** and **STL** are available for **export** only, not import. diff --git a/src/doc/gui.md b/src/doc/gui.md index 8aadb26..3a0e5fb 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -13,7 +13,7 @@ Typical responsibilities: - ImGui frame: menu bar, dock space (passthrough central node for 3D input), toolbar, Sketch List, Shape List, Options, Settings, dist/angle popups. - Mode switching (`Mode` enum in [`mode.h`](../mode.h)) and parent-mode Esc behavior. - Persisted preferences (`ezycad_settings.json` via [`gui_settings.cpp`](../gui_settings.cpp)). -- Project I/O (`.ezy` load/save, import/export dialogs). +- Project I/O (`.ezy` load/save, import/export dialogs; **File -> Import** shows metadata then loads STEP/PLY). - CAD/mesh interchange scales about the origin: project display lengths follow **File -> Project units** (`Project_unit`; Inch or Millimeter). Model space stays inch-scaled (`model = inches * dimension_scale`). STEP import converts OCCT cascade **mm** into model space; PLY import treats coords as inches. **File -> Export** asks for **Inches** or **Millimeters** (STEP/IGES declare that unit; STL/PLY write unitless coords in that scale). `.ezy` persists `projectUnit`. **Settings -> New project defaults** stores `gui.default_project_unit` and inch-based default 2D framing for **File -> New**. - Contextual help links (`doc_urls` in `gui.h`). @@ -201,7 +201,7 @@ Shared sketch controls (snap, midpoint nodes, place-from-center) live in `option | 2 | `menu_bar_`, `toolbar_` | File / View / mode tools | | 3 | `dist_edit_`, `angle_edit_` | Floating numeric entry | | 4 | `sketch_list_`, `sketch_properties_dialog_` | Sketch List + underlay/properties | -| 5 | `shape_list_`, `shape_info_dialog_` | Shape List + info popup | +| 5 | `shape_list_`, `shape_info_dialog_`, `file_inspector_dialog_` | Shape List + info + Import dialog | | 6 | `options_` | Mode-specific Options pane | | 7 | `message_status_window_`, `about_dialog_` | Status + About | | 8 | `add_*_dialog_` | Primitive / sketch creation popups | @@ -274,3 +274,4 @@ Occt_view* view = gui.get_view(); | [`src/doc/shape.md`](shape.md) | Shape operations invoked from toolbar, Options, mouse | | [`scr_lua_console.cpp`](../scr_lua_console.cpp) / [`scr_python_console.cpp`](../scr_python_console.cpp) | Script consoles embedded in `render_gui` | | [`utl_settings.cpp`](../utl_settings.cpp) | User settings file path and I/O helpers | +| [`utl_cad_file_info.h`](../utl_cad_file_info.h) | CAD/mesh file metadata for **File -> Import** | diff --git a/src/doc/utility.md b/src/doc/utility.md index 360c07a..1ec8977 100644 --- a/src/doc/utility.md +++ b/src/doc/utility.md @@ -24,12 +24,13 @@ Typical uses: | [`utl_types.h`](../utl_types.h) | OCCT/AIS handle typedefs, `ScreenCoords`, `Export_format`, `Export_unit`, `DECL_PTR`, `SafeType` | | [`utl_geom.h`](../utl_geom.h) / [`.cpp`](../utl_geom.cpp) | 2D/3D geometry, wires, dimensions, Boost polygon tests, plane projection | | [`utl_geom_boost.inl`](../utl_geom_boost.inl) | `ezy_geom` Boost.Geometry aliases | -| [`utl_occt.h`](../utl_occt.h) / [`.cpp`](../utl_occt.cpp) | `TopAbs` name table, `try_make_solid`, `standard_failure_message` | +| [`utl_occt.h`](../utl_occt.h) / [`.cpp`](../utl_occt.cpp) | `TopAbs` name table, `try_make_solid`, `append_cad_import_bodies`, `standard_failure_message` | | [`utl_json.h`](../utl_json.h) / [`.cpp`](../utl_json.cpp) | JSON serializers for `gp_Pnt`, `gp_Pln`, etc. | | [`utl_io.h`](../utl_io.h) / [`.cpp`](../utl_io.cpp) | `.ezy` zip v3 pack/unpack, format sniff, base64 | | [`utl_asset_store.h`](../utl_asset_store.h) / [`.cpp`](../utl_asset_store.cpp) | Content-addressed RGBA blobs for sketch underlay assets | | [`utl_settings.h`](../utl_settings.h) / [`.cpp`](../utl_settings.cpp) | User settings file paths, startup project blob I/O | | [`utl_ply_io.h`](../utl_ply_io.h) / [`.cpp`](../utl_ply_io.cpp) | PLY import/export for mesh shapes | +| [`utl_cad_file_info.h`](../utl_cad_file_info.h) / [`.cpp`](../utl_cad_file_info.cpp) | Read-only STEP/IGES/STL/PLY metadata for **File -> Import** (no document mutation until Import) | | [`utl_log.h`](../utl_log.h) / [`.cpp`](../utl_log.cpp) | `Log_strm` redirecting stdout/stderr to `GUI::log_message` | | [`utl_dbg.h`](../utl_dbg.h) | `EZY_ASSERT`, `DBG_MSG`, debug break macros | @@ -106,6 +107,7 @@ Includes [`utl_geom_boost.inl`](../utl_geom_boost.inl) for `ezy_geom` polygon / | ----------------------------- | --------------------------------------------------------------------- | | `c_names_TopAbs_ShapeEnum` | String names parallel to OCCT enum (selection filter UI) | | `try_make_solid(shape)` | Wrap closed shell as solid when possible | +| `append_cad_import_bodies` | Expand STEP/IGES compounds into solids (or free shells) for import | | `standard_failure_message(e)` | OCCT 7/8-safe message from `Standard_Failure` (see dual-version note) | ## JSON geom (`utl_json`) @@ -121,6 +123,17 @@ Raw PLY parse/write only (no unit conversion). `Occt_view::import_ply` / `export | `import_ply_shape(bytes, out)` | ASCII or binary_little_endian PLY -> `TopoDS_Shape` | | `export_ply_binary_file(shape, path)` | Mesh export (mesh shape first) | +## CAD file metadata (`utl_cad_file_info`) + +Used by **File -> Import**. Reads file bytes only until the user confirms import; does not add shapes by itself. + +| API | Role | +| --------------------------- | ----------------------------------------------------------------- | +| `detect(path, bytes)` | Format from extension, then content sniff | +| `can_import(fmt)` | True for STEP and PLY | +| `collect(path, bytes)` | Label/value rows (size, roots/shapes, mesh header, bbox, etc.) | +| `read_step_named_bodies` | STEPCAF/XCAF bodies + product names (falls back to plain reader) | + ## Logging and debug | Component | Role | diff --git a/src/gui.cpp b/src/gui.cpp index 2456af7..3e8b1b0 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -35,6 +35,7 @@ #include "shp_info.h" #include "skt.h" #include "utl.h" +#include "utl_cad_file_info.h" #include "version.h" #include @@ -145,6 +146,7 @@ void GUI::render_gui() sketch_properties_dialog_(); shape_list_(); shape_info_dialog_(); + file_inspector_dialog_(); options_(); message_status_window_(); error_modal_dialog_(); @@ -336,12 +338,6 @@ void GUI::menu_bar_() { if (ImGui::MenuItem("Import")) import_file_dialog_(); - - if (ImGui::MenuItem("Sketch underlay image...")) - { - m_underlay_import_sketch_target.reset(); - sketch_underlay_import_dialog_(); - } } if (ImGui::BeginMenu("Export")) @@ -2756,6 +2752,108 @@ void GUI::shape_info_dialog_() ImGui::End(); } +void GUI::open_file_inspector_(const std::string& file_path, const std::string& file_bytes) +{ + m_file_inspector_path = file_path; + m_file_inspector_bytes = file_bytes; + m_file_inspector_fmt = utl_cad_file_info::detect(file_path, file_bytes); + m_file_inspector_lines = utl_cad_file_info::collect(file_path, file_bytes); + m_file_inspector_open = true; +} + +void GUI::close_file_inspector_() +{ + m_file_inspector_open = false; + m_file_inspector_path.clear(); + m_file_inspector_bytes.clear(); + m_file_inspector_lines.clear(); + m_file_inspector_fmt = utl_cad_file_info::Format::Unknown; + m_file_inspector_union = false; +} + +void GUI::file_inspector_dialog_() +{ + if (!m_file_inspector_open) + return; + + const std::string name = std::filesystem::path(m_file_inspector_path).filename().string(); + const std::string title = "Import: " + (name.empty() ? std::string("file") : name); + ImGui::SetNextWindowSize(ImVec2(480.0f, 0.0f), ImGuiCond_FirstUseEver); + bool open = m_file_inspector_open; + if (!ImGui::Begin(title.c_str(), &open, ImGuiWindowFlags_None)) + { + m_file_inspector_open = open; + ImGui::End(); + return; + } + + if (utl_cad_file_info::can_import(m_file_inspector_fmt) && !m_file_inspector_bytes.empty()) + { + const bool can_union = m_file_inspector_fmt == utl_cad_file_info::Format::Step; + if (!can_union) + m_file_inspector_union = false; + + ImGui::BeginDisabled(!can_union); + ImGui::Checkbox("Union shapes", &m_file_inspector_union); + ImGui::EndDisabled(); + if (ui_show_contextual_help() && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) + ImGui::SetTooltip(can_union ? "Fuse multiple STEP roots into one shape on import." + : "Union applies to multi-shape STEP files only."); + + if (ImGui::Button("Import into project")) + { + if (on_import_file(m_file_inspector_path, m_file_inspector_bytes, m_file_inspector_union)) + { + close_file_inspector_(); + ImGui::End(); + return; + } + } + } + + ImGui::Separator(); + + const float max_h = ImGui::GetTextLineHeightWithSpacing() * 20.0f; + if (ImGui::BeginChild("file_inspector_scroll", ImVec2(0.0f, max_h), ImGuiChildFlags_Borders, + ImGuiWindowFlags_AlwaysVerticalScrollbar)) + { + if (ImGui::BeginTable("file_inspector_tbl", 2, ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_RowBg)) + { + ImGui::TableSetupColumn("label", ImGuiTableColumnFlags_WidthFixed, 140.0f); + ImGui::TableSetupColumn("value", ImGuiTableColumnFlags_WidthStretch); + + for (const utl_cad_file_info::Line& line : m_file_inspector_lines) + { + if (line.label.empty() && line.value.empty()) + { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::Separator(); + ImGui::TableSetColumnIndex(1); + ImGui::Separator(); + continue; + } + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(line.label.c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextWrapped("%s", line.value.c_str()); + } + + ImGui::EndTable(); + } + + ImGui::EndChild(); + } + + m_file_inspector_open = open; + if (!open) + close_file_inspector_(); + + ImGui::End(); +} + float GUI::list_name_field_width_(const ImGuiStyle& st, const float max_name_text_w) { constexpr float k_name_field_cap = 480.f; @@ -3626,7 +3724,7 @@ void GUI::import_file_dialog_() const std::string file_bytes{std::istreambuf_iterator(file), std::istreambuf_iterator()}; if (!file_bytes.empty()) - on_import_file(selected, file_bytes); + on_inspector_file(selected, file_bytes); else show_message("Error opening: " + std::filesystem::path(selected).filename().string()); } @@ -3816,7 +3914,7 @@ void GUI::on_file(const std::string& file_path, const std::string& file_bytes, b show_message("Opened: " + std::filesystem::path(file_path).filename().string()); } -void GUI::on_import_file(const std::string& file_path, const std::string& file_data) +bool GUI::on_import_file(const std::string& file_path, const std::string& file_data, const bool union_shapes) { std::string ext = std::filesystem::path(file_path).extension().string(); for (char& c : ext) @@ -3825,17 +3923,29 @@ void GUI::on_import_file(const std::string& file_path, const std::string& file_d if (ext == ".ply") { if (!m_view->import_ply(file_data)) + { show_message("PLY import failed."); - else - show_message("Imported: " + std::filesystem::path(file_path).filename().string()); + return false; + } - return; + show_message("Imported: " + std::filesystem::path(file_path).filename().string()); + return true; } - if (Status st = m_view->import_step(file_data); !st.is_ok()) + if (Status st = m_view->import_step(file_data, union_shapes); !st.is_ok()) + { show_message(st.message()); - else - show_message("Imported: " + std::filesystem::path(file_path).filename().string()); + return false; + } + + show_message(union_shapes ? "Imported (union): " + std::filesystem::path(file_path).filename().string() + : "Imported: " + std::filesystem::path(file_path).filename().string()); + return true; +} + +void GUI::on_inspector_file(const std::string& file_path, const std::string& file_data) +{ + open_file_inspector_(file_path, file_data); } #ifdef __EMSCRIPTEN__ @@ -3893,7 +4003,9 @@ void GUI::import_file_dialog_async() var length = contents.length; var contentsPtr = _malloc(length); HEAPU8.set(contents, contentsPtr); - Module.ccall('on_import_file_selected', null, [ 'string', 'number', 'number' ], [ fileName, contentsPtr, length ]); + // File -> Import opens the Import dialog (metadata, then Import into project). + Module.ccall('on_inspector_file_selected', null, [ 'string', 'number', 'number' ], + [ fileName, contentsPtr, length ]); _free(contentsPtr); }; reader.readAsArrayBuffer(file); @@ -3998,6 +4110,11 @@ extern "C" void on_import_file_selected(const char* file_path, char* contents, i const std::string file_bytes(contents, static_cast(length)); GUI::instance().on_import_file(file_path, file_bytes); } +extern "C" void on_inspector_file_selected(const char* file_path, char* contents, int length) +{ + const std::string file_bytes(contents, static_cast(length)); + GUI::instance().on_inspector_file(file_path, file_bytes); +} extern "C" void on_sketch_underlay_selected(const char* file_path, char* contents, int length) { const std::string file_bytes(contents, static_cast(length)); diff --git a/src/gui.h b/src/gui.h index 315949f..55fbe35 100644 --- a/src/gui.h +++ b/src/gui.h @@ -23,6 +23,7 @@ #include "mode.h" #include "gui_occt_view.h" #include "shp_info.h" +#include "utl_cad_file_info.h" #include "utl_types.h" #include @@ -356,7 +357,7 @@ class GUI #ifdef __EMSCRIPTEN__ void open_file_dialog_async(); // Emscripten: hidden ; no custom title (browser UI) - void import_file_dialog_async(); // STEP / PLY import (routes to on_import_file) + void import_file_dialog_async(); // STEP / PLY: open Import dialog before loading void save_file_dialog_async(const char* title, const std::string& default_file, const std::vector& ezy_bytes); void download_blob_async(const std::string& default_filename, const std::string& data); /// After browser download save, remember basename for window title and Save-as default. @@ -364,7 +365,8 @@ class GUI #endif void on_file(const std::string& file_path, const std::string& file_bytes, bool announce_load = true); - void on_import_file(const std::string& file_path, const std::string& file_data); + [[nodiscard]] bool on_import_file(const std::string& file_path, const std::string& file_data, bool union_shapes = false); + void on_inspector_file(const std::string& file_path, const std::string& file_data); /// Emscripten `on_sketch_underlay_selected` routes here (must be public for C callback). void on_sketch_underlay_file(const std::string& file_path, const std::string& file_bytes); @@ -411,6 +413,9 @@ class GUI void shape_list_(); void shape_info_dialog_(); void open_shape_info_(const Shp_ptr& shape); + void file_inspector_dialog_(); + void open_file_inspector_(const std::string& file_path, const std::string& file_bytes); + void close_file_inspector_(); // Mode + Options panel (gui_mode.cpp) void options_(); @@ -671,6 +676,12 @@ class GUI bool m_shape_info_open{false}; Shp_ptr m_shape_info_shp; std::vector m_shape_info_lines; + bool m_file_inspector_open{false}; + bool m_file_inspector_union{false}; + std::string m_file_inspector_path; + std::string m_file_inspector_bytes; + utl_cad_file_info::Format m_file_inspector_fmt{utl_cad_file_info::Format::Unknown}; + std::vector m_file_inspector_lines; std::string m_about_markdown; uint32_t m_about_splash_gl{0}; glm::ivec2 m_about_splash_size{512, 512}; diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index 86e1e48..aba527e 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -33,7 +34,6 @@ #include #include #include -#include #include #include #include @@ -66,6 +66,7 @@ #include "utl.h" #include "utl_json.h" #include "utl_occt.h" +#include "utl_cad_file_info.h" #ifdef __EMSCRIPTEN__ #include @@ -3243,40 +3244,57 @@ Status Occt_view::export_document(Export_format fmt, Export_unit unit, const std return Status::user_error("Unknown export format."); } -Status Occt_view::import_step(const std::string& step_data) +Status Occt_view::import_step(const std::string& step_data, const bool union_shapes) { - // Force cascade mm so file SI / conversion units land in a known space before our inch scale. - Interface_Static::SetCVal("xstep.cascade.unit", "MM"); + std::vector named; + if (Status st = utl_cad_file_info::read_step_named_bodies(step_data, named); !st.is_ok()) + return st; - STEPControl_Reader reader; - std::istringstream stream(step_data); - - IFSelect_ReturnStatus read_st = reader.ReadStream("", stream); - if (read_st != IFSelect_RetDone) - return Status::user_error("STEP: could not read file (invalid or corrupt STEP data)."); - - if (reader.TransferRoots() == 0) - return Status::user_error("STEP: no geometry was transferred from the file."); - - const double to_model = step_import_to_model_scale_(get_dimension_scale()); - const int num_shps = reader.NbShapes(); - std::vector to_add; - to_add.reserve(static_cast(num_shps)); - for (int i = 1; i <= num_shps; ++i) + const double to_model = step_import_to_model_scale_(get_dimension_scale()); + std::vector to_add; + to_add.reserve(named.size()); + for (utl_cad_file_info::Named_body& body : named) { - TopoDS_Shape shape = reader.Shape(i); - if (!shape.IsNull()) - to_add.push_back(scale_shape_about_origin_(shape, to_model)); + if (body.shape.IsNull()) + continue; + + body.shape = scale_shape_about_origin_(body.shape, to_model); + to_add.push_back(std::move(body)); } if (to_add.empty()) return Status::user_error("STEP: no valid shapes in file."); + bool did_union = false; + if (union_shapes && to_add.size() >= 2) + { + TopoDS_Shape result = to_add[0].shape; + for (size_t i = 1; i < to_add.size(); ++i) + { + BRepAlgoAPI_Fuse fuse_op(result, to_add[i].shape); + if (!fuse_op.IsDone()) + return Status::user_error("STEP: union of imported shapes failed."); + + result = fuse_op.Shape(); + if (result.IsNull()) + return Status::user_error("STEP: union produced an empty shape."); + } + to_add.clear(); + to_add.push_back(utl_cad_file_info::Named_body{result, "Fused"}); + did_union = true; + } + std::vector added; added.reserve(to_add.size()); - for (const TopoDS_Shape& shape : to_add) + for (utl_cad_file_info::Named_body& body : to_add) { - Shp_ptr shp = new Shp(*m_ctx, shape); + Shp_ptr shp = new Shp(*m_ctx, body.shape); + if (did_union) + shp->set_name(unique_shape_name_("Fused")); + else if (!body.name.empty()) + shp->set_name(unique_shape_name_(body.name.c_str())); + else + shp->set_name(unique_shape_name_("Shape")); add_shp_(shp); added.push_back(capture_shape_rec(*shp)); } diff --git a/src/gui_occt_view.h b/src/gui_occt_view.h index d05f2d3..9ce2c6d 100644 --- a/src/gui_occt_view.h +++ b/src/gui_occt_view.h @@ -95,7 +95,8 @@ class Occt_view : protected AIS_ViewController Ezy_asset_store& asset_store() { return m_assets; } const Ezy_asset_store& asset_store() const { return m_assets; } /// Import STEP (OCCT reads cascade mm) scaled into model space (inches * dimension_scale). - [[nodiscard]] Status import_step(const std::string& step_data); + /// When \a union_shapes is true and the file has multiple roots, fuse them into one solid first. + [[nodiscard]] Status import_step(const std::string& step_data, bool union_shapes = false); /// Import PLY (coords treated as inches) scaled into model space (* dimension_scale). bool import_ply(const std::string& ply_bytes); diff --git a/src/utl_cad_file_info.cpp b/src/utl_cad_file_info.cpp new file mode 100644 index 0000000..4e51f7d --- /dev/null +++ b/src/utl_cad_file_info.cpp @@ -0,0 +1,586 @@ +#include "utl_cad_file_info.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "utl_occt.h" +#include "utl_types.h" + +namespace utl_cad_file_info +{ +namespace +{ +void add_line(std::vector& out, const char* label, const std::string& value) +{ + out.push_back({label, value}); +} + +void add_blank(std::vector& out) { out.push_back({"", ""}); } + +std::string to_lower_ext(const std::string& path) +{ + std::string ext = std::filesystem::path(path).extension().string(); + for (char& c : ext) + c = static_cast(std::tolower(static_cast(c))); + + return ext; +} + +std::string fmt_bytes(const size_t n) +{ + char buf[64]; + if (n < 1024) + std::snprintf(buf, sizeof(buf), "%zu B", n); + else if (n < 1024ull * 1024ull) + std::snprintf(buf, sizeof(buf), "%.1f KB", static_cast(n) / 1024.0); + else + std::snprintf(buf, sizeof(buf), "%.2f MB", static_cast(n) / (1024.0 * 1024.0)); + + return buf; +} + +std::string fmt_double(const double v) +{ + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.6g", v); + return buf; +} + +bool starts_with_ci(const std::string& s, const char* prefix) +{ + const size_t n = std::strlen(prefix); + if (s.size() < n) + return false; + + for (size_t i = 0; i < n; ++i) + if (std::tolower(static_cast(s[i])) != std::tolower(static_cast(prefix[i]))) + return false; + + return true; +} + +bool looks_like_ply(const std::string& bytes) { return starts_with_ci(bytes, "ply"); } + +bool looks_like_step(const std::string& bytes) +{ + // ISO-10303-21 exchange files typically start with "ISO-10303-21;" + return bytes.find("ISO-10303-21") != std::string::npos; +} + +bool looks_like_iges(const std::string& bytes) +{ + // Start / global / directory / parameter / terminate section markers (cols 73-80). + if (bytes.size() < 80) + return false; + + const char c73 = bytes[72]; + return c73 == 'S' || c73 == 'G' || c73 == 'D' || c73 == 'P' || c73 == 'T'; +} + +bool is_binary_stl(const std::string& bytes) +{ + if (bytes.size() < 84) + return false; + + uint32_t tri_count = 0; + std::memcpy(&tri_count, bytes.data() + 80, sizeof(tri_count)); + const size_t expected = 84ull + 50ull * static_cast(tri_count); + return expected == bytes.size(); +} + +bool looks_like_stl(const std::string& bytes) +{ + if (is_binary_stl(bytes)) + return true; + + return starts_with_ci(bytes, "solid"); +} + +int count_subshapes(const TopoDS_Shape& shape, const TopAbs_ShapeEnum type) +{ + int n = 0; + for (TopExp_Explorer exp(shape, type); exp.More(); exp.Next()) + ++n; + + return n; +} + +void append_shape_summary(std::vector& lines, const TopoDS_Shape& shape) +{ + if (shape.IsNull()) + { + add_line(lines, "Shape", "null"); + return; + } + + add_line(lines, "Solids", std::to_string(count_subshapes(shape, TopAbs_SOLID))); + add_line(lines, "Shells", std::to_string(count_subshapes(shape, TopAbs_SHELL))); + add_line(lines, "Faces", std::to_string(count_subshapes(shape, TopAbs_FACE))); + add_line(lines, "Wires", std::to_string(count_subshapes(shape, TopAbs_WIRE))); + add_line(lines, "Edges", std::to_string(count_subshapes(shape, TopAbs_EDGE))); + add_line(lines, "Vertices", std::to_string(count_subshapes(shape, TopAbs_VERTEX))); + + Bnd_Box bbox; + BRepBndLib::Add(shape, bbox); + if (bbox.IsVoid()) + return; + + double xmin, ymin, zmin, xmax, ymax, zmax; + bbox.Get(xmin, ymin, zmin, xmax, ymax, zmax); + add_blank(lines); + add_line(lines, "BBox X", fmt_double(xmin) + " .. " + fmt_double(xmax)); + add_line(lines, "BBox Y", fmt_double(ymin) + " .. " + fmt_double(ymax)); + add_line(lines, "BBox Z", fmt_double(zmin) + " .. " + fmt_double(zmax)); + add_line(lines, "BBox size", + fmt_double(xmax - xmin) + " x " + fmt_double(ymax - ymin) + " x " + fmt_double(zmax - zmin)); +} + +void append_common(std::vector& lines, const std::string& file_path, const std::string& file_bytes, Format fmt) +{ + const std::string name = std::filesystem::path(file_path).filename().string(); + add_line(lines, "File", name.empty() ? file_path : name); + if (!file_path.empty() && + (file_path.find('/') != std::string::npos || file_path.find('\\') != std::string::npos)) + add_line(lines, "Path", file_path); + + add_line(lines, "Format", format_label(fmt)); + add_line(lines, "Size", fmt_bytes(file_bytes.size())); + add_line(lines, "Importable", can_import(fmt) ? "yes (File -> Import)" : "no (export-only)"); + add_line(lines, "Exportable", "yes (File -> Export)"); +} + +std::vector collect_step(const std::string& file_path, const std::string& file_bytes) +{ + std::vector lines; + append_common(lines, file_path, file_bytes, Format::Step); + add_blank(lines); + + Interface_Static::SetCVal("xstep.cascade.unit", "MM"); + + STEPControl_Reader reader; + std::istringstream stream(file_bytes); + const IFSelect_ReturnStatus read_st = reader.ReadStream("", stream); + if (read_st != IFSelect_RetDone) + { + add_line(lines, "Status", "could not read STEP data"); + return lines; + } + + const int nb_roots = reader.NbRootsForTransfer(); + add_line(lines, "Roots", std::to_string(nb_roots)); + + const int transferred = reader.TransferRoots(); + add_line(lines, "Transferred", std::to_string(transferred)); + add_line(lines, "Shapes", std::to_string(reader.NbShapes())); + + std::vector bodies; + for (int i = 1; i <= reader.NbShapes(); ++i) + append_cad_import_bodies(reader.Shape(i), bodies); + add_line(lines, "Import bodies", std::to_string(bodies.size())); + + int named_count = 0; + { + std::vector named; + if (read_step_named_bodies(file_bytes, named).is_ok()) + { + for (const Named_body& b : named) + if (!b.name.empty()) + ++named_count; + } + } + add_line(lines, "Named bodies", std::to_string(named_count)); + + const char* cascade = Interface_Static::CVal("xstep.cascade.unit"); + if (cascade && cascade[0] != '\0') + add_line(lines, "Cascade unit", cascade); + + add_blank(lines); + append_shape_summary(lines, reader.OneShape()); + return lines; +} + +std::vector collect_iges(const std::string& file_path, const std::string& file_bytes) +{ + std::vector lines; + append_common(lines, file_path, file_bytes, Format::Iges); + add_blank(lines); + + IGESControl_Reader reader; + std::istringstream stream(file_bytes); + const IFSelect_ReturnStatus read_st = reader.ReadStream("", stream); + if (read_st != IFSelect_RetDone) + { + add_line(lines, "Status", "could not read IGES data"); + return lines; + } + + const int nb_roots = reader.NbRootsForTransfer(); + add_line(lines, "Roots", std::to_string(nb_roots)); + + const int transferred = reader.TransferRoots(); + add_line(lines, "Transferred", std::to_string(transferred)); + add_line(lines, "Shapes", std::to_string(reader.NbShapes())); + + add_blank(lines); + append_shape_summary(lines, reader.OneShape()); + return lines; +} + +std::vector collect_stl(const std::string& file_path, const std::string& file_bytes) +{ + std::vector lines; + append_common(lines, file_path, file_bytes, Format::Stl); + add_blank(lines); + + if (is_binary_stl(file_bytes)) + { + uint32_t tri_count = 0; + std::memcpy(&tri_count, file_bytes.data() + 80, sizeof(tri_count)); + std::string header(file_bytes.data(), 80); + while (!header.empty() && (header.back() == '\0' || std::isspace(static_cast(header.back())))) + header.pop_back(); + + add_line(lines, "Encoding", "binary"); + if (!header.empty()) + add_line(lines, "Header", header); + + add_line(lines, "Triangles", std::to_string(tri_count)); + return lines; + } + + add_line(lines, "Encoding", "ASCII"); + size_t facets = 0; + size_t pos = 0; + while (pos < file_bytes.size()) + { + const size_t end = file_bytes.find('\n', pos); + const size_t len = (end == std::string::npos ? file_bytes.size() : end) - pos; + std::string line(file_bytes, pos, len); + pos = (end == std::string::npos) ? file_bytes.size() : end + 1; + + // Trim leading spaces + size_t i = 0; + while (i < line.size() && std::isspace(static_cast(line[i]))) + ++i; + + if (line.compare(i, 5, "facet") == 0) + ++facets; + else if (facets == 0 && line.compare(i, 5, "solid") == 0) + { + std::string name = line.substr(i + 5); + size_t j = 0; + while (j < name.size() && std::isspace(static_cast(name[j]))) + ++j; + + name.erase(0, j); + if (!name.empty()) + add_line(lines, "Solid name", name); + } + } + + add_line(lines, "Triangles", std::to_string(facets)); + return lines; +} + +std::vector collect_ply(const std::string& file_path, const std::string& file_bytes) +{ + std::vector lines; + append_common(lines, file_path, file_bytes, Format::Ply); + add_blank(lines); + + if (!looks_like_ply(file_bytes)) + { + add_line(lines, "Status", "not a PLY file (missing ply magic)"); + return lines; + } + + std::istringstream in(file_bytes); + std::string line; + if (!std::getline(in, line)) + { + add_line(lines, "Status", "empty file"); + return lines; + } + + std::string format = "unknown"; + int n_vert = -1; + int n_face = -1; + bool ended = false; + + while (std::getline(in, line)) + { + if (!line.empty() && line.back() == '\r') + line.pop_back(); + + if (line == "end_header") + { + ended = true; + break; + } + + if (line.rfind("format ", 0) == 0) + format = line.substr(7); + else if (line.rfind("element vertex ", 0) == 0) + n_vert = std::atoi(line.c_str() + 15); + else if (line.rfind("element face ", 0) == 0) + n_face = std::atoi(line.c_str() + 13); + } + + add_line(lines, "Encoding", format); + if (n_vert >= 0) + add_line(lines, "Vertices", std::to_string(n_vert)); + + if (n_face >= 0) + add_line(lines, "Faces", std::to_string(n_face)); + + if (!ended) + add_line(lines, "Status", "header incomplete (no end_header)"); + + return lines; +} +} // namespace + +Format detect(const std::string& file_path, const std::string& file_bytes) +{ + const std::string ext = to_lower_ext(file_path); + if (ext == ".step" || ext == ".stp") + return Format::Step; + + if (ext == ".igs" || ext == ".iges") + return Format::Iges; + + if (ext == ".stl") + return Format::Stl; + + if (ext == ".ply") + return Format::Ply; + + // Content sniff when extension is missing or wrong (e.g. browser basename only). + if (looks_like_ply(file_bytes)) + return Format::Ply; + + if (looks_like_step(file_bytes)) + return Format::Step; + + if (looks_like_stl(file_bytes)) + return Format::Stl; + + if (looks_like_iges(file_bytes)) + return Format::Iges; + + return Format::Unknown; +} + +bool can_import(Format fmt) { return fmt == Format::Step || fmt == Format::Ply; } + +const char* format_label(Format fmt) +{ + switch (fmt) + { + case Format::Step: + return "STEP"; + case Format::Iges: + return "IGES"; + case Format::Stl: + return "STL"; + case Format::Ply: + return "PLY"; + default: + return "Unknown"; + } +} + +std::vector collect(const std::string& file_path, const std::string& file_bytes) +{ + const Format fmt = detect(file_path, file_bytes); + switch (fmt) + { + case Format::Step: + return collect_step(file_path, file_bytes); + case Format::Iges: + return collect_iges(file_path, file_bytes); + case Format::Stl: + return collect_stl(file_path, file_bytes); + case Format::Ply: + return collect_ply(file_path, file_bytes); + default: + { + std::vector lines; + append_common(lines, file_path, file_bytes, Format::Unknown); + add_blank(lines); + add_line(lines, "Status", "unsupported or unrecognized format"); + add_line(lines, "Hint", "Open STEP, IGES, STL, or PLY"); + return lines; + } + } +} + +namespace +{ +std::string trim_name(std::string s) +{ + while (!s.empty() && std::isspace(static_cast(s.back()))) + s.pop_back(); + + size_t i = 0; + while (i < s.size() && std::isspace(static_cast(s[i]))) + ++i; + + s.erase(0, i); + return s; +} + +std::string xcaf_label_name(const TDF_Label& lab) +{ + TDataStd_Name_ptr attr; + if (!lab.FindAttribute(TDataStd_Name::GetID(), attr) || attr.IsNull()) + return {}; + + const TCollection_AsciiString ascii(attr->Get(), '?'); + return trim_name(std::string(ascii.ToCString())); +} + +std::string xcaf_product_name(const XCAFDoc_ShapeTool_ptr& shapes, const TDF_Label& lab) +{ + TDF_Label ref; + if (shapes->GetReferredShape(lab, ref)) + { + const std::string referred = xcaf_label_name(ref); + if (!referred.empty()) + return referred; + } + + return xcaf_label_name(lab); +} + +void append_named_from_label(const XCAFDoc_ShapeTool_ptr& shapes, const TDF_Label& lab, std::vector& out) +{ + if (shapes->IsAssembly(lab)) + { + NCollection_Sequence comps; + shapes->GetComponents(lab, comps, false); + for (int i = 1; i <= comps.Length(); ++i) + append_named_from_label(shapes, comps.Value(i), out); + return; + } + + const std::string name = xcaf_product_name(shapes, lab); + TopoDS_Shape shape; + if (!XCAFDoc_ShapeTool::GetShape(lab, shape) || shape.IsNull()) + return; + + std::vector bodies; + append_cad_import_bodies(shape, bodies); + for (TopoDS_Shape& body : bodies) + out.push_back(Named_body{std::move(body), name}); +} + +Status read_step_named_bodies_xcaf(const std::string& file_bytes, std::vector& out) +{ + Interface_Static::SetCVal("xstep.cascade.unit", "MM"); + + STEPCAFControl_Reader reader; + reader.SetNameMode(true); + reader.SetColorMode(false); + reader.SetLayerMode(false); + + std::istringstream stream(file_bytes); + const IFSelect_ReturnStatus read_st = reader.ReadStream("", stream); + if (read_st != IFSelect_RetDone) + return Status::user_error("STEP: could not read file (invalid or corrupt STEP data)."); + + XCAFApp_Application_ptr app = XCAFApp_Application::GetApplication(); + if (app.IsNull()) + return Status::user_error("STEP: XCAF application unavailable."); + + TDocStd_Document_ptr doc; + app->NewDocument("MDTV-XCAF", doc); + if (doc.IsNull()) + return Status::user_error("STEP: could not create XCAF document."); + + if (!reader.Transfer(doc)) + return Status::user_error("STEP: no geometry was transferred from the file."); + + XCAFDoc_ShapeTool_ptr shapes = XCAFDoc_DocumentTool::ShapeTool(doc->Main()); + if (shapes.IsNull()) + return Status::user_error("STEP: missing XCAF shape tool."); + + NCollection_Sequence free_shapes; + shapes->GetFreeShapes(free_shapes); + if (free_shapes.IsEmpty()) + return Status::user_error("STEP: no valid shapes in file."); + + for (int i = 1; i <= free_shapes.Length(); ++i) + append_named_from_label(shapes, free_shapes.Value(i), out); + + if (out.empty()) + return Status::user_error("STEP: no valid shapes in file."); + + return Status::ok(); +} + +Status read_step_named_bodies_plain(const std::string& file_bytes, std::vector& out) +{ + Interface_Static::SetCVal("xstep.cascade.unit", "MM"); + + STEPControl_Reader reader; + std::istringstream stream(file_bytes); + if (reader.ReadStream("", stream) != IFSelect_RetDone) + return Status::user_error("STEP: could not read file (invalid or corrupt STEP data)."); + + if (reader.TransferRoots() == 0) + return Status::user_error("STEP: no geometry was transferred from the file."); + + for (int i = 1; i <= reader.NbShapes(); ++i) + { + TopoDS_Shape shape = reader.Shape(i); + if (shape.IsNull()) + continue; + + std::vector bodies; + append_cad_import_bodies(shape, bodies); + for (TopoDS_Shape& body : bodies) + out.push_back(Named_body{std::move(body), {}}); + } + + if (out.empty()) + return Status::user_error("STEP: no valid shapes in file."); + + return Status::ok(); +} +} // namespace + +Status read_step_named_bodies(const std::string& file_bytes, std::vector& out) +{ + out.clear(); + const Status xcaf = read_step_named_bodies_xcaf(file_bytes, out); + if (xcaf.is_ok()) + return xcaf; + + out.clear(); + return read_step_named_bodies_plain(file_bytes, out); +} +} // namespace utl_cad_file_info diff --git a/src/utl_cad_file_info.h b/src/utl_cad_file_info.h new file mode 100644 index 0000000..35b7d6d --- /dev/null +++ b/src/utl_cad_file_info.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +#include + +#include "utl.h" + +/// Read-only metadata for CAD/mesh files EzyCad can import and/or export. +/// Does not modify the document. +namespace utl_cad_file_info +{ +enum class Format : uint8_t +{ + Unknown, + Step, + Iges, + Stl, + Ply +}; + +struct Line +{ + std::string label; + std::string value; +}; + +/// One importable body from STEP (geometry may still need project unit scaling). +struct Named_body +{ + TopoDS_Shape shape; + std::string name; // empty if the file had no usable name +}; + +[[nodiscard]] Format detect(const std::string& file_path, const std::string& file_bytes); + +/// True for formats File -> Import can load (STEP, PLY). +[[nodiscard]] bool can_import(Format fmt); + +[[nodiscard]] const char* format_label(Format fmt); + +/// Collect label/value rows for the Tools -> Inspector window. +[[nodiscard]] std::vector collect(const std::string& file_path, const std::string& file_bytes); + +/// Read STEP bodies with XCAF product/part names when present. +[[nodiscard]] Status read_step_named_bodies(const std::string& file_bytes, std::vector& out); +} // namespace utl_cad_file_info diff --git a/src/utl_occt.cpp b/src/utl_occt.cpp index 4abeab0..90dad8b 100644 --- a/src/utl_occt.cpp +++ b/src/utl_occt.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include const char* standard_failure_message(const Standard_Failure& e) @@ -134,3 +135,47 @@ TopoDS_Shape try_make_solid(const TopoDS_Shape& shape) return shape; } } + +void append_cad_import_bodies(const TopoDS_Shape& shape, std::vector& out) +{ + if (shape.IsNull()) + return; + + switch (shape.ShapeType()) + { + case TopAbs_COMPOUND: + { + // STEP assemblies usually transfer as one compound with many nested solids. + bool has_solid = false; + for (TopExp_Explorer exp(shape, TopAbs_SOLID); exp.More(); exp.Next()) + { + has_solid = true; + out.push_back(exp.Current()); + } + if (has_solid) + return; + + bool has_shell = false; + for (TopExp_Explorer exp(shape, TopAbs_SHELL); exp.More(); exp.Next()) + { + has_shell = true; + out.push_back(exp.Current()); + } + if (has_shell) + return; + + for (TopoDS_Iterator it(shape); it.More(); it.Next()) + append_cad_import_bodies(it.Value(), out); + return; + } + + case TopAbs_COMPSOLID: + for (TopExp_Explorer exp(shape, TopAbs_SOLID); exp.More(); exp.Next()) + out.push_back(exp.Current()); + return; + + default: + out.push_back(shape); + return; + } +} diff --git a/src/utl_occt.h b/src/utl_occt.h index 6b87d11..6b7d9f5 100644 --- a/src/utl_occt.h +++ b/src/utl_occt.h @@ -4,6 +4,7 @@ #include #include #include +#include // Parallel to Open CASCADE TopAbs_ShapeEnum (TopAbs_ShapeEnum.hxx). Order must stay in sync with OCCT. #define EZY_TOPABS_SHAPE_ENUM_LIST(X) \ @@ -31,6 +32,10 @@ static_assert(c_names_TopAbs_ShapeEnum.size() == static_cast(TopAbs /// Otherwise returns \a shape unchanged. TopoDS_Shape try_make_solid(const TopoDS_Shape& shape); +/// Split a STEP/IGES transfer result into document-level bodies. +/// Compounds / compsolids expand to nested solids (or free shells if there are no solids). +void append_cad_import_bodies(const TopoDS_Shape& shape, std::vector& out); + class Standard_Failure; /// Message from `Standard_Failure` for logging / `Status` text. diff --git a/src/utl_types.h b/src/utl_types.h index 0ad440d..a91de87 100644 --- a/src/utl_types.h +++ b/src/utl_types.h @@ -50,6 +50,10 @@ class Wasm_Window; class WNT_WClass; class WNT_Window; class Xw_Window; +class TDocStd_Document; +class XCAFApp_Application; +class XCAFDoc_ShapeTool; +class TDataStd_Name; using AIS_InteractiveObject_ptr = opencascade::handle; using AIS_InteractiveContext_ptr = opencascade::handle; @@ -88,12 +92,16 @@ using Sketch_AIS_edge_ptr = opencascade::handle; using Sketch_AIS_node_mark_ptr = opencascade::handle; using Sketch_face_shp_ptr = opencascade::handle; using StdSelect_BRepOwner_ptr = opencascade::handle; +using TDataStd_Name_ptr = opencascade::handle; +using TDocStd_Document_ptr = opencascade::handle; using V3d_RectangularGrid_ptr = opencascade::handle; using V3d_View_ptr = opencascade::handle; using V3d_Viewer_ptr = opencascade::handle; using Wasm_Window_ptr = opencascade::handle; using WNT_WClass_ptr = opencascade::handle; using WNT_Window_ptr = opencascade::handle; +using XCAFApp_Application_ptr = opencascade::handle; +using XCAFDoc_ShapeTool_ptr = opencascade::handle; using Xw_Window_ptr = opencascade::handle; #define DECL_PTR(TypeName) \ From ff621db6f7dcd82291cf4e1829cd3dca4350c13d Mon Sep 17 00:00:00 2001 From: Trailcode Date: Sun, 19 Jul 2026 17:13:34 -0600 Subject: [PATCH 2/8] Shape list groups --- CHANGELOG.md | 7 +- docs/usage.md | 28 +- src/doc/gui.md | 2 + src/doc/shape.md | 13 +- src/doc/utility.md | 3 +- src/gui.cpp | 410 ++++++++++++++++++------- src/gui.h | 4 + src/gui_occt_view.cpp | 631 ++++++++++++++++++++++++++++++++------ src/gui_occt_view.h | 35 ++- src/shp.cpp | 48 ++- src/shp.h | 17 + src/shp_common.cpp | 1 + src/shp_cut.cpp | 1 + src/shp_delta.cpp | 61 +++- src/shp_delta.h | 29 ++ src/shp_extrude.cpp | 2 +- src/shp_fuse.cpp | 1 + src/shp_operation.cpp | 24 +- src/shp_operation.h | 5 +- src/shp_polar_dup.cpp | 2 + src/utl_cad_file_info.cpp | 132 ++++++++ src/utl_cad_file_info.h | 14 +- tests/shp_tests.cpp | 198 ++++++++++++ 23 files changed, 1438 insertions(+), 230 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f0a438..746aee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Shape List + +- **Shape List** is a hierarchical outliner: organizational **groups**, expand/collapse, drag-and-drop reparent, **Group** / **New group** / **Ungroup**, cascade delete, and Ctrl+click multi-select. Click a group (including empty) to set the **current group**; new primitives/extrudes/revolves are added there. Group visibility hides the subtree. **Hide all** no longer clears per-shape visibility flags. Boolean results inherit a shared parent when inputs share one. STEP import preserves XCAF assembly groups (unless **Union shapes**). +- Persisted in `.ezy`: `parentId`, `order`, `isGroup`, `visible`; UI expand state and current group in `ui.shapeList.expanded` / `ui.shapeList.currentGroupId`. + ### Import / export -- **File -> Import** opens an **Import** dialog with file metadata, optional **Union shapes** for STEP, then **Import into project**. The dialog closes after a successful import. Multi-body STEP assemblies import as separate Shape List entries unless **Union shapes** is checked. STEP product/part names are used in the Shape List when present (with `.001` suffixes if needed). +- **File -> Import** opens an **Import** dialog with file metadata, optional **Union shapes** for STEP, then **Import into project**. The dialog closes after a successful import. Multi-body STEP assemblies import as a **group tree** of Shape List entries unless **Union shapes** is checked. STEP product/part names are used in the Shape List when present (with `.001` suffixes if needed). - Removed the **Tools** menu (**Inspector** lives under **File -> Import**). Removed **File -> Sketch underlay image...** (import underlays from **Sketch properties**). ### Project units diff --git a/docs/usage.md b/docs/usage.md index c5683be..9cf43e5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -107,21 +107,29 @@ The window can be closed with its close button; use **View -> Sketch List** agai ### Shape List -The **Shape List** pane lists every **3D shape** in the current document (extrudes, imports, booleans, etc.). Open it from **View -> Shape List**. +The **Shape List** pane lists every **3D shape** and **group** in the current document (extrudes, imports, booleans, etc.). Open it from **View -> Shape List**. At the top: -- **Hide all** - When checked, hides every shape in the 3D view; when cleared, every shape is shown again (same as turning visibility back on for all rows). +- **Hide all** - When checked, hides every solid in the 3D view without changing each row's visibility checkbox; when cleared, solids return to their previous per-row visibility (and group visibility). +- **New group** - Creates an empty organizational group under the **current group** (document root when none is set) and makes it current. +- **Group** - Places the currently selected solids under a new group (enabled when one or more solids are selected in the 3D view) and makes that group current. -For each shape, one row includes: +Shapes form a parent/child outliner. Groups are folders only (they do not move children when transformed). Drag a row onto a group to reparent it, or onto a solid to place it under that solid's parent. -- **Name** - Editable text field; change the label stored with the shape. -- **Right-click the name** - **Shape info...** opens a dialog with topology and property details for that shape (see [Shape info](#shape-info) below). **Delete** removes the shape from the document. -- **Visibility** - Checkbox (tooltip *visibility*) to show or hide that shape in the 3D view. -- **Solid / wire** - Checkbox (tooltip *solid/wire*) to switch **shaded** display or **wireframe** for that shape. -- **M** - Click to open a **Material** popup; right-click **M** for **Shape info...** or **Delete**. The tooltip on **M** also notes that right-clicking the name deletes the shape. +Click a **group** (including an empty one) to make it the **current group**. Click a solid to select it and set the current group to that solid's parent. New primitives, extrudes, revolves, and similar additions go into the current group. -Rows that match the **current 3D selection** use a brighter row style (text and controls) so the list stays in sync with what is selected in the viewer (tooltip *Selected in 3D viewer* when you hover a highlighted row). Hovering any visible row also highlights that shape in the 3D view (**Settings -> View presentation -> Element hover color**). +Each row (left to right): + +- **Name** - Expandable tree row with an editable name. Click the row to select that solid (or all descendant solids for a group) and update the current group. **Ctrl+click** toggles multi-select. Drag to reparent. +- **Visibility** - Checkbox to show or hide that node. Hiding a **group** hides its whole subtree in the 3D view. +- **Solid / wire** - Checkbox (solids only) to switch **shaded** or **wireframe**. +- **M** - Solids only: material popup; right-click for **Shape info...** or **Delete**. +- **Right-click the name** - Solids: **Shape info...** / **Delete**. Groups: **Ungroup** (moves **all** direct children to the group's parent, then removes the group) / **Delete** (cascade-deletes the whole subtree). + +Boolean results stay under the shared parent of their inputs when all inputs share one parent; otherwise they are placed at the document root. **File -> Import** STEP assemblies preserve product/assembly groups in the tree (unless **Union shapes** is checked). + +The **current group** and rows that match the **current 3D selection** use a highlighted full-row style. Hovering a visible solid row also highlights that shape in the 3D view (**Settings -> View presentation -> Element hover color**). The window can be closed with its close button; use **View -> Shape List** again to show it. @@ -295,7 +303,7 @@ In addition to creating 3D shapes from sketches, EzyCad supports importing exist **STEP import notes:** - If the file cannot be read or contains no transferable geometry, a **message** explains the failure (invalid data, empty transfer, etc.). -- Assemblies often arrive as a single compound containing many solids. Import expands those into separate Shape List entries. The Import dialog **Import bodies** count shows how many shapes will be added. +- Assemblies often arrive with XCAF product structure. Import builds Shape List **groups** for assemblies and leaf solids for bodies (unless **Union shapes** merges everything into one solid). The Import dialog **Import bodies** count shows how many leaf solids will be added. - When the STEP file includes product or part names, those names appear in the Shape List (duplicate names get `.001`, `.002`, ...). Unnamed bodies stay as `Shape`. - **Union shapes** (Import dialog) fuses those bodies into one solid before adding to the document. If union fails, the import is aborted and nothing is added. diff --git a/src/doc/gui.md b/src/doc/gui.md index 3a0e5fb..103630d 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -209,6 +209,8 @@ Shared sketch controls (snap, midpoint nodes, place-from-center) live in `option Sketch List expand **Faces**: each face row supports **`E`** and right-click **Extrude** via `GUI::sketch_list_extrude_face_` (`set_mode(Sketch_face_extrude)` + `Occt_view::begin_sketch_face_extrude` / `Shp_extrude::begin_face_extrude`). Hovering a **Faces**, **Edges**, or **Nodes** row calls `Occt_view::set_sketch_list_hover_{face,edge,node}` (temporarily displays the AIS when hidden outside sketch modes; uses `Graphic3d_ZLayerId_Topmost` so solids do not occlude the highlight). +**Shape List outliner:** `shape_list_` draws a tree of document shapes/groups via `shape_children(0)` and recursive `TreeNodeEx` rows (visibility, solid/wire, name, material). Groups support expand/collapse (`ui.shapeList.expanded`), drag-drop reparent (`EZY_SHAPE_ID` payload), Group / New group / Ungroup, and cascade delete. Clicking a group sets `Occt_view::current_group_id` (including empty groups) and selects descendant solids; clicking a solid selects it and sets current group to its parent. New primitives/extrudes/revolves parent under the current group. Ctrl+click multi-selects. Hover uses `set_shape_list_hover` on leaf solids only. `ui.shapeList.currentGroupId` is persisted in `.ezy`. + **Sketch List UI in the project file:** `GUI::serialized_project_json_` writes `ui.sketchList` (scroll Y plus per-sketch `rows` keyed by sketch `id`: `expanded`, `dimensions`, `nodes`, `edges`, `faces`). `GUI::on_file` restores via `apply_sketch_list_ui_from_json_`. Subsection open state is app-owned (`Sketch_list_row_ui` + `SetNextItemOpen`), not ImGui ini storage. 3D redraw: `render_occt()` -> `Occt_view::do_frame()` (separate from ImGui pass). diff --git a/src/doc/shape.md b/src/doc/shape.md index a0711f1..ae0796a 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -31,9 +31,12 @@ Typical uses: ### Lifetime and ownership - Shapes are stored in `Occt_view::m_shps` (`std::list`). Access via `get_shapes()` or internal `add_shp_()`. -- `Shp_ptr` is `opencascade::handle`. New shapes are allocated with `new Shp(ctx(), topo_shape)` then registered through `Occt_view::add_shp_()`. +- `Shp_ptr` is `opencascade::handle`. New shapes are allocated with `new Shp(ctx(), topo_shape)` then registered through `Occt_view::add_shp_()`. Groups use `Shp::create_group` (empty compound, never displayed). +- Hierarchy: `parent_id` (0 = root) + `sibling_order`. Organizational groups only (no transform inheritance). Helpers: `shape_children`, `shape_descendant_solids`, `group_shapes`, `ungroup_shape`, `reparent_shape`, `would_reparent_create_cycle`. +- **Current group** (`Occt_view::current_group_id`, 0 = root): Shape List click sets it; empty groups are valid. Primitives / extrude / revolve / PLY / unioned STEP import call `add_shp_(..., use_current_group=true)` so new solids land under that group. Booleans keep `assign_result_parent_`. - `Shp_operation_base` is a **`friend` of `Occt_view`** so operations can call `add_shp_()`, read selection, and use pick helpers without exposing those on the public view API. - **`Occt_view&` must outlive** all `Shp_*` operation objects (they are member subobjects of the view). +- Boolean/polar results call `assign_result_parent_` so the new solid shares the inputs' parent when all match; otherwise root (`parent_id` 0). ### Adding a shape to the document @@ -105,11 +108,15 @@ There is no single `Shape` coordinator class; **`Occt_view` is the hub** and exp ```cpp class Shp : public AIS_Shape { Shp(AIS_InteractiveContext& ctx, const TopoDS_Shape& shp); - // name, display mode (shaded/wire), visibility, selection mode (TopAbs_ShapeEnum) + static handle create_group(...); // organizational node; not displayed + // name, display mode, visibility preference, parent_id, sibling_order, is_group + void apply_context_shown(bool); // Erase/Display without changing get_visible() }; ``` -`set_visible(false)` erases from context; `true` re-displays with the effective display mode (user `m_disp_mode`, or sketch-mode faint override) and selection mode. `set_sketch_faint` toggles a temporary ghost/wire override without changing `get_disp_mode()`; while faint is active the shape is display-only (not selectable, so hover/selection highlight does not apply). `update_display_()` re-binds selection after mode changes. +`set_visible` stores the user preference. `Occt_view::sync_sketch_shape_faint_style` applies effective visibility (own flag, ancestor groups, Hide all overlay, sketch faint/hide) via `apply_context_shown` so Hide all does not stomp per-shape flags. `update_display_()` re-binds selection after mode changes. + +`.ezy` `shapes[]` entries include `id`, `name`, `parentId`, `order`, `visible`, and either `isGroup: true` or `material` + `geom`. Undo uses `Shape_rec` (same fields) plus `Shape_tree_delta` for reparent/group/ungroup. ## `Shp_operation_base` diff --git a/src/doc/utility.md b/src/doc/utility.md index 1ec8977..72a634b 100644 --- a/src/doc/utility.md +++ b/src/doc/utility.md @@ -132,7 +132,8 @@ Used by **File -> Import**. Reads file bytes only until the user confirms import | `detect(path, bytes)` | Format from extension, then content sniff | | `can_import(fmt)` | True for STEP and PLY | | `collect(path, bytes)` | Label/value rows (size, roots/shapes, mesh header, bbox, etc.) | -| `read_step_named_bodies` | STEPCAF/XCAF bodies + product names (falls back to plain reader) | +| `read_step_named_bodies` | STEPCAF/XCAF bodies + product names (flat; falls back to plain reader) | +| `read_step_named_tree` | STEPCAF/XCAF assembly tree as group/leaf `Named_node`s (falls back flat) | ## Logging and debug diff --git a/src/gui.cpp b/src/gui.cpp index 3e8b1b0..42ef73b 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -2440,14 +2441,6 @@ void GUI::shape_list_() return; } - float max_name_text_w = 0.f; - for (const Shp_ptr& s : m_view->get_shapes()) - { - EZY_ASSERT(s); - const std::string& nm = s->get_name(); - max_name_text_w = std::max(max_name_text_w, ImGui::CalcTextSize(nm.c_str(), nm.c_str() + nm.size()).x); - } - if (!ImGui::Begin("Shape List", &m_show_shape_list, ImGuiWindowFlags_None)) { m_view->set_shape_list_hover(nullptr); @@ -2455,12 +2448,6 @@ void GUI::shape_list_() return; } - ImGui::BeginChild("##shape_list_scroll", ImVec2(0.f, 0.f), false, ImGuiWindowFlags_HorizontalScrollbar); - - const float name_field_w = list_name_field_width_(ImGui::GetStyle(), max_name_text_w); - int index = 0; - - // Add checkbox for hiding all shapes except current sketches if (ImGui::Checkbox("Hide all", &m_hide_all_shapes)) { if (m_hide_all_shapes) @@ -2468,6 +2455,28 @@ void GUI::shape_list_() m_view->sync_sketch_shape_faint_style(); } + ImGui::SameLine(); + if (ImGui::SmallButton("New group")) + { + Shp_ptr grp = m_view->create_group("Group", m_view->current_group_id()); + if (!grp.IsNull()) + m_view->set_current_group_id(grp->get_id()); + } + + ImGui::SameLine(); + { + const std::vector sel = m_view->get_selected_shps(); + const bool can_group = !sel.empty(); + if (!can_group) + ImGui::BeginDisabled(); + + if (ImGui::SmallButton("Group")) + (void)m_view->group_shapes(sel); + + if (!can_group) + ImGui::EndDisabled(); + } + ImGui::Separator(); const std::vector& mat_names = occt_material_combo_labels_(); @@ -2479,54 +2488,88 @@ void GUI::shape_list_() const ImGuiStyle& st_mat = ImGui::GetStyle(); const float mat_popup_w = std::min(440.0f, std::max(280.0f, mat_label_w_max + st_mat.WindowPadding.x * 2.0f + st_mat.FramePadding.x * 2.0f + st_mat.ScrollbarSize + 8.0f)); + const float check_col_w = ImGui::GetFrameHeight(); + const float mat_col_w = ImGui::CalcTextSize("M").x + st_mat.FramePadding.x * 2.0f; - Shp_ptr shape_to_delete; - Shp_ptr shape_list_hover; + Shp_ptr shape_to_delete; + Shp_ptr shape_list_hover; + Shape_id shape_to_ungroup_id = 0; std::unordered_set selected_in_viewer; for (const AIS_Shape_ptr& ais : m_view->get_selected()) if (!ais.IsNull()) selected_in_viewer.insert(ais.get()); - for (const Shp_ptr& shape : m_view->get_shapes()) + auto row_is_selected = [&](const Shp_ptr& shape) -> bool { - const bool row_selected = selected_in_viewer.count(shape.get()) != 0; + if (shape.IsNull()) + return false; - if (row_selected) + if (shape->is_group()) { - const ImVec4 header = ImGui::GetStyleColorVec4(ImGuiCol_Header); - ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(header.x, header.y, header.z, 0.40f)); - ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, ImVec4(header.x, header.y, header.z, 0.55f)); - ImGui::PushStyleColor(ImGuiCol_FrameBgActive, ImVec4(header.x, header.y, header.z, 0.65f)); - const ImVec4 text = ImGui::GetStyleColorVec4(ImGuiCol_Text); - - // Shape List: when a row matches OCCT selection, ImGuiCol_Text is nudged brighter (RGB only). - constexpr float k_shape_list_selected_text_rgb_scale = 1.08f; // per-channel multiplier for a modest relative lift - constexpr float k_shape_list_selected_text_rgb_bias = - 0.04f; // added after scaling so very dark text still reads a bit lighter - - ImGui::PushStyleColor( - ImGuiCol_Text, - ImVec4(std::min(1.0f, text.x * k_shape_list_selected_text_rgb_scale + k_shape_list_selected_text_rgb_bias), - std::min(1.0f, text.y * k_shape_list_selected_text_rgb_scale + k_shape_list_selected_text_rgb_bias), - std::min(1.0f, text.z * k_shape_list_selected_text_rgb_scale + k_shape_list_selected_text_rgb_bias), text.w)); + for (const Shp_ptr& leaf : m_view->shape_descendant_solids(shape->get_id())) + if (selected_in_viewer.count(leaf.get()) != 0) + return true; + + return false; } + return selected_in_viewer.count(shape.get()) != 0; + }; - ImGui::BeginGroup(); + auto select_shape_row = [&](const Shp_ptr& shape) + { + AIS_InteractiveContext& ctx = m_view->ctx(); + const bool ctrl = ImGui::GetIO().KeyCtrl; + if (!ctrl) + ctx.ClearSelected(false); + + if (shape->is_group()) + { + m_view->set_current_group_id(shape->get_id()); + for (const Shp_ptr& leaf : m_view->shape_descendant_solids(shape->get_id())) + ctx.AddOrRemoveSelected(leaf, true); + } + else + { + m_view->set_current_group_id(shape->get_parent_id()); + ctx.AddOrRemoveSelected(shape, true); + } + + ctx.UpdateCurrentViewer(); + }; + + // Tree node always uses NoTreePushOnOpen; indent children with an explicit TreePush/TreePop + // pair so table rows cannot leave the ImGui tree stack unbalanced (which nested siblings + // under the wrong parent and made Ungroup look like it only moved one child). + auto draw_shape_row = [&](auto&& self, const Shp_ptr& shape) -> void + { + EZY_ASSERT(shape); + const bool is_group = shape->is_group(); + const std::vector children = m_view->shape_children(shape->get_id()); + const bool has_children = !children.empty(); + const bool is_current_group = is_group && shape->get_id() == m_view->current_group_id(); + const bool row_selected = is_current_group || row_is_selected(shape); + bool row_hovered = false; + + ImGui::PushID(static_cast(shape->get_id())); + ImGui::TableNextRow(); + if (row_selected) + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::GetColorU32(ImGuiCol_Header, 0.45f)); - // Unique ID suffix using index - std::string id_suffix = "##" + std::to_string(index++); - // Editable text box for name char name_buffer[1024]; safe_cstr_copy(name_buffer, sizeof(name_buffer), shape->get_name().c_str()); - int mat_idx = shape->Material(); - if (mat_idx < 0 || mat_idx >= nmat) - mat_idx = static_cast(m_view->get_default_material().Name()); + int mat_idx = 0; + if (!is_group) + { + mat_idx = shape->Material(); + if (mat_idx < 0 || mat_idx >= nmat) + mat_idx = static_cast(m_view->get_default_material().Name()); + } auto apply_shape_material = [&](int i) { - if (i < 0 || i >= nmat) + if (is_group || i < 0 || i >= nmat) return; shape->SetMaterial(Graphic3d_MaterialAspect(static_cast(i))); @@ -2535,122 +2578,212 @@ void GUI::shape_list_() m_view->ctx().UpdateCurrentViewer(); }; - ImGui::PushID(("name" + id_suffix).c_str()); - ImGui::SetNextItemWidth(name_field_w); - if (ImGui::InputText("", name_buffer, sizeof(name_buffer))) + // Column 0: tree arrow + name (first column so indent applies like imgui_demo Tables/Tree view) + ImGui::TableSetColumnIndex(0); + ImGui::AlignTextToFramePadding(); + + ImGuiTreeNodeFlags node_flags = ImGuiTreeNodeFlags_SpanAllColumns | ImGuiTreeNodeFlags_FramePadding | + ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_DrawLinesToNodes | + ImGuiTreeNodeFlags_NoTreePushOnOpen; + if (!has_children) + node_flags |= ImGuiTreeNodeFlags_Leaf; + + if (row_selected) + node_flags |= ImGuiTreeNodeFlags_Selected; + + bool open = true; + if (has_children) + { + const auto exp_it = m_shape_list_expanded.find(shape->get_id()); + open = (exp_it == m_shape_list_expanded.end()) ? true : exp_it->second; + ImGui::SetNextItemOpen(open); + } + + ImGui::SetNextItemAllowOverlap(); + const bool node_open = ImGui::TreeNodeEx("##node", node_flags); + if (has_children && node_open != open) + m_shape_list_expanded[shape->get_id()] = node_open; + + if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) + select_shape_row(shape); + + row_hovered |= ImGui::IsItemHovered(); + if (row_selected && ui_show_contextual_help() && ImGui::IsItemHovered()) + ImGui::SetTooltip(is_current_group ? "Current group (new shapes go here)" : "Selected in 3D viewer"); + + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) + { + const Shape_id drag_id = shape->get_id(); + ImGui::SetDragDropPayload("EZY_SHAPE_ID", &drag_id, sizeof(drag_id)); + ImGui::TextUnformatted(shape->get_name().c_str()); + ImGui::EndDragDropSource(); + } + + if (ImGui::BeginDragDropTarget()) + { + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("EZY_SHAPE_ID")) + { + Shape_id drag_id = 0; + std::memcpy(&drag_id, payload->Data, sizeof(drag_id)); + const Shape_id new_parent = is_group ? shape->get_id() : shape->get_parent_id(); + (void)m_view->reparent_shape(drag_id, new_parent, -1, true); + } + ImGui::EndDragDropTarget(); + } + + ImGui::SameLine(); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::InputText("##name", name_buffer, sizeof(name_buffer))) shape->set_name(std::string(name_buffer)); + if (ImGui::IsItemClicked()) + select_shape_row(shape); + + row_hovered |= ImGui::IsItemHovered(); if (ImGui::BeginPopupContextItem("shape_name_ctx")) { - if (ImGui::MenuItem("Shape info...")) + if (!is_group && ImGui::MenuItem("Shape info...")) open_shape_info_(shape); + if (is_group && ImGui::MenuItem("Ungroup")) + shape_to_ungroup_id = shape->get_id(); + if (ImGui::MenuItem("Delete")) shape_to_delete = shape; ImGui::EndPopup(); } - ImGui::PopID(); - // Visibility checkbox - ImGui::SameLine(); - ImGui::PushID(("visible" + id_suffix).c_str()); + // Column 1: visibility + ImGui::TableSetColumnIndex(1); bool visible = shape->get_visible(); - if (ImGui::Checkbox("", &visible)) + if (ImGui::Checkbox("##vis", &visible)) { if (!visible && m_view->shape_list_hover() == shape) m_view->set_shape_list_hover(nullptr); shape->set_visible(visible); + m_view->sync_sketch_shape_faint_style(); } - + row_hovered |= ImGui::IsItemHovered(); if (ui_show_contextual_help() && ImGui::IsItemHovered()) - ImGui::SetTooltip("visibility"); + ImGui::SetTooltip(is_group ? "Show/hide group subtree" : "visibility"); - ImGui::PopID(); + // Column 2: solid/wire (leaves only) + ImGui::TableSetColumnIndex(2); + if (is_group) + ImGui::TextUnformatted(""); + else + { + bool shaded = shape->get_disp_mode() == AIS_Shaded; + if (ImGui::Checkbox("##shaded", &shaded)) + shape->set_disp_mode(shaded ? AIS_Shaded : AIS_WireFrame); - // Shaded display mode checkbox - ImGui::SameLine(); - ImGui::PushID(("shaded" + id_suffix).c_str()); - bool shaded = shape->get_disp_mode() == AIS_Shaded; - if (ImGui::Checkbox("", &shaded)) - shape->set_disp_mode(shaded ? AIS_Shaded : AIS_WireFrame); + row_hovered |= ImGui::IsItemHovered(); + if (ui_show_contextual_help() && ImGui::IsItemHovered()) + ImGui::SetTooltip("solid/wire"); + } - if (ui_show_contextual_help() && ImGui::IsItemHovered()) - ImGui::SetTooltip("solid/wire"); + // Column 3: material (leaves only) + ImGui::TableSetColumnIndex(3); + if (is_group) + ImGui::TextUnformatted(""); + else + { + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4.0f, ImGui::GetStyle().FramePadding.y)); + if (ImGui::Button("M")) + ImGui::OpenPopup("mat_pick"); - ImGui::PopID(); + ImGui::PopStyleVar(); + row_hovered |= ImGui::IsItemHovered(); + if (ui_show_contextual_help() && ImGui::IsItemHovered()) + ImGui::SetTooltip("%s\n(click: material; right-click name: Delete)", mat_names[static_cast(mat_idx)].c_str()); - ImGui::SameLine(); - ImGui::PushID(("matbtn" + id_suffix).c_str()); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4.0f, ImGui::GetStyle().FramePadding.y)); - if (ImGui::Button("M")) - ImGui::OpenPopup("mat_pick"); + ImGui::SetNextWindowSize(ImVec2(mat_popup_w, 0.0f), ImGuiCond_Appearing); + if (ImGui::BeginPopup("mat_pick")) + { + ImGui::TextUnformatted("Material"); + ImGui::Separator(); + const float max_h = ImGui::GetTextLineHeightWithSpacing() * 12.0f; + const float sc_w = std::max(1.0f, ImGui::GetContentRegionAvail().x); + if (ImGui::BeginChild("mat_sc", ImVec2(sc_w, max_h), ImGuiChildFlags_Borders, ImGuiWindowFlags_AlwaysVerticalScrollbar)) + { + for (int i = 0; i < nmat; ++i) + if (ImGui::Selectable(mat_names[static_cast(i)].c_str(), i == mat_idx)) + { + apply_shape_material(i); + ImGui::CloseCurrentPopup(); + } + + ImGui::EndChild(); + } - ImGui::PopStyleVar(); - if (ui_show_contextual_help() && ImGui::IsItemHovered()) - ImGui::SetTooltip("%s\n(click: material; right-click name: Delete)", mat_names[static_cast(mat_idx)].c_str()); + ImGui::EndPopup(); + } - ImGui::SetNextWindowSize(ImVec2(mat_popup_w, 0.0f), ImGuiCond_Appearing); - if (ImGui::BeginPopup("mat_pick")) - { - ImGui::TextUnformatted("Material"); - ImGui::Separator(); - const float max_h = ImGui::GetTextLineHeightWithSpacing() * 12.0f; - const float sc_w = std::max(1.0f, ImGui::GetContentRegionAvail().x); - if (ImGui::BeginChild("mat_sc", ImVec2(sc_w, max_h), ImGuiChildFlags_Borders, ImGuiWindowFlags_AlwaysVerticalScrollbar)) + if (ImGui::BeginPopupContextItem("mat_btn_ctx")) { - for (int i = 0; i < nmat; ++i) - if (ImGui::Selectable(mat_names[static_cast(i)].c_str(), i == mat_idx)) - { - apply_shape_material(i); - ImGui::CloseCurrentPopup(); - } + if (ImGui::MenuItem("Shape info...")) + open_shape_info_(shape); - ImGui::EndChild(); - } + if (ImGui::MenuItem("Delete")) + shape_to_delete = shape; - ImGui::EndPopup(); + ImGui::EndPopup(); + } } - if (ImGui::BeginPopupContextItem("mat_btn_ctx")) - { - if (ImGui::MenuItem("Shape info...")) - open_shape_info_(shape); - - if (ImGui::MenuItem("Delete")) - shape_to_delete = shape; + if (row_hovered && shape->get_visible() && !is_group) + shape_list_hover = shape; - ImGui::EndPopup(); + if (has_children && node_open) + { + ImGui::TreePush(reinterpret_cast(static_cast(shape->get_id()))); + for (const Shp_ptr& child : children) + self(self, child); + ImGui::TreePop(); } ImGui::PopID(); + }; - ImGui::EndGroup(); + const ImGuiTableFlags table_flags = + ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_NoBordersInBody | ImGuiTableFlags_SizingStretchProp; + if (ImGui::BeginTable("##shape_outliner", 4, table_flags, ImVec2(0.f, 0.f))) + { + ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("vis", ImGuiTableColumnFlags_WidthFixed, check_col_w); + ImGui::TableSetupColumn("disp", ImGuiTableColumnFlags_WidthFixed, check_col_w); + ImGui::TableSetupColumn("mat", ImGuiTableColumnFlags_WidthFixed, mat_col_w); - if (ImGui::IsItemHovered() && shape->get_visible()) - shape_list_hover = shape; + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, + ImVec2(st_mat.FramePadding.x, std::max(1.0f, st_mat.FramePadding.y * 0.65f))); + ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(st_mat.CellPadding.x, std::max(1.0f, st_mat.CellPadding.y * 0.5f))); - if (row_selected) - { - ImGui::PopStyleColor(4); - if (ui_show_contextual_help() && ImGui::IsItemHovered()) - ImGui::SetTooltip("Selected in 3D viewer"); - } + for (const Shp_ptr& root : m_view->shape_children(0)) + draw_shape_row(draw_shape_row, root); + + ImGui::PopStyleVar(2); + ImGui::EndTable(); } m_view->set_shape_list_hover(shape_list_hover); + if (shape_to_ungroup_id != 0) + { + m_shape_list_expanded.erase(shape_to_ungroup_id); + (void)m_view->ungroup_shape(shape_to_ungroup_id); + } + if (shape_to_delete) m_view->delete_shapes({shape_to_delete}); - ImGui::EndChild(); ImGui::End(); } void GUI::open_shape_info_(const Shp_ptr& shape) { - if (shape.IsNull()) + if (shape.IsNull() || shape->is_group()) return; const std::vector& mat_names = occt_material_combo_labels_(); @@ -3217,6 +3350,63 @@ void GUI::clear_sketch_list_ui_() m_sketch_list_ui.clear(); m_sketch_list_scroll_y = 0.f; m_sketch_list_scroll_restore = false; + m_shape_list_expanded.clear(); +} + +nlohmann::json GUI::shape_list_ui_to_json_() const +{ + using namespace nlohmann; + json out; + json expanded = json::object(); + for (const auto& [id, is_open] : m_shape_list_expanded) + { + if (!is_open) + expanded[std::to_string(id)] = false; + } + if (!expanded.empty()) + out["expanded"] = std::move(expanded); + + if (m_view && m_view->current_group_id() != 0) + out["currentGroupId"] = m_view->current_group_id(); + + return out; +} + +void GUI::apply_shape_list_ui_from_json_(const nlohmann::json& j) +{ + using namespace nlohmann; + m_shape_list_expanded.clear(); + if (m_view) + m_view->set_current_group_id(0); + + if (!j.contains("ui") || !j["ui"].is_object()) + return; + + const json& ui = j["ui"]; + if (!ui.contains("shapeList") || !ui["shapeList"].is_object()) + return; + + const json& sl = ui["shapeList"]; + if (sl.contains("expanded") && sl["expanded"].is_object()) + { + for (auto it = sl["expanded"].begin(); it != sl["expanded"].end(); ++it) + { + if (!it.value().is_boolean()) + continue; + + try + { + const Shape_id id = static_cast(std::stoull(it.key())); + m_shape_list_expanded[id] = it.value().get(); + } + catch (...) + { + } + } + } + + if (m_view && sl.contains("currentGroupId") && sl["currentGroupId"].is_number_unsigned()) + m_view->set_current_group_id(sl["currentGroupId"].get()); } nlohmann::json GUI::sketch_list_ui_to_json_() const @@ -3309,7 +3499,8 @@ std::string GUI::serialized_project_json_() const std::string project_json = m_view->to_json(); json j = json::parse(project_json); j["mode"] = static_cast(get_mode()); - j["ui"]["sketchList"] = sketch_list_ui_to_json_(); + j["ui"]["sketchList"] = sketch_list_ui_to_json_(); + j["ui"]["shapeList"] = shape_list_ui_to_json_(); return j.dump(2); } @@ -3897,6 +4088,7 @@ void GUI::on_file(const std::string& file_path, const std::string& file_bytes, b m_view->load(*manifest); log_message("on_file: load complete"); apply_sketch_list_ui_from_json_(j); + apply_shape_list_ui_from_json_(j); m_last_saved_path = file_path; Mode opened_mode = Mode::Normal; if (j.contains("mode") && j["mode"].is_number_integer()) diff --git a/src/gui.h b/src/gui.h index 55fbe35..48fc544 100644 --- a/src/gui.h +++ b/src/gui.h @@ -408,6 +408,8 @@ class GUI void clear_sketch_list_ui_(); void apply_sketch_list_ui_from_json_(const nlohmann::json& j); [[nodiscard]] nlohmann::json sketch_list_ui_to_json_() const; + [[nodiscard]] nlohmann::json shape_list_ui_to_json_() const; + void apply_shape_list_ui_from_json_(const nlohmann::json& j); void sketch_properties_dialog_(); void sketch_origin_panel_settings_(const std::shared_ptr& sk); void shape_list_(); @@ -664,6 +666,8 @@ class GUI Example_file_list m_example_files; std::unordered_map m_sketch_list_ui; + /// Shape List group expand state (project `ui.shapeList.expanded`); missing id defaults to open. + std::unordered_map m_shape_list_expanded; float m_sketch_list_scroll_y{0.f}; bool m_sketch_list_scroll_restore{false}; diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index aba527e..f37a40a 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -588,7 +588,7 @@ void Occt_view::revolve_selected(const double angle) Shp_rslt revolved = curr_sketch().revolve_selected(angle); if (revolved.has_value()) { - add_shp_(*revolved); + add_shp_(*revolved, true); push_undo_delta(std::make_unique(std::vector{capture_shape_rec(**revolved)})); // Leave sketch mode so the new solid is shown at full strength (sketch tools use faint/hide). gui().set_mode(Mode::Normal); @@ -940,20 +940,62 @@ void Occt_view::refresh_shape_shading_(const Shp_ptr& shp) #endif } -void Occt_view::add_shp_(Shp_ptr& shp) +void Occt_view::add_shp_(Shp_ptr& shp, bool use_current_group) { if (shp->get_id() == 0) shp->set_id(allocate_shape_id()); - shp->SetMaterial(m_default_material); - refresh_shape_shading_(shp); - shp->set_selection_mode(m_shp_selection_mode); - m_ctx->Redisplay(shp, true); - m_ctx->UpdateCurrentViewer(); + if (use_current_group && !shp->is_group() && shp->get_parent_id() == 0) + { + ensure_current_group_valid_(); + if (m_current_group_id != 0) + { + shp->set_parent_id(m_current_group_id); + shp->set_sibling_order(next_sibling_order(m_current_group_id)); + } + } + + if (!shp->is_group()) + { + shp->SetMaterial(m_default_material); + refresh_shape_shading_(shp); + shp->set_selection_mode(m_shp_selection_mode); + m_ctx->Redisplay(shp, true); + m_ctx->UpdateCurrentViewer(); + } + m_shps.push_back(shp); sync_sketch_shape_faint_style(); } +void Occt_view::ensure_current_group_valid_() +{ + if (m_current_group_id == 0) + return; + + Shp_ptr g = find_shape_by_id(m_current_group_id); + if (g.IsNull() || !g->is_group()) + m_current_group_id = 0; +} + +void Occt_view::set_current_group_id(Shape_id id) +{ + if (id == 0) + { + m_current_group_id = 0; + return; + } + + Shp_ptr g = find_shape_by_id(id); + if (g.IsNull() || !g->is_group()) + { + m_current_group_id = 0; + return; + } + + m_current_group_id = id; +} + Shape_id Occt_view::allocate_shape_id() { return m_next_shape_id++; } void Occt_view::adopt_shape_id(Shape_id id) @@ -971,25 +1013,318 @@ Shp_ptr Occt_view::find_shape_by_id(Shape_id id) const return Shp_ptr(); } +int Occt_view::next_sibling_order(Shape_id parent_id) const +{ + int max_order = -1; + for (const Shp_ptr& s : m_shps) + { + if (s.IsNull() || s->get_parent_id() != parent_id) + continue; + + max_order = std::max(max_order, s->get_sibling_order()); + } + return max_order + 1; +} + +bool Occt_view::would_reparent_create_cycle(Shape_id id, Shape_id new_parent) const +{ + if (new_parent == 0) + return false; + + if (new_parent == id) + return true; + + Shape_id walk = new_parent; + while (walk != 0) + { + if (walk == id) + return true; + + Shp_ptr p = find_shape_by_id(walk); + if (p.IsNull()) + break; + + walk = p->get_parent_id(); + } + return false; +} + +std::vector Occt_view::shape_children(Shape_id parent_id) const +{ + std::vector kids; + for (const Shp_ptr& s : m_shps) + if (!s.IsNull() && s->get_parent_id() == parent_id) + kids.push_back(s); + + std::sort(kids.begin(), kids.end(), [](const Shp_ptr& a, const Shp_ptr& b) + { + if (a->get_sibling_order() != b->get_sibling_order()) + return a->get_sibling_order() < b->get_sibling_order(); + return a->get_id() < b->get_id(); + }); + return kids; +} + +std::vector Occt_view::shape_descendant_solids(Shape_id id) const +{ + std::vector out; + Shp_ptr root = find_shape_by_id(id); + if (root.IsNull()) + return out; + + std::vector stack = {root}; + while (!stack.empty()) + { + Shp_ptr n = stack.back(); + stack.pop_back(); + if (n.IsNull()) + continue; + + if (!n->is_group()) + { + out.push_back(n); + continue; + } + + for (const Shp_ptr& c : shape_children(n->get_id())) + stack.push_back(c); + } + return out; +} + +bool Occt_view::shape_ancestors_visible(const Shp& shp) const +{ + Shape_id walk = shp.get_parent_id(); + while (walk != 0) + { + Shp_ptr p = find_shape_by_id(walk); + if (p.IsNull()) + break; + + if (!p->get_visible()) + return false; + + walk = p->get_parent_id(); + } + return true; +} + +Shape_id Occt_view::result_parent_id(const std::vector& operands) +{ + if (operands.empty() || operands[0].IsNull()) + return 0; + + const Shape_id primary = operands[0]->get_parent_id(); + for (size_t i = 1; i < operands.size(); ++i) + { + if (operands[i].IsNull()) + continue; + + if (operands[i]->get_parent_id() != primary) + return 0; + } + return primary; +} + +void Occt_view::apply_shape_link(Shape_id id, Shape_id parent_id, int sibling_order) +{ + Shp_ptr shp = find_shape_by_id(id); + if (shp.IsNull()) + return; + + shp->set_parent_id(parent_id); + shp->set_sibling_order(sibling_order); +} + +Shp_ptr Occt_view::create_group(const std::string& name, Shape_id parent_id) +{ + Shp_ptr grp = Shp::create_group(*m_ctx, get_unique_shape_name(name.c_str())); + grp->set_parent_id(parent_id); + grp->set_sibling_order(next_sibling_order(parent_id)); + add_shp_(grp); + push_undo_delta(std::make_unique(std::vector{capture_shape_rec(*grp)})); + return grp; +} + +Status Occt_view::group_shapes(const std::vector& nodes) +{ + std::vector to_group; + to_group.reserve(nodes.size()); + for (const Shp_ptr& n : nodes) + if (!n.IsNull()) + to_group.push_back(n); + + if (to_group.empty()) + return Status::user_error("Select one or more shapes to group."); + + Shape_id parent = to_group[0]->get_parent_id(); + for (size_t i = 1; i < to_group.size(); ++i) + if (to_group[i]->get_parent_id() != parent) + { + parent = 0; + break; + } + + Shp_ptr grp = Shp::create_group(*m_ctx, get_unique_shape_name("Group")); + grp->set_id(allocate_shape_id()); + grp->set_parent_id(parent); + grp->set_sibling_order(next_sibling_order(parent)); + + std::vector links; + links.reserve(to_group.size()); + int order = 0; + for (const Shp_ptr& n : to_group) + { + if (would_reparent_create_cycle(n->get_id(), grp->get_id())) + return Status::user_error("Cannot group: would create a cycle."); + + Shape_tree_delta::Link_change ch; + ch.id = n->get_id(); + ch.old_parent = n->get_parent_id(); + ch.old_order = n->get_sibling_order(); + ch.new_parent = grp->get_id(); + ch.new_order = order++; + links.push_back(ch); + } + + m_shps.push_back(grp); + for (const Shape_tree_delta::Link_change& ch : links) + apply_shape_link(ch.id, ch.new_parent, ch.new_order); + + m_current_group_id = grp->get_id(); + push_undo_delta(std::make_unique(std::vector{capture_shape_rec(*grp)}, + std::vector{}, std::move(links))); + sync_sketch_shape_faint_style(); + return Status::ok(); +} + +Status Occt_view::ungroup_shape(Shape_id group_id) +{ + Shp_ptr grp = find_shape_by_id(group_id); + if (grp.IsNull() || !grp->is_group()) + return Status::user_error("Ungroup requires a group."); + + const Shape_id parent = grp->get_parent_id(); + + // Snapshot every direct child id up front (all must move one level up). + std::vector kid_ids; + kid_ids.reserve(m_shps.size()); + for (const Shp_ptr& s : m_shps) + if (!s.IsNull() && s->get_id() != group_id && s->get_parent_id() == group_id) + kid_ids.push_back(s->get_id()); + + std::vector links; + links.reserve(kid_ids.size()); + int order = next_sibling_order(parent); + for (Shape_id kid_id : kid_ids) + { + Shp_ptr c = find_shape_by_id(kid_id); + if (c.IsNull()) + continue; + + Shape_tree_delta::Link_change ch; + ch.id = kid_id; + ch.old_parent = c->get_parent_id(); + ch.old_order = c->get_sibling_order(); + ch.new_parent = parent; + ch.new_order = order++; + links.push_back(ch); + apply_shape_link(ch.id, ch.new_parent, ch.new_order); + } + + // Belt-and-suspenders: nothing may keep pointing at the removed group. + for (const Shp_ptr& s : m_shps) + { + if (s.IsNull() || s->get_id() == group_id || s->get_parent_id() != group_id) + continue; + + Shape_tree_delta::Link_change ch; + ch.id = s->get_id(); + ch.old_parent = group_id; + ch.old_order = s->get_sibling_order(); + ch.new_parent = parent; + ch.new_order = order++; + links.push_back(ch); + apply_shape_link(ch.id, ch.new_parent, ch.new_order); + } + + Shape_rec removed = capture_shape_rec(*grp); + remove_shape_by_id(group_id); + if (m_current_group_id == group_id) + m_current_group_id = parent; + + push_undo_delta(std::make_unique(std::vector{}, std::vector{std::move(removed)}, + std::move(links))); + sync_sketch_shape_faint_style(); + return Status::ok(); +} + +Status Occt_view::reparent_shape(Shape_id id, Shape_id new_parent, int sibling_order, bool push_undo) +{ + Shp_ptr shp = find_shape_by_id(id); + if (shp.IsNull()) + return Status::user_error("Shape not found."); + + if (new_parent != 0) + { + Shp_ptr p = find_shape_by_id(new_parent); + if (p.IsNull() || !p->is_group()) + return Status::user_error("Parent must be a group (or root)."); + } + + if (would_reparent_create_cycle(id, new_parent)) + return Status::user_error("Cannot reparent: would create a cycle."); + + const int new_order = (sibling_order < 0) ? next_sibling_order(new_parent) : sibling_order; + + Shape_tree_delta::Link_change ch; + ch.id = id; + ch.old_parent = shp->get_parent_id(); + ch.old_order = shp->get_sibling_order(); + ch.new_parent = new_parent; + ch.new_order = new_order; + + if (ch.old_parent == ch.new_parent && ch.old_order == ch.new_order) + return Status::ok(); + + apply_shape_link(id, new_parent, new_order); + if (push_undo) + push_undo_delta(std::make_unique(std::vector{}, std::vector{}, + std::vector{ch})); + sync_sketch_shape_faint_style(); + return Status::ok(); +} + void Occt_view::insert_shape_rec(const Shape_rec& rec) { - Shp_ptr shp = new Shp(*m_ctx, rec.geom); + Shp_ptr shp; + if (rec.is_group) + { + shp = Shp::create_group(*m_ctx, rec.name); + } + else + { + shp = new Shp(*m_ctx, rec.geom); + const int nmat = Graphic3d_MaterialAspect::NumberOfMaterials(); + int mat_idx = rec.material; + if (mat_idx < 0 || mat_idx >= nmat) + mat_idx = static_cast(m_default_material.Name()); + + shp->SetMaterial(Graphic3d_MaterialAspect(static_cast(mat_idx))); + refresh_shape_shading_(shp); + shp->set_selection_mode(m_shp_selection_mode); + } + shp->set_id(rec.id); adopt_shape_id(rec.id); shp->set_name(rec.name); + shp->set_parent_id(rec.parent_id); + shp->set_sibling_order(rec.sibling_order); + // Prefer writing the flag without relying on Display order; sync applies context. + if (shp->get_visible() != rec.visible) + shp->set_visible(rec.visible); - const int nmat = Graphic3d_MaterialAspect::NumberOfMaterials(); - int mat_idx = rec.material; - if (mat_idx < 0 || mat_idx >= nmat) - mat_idx = static_cast(m_default_material.Name()); - - shp->SetMaterial(Graphic3d_MaterialAspect(static_cast(mat_idx))); - refresh_shape_shading_(shp); - shp->set_selection_mode(m_shp_selection_mode); m_shps.push_back(shp); - m_ctx->Display(shp, shp->get_disp_mode(), AIS_Shape::SelectionMode(m_shp_selection_mode), true); - m_ctx->Redisplay(shp, true); - m_ctx->UpdateCurrentViewer(); sync_sketch_shape_faint_style(); } @@ -1004,8 +1339,11 @@ void Occt_view::remove_shape_by_id(Shape_id id) if (m_shape_list_hover == shp) set_shape_list_hover(nullptr); - m_ctx->Remove(shp, false); + if (!shp->is_group()) + m_ctx->Remove(shp, false); + m_shps.erase(it); + ensure_current_group_valid_(); m_ctx->UpdateCurrentViewer(); return; } @@ -1093,7 +1431,7 @@ void Occt_view::add_box(double ox, double oy, double oz, double width, double le TopoDS_Shape box = shp_create::create_box(ox, oy, oz, width, length, height); Shp_ptr shp = new Shp(*m_ctx, box); shp->set_name(unique_shape_name_("Box")); - add_shp_(shp); + add_shp_(shp, true); m_ctx->Display(shp, shp->get_disp_mode(), AIS_Shape::SelectionMode(m_shp_selection_mode), true); m_view->Redraw(); push_undo_delta(std::make_unique(std::vector{capture_shape_rec(*shp)})); @@ -1114,7 +1452,7 @@ void Occt_view::add_pyramid(double ox, double oy, double oz, double side) shp->SetLocalTransformation(trsf); } - add_shp_(shp); + add_shp_(shp, true); m_ctx->Display(shp, shp->get_disp_mode(), AIS_Shape::SelectionMode(m_shp_selection_mode), true); if (ox != 0 || oy != 0 || oz != 0) { @@ -1138,7 +1476,7 @@ void Occt_view::add_sphere(double ox, double oy, double oz, double radius) shp->SetLocalTransformation(trsf); } - add_shp_(shp); + add_shp_(shp, true); m_ctx->Display(shp, shp->get_disp_mode(), AIS_Shape::SelectionMode(m_shp_selection_mode), true); if (ox != 0 || oy != 0 || oz != 0) { @@ -1162,7 +1500,7 @@ void Occt_view::add_cylinder(double ox, double oy, double oz, double radius, dou shp->SetLocalTransformation(trsf); } - add_shp_(shp); + add_shp_(shp, true); m_ctx->Display(shp, shp->get_disp_mode(), AIS_Shape::SelectionMode(m_shp_selection_mode), true); if (ox != 0 || oy != 0 || oz != 0) { @@ -1186,7 +1524,7 @@ void Occt_view::add_cone(double ox, double oy, double oz, double R1, double R2, shp->SetLocalTransformation(trsf); } - add_shp_(shp); + add_shp_(shp, true); m_ctx->Display(shp, shp->get_disp_mode(), AIS_Shape::SelectionMode(m_shp_selection_mode), true); if (ox != 0 || oy != 0 || oz != 0) { @@ -1210,7 +1548,7 @@ void Occt_view::add_torus(double ox, double oy, double oz, double R1, double R2) shp->SetLocalTransformation(trsf); } - add_shp_(shp); + add_shp_(shp, true); m_ctx->Display(shp, shp->get_disp_mode(), AIS_Shape::SelectionMode(m_shp_selection_mode), true); if (ox != 0 || oy != 0 || oz != 0) { @@ -1382,6 +1720,39 @@ void Occt_view::delete_shapes(std::vector to_delete) if (to_delete.empty()) return; + // Cascade: deleting a group removes its entire subtree. + std::unordered_set seen; + std::vector expanded; + for (const AIS_Shape_ptr& obj : to_delete) + { + if (obj.IsNull()) + continue; + + Shp_ptr shp = Shp_ptr::DownCast(obj); + if (shp.IsNull()) + { + if (seen.insert(obj.get()).second) + expanded.push_back(obj); + + continue; + } + + std::vector stack = {shp}; + while (!stack.empty()) + { + Shp_ptr cur = stack.back(); + stack.pop_back(); + if (cur.IsNull() || !seen.insert(cur.get()).second) + continue; + + expanded.push_back(cur); + if (cur->is_group()) + for (const Shp_ptr& c : shape_children(cur->get_id())) + stack.push_back(c); + } + } + to_delete = std::move(expanded); + std::vector removed_shapes; bool has_non_shape = false; for (const AIS_Shape_ptr& obj : to_delete) @@ -1439,6 +1810,8 @@ void Occt_view::delete_(std::vector& to_delete) else ++itr; + ensure_current_group_valid_(); + for (const AIS_Shape_ptr& shp : to_delete) if (m_shape_list_hover == shp) { @@ -1956,7 +2329,7 @@ std::vector Occt_view::get_selected_shps() const std::vector ret; std::unordered_set seen; for (const AIS_Shape_ptr& obj : get_selected()) - if (Shp_ptr shp = Shp_ptr::DownCast(obj); !shp.IsNull()) + if (Shp_ptr shp = Shp_ptr::DownCast(obj); !shp.IsNull() && !shp->is_group()) if (seen.insert(shp.get()).second) ret.push_back(shp); @@ -2578,26 +2951,29 @@ void Occt_view::sync_sketch_shape_faint_style() for (Shp_ptr& shp : m_shps) { - if (shp.IsNull()) + if (shp.IsNull() || shp->is_group()) continue; - if (hide_all || !sketch || hide_in_sketch) + // Hide all / sketch-hide are overlays: do not write get_visible(). + const bool own_ok = shp->get_visible() && shape_ancestors_visible(*shp); + const bool hide_overlay = hide_all || (sketch && hide_in_sketch); + const bool show = own_ok && !hide_overlay; + + if (faint_active && show) { - shp->set_sketch_faint(false, AIS_Shaded, 0.0f); - if (hide_all || (sketch && hide_in_sketch)) - shp->set_visible(false); + // Ghost (1) or Wire (2) while sketching; strength drives transparency for both. + if (style == 2) + shp->set_sketch_faint(true, AIS_WireFrame, transparency); else - shp->set_visible(true); - continue; - } + shp->set_sketch_faint(true, AIS_Shaded, transparency); - // Ghost (1) or Wire (2) while sketching; strength drives transparency for both. - if (style == 2) - shp->set_sketch_faint(true, AIS_WireFrame, transparency); + shp->apply_context_shown(true); + } else - shp->set_sketch_faint(true, AIS_Shaded, transparency); - - shp->set_visible(true); + { + shp->set_sketch_faint(false, AIS_Shaded, 0.0f); + shp->apply_context_shown(show); + } } if (!m_ctx.IsNull()) @@ -2917,14 +3293,24 @@ std::string Occt_view::to_json() const for (const Shp_ptr& s : m_shps) { - const TopoDS_Shape& shape = s->Shape(); - std::ostringstream oss; - BRepTools::Write(shape, oss, false, false, TopTools_FormatVersion_CURRENT); // Write BREP data to the stream json shp_json; shp_json["id"] = s->get_id(); shp_json["name"] = s->get_name(); - shp_json["material"] = s->Material(); - shp_json["geom"] = oss.str(); + shp_json["parentId"] = s->get_parent_id(); + shp_json["order"] = s->get_sibling_order(); + shp_json["visible"] = s->get_visible(); + if (s->is_group()) + { + shp_json["isGroup"] = true; + } + else + { + const TopoDS_Shape& shape = s->Shape(); + std::ostringstream oss; + BRepTools::Write(shape, oss, false, false, TopTools_FormatVersion_CURRENT); + shp_json["material"] = s->Material(); + shp_json["geom"] = oss.str(); + } shps.push_back(shp_json); } @@ -3001,11 +3387,31 @@ void Occt_view::load(const std::string& json_str, bool restore_view) for (const json& s : j["shapes"]) { - TopoDS_Shape shape; - std::istringstream iss; - iss.str(s["geom"]); - BRepTools::Read(shape, iss, BRep_Builder()); - Shp_ptr shp = new Shp(*m_ctx, shape); + const bool is_group = s.contains("isGroup") && s["isGroup"].is_boolean() && s["isGroup"].get(); + Shp_ptr shp; + if (is_group) + { + shp = Shp::create_group(*m_ctx, s.value("name", "Group")); + } + else + { + TopoDS_Shape shape; + std::istringstream iss; + iss.str(s["geom"]); + BRepTools::Read(shape, iss, BRep_Builder()); + shp = new Shp(*m_ctx, shape); + int mat_idx = static_cast(m_default_material.Name()); + if (s.contains("material") && s["material"].is_number_integer()) + mat_idx = s["material"].get(); + + const int nmat = Graphic3d_MaterialAspect::NumberOfMaterials(); + if (mat_idx < 0 || mat_idx >= nmat) + mat_idx = static_cast(m_default_material.Name()); + + shp->SetMaterial(Graphic3d_MaterialAspect(static_cast(mat_idx))); + refresh_shape_shading_(shp); + } + if (s.contains("id") && s["id"].is_number_unsigned()) { shp->set_id(s["id"].get()); @@ -3014,21 +3420,23 @@ void Occt_view::load(const std::string& json_str, bool restore_view) else shp->set_id(allocate_shape_id()); - shp->set_name(s["name"]); - int mat_idx = static_cast(m_default_material.Name()); - if (s.contains("material") && s["material"].is_number_integer()) - mat_idx = s["material"].get(); + shp->set_name(s.value("name", shp->get_name())); + if (s.contains("parentId") && s["parentId"].is_number_unsigned()) + shp->set_parent_id(s["parentId"].get()); - const int nmat = Graphic3d_MaterialAspect::NumberOfMaterials(); - if (mat_idx < 0 || mat_idx >= nmat) - mat_idx = static_cast(m_default_material.Name()); + if (s.contains("order") && s["order"].is_number_integer()) + shp->set_sibling_order(s["order"].get()); + + if (s.contains("visible") && s["visible"].is_boolean()) + { + const bool vis = s["visible"].get(); + if (shp->get_visible() != vis) + shp->set_visible(vis); + } - shp->SetMaterial(Graphic3d_MaterialAspect(static_cast(mat_idx))); - refresh_shape_shading_(shp); m_shps.push_back(shp); - m_ctx->Display(shp, shp->get_disp_mode(), 0, true); - m_ctx->Redisplay(shp, true); } + sync_sketch_shape_faint_style(); // --------------------------------------------------------------------------- // Restore view / camera state if requested (e.g. File > Open; not for undo/redo). @@ -3246,32 +3654,47 @@ Status Occt_view::export_document(Export_format fmt, Export_unit unit, const std Status Occt_view::import_step(const std::string& step_data, const bool union_shapes) { - std::vector named; - if (Status st = utl_cad_file_info::read_step_named_bodies(step_data, named); !st.is_ok()) + std::vector named; + if (Status st = utl_cad_file_info::read_step_named_tree(step_data, named); !st.is_ok()) return st; - const double to_model = step_import_to_model_scale_(get_dimension_scale()); - std::vector to_add; - to_add.reserve(named.size()); - for (utl_cad_file_info::Named_body& body : named) + const double to_model = step_import_to_model_scale_(get_dimension_scale()); + for (utl_cad_file_info::Named_node& node : named) { - if (body.shape.IsNull()) + if (node.is_group || node.shape.IsNull()) continue; - body.shape = scale_shape_about_origin_(body.shape, to_model); - to_add.push_back(std::move(body)); + node.shape = scale_shape_about_origin_(node.shape, to_model); } - if (to_add.empty()) + bool any_leaf = false; + for (const utl_cad_file_info::Named_node& n : named) + if (!n.is_group && !n.shape.IsNull()) + { + any_leaf = true; + break; + } + + if (!any_leaf) return Status::user_error("STEP: no valid shapes in file."); - bool did_union = false; - if (union_shapes && to_add.size() >= 2) + if (union_shapes) { - TopoDS_Shape result = to_add[0].shape; - for (size_t i = 1; i < to_add.size(); ++i) + TopoDS_Shape result; + bool have = false; + for (utl_cad_file_info::Named_node& node : named) { - BRepAlgoAPI_Fuse fuse_op(result, to_add[i].shape); + if (node.is_group || node.shape.IsNull()) + continue; + + if (!have) + { + result = node.shape; + have = true; + continue; + } + + BRepAlgoAPI_Fuse fuse_op(result, node.shape); if (!fuse_op.IsDone()) return Status::user_error("STEP: union of imported shapes failed."); @@ -3279,26 +3702,53 @@ Status Occt_view::import_step(const std::string& step_data, const bool union_sha if (result.IsNull()) return Status::user_error("STEP: union produced an empty shape."); } - to_add.clear(); - to_add.push_back(utl_cad_file_info::Named_body{result, "Fused"}); - did_union = true; + + Shp_ptr shp = new Shp(*m_ctx, result); + shp->set_name(unique_shape_name_("Fused")); + add_shp_(shp, true); + push_undo_delta(std::make_unique(std::vector{capture_shape_rec(*shp)})); + return Status::ok(); } + std::vector id_by_index(named.size(), 0); std::vector added; - added.reserve(to_add.size()); - for (utl_cad_file_info::Named_body& body : to_add) - { - Shp_ptr shp = new Shp(*m_ctx, body.shape); - if (did_union) - shp->set_name(unique_shape_name_("Fused")); - else if (!body.name.empty()) - shp->set_name(unique_shape_name_(body.name.c_str())); + added.reserve(named.size()); + + for (size_t i = 0; i < named.size(); ++i) + { + utl_cad_file_info::Named_node& node = named[i]; + Shape_id parent_id = 0; + if (node.parent_index >= 0 && static_cast(node.parent_index) < id_by_index.size()) + parent_id = id_by_index[static_cast(node.parent_index)]; + + Shp_ptr shp; + if (node.is_group) + { + const char* base = node.name.empty() ? "Assembly" : node.name.c_str(); + shp = Shp::create_group(*m_ctx, unique_shape_name_(base)); + } else - shp->set_name(unique_shape_name_("Shape")); + { + if (node.shape.IsNull()) + continue; + + shp = new Shp(*m_ctx, node.shape); + if (!node.name.empty()) + shp->set_name(unique_shape_name_(node.name.c_str())); + else + shp->set_name(unique_shape_name_("Shape")); + } + + shp->set_parent_id(parent_id); + shp->set_sibling_order(next_sibling_order(parent_id)); add_shp_(shp); + id_by_index[i] = shp->get_id(); added.push_back(capture_shape_rec(*shp)); } + if (added.empty()) + return Status::user_error("STEP: no valid shapes in file."); + push_undo_delta(std::make_unique(std::move(added))); return Status::ok(); } @@ -3317,7 +3767,7 @@ bool Occt_view::import_ply(const std::string& ply_bytes) shape = scale_shape_about_origin_(shape, ply_import_to_model_scale_(get_dimension_scale())); Shp_ptr shp = new Shp(*m_ctx, shape); - add_shp_(shp); + add_shp_(shp, true); push_undo_delta(std::make_unique(std::vector{capture_shape_rec(*shp)})); return true; } @@ -3335,6 +3785,7 @@ void Occt_view::new_file() m_assets.clear(); m_next_sketch_id = 1; m_next_shape_id = 1; + m_current_group_id = 0; m_project_unit = m_gui.default_project_unit(); create_default_sketch_(); diff --git a/src/gui_occt_view.h b/src/gui_occt_view.h index 9ce2c6d..c027916 100644 --- a/src/gui_occt_view.h +++ b/src/gui_occt_view.h @@ -120,13 +120,41 @@ class Occt_view : protected AIS_ViewController Shape_id allocate_shape_id(); void adopt_shape_id(Shape_id id); Shp_ptr find_shape_by_id(Shape_id id) const; - /// Insert a shape from an undo snapshot (keeps \a rec.id). Displays in the viewer. + /// Insert a shape from an undo snapshot (keeps \a rec.id). Displays solids in the viewer (not groups). void insert_shape_rec(const Shape_rec& rec); /// Remove a shape by stable id (viewer + document list). void remove_shape_by_id(Shape_id id); /// Replace BREP of an existing shape (identity local transform). void set_shape_geom_by_id(Shape_id id, const TopoDS_Shape& geom); + /// Next sibling_order among children of \a parent_id (0 = document root). + int next_sibling_order(Shape_id parent_id) const; + /// True if setting \a id's parent to \a new_parent would create a cycle. + bool would_reparent_create_cycle(Shape_id id, Shape_id new_parent) const; + /// Children of \a parent_id sorted by sibling_order (0 = roots). + std::vector shape_children(Shape_id parent_id) const; + /// Descendant leaf solids under \a id (if \a id is a solid, returns itself). + std::vector shape_descendant_solids(Shape_id id) const; + /// Effective visibility: own flag and all ancestors visible (ignores Hide all / sketch overlays). + bool shape_ancestors_visible(const Shp& shp) const; + /// Create an empty group under \a parent_id; pushes undo. + Shp_ptr create_group(const std::string& name, Shape_id parent_id = 0); + /// Group \a nodes under a new parent (common parent of selection, or root if mixed). Pushes undo. + [[nodiscard]] Status group_shapes(const std::vector& nodes); + /// Move children of \a group_id to its parent and remove the group. Pushes undo. + [[nodiscard]] Status ungroup_shape(Shape_id group_id); + /// Change parent/order; rejects cycles. Pushes undo when \a push_undo. + [[nodiscard]] Status reparent_shape(Shape_id id, Shape_id new_parent, int sibling_order = -1, bool push_undo = true); + /// Apply parent/order without undo (used by deltas). + void apply_shape_link(Shape_id id, Shape_id parent_id, int sibling_order); + /// Parent for a boolean/extrude result: shared parent if all match, else primary's parent, else root if mixed. + static Shape_id result_parent_id(const std::vector& operands); + + /// Group that receives new primitives / extrudes / revolves (0 = document root). Empty groups are valid. + Shape_id current_group_id() const { return m_current_group_id; } + /// Set current group; \a id must be 0 or an existing group. Invalid ids clear to root. + void set_current_group_id(Shape_id id); + /// Insert a sketch from JSON for undo/redo (adopts sketch id from JSON). void undo_insert_sketch(const nlohmann::json& sketch_json, bool make_current); /// Remove a sketch by id for undo/redo (does not auto-create a default sketch). @@ -358,7 +386,9 @@ class Occt_view : protected AIS_ViewController Ray get_hit_test_ray_(const ScreenCoords& screen_coords) const; std::optional get_hit_point_(const AIS_Shape_ptr& shp, const ScreenCoords& screen_coords) const; - void add_shp_(Shp_ptr& shp); + /// Register shape. When \a use_current_group, solids still at parent 0 are placed under current_group_id(). + void add_shp_(Shp_ptr& shp, bool use_current_group = false); + void ensure_current_group_valid_(); std::string unique_shape_name_(const char* base_name) const; TopoDS_Shape shape_with_local_transform_(const AIS_Shape_ptr& ais) const; @@ -408,6 +438,7 @@ class Occt_view : protected AIS_ViewController bool m_restoring{false}; size_t m_next_sketch_id{1}; Shape_id m_next_shape_id{1}; + Shape_id m_current_group_id{0}; Ezy_asset_store m_assets; // -------------------------------------------------------------------- diff --git a/src/shp.cpp b/src/shp.cpp index 6339fb6..0457818 100644 --- a/src/shp.cpp +++ b/src/shp.cpp @@ -1,6 +1,8 @@ #include "shp.h" #include +#include +#include Shp::Shp(AIS_InteractiveContext& ctx, const TopoDS_Shape& shp) : AIS_Shape(shp) @@ -15,6 +17,17 @@ Shp::Shp(AIS_InteractiveContext& ctx, const TopoDS_Shape& shp) Shp::~Shp() {} +opencascade::handle Shp::create_group(AIS_InteractiveContext& ctx, const std::string& name) +{ + TopoDS_Compound comp; + BRep_Builder().MakeCompound(comp); + opencascade::handle grp = new Shp(ctx, comp); + grp->m_is_group = true; + grp->set_name(name); + grp->m_visible = true; + return grp; +} + Shape_id Shp::get_id() const { return m_id; } void Shp::set_id(Shape_id id) { m_id = id; } @@ -28,7 +41,8 @@ AIS_DisplayMode Shp::get_disp_mode() const { return m_disp_mode; } void Shp::set_disp_mode(const AIS_DisplayMode mode) { m_disp_mode = mode; - update_display_(); + if (!m_is_group) + update_display_(); } bool Shp::get_visible() const { return m_visible; } @@ -39,10 +53,13 @@ void Shp::set_visible(const bool visible) return; m_visible = visible; + if (m_is_group) + return; + + // Immediate context update for leaf solids when no overlay is applied by the view. + // Occt_view::sync_sketch_shape_faint_style() recomputes effective visibility afterward. if (visible) - { redisplay_(); - } else { m_ctx.Unhilight(this, false); @@ -50,14 +67,32 @@ void Shp::set_visible(const bool visible) } } +void Shp::apply_context_shown(bool shown) +{ + if (m_is_group) + return; + + if (shown) + redisplay_(); + else + { + m_ctx.Unhilight(this, false); + m_ctx.Erase(this, false); + } +} + void Shp::set_selection_mode(const TopAbs_ShapeEnum mode) { m_selection_mode = mode; - update_display_(); + if (!m_is_group) + update_display_(); } void Shp::set_sketch_faint(bool enabled, AIS_DisplayMode faint_mode, float transparency) { + if (m_is_group) + return; + m_sketch_faint_active = enabled; m_faint_disp_mode = faint_mode; SetTransparency(enabled ? static_cast(transparency) : 0.0); @@ -71,6 +106,9 @@ AIS_DisplayMode Shp::effective_disp_mode_() const void Shp::redisplay_() { + if (m_is_group) + return; + m_ctx.Unhilight(this, false); // Faint sketch-mode shapes are display-only (no pick/hover highlight). if (m_sketch_faint_active) @@ -87,7 +125,7 @@ void Shp::redisplay_() void Shp::update_display_() { - if (!get_visible()) + if (m_is_group || !get_visible()) return; // Required to update selection mode in some cases. diff --git a/src/shp.h b/src/shp.h index 102ccc7..1af8ea7 100644 --- a/src/shp.h +++ b/src/shp.h @@ -10,10 +10,13 @@ class AIS_InteractiveContext; using Shape_id = uint64_t; +/// Document shape or organizational group (group nodes have no viewer display). class Shp : public AIS_Shape { public: Shp(AIS_InteractiveContext& ctx, const TopoDS_Shape& shp); + /// Organizational group (empty compound geometry; never displayed). + static opencascade::handle create_group(AIS_InteractiveContext& ctx, const std::string& name); virtual ~Shp(); Shape_id get_id() const; @@ -23,9 +26,20 @@ class Shp : public AIS_Shape AIS_DisplayMode get_disp_mode() const; void set_disp_mode(const AIS_DisplayMode mode); bool get_visible() const; + /// User visibility preference (does not alone control viewer Erase/Display when overlays apply). void set_visible(const bool visible); void set_selection_mode(const TopAbs_ShapeEnum mode); + bool is_group() const { return m_is_group; } + Shape_id get_parent_id() const { return m_parent_id; } + void set_parent_id(Shape_id parent_id) { m_parent_id = parent_id; } + int get_sibling_order() const { return m_sibling_order; } + void set_sibling_order(int order) { m_sibling_order = order; } + + /// Show or erase in the interactive context without changing get_visible(). + /// No-op for group nodes. + void apply_context_shown(bool shown); + /// Temporary display override for sketch-mode ghost/wire (does not change get_disp_mode()). /// When active, the shape is not selectable (no hover/selection highlight). void set_sketch_faint(bool enabled, AIS_DisplayMode faint_mode, float transparency); @@ -44,6 +58,9 @@ class Shp : public AIS_Shape TopAbs_ShapeEnum m_selection_mode; bool m_sketch_faint_active{false}; AIS_DisplayMode m_faint_disp_mode{AIS_Shaded}; + bool m_is_group{false}; + Shape_id m_parent_id{0}; + int m_sibling_order{0}; }; using Shp_ptr = opencascade::handle; diff --git a/src/shp_common.cpp b/src/shp_common.cpp index 8088d22..86bf1da 100644 --- a/src/shp_common.cpp +++ b/src/shp_common.cpp @@ -51,6 +51,7 @@ Shp_rslt Shp_common::common(std::vector shps) // Create a new shape from the common result Shp_ptr shp = new Shp(ctx(), result); shp->set_name("Common"); + assign_result_parent_(shp, m_shps); delete_operation_shps_(); add_shp_(shp); view().push_undo_delta(std::make_unique(std::move(removed), std::vector{capture_shape_rec(*shp)})); diff --git a/src/shp_cut.cpp b/src/shp_cut.cpp index 0eb96e6..16969f1 100644 --- a/src/shp_cut.cpp +++ b/src/shp_cut.cpp @@ -51,6 +51,7 @@ Shp_rslt Shp_cut::cut(std::vector shps) Shp_ptr shp = new Shp(ctx(), result_shape); shp->set_name("Cut"); + assign_result_parent_(shp, m_shps); delete_operation_shps_(); add_shp_(shp); view().push_undo_delta(std::make_unique(std::move(removed), std::vector{capture_shape_rec(*shp)})); diff --git a/src/shp_delta.cpp b/src/shp_delta.cpp index caccdac..9e3d67a 100644 --- a/src/shp_delta.cpp +++ b/src/shp_delta.cpp @@ -5,10 +5,14 @@ Shape_rec capture_shape_rec(const Shp& shp) { Shape_rec rec; - rec.id = shp.get_id(); - rec.name = shp.get_name(); - rec.material = shp.Material(); - rec.geom = shp.Shape(); + rec.id = shp.get_id(); + rec.name = shp.get_name(); + rec.material = shp.Material(); + rec.geom = shp.Shape(); + rec.parent_id = shp.get_parent_id(); + rec.sibling_order = shp.get_sibling_order(); + rec.is_group = shp.is_group(); + rec.visible = shp.get_visible(); return rec; } @@ -27,6 +31,28 @@ void insert_recs_(Occt_view& view, const std::vector& recs) view.insert_shape_rec(rec); } +void apply_links_(Occt_view& view, const std::vector& links, bool forward) +{ + for (const Shape_tree_delta::Link_change& ch : links) + { + Shp_ptr shp = view.find_shape_by_id(ch.id); + if (shp.IsNull()) + continue; + + if (forward) + { + shp->set_parent_id(ch.new_parent); + shp->set_sibling_order(ch.new_order); + } + else + { + shp->set_parent_id(ch.old_parent); + shp->set_sibling_order(ch.old_order); + } + } + view.sync_sketch_shape_faint_style(); +} + } // namespace Shape_add_delta::Shape_add_delta(std::vector added) @@ -92,3 +118,30 @@ std::unique_ptr Shape_replace_delta::clone() const { return std::make_unique(m_removed, m_added); } + +Shape_tree_delta::Shape_tree_delta(std::vector added, std::vector removed, + std::vector links) + : m_added(std::move(added)) + , m_removed(std::move(removed)) + , m_links(std::move(links)) +{ +} + +void Shape_tree_delta::apply_forward(Occt_view& view) +{ + insert_recs_(view, m_added); + apply_links_(view, m_links, true); + remove_recs_(view, m_removed); +} + +void Shape_tree_delta::apply_reverse(Occt_view& view) +{ + insert_recs_(view, m_removed); + apply_links_(view, m_links, false); + remove_recs_(view, m_added); +} + +std::unique_ptr Shape_tree_delta::clone() const +{ + return std::make_unique(m_added, m_removed, m_links); +} diff --git a/src/shp_delta.h b/src/shp_delta.h index 79d2c38..058325e 100644 --- a/src/shp_delta.h +++ b/src/shp_delta.h @@ -16,6 +16,10 @@ struct Shape_rec std::string name; int material{0}; TopoDS_Shape geom; + Shape_id parent_id{0}; + int sibling_order{0}; + bool is_group{false}; + bool visible{true}; }; Shape_rec capture_shape_rec(const Shp& shp); @@ -83,3 +87,28 @@ class Shape_replace_delta : public Delta std::vector m_removed; std::vector m_added; }; + +/// Parent/sibling-order edits plus optional group add/remove (group, ungroup, reparent). +class Shape_tree_delta : public Delta +{ +public: + struct Link_change + { + Shape_id id{0}; + Shape_id old_parent{0}; + int old_order{0}; + Shape_id new_parent{0}; + int new_order{0}; + }; + + Shape_tree_delta(std::vector added, std::vector removed, std::vector links); + + void apply_forward(Occt_view& view) override; + void apply_reverse(Occt_view& view) override; + std::unique_ptr clone() const override; + +private: + std::vector m_added; + std::vector m_removed; + std::vector m_links; +}; diff --git a/src/shp_extrude.cpp b/src/shp_extrude.cpp index 370108d..9d0f263 100644 --- a/src/shp_extrude.cpp +++ b/src/shp_extrude.cpp @@ -72,7 +72,7 @@ void Shp_extrude::finalize() DBG_MSG(""); EZY_ASSERT(m_extruded); m_extruded->set_name(view().get_unique_shape_name("Shape")); - add_shp_(m_extruded); + add_shp_(m_extruded, true); view().push_undo_delta(std::make_unique(std::vector{capture_shape_rec(*m_extruded)})); ctx().Remove(m_tmp_dim, false); clear_all(m_to_extrude_pt, m_to_extrude, m_extruded, m_tmp_dim); diff --git a/src/shp_fuse.cpp b/src/shp_fuse.cpp index 8d0070c..2135687 100644 --- a/src/shp_fuse.cpp +++ b/src/shp_fuse.cpp @@ -51,6 +51,7 @@ Shp_rslt Shp_fuse::fuse(std::vector shps) // Create a new shape from the union result Shp_ptr shp = new Shp(ctx(), result); shp->set_name("Fused"); + assign_result_parent_(shp, m_shps); delete_operation_shps_(); add_shp_(shp); view().push_undo_delta(std::make_unique(std::move(removed), std::vector{capture_shape_rec(*shp)})); diff --git a/src/shp_operation.cpp b/src/shp_operation.cpp index c53c74c..2736abd 100644 --- a/src/shp_operation.cpp +++ b/src/shp_operation.cpp @@ -21,13 +21,28 @@ std::vector Shp_operation_base::get_selected_shps_() const std::vector ret; std::unordered_set seen; for (AIS_Shape_ptr& obj : m_view.get_selected()) - if (Shp_ptr shp = Shp_ptr::DownCast(obj); !shp.IsNull()) + if (Shp_ptr shp = Shp_ptr::DownCast(obj); !shp.IsNull() && !shp->is_group()) if (seen.insert(shp.get()).second) ret.push_back(shp); return ret; } +Shape_id Shp_operation_base::result_parent_from_operands_() const +{ + return Occt_view::result_parent_id(m_shps); +} + +void Shp_operation_base::assign_result_parent_(Shp_ptr& result, const std::vector& operands) +{ + if (result.IsNull()) + return; + + const Shape_id parent = Occt_view::result_parent_id(operands); + result->set_parent_id(parent); + result->set_sibling_order(view().next_sibling_order(parent)); +} + Status Shp_operation_base::ensure_operation_shps_() { if (m_shps.empty()) @@ -94,7 +109,10 @@ const TopoDS_Edge* Shp_operation_base::get_edge_(const ScreenCoords& screen_coor return m_view.get_edge_(screen_coords); } -void Shp_operation_base::add_shp_(Shp_ptr& shp) { m_view.add_shp_(shp); } +void Shp_operation_base::add_shp_(Shp_ptr& shp, bool use_current_group) +{ + m_view.add_shp_(shp, use_current_group); +} void Shp_operation_base::replace_picked_shape_(Shp_ptr& old_shp, Shp_ptr& new_shp, const std::string& name) { @@ -113,6 +131,8 @@ void Shp_operation_base::replace_picked_shape_(Shp_ptr& old_shp, Shp_ptr& new_sh v.get_shapes().remove(old_shp); new_shp->set_name(name); + new_shp->set_parent_id(old_shp->get_parent_id()); + new_shp->set_sibling_order(old_shp->get_sibling_order()); add_shp_(new_shp); copy_shape_material_from_(new_shp, old_shp); ctx().Display(new_shp, new_shp->get_disp_mode(), AIS_Shape::SelectionMode(v.get_shp_selection_mode()), true); diff --git a/src/shp_operation.h b/src/shp_operation.h index e4fa5d4..1711c30 100644 --- a/src/shp_operation.h +++ b/src/shp_operation.h @@ -19,6 +19,9 @@ class Shp_operation_base AIS_InteractiveContext& ctx(); std::vector get_selected_shps_() const; + /// Parent id for new shapes from multi-selection (see Occt_view::result_parent_id). + Shape_id result_parent_from_operands_() const; + void assign_result_parent_(Shp_ptr& result, const std::vector& operands); [[nodiscard]] Status ensure_operation_shps_(); [[nodiscard]] Status ensure_operation_multi_shps_(); void delete_operation_shps_(); @@ -30,7 +33,7 @@ class Shp_operation_base const TopoDS_Wire* get_wire_(const ScreenCoords& screen_coords) const; const TopoDS_Edge* get_edge_(const ScreenCoords& screen_coords) const; - void add_shp_(Shp_ptr& shp); + void add_shp_(Shp_ptr& shp, bool use_current_group = false); /// Remove \a old_shp from the viewer and register \a new_shp (fillet/chamfer in-place replace). void replace_picked_shape_(Shp_ptr& old_shp, Shp_ptr& new_shp, const std::string& name); diff --git a/src/shp_polar_dup.cpp b/src/shp_polar_dup.cpp index 54fe37b..173e25f 100644 --- a/src/shp_polar_dup.cpp +++ b/src/shp_polar_dup.cpp @@ -146,6 +146,7 @@ Status Shp_polar_dup::dup() // Create new shape and add to view as before Shp_ptr new_shape = new Shp(ctx(), transformed_shape); new_shape->set_name("Polar duplicate"); + assign_result_parent_(new_shape, m_shps); add_shp_(new_shape); added.push_back(capture_shape_rec(*new_shape)); } @@ -166,6 +167,7 @@ Status Shp_polar_dup::dup() // Create a single shape from all the combined shapes Shp_ptr new_shape = new Shp(ctx(), combined_shape); new_shape->set_name("Combined polar duplicate"); + assign_result_parent_(new_shape, m_shps); add_shp_(new_shape); added.push_back(capture_shape_rec(*new_shape)); } diff --git a/src/utl_cad_file_info.cpp b/src/utl_cad_file_info.cpp index 4e51f7d..6e00736 100644 --- a/src/utl_cad_file_info.cpp +++ b/src/utl_cad_file_info.cpp @@ -498,6 +498,61 @@ void append_named_from_label(const XCAFDoc_ShapeTool_ptr& shapes, const TDF_Labe out.push_back(Named_body{std::move(body), name}); } +void append_tree_from_label(const XCAFDoc_ShapeTool_ptr& shapes, const TDF_Label& lab, int parent_index, + std::vector& out) +{ + const std::string name = xcaf_product_name(shapes, lab); + + if (shapes->IsAssembly(lab)) + { + Named_node grp; + grp.name = name.empty() ? "Assembly" : name; + grp.is_group = true; + grp.parent_index = parent_index; + const int grp_i = static_cast(out.size()); + out.push_back(std::move(grp)); + + NCollection_Sequence comps; + shapes->GetComponents(lab, comps, false); + for (int i = 1; i <= comps.Length(); ++i) + append_tree_from_label(shapes, comps.Value(i), grp_i, out); + return; + } + + TopoDS_Shape shape; + if (!XCAFDoc_ShapeTool::GetShape(lab, shape) || shape.IsNull()) + return; + + std::vector bodies; + append_cad_import_bodies(shape, bodies); + if (bodies.empty()) + return; + + // Multiple solids under one product: wrap in a group when more than one body. + int leaf_parent = parent_index; + if (bodies.size() > 1) + { + Named_node grp; + grp.name = name.empty() ? "Compound" : name; + grp.is_group = true; + grp.parent_index = parent_index; + leaf_parent = static_cast(out.size()); + out.push_back(std::move(grp)); + } + + for (size_t bi = 0; bi < bodies.size(); ++bi) + { + Named_node leaf; + leaf.shape = std::move(bodies[bi]); + leaf.name = name; + leaf.is_group = false; + leaf.parent_index = leaf_parent; + if (bodies.size() > 1 && !name.empty()) + leaf.name = name + "." + std::to_string(bi + 1); + out.push_back(std::move(leaf)); + } +} + Status read_step_named_bodies_xcaf(const std::string& file_bytes, std::vector& out) { Interface_Static::SetCVal("xstep.cascade.unit", "MM"); @@ -583,4 +638,81 @@ Status read_step_named_bodies(const std::string& file_bytes, std::vector& out) +{ + Interface_Static::SetCVal("xstep.cascade.unit", "MM"); + + STEPCAFControl_Reader reader; + reader.SetNameMode(true); + reader.SetColorMode(false); + reader.SetLayerMode(false); + + std::istringstream stream(file_bytes); + const IFSelect_ReturnStatus read_st = reader.ReadStream("", stream); + if (read_st != IFSelect_RetDone) + return Status::user_error("STEP: could not read file (invalid or corrupt STEP data)."); + + XCAFApp_Application_ptr app = XCAFApp_Application::GetApplication(); + if (app.IsNull()) + return Status::user_error("STEP: XCAF application unavailable."); + + TDocStd_Document_ptr doc; + app->NewDocument("MDTV-XCAF", doc); + if (doc.IsNull()) + return Status::user_error("STEP: could not create XCAF document."); + + if (!reader.Transfer(doc)) + return Status::user_error("STEP: no geometry was transferred from the file."); + + XCAFDoc_ShapeTool_ptr shapes = XCAFDoc_DocumentTool::ShapeTool(doc->Main()); + if (shapes.IsNull()) + return Status::user_error("STEP: missing XCAF shape tool."); + + NCollection_Sequence free_shapes; + shapes->GetFreeShapes(free_shapes); + if (free_shapes.IsEmpty()) + return Status::user_error("STEP: no valid shapes in file."); + + for (int i = 1; i <= free_shapes.Length(); ++i) + append_tree_from_label(shapes, free_shapes.Value(i), -1, out); + + bool any_leaf = false; + for (const Named_node& n : out) + if (!n.is_group) + { + any_leaf = true; + break; + } + + if (!any_leaf) + return Status::user_error("STEP: no valid shapes in file."); + + return Status::ok(); +} + +Status read_step_named_tree(const std::string& file_bytes, std::vector& out) +{ + out.clear(); + const Status xcaf = read_step_named_tree_xcaf(file_bytes, out); + if (xcaf.is_ok()) + return xcaf; + + out.clear(); + std::vector flat; + const Status plain = read_step_named_bodies_plain(file_bytes, flat); + if (!plain.is_ok()) + return plain; + + for (Named_body& b : flat) + { + Named_node n; + n.shape = std::move(b.shape); + n.name = std::move(b.name); + n.is_group = false; + n.parent_index = -1; + out.push_back(std::move(n)); + } + return Status::ok(); +} } // namespace utl_cad_file_info diff --git a/src/utl_cad_file_info.h b/src/utl_cad_file_info.h index 35b7d6d..6e1ba94 100644 --- a/src/utl_cad_file_info.h +++ b/src/utl_cad_file_info.h @@ -34,6 +34,15 @@ struct Named_body std::string name; // empty if the file had no usable name }; +/// STEP XCAF node: assemblies become groups (`is_group`); leaves carry geometry. +struct Named_node +{ + TopoDS_Shape shape; // null/empty when is_group + std::string name; + bool is_group{false}; + int parent_index{-1}; // index in the output vector, or -1 for root +}; + [[nodiscard]] Format detect(const std::string& file_path, const std::string& file_bytes); /// True for formats File -> Import can load (STEP, PLY). @@ -44,6 +53,9 @@ struct Named_body /// Collect label/value rows for the Tools -> Inspector window. [[nodiscard]] std::vector collect(const std::string& file_path, const std::string& file_bytes); -/// Read STEP bodies with XCAF product/part names when present. +/// Read STEP bodies with XCAF product/part names when present (flat; assemblies expanded to leaves). [[nodiscard]] Status read_step_named_bodies(const std::string& file_bytes, std::vector& out); + +/// Read STEP as a group/leaf tree (XCAF assemblies preserved). Falls back to flat bodies as root leaves. +[[nodiscard]] Status read_step_named_tree(const std::string& file_bytes, std::vector& out); } // namespace utl_cad_file_info diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index 79d3be0..11993f5 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -427,3 +427,201 @@ TEST_F(Shp_test, Shape_ids_persist_in_json) ASSERT_EQ(view().get_shapes().size(), 1u); EXPECT_EQ(view().get_shapes().back()->get_id(), id); } + +// --------------------------------------------------------------------------- +// Shape hierarchy (organizational groups) +// --------------------------------------------------------------------------- + +TEST_F(Shp_test, Group_reparent_cycle_rejected) +{ + view().add_box(0, 0, 0, 1, 1, 1); + view().add_box(2, 0, 0, 1, 1, 1); + Shp_ptr a = view().get_shapes().front(); + Shp_ptr b = view().get_shapes().back(); + + ASSERT_TRUE(view().group_shapes({a, b}).is_ok()); + Shp_ptr grp; + for (const Shp_ptr& s : view().get_shapes()) + if (s->is_group()) + grp = s; + + ASSERT_FALSE(grp.IsNull()); + EXPECT_TRUE(view().would_reparent_create_cycle(grp->get_id(), a->get_id())); + EXPECT_FALSE(view().reparent_shape(grp->get_id(), a->get_id()).is_ok()); +} + +TEST_F(Shp_test, Ungroup_moves_all_direct_children) +{ + view().add_box(0, 0, 0, 1, 1, 1); + view().add_box(2, 0, 0, 1, 1, 1); + view().add_box(4, 0, 0, 1, 1, 1); + ASSERT_EQ(view().get_shapes().size(), 3u); + + Shp_ptr grp = view().create_group("Group", 0); + ASSERT_FALSE(grp.IsNull()); + const Shape_id gid = grp->get_id(); + + std::vector boxes; + for (const Shp_ptr& s : view().get_shapes()) + if (!s->is_group()) + boxes.push_back(s); + + ASSERT_EQ(boxes.size(), 3u); + for (const Shp_ptr& b : boxes) + ASSERT_TRUE(view().reparent_shape(b->get_id(), gid).is_ok()); + + EXPECT_EQ(view().shape_children(gid).size(), 3u); + + ASSERT_TRUE(view().ungroup_shape(gid).is_ok()); + EXPECT_TRUE(view().find_shape_by_id(gid).IsNull()); + EXPECT_EQ(view().shape_children(0).size(), 3u); + for (const Shp_ptr& s : view().get_shapes()) + { + EXPECT_FALSE(s->is_group()); + EXPECT_EQ(s->get_parent_id(), 0u); + } +} + +TEST_F(Shp_test, Group_ungroup_and_cascade_delete_undo) +{ + view().add_box(0, 0, 0, 1, 1, 1); + view().add_box(2, 0, 0, 1, 1, 1); + std::vector boxes(view().get_shapes().begin(), view().get_shapes().end()); + ASSERT_TRUE(view().group_shapes(boxes).is_ok()); + + Shp_ptr grp; + for (const Shp_ptr& s : view().get_shapes()) + if (s->is_group()) + grp = s; + + ASSERT_FALSE(grp.IsNull()); + EXPECT_EQ(view().shape_children(grp->get_id()).size(), 2u); + + ASSERT_TRUE(view().ungroup_shape(grp->get_id()).is_ok()); + EXPECT_EQ(view().shape_children(0).size(), 2u); + for (const Shp_ptr& s : view().get_shapes()) + EXPECT_FALSE(s->is_group()); + + EXPECT_TRUE(view().undo()); // undo ungroup -> group back + size_t groups = 0; + for (const Shp_ptr& s : view().get_shapes()) + if (s->is_group()) + ++groups; + + EXPECT_EQ(groups, 1u); + + for (const Shp_ptr& s : view().get_shapes()) + if (s->is_group()) + grp = s; + + view().delete_shapes({grp}); + EXPECT_TRUE(view().get_shapes().empty()); + + EXPECT_TRUE(view().undo()); + EXPECT_EQ(view().get_shapes().size(), 3u); // group + 2 boxes +} + +TEST_F(Shp_test, Hierarchy_json_round_trip) +{ + view().add_box(0, 0, 0, 1, 1, 1); + view().add_box(2, 0, 0, 1, 1, 1); + std::vector boxes(view().get_shapes().begin(), view().get_shapes().end()); + ASSERT_TRUE(view().group_shapes(boxes).is_ok()); + + const std::string json = view().to_json(); + view().new_file(); + view().load(json, false); + + size_t groups = 0; + size_t leaves = 0; + Shape_id group_id = 0; + for (const Shp_ptr& s : view().get_shapes()) + { + if (s->is_group()) + { + ++groups; + group_id = s->get_id(); + } + else + ++leaves; + } + EXPECT_EQ(groups, 1u); + EXPECT_EQ(leaves, 2u); + EXPECT_EQ(view().shape_children(group_id).size(), 2u); + for (const Shp_ptr& c : view().shape_children(group_id)) + EXPECT_EQ(c->get_parent_id(), group_id); +} + +TEST_F(Shp_test, Hide_all_preserves_per_shape_visibility) +{ + view().add_box(0, 0, 0, 1, 1, 1); + Shp_ptr shp = view().get_shapes().back(); + shp->set_visible(false); + EXPECT_FALSE(shp->get_visible()); + + gui().set_hide_all_shapes(true); + view().sync_sketch_shape_faint_style(); + EXPECT_FALSE(shp->get_visible()); // preference unchanged + + gui().set_hide_all_shapes(false); + view().sync_sketch_shape_faint_style(); + EXPECT_FALSE(shp->get_visible()); +} + +TEST_F(Shp_test, Fuse_keeps_shared_parent) +{ + view().add_box(0, 0, 0, 10, 10, 10); + view().add_box(5, 0, 0, 10, 10, 10); + std::vector boxes(view().get_shapes().begin(), view().get_shapes().end()); + ASSERT_TRUE(view().group_shapes(boxes).is_ok()); + + Shp_ptr grp; + for (const Shp_ptr& s : view().get_shapes()) + if (s->is_group()) + grp = s; + + ASSERT_FALSE(grp.IsNull()); + std::vector kids = view().shape_children(grp->get_id()); + ASSERT_EQ(kids.size(), 2u); + select_shapes(view(), kids); + ASSERT_TRUE(view().shp_fuse().selected_fuse().is_ok()); + + Shp_ptr fused; + for (const Shp_ptr& s : view().get_shapes()) + if (!s->is_group()) + fused = s; + + ASSERT_FALSE(fused.IsNull()); + EXPECT_EQ(fused->get_parent_id(), grp->get_id()); +} + +TEST_F(Shp_test, Current_group_parents_new_primitives) +{ + Shp_ptr grp = view().create_group("Group", 0); + ASSERT_FALSE(grp.IsNull()); + view().set_current_group_id(grp->get_id()); + EXPECT_EQ(view().current_group_id(), grp->get_id()); + + view().add_box(0, 0, 0, 1, 1, 1); + Shp_ptr box; + for (const Shp_ptr& s : view().get_shapes()) + if (!s->is_group()) + box = s; + + ASSERT_FALSE(box.IsNull()); + EXPECT_EQ(box->get_parent_id(), grp->get_id()); + + view().set_current_group_id(0); + view().add_box(2, 0, 0, 1, 1, 1); + Shp_ptr root_box; + for (const Shp_ptr& s : view().get_shapes()) + if (!s->is_group() && s->get_parent_id() == 0) + root_box = s; + + ASSERT_FALSE(root_box.IsNull()); + EXPECT_EQ(root_box->get_parent_id(), 0u); + + view().set_current_group_id(grp->get_id()); + ASSERT_TRUE(view().ungroup_shape(grp->get_id()).is_ok()); + EXPECT_EQ(view().current_group_id(), 0u); +} From 674dc07e7f3c09a5923ea66ad271162f1b140969 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Sun, 19 Jul 2026 17:23:35 -0600 Subject: [PATCH 3/8] UI tweek --- docs/usage.md | 2 +- src/doc/gui.md | 2 +- src/gui.cpp | 176 +++++++++++++++++++++++++------------------------ 3 files changed, 92 insertions(+), 88 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 9cf43e5..706d224 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -121,10 +121,10 @@ Click a **group** (including an empty one) to make it the **current group**. Cli Each row (left to right): -- **Name** - Expandable tree row with an editable name. Click the row to select that solid (or all descendant solids for a group) and update the current group. **Ctrl+click** toggles multi-select. Drag to reparent. - **Visibility** - Checkbox to show or hide that node. Hiding a **group** hides its whole subtree in the 3D view. - **Solid / wire** - Checkbox (solids only) to switch **shaded** or **wireframe**. - **M** - Solids only: material popup; right-click for **Shape info...** or **Delete**. +- **Name** - Expandable tree row with an editable name. Click the row to select that solid (or all descendant solids for a group) and update the current group. **Ctrl+click** toggles multi-select. Drag to reparent. - **Right-click the name** - Solids: **Shape info...** / **Delete**. Groups: **Ungroup** (moves **all** direct children to the group's parent, then removes the group) / **Delete** (cascade-deletes the whole subtree). Boolean results stay under the shared parent of their inputs when all inputs share one parent; otherwise they are placed at the document root. **File -> Import** STEP assemblies preserve product/assembly groups in the tree (unless **Union shapes** is checked). diff --git a/src/doc/gui.md b/src/doc/gui.md index 103630d..f267412 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -209,7 +209,7 @@ Shared sketch controls (snap, midpoint nodes, place-from-center) live in `option Sketch List expand **Faces**: each face row supports **`E`** and right-click **Extrude** via `GUI::sketch_list_extrude_face_` (`set_mode(Sketch_face_extrude)` + `Occt_view::begin_sketch_face_extrude` / `Shp_extrude::begin_face_extrude`). Hovering a **Faces**, **Edges**, or **Nodes** row calls `Occt_view::set_sketch_list_hover_{face,edge,node}` (temporarily displays the AIS when hidden outside sketch modes; uses `Graphic3d_ZLayerId_Topmost` so solids do not occlude the highlight). -**Shape List outliner:** `shape_list_` draws a tree of document shapes/groups via `shape_children(0)` and recursive `TreeNodeEx` rows (visibility, solid/wire, name, material). Groups support expand/collapse (`ui.shapeList.expanded`), drag-drop reparent (`EZY_SHAPE_ID` payload), Group / New group / Ungroup, and cascade delete. Clicking a group sets `Occt_view::current_group_id` (including empty groups) and selects descendant solids; clicking a solid selects it and sets current group to its parent. New primitives/extrudes/revolves parent under the current group. Ctrl+click multi-selects. Hover uses `set_shape_list_hover` on leaf solids only. `ui.shapeList.currentGroupId` is persisted in `.ezy`. +**Shape List outliner:** `shape_list_` draws a tree of document shapes/groups via `shape_children(0)` and recursive `TreeNodeEx` rows. Fixed-width vis/disp/mat columns are on the left; the name column stretches on the right with tree indent (`IndentEnable` on name only). Groups support expand/collapse (`ui.shapeList.expanded`), drag-drop reparent (`EZY_SHAPE_ID` payload), Group / New group / Ungroup, and cascade delete. Clicking a group sets `Occt_view::current_group_id` (including empty groups) and selects descendant solids; clicking a solid selects it and sets current group to its parent. New primitives/extrudes/revolves parent under the current group. Ctrl+click multi-selects. Hover uses `set_shape_list_hover` on leaf solids only. `ui.shapeList.currentGroupId` is persisted in `.ezy`. **Sketch List UI in the project file:** `GUI::serialized_project_json_` writes `ui.sketchList` (scroll Y plus per-sketch `rows` keyed by sketch `id`: `expanded`, `dimensions`, `nodes`, `edges`, `faces`). `GUI::on_file` restores via `apply_sketch_list_ui_from_json_`. Subsection open state is app-owned (`Sketch_list_row_ui` + `SetNextItemOpen`), not ImGui ini storage. diff --git a/src/gui.cpp b/src/gui.cpp index 42ef73b..fb18ae7 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -2578,84 +2578,8 @@ void GUI::shape_list_() m_view->ctx().UpdateCurrentViewer(); }; - // Column 0: tree arrow + name (first column so indent applies like imgui_demo Tables/Tree view) + // Columns 0-2: fixed actions on the left (no tree indent). ImGui::TableSetColumnIndex(0); - ImGui::AlignTextToFramePadding(); - - ImGuiTreeNodeFlags node_flags = ImGuiTreeNodeFlags_SpanAllColumns | ImGuiTreeNodeFlags_FramePadding | - ImGuiTreeNodeFlags_OpenOnArrow | ImGuiTreeNodeFlags_DrawLinesToNodes | - ImGuiTreeNodeFlags_NoTreePushOnOpen; - if (!has_children) - node_flags |= ImGuiTreeNodeFlags_Leaf; - - if (row_selected) - node_flags |= ImGuiTreeNodeFlags_Selected; - - bool open = true; - if (has_children) - { - const auto exp_it = m_shape_list_expanded.find(shape->get_id()); - open = (exp_it == m_shape_list_expanded.end()) ? true : exp_it->second; - ImGui::SetNextItemOpen(open); - } - - ImGui::SetNextItemAllowOverlap(); - const bool node_open = ImGui::TreeNodeEx("##node", node_flags); - if (has_children && node_open != open) - m_shape_list_expanded[shape->get_id()] = node_open; - - if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) - select_shape_row(shape); - - row_hovered |= ImGui::IsItemHovered(); - if (row_selected && ui_show_contextual_help() && ImGui::IsItemHovered()) - ImGui::SetTooltip(is_current_group ? "Current group (new shapes go here)" : "Selected in 3D viewer"); - - if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) - { - const Shape_id drag_id = shape->get_id(); - ImGui::SetDragDropPayload("EZY_SHAPE_ID", &drag_id, sizeof(drag_id)); - ImGui::TextUnformatted(shape->get_name().c_str()); - ImGui::EndDragDropSource(); - } - - if (ImGui::BeginDragDropTarget()) - { - if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("EZY_SHAPE_ID")) - { - Shape_id drag_id = 0; - std::memcpy(&drag_id, payload->Data, sizeof(drag_id)); - const Shape_id new_parent = is_group ? shape->get_id() : shape->get_parent_id(); - (void)m_view->reparent_shape(drag_id, new_parent, -1, true); - } - ImGui::EndDragDropTarget(); - } - - ImGui::SameLine(); - ImGui::SetNextItemWidth(-FLT_MIN); - if (ImGui::InputText("##name", name_buffer, sizeof(name_buffer))) - shape->set_name(std::string(name_buffer)); - - if (ImGui::IsItemClicked()) - select_shape_row(shape); - - row_hovered |= ImGui::IsItemHovered(); - if (ImGui::BeginPopupContextItem("shape_name_ctx")) - { - if (!is_group && ImGui::MenuItem("Shape info...")) - open_shape_info_(shape); - - if (is_group && ImGui::MenuItem("Ungroup")) - shape_to_ungroup_id = shape->get_id(); - - if (ImGui::MenuItem("Delete")) - shape_to_delete = shape; - - ImGui::EndPopup(); - } - - // Column 1: visibility - ImGui::TableSetColumnIndex(1); bool visible = shape->get_visible(); if (ImGui::Checkbox("##vis", &visible)) { @@ -2669,8 +2593,7 @@ void GUI::shape_list_() if (ui_show_contextual_help() && ImGui::IsItemHovered()) ImGui::SetTooltip(is_group ? "Show/hide group subtree" : "visibility"); - // Column 2: solid/wire (leaves only) - ImGui::TableSetColumnIndex(2); + ImGui::TableSetColumnIndex(1); if (is_group) ImGui::TextUnformatted(""); else @@ -2684,8 +2607,7 @@ void GUI::shape_list_() ImGui::SetTooltip("solid/wire"); } - // Column 3: material (leaves only) - ImGui::TableSetColumnIndex(3); + ImGui::TableSetColumnIndex(2); if (is_group) ImGui::TextUnformatted(""); else @@ -2733,6 +2655,81 @@ void GUI::shape_list_() } } + // Column 3: tree arrow + name (indent applies here so hierarchy stays under the name). + ImGui::TableSetColumnIndex(3); + ImGui::AlignTextToFramePadding(); + + ImGuiTreeNodeFlags node_flags = ImGuiTreeNodeFlags_FramePadding | ImGuiTreeNodeFlags_OpenOnArrow | + ImGuiTreeNodeFlags_DrawLinesToNodes | ImGuiTreeNodeFlags_NoTreePushOnOpen; + if (!has_children) + node_flags |= ImGuiTreeNodeFlags_Leaf; + + if (row_selected) + node_flags |= ImGuiTreeNodeFlags_Selected; + + bool open = true; + if (has_children) + { + const auto exp_it = m_shape_list_expanded.find(shape->get_id()); + open = (exp_it == m_shape_list_expanded.end()) ? true : exp_it->second; + ImGui::SetNextItemOpen(open); + } + + ImGui::SetNextItemAllowOverlap(); + const bool node_open = ImGui::TreeNodeEx("##node", node_flags); + if (has_children && node_open != open) + m_shape_list_expanded[shape->get_id()] = node_open; + + if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) + select_shape_row(shape); + + row_hovered |= ImGui::IsItemHovered(); + if (row_selected && ui_show_contextual_help() && ImGui::IsItemHovered()) + ImGui::SetTooltip(is_current_group ? "Current group (new shapes go here)" : "Selected in 3D viewer"); + + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) + { + const Shape_id drag_id = shape->get_id(); + ImGui::SetDragDropPayload("EZY_SHAPE_ID", &drag_id, sizeof(drag_id)); + ImGui::TextUnformatted(shape->get_name().c_str()); + ImGui::EndDragDropSource(); + } + + if (ImGui::BeginDragDropTarget()) + { + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("EZY_SHAPE_ID")) + { + Shape_id drag_id = 0; + std::memcpy(&drag_id, payload->Data, sizeof(drag_id)); + const Shape_id new_parent = is_group ? shape->get_id() : shape->get_parent_id(); + (void)m_view->reparent_shape(drag_id, new_parent, -1, true); + } + ImGui::EndDragDropTarget(); + } + + ImGui::SameLine(); + ImGui::SetNextItemWidth(std::max(1.0f, ImGui::GetContentRegionAvail().x)); + if (ImGui::InputText("##name", name_buffer, sizeof(name_buffer))) + shape->set_name(std::string(name_buffer)); + + if (ImGui::IsItemClicked()) + select_shape_row(shape); + + row_hovered |= ImGui::IsItemHovered(); + if (ImGui::BeginPopupContextItem("shape_name_ctx")) + { + if (!is_group && ImGui::MenuItem("Shape info...")) + open_shape_info_(shape); + + if (is_group && ImGui::MenuItem("Ungroup")) + shape_to_ungroup_id = shape->get_id(); + + if (ImGui::MenuItem("Delete")) + shape_to_delete = shape; + + ImGui::EndPopup(); + } + if (row_hovered && shape->get_visible() && !is_group) shape_list_hover = shape; @@ -2748,13 +2745,20 @@ void GUI::shape_list_() }; const ImGuiTableFlags table_flags = - ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_NoBordersInBody | ImGuiTableFlags_SizingStretchProp; + ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | ImGuiTableFlags_NoBordersInBody | ImGuiTableFlags_SizingFixedFit; if (ImGui::BeginTable("##shape_outliner", 4, table_flags, ImVec2(0.f, 0.f))) { - ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("vis", ImGuiTableColumnFlags_WidthFixed, check_col_w); - ImGui::TableSetupColumn("disp", ImGuiTableColumnFlags_WidthFixed, check_col_w); - ImGui::TableSetupColumn("mat", ImGuiTableColumnFlags_WidthFixed, mat_col_w); + // Fixed actions on the left; stretch name on the right (tree indent on name only). + ImGui::TableSetupColumn("vis", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | + ImGuiTableColumnFlags_IndentDisable, + check_col_w); + ImGui::TableSetupColumn("disp", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | + ImGuiTableColumnFlags_IndentDisable, + check_col_w); + ImGui::TableSetupColumn("mat", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoResize | + ImGuiTableColumnFlags_IndentDisable, + mat_col_w); + ImGui::TableSetupColumn("name", ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_IndentEnable, 1.0f); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(st_mat.FramePadding.x, std::max(1.0f, st_mat.FramePadding.y * 0.65f))); From da9c08c112e8e29e27928fee282dada553007362 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Sun, 19 Jul 2026 17:43:35 -0600 Subject: [PATCH 4/8] Improve --- CHANGELOG.md | 2 +- docs/usage.md | 4 +-- src/doc/gui.md | 2 +- src/gui.cpp | 70 ++++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 746aee8..f9c7341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Shape List -- **Shape List** is a hierarchical outliner: organizational **groups**, expand/collapse, drag-and-drop reparent, **Group** / **New group** / **Ungroup**, cascade delete, and Ctrl+click multi-select. Click a group (including empty) to set the **current group**; new primitives/extrudes/revolves are added there. Group visibility hides the subtree. **Hide all** no longer clears per-shape visibility flags. Boolean results inherit a shared parent when inputs share one. STEP import preserves XCAF assembly groups (unless **Union shapes**). +- **Shape List** is a hierarchical outliner: organizational **groups**, expand/collapse, drag-and-drop reparent, **Group** / **New group** / **Ungroup**, cascade delete, and Ctrl+click multi-select. Click a group (including empty) to set the **current group**; new primitives/extrudes/revolves are added there. Drop on empty space below the list to move a node to the document root. Group visibility hides the subtree. **Hide all** no longer clears per-shape visibility flags. Boolean results inherit a shared parent when inputs share one. STEP import preserves XCAF assembly groups (unless **Union shapes**). - Persisted in `.ezy`: `parentId`, `order`, `isGroup`, `visible`; UI expand state and current group in `ui.shapeList.expanded` / `ui.shapeList.currentGroupId`. ### Import / export diff --git a/docs/usage.md b/docs/usage.md index 706d224..79258b0 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -115,7 +115,7 @@ At the top: - **New group** - Creates an empty organizational group under the **current group** (document root when none is set) and makes it current. - **Group** - Places the currently selected solids under a new group (enabled when one or more solids are selected in the 3D view) and makes that group current. -Shapes form a parent/child outliner. Groups are folders only (they do not move children when transformed). Drag a row onto a group to reparent it, or onto a solid to place it under that solid's parent. +Shapes form a parent/child outliner. Groups are folders only (they do not move children when transformed). Drag a row onto a group to reparent it, onto a solid to place it under that solid's parent, or into the empty space below the list to move it to the document root. Click a **group** (including an empty one) to make it the **current group**. Click a solid to select it and set the current group to that solid's parent. New primitives, extrudes, revolves, and similar additions go into the current group. @@ -124,7 +124,7 @@ Each row (left to right): - **Visibility** - Checkbox to show or hide that node. Hiding a **group** hides its whole subtree in the 3D view. - **Solid / wire** - Checkbox (solids only) to switch **shaded** or **wireframe**. - **M** - Solids only: material popup; right-click for **Shape info...** or **Delete**. -- **Name** - Expandable tree row with an editable name. Click the row to select that solid (or all descendant solids for a group) and update the current group. **Ctrl+click** toggles multi-select. Drag to reparent. +- **Name** - Expandable tree row with an editable name. Click the row to select that solid (or all descendant solids for a group) and update the current group. **Ctrl+click** toggles multi-select. Drag to reparent (empty area below the list = document root). - **Right-click the name** - Solids: **Shape info...** / **Delete**. Groups: **Ungroup** (moves **all** direct children to the group's parent, then removes the group) / **Delete** (cascade-deletes the whole subtree). Boolean results stay under the shared parent of their inputs when all inputs share one parent; otherwise they are placed at the document root. **File -> Import** STEP assemblies preserve product/assembly groups in the tree (unless **Union shapes** is checked). diff --git a/src/doc/gui.md b/src/doc/gui.md index f267412..ee4cffe 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -209,7 +209,7 @@ Shared sketch controls (snap, midpoint nodes, place-from-center) live in `option Sketch List expand **Faces**: each face row supports **`E`** and right-click **Extrude** via `GUI::sketch_list_extrude_face_` (`set_mode(Sketch_face_extrude)` + `Occt_view::begin_sketch_face_extrude` / `Shp_extrude::begin_face_extrude`). Hovering a **Faces**, **Edges**, or **Nodes** row calls `Occt_view::set_sketch_list_hover_{face,edge,node}` (temporarily displays the AIS when hidden outside sketch modes; uses `Graphic3d_ZLayerId_Topmost` so solids do not occlude the highlight). -**Shape List outliner:** `shape_list_` draws a tree of document shapes/groups via `shape_children(0)` and recursive `TreeNodeEx` rows. Fixed-width vis/disp/mat columns are on the left; the name column stretches on the right with tree indent (`IndentEnable` on name only). Groups support expand/collapse (`ui.shapeList.expanded`), drag-drop reparent (`EZY_SHAPE_ID` payload), Group / New group / Ungroup, and cascade delete. Clicking a group sets `Occt_view::current_group_id` (including empty groups) and selects descendant solids; clicking a solid selects it and sets current group to its parent. New primitives/extrudes/revolves parent under the current group. Ctrl+click multi-selects. Hover uses `set_shape_list_hover` on leaf solids only. `ui.shapeList.currentGroupId` is persisted in `.ezy`. +**Shape List outliner:** `shape_list_` draws a tree of document shapes/groups via `shape_children(0)` and recursive `TreeNodeEx` rows. Fixed-width vis/disp/mat columns are on the left; the name column stretches on the right with tree indent (`IndentEnable` on name only). An empty pad row after the last item is a drag-drop target for document root (`reparent_shape(..., 0)`); it shows a "Move to root" hint while dragging. Groups support expand/collapse (`ui.shapeList.expanded`), drag-drop reparent (`EZY_SHAPE_ID` payload), Group / New group / Ungroup, and cascade delete. Clicking a group sets `Occt_view::current_group_id` (including empty groups) and selects descendant solids; clicking a solid selects it and sets current group to its parent. New primitives/extrudes/revolves parent under the current group. Ctrl+click multi-selects. Hover uses `set_shape_list_hover` on leaf solids only. `ui.shapeList.currentGroupId` is persisted in `.ezy`. **Sketch List UI in the project file:** `GUI::serialized_project_json_` writes `ui.sketchList` (scroll Y plus per-sketch `rows` keyed by sketch `id`: `expanded`, `dimensions`, `nodes`, `edges`, `faces`). `GUI::on_file` restores via `apply_sketch_list_ui_from_json_`. Subsection open state is app-owned (`Sketch_list_row_ui` + `SetNextItemOpen`), not ImGui ini storage. diff --git a/src/gui.cpp b/src/gui.cpp index fb18ae7..087d508 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -2695,8 +2695,13 @@ void GUI::shape_list_() ImGui::EndDragDropSource(); } - if (ImGui::BeginDragDropTarget()) + // Drop onto group -> that group; onto solid -> solid's parent. Register on both the + // tree arrow and the name field — the name covers most of the row. + auto accept_reparent_drop = [&]() { + if (!ImGui::BeginDragDropTarget()) + return; + if (const ImGuiPayload* payload = ImGui::AcceptDragDropPayload("EZY_SHAPE_ID")) { Shape_id drag_id = 0; @@ -2705,7 +2710,8 @@ void GUI::shape_list_() (void)m_view->reparent_shape(drag_id, new_parent, -1, true); } ImGui::EndDragDropTarget(); - } + }; + accept_reparent_drop(); ImGui::SameLine(); ImGui::SetNextItemWidth(std::max(1.0f, ImGui::GetContentRegionAvail().x)); @@ -2715,6 +2721,15 @@ void GUI::shape_list_() if (ImGui::IsItemClicked()) select_shape_row(shape); + if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) + { + const Shape_id drag_id = shape->get_id(); + ImGui::SetDragDropPayload("EZY_SHAPE_ID", &drag_id, sizeof(drag_id)); + ImGui::TextUnformatted(shape->get_name().c_str()); + ImGui::EndDragDropSource(); + } + accept_reparent_drop(); + row_hovered |= ImGui::IsItemHovered(); if (ImGui::BeginPopupContextItem("shape_name_ctx")) { @@ -2767,6 +2782,57 @@ void GUI::shape_list_() for (const Shp_ptr& root : m_view->shape_children(0)) draw_shape_row(draw_shape_row, root); + // Empty pad below the last row: drop here to move to document root (no permanent root node). + { + const float min_pad_h = ImGui::GetFrameHeight(); + const float pad_h = std::max(min_pad_h, ImGui::GetContentRegionAvail().y); + ImGui::TableNextRow(ImGuiTableRowFlags_None, pad_h); + ImGui::TableSetColumnIndex(0); + + const ImGuiPayload* active_payload = ImGui::GetDragDropPayload(); + const bool dragging_shape = + active_payload != nullptr && active_payload->IsDataType("EZY_SHAPE_ID") && active_payload->DataSize == sizeof(Shape_id); + + if (dragging_shape) + ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0, ImGui::GetColorU32(ImGuiCol_Header, 0.20f)); + + ImGui::Selectable("##shape_root_drop", false, + ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap | ImGuiSelectableFlags_Disabled, + ImVec2(0.0f, pad_h)); + + bool root_drop_hovered = false; + if (ImGui::BeginDragDropTarget()) + { + const ImGuiPayload* payload = + ImGui::AcceptDragDropPayload("EZY_SHAPE_ID", ImGuiDragDropFlags_AcceptBeforeDelivery); + if (payload != nullptr) + { + root_drop_hovered = payload->Preview; + if (payload->IsDelivery()) + { + Shape_id drag_id = 0; + std::memcpy(&drag_id, payload->Data, sizeof(drag_id)); + (void)m_view->reparent_shape(drag_id, 0, -1, true); + } + } + ImGui::EndDragDropTarget(); + } + + if (dragging_shape) + { + const ImVec2 rmin = ImGui::GetItemRectMin(); + const ImVec2 rmax = ImGui::GetItemRectMax(); + if (root_drop_hovered) + ImGui::GetWindowDrawList()->AddRectFilled(rmin, rmax, ImGui::GetColorU32(ImGuiCol_HeaderHovered, 0.55f)); + + const char* hint = "Move to root"; + const ImVec2 ts = ImGui::CalcTextSize(hint); + ImGui::GetWindowDrawList()->AddText(ImVec2(rmin.x + (rmax.x - rmin.x - ts.x) * 0.5f, + rmin.y + (rmax.y - rmin.y - ts.y) * 0.5f), + ImGui::GetColorU32(ImGuiCol_TextDisabled), hint); + } + } + ImGui::PopStyleVar(2); ImGui::EndTable(); } From 3ccbefa90a8f5b327aac20e5f7a85ed2239f831a Mon Sep 17 00:00:00 2001 From: Trailcode Date: Sun, 19 Jul 2026 18:08:32 -0600 Subject: [PATCH 5/8] Trigger CI re-queue for PR checks. Co-authored-by: Cursor --- CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 49c4867..b725054 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -840,3 +840,5 @@ if(EXISTS ${GITIGNORE_FILE}) target_sources(${PROJECT_NAME} PRIVATE ${GITIGNORE_FILE}) set_source_files_properties(${GITIGNORE_FILE} PROPERTIES HEADER_FILE_ONLY TRUE) endif() + + From cb22299c9e2487af826b5e2d880fcbf0eefb27c0 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Sun, 19 Jul 2026 18:09:08 -0600 Subject: [PATCH 6/8] WIP --- .../gh-214-shape-list-hierarchy-phase3.md | 61 ++++++++++++++ .../gh-215-shape-list-hierarchy-phase2.md | 60 ++++++++++++++ .../gh-217-shape-list-hierarchy-phase1.md | 82 +++++++++++++++++++ .../gh-216-shape-list-hierarchy-phase1.md | 32 ++++++++ src/doc/shape.md | 2 +- src/shp.cpp | 4 +- src/shp.h | 6 +- 7 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 agents/drafts/issues/active/gh-214-shape-list-hierarchy-phase3.md create mode 100644 agents/drafts/issues/active/gh-215-shape-list-hierarchy-phase2.md create mode 100644 agents/drafts/issues/active/gh-217-shape-list-hierarchy-phase1.md create mode 100644 agents/drafts/prs/active/gh-216-shape-list-hierarchy-phase1.md diff --git a/agents/drafts/issues/active/gh-214-shape-list-hierarchy-phase3.md b/agents/drafts/issues/active/gh-214-shape-list-hierarchy-phase3.md new file mode 100644 index 0000000..f8cda1d --- /dev/null +++ b/agents/drafts/issues/active/gh-214-shape-list-hierarchy-phase3.md @@ -0,0 +1,61 @@ +--- +github_issue: 214 +status: active +paired_draft: gh-215-shape-list-hierarchy-phase2.md +--- + +# Shape List hierarchy phase 3 + +**Suggested labels:** `enhancement`, `feature-request` + +--- + +## Title (GitHub) + +Shape List hierarchy phase 3: parent transforms and advanced assembly model + +## Body (GitHub) + +### Summary + +Longer-term Shape List / document hierarchy work that should **not** ship with the organizational outliner. Phase 1 delivered folder-style groups without changing leaf BREP or transform semantics. Phase 2 covers outliner polish (reorder, duplicate, selection). Phase 3 is the product decision layer: inherited placement, optional boolean history, and part-local frames. + +### Context + +- Phase 1: organizational groups only — parents are folders; leaf solids remain the only move/boolean targets. +- Phase 2: #215 reorder, duplicate group, selection polish. +- Related: #59 (compound/assembly modeling; XCAF/instance transforms overlap this phase). + +### Scope (candidates — pick deliberately) + +**Parent transform inheritance** + +- Move / rotate / scale a group applies to descendants (relative placement). +- Needs clear interaction with `Shp_move` / `Shp_rotate` / `Shp_scale`, undo, and baking vs live local transforms. + +**Boolean feature history tree** + +- Fuse/cut/common keep inputs as children instead of today's replace-input model. +- Separate product decision; conflicts with current boolean UX and deltas. + +**Part-local origins / planes** + +- FreeCAD-like coordinate objects under a part/group — new object types, not just grouping. + +**Cross-sketch parenting** + +- Explicitly **out of product intent** for now: sketches stay in Sketch List. + +### Acceptance criteria (once scoped) + +- [ ] Written design note choosing which of the candidates ship (and which stay deferred) +- [ ] Implementation matches that design; leaf-only ops remain safe where inheritance does not apply +- [ ] Undo/redo covers new transform or history semantics +- [ ] User docs and CHANGELOG updated +- [ ] Tests for the chosen model (placement inheritance and/or history) + +### Related + +- Issue: https://github.com/trailcode/EzyCad/issues/214 +- #59 Compound / assembly modeling in EzyCad +- #215 Shape List hierarchy phase 2 diff --git a/agents/drafts/issues/active/gh-215-shape-list-hierarchy-phase2.md b/agents/drafts/issues/active/gh-215-shape-list-hierarchy-phase2.md new file mode 100644 index 0000000..ffa8e42 --- /dev/null +++ b/agents/drafts/issues/active/gh-215-shape-list-hierarchy-phase2.md @@ -0,0 +1,60 @@ +--- +github_issue: 215 +status: active +paired_draft: gh-214-shape-list-hierarchy-phase3.md +--- + +# Shape List hierarchy phase 2 + +**Suggested labels:** `enhancement`, `ui` + +--- + +## Title (GitHub) + +Shape List hierarchy phase 2: reorder, duplicate group, selection polish + +## Body (GitHub) + +### Summary + +Follow-up work after the organizational Shape List outliner (phase 1). Phase 1 already covers groups, current group, DnD reparent, cascade delete/ungroup, visibility, boolean result parenting, and STEP XCAF assembly import as groups. Phase 2 hardens outliner workflows that still feel incomplete without changing leaf BREP or adding parent transforms. + +### Context (phase 1 done) + +- Hierarchical Shape List with organizational groups (`parentId` / `order` / `isGroup`) +- Current group; new primitives/extrudes/revolves land under it +- Group / New group / Ungroup, DnD reparent, cascade delete, Ctrl multi-select +- STEP import preserves XCAF product/assembly groups (unless **Union shapes**) + +Related: #59 (broader compound/assembly modeling; this issue is the next Shape List slice). + +### Scope + +**In scope** + +- **Reorder without reparent** — drag between siblings (or explicit move up/down) to change `sibling_order` under the same parent without changing `parent_id`. +- **Duplicate group** — deep-copy a group subtree (structure + leaf solids), unique names, undoable. +- **Selection / hover polish** — e.g. Select children only, clearer empty-group vs solid selection feedback, optional middle-click or context-menu variants for subtree selection. +- Optional stretch: scripting helpers for `parent_id` / children / `group()` / `ungroup()` if low cost. + +**Out of scope (phase 3)** + +- Parent transform inheritance (moving a group moves children) +- Boolean feature-history trees (fuse keeps inputs as children) +- Part-local origins / planes +- Cross-sketch parenting + +### Acceptance criteria + +- [ ] User can reorder siblings under the same parent without reparenting to another group +- [ ] Duplicate group creates an independent deep copy (structure + solids) with undo +- [ ] At least one polished subtree-selection path beyond click-group selects all descendant solids +- [ ] Docs (`docs/usage.md`, `src/doc/gui.md` / `shape.md`) and CHANGELOG updated +- [ ] Unit tests for reorder and duplicate-group (plus any selection helpers that are non-UI) + +### Related + +- Issue: https://github.com/trailcode/EzyCad/issues/215 +- #59 Compound / assembly modeling in EzyCad +- #214 Shape List hierarchy phase 3 diff --git a/agents/drafts/issues/active/gh-217-shape-list-hierarchy-phase1.md b/agents/drafts/issues/active/gh-217-shape-list-hierarchy-phase1.md new file mode 100644 index 0000000..97f1059 --- /dev/null +++ b/agents/drafts/issues/active/gh-217-shape-list-hierarchy-phase1.md @@ -0,0 +1,82 @@ +--- +github_issue: 217 +github_pr: 216 +status: active +paired_draft: ../../prs/active/gh-216-shape-list-hierarchy-phase1.md +--- + +# Shape List hierarchy phase 1 + +**Suggested labels:** `enhancement`, `ui` + +--- + +## Title (GitHub) + +Shape List hierarchy phase 1: organizational groups and outliner + +## Body (GitHub) + +### Summary + +Documents the Shape List organizational hierarchy and related import/outliner work landed in PR #216 (phase 1). Groups are folder nodes in the document tree (no parent transform inheritance). Follow-ups: #215 (phase 2), #214 (phase 3), #59 (broader assembly modeling). + +### Problem + +- Shape List was a flat list of solids with no parent/child outliner. +- Multi-body STEP assemblies flattened; no way to organize parts into folders for visibility, selection, or parenting new geometry. +- Hide-all and selection UX did not support group-oriented workflows. + +### Implemented scope + +**Document model** + +- `Shp` nodes gain `parent_id`, `sibling_order`, `is_group` (empty compound; never displayed). +- `.ezy` persists `parentId`, `order`, `isGroup`, `visible`. +- Undo: `Shape_tree_delta` for reparent / group / ungroup; shape add/remove records include hierarchy fields. + +**Shape List UI** + +- Tree table: expand/collapse, vis / solid-wire / material on the left, name on the right. +- **Current group**: click a group (including empty) to set it; new primitives, extrudes, revolves, PLY, and unioned STEP import parent under it. +- **New group** / **Group** / **Ungroup** (all direct children move up); cascade delete of group subtrees. +- Drag-drop reparent (onto group, onto solid → that solid's parent, empty pad below list → document root). +- Ctrl multi-select; group click selects descendant solids. +- Persist `ui.shapeList.expanded` and `ui.shapeList.currentGroupId`. + +**Operations / import** + +- Booleans / polar dup: result parent = shared operand parent, else root. +- Hide all no longer stomps per-shape visibility flags (effective visibility via ancestors + overlays). +- STEP XCAF assembly → Shape List group tree (unless **Union shapes**); product/part names used when present. +- File → Import dialog (metadata, optional union) — related import UX on the same branch. + +**Tests / docs** + +- `tests/shp_tests.cpp`: group/ungroup, hierarchy JSON, fuse parent, current-group parenting, etc. +- `docs/usage.md`, `CHANGELOG.md`, `src/doc/shape.md`, `gui.md`, `utility.md`. + +### Acceptance criteria + +- [x] Group / ungroup / reparent / cascade delete with undo +- [x] Current group; empty groups selectable; new solids parent under it +- [x] DnD to group, to sibling-via-solid, and to document root (empty pad) +- [x] Group visibility hides subtree; Hide all preserves per-node flags +- [x] Boolean results inherit shared parent when applicable +- [x] STEP assembly import as group tree (optional union) +- [x] `.ezy` round-trip of hierarchy + UI expand/current group +- [x] Unit tests and user/developer docs updated + +### Out of scope (follow-ups) + +- #215 — sibling reorder without reparent, duplicate group, selection polish +- #214 — parent transform inheritance, boolean history tree, part-local frames +- #59 — broader compound / assembly modeling + +### Related + +- Issue: https://github.com/trailcode/EzyCad/issues/217 +- PR: https://github.com/trailcode/EzyCad/pull/216 +- #215 Shape List hierarchy phase 2 +- #214 Shape List hierarchy phase 3 +- #59 Compound / assembly modeling in EzyCad diff --git a/agents/drafts/prs/active/gh-216-shape-list-hierarchy-phase1.md b/agents/drafts/prs/active/gh-216-shape-list-hierarchy-phase1.md new file mode 100644 index 0000000..5949667 --- /dev/null +++ b/agents/drafts/prs/active/gh-216-shape-list-hierarchy-phase1.md @@ -0,0 +1,32 @@ +--- +github_pr: 216 +github_issue: 217 +status: active +paired_draft: ../../issues/active/gh-217-shape-list-hierarchy-phase1.md +--- + +# PR - Shape List hierarchy phase 1 + +## Title + +Shape List hierarchy phase 1: organizational groups and outliner + +## Summary + +- Organizational Shape List hierarchy (groups, current group, DnD including drop-to-root). +- Boolean/extrude parenting; Hide all preserves visibility flags. +- STEP XCAF assembly → group tree; Import dialog on this branch. +- Docs, CHANGELOG, tests. + +## Related + +- Issue: https://github.com/trailcode/EzyCad/issues/217 +- PR: https://github.com/trailcode/EzyCad/pull/216 +- Follow-ups: #215, #214, #59 + +## Test plan + +- [ ] Shape List group / current group / DnD / ungroup / cascade delete +- [ ] Hide all / group visibility +- [ ] STEP import with/without Union shapes +- [ ] `EzyCad_tests.exe --gtest_filter=Shp_test.*` diff --git a/src/doc/shape.md b/src/doc/shape.md index ae0796a..2ccbd47 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -108,7 +108,7 @@ There is no single `Shape` coordinator class; **`Occt_view` is the hub** and exp ```cpp class Shp : public AIS_Shape { Shp(AIS_InteractiveContext& ctx, const TopoDS_Shape& shp); - static handle create_group(...); // organizational node; not displayed + static Shp_ptr create_group(...); // organizational node; not displayed // name, display mode, visibility preference, parent_id, sibling_order, is_group void apply_context_shown(bool); // Erase/Display without changing get_visible() }; diff --git a/src/shp.cpp b/src/shp.cpp index 0457818..204bdde 100644 --- a/src/shp.cpp +++ b/src/shp.cpp @@ -17,11 +17,11 @@ Shp::Shp(AIS_InteractiveContext& ctx, const TopoDS_Shape& shp) Shp::~Shp() {} -opencascade::handle Shp::create_group(AIS_InteractiveContext& ctx, const std::string& name) +Shp_ptr Shp::create_group(AIS_InteractiveContext& ctx, const std::string& name) { TopoDS_Compound comp; BRep_Builder().MakeCompound(comp); - opencascade::handle grp = new Shp(ctx, comp); + Shp_ptr grp = new Shp(ctx, comp); grp->m_is_group = true; grp->set_name(name); grp->m_visible = true; diff --git a/src/shp.h b/src/shp.h index 1af8ea7..3c6324a 100644 --- a/src/shp.h +++ b/src/shp.h @@ -10,13 +10,16 @@ class AIS_InteractiveContext; using Shape_id = uint64_t; +class Shp; +using Shp_ptr = opencascade::handle; + /// Document shape or organizational group (group nodes have no viewer display). class Shp : public AIS_Shape { public: Shp(AIS_InteractiveContext& ctx, const TopoDS_Shape& shp); /// Organizational group (empty compound geometry; never displayed). - static opencascade::handle create_group(AIS_InteractiveContext& ctx, const std::string& name); + static Shp_ptr create_group(AIS_InteractiveContext& ctx, const std::string& name); virtual ~Shp(); Shape_id get_id() const; @@ -63,5 +66,4 @@ class Shp : public AIS_Shape int m_sibling_order{0}; }; -using Shp_ptr = opencascade::handle; using Shp_rslt = Result; From 2696a8c346a12970512f197834e617602d6a1633 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Mon, 20 Jul 2026 08:48:34 -0600 Subject: [PATCH 7/8] Fix --- src/gui.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui.cpp b/src/gui.cpp index 087d508..eebb199 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -2696,7 +2696,7 @@ void GUI::shape_list_() } // Drop onto group -> that group; onto solid -> solid's parent. Register on both the - // tree arrow and the name field — the name covers most of the row. + // tree arrow and the name field - the name covers most of the row. auto accept_reparent_drop = [&]() { if (!ImGui::BeginDragDropTarget()) From 703f578f38c42cda14716a484f75ec1a3c4b6263 Mon Sep 17 00:00:00 2001 From: Trailcode Date: Mon, 20 Jul 2026 17:34:38 -0600 Subject: [PATCH 8/8] Bug fix. --- src/gui.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/gui.cpp b/src/gui.cpp index eebb199..cee6adb 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -2783,9 +2783,14 @@ void GUI::shape_list_() draw_shape_row(draw_shape_row, root); // Empty pad below the last row: drop here to move to document root (no permanent root node). + // Size from the visible clip remainder, not GetContentRegionAvail().y -- inside a ScrollY + // table that avail tracks content size, so a fill-avail pad feeds back into growing scroll. { - const float min_pad_h = ImGui::GetFrameHeight(); - const float pad_h = std::max(min_pad_h, ImGui::GetContentRegionAvail().y); + const float min_pad_h = ImGui::GetFrameHeight(); + const ImGuiWindow* inner = ImGui::GetCurrentWindow(); + const float visible_remain = + inner->InnerClipRect.Max.y - ImGui::GetCursorScreenPos().y - ImGui::GetStyle().CellPadding.y; + const float pad_h = std::max(min_pad_h, visible_remain); ImGui::TableNextRow(ImGuiTableRowFlags_None, pad_h); ImGui::TableSetColumnIndex(0);