diff --git a/CHANGELOG.md b/CHANGELOG.md index dd36333..0ca5478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Status toast messages (`GUI::show_message` / `ezy.msg`) are also written to the **Log** window. - WASM (OCCT 7.9.3): stop forcing a near-white AIS `SetColor` after every material apply. That OCCT 8 GLES workaround had been running on all Emscripten builds and made steel, gold, and other presets look identical (glass still showed transparency). - WASM: clear AIS `OwnColor` when applying a Shape List material so presets are not stuck on a forced color. Prefer the **OCCT 7.9.3** wasm kit — OCCT 8.x GLES still has known shading regressions (`docs/building-occt.md`). ### Cross-section -- Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately; changing the selection while in the tool also updates it. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center), **Invert normal** (flips the annotation arrow and positive-offset direction while keeping the plane in place), **Hide back side** (on by default; display-only AIS clip opposite the yellow arrow), a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units, and **Clip** (half-space cut: keeps the positive-normal side, deletes the originals, adds clipped replacements; undoable). A translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. +- Added a temporary **Shape cross-section** preview tool for selected solids. Entering the tool with solids already selected updates the preview immediately; changing the selection while in the tool also updates it. Options choose a shared local **XY**, **XZ**, or **YZ** plane (first-selected axes, selection-bbox center), **Invert normal** (flips the annotation arrow and positive-offset direction while keeping the plane in place), **Hide back side** (on by default; display-only AIS clip opposite the yellow arrow), **Show section outline** (off by default; toggles the cyan intersection wires), a bounding-box **Offset** slider (Ctrl+click to type; auto-updates preview) in project units, **Clip** (half-space cut: keeps the positive-normal side, deletes the originals, adds clipped replacements; solids fully on the discarded side are removed with no replacement; undoable), and **Cross section sketch** (new sketch on the cutting plane with imported line/circle section edges; undoable). A translucent yellow plane and normal arrow annotate the cut. Solids the plane misses are skipped; the preview clears only if nothing in the selection is cut. - Desktop preview sections multiple selected solids in parallel (worker pool); WASM remains single-threaded for the per-solid pool. Bounding-box culling skips solids that cannot meet the plane. Offset/plane dragging updates the yellow plane immediately and runs section wires asynchronously (cancel + latest-pending) so the Options slider stays responsive. - Shapes now carry a bbox-centered local frame that follows baked move/rotate/scale transforms and persists in project JSON and shape undo records. diff --git a/agents/drafts/prs/active/gh-219-cross-section-tool.md b/agents/drafts/prs/active/gh-219-cross-section-tool.md index 6ad9b80..a072f70 100644 --- a/agents/drafts/prs/active/gh-219-cross-section-tool.md +++ b/agents/drafts/prs/active/gh-219-cross-section-tool.md @@ -13,7 +13,7 @@ Add temporary shape cross-section preview tool ## Summary -- Temporary shape cross-section preview (local XY/XZ/YZ + offset, Invert normal, Hide back side, Clip). +- Temporary shape cross-section preview (local XY/XZ/YZ + offset, Invert normal, Hide back side, Show section outline, Clip, Cross section sketch). - Async section wires + desktop parallel pool; WASM serial (see `wasm-multithreading.md`). - Shape local frames for plane orientation / persistence. @@ -28,5 +28,5 @@ Add temporary shape cross-section preview tool - [ ] Desktop Release build - [ ] `EzyCad_tests --gtest_filter=Shp_cross_section.*:Shp_test.Cross_section*` -- [ ] Manual enter / selection / plane / offset / Hide back / Clip / Esc +- [ ] Manual enter / selection / plane / offset / Hide back / Show section outline / Clip / Cross section sketch / Esc - [ ] Docs sync checklist diff --git a/agents/plans/sketch-from-shape-section.md b/agents/plans/sketch-from-shape-section.md index b586cd9..8177d41 100644 --- a/agents/plans/sketch-from-shape-section.md +++ b/agents/plans/sketch-from-shape-section.md @@ -1,5 +1,5 @@ --- -status: deferred +status: partial topic: sketch-from-shape depends_on: - shp-origin-orientation @@ -12,12 +12,7 @@ depends_on: ## Status -**Deferred.** Needs experimentation first: - -1. [shp-origin-orientation.md](shp-origin-orientation.md) — persistent shape frame (origin + orientation). -2. [cross-section-tool.md](cross-section-tool.md) — prototype section display and OCCT curve discovery. - -Do **not** implement sketch import until the cross-section prototype has findings (curve types, plane UX, fail cases). +**Partial.** Cross-section Options **Cross section sketch** imports cached section **line** and **circle** edges into a new sketch (`Occt_view::create_sketch_from_cross_section`). Remaining work: weld/dedup for boolean sections, ellipse/B-spline densify, script binding. ## Goal (end state) @@ -43,18 +38,19 @@ Earlier draft used world XY/XZ/YZ through bbox center — superseded once shape 3. Weld nodes → `rebuild_faces()` → one undo commit. **Reuse:** `to_2d`/`to_3d`, `add_sketch`, `Sketch_edges`, `Sketch_op_recorder`. -**New:** section→sketch importer after prototype validates section quality. +**Shipped MVP:** Options button on the cross-section tool; caches section plane with compound. ## Phases (after prerequisites) -- **MVP:** One solid; plane from shape frame; line+circle; polyline fallback; undo; tests. -- **Later:** Multi-shape; densify; script; face-pick plane; optional `originating_shape_id`. +- **MVP:** One solid; plane from shape frame; line+circle; undo; tests. *(Delivered via cross-section tool button; multi-solid uses shared plane.)* +- **Later:** densify; script; face-pick plane; optional `originating_shape_id`; weld overlapping boolean edges. ## Acceptance (when un-deferred) -- [ ] Editable sketch edges from section; undo works; fail closed on miss. -- [ ] Docs + CHANGELOG; curve rules match cross-section findings. +- [x] Editable sketch edges from section; undo works; fail closed on miss. +- [x] Docs + CHANGELOG; curve rules match cross-section findings (line + circle first). +- [ ] Weld/dedup for fused solids; densify unsupported curves. ## Out of scope until unblocked -C++ sketch import; changing planar-face behavior. +Changing planar-face behavior. diff --git a/docs/scripting.md b/docs/scripting.md index e0b2850..8e1a003 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -47,7 +47,7 @@ ezy.Shp | Method | Purpose | | ------------------------------- | ---------------------------------------------- | | `ezy.log(msg)` | Print to the console and the main **Log** pane | -| `ezy.msg(text)` | Show a status message | +| `ezy.msg(text)` | Status toast and append to the main **Log** pane | | `ezy.get_mode()` | Current application mode name (string) | | `ezy.set_mode(name)` | Switch mode by name | | `ezy.save_occt_view_settings()` | Write settings JSON (including view colors) | diff --git a/docs/usage-settings.md b/docs/usage-settings.md index 9a127b4..f768a1f 100644 --- a/docs/usage-settings.md +++ b/docs/usage-settings.md @@ -104,7 +104,7 @@ Under **Material**: - Document preset for new solids that do not inherit from a clicked shape (toolbar **Box**, **polar duplicate** output, and similar). To change material on an existing solid, use the [Shape List](usage.md#shape-list). -For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, **Invert normal**, **Hide back side**, a bbox-ranged **Offset** slider (Ctrl+click to type), **Clip** (half-space cut, replaces selection), and automatic preview updates when the selection changes; see [Shape cross-section tool](usage.md#shape-cross-section-tool). +For other non-sketch Options content, see the matching tool section in the usage guide. In particular, **Shape cross-section** provides local **XY/XZ/YZ**, **Invert normal**, **Hide back side**, **Show section outline**, a bbox-ranged **Offset** slider (Ctrl+click to type), **Clip** (half-space cut, replaces selection), **Cross section sketch** (new sketch from section outline), and automatic preview updates when the selection changes; see [Shape cross-section tool](usage.md#shape-cross-section-tool). ### Sketch tools diff --git a/docs/usage.md b/docs/usage.md index dce4527..84c2b2f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -73,7 +73,7 @@ EzyCad (Easy CAD) is an open-source CAD application for hobbyist machinists to d 6. **Log Window** - View operation history - Check for errors and warnings - - Monitor system status + - Monitor system status (including status toast messages) ### Help menu @@ -599,13 +599,13 @@ The polar duplicate tool allows you to create multiple copies of selected shapes ![Cross-section Tool](res/icons/Curves_ExtractSubshape.png) -Use the cross-section tool to inspect where a plane cuts one or more selected solids. The result is a temporary cyan wire preview. A translucent yellow rectangle outlines the cutting plane, with an arrow showing its positive normal direction. These annotations do not modify the solids or create a sketch. +Use the cross-section tool to inspect where a plane cuts one or more selected solids. The result is a temporary cyan wire preview (optional; **Show section outline** is off by default). A translucent yellow rectangle outlines the cutting plane, with an arrow showing its positive normal direction. These annotations do not modify the solids until you **Clip** or **Cross section sketch**. 1. Select one or more solids. 2. Click **Shape cross-section** in the toolbar. If solids were already selected, the preview updates immediately. -3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. Use **Invert normal** to flip the yellow arrow (and the positive-offset direction); the cut stays in place. **Hide back side** is on by default and preview-clips the selected solids so geometry on the negative-normal side of the plane is not drawn (opposite the yellow arrow); turn it off to show the full solids. +3. In **Options**, choose **Local XY**, **Local XZ**, or **Local YZ**. Use **Invert normal** to flip the yellow arrow (and the positive-offset direction); the cut stays in place. **Hide back side** is on by default and preview-clips the selected solids so geometry on the negative-normal side of the plane is not drawn (opposite the yellow arrow); turn it off to show the full solids. **Show section outline** is off by default; turn it on for cyan intersection wires. The yellow plane annotation stays visible either way. 4. Drag **Offset** (or Ctrl+click to type) along that plane's local normal. The slider range follows the selected solids' bounding box in the current project unit. The yellow plane updates immediately while you drag; cyan section wires catch up asynchronously so the control stays responsive. -5. Click **Clip** to keep the positive-normal half of each selected solid as a new shape and delete the originals (undoable). Leave the tool (or clear the selection) to remove the preview and any hide-back display clip. +5. Click **Clip** to keep the positive-normal half of each selected solid as a new shape and delete the originals (undoable). Solids that lie entirely on the discarded side are removed with no replacement. Click **Cross section sketch** to create a new sketch on the cutting plane and import the section outline as editable line and circle edges (ellipses and other curve types are skipped). Leave the tool (or clear the selection) to remove the preview and any hide-back display clip. 6. Changing the selection or **Section plane** (or dragging **Offset**) updates the preview automatically. If nothing is selected, Options shows a bold prompt to select one or more shapes. Each solid has a local frame used for orientation. New solids start with a world-aligned frame at the center of their bounding box; moving, rotating, or scaling a solid updates that frame. With multiple selected solids, all of them share one cutting plane: orientation from the first selected solid's local axes, and origin at the selection bounding-box center. Offset moves that shared plane along its normal. diff --git a/src/doc/gui.md b/src/doc/gui.md index 7dc5bc7..f06b4e7 100644 --- a/src/doc/gui.md +++ b/src/doc/gui.md @@ -186,7 +186,7 @@ Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse positio | `Move` / `Rotate` / `Scale` | `options_*_mode_` (constraints, axis, material) | | `Shape_chamfer` / `Shape_fillet` | mode + radius/distance | | `Shape_polar_duplicate` | angle, count, rotate/combine, **Dup** button | -| `Shape_cross_section` | local XY/XZ/YZ, invert normal, hide back side, bbox-ranged offset, Clip (replace) | +| `Shape_cross_section` | local XY/XZ/YZ, invert normal, hide back side, show section outline, bbox-ranged offset, Clip, Cross section sketch | | `Sketch_inspection_mode` | `options_sketch_common_` | | Each sketch tool mode | Matching `options_sketch_*_mode_` | | `Sketch_operation_axis` | Mirror / Revolve / Clear axis | @@ -208,6 +208,8 @@ Shared sketch controls (snap, midpoint nodes, place-from-center) live in `option | 8 | `add_*_dialog_` | Primitive / sketch creation popups | | 9 | `log_window_`, consoles, `settings_`, `dbg_` | Log, Lua/Python, Settings | +`GUI::show_message` drives the transient status toast (`message_status_window_`) and also appends via `log_message`. `show_error_dialog` logs `title: message` once and toasts the title only. + 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). 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`. @@ -235,7 +237,7 @@ Toolbar buttons hold `std::variant`. `Command` (`Shape_cut`, `Sha Mode buttons call `set_mode`. Active state tracks `m_mode`. -The cross-section toolbar button enters `Mode::Shape_cross_section`. Entering the mode snapshots any selected solids first (selection-mode and sketch-faint redisplay Erase AIS selection), restores them after that sync, and calls `Shp_cross_section::preview` (blocking) with the snapshot. While the mode is active, Options updates the yellow plane annotation immediately on plane/offset/hide-back changes (`request_preview`), then `poll`s a background section job (desktop `std::async`; WASM chunks one solid per frame). At most one running job plus one pending (latest only); moving the slider cancels/coalesces work so the UI stays responsive. **Hide back side** attaches a temporary per-shape `Graphic3d_ClipPlane` for display only. **Clip** runs a half-space `BRepAlgoAPI_Common`, deletes the input solids, and adds clipped replacements (`Shape_replace_delta`). Temporary AIS and jobs are cleared when the mode is left, when the selection becomes empty, after a successful **Clip**, or on `clear()`. +The cross-section toolbar button enters `Mode::Shape_cross_section`. Entering the mode snapshots any selected solids first (selection-mode and sketch-faint redisplay Erase AIS selection), restores them after that sync, and calls `Shp_cross_section::preview` (blocking) with the snapshot. While the mode is active, Options updates the yellow plane annotation immediately on plane/offset/hide-back changes (`request_preview`), then `poll`s a background section job (desktop `std::async`; WASM chunks one solid per frame). At most one running job plus one pending (latest only); moving the slider cancels/coalesces work so the UI stays responsive. **Hide back side** attaches a temporary per-shape `Graphic3d_ClipPlane` for display only. **Clip** runs a half-space `BRepAlgoAPI_Common`, deletes the input solids, and adds clipped replacements (`Shape_replace_delta`). **Cross section sketch** imports cached section line/circle edges into a new sketch (`Sketch_struct_delta::Add`) and switches to sketch inspection. Temporary AIS and jobs are cleared when the mode is left, when the selection becomes empty, after a successful **Clip**, or on `clear()`. ## Typical developer usage diff --git a/src/doc/script.md b/src/doc/script.md index ac0a63c..115e6db 100644 --- a/src/doc/script.md +++ b/src/doc/script.md @@ -109,7 +109,7 @@ Aliases: global `view` == `ezy.view`; `Shp` == `ezy.Shp`; `view.add_sketch` / `f | Method | C++ delegate | | ------------------------------- | -------------------------------------- | | `ezy.log(msg)` | `GUI::log_message` + console history | -| `ezy.msg(text)` | `GUI::show_message` | +| `ezy.msg(text)` | `GUI::show_message` (toast + Log) | | `ezy.get_mode()` | `GUI::get_mode()` -> mode name string | | `ezy.set_mode(name)` | `GUI::set_mode(mode_from_string(...))` | | `ezy.save_occt_view_settings()` | `GUI::save_occt_view_settings()` | diff --git a/src/doc/shape.md b/src/doc/shape.md index 957eb10..19b83b9 100644 --- a/src/doc/shape.md +++ b/src/doc/shape.md @@ -151,7 +151,7 @@ Protected helpers used by all operation classes: | `shp_fillet.h` | `Shp_fillet` | `add_fillet(..., Fillet_mode)` -- `BRepFilletAPI_MakeFillet`; modes: Shape, Face, Wire, Edge (`mode.h`). | | `shp_chamfer.h` | `Shp_chamfer` | `add_chamfer(..., Chamfer_mode)` -- diagonal distance converted to setback (`dist/sqrt(2)`). | | `shp_polar_dup.h` | `Shp_polar_dup` | Arm on sketch plane; `dup()` copies selection at polar steps; options: rotate copies, combine into one solid. | -| `shp_cross_section.h` | `Shp_cross_section` | Shared cutting-plane preview: immediate yellow plane AIS; cyan section wires via async job (desktop `std::async` + per-solid pool; WASM one-solid-per-`poll` chunks); running+latest-pending cancel/coalesce; optional hide-back AIS clip; **Clip** half-space-commons and replaces inputs. | +| `shp_cross_section.h` | `Shp_cross_section` | Shared cutting-plane preview: immediate yellow plane AIS; cyan section wires via async job (desktop `std::async` + per-solid pool; WASM one-solid-per-`poll` chunks); running+latest-pending cancel/coalesce; optional hide-back AIS clip; **Show section outline** (default off) toggles cyan wires without recompute; **Clip** half-space-commons and replaces inputs (fully discarded solids are removed only); **Cross section sketch** imports cached section line/circle edges into a new sketch. | | `shp_info.h` | `namespace shp_info` | `collect(TopoDS_Shape, Display_meta*)` -> labeled lines for Shape info dialog. | ## Input routing (from UI / `Occt_view`) diff --git a/src/gui.cpp b/src/gui.cpp index 218cec7..26ae8b2 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -3109,12 +3109,16 @@ void GUI::show_message(const std::string& message) m_message = message; m_message_visible = true; m_message_start_time = std::chrono::steady_clock::now(); + log_message(message); } void GUI::show_error_dialog(const std::string& title, const std::string& message) { + // Log full detail once; toast title only (do not call show_message - that would log twice). log_message(title + ": " + message); - show_message(title); + m_message = title; + m_message_visible = true; + m_message_start_time = std::chrono::steady_clock::now(); m_error_modal_title = title; m_error_modal_message = message; m_open_error_modal = true; diff --git a/src/gui.h b/src/gui.h index 07a73ee..ca8ea67 100644 --- a/src/gui.h +++ b/src/gui.h @@ -313,8 +313,9 @@ class GUI /// True when dist or angle edit is visible; Tab should be routed to on_key() instead of ImGui. bool is_dist_or_angle_edit_active() const; bool is_sketch_origin_set_edit_active() const; + /// Transient bottom-right status toast; also appends to the Log window. void show_message(const std::string& message); - /// Queue an ImGui error modal (also logs and shows a short toast). Works on native and wasm. + /// Queue an ImGui error modal (logs title: message once; short title toast). Native and wasm. void show_error_dialog(const std::string& title, const std::string& message); void log_message(const std::string& message); void set_show_options(bool v) { m_show_options = v; } diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp index a49235a..c6b1d39 100644 --- a/src/gui_mode.cpp +++ b/src/gui_mode.cpp @@ -798,6 +798,10 @@ void GUI::options_shape_cross_section_mode_() if (ImGui::Checkbox("Hide back side", &hide_back_side)) section.set_hide_back_side(hide_back_side); + bool show_section_outline = section.get_show_section_outline(); + if (ImGui::Checkbox("Show section outline", &show_section_outline)) + section.set_show_section_outline(show_section_outline); + double offset_min = -1.0; double offset_max = 1.0; const bool have_range = section.try_get_offset_range_display(offset_min, offset_max); @@ -857,6 +861,14 @@ void GUI::options_shape_cross_section_mode_() } ImGui::EndDisabled(); + ImGui::BeginDisabled(!section.has_preview() || section.section_busy()); + if (ImGui::Button("Cross section sketch")) + { + const Status status = m_view->create_sketch_from_cross_section(); + show_message(status.message()); + } + ImGui::EndDisabled(); + options_orthographic_projection_(); } diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp index d16bf40..9ecdab4 100644 --- a/src/gui_occt_view.cpp +++ b/src/gui_occt_view.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -14,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -80,6 +82,7 @@ #include #include +#include #include using namespace glm; @@ -695,6 +698,125 @@ void Occt_view::add_sketch_on_ref_plane(Sketch_ref_plane plane, double offset_di add_sketch(pln, base_name); } +namespace +{ +[[nodiscard]] bool import_section_circle_edge_(Sketch& sketch, const TopoDS_Edge& edge, const gp_Pln& pln) +{ + const BRepAdaptor_Curve curve(edge); + const double u0 = curve.FirstParameter(); + const double u1 = curve.LastParameter(); + const double span = u1 - u0; + if (std::abs(span) <= Precision::Confusion()) + return false; + + // Full (or near-full) circles become two semicircles; sketch arcs cannot be closed loops. + const bool full_circle = curve.IsClosed() || std::abs(std::abs(span) - 2.0 * std::numbers::pi) <= 1.0e-3; + if (full_circle) + { + const gp_Pnt2d a = to_2d(pln, curve.Value(u0)); + const gp_Pnt2d mid1 = to_2d(pln, curve.Value(u0 + 0.25 * span)); + const gp_Pnt2d b = to_2d(pln, curve.Value(u0 + 0.5 * span)); + const gp_Pnt2d mid2 = to_2d(pln, curve.Value(u0 + 0.75 * span)); + sketch.add_arc_circle(a, mid1, b); + sketch.add_arc_circle(b, mid2, a); + return true; + } + + const auto [pt_a, pt_c] = get_edge_endpoints(pln, edge); + if (pt_a.Distance(pt_c) <= Precision::Confusion()) + return false; + + sketch.add_arc_circle(pt_a, arc_curve_midpoint_2d(edge, pln), pt_c); + return true; +} + +struct Section_import_counts +{ + size_t imported{0}; + size_t skipped{0}; +}; + +Section_import_counts import_section_edges_into_sketch_(Sketch& sketch, const TopoDS_Shape& compound, const gp_Pln& pln) +{ + Section_import_counts counts; + for (TopExp_Explorer it(compound, TopAbs_EDGE); it.More(); it.Next()) + { + const TopoDS_Edge edge = TopoDS::Edge(it.Current()); + switch (BRepAdaptor_Curve(edge).GetType()) + { + case GeomAbs_Line: + { + const auto [pt_a, pt_b] = get_edge_endpoints(pln, edge); + if (pt_a.Distance(pt_b) <= Precision::Confusion()) + { + ++counts.skipped; + break; + } + sketch.add_linear_edge(pt_a, pt_b); + ++counts.imported; + break; + } + case GeomAbs_Circle: + if (import_section_circle_edge_(sketch, edge, pln)) + ++counts.imported; + else + ++counts.skipped; + break; + default: + ++counts.skipped; + break; + } + } + return counts; +} +} // namespace + +Status Occt_view::create_sketch_from_cross_section(const std::string& base_name) +{ + Shp_cross_section& section = shp_cross_section(); + if (section.section_busy()) + { + const Status waited = section.wait_section(); + if (!waited.is_ok() && !section.has_preview()) + return waited; + } + + if (!section.has_preview()) + return Status::user_error("No cross-section outline to convert into a sketch."); + + const gp_Pln& pln = section.last_section_plane(); + const TopoDS_Shape& compound = section.last_section_compound(); + + std::vector existing; + existing.reserve(m_sketches.size()); + for (const Sketch_ptr& s : m_sketches) + existing.push_back(s->get_name()); + + const std::string name = unique_sequential_name(base_name, existing); + Sketch_ptr sketch = std::make_shared(name, *this, pln); + const Section_import_counts counts = import_section_edges_into_sketch_(*sketch, compound, pln); + if (counts.imported == 0 || sketch->edge_count() == 0) + return Status::user_error("Cross-section has no line or circle edges to import."); + + sketch->rebuild_faces(); + m_cur_sketch = sketch; + m_sketches.push_back(m_cur_sketch); + m_cur_sketch->set_current(); + refresh_viewer_grid_(); + push_undo_delta(std::make_unique(Sketch_struct_delta::Kind::Add, + Sketch_json::to_json(*m_cur_sketch, m_assets), true)); + m_gui.set_mode(Mode::Sketch_inspection_mode); + + std::ostringstream msg; + msg << "Created sketch '" << name << "' with " << counts.imported + << (counts.imported == 1 ? " imported edge" : " imported edges"); + if (counts.skipped > 0) + msg << " (" << counts.skipped << " unsupported curve" + << (counts.skipped == 1 ? "" : "s") << " skipped)"; + msg << "."; + return Status::ok(msg.str()); +} + void Occt_view::curr_sketch_add_edge(double x1, double y1, double x2, double y2) { curr_sketch().add_linear_edge(gp_Pnt2d(x1, y1), gp_Pnt2d(x2, y2)); diff --git a/src/gui_occt_view.h b/src/gui_occt_view.h index 2ae24bf..1e124fa 100644 --- a/src/gui_occt_view.h +++ b/src/gui_occt_view.h @@ -199,6 +199,8 @@ class Occt_view : protected AIS_ViewController void add_sketch(const gp_Pln& pln, const std::string& base_name); /// Like add_sketch; \a offset_display is multiplied by get_display_to_model_scale(). void add_sketch_on_ref_plane(Sketch_ref_plane plane, double offset_display, const std::string& base_name); + /// New sketch on the current cross-section plane; imports line/circle section edges (undoable). + [[nodiscard]] Status create_sketch_from_cross_section(const std::string& base_name = "Section_sketch"); void curr_sketch_add_edge(double x1, double y1, double x2, double y2); void curr_sketch_rebuild_faces(); Sketch& curr_sketch(); diff --git a/src/shp_cross_section.cpp b/src/shp_cross_section.cpp index 163093b..14152ba 100644 --- a/src/shp_cross_section.cpp +++ b/src/shp_cross_section.cpp @@ -2,6 +2,7 @@ #include "gui_occt_view.h" #include "shp_delta.h" +#include "utl_dbg.h" #include "utl_occt.h" #include @@ -182,7 +183,7 @@ Status Shp_cross_section::preview(const std::vector& shapes) const Status requested = request_preview(shapes); if (!requested.is_ok()) return requested; - return wait_section_(); + return wait_section(); } void Shp_cross_section::enqueue_section_(const Shared_plane& plane_ctx) @@ -245,6 +246,7 @@ Shp_cross_section::Section_result Shp_cross_section::compute_section_result_(Sec const Assembled_section assembled = assemble_section_geometries_(section_results, req.shape_names); out.status = assembled.status; out.compound = assembled.compound; + out.plane = req.plane; return out; } @@ -255,9 +257,18 @@ std::optional Shp_cross_section::finish_section_result_(Section_result r m_last_section_status = result.status; if (result.status.is_ok() && !result.compound.IsNull()) - display_section_wires_(result.compound); + { + m_last_section_compound = result.compound; + m_last_section_plane = result.plane; + m_have_last_section_plane = true; + display_section_wires_(m_last_section_compound); + } else + { + m_last_section_compound.Nullify(); + m_have_last_section_plane = false; clear_section_wires_(); + } ctx().UpdateCurrentViewer(); return result.status; @@ -293,6 +304,7 @@ std::optional Shp_cross_section::poll() result.generation = job.request.generation; result.status = assembled.status; result.compound = assembled.compound; + result.plane = job.request.plane; finished = std::move(result); m_chunked.reset(); } @@ -328,7 +340,7 @@ bool Shp_cross_section::section_busy() const #endif } -Status Shp_cross_section::wait_section_() +Status Shp_cross_section::wait_section() { while (section_busy()) { @@ -393,6 +405,9 @@ void Shp_cross_section::display_section_wires_(const TopoDS_Shape& compound) { clear_section_wires_(); + if (!m_show_section_outline || compound.IsNull()) + return; + m_preview = new AIS_Shape(compound); m_preview->SetColor(Quantity_NOC_CYAN); m_preview->SetWidth(3.0); @@ -401,6 +416,20 @@ void Shp_cross_section::display_section_wires_(const TopoDS_Shape& compound) ctx().Deactivate(m_preview); } +void Shp_cross_section::set_show_section_outline(bool show) +{ + if (m_show_section_outline == show) + return; + + m_show_section_outline = show; + if (show) + display_section_wires_(m_last_section_compound); + else + clear_section_wires_(); + + ctx().UpdateCurrentViewer(); +} + void Shp_cross_section::clear_section_wires_() { if (!m_preview.IsNull()) @@ -425,6 +454,14 @@ void Shp_cross_section::clear_preview_ais_() { clear_section_wires_(); clear_plane_annotation_(); + m_last_section_compound.Nullify(); + m_have_last_section_plane = false; +} + +const gp_Pln& Shp_cross_section::last_section_plane() const +{ + EZY_ASSERT(m_have_last_section_plane); + return m_last_section_plane; } Status Shp_cross_section::clip_selected() { return clip(get_selected_shps_()); } @@ -444,13 +481,23 @@ Status Shp_cross_section::clip(const std::vector& shapes) std::vector clipped_geoms; clipped_geoms.reserve(plane_ctx.shapes.size()); + std::vector survivors; + survivors.reserve(plane_ctx.shapes.size()); + size_t removed_fully = 0; for (size_t i = 0; i < plane_ctx.shapes.size(); ++i) { Result clipped = clip_solid_to_half_space_(plane_ctx.world_shapes[i], *half); if (!clipped.has_value()) return Status(clipped.status(), plane_ctx.shapes[i]->get_name() + ": " + clipped.message()); + if ((*clipped).IsNull() || !contains_solid_(*clipped)) + { + ++removed_fully; + continue; + } + clipped_geoms.push_back(std::move(*clipped)); + survivors.push_back(plane_ctx.shapes[i]); } std::vector removed; @@ -459,12 +506,12 @@ Status Shp_cross_section::clip(const std::vector& shapes) removed.push_back(capture_shape_rec(*shp)); std::vector added; - added.reserve(plane_ctx.shapes.size()); + added.reserve(survivors.size()); m_shps = plane_ctx.shapes; - for (size_t i = 0; i < plane_ctx.shapes.size(); ++i) + for (size_t i = 0; i < survivors.size(); ++i) { - const Shp_ptr& old_shp = plane_ctx.shapes[i]; + const Shp_ptr& old_shp = survivors[i]; Shp_ptr new_shp = new Shp(ctx(), clipped_geoms[i]); new_shp->set_name(old_shp->get_name()); new_shp->set_frame(frame_world_(*old_shp)); @@ -477,7 +524,18 @@ Status Shp_cross_section::clip(const std::vector& shapes) delete_operation_shps_(); view().push_undo_delta(std::make_unique(std::move(removed), std::move(added))); clear(); - return Status::ok("Clipped " + std::to_string(added.size()) + (added.size() == 1 ? " shape." : " shapes.")); + + std::ostringstream msg; + if (added.empty()) + msg << "Removed " << removed_fully << (removed_fully == 1 ? " fully clipped shape." : " fully clipped shapes."); + else + { + msg << "Clipped " << added.size() << (added.size() == 1 ? " shape." : " shapes."); + if (removed_fully > 0) + msg << " Removed " << removed_fully + << (removed_fully == 1 ? " fully clipped shape." : " fully clipped shapes."); + } + return Status::ok(msg.str()); } void Shp_cross_section::clear() @@ -968,8 +1026,9 @@ Result clip_solid_to_half_space_(const TopoDS_Shape& world_shape, return {Result_status::Topo_error, "Open CASCADE could not clip the solid."}; const TopoDS_Shape& result = common.Shape(); + // Null / non-solid means the solid lies entirely on the discarded side. if (result.IsNull() || !contains_solid_(result)) - return {Result_status::User_error, "Clip removed the entire solid (nothing left on the kept side)."}; + return TopoDS_Shape(); return result; } diff --git a/src/shp_cross_section.h b/src/shp_cross_section.h index 0bfb87f..663b7b1 100644 --- a/src/shp_cross_section.h +++ b/src/shp_cross_section.h @@ -77,12 +77,22 @@ class Shp_cross_section : private Shp_operation_base void set_invert_normal(bool invert); bool get_hide_back_side() const { return m_hide_back_side; } void set_hide_back_side(bool hide) { m_hide_back_side = hide; } - bool has_preview() const { return !m_preview.IsNull(); } + bool get_show_section_outline() const { return m_show_section_outline; } + /// Show/hide cyan section wires without recomputing (keeps last compound for restore). + void set_show_section_outline(bool show); + /// True when a section compound is cached (wires may still be hidden via show outline). + bool has_preview() const { return !m_last_section_compound.IsNull(); } + /// Cached section compound from the last successful preview (null if none). + const TopoDS_Shape& last_section_compound() const { return m_last_section_compound; } + /// Cutting plane used for `last_section_compound` (valid when `has_preview()`). + const gp_Pln& last_section_plane() const; bool section_busy() const; [[nodiscard]] bool selection_stale() const; [[nodiscard]] bool preview_inputs_stale() const; void acknowledge_current_selection(); [[nodiscard]] bool try_get_offset_range_display(double& out_min, double& out_max); + /// Block until the current section job finishes (used by sketch import / tests). + [[nodiscard]] Status wait_section(); private: struct Shared_plane @@ -107,6 +117,7 @@ class Shp_cross_section : private Shp_operation_base std::uint64_t generation{0}; Status status{Result_status::Success}; TopoDS_Shape compound; + gp_Pln plane; }; static std::vector selection_ids_(const std::vector& shapes); @@ -124,17 +135,20 @@ class Shp_cross_section : private Shp_operation_base [[nodiscard]] static Section_result compute_section_result_(Section_request req, std::atomic* cancel); [[nodiscard]] std::optional finish_section_result_(Section_result result); [[nodiscard]] Result build_shared_plane_(const std::vector& shapes); - [[nodiscard]] Status wait_section_(); Cross_section_plane m_plane{Cross_section_plane::XY}; double m_offset_display{0.0}; bool m_invert_normal{false}; bool m_hide_back_side{true}; + bool m_show_section_outline{false}; Cross_section_plane m_acked_plane{Cross_section_plane::XY}; double m_acked_offset_display{0.0}; bool m_acked_invert_normal{false}; bool m_acked_hide_back_side{true}; std::vector m_acked_selection_ids; + TopoDS_Shape m_last_section_compound; + gp_Pln m_last_section_plane; + bool m_have_last_section_plane{false}; AIS_Shape_ptr m_preview; AIS_Shape_ptr m_plane_fill; AIS_Shape_ptr m_plane_lines; diff --git a/src/skt.cpp b/src/skt.cpp index 2e5800d..2248db7 100644 --- a/src/skt.cpp +++ b/src/skt.cpp @@ -143,6 +143,11 @@ void Sketch::add_edge_(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b) { m_edges.add void Sketch::add_linear_edge(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b) { add_edge_(pt_a, pt_b); } +void Sketch::add_arc_circle(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_mid, const gp_Pnt2d& pt_c) +{ + add_arc_circle_(pt_a, pt_mid, pt_c); +} + void Sketch::rebuild_faces() { update_faces_(); } void Sketch::add_edge_(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b, Sketch_op_recorder& rec) diff --git a/src/skt.h b/src/skt.h index 12fb9f3..391f2af 100644 --- a/src/skt.h +++ b/src/skt.h @@ -163,6 +163,8 @@ class Sketch /// Add a linear edge between plane points (scripting / import). void add_linear_edge(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b); + /// Add a circular arc through plane points start, mid, end (scripting / import). + void add_arc_circle(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_mid, const gp_Pnt2d& pt_c); /// Rebuild closed-face topology after bulk edge import. void rebuild_faces(); diff --git a/tests/shp_tests.cpp b/tests/shp_tests.cpp index 17f3765..68ce028 100644 --- a/tests/shp_tests.cpp +++ b/tests/shp_tests.cpp @@ -378,6 +378,16 @@ TEST_F(Shp_test, Cross_section_selection_stale_after_selection_change) EXPECT_FALSE(boxes[1]->ClipPlanes().IsNull()); EXPECT_EQ(boxes[1]->ClipPlanes()->Size(), 1); + // Outline defaults off; toggle shows/hides cyan AIS without a full recompute (not a preview-input). + EXPECT_FALSE(view().shp_cross_section().get_show_section_outline()); + EXPECT_TRUE(view().shp_cross_section().has_preview()); + view().shp_cross_section().set_show_section_outline(true); + EXPECT_TRUE(view().shp_cross_section().has_preview()); + EXPECT_FALSE(view().shp_cross_section().preview_inputs_stale()); + view().shp_cross_section().set_show_section_outline(false); + EXPECT_TRUE(view().shp_cross_section().has_preview()); + EXPECT_FALSE(view().shp_cross_section().preview_inputs_stale()); + const Shape_id clipped_id = boxes[1]->get_id(); ASSERT_TRUE(view().shp_cross_section().clip_selected().is_ok()); EXPECT_FALSE(view().shp_cross_section().has_preview()); @@ -386,6 +396,44 @@ TEST_F(Shp_test, Cross_section_selection_stale_after_selection_change) EXPECT_TRUE(contains_solid_like(view().get_shapes().back()->Shape())); } +TEST_F(Shp_test, Cross_section_clip_removes_fully_discarded_solids) +{ + // Short box lies entirely below the shared midplane; tall box is cut (kept half survives). + view().add_box(0, 0, 0, 4, 4, 4); + view().add_box(0, 0, 0, 4, 4, 10); + const std::vector boxes(view().get_shapes().begin(), view().get_shapes().end()); + ASSERT_EQ(boxes.size(), 2u); + const Shape_id short_id = boxes[0]->get_id(); + const Shape_id tall_id = boxes[1]->get_id(); + + select_shapes(view(), boxes); + gui().set_mode(Mode::Shape_cross_section); + ASSERT_TRUE(view().shp_cross_section().preview_selected().is_ok()); + + const Status clip_status = view().shp_cross_section().clip_selected(); + ASSERT_TRUE(clip_status.is_ok()) << clip_status.message(); + EXPECT_TRUE(view().find_shape_by_id(short_id).IsNull()); + EXPECT_TRUE(view().find_shape_by_id(tall_id).IsNull()); + ASSERT_EQ(view().get_shapes().size(), 1u); + EXPECT_TRUE(contains_solid_like(view().get_shapes().front()->Shape())); +} + +TEST_F(Shp_test, Cross_section_sketch_imports_box_midplane_lines) +{ + view().add_box(0, 0, 0, 10, 10, 10); + select_shapes(view(), {view().get_shapes().back()}); + gui().set_mode(Mode::Shape_cross_section); + ASSERT_TRUE(view().shp_cross_section().preview_selected().is_ok()); + ASSERT_TRUE(view().shp_cross_section().has_preview()); + + const size_t sketches_before = view().get_sketches().size(); + const Status status = view().create_sketch_from_cross_section(); + ASSERT_TRUE(status.is_ok()) << status.message(); + EXPECT_EQ(view().get_sketches().size(), sketches_before + 1u); + EXPECT_EQ(gui().get_mode(), Mode::Sketch_inspection_mode); + EXPECT_GE(Sketch_access::get_linear_edge_count(view().curr_sketch()), 4u); +} + // --------------------------------------------------------------------------- // shp_info