diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ee61f4d..887e9e17 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,11 +14,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- **Add bone Holes**: switching **Holes** to **None** after the waist is set commits the outline. Clicks and finalize no longer run hole handlers when holes are off (those handlers reject `None` and left the tool stuck).
+- **Sketch overlapping edges**: Drawing the same line or circle twice, or adding a collinear / same-circle overlap, no longer stacks a second copy. The sketch splits at overlap ends and keeps each atomic piece once. Undo records only the new atomic pieces (for arcs, each leftover start/bulge/end, not the input triple), so extending a line past an existing collinear edge no longer deletes the original on undo, and a circle over an existing same-circle cap reverses fully. A no-op redraw of an already-covered span does not push undo. Fixes an access violation in `TKGeomBase` when a new circle coincided with an existing arc (for example a bone end-cap).
- **Wasm configure/link**: Detect Emscripten via `EMSCRIPTEN` / `CMAKE_SYSTEM_NAME` (modern emsdk reports Clang). Link the FreeType package from the OCCT wasm install so `wasm-ld` finds `libfreetype.a` instead of bare `-lfreetype`.
### Added
-- **Add bone** sketch tool (Shift+U): click the two circle centers, then radius at the second center, radius at the first center, and waist width (like other sketch tools). Waist is the minimum neck thickness (offset from mid when end radii differ). Live length dim only for center-to-center; radii / holes / waist use the geometry preview (Tab still enters exact values). Options: **Add center nodes** (default on) and **Holes** (**None** / **One radius** / **Two radii**). Waist cutters are tangent to both end circles. Commits the trimmed outline (outer end arcs and inner waist arcs), optional hole circles, and optional permanent **Bone A** / **Bone B** center nodes, then exits to **Sketch inspection** (does not stay in Add bone for another). Remappable as **`mode.add_bone`**.
+- **Status toast kinds**: the bottom-right toast is colored by kind (**success**, **info**, **constraint**, **warning**, **error**) instead of always red. Failed `Status` toasts follow `Result_status` (`User_error` is amber constraint). Scripts: `ezy.msg(text [, kind])`. **Add bone** uses info for the next click, amber for rejected input, green on commit, and red if create fails.
+
+- **Curve segment angle**: **Settings -> View presentation** slider (**2** to **20** degrees, default **6**) sets Open CASCADE AIS curve tessellation. Smaller draws smoother arcs and fillets (display only). Persisted as **`gui.curve_deviation_angle_deg`**.
+
+- **Add bone** sketch tool (Shift+U): click the two circle centers, then radius at the second center, radius at the first center, and waist width (like other sketch tools). Waist is the minimum neck thickness (offset from mid when end radii differ). Live length dim and a tmp segment for center-to-center, then for each radius, waist, and hole so Tab can enter exact values (measure segments are not committed). While placing radius 1, snap targets sit on the second circle at the bone-axis-parallel tangents (match radius 2). Options: **Add center nodes**, **Add radius nodes**, and **Add total length nodes** (all default on) plus **Holes** (**None** / **One radius** / **Two radii**). Radius nodes are **Bone A+** / **A-** / **B+** / **B-**; total-length nodes are **Bone A tip** / **Bone B tip**. Waist cutters are tangent to both end circles. Commits the trimmed outline (outer end arcs and inner waist arcs), optional hole circles, and optional permanent **Bone A** / **Bone B** center nodes, then exits to **Sketch inspection** (does not stay in Add bone for another). Remappable as **`mode.add_bone`**.
- **Shape local frame**: Shape List right-click on a solid toggles **Show axes** / **Show plane** / **Show up**, **Reset frame to bbox**, **Set from planar/cylindrical face**, and **Flip up** / **Flip axis (Z)**. Frame display flags persist in `.ezy` as `frameDisplay`. Face picks use `Mode::Shape_set_frame` (no toolbar hotkey).
diff --git a/docs/ezycad_code_style.md b/docs/ezycad_code_style.md
index 73da9aed..b83e8c6b 100644
--- a/docs/ezycad_code_style.md
+++ b/docs/ezycad_code_style.md
@@ -118,7 +118,7 @@ if (!content.empty())
// apply / succeed
}
else
- show_message("Failed...");
+ show_message("Failed...", Status_msg::Error);
```
rather than leading with the failure `if` and putting the main work in `else`, unless an early-return failure keeps the success path flatter.
diff --git a/docs/scripting.md b/docs/scripting.md
index ae36c437..bbaeb1cb 100644
--- a/docs/scripting.md
+++ b/docs/scripting.md
@@ -44,15 +44,17 @@ ezy.Shp
### `ezy`
-| Method | Purpose |
-| ------------------------------- | ------------------------------------------------ |
-| `ezy.log(msg)` | Print to the console and the main **Log** pane |
-| `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) |
-| `ezy.occt_view_settings_json()` | Settings JSON string |
-| `ezy.help()` | Print binding summary |
+| Method | Purpose |
+| ------------------------------- | ----------------------------------------------- |
+| `ezy.log(msg)` | Print to the console and the main **Log** pane |
+| `ezy.msg(text [, kind])` | Status toast and Log; optional kind (see below) |
+| `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) |
+| `ezy.occt_view_settings_json()` | Settings JSON string |
+| `ezy.help()` | Print binding summary |
+
+Optional `kind` for `ezy.msg` is `success`, `info` (default), `constraint`, `warning`, or `error`. Unknown names use info. Toast color follows the kind.
### `ezy.view`
diff --git a/docs/usage-occt-view.md b/docs/usage-occt-view.md
index 9860c9af..0d64b47c 100644
--- a/docs/usage-occt-view.md
+++ b/docs/usage-occt-view.md
@@ -15,9 +15,13 @@ EzyCad's 3D viewport uses **Open CASCADE Technology (OCCT)** for displaying soli
The **coordinate axes** (small triedron) and optional **view cube** are part of the OCCT-based viewer and help you stay oriented.
+## Curve display
+
+Sketch arcs, circles, and rounded 3D edges are true curves. The viewer draws them as polylines. **Settings -> View presentation -> Curve segment angle** (`gui.curve_deviation_angle_deg`, **2** to **20** degrees, default **6**) is the maximum angle between consecutive segments. Smaller values look smoother and use more segments. This does not change stored geometry or mesh export deflection.
+
## Settings file
-Background gradient, grid lines, and related **3D appearance** are stored under **`occt_view`** in your settings JSON. **View rotation step**, **zoom scroll scale**, **default 2D view width/height** (New Project framing), and **element hover color** are stored under **`gui`**. Full lists of keys: **[usage-settings.md — Settings file reference](usage-settings.md#settings-file-reference)**.
+Background gradient, grid lines, and related **3D appearance** are stored under **`occt_view`** in your settings JSON. **View rotation step**, **zoom scroll scale**, **default 2D view width/height** (New Project framing), **element hover color**, **shape selection color**, and **curve segment angle** are stored under **`gui`**. Full lists of keys: **[usage-settings.md — Settings file reference](usage-settings.md#settings-file-reference)**.
If you use scripts, **`ezy.occt_view_settings_json()`** returns a small JSON snapshot of some of these values — see **[scripting.md](scripting.md)**.
diff --git a/docs/usage-settings.md b/docs/usage-settings.md
index 0f903ea3..5ca799e3 100644
--- a/docs/usage-settings.md
+++ b/docs/usage-settings.md
@@ -48,7 +48,7 @@ Between those, the pane has collapsible sections. Expand a section to see its co
3. **UI** — **Dark mode** checkbox. ImGui layout sliders apply to the **active theme** (switch **Dark mode** to edit the other theme). Grouped under **Transparency**, **Rounding**, **Borders**, **Padding**, and **Spacing**: **Window transparency** (**0.1** to **1.0**); corner rounding sliders **0** to **16** for **Windows, frames, popups**, **Scrollbars and sliders**, and **Tabs**; **Window border**, **Frame border** (**0** to **2**); **Window padding** X/Y, **Frame padding** X/Y, and **Item spacing** X/Y (**0** to **24**).
-4. **View presentation** — **Background color 1** and **Background color 2** (float RGB fields and swatches). **Gradient blend** — combo: **Horizontal**, **Vertical**, **Diagonal 1**, **Diagonal 2**, **Corner 1** … **Corner 4**. **Element hover color** — highlight for rows hovered in the **Shape List** or **Sketch List** (**Dimensions** table); stored as **`gui.elm_list_hover_color`**. **Shape selection color** — selected 3D shape highlight in the viewer (RGBA; default purple **0.75**, **0.07**, **0.85**, **1**); stored as **`gui.shape_selection_color`**.
+4. **View presentation** — **Background color 1** and **Background color 2** (float RGB fields and swatches). **Gradient blend** — combo: **Horizontal**, **Vertical**, **Diagonal 1**, **Diagonal 2**, **Corner 1** … **Corner 4**. **Element hover color** — highlight for rows hovered in the **Shape List** or **Sketch List** (**Dimensions** table); stored as **`gui.elm_list_hover_color`**. **Shape selection color** — selected 3D shape highlight in the viewer (RGBA; default purple **0.75**, **0.07**, **0.85**, **1**); stored as **`gui.shape_selection_color`**. **Curve segment angle** — Open CASCADE display tessellation for curves (**2** to **20** degrees; default **6**; smaller is smoother); stored as **`gui.curve_deviation_angle_deg`**. See [usage-occt-view.md](usage-occt-view.md#curve-display).
5. **3D view grid** — **Show grid** (checkbox; grid is drawn on the **active sketch** plane, behind coplanar geometry). **Fine grid lines** and **Major grid lines** (dense lines vs every-tenth emphasis). **Grid step**, **Grid padding** (margin around sketch content when sizing the grid), and **Grid display Z offset** in the Settings pane use the **same length scale as sketch length dimensions** (**File -> Project units**; Settings shows `in` or `mm`). Saved JSON (`occt_view`) stores values in model units (`grid_step`, `grid_padding`).
@@ -118,7 +118,7 @@ Sketch-related preferences are edited in the **Options** panel while you use a s
- **Extrude sketch face**: under **Extrude**, **Both sides** (default on), **Twist**, and **Material** for the new solid (same document preset as **Normal** mode Options **Material**). With **Twist** on, the first drag sets height, then a second drag sets twist angle about the face center; Tab / Shift+Tab enter height / angle. Fast drag preview for dense faces is configured in **Settings -> Sketch -> Appearance** (**Extrude fast preview**); with **Twist**, face copies rotate as well as translate. Other modes that still show **Material** in Options use that same preset when relevant (for example **Sketch from planar face**).
- **Add edge** / **Add node** (and similar): a **Shortcuts** line documents TAB / Shift+TAB typing behavior.
- **Add line edge**: under **Options**, **Add midpoint nodes** ([usage-sketch.md#line-edge-option-add-midpoint-nodes](usage-sketch.md#line-edge-option-add-midpoint-nodes)) and **Place from center** ([usage-sketch.md#line-edge-option-place-from-center](usage-sketch.md#line-edge-option-place-from-center)); each has a **?** link. See also [Line edge Options](usage-sketch.md#line-edge-options). The midpoint setting is also in **Settings -> Sketch -> Nodes** (persisted).
-- **Add bone**: under **Options**, **Add center nodes** (default on; permanent **Bone A** / **Bone B**) and **Holes** (**None**, **One radius**, **Two radii**). Persisted as **`gui.bone_add_center_nodes`** and **`gui.bone_holes`**. See [Bone Creation Tool](usage-sketch.md#bone-creation-tool).
+- **Add bone**: under **Options**, **Add center nodes**, **Add radius nodes**, and **Add total length nodes** (all default on) and **Holes** (**None**, **One radius**, **Two radii**). Persisted as **`gui.bone_add_center_nodes`**, **`gui.bone_add_radius_nodes`**, **`gui.bone_add_total_length_nodes`**, and **`gui.bone_holes`**. See [Bone Creation Tool](usage-sketch.md#bone-creation-tool).
- **Sketch operation** (mirror / revolve axis): mirror, revolve, angle, and clear-axis actions (see [usage-sketch.md](usage-sketch.md#operation-axis-tool)).
Global length-dimension style (line width, arrows, color, text) is in **Settings -> Sketch -> Dimensions**. Per-dimension visibility, name, and offset remain in **Sketch List -> Dimensions** (saved in the project `.ezy` file).
@@ -235,6 +235,8 @@ If saved layout text has no `[Docking]` section (older installs), a default dock
| `show_sketch_dimensions` | boolean | When false, hides length dimensions on all sketches. |
| `permanent_node_anno_scale` | number | Scale for permanent **+** markers: the sketch **Origin** and user-placed Add node points ([Sketch origin](usage-sketch.md#sketch-origin); **0.25** to **3.0**; default **1.0**). |
| `bone_add_center_nodes` | boolean | Add-bone Options: commit permanent **Bone A** / **Bone B** (default **true**). |
+| `bone_add_radius_nodes` | boolean | Add-bone Options: commit permanent **Bone A+** / **A-** / **B+** / **B-** axis-tangent nodes (default **true**). |
+| `bone_add_total_length_nodes` | boolean | Add-bone Options: commit permanent **Bone A tip** / **Bone B tip** outer-end nodes (default **true**). |
| `bone_holes` | integer | Add-bone Options holes mode: **0** None, **1** One radius, **2** Two radii (default **0**). |
| `origin_marker_color` | array of 3 numbers | RGB color for the **active** sketch's Origin marker (+ with circle; **0** to **1** per channel; default cyan **0.0**, **0.75**, **1.0**). |
| `sketch_edge_color` | array of 4 numbers | Current-sketch edge RGBA (**0** to **1**; alpha = opacity; default green **0**, **1**, **0**, **1**). |
@@ -258,6 +260,7 @@ If saved layout text has no `[Docking]` section (older installs), a default dock
| `underlay_highlight_color` | array of 3 numbers | Default underlay tint (float RGB **0** to **1** per channel; default **0.64**, **0.56**, **0.31**). |
| `elm_list_hover_color` | array of 4 numbers | RGBA highlight for rows hovered in the **Shape List** or **Sketch List** dimensions table (float **0** to **1** per channel; default purple **0.40**, **0.10**, **0.47**, **1**). |
| `shape_selection_color` | array of 4 numbers | Selected 3D shape highlight RGBA (AIS SelectionStyle; float **0** to **1**; alpha = opacity; default purple **0.75**, **0.07**, **0.85**, **1**). |
+| `curve_deviation_angle_deg` | number | Max AIS curve segment angle in degrees (**2** to **20**; default **6**). Smaller is smoother on-screen (sketch arcs, round solids). Display only; STEP/STL export is unchanged. See [usage-occt-view.md](usage-occt-view.md#curve-display). |
| `sketch_shape_faint_style` | integer | How 3D shapes appear in sketch mode when enabled: **0** Off (hide), **1** Ghost (default), **2** Wire. See [usage-sketch.md](usage-sketch.md#sketching-2d). |
| `sketch_shape_faint_opacity` | number | Shape Faint Strength as opacity (**0.05** to **0.85**; default **0.14**; Settings shows **5%**–**85%**). Ghost and Wire. OCCT transparency is **1 - opacity**. |
| `sketch_shape_faint_enabled` | boolean | Master switch for faint solids in all sketch tools (default **true**). Also **Options -> Sketch options -> Faint shapes**. When false, solids are hidden in sketch modes. |
@@ -304,7 +307,7 @@ Each **`settings_headers`** object may contain (default: only nested Sketch **Ap
| `startup` | boolean | **Startup project** section expanded. |
| `hotkeys` | boolean | **Keyboard shortcuts** section expanded. |
-Scripting API **`ezy.occt_view_settings_json()`** returns a JSON string with **`occt_view`** plus selected **`gui`** keys (including dimension and snap keys above, **`gui.permanent_node_anno_scale`**, **`gui.inspection_orthographic`**, **`gui.view_roll_step_deg`**, **`gui.view_zoom_scroll_scale`**, **`gui.default_project_unit`**, **`gui.default_2d_view_width`**, **`gui.default_2d_view_height`** when saved). See [scripting.md](scripting.md).
+Scripting API **`ezy.occt_view_settings_json()`** returns a JSON string with **`occt_view`** plus selected **`gui`** keys (including dimension and snap keys above, **`gui.permanent_node_anno_scale`**, **`gui.inspection_orthographic`**, **`gui.view_roll_step_deg`**, **`gui.view_zoom_scroll_scale`**, **`gui.curve_deviation_angle_deg`**, **`gui.default_project_unit`**, **`gui.default_2d_view_width`**, **`gui.default_2d_view_height`** when saved). See [scripting.md](scripting.md).
---
diff --git a/docs/usage-sketch.md b/docs/usage-sketch.md
index 544b22ac..ee05299d 100644
--- a/docs/usage-sketch.md
+++ b/docs/usage-sketch.md
@@ -82,6 +82,7 @@ While you draw or place points in sketch mode, EzyCad helps you align to existin
| **Sketch origin** | Each sketch's built-in **Origin** (see [Sketch origin](#sketch-origin)) is a snap target when its marker is shown. |
| **Mid-point snap (Add node)** | A click near a **straight** edge (not at its ends) snaps onto the segment and **splits** it at commit time (see [Add node tool](#add-node-tool)). Separate from vertex lock. |
| **Automatic splitting on edge intersections** | When you add a new straight (linear) edge using the Line Edge tool or Multi-Line Edge tool, if it crosses or touches the interior of any existing straight edge, the existing edge is automatically split at the intersection point. The new edge is also subdivided into atomic segments where needed. The same splitting occurs when an endpoint of the new edge snaps to the midpoint of an existing edge. When you add an **arc segment**, existing straight and arc edges are split at interior crossings, and the new arc is subdivided at intersection points on its interior. This produces correct T-junctions, crossings, and cleanly divided faces from a single sketch. |
+| **Overlapping / duplicate edges** | If a new straight edge or arc lies on the **same line or same circle** as an existing edge and they overlap along a range (including drawing the same edge twice), the sketch keeps each atomic piece **once**. Existing edges split at the overlap ends; the overlapping copy is not added. |
| **Other visible sketches** | Nodes from **other visible sketches** are projected onto the current sketch plane and act as snap targets (same distance rules). Each visible sketch contributes its **Origin** when that sketch's marker is shown, plus any user-placed nodes. Useful for multi-sketch layouts and tools such as **polar duplicate** that pick sketch points. |
| **Operational axis mode** | While an operational axis is **defined** and **Operational axis** mode is active (mirror/revolve phase), sketch snap and permanent **+** node markers are **suppressed** so edge and face selection stays clear. Normal snapping applies again after **Clear axis** or when you leave the tool. Axis placement (before the axis exists) still uses snap. |
@@ -215,7 +216,7 @@ When checked, the **first click** sets the **midpoint** of the new edge (not an
- Lines can be used as construction geometry or as part of your final design
- The line tool works in any sketch plane
- Multiple line edges can be created in sequence by right-clicking after each line
-- **New straight edges automatically split existing straight edges** at interior intersection points (or when snapping to an existing midpoint, if midpoints were created). This is how you divide a closed profile into multiple faces (for separate extrusions, etc.) — just draw the crossing or connecting line; no extra "split" step is required. The same logic applies to multi-line sequences. Midpoint creation for new edges is controlled by **Add midpoint nodes** in the Line Edge Options panel or **Settings > Sketch > Add midpoints to new linear edges** (default off).
+- **New straight edges automatically split existing straight edges** at interior intersection points (or when snapping to an existing midpoint, if midpoints were created). This is how you divide a closed profile into multiple faces (for separate extrusions, etc.) — just draw the crossing or connecting line; no extra "split" step is required. The same logic applies to multi-line sequences. Collinear overlaps (including drawing the same segment twice) merge to a single copy of each piece. Midpoint creation for new edges is controlled by **Add midpoint nodes** in the Line Edge Options panel or **Settings > Sketch > Add midpoints to new linear edges** (default off).
- **Place from center** is useful for symmetric segments (e.g. a divider through the middle of a rectangle) without measuring half-lengths manually.
## Multi-Line Edge Tool
@@ -389,7 +390,7 @@ The arc segment tool creates a single circular arc edge from three clicks: **sta
| **Rubber-band preview** | After the first click, a straight preview line follows the cursor; after the second click, an arc preview follows the cursor until the third click |
| **Automatic finalization** | The arc is created and added to the sketch after the third click |
| **Circular arc** | One smooth circular arc through the three defining points |
-| **Intersection splitting** | Splits existing straight and arc edges at interior crossings; the new arc is subdivided where other edges cross its interior |
+| **Intersection splitting** | Splits at interior crossings; same-circle overlap is merged (no second copy) |
| **Optional arc midpoint node** | When **Add midpoint nodes** is on in the Options panel, a snap node is placed at the arc's geometric midpoint (see [Arc segment Options](#arc-segment-options)) |
| **Snap support** | Snaps to existing nodes and geometry |
| **Unique points** | All three clicks must be different (cannot be coincident) |
@@ -432,6 +433,7 @@ While the **Arc Segment** tool is active, the Options panel shows the same **Add
- The three clicks define a unique circular arc; the second click controls bulge direction (which side of the chord the arc curves toward).
- Use snap to connect arc endpoints precisely to existing geometry.
- Adding an arc across an existing straight edge splits that edge at the crossing; adding a line across an existing arc splits the arc at the crossing.
+- Adding an arc or circle on top of an existing arc of the **same** circle does not create a second copy of the overlapping part (the rest of the new arc is still added).
- Arc segments combine with straight edges to form closed shapes (slots, circles, custom profiles).
**Known limitations:**
@@ -713,7 +715,7 @@ The slot tool allows you to create an oblong or oval-shaped slot with rounded en

-The bone tool builds a connecting-rod outline: the outer arcs of the two end circles and the inward waist arcs where the cutters touch those circles. The result is one closed face you can extrude. Permanent nodes **Bone A** and **Bone B** stay at the two centers for later holes, dimensions, or an operation axis.
+The bone tool builds a connecting-rod outline: the outer arcs of the two end circles and the inward waist arcs where the cutters touch those circles. The result is one closed face you can extrude. Options control permanent nodes: **Bone A** / **Bone B** at the centers, **Bone A+** / **A-** / **B+** / **B-** at the axis-tangent points on each end circle, and **Bone A tip** / **Bone B tip** at the outer ends (overall length).
**How to use:**
@@ -721,20 +723,22 @@ The bone tool builds a connecting-rod outline: the outer arcs of the two end cir
2. Click the first circle center (normal sketch snap).
3. Click the second circle center. Tab sets the center-to-center distance; Shift+Tab sets the bone-axis angle.
4. Click to set **radius 2** (distance from the second center, where the cursor just finished). Tab enters an exact radius.
-5. Click to set **radius 1** (distance from the first center). Tab enters an exact radius.
-6. Click a point to set **waist width** (minimum remaining thickness between the waist arcs, perpendicular to the center line). When the end radii differ, the neck is offset toward the smaller end, not at the center midpoint. Tab enters an exact waist. If **Holes** is **None**, the tool commits after this click.
+5. Click to set **radius 1** (distance from the first center). After radius 2 is set, snap targets appear on the **second** circle where a line parallel to the bone axis is tangent (so you can match the two radii in one snap). Tab enters an exact radius.
+6. Click a point to set **waist width** (minimum remaining thickness between the waist arcs, perpendicular to the center line). When the end radii differ, the neck is offset toward the smaller end, not at the center midpoint. Tab enters an exact waist. If **Holes** is **None**, the tool commits after this click. Switching **Holes** to **None** after the waist is set also commits (outline only).
7. If **Holes** is **One radius**, click once from the first center to set both hole radii. If **Two radii**, click hole radius at end A, then at end B. Each hole must be smaller than that end's outer radius.
After a successful commit, the mode switches to **Sketch inspection** (ready to select the face or Extrude). Unlike circle or slot, Add bone does not stay active for another bone.
-A live length dimension is shown only while placing the two centers. End radii, holes, and waist use the geometry preview (circle / bone outline); Tab still enters exact values.
+A live length dimension and rubber-band segment are shown for the two centers, then for each radius, the waist, and hole radii so Tab can enter an exact value. Those measure segments are temporary (they are not committed as sketch edges).
**Options** (while Add bone is active):
-| Option | Default | Meaning |
-| -------------------- | ------- | ------------------------------------------------------------------------------------------- |
-| **Add center nodes** | On | Commit permanent **Bone A** / **Bone B** nodes at the two centers |
-| **Holes** | None | **None** (outline only), **One radius** (both holes match), or **Two radii** (set each end) |
+| Option | Default | Meaning |
+| -------------------------- | ------- | ------------------------------------------------------------------------------------------- |
+| **Add center nodes** | On | Commit permanent **Bone A** / **Bone B** nodes at the two centers |
+| **Add radius nodes** | On | Commit permanent **Bone A+** / **A-** / **B+** / **B-** at the axis-tangent points |
+| **Add total length nodes** | On | Commit permanent **Bone A tip** / **Bone B tip** at the outer ends (overall bone length) |
+| **Holes** | None | **None** (outline only), **One radius** (both holes match), or **Two radii** (set each end) |
Waist cutters are tangent to both end circles (cut radius is solved from the waist). Geometry fails when the two centers coincide, one circle sits inside the other, or the waist cannot be realized.
@@ -743,10 +747,11 @@ The sketch keeps only that trimmed outline (four arcs). Extrude it with the norm
**Tips:**
- Snap the two centers to existing nodes, or to the sketch origin.
+- To match both end radii, snap radius 1 to the axis-tangent mark on the second circle.
- After Add with no holes, use **Add circle** snapped to **Bone A** / **Bone B** if you want holes later.
- For waist, click on either side of the bone axis; the distance from the axis to that point is half the waist.
-- If a hole radius is greater than or equal to that end's outer radius, a toast explains the hole must be smaller; the click is ignored.
-- Preferences **Add center nodes** and **Holes** are saved as `gui.bone_add_center_nodes` and `gui.bone_holes`.
+- Status toasts follow the click sequence (info for the next pick), turn amber when a click is rejected (hole too large, waist that cannot be formed, zero radius), green when the bone is added, and red only if commit fails.
+- Preferences **Add center nodes**, **Add radius nodes**, **Add total length nodes**, and **Holes** are saved as `gui.bone_add_center_nodes`, `gui.bone_add_radius_nodes`, `gui.bone_add_total_length_nodes`, and `gui.bone_holes`.
## Operation Axis Tool
diff --git a/docs/usage.md b/docs/usage.md
index c373b4cf..bf0e3b40 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 (including status toast messages)
+ - Monitor system status (including status toast messages: green success, light info, amber constraint, yellow warning, red error)
### Help menu
diff --git a/res/ezycad_settings.json b/res/ezycad_settings.json
index 804b799c..bb7b57ae 100644
--- a/res/ezycad_settings.json
+++ b/res/ezycad_settings.json
@@ -4,6 +4,8 @@
"add_mid_pt_rect_edges": true,
"add_mid_pt_slot_edges": true,
"bone_add_center_nodes": true,
+ "bone_add_radius_nodes": true,
+ "bone_add_total_length_nodes": true,
"bone_holes": 0,
"annotate_all_coaxial_nodes": true,
"dark_mode": true,
@@ -31,6 +33,7 @@
0.8468899726867676,
1.0
],
+ "curve_deviation_angle_deg": 6.0,
"sketch_shape_faint_style": 1,
"sketch_shape_faint_opacity": 0.14,
"sketch_shape_faint_enabled": true,
diff --git a/scripts/ezycad/api.py b/scripts/ezycad/api.py
index 155d8e8a..f9d7bfe0 100644
--- a/scripts/ezycad/api.py
+++ b/scripts/ezycad/api.py
@@ -250,8 +250,8 @@ def __init__(self, session: Session) -> None:
def log(self, msg: Any) -> Optional[str]:
return self._call_void("ezy.log", msg)
- def msg(self, text: str) -> None:
- self._call_void("ezy.msg", text)
+ def msg(self, text: str, kind: str = "info") -> None:
+ self._call_void("ezy.msg", text, kind)
def get_mode(self) -> str:
return str(self._call("ezy.get_mode"))
diff --git a/src/doc/gui.md b/src/doc/gui.md
index d7d3d1ca..9111d65b 100644
--- a/src/doc/gui.md
+++ b/src/doc/gui.md
@@ -225,7 +225,7 @@ Tests use `sketch_left_click` to simulate sketch LMB without ImGui mouse positio
| Each sketch tool mode | Matching `options_sketch_*_mode_` |
| `Sketch_operation_axis` | Mirror / Revolve / Clear axis |
| `Sketch_face_extrude` | **Extrude** (Both sides, Twist, material) above Sketch options; help mentions Settings fast preview |
-| `Sketch_add_bone` | Center nodes, holes; when `DEV_MODE` in `config.h`, **Debug vis** construction overlays (not persisted) |
+| `Sketch_add_bone` | Center / radius / total-length nodes, holes; None after waist commits; `DEV_MODE` **Debug vis** |
### Options panel layout (sketch tools)
@@ -254,7 +254,7 @@ Shared sketch controls (snap, faint shapes) live in `options_sketch_shared_contr
| 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.
+`GUI::show_message` drives the transient status toast (`message_status_window_`) and also appends via `log_message`. The toast color comes from `Status_msg` (`Success`, `Info`, `Constraint`, `Warning`, `Error`; default `Info`). `show_status` maps `Result_status` (`User_error` -> Constraint, `Error` / `Topo_error` -> Error, ok -> Info). `show_error_dialog` logs `title: message` once and toasts the title as Error.
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).
@@ -273,7 +273,7 @@ Sketch List expand **Faces**: each face row supports **`E`** and right-click **E
| `load_occt_view_settings_` | Called from `GUI::init` |
| `occt_view_settings_json()` | Scripting API for settings blob |
-Sketch edge/face display colors live under `gui.sketch_edge_*` / `gui.sketch_face_*` and are applied live via `Sketch_annotation_refresh::edge_face_style`. Sketch-mode shape ghost/wire uses `gui.sketch_shape_faint_style` / `gui.sketch_shape_faint_opacity` via `Occt_view::sync_sketch_shape_faint_style`. 3D shape selection highlight uses `gui.shape_selection_color` applied through `Occt_view::apply_shape_selection_style` (`AIS_InteractiveContext::SelectionStyle`). Settings collapsing-header open state is stored in `gui.settings_headers` (Sketch nests **Appearance**, **Dimensions**, **Nodes**, **Snap**, **Underlay**; also **Keyboard shortcuts** / `hotkeys`). Remappable chords: `gui.hotkeys` object via `Gui_hotkeys::to_json` / `merge_from_json`.
+Sketch edge/face display colors live under `gui.sketch_edge_*` / `gui.sketch_face_*` and are applied live via `Sketch_annotation_refresh::edge_face_style`. Sketch-mode shape ghost/wire uses `gui.sketch_shape_faint_style` / `gui.sketch_shape_faint_opacity` via `Occt_view::sync_sketch_shape_faint_style`. 3D shape selection highlight uses `gui.shape_selection_color` applied through `Occt_view::apply_shape_selection_style` (`AIS_InteractiveContext::SelectionStyle`). AIS curve tessellation uses `gui.curve_deviation_angle_deg` via `Occt_view::apply_curve_deviation` (`Prs3d_Drawer::SetDeviationAngle`). Settings collapsing-header open state is stored in `gui.settings_headers` (Sketch nests **Appearance**, **Dimensions**, **Nodes**, **Snap**, **Underlay**; also **Keyboard shortcuts** / `hotkeys`). Remappable chords: `gui.hotkeys` object via `Gui_hotkeys::to_json` / `merge_from_json`.
User-visible key tables: [`docs/usage-settings.md`](../../docs/usage-settings.md). When adding a Settings control, follow [agents/conventions/user-docs-sync.md](../../agents/conventions/user-docs-sync.md).
diff --git a/src/doc/script.md b/src/doc/script.md
index 4d41298b..0e1b872f 100644
--- a/src/doc/script.md
+++ b/src/doc/script.md
@@ -109,13 +109,15 @@ 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` (toast + Log) |
+| `ezy.msg(text [, kind])` | `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()` |
| `ezy.occt_view_settings_json()` | `GUI::occt_view_settings_json()` |
| `ezy.help()` / global `help()` | Prints binding summary to console |
+Optional `kind` for `ezy.msg` is `success`, `info` (default), `constraint`, `warning`, or `error` (`parse_status_msg`).
+
Lua overrides global `print` to call `ezy.log`. Python bootstrap assigns `builtins.print` similarly.
### `ezy.view`
diff --git a/src/doc/sketch.md b/src/doc/sketch.md
index 0ade91e8..f702871f 100644
--- a/src/doc/sketch.md
+++ b/src/doc/sketch.md
@@ -61,7 +61,7 @@ Snap distance, guide mode (*Traditional* / *Fullscreen* / *Both* / *None*), and
```
Sketch (coordinator: skt.cpp, skt.h)
|
- +-- Sketch_nodes vertices, snapping, outside-sketch snap points
+ +-- Sketch_nodes vertices, snapping, outside-sketch and tool-session snap points
+-- Sketch_node_marks permanent "+" markers for user-placed nodes; origin uses cyan + with circle
+-- Sketch_edges persistent edge list; add, split, remove, pick
+-- Sketch_topo planar graph -> closed faces, edge splitting
@@ -201,12 +201,12 @@ Prefer these visitors in JSON/delta/topo code over iterating `std::list`; add linear/arc edges; split at intersections; pick and selection |
| `skt_topo.h` | Planar face extraction from edge graph; automatic splitting at interior nodes and arc crossings |
-| `skt_nodes.h` | Node storage, snap, snap guides, outside-sketch snap points |
+| `skt_nodes.h` | Node storage, snap, snap guides, outside-sketch and tool-session snap points |
| `skt_node_marks.h` | AIS "+" markers for permanent nodes only |
| `skt_dims.h` | Length dimensions between node pairs; Tab/Shift+Tab input; dimension-tool pick state |
| `skt_tools.h` | Mode-specific click/move/finalize/cancel; shared helpers in `skt_tools.inl` |
| `skt_bone.h` | Bone outline geom (`compute_bone_geom`, `make_bone_wire`); `DEV_MODE` construction overlay (`make_bone_debug_shape`) |
-| `skt_tools_bone.cpp` | Add-bone tool (`Sketch_tools` members; Options: center nodes / holes; successful commit exits to `Sketch_inspection_mode`) |
+| `skt_tools_bone.cpp` | Add-bone tool; tmp length segs; session snap; Holes None after waist commits; colored toasts |
| `skt_underlay.h` | Calibrated raster underlay on the sketch plane |
| `skt_ais.h` | OCCT AIS wrappers tied back to owning `Sketch` |
| `skt_display.cpp` | Visibility, edge/face styling, `set_current`, list hover |
@@ -223,7 +223,11 @@ A waist cutter is externally tangent to both end circles, so cutter center, cont
| **Linear edge** | `Sketch_edge` in `Sketch_edges` | `node_idx_a`, `node_idx_b`; optional `node_idx_mid` when add-midpoint is on |
| **Arc edge** | same | Start, end, arc midpoint nodes; `node_idx_arc_pt` for curve snap |
| **Face** | Derived in `Sketch_topo` | Rebuilt by `update_faces()` into `Sketch_face_shp`; drives extrude/revolve and face selection |
-| **Auto-split** | `Sketch_topo` / edge add | New edges crossing existing ones split at intersections (T-junctions, divided regions) |
+| **Auto-split** | `Sketch_topo` / edge add | Crossings split at points; same-line / same-circle overlap is partitioned and kept once |
+
+Undo `curr` for a new linear edge is the input span (redo re-runs merge). Pieces already on that span that were not split are also stored in `prev`, so undo does not drop an original that only overlapped. A no-op add (duplicate or already-covered span) records neither, so it does not push an undo step.
+
+Undo `curr` for a new arc is each **stored** start/bulge/end piece, not the input triple. Same-circle overlap skips the existing span; leftover parametric pieces have different endpoints, so recording the input triple would miss them on undo (for example a full circle over a bone end-cap).
### Face extraction (`Sketch_topo::update_faces`)
diff --git a/src/doc/utility.md b/src/doc/utility.md
index 658d012c..1f825fba 100644
--- a/src/doc/utility.md
+++ b/src/doc/utility.md
@@ -65,7 +65,7 @@ Large module; grouped by concern:
| Plane / 2D | `to_2d`, `to_3d`, `xy_plane`, `sketch_reference_plane`, `Plane_side` |
| Profile wires | `make_square_wire`, `make_circle_wire`, `make_slot_wire`, `create_wire_box` |
| Sketch dimensions | `Length_dimension_style`, `create_distance_annotation`, `create_angle_annotation`, `apply_length_dimension_style`, `apply_angle_dimension_style` |
-| Analysis | `to_boost` (polygon), `to_boost_ls` (edge `linestring_2d`), `get_shape_bbox_center`, `plane_from_face`, `side_of_plane` |
+| Analysis | `to_boost` (polygon), `to_boost_ls` (edge `linestring_2d`), `get_shape_bbox_center`, `plane_from_face`, `side_of_plane`, `same_line_support_2d`, `collinear_overlap_cut_points_2d`, `same_circle_support_2d` |
| Tests / debug | `to_wkt_string` (linestring / ring / polygon), `ezy_geom::area`, `is_valid`; Geometry Watch in `scripts/ezycad_graphical_debugging.xml` (`ring_2d` inherits vector as Ring; `linestring_2d` uses named `points` as Linestring). Re-select the XML path in Options after editing it. |
Includes [`utl_geom_boost.inl`](../utl_geom_boost.inl) for `ezy_geom` polygon / linestring types used in tests and face validation.
diff --git a/src/gui.cpp b/src/gui.cpp
index 1b24a997..37bacc24 100644
--- a/src/gui.cpp
+++ b/src/gui.cpp
@@ -437,7 +437,7 @@ void GUI::menu_bar_()
if (file.good() && !file_bytes.empty())
on_file(ex.path, file_bytes);
else
- show_message("Error opening example: " + ex.label);
+ show_message("Error opening example: " + ex.label, Status_msg::Error);
}
ImGui::EndMenu();
@@ -447,7 +447,7 @@ void GUI::menu_bar_()
if (ImGui::MenuItem("Save settings"))
{
save_occt_view_settings();
- show_message("Settings saved");
+ show_message("Settings saved", Status_msg::Success);
}
#endif
@@ -819,19 +819,19 @@ void GUI::toolbar_()
{
case Command::Shape_cut:
if (Status s = m_view->shp_cut().selected_cut(); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case Command::Shape_fuse:
if (Status s = m_view->shp_fuse().selected_fuse(); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case Command::Shape_common:
if (Status s = m_view->shp_common().selected_common(); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
@@ -1618,7 +1618,7 @@ void GUI::sketch_underlay_import_dialog_()
std::ifstream file(selected, std::ios::binary);
if (!file.is_open())
{
- show_message("Error opening: " + std::filesystem::path(selected).filename().string());
+ show_message("Error opening: " + std::filesystem::path(selected).filename().string(), Status_msg::Error);
return;
}
@@ -1626,7 +1626,7 @@ void GUI::sketch_underlay_import_dialog_()
if (!file_bytes.empty())
on_sketch_underlay_file(selected, file_bytes);
else
- show_message("Error opening: " + std::filesystem::path(selected).filename().string());
+ show_message("Error opening: " + std::filesystem::path(selected).filename().string(), Status_msg::Error);
}
#else
sketch_underlay_file_dialog_async();
@@ -2203,7 +2203,7 @@ void GUI::on_sketch_underlay_file(const std::string& file_path, const std::strin
if (!sk->underlay().load_from_file_bytes(file_bytes, m_view->asset_store(), hr, hg, hb, ha, sk->get_plane(),
sk->is_visible()))
{
- show_message("Could not decode image: " + std::filesystem::path(file_path).filename().string());
+ show_message("Could not decode image: " + std::filesystem::path(file_path).filename().string(), Status_msg::Error);
return;
}
@@ -2211,7 +2211,7 @@ void GUI::on_sketch_underlay_file(const std::string& file_path, const std::strin
m_underlay_calib_x_done = false;
m_underlay_calib_y_done = false;
m_underlay_panel_sketch = nullptr;
- show_message("Underlay: " + std::filesystem::path(file_path).filename().string());
+ show_message("Underlay: " + std::filesystem::path(file_path).filename().string(), Status_msg::Success);
}
void GUI::cancel_underlay_calib_()
@@ -2292,7 +2292,7 @@ void GUI::begin_underlay_calib_set_x_(const Sketch::sptr& sk)
if (m_view->curr_sketch_shared() != sk)
{
- show_message("Make this sketch current in the sketch list, then try again.");
+ show_message("Make this sketch current in the sketch list, then try again.", Status_msg::Constraint);
return;
}
@@ -2311,7 +2311,7 @@ void GUI::begin_underlay_calib_set_y_(const Sketch::sptr& sk)
if (m_view->curr_sketch_shared() != sk)
{
- show_message("Make this sketch current in the sketch list, then try again.");
+ show_message("Make this sketch current in the sketch list, then try again.", Status_msg::Constraint);
return;
}
@@ -2351,7 +2351,7 @@ void GUI::underlay_calib_prompt_x_distance_(const Sketch::sptr& sk)
const double Dx = static_cast(new_dist) * m_view->get_display_to_model_scale();
if (Dx <= 1e-12)
{
- show_message("Distance must be positive.");
+ show_message("Distance must be positive.", Status_msg::Constraint);
return;
}
@@ -2359,7 +2359,7 @@ void GUI::underlay_calib_prompt_x_distance_(const Sketch::sptr& sk)
if (!s->underlay().rescale_uv_chord_to_length(m_underlay_calib_x0, m_underlay_calib_x1, Dx, s->get_plane(),
s->is_visible()))
{
- show_message("Could not calibrate X (underlay axes degenerate or segment too short).");
+ show_message("Could not calibrate X (underlay axes degenerate or segment too short).", Status_msg::Error);
return;
}
@@ -2377,7 +2377,8 @@ void GUI::underlay_calib_prompt_x_distance_(const Sketch::sptr& sk)
m_dist_callback = nullptr;
m_underlay_calib_phase = Underlay_calib_phase::None;
show_message("X distance applied to the picked segment. Use Set Y from edge for the vertical span if needed, or adjust "
- "transforms.");
+ "transforms.",
+ Status_msg::Success);
};
set_dist_edit(dist_show, std::move(on_dist), spos);
@@ -2410,7 +2411,7 @@ void GUI::underlay_calib_prompt_y_distance_(const Sketch::sptr& sk)
const double Dy = static_cast(new_dist) * m_view->get_display_to_model_scale();
if (Dy <= 1e-12)
{
- show_message("Distance must be positive.");
+ show_message("Distance must be positive.", Status_msg::Constraint);
return;
}
@@ -2418,7 +2419,8 @@ void GUI::underlay_calib_prompt_y_distance_(const Sketch::sptr& sk)
if (!s->underlay().rescale_v_chord_to_length(m_underlay_calib_y0, m_underlay_calib_y1, Dy, s->get_plane(), s->is_visible()))
{
show_message("Set Y: picks need a clear span along image height (not along the same edge as X only). Try two points "
- "further apart in V.");
+ "further apart in V.",
+ Status_msg::Constraint);
return;
}
@@ -2436,7 +2438,8 @@ void GUI::underlay_calib_prompt_y_distance_(const Sketch::sptr& sk)
m_dist_callback = nullptr;
cancel_underlay_calib_();
show_message("Y distance applied to the picked segment. Use Set X from edge for the horizontal span if needed, or adjust "
- "transforms.");
+ "transforms.",
+ Status_msg::Success);
};
set_dist_edit(dist_show, std::move(on_dist), spos);
@@ -2488,7 +2491,7 @@ bool GUI::try_underlay_calib_click_(const ScreenCoords& screen_coords)
case Underlay_calib_phase::PickX2:
if (too_short(m_underlay_calib_x0, *pt))
{
- show_message("X segment too short.");
+ show_message("X segment too short.", Status_msg::Constraint);
return true;
}
@@ -2506,7 +2509,7 @@ bool GUI::try_underlay_calib_click_(const ScreenCoords& screen_coords)
case Underlay_calib_phase::PickY2:
if (too_short(m_underlay_calib_y0, *pt))
{
- show_message("Y segment too short.");
+ show_message("Y segment too short.", Status_msg::Constraint);
return true;
}
@@ -3225,19 +3228,19 @@ void GUI::finish_step_import_(Status st, Occt_view::Step_import_geom& geom)
if (cancelled || (!st.is_ok() && st.message().find("cancelled") != std::string::npos))
{
- show_message("STEP import cancelled.");
+ show_message("STEP import cancelled.", Status_msg::Info);
return;
}
if (!st.is_ok())
{
- show_message(st.message());
+ show_status(st);
return;
}
if (Status commit = m_view->commit_step_import(geom); !commit.is_ok())
{
- show_message(commit.message());
+ show_status(commit);
return;
}
@@ -3245,13 +3248,13 @@ void GUI::finish_step_import_(Status st, Occt_view::Step_import_geom& geom)
switch (m_cad_busy_import_mode)
{
case Step_import_mode::Union_shapes:
- show_message("Imported (union): " + name);
+ show_message("Imported (union): " + name, Status_msg::Success);
break;
case Step_import_mode::Flat_solids:
- show_message("Imported (flat): " + name);
+ show_message("Imported (flat): " + name, Status_msg::Success);
break;
default:
- show_message("Imported: " + name);
+ show_message("Imported: " + name, Status_msg::Success);
break;
}
close_file_inspector_();
@@ -3474,19 +3477,105 @@ void GUI::dbg_()
void GUI::dbg_() {}
#endif
-void GUI::show_message(const std::string& message)
+Status_msg parse_status_msg(std::string_view name)
+{
+ std::string s(name);
+ for (char& c : s)
+ c = static_cast(std::tolower(static_cast(c)));
+
+ if (s == "success")
+ return Status_msg::Success;
+
+ if (s == "constraint")
+ return Status_msg::Constraint;
+
+ if (s == "warning")
+ return Status_msg::Warning;
+
+ if (s == "error")
+ return Status_msg::Error;
+
+ return Status_msg::Info;
+}
+
+const char* status_msg_name(Status_msg kind)
+{
+ switch (kind)
+ {
+ case Status_msg::Success:
+ return "success";
+ case Status_msg::Constraint:
+ return "constraint";
+ case Status_msg::Warning:
+ return "warning";
+ case Status_msg::Error:
+ return "error";
+ case Status_msg::Info:
+ default:
+ return "info";
+ }
+}
+
+namespace
+{
+Status_msg toast_kind_from_status_(const Status& status)
+{
+ switch (status.status())
+ {
+ case Result_status::User_error:
+ return Status_msg::Constraint;
+ case Result_status::Error:
+ case Result_status::Topo_error:
+ return Status_msg::Error;
+ case Result_status::Success:
+ case Result_status::Null:
+ default:
+ return Status_msg::Info;
+ }
+}
+
+ImVec4 toast_text_color_(Status_msg kind, float alpha)
+{
+ switch (kind)
+ {
+ case Status_msg::Success:
+ return ImVec4(0.35f, 0.85f, 0.40f, alpha);
+ case Status_msg::Constraint:
+ return ImVec4(1.00f, 0.72f, 0.22f, alpha);
+ case Status_msg::Warning:
+ return ImVec4(1.00f, 0.88f, 0.25f, alpha);
+ case Status_msg::Error:
+ return ImVec4(1.00f, 0.28f, 0.28f, alpha);
+ case Status_msg::Info:
+ default:
+ return ImVec4(0.80f, 0.88f, 0.95f, alpha);
+ }
+}
+} // namespace
+
+void GUI::show_message(const std::string& message, Status_msg kind)
{
m_message = message;
+ m_message_kind = kind;
m_message_visible = true;
m_message_start_time = std::chrono::steady_clock::now();
log_message(message);
}
+void GUI::show_status(const Status& status)
+{
+ if (status.message().empty())
+ return;
+
+ show_message(status.message(), toast_kind_from_status_(status));
+}
+
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);
m_message = title;
+ m_message_kind = Status_msg::Error;
m_message_visible = true;
m_message_start_time = std::chrono::steady_clock::now();
m_error_modal_title = title;
@@ -3554,8 +3643,7 @@ void GUI::message_status_window_()
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_AlwaysAutoResize |
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav);
- // Set text color with alpha for fade effect
- ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.0f, 0.0f, alpha));
+ ImGui::PushStyleColor(ImGuiCol_Text, toast_text_color_(m_message_kind, alpha));
ImGui::TextWrapped("%s", m_message.c_str());
ImGui::PopStyleColor();
@@ -3720,7 +3808,7 @@ void GUI::load_default_project_()
}
log_message("EzyCad: last opened project JSON is invalid; falling back to startup/default.");
- show_message("Last opened project file is invalid; loading startup/default.");
+ show_message("Last opened project file is invalid; loading startup/default.", Status_msg::Warning);
}
}
else
@@ -3752,7 +3840,7 @@ void GUI::load_default_project_()
if (!user_startup.empty())
{
log_message("EzyCad: saved startup project is invalid or incomplete; falling back to install default.");
- show_message("Saved startup project is invalid; loading install default.");
+ show_message("Saved startup project is invalid; loading install default.", Status_msg::Warning);
}
else
log_message("EzyCad: no saved startup project; trying bundled default.");
@@ -3954,7 +4042,7 @@ void GUI::save_startup_project_()
const std::vector ezy_bytes = serialized_project_ezy_();
if (!settings::save_user_startup_project(ezy_bytes))
{
- show_message("Could not save startup project.");
+ show_message("Could not save startup project.", Status_msg::Error);
return;
}
@@ -3963,13 +4051,13 @@ void GUI::save_startup_project_()
if (!p.empty())
log_message("Startup saved to: " + p.string());
#endif
- show_message("Startup project saved. It will load automatically the next time you start EzyCad.");
+ show_message("Startup project saved. It will load automatically the next time you start EzyCad.", Status_msg::Success);
}
void GUI::clear_saved_startup_project_()
{
settings::clear_user_startup_project();
- show_message("Saved startup cleared. Next launch uses the install default (res/default.ezy).");
+ show_message("Saved startup cleared. Next launch uses the install default (res/default.ezy).", Status_msg::Success);
}
void GUI::on_mouse_pos(const ScreenCoords& screen_coords)
@@ -3980,19 +4068,19 @@ void GUI::on_mouse_pos(const ScreenCoords& screen_coords)
{
case Mode::Move:
if (Status s = m_view->shp_move().move_selected(screen_coords); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case Mode::Rotate:
if (Status s = m_view->shp_rotate().rotate_selected(screen_coords); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case Mode::Scale:
if (Status s = m_view->shp_scale().scale_selected(screen_coords); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
@@ -4000,16 +4088,16 @@ void GUI::on_mouse_pos(const ScreenCoords& screen_coords)
if (m_view->shp_cyl_align().is_twist_phase())
{
if (Status s = m_view->shp_cyl_align().drag_twist(screen_coords); !s.is_ok())
- show_message(s.message());
+ show_status(s);
}
else if (Status s = m_view->shp_cyl_align().drag_depth(screen_coords); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case Mode::Shape_polar_duplicate:
if (Status s = m_view->shp_polar_dup().move_point(screen_coords); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
@@ -4047,11 +4135,11 @@ void GUI::on_left_click_(const ScreenCoords& screen_coords)
if (m_view->shp_cyl_align().is_dragging())
m_view->shp_cyl_align().on_left_click();
else if (Status s = m_view->shp_cyl_align().pick(screen_coords); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case Mode::Shape_set_frame:
if (Status s = m_view->shp_set_frame().pick(screen_coords); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case Mode::Sketch_face_extrude: m_view->sketch_face_extrude(screen_coords, false); break;
// clang-format on
@@ -4085,19 +4173,19 @@ void GUI::on_left_click_(const ScreenCoords& screen_coords)
case Mode::Shape_chamfer:
if (Status s = m_view->shp_chamfer().add_chamfer(screen_coords, m_chamfer_mode); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case Mode::Shape_fillet:
if (Status s = m_view->shp_fillet().add_fillet(screen_coords, m_fillet_mode); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case Mode::Shape_polar_duplicate:
if (Status s = m_view->shp_polar_dup().add_point(screen_coords); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
@@ -4317,15 +4405,15 @@ void GUI::export_file_dialog_(Export_format fmt, Export_unit unit)
char const* selected = tinyfd_saveFileDialog(title, def_name, 1, filter_patterns, filter_desc);
if (!selected)
{
- show_message("Export canceled");
+ show_message("Export canceled", Status_msg::Info);
return;
}
const Status s = m_view->export_document(fmt, unit, selected);
if (!s.is_ok())
- show_message(s.message());
+ show_status(s);
else
- show_message("Exported: " + std::filesystem::path(selected).filename().string());
+ show_message("Exported: " + std::filesystem::path(selected).filename().string(), Status_msg::Success);
#else
const char* mem_path = "/ezycad_export.step";
std::string download_name{"export.step"};
@@ -4349,20 +4437,20 @@ void GUI::export_file_dialog_(Export_format fmt, Export_unit unit)
const Status s = m_view->export_document(fmt, unit, mem_path);
if (!s.is_ok())
{
- show_message(s.message());
+ show_status(s);
return;
}
std::ifstream in(mem_path, std::ios::binary);
if (!in)
{
- show_message("Export read failed.");
+ show_message("Export read failed.", Status_msg::Error);
return;
}
const std::string bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator());
download_blob_async(download_name, bytes);
- show_message("Exported: " + download_name);
+ show_message("Exported: " + download_name, Status_msg::Success);
#endif
}
@@ -4379,7 +4467,7 @@ void GUI::import_file_dialog_()
std::ifstream file(selected, std::ios::binary);
if (!file.is_open())
{
- show_message("Error opening: " + std::filesystem::path(selected).filename().string());
+ show_message("Error opening: " + std::filesystem::path(selected).filename().string(), Status_msg::Error);
return;
}
@@ -4387,7 +4475,7 @@ void GUI::import_file_dialog_()
if (!file_bytes.empty())
on_inspector_file(selected, file_bytes);
else
- show_message("Error opening: " + std::filesystem::path(selected).filename().string());
+ show_message("Error opening: " + std::filesystem::path(selected).filename().string(), Status_msg::Error);
}
#else
import_file_dialog_async();
@@ -4422,7 +4510,7 @@ void GUI::open_file_dialog_()
on_file(selected, file_bytes);
else
- show_message("Error opening: " + std::filesystem::path(selected).filename().string());
+ show_message("Error opening: " + std::filesystem::path(selected).filename().string(), Status_msg::Error);
}
#else
// Emscripten: Call async version (synchronous fallback for simplicity)
@@ -4511,12 +4599,12 @@ void GUI::save_file_dialog_()
if (!out)
show_error_dialog("Save failed", describe_save_failure("closing the file"));
else
- show_message("Saved: " + std::filesystem::path(file).filename().string());
+ show_message("Saved: " + std::filesystem::path(file).filename().string(), Status_msg::Success);
}
}
}
else
- show_message("Save canceled");
+ show_message("Save canceled", Status_msg::Info);
#else
if (ezy_bytes.empty())
{
@@ -4551,7 +4639,7 @@ void GUI::on_file(const std::string& file_path, const std::string& file_bytes, b
if (!manifest || !is_valid_project_manifest_(*manifest))
{
m_view->pop_undo_snapshot();
- show_message("Invalid EzyCad project: " + std::filesystem::path(file_path).filename().string());
+ show_message("Invalid EzyCad project: " + std::filesystem::path(file_path).filename().string(), Status_msg::Error);
return;
}
@@ -4575,7 +4663,7 @@ void GUI::on_file(const std::string& file_path, const std::string& file_bytes, b
#endif
if (announce_load)
- show_message("Opened: " + std::filesystem::path(file_path).filename().string());
+ show_message("Opened: " + std::filesystem::path(file_path).filename().string(), Status_msg::Success);
}
bool GUI::on_import_file(const std::string& file_path, const std::string& file_data, const Step_import_mode step_mode)
@@ -4588,17 +4676,17 @@ bool GUI::on_import_file(const std::string& file_path, const std::string& file_d
{
if (!m_view->import_ply(file_data))
{
- show_message("PLY import failed.");
+ show_message("PLY import failed.", Status_msg::Error);
return false;
}
- show_message("Imported: " + std::filesystem::path(file_path).filename().string());
+ show_message("Imported: " + std::filesystem::path(file_path).filename().string(), Status_msg::Success);
return true;
}
if (Status st = m_view->import_step(file_data, step_mode); !st.is_ok())
{
- show_message(st.message());
+ show_status(st);
return false;
}
@@ -4606,14 +4694,14 @@ bool GUI::on_import_file(const std::string& file_path, const std::string& file_d
switch (step_mode)
{
case Step_import_mode::Union_shapes:
- show_message("Imported (union): " + std::filesystem::path(file_path).filename().string());
+ show_message("Imported (union): " + std::filesystem::path(file_path).filename().string(), Status_msg::Success);
break;
case Step_import_mode::Flat_solids:
- show_message("Imported (flat): " + std::filesystem::path(file_path).filename().string());
+ show_message("Imported (flat): " + std::filesystem::path(file_path).filename().string(), Status_msg::Success);
break;
case Step_import_mode::Preserve_hierarchy:
default:
- show_message(base);
+ show_message(base, Status_msg::Success);
break;
}
return true;
@@ -4830,7 +4918,7 @@ extern "C" void on_save_file_selected(const char* file_name)
if (file_name)
g.note_saved_project_filename(file_name);
- g.show_message(std::string("Saved: ") + (file_name ? file_name : ""));
+ g.show_message(std::string("Saved: ") + (file_name ? file_name : ""), Status_msg::Success);
}
extern "C" void on_save_file_failed(const char* reason)
{
diff --git a/src/gui.h b/src/gui.h
index 875ed6d7..ff3e6079 100644
--- a/src/gui.h
+++ b/src/gui.h
@@ -31,6 +31,7 @@
#include "gui_hotkeys.h"
#include "gui_occt_view.h"
#include "shp_info.h"
+#include "utl.h"
#include "utl_cad_file_info.h"
#include "utl_types.h"
@@ -49,6 +50,22 @@ enum class Command
_count
};
+/// Kind for the transient status toast (`GUI::show_message` / `ezy.msg`).
+enum class Status_msg : std::uint8_t
+{
+ Success,
+ Info,
+ Constraint,
+ Warning,
+ Error
+};
+
+/// Parse a script/API name (`"success"`, `"info"`, `"constraint"`, `"warning"`, `"error"`).
+/// Unknown names become `Info`.
+Status_msg parse_status_msg(std::string_view name);
+/// Stable lowercase name for \\a kind (`"info"`, `"error"`, ...).
+const char* status_msg_name(Status_msg kind);
+
/// Two-segment picks for underlay X/Y calibration (Sketch properties pane).
enum class Underlay_calib_phase : std::uint8_t
{
@@ -114,6 +131,11 @@ inline constexpr float k_gui_sketch_face_selection_color_default[4] = {0.799043f
inline constexpr float k_gui_sketch_face_highlight_color_default[4] = {0.822967f, 0.0f, 1.0f, 1.0f};
/// 3D shape selection (AIS SelectionStyle) RGBA (`gui.shape_selection_color`).
inline constexpr float k_gui_shape_selection_color_default[4] = {0.754312f, 0.072938f, 0.846890f, 1.0f};
+/// OCCT AIS curve tessellation: max segment angle in degrees (`gui.curve_deviation_angle_deg`).
+/// Smaller is smoother. OCCT library default is 20 deg; EzyCad default is tighter.
+inline constexpr float k_gui_curve_deviation_angle_deg_min = 2.0f;
+inline constexpr float k_gui_curve_deviation_angle_deg_max = 20.0f;
+inline constexpr float k_gui_curve_deviation_angle_deg_default = 6.0f;
/// `gui.sketch_shape_faint_style`: 0 = Off (hide shapes in sketch mode), 1 = Ghost, 2 = Wire.
inline constexpr int k_gui_sketch_shape_faint_style_min = 0;
inline constexpr int k_gui_sketch_shape_faint_style_max = 2;
@@ -297,6 +319,8 @@ class GUI
const float* sketch_face_highlight_color_rgba() const { return m_sketch_face_highlight_color; }
/// 3D shape selection (AIS SelectionStyle) RGBA (0-1; alpha = opacity).
const float* shape_selection_color_rgba() const { return m_shape_selection_color; }
+ /// Max AIS curve segment angle in degrees (`gui.curve_deviation_angle_deg`).
+ float curve_deviation_angle_deg() const { return m_curve_deviation_angle_deg; }
/// How 3D shapes appear while in sketch mode (`gui.sketch_shape_faint_style`).
int sketch_shape_faint_style() const { return m_sketch_shape_faint_style; }
float sketch_shape_faint_opacity() const { return m_sketch_shape_faint_opacity; }
@@ -311,6 +335,10 @@ class GUI
bool get_add_mid_pt_slot_edges() const { return m_add_mid_pt_slot_edges; }
/// Add-bone Options: permanent Bone A / Bone B nodes (`gui.bone_add_center_nodes`).
bool get_bone_add_center_nodes() const { return m_bone_add_center_nodes; }
+ /// Add-bone Options: permanent axis-tangent radius nodes (`gui.bone_add_radius_nodes`).
+ bool get_bone_add_radius_nodes() const { return m_bone_add_radius_nodes; }
+ /// Add-bone Options: permanent outer-tip nodes for overall length (`gui.bone_add_total_length_nodes`).
+ bool get_bone_add_total_length_nodes() const { return m_bone_add_total_length_nodes; }
/// Add-bone Options: hole clicks after waist (`gui.bone_holes`).
Bone_holes get_bone_holes() const { return m_bone_holes; }
#if DEV_MODE
@@ -345,9 +373,11 @@ 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 (logs title: message once; short title toast). Native and wasm.
+ /// Transient bottom-right status toast (colored by \\a kind); also appends to the Log window.
+ void show_message(const std::string& message, Status_msg kind = Status_msg::Info);
+ /// Toast `status.message()` using `Result_status` (ok -> Info, User_error -> Constraint, else Error).
+ void show_status(const Status& status);
+ /// Queue an ImGui error modal (logs title: message once; short title toast as Error). 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; }
@@ -414,7 +444,8 @@ class GUI
/// RGBA (0-255) for Shape List row hover highlight in the 3D viewer (see Settings).
void elm_list_hover_color_rgba(uint8_t& r, uint8_t& g, uint8_t& b, uint8_t& a) const;
/// For scripting (Lua console): access the 3D view.
- Occt_view* get_view() { return m_view.get(); }
+ Occt_view* get_view() { return m_view.get(); }
+ const Occt_view* get_view() const { return m_view.get(); }
private:
friend class GUI_access;
@@ -669,6 +700,8 @@ class GUI
bool m_add_mid_pt_rect_edges = true;
bool m_add_mid_pt_slot_edges = false;
bool m_bone_add_center_nodes = true;
+ bool m_bone_add_radius_nodes = true;
+ bool m_bone_add_total_length_nodes = true;
Bone_holes m_bone_holes = Bone_holes::None;
#if DEV_MODE
Bone_debug_flags m_bone_debug;
@@ -687,6 +720,7 @@ class GUI
// Message status window
std::string m_message;
+ Status_msg m_message_kind = Status_msg::Info;
bool m_message_visible = false;
std::chrono::steady_clock::time_point m_message_start_time;
@@ -857,6 +891,7 @@ class GUI
/// AIS SelectionStyle for selected 3D shapes (0-1 RGBA; Settings -> View presentation).
float m_shape_selection_color[4] = {k_gui_shape_selection_color_default[0], k_gui_shape_selection_color_default[1],
k_gui_shape_selection_color_default[2], k_gui_shape_selection_color_default[3]};
+ float m_curve_deviation_angle_deg = k_gui_curve_deviation_angle_deg_default;
std::unique_ptr m_lua_console;
bool m_show_python_console{false};
diff --git a/src/gui_mode.cpp b/src/gui_mode.cpp
index 6f5985e2..e3029801 100644
--- a/src/gui_mode.cpp
+++ b/src/gui_mode.cpp
@@ -412,27 +412,27 @@ void GUI::dispatch_hotkey_action_(Gui_action action)
case Gui_action::Mode_cross_section: set_mode(Mode::Shape_cross_section); break;
case Gui_action::Cmd_shape_cut:
if (Status s = m_view->shp_cut().selected_cut(); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case Gui_action::Cmd_shape_fuse:
if (Status s = m_view->shp_fuse().selected_fuse(); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case Gui_action::Cmd_shape_common:
if (Status s = m_view->shp_common().selected_common(); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case Gui_action::Edit_delete: m_view->delete_selected(); break;
case Gui_action::Edit_copy:
if (Status s = m_view->copy_selected_shapes(); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case Gui_action::Edit_paste:
if (Status s = m_view->paste_clipboard_shapes(); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case Gui_action::File_new: new_project_(); break;
case Gui_action::File_open: open_file_dialog_(); break;
@@ -474,21 +474,21 @@ bool GUI::try_capture_hotkey_press_(int key, int mods)
if (!Gui_hotkeys::is_bindable_key(key))
{
m_hotkey_capture_error = "Unsupported key. Use a letter, digit, or Space (modifiers allowed).";
- show_message(m_hotkey_capture_error);
+ show_message(m_hotkey_capture_error, Status_msg::Constraint);
return true;
}
if (Gui_hotkeys::is_reserved_chord(chord))
{
m_hotkey_capture_error = "Reserved: " + Gui_hotkeys::format_chord(chord) + " is a fixed shortcut and cannot be remapped.";
- show_message(m_hotkey_capture_error);
+ show_message(m_hotkey_capture_error, Status_msg::Constraint);
return true;
}
if (!m_hotkeys.set_chord(*m_hotkey_capture_action, chord))
{
m_hotkey_capture_error = "Conflict: " + Gui_hotkeys::format_chord(chord) + " is already assigned.";
- show_message(m_hotkey_capture_error);
+ show_message(m_hotkey_capture_error, Status_msg::Constraint);
return true;
}
@@ -909,7 +909,7 @@ void GUI::options_shape_polar_duplicate_mode_()
ImGui::TableSetColumnIndex(1);
if (ImGui::Button("Dup"))
if (Status s = polar_dup.dup(); !s.is_ok())
- show_message(s.message());
+ show_status(s);
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
@@ -1019,21 +1019,21 @@ void GUI::options_shape_cross_section_mode_()
section.acknowledge_current_selection();
}
else if (const Status status = section.request_preview_selected(); !status.is_ok())
- show_message(status.message());
+ show_status(status);
}
if (std::optional finished = section.poll())
{
// Toast failures only; success edge-count spam on Offset drag is noise.
if (!finished->is_ok())
- show_message(finished->message());
+ show_status(*finished);
}
ImGui::BeginDisabled(!have_selection);
if (ImGui::Button("Clip"))
{
const Status status = section.clip_selected();
- show_message(status.message());
+ show_status(status);
}
ImGui::EndDisabled();
@@ -1041,7 +1041,7 @@ void GUI::options_shape_cross_section_mode_()
if (ImGui::Button("Cross section sketch"))
{
const Status status = m_view->create_sketch_from_cross_section();
- show_message(status.message());
+ show_status(status);
}
ImGui::EndDisabled();
@@ -1285,6 +1285,30 @@ void GUI::options_sketch_add_bone_mode_()
"Click ? to open the user guide.",
doc_urls::k_bone_creation_tool);
+ bool add_radius = m_bone_add_radius_nodes;
+ if (ImGui::Checkbox("Add radius nodes", &add_radius))
+ {
+ m_bone_add_radius_nodes = add_radius;
+ save_occt_view_settings();
+ }
+
+ ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x);
+ GUI_DOC_HELP_("When on, commits permanent Bone A+ / A- and Bone B+ / B- nodes at the "
+ "axis-tangent points on each end circle. Click ? to open the user guide.",
+ doc_urls::k_bone_creation_tool);
+
+ bool add_total = m_bone_add_total_length_nodes;
+ if (ImGui::Checkbox("Add total length nodes", &add_total))
+ {
+ m_bone_add_total_length_nodes = add_total;
+ save_occt_view_settings();
+ }
+
+ ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x);
+ GUI_DOC_HELP_("When on, commits permanent Bone A tip and Bone B tip nodes at the outer "
+ "ends (overall bone length). Click ? to open the user guide.",
+ doc_urls::k_bone_creation_tool);
+
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted("Holes");
ImGui::SameLine();
@@ -1295,6 +1319,7 @@ void GUI::options_sketch_add_bone_mode_()
{
m_bone_holes = static_cast(holes);
save_occt_view_settings();
+ m_view->curr_sketch().apply_bone_holes_option();
}
}
@@ -1303,8 +1328,7 @@ void GUI::options_sketch_add_bone_mode_()
"Hole radius must be smaller than that end's outer radius. Click ? to open the user guide.",
doc_urls::k_bone_creation_tool);
- ImGui::TextWrapped(
- "Click center A, center B, radius 1, radius 2, waist, then holes if enabled.");
+ ImGui::TextWrapped("Click center A, center B, radius 2, radius 1, waist, then holes if enabled.");
#if DEV_MODE
ImGui::Separator();
@@ -1584,7 +1608,7 @@ void GUI::on_key_rotate_mode_(int key)
case GLFW_KEY_TAB:
if (Status s = m_view->shp_rotate().show_angle_edit(screen_coords); !s.is_ok())
- show_message(s.message());
+ show_status(s);
break;
case GLFW_KEY_X:
diff --git a/src/gui_occt_view.cpp b/src/gui_occt_view.cpp
index f4b10d5c..47281b7c 100644
--- a/src/gui_occt_view.cpp
+++ b/src/gui_occt_view.cpp
@@ -26,6 +26,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -34,6 +35,8 @@
#include
#include
#include
+#include
+#include
#include
#include
#include
@@ -42,6 +45,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -270,6 +274,7 @@ void Occt_view::init_viewer()
m_ctx->SetHighlightStyle(highlight_style);
apply_shape_selection_style();
+ apply_curve_deviation();
m_ctx->SetPixelTolerance(10); // Picking?
m_ctx->UpdateCurrentViewer();
@@ -636,7 +641,7 @@ void Occt_view::revolve_selected(const double angle)
}
else
{
- gui().show_message("Revolve failed, ensure edges or faces on one side of operation axis.");
+ gui().show_message("Revolve failed, ensure edges or faces on one side of operation axis.", Status_msg::Error);
DBG_MSG(revolved.message());
}
}
@@ -644,23 +649,27 @@ void Occt_view::revolve_selected(const double angle)
// Sketch related
void Occt_view::create_sketch_from_planar_face_(const ScreenCoords& screen_coords)
{
- if (auto face = get_face_(screen_coords); face)
- if (auto pln = plane_from_face(*face); pln)
- {
- // Get the outer wire of the face
- TopoDS_Wire outer_wire = BRepTools::OuterWire(*face);
- EZY_ASSERT(!outer_wire.IsNull());
- m_cur_sketch = std::make_shared("Sketch from face", *this, *pln, outer_wire);
- 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));
- // fit_face_in_view(*face);
- m_gui.set_mode(Mode::Sketch_inspection_mode);
- }
- else
- gui().show_message("Error: Selected face is not planar. Please select a planar face.");
+ const TopoDS_Face* face = get_face_(screen_coords);
+ if (!face)
+ return;
+
+ if (auto pln = plane_from_face(*face); pln)
+ {
+ // Get the outer wire of the face
+ TopoDS_Wire outer_wire = BRepTools::OuterWire(*face);
+ EZY_ASSERT(!outer_wire.IsNull());
+ m_cur_sketch = std::make_shared("Sketch from face", *this, *pln, outer_wire);
+ 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));
+ // fit_face_in_view(*face);
+ m_gui.set_mode(Mode::Sketch_inspection_mode);
+ return;
+ }
+
+ gui().show_message("Error: Selected face is not planar. Please select a planar face.", Status_msg::Constraint);
}
void Occt_view::create_default_sketch_()
@@ -3022,6 +3031,108 @@ void Occt_view::apply_shape_selection_style()
m_ctx->UpdateCurrentViewer();
}
+namespace
+{
+// OCCT default relative coefficient. AIS_Shape::SetAngleAndDeviation pairs the angle with
+// HLRBRep::PolyHLRAngleAndDeflection, which yields ~0.04 and makes curves coarser, not smoother.
+constexpr double k_ais_rel_deviation_coeff = 0.001;
+
+void set_drawer_curve_tessellation_(const Prs3d_Drawer_ptr& drawer, const double ang_rad)
+{
+ if (drawer.IsNull())
+ return;
+
+ drawer->SetDeviationAngle(ang_rad);
+ drawer->SetDeviationCoefficient(k_ais_rel_deviation_coeff);
+}
+
+bool ais_shape_is_edge_or_wire_(const AIS_Shape& shp)
+{
+ if (shp.Shape().IsNull())
+ return false;
+
+ const TopAbs_ShapeEnum t = shp.Shape().ShapeType();
+ return t == TopAbs_EDGE || t == TopAbs_WIRE;
+}
+
+// Default Clean() leaves PolygonOnTriangulation that belongs to other faces (shared TEdge).
+void clean_ais_polygons_(AIS_Shape& shp)
+{
+ if (!shp.Shape().IsNull())
+ BRepTools::Clean(shp.Shape(), true);
+}
+
+void apply_curve_tessellation_to_ais_shape_(AIS_Shape& shp, const double ang_rad)
+{
+ set_drawer_curve_tessellation_(shp.Attributes(), ang_rad);
+ shp.SetOwnDeviationAngle(ang_rad);
+ shp.SetOwnDeviationCoefficient(k_ais_rel_deviation_coeff);
+ if (ais_shape_is_edge_or_wire_(shp))
+ shp.Attributes()->SetAutoTriangulation(false);
+
+ clean_ais_polygons_(shp);
+ shp.SetToUpdate();
+}
+} // namespace
+
+void Occt_view::apply_curve_deviation_to_shape(AIS_Shape& shp) const
+{
+ apply_curve_tessellation_to_ais_shape_(shp, to_radians(static_cast(gui().curve_deviation_angle_deg())));
+}
+
+void Occt_view::apply_curve_deviation()
+{
+ if (m_ctx.IsNull())
+ return;
+
+ const double ang = to_radians(static_cast(gui().curve_deviation_angle_deg()));
+ set_drawer_curve_tessellation_(m_ctx->DefaultDrawer(), ang);
+ m_ctx->SetDeviationAngle(ang);
+ m_ctx->SetDeviationCoefficient(k_ais_rel_deviation_coeff);
+
+ NCollection_List objs;
+ m_ctx->ObjectsInside(objs);
+ NCollection_List edge_wires;
+ for (NCollection_List::Iterator it(objs); it.More(); it.Next())
+ {
+ const AIS_InteractiveObject_ptr& obj = it.Value();
+ if (obj.IsNull())
+ continue;
+
+ set_drawer_curve_tessellation_(obj->Attributes(), ang);
+ set_drawer_curve_tessellation_(obj->HilightAttributes(), ang);
+ set_drawer_curve_tessellation_(obj->DynamicHilightAttributes(), ang);
+ if (const AIS_Shape_ptr shape = AIS_Shape_ptr::DownCast(obj); !shape.IsNull())
+ {
+ apply_curve_tessellation_to_ais_shape_(*shape, ang);
+ if (ais_shape_is_edge_or_wire_(*shape))
+ {
+ edge_wires.Append(shape);
+ continue;
+ }
+ }
+
+ m_ctx->Redisplay(obj, false);
+ }
+
+ // Faces remesh first (needed so shaded fillets pick up the new angle; IsTessellated
+ // ignores angle). That reattaches PolygonOnTriangulation on shared sketch TEdges.
+ // Force-clean those edges so wireframe uses StdPrs_DeflectionCurve (the slider).
+ for (NCollection_List::Iterator it(edge_wires); it.More(); it.Next())
+ {
+ const AIS_Shape_ptr& shape = it.Value();
+ if (shape.IsNull())
+ continue;
+
+ clean_ais_polygons_(*shape);
+ shape->Attributes()->SetAutoTriangulation(false);
+ shape->SetToUpdate();
+ m_ctx->Redisplay(shape, false);
+ }
+
+ m_ctx->UpdateCurrentViewer();
+}
+
void Occt_view::update_shape_list_hover_drawer_()
{
uint8_t r{}, g{}, b{}, a{};
@@ -3620,7 +3731,7 @@ void Occt_view::on_mode()
if (mode == Mode::Shape_cross_section && !enter_selection.empty())
{
const Status status = shp_cross_section().preview(enter_selection);
- gui().show_message(status.message());
+ gui().show_status(status);
}
}
@@ -4597,7 +4708,8 @@ bool Occt_view::import_ply(const std::string& ply_bytes)
return true;
}
-GUI& Occt_view::gui() { return m_gui; }
+GUI& Occt_view::gui() { return m_gui; }
+const GUI& Occt_view::gui() const { return m_gui; }
AIS_InteractiveContext& Occt_view::ctx() { return *m_ctx; }
diff --git a/src/gui_occt_view.h b/src/gui_occt_view.h
index bb5bbba9..d4413bcb 100644
--- a/src/gui_occt_view.h
+++ b/src/gui_occt_view.h
@@ -360,6 +360,7 @@ class Occt_view : protected AIS_ViewController
void snap_view_to_nearest_standard_axis();
GUI& gui();
+ const GUI& gui() const;
AIS_InteractiveContext& ctx();
// Selection related
@@ -391,6 +392,10 @@ class Occt_view : protected AIS_ViewController
void refresh_shape_list_hover_highlight();
/// Apply AIS SelectionStyle from Settings (shape selection color).
void apply_shape_selection_style();
+ /// Apply AIS curve tessellation angle from Settings (`gui.curve_deviation_angle_deg`) and redisplay.
+ void apply_curve_deviation();
+ /// Apply the current curve tessellation settings to one AIS shape (sketch edges/faces).
+ void apply_curve_deviation_to_shape(AIS_Shape& shp) const;
/// Apply or clear sketch-mode shape ghost/wire/hide from current GUI settings and mode.
void sync_sketch_shape_faint_style();
diff --git a/src/gui_settings.cpp b/src/gui_settings.cpp
index fba7aef2..7f6079a3 100644
--- a/src/gui_settings.cpp
+++ b/src/gui_settings.cpp
@@ -65,6 +65,8 @@ std::string GUI::occt_view_settings_json() const
{"add_mid_pt_rect_edges", m_add_mid_pt_rect_edges},
{"add_mid_pt_slot_edges", m_add_mid_pt_slot_edges},
{"bone_add_center_nodes", m_bone_add_center_nodes},
+ {"bone_add_radius_nodes", m_bone_add_radius_nodes},
+ {"bone_add_total_length_nodes", m_bone_add_total_length_nodes},
{"bone_holes", static_cast(m_bone_holes)},
{"view_roll_step_deg", m_view_roll_step_deg},
{"view_zoom_scroll_scale", m_view_zoom_scroll_scale},
@@ -91,6 +93,7 @@ std::string GUI::occt_view_settings_json() const
m_elm_list_hover_color[2], m_elm_list_hover_color[3]}},
{"shape_selection_color", {m_shape_selection_color[0], m_shape_selection_color[1],
m_shape_selection_color[2], m_shape_selection_color[3]}},
+ {"curve_deviation_angle_deg", m_curve_deviation_angle_deg},
{"sketch_shape_faint_style", m_sketch_shape_faint_style},
{"sketch_shape_faint_opacity", m_sketch_shape_faint_opacity},
{"sketch_shape_faint_enabled", m_sketch_shape_faint_enabled},
@@ -159,6 +162,8 @@ void GUI::save_occt_view_settings()
{"add_mid_pt_rect_edges", m_add_mid_pt_rect_edges},
{"add_mid_pt_slot_edges", m_add_mid_pt_slot_edges},
{"bone_add_center_nodes", m_bone_add_center_nodes},
+ {"bone_add_radius_nodes", m_bone_add_radius_nodes},
+ {"bone_add_total_length_nodes", m_bone_add_total_length_nodes},
{"bone_holes", static_cast(m_bone_holes)},
{"load_last_opened_on_startup", m_load_last_opened_on_startup},
{"last_opened_project_path", m_last_opened_project_path},
@@ -194,6 +199,7 @@ void GUI::save_occt_view_settings()
m_elm_list_hover_color[2], m_elm_list_hover_color[3]}},
{"shape_selection_color", {m_shape_selection_color[0], m_shape_selection_color[1],
m_shape_selection_color[2], m_shape_selection_color[3]}},
+ {"curve_deviation_angle_deg", m_curve_deviation_angle_deg},
{"sketch_shape_faint_style", m_sketch_shape_faint_style},
{"sketch_shape_faint_opacity", m_sketch_shape_faint_opacity},
{"sketch_shape_faint_enabled", m_sketch_shape_faint_enabled},
@@ -419,6 +425,9 @@ void GUI::parse_gui_panes_settings_(const std::string& content)
k_gui_sketch_edge_line_width_max, k_gui_sketch_edge_line_width_default);
parse_rgba4("shape_selection_color", m_shape_selection_color, k_gui_shape_selection_color_default);
+ m_curve_deviation_angle_deg =
+ parse_bounded_float("curve_deviation_angle_deg", k_gui_curve_deviation_angle_deg_min,
+ k_gui_curve_deviation_angle_deg_max, k_gui_curve_deviation_angle_deg_default);
if (g.contains("sketch_shape_faint_style") && g["sketch_shape_faint_style"].is_number_integer())
{
@@ -467,6 +476,8 @@ void GUI::parse_gui_panes_settings_(const std::string& content)
m_add_mid_pt_rect_edges = b("add_mid_pt_rect_edges", true);
m_add_mid_pt_slot_edges = b("add_mid_pt_slot_edges", false);
m_bone_add_center_nodes = b("bone_add_center_nodes", true);
+ m_bone_add_radius_nodes = b("bone_add_radius_nodes", true);
+ m_bone_add_total_length_nodes = b("bone_add_total_length_nodes", true);
m_bone_holes = Bone_holes::None;
if (g.contains("bone_holes") && g["bone_holes"].is_number_integer())
{
@@ -751,6 +762,7 @@ void GUI::load_occt_view_settings_()
{
apply_sketch_dimensions_visibility();
m_view->apply_shape_selection_style();
+ m_view->apply_curve_deviation();
m_view->sync_sketch_shape_faint_style();
}
@@ -1125,6 +1137,40 @@ void GUI::settings_()
m_view->apply_shape_selection_style();
save_occt_view_settings();
}
+
+ bool curve_dev_changed = false;
+ if (ImGui::BeginTable("settings_curve_deviation", 2, ImGuiTableFlags_SizingStretchProp))
+ {
+ ImGui::TableSetupColumn("label", ImGuiTableColumnFlags_WidthFixed, k_label_col_w);
+ ImGui::TableSetupColumn("control", ImGuiTableColumnFlags_WidthStretch);
+
+ ImGui::TableNextRow();
+ ImGui::TableSetColumnIndex(0);
+ ImGui::AlignTextToFramePadding();
+ ImGui::TextUnformatted("Curve segment angle");
+ ImGui::TableSetColumnIndex(1);
+ if (ImGui::SliderFloat("##curve_dev_ang", &m_curve_deviation_angle_deg, k_gui_curve_deviation_angle_deg_min,
+ k_gui_curve_deviation_angle_deg_max, "%.1f deg",
+ ImGuiSliderFlags_AlwaysClamp | ImGuiSliderFlags_ClampOnInput))
+ curve_dev_changed = true;
+
+ m_curve_deviation_angle_deg =
+ std::clamp(m_curve_deviation_angle_deg, k_gui_curve_deviation_angle_deg_min, k_gui_curve_deviation_angle_deg_max);
+
+ ImGui::SameLine(0.0f, ImGui::GetStyle().ItemInnerSpacing.x);
+ GUI_DOC_HELP_("Maximum angle between consecutive segments when Open CASCADE draws curves (sketch arcs, circles, "
+ "fillets). Smaller is smoother and uses more segments. Display only; stored geometry is unchanged. "
+ "Ctrl+click the slider to type a value.",
+ doc_urls::k_occt_view);
+
+ ImGui::EndTable();
+ }
+
+ if (curve_dev_changed)
+ {
+ m_view->apply_curve_deviation();
+ save_occt_view_settings();
+ }
}
if (ui_show_feature(3) && settings_collapsing_header_("3D view grid", m_settings_headers.grid))
@@ -2000,7 +2046,7 @@ void GUI::settings_()
{
m_hotkey_capture_error =
"Conflict: " + Gui_hotkeys::format_chord(Gui_hotkeys::default_chord(action)) + " is already assigned.";
- show_message(m_hotkey_capture_error);
+ show_message(m_hotkey_capture_error, Status_msg::Constraint);
}
else
{
@@ -2089,23 +2135,24 @@ void GUI::settings_()
}
}
- show_message("Default settings applied.");
+ show_message("Default settings applied.", Status_msg::Success);
if (m_view)
{
m_view->refresh_sketch_annotations(
{.length_dimensions = true, .permanent_node_marks = true, .edge_face_style = true});
m_view->apply_shape_selection_style();
+ m_view->apply_curve_deviation();
m_view->sync_sketch_shape_faint_style();
m_view->refresh_shape_list_hover_highlight();
}
}
catch (...)
{
- show_message("Failed to apply default settings.");
+ show_message("Failed to apply default settings.", Status_msg::Error);
}
}
else
- show_message("Failed to load default settings.");
+ show_message("Failed to load default settings.", Status_msg::Error);
}
ImGui::End();
diff --git a/src/scr_lua_console.cpp b/src/scr_lua_console.cpp
index 62706a40..93ab755f 100644
--- a/src/scr_lua_console.cpp
+++ b/src/scr_lua_console.cpp
@@ -82,13 +82,18 @@ int l_ezy_log(lua_State* L)
return 0;
}
-// ezy.msg(text) -> show_message
+// ezy.msg(text [, kind]) -> show_message
int l_ezy_msg(lua_State* L)
{
const char* text = luaL_checkstring(L, 1);
- GUI* gui = get_gui(L);
+ Status_msg kind = Status_msg::Info;
+ if (lua_gettop(L) >= 2 && !lua_isnoneornil(L, 2))
+ kind = parse_status_msg(luaL_checkstring(L, 2));
+
+ GUI* gui = get_gui(L);
if (gui)
- gui->show_message(text);
+ gui->show_message(text, kind);
+
return 0;
}
@@ -697,7 +702,7 @@ int l_ezy_help(lua_State* L)
if (!con)
return 0;
const char* help_text = "ezy (public scripting API):\n"
- " ezy.log(msg) / ezy.msg(text) / ezy.help()\n"
+ " ezy.log(msg) / ezy.msg(text [, kind]) / ezy.help()\n"
" ezy.get_mode() / ezy.set_mode(name)\n"
" ezy.save_occt_view_settings() / ezy.occt_view_settings_json()\n"
"ezy.view:\n"
diff --git a/src/scr_python_console.cpp b/src/scr_python_console.cpp
index 4b9d9b36..03b3ce07 100644
--- a/src/scr_python_console.cpp
+++ b/src/scr_python_console.cpp
@@ -271,8 +271,8 @@ def _ezycad_bootstrap():
self.Shp = _n.Shp
def log(self, msg):
return _n.ezy_log(msg)
- def msg(self, text):
- return _n.ezy_msg(text)
+ def msg(self, text, kind="info"):
+ return _n.ezy_msg(text, kind)
def get_mode(self):
return _n.ezy_get_mode()
def set_mode(self, name):
@@ -321,12 +321,13 @@ PYBIND11_EMBEDDED_MODULE(ezycad_native, m)
m.def(
"ezy_msg",
- [](const std::string& text)
+ [](const std::string& text, const std::string& kind)
{
if (g_py_gui)
- g_py_gui->show_message(text);
+ g_py_gui->show_message(text, parse_status_msg(kind));
},
- py::arg("text"));
+ py::arg("text"),
+ py::arg("kind") = "info");
m.def("ezy_get_mode",
[]
@@ -354,7 +355,7 @@ PYBIND11_EMBEDDED_MODULE(ezycad_native, m)
return;
const char* help_text = "ezy (public scripting API):\n"
- " ezy.log(msg) / ezy.msg(text) / ezy.help()\n"
+ " ezy.log(msg) / ezy.msg(text [, kind]) / ezy.help()\n"
" ezy.get_mode() / ezy.set_mode(name)\n"
" ezy.save_occt_view_settings() / ezy.occt_view_settings_json()\n"
"ezy.view:\n"
diff --git a/src/shp_set_frame.cpp b/src/shp_set_frame.cpp
index feb4ae07..1199ecad 100644
--- a/src/shp_set_frame.cpp
+++ b/src/shp_set_frame.cpp
@@ -111,7 +111,7 @@ Status Shp_set_frame::pick(const ScreenCoords& screen_coords)
gui().set_mode(Mode::Normal);
view().set_shape_frame(target, *frame);
target->set_show_frame_axes(true);
- gui().show_message("Local frame updated.");
+ gui().show_message("Local frame updated.", Status_msg::Success);
return Status::ok();
}
diff --git a/src/skt.cpp b/src/skt.cpp
index e1bb68b6..a3d0711c 100644
--- a/src/skt.cpp
+++ b/src/skt.cpp
@@ -12,6 +12,7 @@
#include
#include
#include
+#include
#include "utl_geom.h"
#include "skt_bone.h"
@@ -155,8 +156,13 @@ void Sketch::rebuild_faces() { update_faces_(); }
void Sketch::refresh_bone_preview() { m_tools.refresh_bone_preview(); }
+void Sketch::apply_bone_holes_option() { m_tools.apply_bone_holes_option(); }
+
+void Sketch::prompt_add_bone() { m_tools.bone_prompt_next_(); }
+
void Sketch::add_bone(const gp_Pnt2d& c1, const gp_Pnt2d& c2, double r1, double r2, double waist,
- bool add_center_nodes, std::optional hole_r1, std::optional hole_r2)
+ bool add_center_nodes, std::optional hole_r1, std::optional hole_r2,
+ bool add_radius_nodes, bool add_total_length_nodes)
{
Bone_params params;
params.c1 = c1;
@@ -181,13 +187,13 @@ void Sketch::add_bone(const gp_Pnt2d& c1, const gp_Pnt2d& c2, double r1, double
Sketch_op_recorder rec(m_view, *this);
{
- auto mark_center = [&](const gp_Pnt2d& c, const char* name)
+ auto mark_center = [&](const gp_Pnt2d& c, const char* name, bool split_edges = true)
{
const size_t idx = m_nodes.get_node_exact(c, true);
Sketch_nodes::Node& n = m_nodes[idx];
n.permanent = true;
n.name = name;
- rec.note_curr_node(idx);
+ rec.note_curr_node(idx, split_edges);
};
auto add_hole_circle = [&](const gp_Pnt2d& center, double radius)
@@ -204,6 +210,26 @@ void Sketch::add_bone(const gp_Pnt2d& c1, const gp_Pnt2d& c2, double r1, double
mark_center(g->c1, "Bone A");
mark_center(g->c2, "Bone B");
}
+
+ gp_Vec2d axis(g->c1, g->c2);
+ const double dist = axis.Magnitude();
+ if (dist > Precision::Confusion())
+ {
+ const gp_Vec2d n = gp_Vec2d(axis / dist).Rotated(std::numbers::pi / 2.0);
+ if (add_radius_nodes)
+ {
+ mark_center(gp_Pnt2d(g->c1).Translated(n * g->r1), "Bone A+", false);
+ mark_center(gp_Pnt2d(g->c1).Translated(-n * g->r1), "Bone A-", false);
+ mark_center(gp_Pnt2d(g->c2).Translated(n * g->r2), "Bone B+", false);
+ mark_center(gp_Pnt2d(g->c2).Translated(-n * g->r2), "Bone B-", false);
+ }
+
+ if (add_total_length_nodes)
+ {
+ mark_center(p.c1_outer, "Bone A tip", false);
+ mark_center(p.c2_outer, "Bone B tip", false);
+ }
+ }
add_arc_circle_(p.c1_minus, p.c1_outer, p.c1_plus, rec);
add_arc_circle_(p.c1_plus, p.waist_plus, p.c2_plus, rec);
add_arc_circle_(p.c2_plus, p.c2_outer, p.c2_minus, rec);
@@ -372,12 +398,13 @@ void Sketch::add_arc_circle_(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b, const g
const size_t node_idx_c = m_nodes.get_node_exact(pt_c);
const size_t node_idx_b = m_nodes.get_node_exact(pt_b);
- rec.note_curr_arc_edge(pt_a, pt_b, pt_c);
- rec.note_curr_node(node_idx_a);
- rec.note_curr_node(node_idx_c);
- rec.note_curr_node(node_idx_b);
-
- add_arc_circle_(std::vector{node_idx_a, node_idx_c, node_idx_b}, rec);
+ if (m_edges.add_arc_circle_edges({node_idx_a, node_idx_c, node_idx_b}, &rec))
+ {
+ // Stored pieces are noted in add_arc_circle_edges (leftovers, not the input triple).
+ rec.note_curr_node(node_idx_a);
+ rec.note_curr_node(node_idx_c);
+ rec.note_curr_node(node_idx_b);
+ }
}
void Sketch::add_arc_circle_(const std::vector& node_idxs) { m_edges.add_arc_circle_edges(node_idxs, nullptr); }
@@ -393,6 +420,8 @@ void Sketch::on_mode()
cancel_elm();
sync_operation_axis_display_();
m_node_marks.sync();
+ if (get_mode() == Mode::Sketch_add_bone && m_view.curr_sketch_shared().get() == this)
+ m_tools.bone_prompt_next_();
}
Mode Sketch::get_mode() const { return m_view.get_mode(); }
diff --git a/src/skt.h b/src/skt.h
index ef6bbf9c..08daa545 100644
--- a/src/skt.h
+++ b/src/skt.h
@@ -169,14 +169,21 @@ class Sketch
void rebuild_faces();
/// Add a bone outline (outer end arcs and inner waist arcs).
/// Waist cutters are tangent to both end circles; cut radius is solved from \\a waist.
- /// Optional permanent **Bone A** / **Bone B** centers; optional hole circles (radii must be
+ /// Optional permanent **Bone A** / **Bone B** centers, axis-tangent radius nodes
+ /// (**Bone A+** / **A-**, **Bone B+** / **B-**), and outer-tip nodes for overall length
+ /// (**Bone A tip** / **Bone B tip**). Optional hole circles (radii must be
/// positive and smaller than the matching end radii).
void add_bone(const gp_Pnt2d& c1, const gp_Pnt2d& c2, double r1, double r2, double waist,
bool add_center_nodes = true, std::optional hole_r1 = std::nullopt,
- std::optional hole_r2 = std::nullopt);
+ std::optional hole_r2 = std::nullopt, bool add_radius_nodes = true,
+ bool add_total_length_nodes = true);
/// Rebuild Add-bone AIS preview (outline + DEV_MODE construction overlays).
void refresh_bone_preview();
+ /// Apply Options Holes (commit outline when waist is set and Holes is None).
+ void apply_bone_holes_option();
+ /// Info toast for the next Add-bone click.
+ void prompt_add_bone();
private:
friend class Sketch_json;
diff --git a/src/skt_display.cpp b/src/skt_display.cpp
index 27638527..b53a6a2c 100644
--- a/src/skt_display.cpp
+++ b/src/skt_display.cpp
@@ -16,6 +16,7 @@
#include "gui.h"
#include "gui_occt_view.h"
#include "utl.h"
+#include "utl_geom.h"
namespace
{
@@ -28,8 +29,9 @@ constexpr float k_edge_highlight_line_width = 2.0f;
Quantity_Color rgb_from_rgba_(const float* rgba);
float transparency_from_rgba_(const float* rgba);
void apply_rgba_style_(AIS_Shape& shp, const float* rgba, float line_width);
-Prs3d_Drawer_ptr make_edge_hilight_drawer_(const float* rgba, float line_width);
-Prs3d_Drawer_ptr make_face_hilight_drawer_(const float* rgba);
+void apply_drawer_curve_angle_(const Prs3d_Drawer_ptr& drawer, const GUI& gui);
+Prs3d_Drawer_ptr make_edge_hilight_drawer_(const GUI& gui, const float* rgba, float line_width);
+Prs3d_Drawer_ptr make_face_hilight_drawer_(const GUI& gui, const float* rgba);
void apply_edge_hilight_(AIS_Shape& shp, const GUI& gui);
void apply_face_hilight_(AIS_Shape& shp, const GUI& gui);
} // namespace
@@ -279,6 +281,17 @@ void Sketch::set_edge_style(Edge_style style)
update_all_face_styles_();
update_originating_face_style();
+
+ // Face remesh writes PolygonOnTriangulation onto shared TEdges; strip it so the
+ // curve-angle slider (StdPrs_DeflectionCurve) is what you see, not the face mesh.
+ for (Edge& e : m_edges.edges())
+ {
+ if (e.shp.IsNull())
+ continue;
+
+ m_view.apply_curve_deviation_to_shape(*e.shp);
+ m_ctx.Redisplay(e.shp, false);
+ }
}
namespace
@@ -291,6 +304,15 @@ Quantity_Color rgb_from_rgba_(const float* rgba)
float transparency_from_rgba_(const float* rgba) { return std::clamp(1.f - rgba[3], 0.f, 1.f); }
+void apply_drawer_curve_angle_(const Prs3d_Drawer_ptr& drawer, const GUI& gui)
+{
+ if (drawer.IsNull())
+ return;
+
+ drawer->SetDeviationAngle(to_radians(static_cast(gui.curve_deviation_angle_deg())));
+ drawer->SetDeviationCoefficient(0.001);
+}
+
void apply_rgba_style_(AIS_Shape& shp, const float* rgba, float line_width)
{
shp.SetWidth(static_cast(line_width));
@@ -298,9 +320,10 @@ void apply_rgba_style_(AIS_Shape& shp, const float* rgba, float line_width)
shp.SetTransparency(static_cast(transparency_from_rgba_(rgba)));
}
-Prs3d_Drawer_ptr make_edge_hilight_drawer_(const float* rgba, float line_width)
+Prs3d_Drawer_ptr make_edge_hilight_drawer_(const GUI& gui, const float* rgba, float line_width)
{
Prs3d_Drawer_ptr drawer = new Prs3d_Drawer();
+ apply_drawer_curve_angle_(drawer, gui);
const Quantity_Color qc = rgb_from_rgba_(rgba);
drawer->SetColor(qc);
drawer->SetTransparency(transparency_from_rgba_(rgba));
@@ -312,10 +335,11 @@ Prs3d_Drawer_ptr make_edge_hilight_drawer_(const float* rgba, float line_width)
return drawer;
}
-Prs3d_Drawer_ptr make_face_hilight_drawer_(const float* rgba)
+Prs3d_Drawer_ptr make_face_hilight_drawer_(const GUI& gui, const float* rgba)
{
Prs3d_Drawer_ptr drawer = new Prs3d_Drawer();
drawer->SetupOwnDefaults();
+ apply_drawer_curve_angle_(drawer, gui);
const Quantity_Color qc = rgb_from_rgba_(rgba);
const float transp = transparency_from_rgba_(rgba);
drawer->SetColor(qc);
@@ -341,19 +365,27 @@ Prs3d_Drawer_ptr make_face_hilight_drawer_(const float* rgba)
void apply_edge_hilight_(AIS_Shape& shp, const GUI& gui)
{
- Prs3d_Drawer_ptr selected = make_edge_hilight_drawer_(gui.sketch_edge_selection_color_rgba(), k_edge_highlight_line_width);
- Prs3d_Drawer_ptr hover = make_edge_hilight_drawer_(gui.sketch_edge_highlight_color_rgba(), k_edge_highlight_line_width);
+ Prs3d_Drawer_ptr selected =
+ make_edge_hilight_drawer_(gui, gui.sketch_edge_selection_color_rgba(), k_edge_highlight_line_width);
+ Prs3d_Drawer_ptr hover =
+ make_edge_hilight_drawer_(gui, gui.sketch_edge_highlight_color_rgba(), k_edge_highlight_line_width);
shp.SetHilightAttributes(selected);
shp.SetDynamicHilightAttributes(hover);
+ apply_drawer_curve_angle_(shp.Attributes(), gui);
+ if (const Occt_view* view = gui.get_view())
+ view->apply_curve_deviation_to_shape(shp);
}
void apply_face_hilight_(AIS_Shape& shp, const GUI& gui)
{
// Use shaded hilight so selection/hover tint the face fill, not only a wire outline.
shp.SetHilightMode(AIS_Shaded);
- Prs3d_Drawer_ptr selected = make_face_hilight_drawer_(gui.sketch_face_selection_color_rgba());
- Prs3d_Drawer_ptr hover = make_face_hilight_drawer_(gui.sketch_face_highlight_color_rgba());
+ Prs3d_Drawer_ptr selected = make_face_hilight_drawer_(gui, gui.sketch_face_selection_color_rgba());
+ Prs3d_Drawer_ptr hover = make_face_hilight_drawer_(gui, gui.sketch_face_highlight_color_rgba());
shp.SetHilightAttributes(selected);
shp.SetDynamicHilightAttributes(hover);
+ apply_drawer_curve_angle_(shp.Attributes(), gui);
+ if (const Occt_view* view = gui.get_view())
+ view->apply_curve_deviation_to_shape(shp);
}
} // namespace
diff --git a/src/skt_edges.cpp b/src/skt_edges.cpp
index 8e7ebbc5..c66e2de0 100644
--- a/src/skt_edges.cpp
+++ b/src/skt_edges.cpp
@@ -7,6 +7,8 @@
#include
#include
#include
+#include
+#include
#include
#include "gui_occt_view.h"
@@ -52,7 +54,6 @@ void Sketch_edges::add_edge(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b) { add_ed
void Sketch_edges::add_edge(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b, Sketch_op_recorder& rec)
{
- rec.note_curr_linear_edge(pt_a, pt_b);
add_edge_impl_(pt_a, pt_b, &rec);
}
@@ -71,7 +72,12 @@ void Sketch_edges::add_edge_impl_(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b, Sk
gp_Pnt2d qa = m_sketch.m_nodes[e.node_idx_a];
gp_Pnt2d qb = m_sketch.m_nodes[e.node_idx_b];
- if (auto inter = segment_intersection_2d(pt_a, pt_b, qa, qb, Segment_inclusion::Closed))
+ if (same_line_support_2d(pt_a, pt_b, qa, qb))
+ {
+ for (const gp_Pnt2d& ip : collinear_overlap_cut_points_2d(pt_a, pt_b, qa, qb))
+ add_unique_point(inters, ip);
+ }
+ else if (auto inter = segment_intersection_2d(pt_a, pt_b, qa, qb, Segment_inclusion::Closed))
add_unique_point(inters, *inter);
}
else if (sketch_edge_is_arc(e))
@@ -147,11 +153,31 @@ void Sketch_edges::add_edge_impl_(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b, Sk
m_sketch.m_topo.split_arcs_at_node_if_interior(nidx, rec);
}
+ std::vector unsplit;
+ bool added_any = false;
for (size_t i = 0; i + 1 < div_node_idxs.size(); ++i)
{
- const gp_Pnt2d& pa = m_sketch.m_nodes[div_node_idxs[i]];
- const gp_Pnt2d& pb = m_sketch.m_nodes[div_node_idxs[i + 1]];
+ const gp_Pnt2d pa = m_sketch.m_nodes[div_node_idxs[i]];
+ const gp_Pnt2d pb = m_sketch.m_nodes[div_node_idxs[i + 1]];
+ if (const Sketch_edge* existing = find_linear_edge_(pa, pb))
+ {
+ unsplit.push_back(existing);
+ continue;
+ }
+
add_edge_raw_(pa, pb);
+ added_any = true;
+ }
+
+ // Unsplit originals on the new span are not in prev from a split. Undo removes every
+ // linear edge on curr (the full new span), so record those pieces when curr is recorded.
+ // A no-op add (already covered) must not write prev alone or commit() pushes a dead undo.
+ if (rec && (added_any || !inters_to_split.empty()))
+ {
+ for (const Sketch_edge* existing : unsplit)
+ rec->note_prev_linear_edge(existing->node_idx_a, *existing->node_idx_b, existing->node_idx_mid, existing->name);
+
+ rec->note_curr_linear_edge(pt_a, pt_b);
}
}
@@ -184,14 +210,14 @@ void Sketch_edges::update_end_pt(Sketch_edge& edge, size_t end_pt_idx)
edge.node_idx_mid = std::nullopt;
}
-void Sketch_edges::add_arc_circle_edges(const std::vector& node_idxs, Sketch_op_recorder* rec)
+bool Sketch_edges::add_arc_circle_edges(const std::vector& node_idxs, Sketch_op_recorder* rec)
{
EZY_ASSERT(node_idxs.size() == 3);
// node_idxs[2] is the user pick (may be toward the circle center); it defines the arc only.
Geom_TrimmedCurve_ptr arc_of_circle =
GC_MakeArcOfCircle(m_sketch.to_3d_(node_idxs[0]), m_sketch.to_3d_(node_idxs[2]), m_sketch.to_3d_(node_idxs[1]));
if (!arc_of_circle)
- return;
+ return false;
const TopoDS_Edge new_edge = BRepBuilderAPI_MakeEdge(arc_of_circle).Edge();
const gp_Pnt2d pt_start = m_sketch.m_nodes[node_idxs[0]];
@@ -216,8 +242,28 @@ void Sketch_edges::add_arc_circle_edges(const std::vector& node_idxs, Sk
}
else if (sketch_edge_is_arc(e))
{
- for (const gp_Pnt2d& ip : arc_arc_intersections_2d(TopoDS::Edge(e.shp->Shape()), new_edge, m_sketch.m_pln))
- add_unique_point(inters, ip);
+ const TopoDS_Edge old_arc = TopoDS::Edge(e.shp->Shape());
+ if (same_circle_support_2d(old_arc, new_edge, m_sketch.m_pln))
+ {
+ const gp_Pnt2d ea = m_sketch.m_nodes[e.node_idx_a];
+ const gp_Pnt2d eb = m_sketch.m_nodes[*e.node_idx_b];
+ if (point_on_open_arc_interior_2d(ea, new_edge, m_sketch.m_pln))
+ add_unique_point(inters, ea);
+
+ if (point_on_open_arc_interior_2d(eb, new_edge, m_sketch.m_pln))
+ add_unique_point(inters, eb);
+
+ if (point_on_open_arc_interior_2d(pt_start, old_arc, m_sketch.m_pln))
+ add_unique_point(inters, pt_start);
+
+ if (point_on_open_arc_interior_2d(pt_end, old_arc, m_sketch.m_pln))
+ add_unique_point(inters, pt_end);
+ }
+ else
+ {
+ for (const gp_Pnt2d& ip : arc_arc_intersections_2d(old_arc, new_edge, m_sketch.m_pln))
+ add_unique_point(inters, ip);
+ }
}
}
@@ -226,7 +272,6 @@ void Sketch_edges::add_arc_circle_edges(const std::vector& node_idxs, Sk
{
bool split_here = false;
for (const Sketch_edge& e : m_edges)
- {
if (is_linear(e))
{
const gp_Pnt2d qa = m_sketch.m_nodes[e.node_idx_a];
@@ -245,7 +290,7 @@ void Sketch_edges::add_arc_circle_edges(const std::vector& node_idxs, Sk
break;
}
}
- }
+
if (split_here)
add_unique_point(inters_to_split, ip);
}
@@ -260,28 +305,79 @@ void Sketch_edges::add_arc_circle_edges(const std::vector& node_idxs, Sk
m_sketch.m_topo.split_arcs_at_node_if_interior(nidx, rec);
}
- std::optional arc_pt_idx;
- if (Sketch::get_add_mid_pt_edges())
- arc_pt_idx = m_sketch.m_nodes.add_new_node(arc_curve_midpoint_2d(new_edge, m_sketch.m_pln), true);
+ const BRepAdaptor_Curve curve(new_edge);
+ const Geom_Curve_ptr geom = curve.Curve().Curve();
+ const double u_first = curve.FirstParameter();
+ const double u_last = curve.LastParameter();
- Sketch_AIS_edge_ptr shp = new Sketch_AIS_edge(m_sketch, new_edge);
- m_sketch.update_edge_style_(shp);
- m_sketch.m_ctx.Display(shp, false);
+ auto param_of = [&](const gp_Pnt2d& p) -> std::optional
+ {
+ GeomAPI_ProjectPointOnCurve proj(to_3d(m_sketch.m_pln, p), geom, u_first, u_last);
+ if (proj.NbPoints() == 0 || proj.LowerDistance() > Precision::Confusion())
+ return std::nullopt;
- // One graph edge per arc; endpoints are start and end only.
- m_edges.push_back({node_idxs[0], node_idxs[1], arc_pt_idx, std::nullopt, shp});
+ return proj.LowerDistanceParameter();
+ };
+ std::vector cuts{u_first, u_last};
for (const gp_Pnt2d& ip : inters)
{
- if (!point_on_open_arc_interior_2d(ip, new_edge, m_sketch.m_pln))
- continue;
+ if (const std::optional u = param_of(ip))
+ cuts.push_back(*u);
+ }
- const size_t nidx = m_sketch.m_nodes.get_node_exact(ip);
+ std::sort(cuts.begin(), cuts.end());
+ const double span = u_last - u_first;
+ const double margin = std::max(Precision::Confusion(), std::abs(span) * 1e-9);
+ std::vector unique_cuts;
+ for (double u : cuts)
+ {
+ if (unique_cuts.empty() || std::abs(u - unique_cuts.back()) > margin)
+ unique_cuts.push_back(u);
+ }
+
+ auto add_stored_arc_ = [&](size_t idx_a, size_t idx_b, size_t bulge_idx)
+ {
+ add_arc_raw_(idx_a, idx_b, bulge_idx);
if (rec)
- rec->note_curr_node(nidx);
+ {
+ rec->note_curr_arc_edge(m_sketch.m_nodes[idx_a], m_sketch.m_nodes[bulge_idx], m_sketch.m_nodes[idx_b]);
+ rec->note_curr_node(idx_a);
+ rec->note_curr_node(bulge_idx);
+ rec->note_curr_node(idx_b);
+ }
+ };
- m_sketch.m_topo.split_arcs_at_node_if_interior(nidx, rec);
+ bool added_any = false;
+ if (unique_cuts.size() == 2)
+ {
+ const gp_Pnt2d bulge = m_sketch.m_nodes[node_idxs[2]];
+ if (!has_equivalent_arc_(pt_start, pt_end, bulge))
+ {
+ add_stored_arc_(node_idxs[0], node_idxs[1], node_idxs[2]);
+ added_any = true;
+ }
}
+ else
+ {
+ for (size_t i = 0; i + 1 < unique_cuts.size(); ++i)
+ {
+ if (unique_cuts[i + 1] - unique_cuts[i] <= margin)
+ continue;
+
+ const gp_Pnt2d p0 = to_2d(m_sketch.m_pln, curve.Value(unique_cuts[i]));
+ const gp_Pnt2d p1 = to_2d(m_sketch.m_pln, curve.Value(unique_cuts[i + 1]));
+ const gp_Pnt2d bulge = to_2d(m_sketch.m_pln, curve.Value(0.5 * (unique_cuts[i] + unique_cuts[i + 1])));
+ if (has_equivalent_arc_(p0, p1, bulge))
+ continue;
+
+ add_stored_arc_(m_sketch.m_nodes.get_node_exact(p0), m_sketch.m_nodes.get_node_exact(p1),
+ m_sketch.m_nodes.get_node_exact(bulge));
+ added_any = true;
+ }
+ }
+
+ return added_any || !inters_to_split.empty();
}
void Sketch_edges::split_arc_at_node_(std::list::iterator itr, size_t split_idx, Sketch_op_recorder* rec)
@@ -326,8 +422,8 @@ void Sketch_edges::split_arc_at_node_(std::list::iterator itr, size
const size_t bulge1_idx = m_sketch.m_nodes.get_node_exact(bulge1);
const size_t bulge2_idx = m_sketch.m_nodes.get_node_exact(bulge2);
- add_arc_circle_edges({idx_a, split_idx, bulge1_idx});
- add_arc_circle_edges({split_idx, idx_b, bulge2_idx});
+ add_arc_raw_(idx_a, split_idx, bulge1_idx);
+ add_arc_raw_(split_idx, idx_b, bulge2_idx);
if (rec)
{
@@ -337,6 +433,72 @@ void Sketch_edges::split_arc_at_node_(std::list::iterator itr, size
}
}
+void Sketch_edges::add_arc_raw_(size_t idx_a, size_t idx_b, size_t bulge_idx)
+{
+ Geom_TrimmedCurve_ptr arc =
+ GC_MakeArcOfCircle(m_sketch.to_3d_(idx_a), m_sketch.to_3d_(bulge_idx), m_sketch.to_3d_(idx_b));
+ if (!arc)
+ return;
+
+ const TopoDS_Edge occ = BRepBuilderAPI_MakeEdge(arc).Edge();
+ std::optional arc_pt_idx;
+ if (Sketch::get_add_mid_pt_edges())
+ arc_pt_idx = m_sketch.m_nodes.add_new_node(arc_curve_midpoint_2d(occ, m_sketch.m_pln), true);
+
+ Sketch_AIS_edge_ptr shp = new Sketch_AIS_edge(m_sketch, occ);
+ m_sketch.update_edge_style_(shp);
+ m_sketch.m_ctx.Display(shp, false);
+ m_edges.push_back({idx_a, idx_b, arc_pt_idx, std::nullopt, shp});
+}
+
+const Sketch_edge* Sketch_edges::find_linear_edge_(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b) const
+{
+ const double tol = Precision::Confusion();
+ for (const Sketch_edge& e : m_edges)
+ {
+ if (!is_linear(e) || !e.node_idx_b.has_value())
+ continue;
+
+ const gp_Pnt2d ea = m_sketch.m_nodes[e.node_idx_a];
+ const gp_Pnt2d eb = m_sketch.m_nodes[*e.node_idx_b];
+ if ((ea.Distance(pt_a) <= tol && eb.Distance(pt_b) <= tol) || (ea.Distance(pt_b) <= tol && eb.Distance(pt_a) <= tol))
+ return &e;
+ }
+
+ return nullptr;
+}
+
+bool Sketch_edges::has_linear_edge_(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b) const
+{
+ return find_linear_edge_(pt_a, pt_b) != nullptr;
+}
+
+bool Sketch_edges::has_equivalent_arc_(const gp_Pnt2d& start, const gp_Pnt2d& end, const gp_Pnt2d& bulge) const
+{
+ const double tol = Precision::Confusion();
+ for (const Sketch_edge& e : m_edges)
+ {
+ if (!sketch_edge_is_arc(e) || !e.node_idx_b.has_value() || e.shp.IsNull())
+ continue;
+
+ const gp_Pnt2d ea = m_sketch.m_nodes[e.node_idx_a];
+ const gp_Pnt2d eb = m_sketch.m_nodes[*e.node_idx_b];
+ const bool ends_fwd = ea.Distance(start) <= tol && eb.Distance(end) <= tol;
+ const bool ends_rev = ea.Distance(end) <= tol && eb.Distance(start) <= tol;
+ if (!ends_fwd && !ends_rev)
+ continue;
+
+ const TopoDS_Edge occ = TopoDS::Edge(e.shp->Shape());
+ if (point_on_open_arc_interior_2d(bulge, occ, m_sketch.m_pln))
+ return true;
+
+ if (arc_curve_midpoint_2d(occ, m_sketch.m_pln).Distance(bulge) <= tol)
+ return true;
+ }
+
+ return false;
+}
+
void Sketch_edges::for_each_linear(const Linear_visitor& fn) const
{
for (const Sketch_edge& e : m_edges)
diff --git a/src/skt_edges.h b/src/skt_edges.h
index 2a9d470e..dda40ce5 100644
--- a/src/skt_edges.h
+++ b/src/skt_edges.h
@@ -23,7 +23,9 @@ class Sketch_edges
void sketch_json_add_linear_edge(size_t idx_a, size_t idx_b, std::optional idx_mid);
void update_end_pt(Sketch_edge& edge, size_t end_pt_idx);
- void add_arc_circle_edges(const std::vector& node_idxs, Sketch_op_recorder* rec = nullptr);
+ /// Adds the arc, splitting existing edges at crossings and merging same-circle overlap.
+ /// Returns true when the graph changed (new pieces or existing splits).
+ bool add_arc_circle_edges(const std::vector& node_idxs, Sketch_op_recorder* rec = nullptr);
void remove_by_ais(const Sketch_AIS_edge& to_remove);
void remove_displayed();
@@ -51,7 +53,11 @@ class Sketch_edges
void add_edge_raw_(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b);
void add_edge_impl_(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b, Sketch_op_recorder* rec);
+ void add_arc_raw_(size_t idx_a, size_t idx_b, size_t bulge_idx);
void split_arc_at_node_(std::list::iterator itr, size_t split_idx, Sketch_op_recorder* rec);
+ [[nodiscard]] const Sketch_edge* find_linear_edge_(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b) const;
+ [[nodiscard]] bool has_linear_edge_(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b) const;
+ [[nodiscard]] bool has_equivalent_arc_(const gp_Pnt2d& start, const gp_Pnt2d& end, const gp_Pnt2d& bulge) const;
Sketch& m_sketch;
std::list m_edges;
diff --git a/src/skt_nodes.cpp b/src/skt_nodes.cpp
index 1f1df5db..b1ff176f 100644
--- a/src/skt_nodes.cpp
+++ b/src/skt_nodes.cpp
@@ -73,6 +73,8 @@ class Sketch_nodes::Impl
void clear_outside_snap_pnts();
void add_outside_snap_pnt(const gp_Pnt& pt3d);
+ void clear_session_snap_pnts();
+ void add_session_snap_pnt(const gp_Pnt2d& pt);
void set_origin_snap_enabled(bool enabled) { m_origin_snap_enabled = enabled; }
[[nodiscard]] bool origin_snap_enabled() const { return m_origin_snap_enabled; }
@@ -81,6 +83,7 @@ class Sketch_nodes::Impl
Sketch_nodes* m_owner;
std::vector m_nodes;
std::set m_outside_snap_pts; // Projected snap points from other sketches.
+ std::set m_session_snap_pts; // Active-tool extras (e.g. bone radius).
AIS_Shape_ptr m_snap_anno_axis[2];
AIS_Shape_ptr m_snap_anno_axis_fs[2];
std::optional m_last_snap_pt; // Used for snap annotation
@@ -398,6 +401,9 @@ void Sketch_nodes::Impl::update_global_coaxial_annotations_(double snap_dist)
for (const auto& p : m_outside_snap_pts)
all_pts.push_back(p);
+ for (const auto& p : m_session_snap_pts)
+ all_pts.push_back(p);
+
if (all_pts.empty())
return;
@@ -516,6 +522,17 @@ std::optional Sketch_nodes::Impl::try_get_node_idx_snap(
m_owner->hide_snap_annos();
gp_Pnt2d pt_original = pt;
+ const double session_hit = snap_dist * 0.25 * snap_dist;
+ for (const gp_Pnt2d& sp : m_session_snap_pts)
+ {
+ if (pt_original.SquareDistance(sp) <= session_hit)
+ {
+ pt = sp;
+ update_node_snap_anno_(pt, sqrt(snap_dist));
+ m_ctx.UpdateCurrentViewer();
+ return {};
+ }
+ }
std::optional snap_node_idx[2];
for (int axis_idx = 0; axis_idx < 2; ++axis_idx)
{
@@ -568,6 +585,9 @@ std::optional Sketch_nodes::Impl::try_get_node_idx_snap(
for (const gp_Pnt2d& nd_pt : m_outside_snap_pts)
try_nd_pt(nd_pt);
+ for (const gp_Pnt2d& nd_pt : m_session_snap_pts)
+ try_nd_pt(nd_pt);
+
// If the "annotate all coaxial" option is on we keep the full list (dedup later if wanted).
// Otherwise we only annotate the single closest one that drove the snap.
if (!s_annotate_all_coaxial_nodes && snap_axis_point)
@@ -628,6 +648,13 @@ std::optional Sketch_nodes::Impl::try_get_node_idx_snap(
matches.push_back(p);
}
+ for (const auto& p : m_session_snap_pts)
+ {
+ double axis_diff = std::fabs(guide_val - p.XY().Coord(axis_idx + 1));
+ if (axis_diff <= Precision::Confusion())
+ matches.push_back(p);
+ }
+
if (!matches.empty())
{
// dedup by position
@@ -724,6 +751,10 @@ void Sketch_nodes::Impl::clear_outside_snap_pnts() { m_outside_snap_pts.clear();
void Sketch_nodes::Impl::add_outside_snap_pnt(const gp_Pnt& pt3d) { m_outside_snap_pts.insert(to_2d(m_pln, pt3d)); }
+void Sketch_nodes::Impl::clear_session_snap_pnts() { m_session_snap_pts.clear(); }
+
+void Sketch_nodes::Impl::add_session_snap_pnt(const gp_Pnt2d& pt) { m_session_snap_pts.insert(pt); }
+
Sketch_nodes::Sketch_nodes(Occt_view& view, const gp_Pln& pln)
: m_impl(std::make_unique(this, view, pln))
{
@@ -817,6 +848,10 @@ void Sketch_nodes::clear_outside_snap_pnts() { m_impl->clear_outside_snap_pnts()
void Sketch_nodes::add_outside_snap_pnt(const gp_Pnt& pt3d) { m_impl->add_outside_snap_pnt(pt3d); }
+void Sketch_nodes::clear_session_snap_pnts() { m_impl->clear_session_snap_pnts(); }
+
+void Sketch_nodes::add_session_snap_pnt(const gp_Pnt2d& pt) { m_impl->add_session_snap_pnt(pt); }
+
// === Snap settings =========================================================
void Sketch_nodes::set_snap_dist(double snap_dist_pixels) { s_snap_dist_pixels = snap_dist_pixels; }
diff --git a/src/skt_nodes.h b/src/skt_nodes.h
index 3df1dff9..d0b927f5 100644
--- a/src/skt_nodes.h
+++ b/src/skt_nodes.h
@@ -76,6 +76,9 @@ class Sketch_nodes
void clear_outside_snap_pnts();
void add_outside_snap_pnt(const gp_Pnt& pt3d);
+ /// Tool-session snap targets (not cleared when other-sketch snaps are rebuilt).
+ void clear_session_snap_pnts();
+ void add_session_snap_pnt(const gp_Pnt2d& pt);
// Snap distance related
static void set_snap_dist(double snap_dist_pixels);
diff --git a/src/skt_op_recorder.cpp b/src/skt_op_recorder.cpp
index 84c1a3da..972a5696 100644
--- a/src/skt_op_recorder.cpp
+++ b/src/skt_op_recorder.cpp
@@ -59,6 +59,7 @@ struct Sketch_op_data
gp_Pnt2d pt;
bool permanent{false};
std::string name;
+ bool split_edges{true};
};
Sketch* m_sketch{nullptr};
@@ -148,7 +149,7 @@ class Sketch_op_recorder::Impl
void note_curr_linear_edge(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b);
void note_prev_arc_edge(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b, const gp_Pnt2d& pt_c);
void note_curr_arc_edge(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b, const gp_Pnt2d& pt_c);
- void note_curr_node(size_t node_idx);
+ void note_curr_node(size_t node_idx, bool split_edges = true);
void note_prev_length_dim(size_t lo, size_t hi, bool visible, std::optional flyout, const std::string& name);
void note_curr_length_dim(size_t lo, size_t hi, bool visible, std::optional flyout, const std::string& name);
void note_prev_operation_axis(size_t node_idx_a, size_t node_idx_b);
@@ -194,7 +195,7 @@ void Sketch_op_recorder::note_curr_arc_edge(const gp_Pnt2d& pt_a, const gp_Pnt2d
m_impl->note_curr_arc_edge(pt_a, pt_b, pt_c);
}
-void Sketch_op_recorder::note_curr_node(size_t node_idx) { m_impl->note_curr_node(node_idx); }
+void Sketch_op_recorder::note_curr_node(size_t node_idx, bool split_edges) { m_impl->note_curr_node(node_idx, split_edges); }
void Sketch_op_recorder::note_prev_length_dim(size_t lo, size_t hi, bool visible, std::optional flyout,
const std::string& name)
@@ -308,7 +309,7 @@ void Sketch_op_recorder::Impl::note_curr_arc_edge(const gp_Pnt2d& pt_a, const gp
m_data.curr_arc_edges.push_back(rec);
}
-void Sketch_op_recorder::Impl::note_curr_node(size_t node_idx)
+void Sketch_op_recorder::Impl::note_curr_node(size_t node_idx, bool split_edges)
{
if (!m_active)
return;
@@ -333,7 +334,7 @@ void Sketch_op_recorder::Impl::note_curr_node(size_t node_idx)
if (pts_equal_(x.pt, pt))
return;
- m_data.curr_nodes.push_back({pt, now_permanent, m_sketch.m_nodes[node_idx].name});
+ m_data.curr_nodes.push_back({pt, now_permanent, m_sketch.m_nodes[node_idx].name, split_edges});
}
void Sketch_op_recorder::Impl::note_prev_length_dim(size_t lo, size_t hi, bool visible, std::optional flyout,
@@ -635,7 +636,7 @@ void Sketch_op_data::restore_curr_node_at_pt_(Sketch& sketch, const Curr_node_re
if (!rec.name.empty())
sketch.m_nodes[node_idx].name = rec.name;
- if (is_arc_bulge)
+ if (is_arc_bulge || !rec.split_edges)
return;
sketch.m_topo.split_linear_edges_at_node_if_interior(node_idx);
diff --git a/src/skt_op_recorder.h b/src/skt_op_recorder.h
index 17f3a8ad..c33d8d9b 100644
--- a/src/skt_op_recorder.h
+++ b/src/skt_op_recorder.h
@@ -25,7 +25,9 @@ class Sketch_op_recorder
void note_curr_linear_edge(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b);
void note_prev_arc_edge(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b, const gp_Pnt2d& pt_c);
void note_curr_arc_edge(const gp_Pnt2d& pt_a, const gp_Pnt2d& pt_b, const gp_Pnt2d& pt_c);
- void note_curr_node(size_t node_idx);
+ /// When \\a split_edges is false, redo restores the node without splitting edges at it
+ /// (snap-only markers that happen to lie on existing arcs).
+ void note_curr_node(size_t node_idx, bool split_edges = true);
void note_prev_length_dim(size_t lo, size_t hi, bool visible, std::optional flyout, const std::string& name);
void note_curr_length_dim(size_t lo, size_t hi, bool visible, std::optional flyout, const std::string& name);
void note_prev_operation_axis(size_t node_idx_a, size_t node_idx_b);
diff --git a/src/skt_operations.cpp b/src/skt_operations.cpp
index a3ee440c..ac138fed 100644
--- a/src/skt_operations.cpp
+++ b/src/skt_operations.cpp
@@ -49,7 +49,7 @@ void Sketch::mirror_selected_edges()
if (mirror_edges.empty())
{
rec.cancel();
- m_view.gui().show_message(ERROR_NO_EDGES_SELECTED);
+ m_view.gui().show_message(ERROR_NO_EDGES_SELECTED, Status_msg::Constraint);
return;
}
diff --git a/src/skt_tools.cpp b/src/skt_tools.cpp
index e5fc02a1..5b682074 100644
--- a/src/skt_tools.cpp
+++ b/src/skt_tools.cpp
@@ -166,7 +166,15 @@ void Sketch_tools::finalize()
}
}
-bool Sketch_tools::cancel() { return clear_tmps(); }
+bool Sketch_tools::cancel()
+{
+ const bool bone = m_sketch.get_mode() == Mode::Sketch_add_bone;
+ const bool canceled = clear_tmps();
+ if (bone && canceled)
+ m_sketch.m_view.gui().show_message("Add bone canceled.", Status_msg::Info);
+
+ return canceled;
+}
bool Sketch_tools::edge_from_center_active() const
{
@@ -435,141 +443,26 @@ void Sketch_tools::finalize_edges_(Sketch_op_recorder& rec)
if (m_tmp_edges.empty())
return;
- // Record user-intent edges before any split/append side effects.
- for (const Sketch_edge& te : m_tmp_edges)
- {
- EZY_ASSERT(te.node_idx_b.has_value());
- rec.note_curr_linear_edge(m_sketch.m_nodes[te.node_idx_a], m_sketch.m_nodes[*te.node_idx_b]);
- }
-
- std::vector split_mid_points;
- for (Sketch_edge& e : m_tmp_edges)
+ struct Seg
{
- EZY_ASSERT(e.node_idx_b.has_value());
- if (m_sketch.m_nodes[e.node_idx_a].midpoint)
- split_mid_points.push_back(e.node_idx_a);
-
- if (m_sketch.m_nodes[e.node_idx_b].midpoint)
- split_mid_points.push_back(*e.node_idx_b);
- }
+ gp_Pnt2d a;
+ gp_Pnt2d b;
+ };
- // Split any *existing* (pre this batch) linear edges that intersect or touch the
- // new segments being committed (the tmp ones). This fulfills the requirement that
- // adding edges from the GUI splits existing intersecting/touching edges.
- std::vector batch_inters;
+ std::vector segs;
+ segs.reserve(m_tmp_edges.size());
+ for (const Sketch_edge& te : m_tmp_edges)
{
- for (const Sketch_edge& te : m_tmp_edges)
- {
- // All remaining tmp edges must be complete at this point:
- // - The code above already popped any trailing incomplete one.
- // - The split_mid_points loop immediately before this used EZY_ASSERT on every te.
- // Therefore we can (and should) assert rather than silently continue.
- // (A defensive "if (!...) continue" would be appropriate for filtering *old* edges
- // in m_sketch.m_edges, which may contain arcs or other non-linear things, but not here.)
- EZY_ASSERT(te.node_idx_b.has_value());
-
- gp_Pnt2d pa = m_sketch.m_nodes[te.node_idx_a];
- gp_Pnt2d pb = m_sketch.m_nodes[te.node_idx_b];
- for (const Sketch_edge& oe : m_sketch.m_edges.edges()) // pre-batch olds only
- {
- if (Sketch::is_linear_edge_(oe))
- {
- gp_Pnt2d qa = m_sketch.m_nodes[oe.node_idx_a];
- gp_Pnt2d qb = m_sketch.m_nodes[oe.node_idx_b];
- if (auto inter = segment_intersection_2d(pa, pb, qa, qb, Segment_inclusion::Closed))
- add_unique_point(batch_inters, *inter);
- }
- else if (sketch_edge_is_arc(oe))
- {
- for (const gp_Pnt2d& inter :
- segment_arc_intersections_2d(pa, pb, TopoDS::Edge(oe.shp->Shape()), m_sketch.m_pln, Segment_inclusion::Closed))
- add_unique_point(batch_inters, inter);
- }
- }
- }
-
- // Only split olds at interior hits (avoid snap on endpoint-touch inters from the batch, same reason as add_edge_).
- std::vector batch_to_split;
- for (const auto& ip : batch_inters)
- {
- bool is_old_interior = false;
- for (const Sketch_edge& e : m_sketch.m_edges.edges())
- {
- if (Sketch::is_linear_edge_(e))
- {
- gp_Pnt2d qa = m_sketch.m_nodes[e.node_idx_a];
- gp_Pnt2d qb = m_sketch.m_nodes[e.node_idx_b];
- if (point_on_open_segment_2d(ip, qa, qb))
- {
- is_old_interior = true;
- break;
- }
- }
- else if (sketch_edge_is_arc(e))
- {
- if (point_on_open_arc_interior_2d(ip, TopoDS::Edge(e.shp->Shape()), m_sketch.m_pln))
- {
- is_old_interior = true;
- break;
- }
- }
- }
-
- if (is_old_interior)
- add_unique_point(batch_to_split, ip);
- }
-
- for (const auto& ip : batch_to_split)
- {
- const size_t nidx = m_sketch.m_nodes.get_node_exact(ip);
- m_sketch.m_topo.split_linear_edges_at_node_if_interior(nidx, rec);
- m_sketch.m_topo.split_arcs_at_node_if_interior(nidx, rec);
- }
+ EZY_ASSERT(te.node_idx_b.has_value());
+ segs.push_back({m_sketch.m_nodes[te.node_idx_a], m_sketch.m_nodes[*te.node_idx_b]});
+ if (te.shp)
+ m_sketch.m_ctx.Remove(te.shp, false);
}
- append(m_sketch.m_edges.edges(), m_tmp_edges);
m_tmp_edges.clear();
- // Ensure topology is correct if snapping on a midpoint happened.
- // These edges need to be split. (Kept for compatibility with current midpoint marking during draw.)
- for (size_t mid_pt_idx : split_mid_points)
- for (auto itr = m_sketch.m_edges.edges().begin(), end = m_sketch.m_edges.edges().end(); itr != end; ++itr)
- if (itr->node_idx_mid.has_value() && *itr->node_idx_mid == mid_pt_idx)
- if (sketch_edge_is_linear(*itr))
- {
- // Split the edge.
- Sketch_edge edge_a{itr->node_idx_a, mid_pt_idx};
- Sketch_edge edge_b{mid_pt_idx, *itr->node_idx_b};
- m_sketch.update_edge_shp_(edge_a, m_sketch.m_nodes[itr->node_idx_a], m_sketch.m_nodes[mid_pt_idx]);
- m_sketch.update_edge_shp_(edge_b, m_sketch.m_nodes[itr->node_idx_b], m_sketch.m_nodes[mid_pt_idx]);
- rec.note_prev_linear_edge(itr->node_idx_a, *itr->node_idx_b, itr->node_idx_mid, itr->name);
- // Set midpoints for the new split edges so they can be snapped to
- edge_a.node_idx_mid = m_sketch.m_nodes.add_new_node(
- get_midpoint(m_sketch.m_nodes[itr->node_idx_a], m_sketch.m_nodes[mid_pt_idx]), true);
- edge_b.node_idx_mid = m_sketch.m_nodes.add_new_node(
- get_midpoint(m_sketch.m_nodes[mid_pt_idx], m_sketch.m_nodes[itr->node_idx_b]), true);
- rec.note_curr_node(edge_a.node_idx_mid.value());
- rec.note_curr_node(edge_b.node_idx_mid.value());
- m_sketch.m_ctx.Remove(itr->shp, false);
- m_sketch.m_edges.edges().erase(itr);
- m_sketch.m_nodes[mid_pt_idx].midpoint = false;
- m_sketch.m_edges.edges().emplace_back(edge_a);
- m_sketch.m_edges.edges().emplace_back(edge_b);
- break;
- }
-
- // Subdivide any *newly committed* edges (the ones from m_tmp) at interior intersections
- // discovered in the pre-pass. The pre-pass already handled olds; re-calling split here
- // will target the news (olds' original long edges no longer exist). This ensures the
- // GUI finalize path produces the same atomic edge count as direct add_edge_ (e.g. 4
- // edges for a cross, whether or not the cross is at an existing mid).
- for (const auto& ip : batch_inters)
- {
- const size_t nidx = m_sketch.m_nodes.get_node_exact(ip);
- rec.note_curr_node(nidx);
- m_sketch.m_topo.split_linear_edges_at_node_if_interior(nidx, rec);
- m_sketch.m_topo.split_arcs_at_node_if_interior(nidx, rec);
- }
+ for (const Seg& s : segs)
+ m_sketch.add_edge_(s.a, s.b, rec);
m_sketch.m_nodes.hide_snap_annos();
m_sketch.update_faces_();
@@ -934,6 +827,7 @@ bool Sketch_tools::clear_tmps()
m_bone_waist.reset();
m_bone_hole_r1.reset();
m_bone_hole_r2.reset();
+ m_sketch.m_nodes.clear_session_snap_pnts();
clear_all(m_tmp_node_idxs, m_tmp_shp, m_tmp_edges);
m_sketch.m_dims.on_clear_tmps();
diff --git a/src/skt_tools.h b/src/skt_tools.h
index 1ae637da..d0f74f3b 100644
--- a/src/skt_tools.h
+++ b/src/skt_tools.h
@@ -44,6 +44,10 @@ class Sketch_tools
/// Rebuild Add-bone preview from current tmp state (Options debug checkboxes).
void refresh_bone_preview();
+ /// Holes combo: commit if waist is set and Holes is None; otherwise preview + prompt.
+ void apply_bone_holes_option();
+ /// Info toast for the next Add-bone click (centers, radii, waist, or holes).
+ void bone_prompt_next_();
void clear_tmp_node_idxs() { m_tmp_node_idxs.clear(); }
@@ -82,8 +86,12 @@ class Sketch_tools
void add_bone_pt_(const ScreenCoords& screen_coords);
void move_bone_pt_(const ScreenCoords& screen_coords);
void finalize_bone_();
- void bone_begin_next_edge_from_(const gp_Pnt2d& origin);
void bone_on_centers_ready_(const gp_Pnt2d& c1, const gp_Pnt2d& c2);
+ void bone_refresh_radius_session_snap_();
+ void bone_ensure_measure_from_(const gp_Pnt2d& origin);
+ void bone_show_len_seg_(const gp_Pnt2d& a, const gp_Pnt2d& b);
+ [[nodiscard]] gp_Pnt2d bone_perp_rim_(const gp_Pnt2d& center, double radius, const gp_Pnt2d& hint) const;
+ [[nodiscard]] std::optional bone_pick_snapped_(const ScreenCoords& screen_coords);
[[nodiscard]] bool bone_after_waist_(double waist);
[[nodiscard]] bool bone_after_hole_a_(double hole_r);
[[nodiscard]] bool bone_after_hole_b_(double hole_r);
@@ -95,6 +103,8 @@ class Sketch_tools
#endif
[[nodiscard]] std::optional bone_axis_perp_() const;
[[nodiscard]] std::optional bone_waist_from_pt_(const gp_Pnt2d& pt) const;
+ /// Radius at the first center: session snap on circle 2 means match r2.
+ [[nodiscard]] std::optional bone_r1_from_pick_(const gp_Pnt2d& pt) const;
void add_operation_axis_pt_(const ScreenCoords& screen_coords);
void finalize_operation_axis_(Sketch_op_recorder& rec);
diff --git a/src/skt_tools_bone.cpp b/src/skt_tools_bone.cpp
index 9320817e..62741539 100644
--- a/src/skt_tools_bone.cpp
+++ b/src/skt_tools_bone.cpp
@@ -36,8 +36,57 @@ bool bone_hole_radius_too_large_(double outer_r, double hole_r)
{
return hole_r + Precision::Confusion() >= outer_r;
}
+
+void bone_toast_(GUI& gui, const char* text, Status_msg kind)
+{
+ gui.show_message(text, kind);
+}
+
+void bone_toast_hole_reject_(GUI& gui, double outer_r, double hole_r)
+{
+ if (bone_hole_radius_too_large_(outer_r, hole_r))
+ bone_toast_(gui, "Hole radius must be smaller than the end circle.", Status_msg::Constraint);
+ else
+ bone_toast_(gui, "Hole radius must be positive.", Status_msg::Constraint);
+}
} // namespace
+void Sketch_tools::bone_prompt_next_()
+{
+ GUI& gui = m_sketch.m_view.gui();
+ if (!m_bone_centers)
+ {
+ bone_toast_(m_sketch.m_view.gui(), "Add bone: click the two circle centers.", Status_msg::Info);
+ return;
+ }
+
+ if (!m_bone_r2)
+ {
+ bone_toast_(m_sketch.m_view.gui(), "Click to set radius 2 (at the second center).", Status_msg::Info);
+ return;
+ }
+
+ if (!m_bone_r1)
+ {
+ bone_toast_(m_sketch.m_view.gui(), "Click to set radius 1 (snap on circle 2 to match).", Status_msg::Info);
+ return;
+ }
+
+ if (!m_bone_waist)
+ {
+ bone_toast_(m_sketch.m_view.gui(), "Click to set waist width.", Status_msg::Info);
+ return;
+ }
+
+ const Bone_holes holes = gui.get_bone_holes();
+ if (holes == Bone_holes::One_radius && !m_bone_hole_r1)
+ bone_toast_(m_sketch.m_view.gui(), "Click to set the hole radius (both ends).", Status_msg::Info);
+ else if (holes == Bone_holes::Two_radii && !m_bone_hole_r1)
+ bone_toast_(m_sketch.m_view.gui(), "Click to set hole radius at end A.", Status_msg::Info);
+ else if (holes == Bone_holes::Two_radii && !m_bone_hole_r2)
+ bone_toast_(m_sketch.m_view.gui(), "Click to set hole radius at end B.", Status_msg::Info);
+}
+
void Sketch_tools::bone_on_enter_()
{
if (!m_tmp_edges.empty() && !m_bone_centers && m_sketch.m_dims.entered_edge_len().has_value())
@@ -48,6 +97,8 @@ void Sketch_tools::bone_on_enter_()
m_sketch.m_dims.entered_edge_len()->len);
if (unique(pt_a, *m_last_pt))
m_sketch.update_edge_end_pt_(edge, m_sketch.m_nodes.get_node_exact(*m_last_pt));
+ else
+ bone_toast_(m_sketch.m_view.gui(), "Circle centers must be different.", Status_msg::Constraint);
m_sketch.m_dims.clear_typed_constraints();
}
@@ -64,34 +115,41 @@ void Sketch_tools::bone_on_enter_()
const double len = m_sketch.m_dims.entered_edge_len()->len;
if (len <= Precision::Confusion())
+ {
+ bone_toast_(m_sketch.m_view.gui(), "Distance must be positive.", Status_msg::Constraint);
return;
-
- Sketch_edge& edge = m_tmp_edges.back();
- const gp_Pnt2d& pt_a = m_sketch.m_nodes[edge.node_idx_a];
- m_last_pt = gp_Pnt2d(pt_a).Translated(gp_Vec2d(m_sketch.m_dims.entered_edge_len()->dir) * len);
- if (unique(pt_a, *m_last_pt))
- m_sketch.update_edge_end_pt_(edge, m_sketch.m_nodes.get_node_exact(*m_last_pt));
+ }
m_sketch.m_dims.clear_typed_constraints();
- // Radii after centers: second end first (mouse is near c2), then first end.
- if (m_tmp_edges.size() == 2)
+ if (!m_bone_r2)
{
m_bone_r2 = len;
- bone_begin_next_edge_from_(m_bone_centers->first);
+ m_last_pt.reset();
+ bone_ensure_measure_from_(m_bone_centers->first);
+ bone_refresh_radius_session_snap_();
+ bone_update_preview_();
+ bone_prompt_next_();
+ return;
}
- else if (m_tmp_edges.size() == 3)
+
+ if (!m_bone_r1)
{
m_bone_r1 = len;
- bone_begin_next_edge_from_(get_midpoint(m_bone_centers->first, m_bone_centers->second));
+ m_last_pt.reset();
+ bone_refresh_radius_session_snap_();
+ bone_update_preview_();
+ bone_prompt_next_();
+ return;
}
- else if (m_tmp_edges.size() == 4)
- (void)bone_after_waist_(len);
- else if (m_tmp_edges.size() == 5)
+ if (!m_bone_waist)
+ (void)bone_after_waist_(len);
+ else if (m_sketch.m_view.gui().get_bone_holes() == Bone_holes::None)
+ (void)bone_try_commit_();
+ else if (!m_bone_hole_r1)
(void)bone_after_hole_a_(len);
-
- else if (m_tmp_edges.size() == 6)
+ else if (!m_bone_hole_r2)
(void)bone_after_hole_b_(len);
}
@@ -109,7 +167,10 @@ void Sketch_tools::add_bone_pt_(const ScreenCoords& screen_coords)
{
Sketch_edge& last = m_tmp_edges.back();
if (node_idx == last.node_idx_a)
+ {
+ bone_toast_(m_sketch.m_view.gui(), "Circle centers must be different.", Status_msg::Constraint);
return;
+ }
m_sketch.update_edge_end_pt_(last, node_idx);
bone_on_centers_ready_(m_sketch.m_nodes[last.node_idx_a], m_sketch.m_nodes[node_idx]);
@@ -128,7 +189,10 @@ void Sketch_tools::add_bone_pt_(const ScreenCoords& screen_coords)
const double dist_along = to_click.Dot(gp_Vec2d(constrained_dir));
gp_Pnt2d final_pt = gp_Pnt2d(pt_a).Translated(gp_Vec2d(constrained_dir) * dist_along);
if (!unique(pt_a, final_pt))
+ {
+ bone_toast_(m_sketch.m_view.gui(), "Circle centers must be different.", Status_msg::Constraint);
return;
+ }
const size_t node_idx = m_sketch.m_nodes.get_node_exact(final_pt);
m_tmp_node_idxs.push_back(node_idx);
@@ -140,137 +204,212 @@ void Sketch_tools::add_bone_pt_(const ScreenCoords& screen_coords)
return;
}
- auto on_dim = [&](size_t node_idx)
- {
- Sketch_edge& last = m_tmp_edges.back();
- if (node_idx == last.node_idx_a)
- return;
-
- const gp_Pnt2d& pt_a = m_sketch.m_nodes[last.node_idx_a];
- const gp_Pnt2d& pt_b = m_sketch.m_nodes[node_idx];
- if (!unique(pt_a, pt_b))
- return;
+ const std::optional pt = bone_pick_snapped_(screen_coords);
+ if (!pt)
+ return;
- m_sketch.update_edge_end_pt_(last, node_idx);
- const double len = pt_a.Distance(pt_b);
+ m_last_pt = *pt;
+ const gp_Pnt2d& c1 = m_bone_centers->first;
+ const gp_Pnt2d& c2 = m_bone_centers->second;
- // Radii after centers: second end first (mouse is near c2), then first end.
- if (m_tmp_edges.size() == 2)
- {
- m_bone_r2 = len;
- bone_begin_next_edge_from_(m_bone_centers->first);
- }
- else if (m_tmp_edges.size() == 3)
+ if (!m_bone_r2)
+ {
+ const double r = c2.Distance(*pt);
+ if (r <= Precision::Confusion())
{
- m_bone_r1 = len;
- bone_begin_next_edge_from_(get_midpoint(m_bone_centers->first, m_bone_centers->second));
+ bone_toast_(m_sketch.m_view.gui(), "Radius must be positive.", Status_msg::Constraint);
+ return;
}
- else if (m_tmp_edges.size() == 4)
+
+ m_bone_r2 = r;
+ m_last_pt.reset();
+ bone_ensure_measure_from_(c1);
+ bone_refresh_radius_session_snap_();
+ bone_update_preview_();
+ bone_prompt_next_();
+ return;
+ }
+
+ if (!m_bone_r1)
+ {
+ const std::optional r = bone_r1_from_pick_(*pt);
+ if (!r)
{
- const std::optional waist = bone_waist_from_pt_(pt_b);
- if (waist)
- (void)bone_after_waist_(*waist);
+ bone_toast_(m_sketch.m_view.gui(), "Radius must be positive.", Status_msg::Constraint);
+ return;
}
- else if (m_tmp_edges.size() == 5)
- (void)bone_after_hole_a_(len);
- else if (m_tmp_edges.size() == 6)
- (void)bone_after_hole_b_(len);
- };
+ m_bone_r1 = r;
+ m_last_pt.reset();
+ bone_refresh_radius_session_snap_();
+ bone_update_preview_();
+ bone_prompt_next_();
+ return;
+ }
+
+ if (!m_bone_waist)
+ {
+ if (const std::optional waist = bone_waist_from_pt_(*pt))
+ (void)bone_after_waist_(*waist);
+ else
+ bone_toast_(m_sketch.m_view.gui(), "Waist must be greater than zero.", Status_msg::Constraint);
+
+ return;
+ }
- add_sketch_pt_(screen_coords, 1, on_dim);
+ if (m_sketch.m_view.gui().get_bone_holes() == Bone_holes::None)
+ {
+ (void)bone_try_commit_();
+ return;
+ }
+
+ if (!m_bone_hole_r1)
+ (void)bone_after_hole_a_(c1.Distance(*pt));
+
+ else if (!m_bone_hole_r2)
+ (void)bone_after_hole_b_(c2.Distance(*pt));
}
void Sketch_tools::move_bone_pt_(const ScreenCoords& screen_coords)
{
- // Only center-to-center shows an AIS length dim. End radii / holes use the circle
- // preview; waist uses the bone outline. Tab distance entry still works.
- if (!m_bone_centers || m_tmp_edges.size() <= 1)
+ if (!m_bone_centers)
{
move_line_string_pt_(screen_coords);
return;
}
- if (m_tmp_edges.size() == 4)
- {
- const std::optional n = bone_axis_perp_();
- if (!n || !m_bone_r1 || !m_bone_r2)
- return;
+ const gp_Pnt2d& c1 = m_bone_centers->first;
+ const gp_Pnt2d& c2 = m_bone_centers->second;
- const gp_Pnt2d& c1 = m_bone_centers->first;
- const gp_Pnt2d& c2 = m_bone_centers->second;
- const gp_Pnt2d mid = get_midpoint(c1, c2);
+ auto l = [&](const std::optional&, const gp_Pnt2d& pt)
+ {
+ m_last_pt = pt;
- auto l = [&](const std::optional&, const gp_Pnt2d& pt_b)
+ if (!m_bone_r2)
{
- Sketch_edge& edge = m_tmp_edges.back();
- // Waist value is twice the distance from the bone axis (independent of click along-axis).
- double half = std::abs(gp_Vec2d(c1, pt_b).Dot(*n));
- if (m_sketch.m_dims.entered_edge_len().has_value())
- half = m_sketch.m_dims.entered_edge_len()->len * 0.5;
-
- if (half <= Precision::Confusion())
- {
- m_sketch.m_dims.clear_tmp_dim_anno();
- m_sketch.m_view.remove(m_tmp_shp);
- m_tmp_shp = nullptr;
- return;
- }
-
- const double waist = 2.0 * half;
- gp_Pnt2d span_a = gp_Pnt2d(mid).Translated(-(*n) * half);
- gp_Pnt2d span_b = gp_Pnt2d(mid).Translated((*n) * half);
-
- Bone_params params;
- params.c1 = c1;
- params.c2 = c2;
- params.r1 = *m_bone_r1;
- params.r2 = *m_bone_r2;
- params.waist = waist;
- params.drive = Bone_drive::Waist;
- if (const std::optional g = compute_bone_geom(params))
+ const double r = c2.Distance(pt);
+ if (r > Precision::Confusion())
+ bone_show_len_seg_(c2, bone_perp_rim_(c2, r, pt));
+ }
+ else if (!m_bone_r1)
+ {
+ if (const std::optional r = bone_r1_from_pick_(pt))
+ bone_show_len_seg_(c1, bone_perp_rim_(c1, *r, pt));
+ }
+ else if (!m_bone_waist)
+ {
+ if (const std::optional waist = bone_waist_from_pt_(pt))
{
- // Place the live rubber-band on the true neck (offset from mid when r1 != r2).
- const Bone_profile pr = get_bone_profile(*g);
- span_a = pr.waist_plus;
- span_b = pr.waist_minus;
+ const std::optional n = bone_axis_perp_();
+ if (n)
+ {
+ const gp_Pnt2d mid = get_midpoint(c1, c2);
+ const double half = *waist * 0.5;
+ bone_show_len_seg_(gp_Pnt2d(mid).Translated(-(*n) * half), gp_Pnt2d(mid).Translated((*n) * half));
+ }
}
+ }
+ else if (!m_bone_hole_r1)
+ {
+ const double r = c1.Distance(pt);
+ if (r > Precision::Confusion())
+ bone_show_len_seg_(c1, bone_perp_rim_(c1, r, pt));
+ }
+ else if (!m_bone_hole_r2)
+ {
+ const double r = c2.Distance(pt);
+ if (r > Precision::Confusion())
+ bone_show_len_seg_(c2, bone_perp_rim_(c2, r, pt));
+ }
- m_last_pt = span_b;
- m_sketch.update_edge_shp_(edge, span_a, span_b);
+ bone_update_preview_();
+ };
- const double dist = waist / m_sketch.m_view.get_display_to_model_scale();
- m_sketch.m_dims.clear_tmp_dim_anno();
- m_sketch.m_dims.offer_dist_edit_for_segment(span_a, span_b, dist);
- bone_update_preview_();
- };
+ move_sketch_pt_(screen_coords, l);
+}
- move_sketch_pt_(screen_coords, l);
+void Sketch_tools::bone_on_centers_ready_(const gp_Pnt2d& c1, const gp_Pnt2d& c2)
+{
+ if (!unique(c1, c2))
+ {
+ bone_toast_(m_sketch.m_view.gui(), "Circle centers must be different.", Status_msg::Constraint);
return;
}
- move_line_string_pt_(screen_coords);
- m_sketch.m_dims.clear_tmp_dim_anno();
- bone_update_preview_();
-}
-
-void Sketch_tools::bone_begin_next_edge_from_(const gp_Pnt2d& origin)
-{
+ m_bone_centers = std::make_pair(c1, c2);
m_sketch.m_dims.clear_typed_constraints();
m_sketch.m_dims.set_show_angle_input(false);
m_sketch.m_view.gui().hide_angle_edit();
m_sketch.m_view.gui().hide_dist_edit(false);
- m_tmp_edges.push_back({m_sketch.m_nodes.get_node_exact(origin)});
+ m_sketch.m_dims.show_tmp_dim_preview(c1, c2);
+ bone_ensure_measure_from_(c2);
+ bone_prompt_next_();
}
-void Sketch_tools::bone_on_centers_ready_(const gp_Pnt2d& c1, const gp_Pnt2d& c2)
+void Sketch_tools::bone_refresh_radius_session_snap_()
{
- if (!unique(c1, c2))
+ m_sketch.m_nodes.clear_session_snap_pnts();
+ if (!m_bone_centers || !m_bone_r2 || m_bone_r1)
return;
- m_bone_centers = std::make_pair(c1, c2);
- // Start radius at the second center (where the cursor just placed c2).
- bone_begin_next_edge_from_(c2);
+ const std::optional n = bone_axis_perp_();
+ if (!n)
+ return;
+
+ // Tangent points on the already-sized second circle (axis-parallel tangents).
+ // Snapping radius 1 to either point matches r1 to r2.
+ const gp_Pnt2d c2 = m_bone_centers->second;
+ m_sketch.m_nodes.add_session_snap_pnt(gp_Pnt2d(c2).Translated((*n) * (*m_bone_r2)));
+ m_sketch.m_nodes.add_session_snap_pnt(gp_Pnt2d(c2).Translated(-(*n) * (*m_bone_r2)));
+}
+
+void Sketch_tools::bone_ensure_measure_from_(const gp_Pnt2d& origin)
+{
+ const size_t idx = m_sketch.m_nodes.get_node_exact(origin);
+ if (m_tmp_edges.size() <= 1)
+ m_tmp_edges.push_back({idx});
+ else
+ {
+ m_tmp_edges.back().node_idx_a = idx;
+ m_tmp_edges.back().node_idx_b.reset();
+ }
+}
+
+void Sketch_tools::bone_show_len_seg_(const gp_Pnt2d& a, const gp_Pnt2d& b)
+{
+ if (!unique(a, b))
+ return;
+
+ if (m_tmp_edges.size() <= 1)
+ bone_ensure_measure_from_(a);
+
+ m_sketch.update_edge_shp_(m_tmp_edges.back(), a, b);
+ const double dist = a.Distance(b) / m_sketch.m_view.get_display_to_model_scale();
+ m_sketch.m_dims.show_tmp_dim_preview(a, b);
+ m_sketch.m_dims.offer_dist_edit_for_segment(a, b, dist);
+}
+
+gp_Pnt2d Sketch_tools::bone_perp_rim_(const gp_Pnt2d& center, double radius, const gp_Pnt2d& hint) const
+{
+ const std::optional n = bone_axis_perp_();
+ if (!n || radius <= Precision::Confusion())
+ return hint;
+
+ double s = gp_Vec2d(center, hint).Dot(*n);
+ if (std::abs(s) <= Precision::Confusion())
+ s = 1.0;
+
+ return gp_Pnt2d(center).Translated(*n * (s > 0.0 ? radius : -radius));
+}
+
+std::optional Sketch_tools::bone_pick_snapped_(const ScreenCoords& screen_coords)
+{
+ std::optional pt = m_sketch.m_view.pt_on_plane(screen_coords, m_sketch.m_pln);
+ if (!pt)
+ return std::nullopt;
+
+ (void)m_sketch.m_nodes.try_get_node_idx_snap(*pt);
+ return pt;
}
bool Sketch_tools::bone_after_waist_(double waist)
@@ -286,14 +425,18 @@ bool Sketch_tools::bone_after_waist_(double waist)
params.waist = waist;
params.drive = Bone_drive::Waist;
if (!compute_bone_geom(params))
+ {
+ bone_toast_(m_sketch.m_view.gui(), "Cannot form a bone with that waist (too wide, or one circle inside the other).",
+ Status_msg::Constraint);
return false;
+ }
m_bone_waist = waist;
const Bone_holes holes = m_sketch.m_view.gui().get_bone_holes();
if (holes == Bone_holes::None)
return bone_try_commit_();
- bone_begin_next_edge_from_(m_bone_centers->first);
+ bone_prompt_next_();
return true;
}
@@ -307,8 +450,8 @@ bool Sketch_tools::bone_after_hole_a_(double hole_r)
{
if (!bone_hole_radius_ok_(*m_bone_r1, hole_r) || !bone_hole_radius_ok_(*m_bone_r2, hole_r))
{
- if (bone_hole_radius_too_large_(*m_bone_r1, hole_r) || bone_hole_radius_too_large_(*m_bone_r2, hole_r))
- m_sketch.m_view.gui().show_message("Hole radius must be smaller than the end circle.");
+ const double outer = bone_hole_radius_too_large_(*m_bone_r1, hole_r) ? *m_bone_r1 : *m_bone_r2;
+ bone_toast_hole_reject_(m_sketch.m_view.gui(), outer, hole_r);
return false;
}
@@ -322,13 +465,12 @@ bool Sketch_tools::bone_after_hole_a_(double hole_r)
if (!bone_hole_radius_ok_(*m_bone_r1, hole_r))
{
- if (bone_hole_radius_too_large_(*m_bone_r1, hole_r))
- m_sketch.m_view.gui().show_message("Hole radius must be smaller than the end circle.");
+ bone_toast_hole_reject_(m_sketch.m_view.gui(), *m_bone_r1, hole_r);
return false;
}
m_bone_hole_r1 = hole_r;
- bone_begin_next_edge_from_(m_bone_centers->second);
+ bone_prompt_next_();
return true;
}
@@ -339,8 +481,7 @@ bool Sketch_tools::bone_after_hole_b_(double hole_r)
if (!bone_hole_radius_ok_(*m_bone_r2, hole_r))
{
- if (bone_hole_radius_too_large_(*m_bone_r2, hole_r))
- m_sketch.m_view.gui().show_message("Hole radius must be smaller than the end circle.");
+ bone_toast_hole_reject_(m_sketch.m_view.gui(), *m_bone_r2, hole_r);
return false;
}
@@ -361,44 +502,60 @@ bool Sketch_tools::bone_try_commit_()
params.waist = *m_bone_waist;
params.drive = Bone_drive::Waist;
if (!compute_bone_geom(params))
+ {
+ bone_toast_(m_sketch.m_view.gui(), "Could not create bone.", Status_msg::Error);
return false;
+ }
- const bool add_centers = m_sketch.m_view.gui().get_bone_add_center_nodes();
- m_sketch.add_bone(params.c1, params.c2, params.r1, params.r2, *m_bone_waist, add_centers, m_bone_hole_r1,
- m_bone_hole_r2);
+ const GUI& gui = m_sketch.m_view.gui();
+ if (gui.get_bone_holes() == Bone_holes::None)
+ {
+ m_bone_hole_r1.reset();
+ m_bone_hole_r2.reset();
+ }
+
+ m_sketch.add_bone(params.c1, params.c2, params.r1, params.r2, *m_bone_waist, gui.get_bone_add_center_nodes(),
+ m_bone_hole_r1, m_bone_hole_r2, gui.get_bone_add_radius_nodes(),
+ gui.get_bone_add_total_length_nodes());
clear_tmps();
+ bone_toast_(m_sketch.m_view.gui(), "Bone added.", Status_msg::Success);
m_sketch.m_view.gui().set_parent_mode();
return true;
}
void Sketch_tools::finalize_bone_()
{
- if (!m_bone_centers || !m_bone_r1 || !m_bone_r2 || !m_last_pt)
+ if (!m_bone_centers || !m_bone_r1 || !m_bone_r2)
return;
- if (m_tmp_edges.size() == 4)
+ if (!m_bone_waist)
{
- if (m_sketch.m_view.gui().get_bone_holes() != Bone_holes::None)
+ if (!m_last_pt || m_sketch.m_view.gui().get_bone_holes() != Bone_holes::None)
return;
- const std::optional waist = bone_waist_from_pt_(*m_last_pt);
- if (waist)
+ if (const std::optional waist = bone_waist_from_pt_(*m_last_pt))
(void)bone_after_waist_(*waist);
+
return;
}
- if (m_tmp_edges.size() == 5 && m_bone_waist)
+ if (m_sketch.m_view.gui().get_bone_holes() == Bone_holes::None)
{
- const gp_Pnt2d& c1 = m_bone_centers->first;
- (void)bone_after_hole_a_(c1.Distance(*m_last_pt));
+ (void)bone_try_commit_();
return;
}
- if (m_tmp_edges.size() == 6 && m_bone_waist && m_bone_hole_r1)
+ if (!m_last_pt)
+ return;
+
+ if (!m_bone_hole_r1)
{
- const gp_Pnt2d& c2 = m_bone_centers->second;
- (void)bone_after_hole_b_(c2.Distance(*m_last_pt));
+ (void)bone_after_hole_a_(m_bone_centers->first.Distance(*m_last_pt));
+ return;
}
+
+ if (!m_bone_hole_r2)
+ (void)bone_after_hole_b_(m_bone_centers->second.Distance(*m_last_pt));
}
std::optional Sketch_tools::bone_axis_perp_() const
@@ -414,6 +571,28 @@ std::optional Sketch_tools::bone_axis_perp_() const
return gp_Vec2d(axis / dist).Rotated(std::numbers::pi / 2.0);
}
+std::optional Sketch_tools::bone_r1_from_pick_(const gp_Pnt2d& pt) const
+{
+ if (!m_bone_centers || !m_bone_r2)
+ return std::nullopt;
+
+ if (const std::optional n = bone_axis_perp_())
+ {
+ const gp_Pnt2d c2 = m_bone_centers->second;
+ const gp_Pnt2d t0 = gp_Pnt2d(c2).Translated((*n) * (*m_bone_r2));
+ const gp_Pnt2d t1 = gp_Pnt2d(c2).Translated(-(*n) * (*m_bone_r2));
+ const double tol = Precision::Confusion();
+ if (pt.Distance(t0) <= tol || pt.Distance(t1) <= tol)
+ return m_bone_r2;
+ }
+
+ const double r = m_bone_centers->first.Distance(pt);
+ if (r <= Precision::Confusion())
+ return std::nullopt;
+
+ return r;
+}
+
std::optional Sketch_tools::bone_waist_from_pt_(const gp_Pnt2d& pt) const
{
const std::optional n = bone_axis_perp_();
@@ -430,6 +609,18 @@ std::optional Sketch_tools::bone_waist_from_pt_(const gp_Pnt2d& pt) cons
void Sketch_tools::refresh_bone_preview() { bone_update_preview_(); }
+void Sketch_tools::apply_bone_holes_option()
+{
+ if (m_bone_waist && m_sketch.m_view.gui().get_bone_holes() == Bone_holes::None)
+ {
+ (void)bone_try_commit_();
+ return;
+ }
+
+ bone_update_preview_();
+ bone_prompt_next_();
+}
+
#if DEV_MODE
void Sketch_tools::bone_hide_debug_()
{
@@ -470,16 +661,14 @@ void Sketch_tools::bone_update_preview_()
auto circle_at = [&](const gp_Pnt2d& c, double r) -> TopoDS_Wire
{ return make_circle_wire(m_sketch.m_pln, c, gp_Pnt2d(c.X() + r, c.Y())); };
- // Live preview: edge 2 sizes c2, edge 3 sizes c1 (same order as placement).
- const double r1 = m_bone_r1.value_or(
- (m_tmp_edges.size() == 3 && m_last_pt) ? c1.Distance(*m_last_pt) : 0.0);
- const double r2 = m_bone_r2.value_or(
- (m_tmp_edges.size() == 2 && m_last_pt) ? c2.Distance(*m_last_pt) : 0.0);
+ // Live preview: r2 first (cursor still near c2), then r1.
+ const double r2 = m_bone_r2.value_or(m_last_pt ? c2.Distance(*m_last_pt) : 0.0);
+ const double r1 = m_bone_r1.value_or((m_bone_r2 && m_last_pt) ? bone_r1_from_pick_(*m_last_pt).value_or(0.0) : 0.0);
auto show_compound = [&](const TopoDS_Compound& comp)
{ show(m_sketch.m_ctx, m_tmp_shp, comp); };
- if (m_tmp_edges.size() >= 4 && m_bone_r1 && m_bone_r2)
+ if (m_bone_r1 && m_bone_r2)
{
const std::optional waist =
m_bone_waist
@@ -515,7 +704,7 @@ void Sketch_tools::bone_update_preview_()
bb.Add(comp, circle_at(c, hr));
};
- if (m_tmp_edges.size() >= 5)
+ if (m_bone_waist)
{
const Bone_holes holes = m_sketch.m_view.gui().get_bone_holes();
if (holes == Bone_holes::One_radius)
@@ -530,8 +719,8 @@ void Sketch_tools::bone_update_preview_()
}
else if (holes == Bone_holes::Two_radii)
{
- maybe_hole(c1, *m_bone_r1, m_bone_hole_r1, m_tmp_edges.size() == 5);
- maybe_hole(c2, *m_bone_r2, m_bone_hole_r2, m_tmp_edges.size() == 6);
+ maybe_hole(c1, *m_bone_r1, m_bone_hole_r1, !m_bone_hole_r1);
+ maybe_hole(c2, *m_bone_r2, m_bone_hole_r2, m_bone_hole_r1.has_value() && !m_bone_hole_r2);
}
}
diff --git a/src/utl_geom.cpp b/src/utl_geom.cpp
index 9d1b70c4..16942a3c 100644
--- a/src/utl_geom.cpp
+++ b/src/utl_geom.cpp
@@ -11,6 +11,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -49,6 +50,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -1442,7 +1444,7 @@ std::optional segment_intersection_2d(const gp_Pnt2d& a1, const gp_Pnt
double den = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
if (std::abs(den) < tol)
- return std::nullopt; // parallel/collinear (overlap not handled)
+ return std::nullopt; // parallel/collinear (overlap handled by same_line_support_2d)
double t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / den;
double u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / den;
@@ -1482,6 +1484,59 @@ std::optional segment_intersection_2d(const gp_Pnt2d& a1, const gp_Pnt
return std::nullopt;
}
+bool same_line_support_2d(const gp_Pnt2d& a1, const gp_Pnt2d& a2, const gp_Pnt2d& b1, const gp_Pnt2d& b2)
+{
+ const double tol = Precision::Confusion();
+ if (a1.Distance(a2) <= tol || b1.Distance(b2) <= tol)
+ return false;
+
+ auto on_infinite_line = [&](const gp_Pnt2d& p, const gp_Pnt2d& a, const gp_Pnt2d& b) -> bool
+ {
+ gp_Vec2d ab(a, b);
+ const double len = ab.Magnitude();
+ if (len < tol)
+ return p.Distance(a) <= tol;
+
+ gp_Vec2d ap(a, p);
+ return std::abs(ap.X() * ab.Y() - ap.Y() * ab.X()) <= tol * len;
+ };
+
+ return on_infinite_line(b1, a1, a2) && on_infinite_line(b2, a1, a2);
+}
+
+std::vector collinear_overlap_cut_points_2d(const gp_Pnt2d& a1, const gp_Pnt2d& a2, const gp_Pnt2d& b1,
+ const gp_Pnt2d& b2)
+{
+ std::vector ret;
+ if (!same_line_support_2d(a1, a2, b1, b2))
+ return ret;
+
+ auto add_if_interior = [&](const gp_Pnt2d& p, const gp_Pnt2d& s, const gp_Pnt2d& e)
+ {
+ if (point_on_open_segment_2d(p, s, e))
+ add_unique_point(ret, p);
+ };
+
+ add_if_interior(a1, b1, b2);
+ add_if_interior(a2, b1, b2);
+ add_if_interior(b1, a1, a2);
+ add_if_interior(b2, a1, a2);
+ return ret;
+}
+
+bool same_circle_support_2d(const TopoDS_Edge& arc_a, const TopoDS_Edge& arc_b, const gp_Pln& pln)
+{
+ const BRepAdaptor_Curve ca(arc_a);
+ const BRepAdaptor_Curve cb(arc_b);
+ if (ca.GetType() != GeomAbs_Circle || cb.GetType() != GeomAbs_Circle)
+ return false;
+
+ const gp_Circ a = ca.Circle();
+ const gp_Circ b = cb.Circle();
+ const double tol = Precision::Confusion();
+ return to_2d(pln, a.Location()).Distance(to_2d(pln, b.Location())) <= tol && std::abs(a.Radius() - b.Radius()) <= tol;
+}
+
/// Adds \a p to \a points only if it is not already present (within Precision::Confusion()).
/// This avoids duplicate intersection points when collecting from multiple edges.
void add_unique_point(std::vector& points, const gp_Pnt2d& p)
@@ -1586,7 +1641,10 @@ std::vector segment_arc_intersections_2d(const gp_Pnt2d& seg_a, const
std::vector arc_arc_intersections_2d(const TopoDS_Edge& arc_a, const TopoDS_Edge& arc_b, const gp_Pln& pln)
{
- std::vector ret;
+ std::vector ret;
+ if (same_circle_support_2d(arc_a, arc_b, pln))
+ return ret;
+
Geom_TrimmedCurve_ptr curve_a = edge_trimmed_curve_(arc_a);
Geom_TrimmedCurve_ptr curve_b = edge_trimmed_curve_(arc_b);
GeomAPI_ExtremaCurveCurve ext(curve_a, curve_b);
diff --git a/src/utl_geom.h b/src/utl_geom.h
index 9897a966..8cd0fcb4 100644
--- a/src/utl_geom.h
+++ b/src/utl_geom.h
@@ -249,11 +249,23 @@ enum class Segment_inclusion
/// Returns the intersection point of the two line *segments* [a1-a2] and [b1-b2] if they intersect,
/// according to the given \a inclusion mode. Uses a tolerance for floating-point robustness.
-/// Returns nullopt if the lines are (nearly) parallel/collinear (overlaps not supported) or if the
-/// intersection point does not lie on the segments per the inclusion mode.
+/// Returns nullopt if the lines are (nearly) parallel/collinear (use `same_line_support_2d` /
+/// `collinear_overlap_cut_points_2d` for overlap) or if the intersection does not lie on both
+/// segments per the inclusion mode.
std::optional segment_intersection_2d(const gp_Pnt2d& a1, const gp_Pnt2d& a2, const gp_Pnt2d& b1, const gp_Pnt2d& b2,
Segment_inclusion inclusion = Segment_inclusion::Closed);
+/// True when both segments have length and [b1,b2] lies on the infinite line through [a1,a2].
+[[nodiscard]] bool same_line_support_2d(const gp_Pnt2d& a1, const gp_Pnt2d& a2, const gp_Pnt2d& b1, const gp_Pnt2d& b2);
+
+/// Endpoints of either segment that lie in the open interior of the other, when the segments
+/// share a line and overlap on a range. Empty when disjoint, endpoint-touch only, or not collinear.
+[[nodiscard]] std::vector collinear_overlap_cut_points_2d(const gp_Pnt2d& a1, const gp_Pnt2d& a2, const gp_Pnt2d& b1,
+ const gp_Pnt2d& b2);
+
+/// True when both edges are circles with the same center and radius (in \a pln).
+[[nodiscard]] bool same_circle_support_2d(const TopoDS_Edge& arc_a, const TopoDS_Edge& arc_b, const gp_Pln& pln);
+
/// Adds \a p to the vector only if no existing point is within Precision::Confusion() distance.
/// Used to collect unique intersection points without duplicates.
void add_unique_point(std::vector& points, const gp_Pnt2d& p);
diff --git a/tests/skt_tests.cpp b/tests/skt_tests.cpp
index 056a9e56..47b621a2 100644
--- a/tests/skt_tests.cpp
+++ b/tests/skt_tests.cpp
@@ -1052,6 +1052,24 @@ TEST_F(Sketch_test, AddBone_createsFacesAndPermanentCenters)
EXPECT_TRUE(found_a);
EXPECT_TRUE(found_b);
+ auto has_named = [&](const char* name, const gp_Pnt2d& pt) -> bool
+ {
+ for (size_t i = 0; i < sketch.get_nodes().size(); ++i)
+ {
+ const Sketch_nodes::Node& n = sketch.get_nodes()[i];
+ if (!n.deleted && n.permanent && n.name == name)
+ return n.IsEqual(pt, Precision::Confusion());
+ }
+ return false;
+ };
+
+ EXPECT_TRUE(has_named("Bone A+", gp_Pnt2d(-2.0, 1.0)));
+ EXPECT_TRUE(has_named("Bone A-", gp_Pnt2d(-2.0, -1.0)));
+ EXPECT_TRUE(has_named("Bone B+", gp_Pnt2d(2.0, 0.5)));
+ EXPECT_TRUE(has_named("Bone B-", gp_Pnt2d(2.0, -0.5)));
+ EXPECT_TRUE(has_named("Bone A tip", gp_Pnt2d(-3.0, 0.0)));
+ EXPECT_TRUE(has_named("Bone B tip", gp_Pnt2d(2.5, 0.0)));
+
const std::vector labels = sketch.inspector_node_labels();
EXPECT_NE(std::find(labels.begin(), labels.end(), "Bone A"), labels.end());
EXPECT_NE(std::find(labels.begin(), labels.end(), "Bone B"), labels.end());
@@ -1076,6 +1094,9 @@ TEST_F(Sketch_test, AddBone_optionalHolesAndNoCenterNodes)
continue;
EXPECT_FALSE(n.permanent && (n.name == "Bone A" || n.name == "Bone B"));
}
+
+ EXPECT_EQ(Sketch_access::count_permanent_nodes(sketch), 7u)
+ << "Origin, four axis-tangent nodes, and two total-length tip nodes";
}
TEST_F(Sketch_test, AddBone_undoRemovesPromotedCenters)
@@ -1094,16 +1115,17 @@ TEST_F(Sketch_test, AddBone_undoRemovesPromotedCenters)
sketch.add_bone(c1, c2, 1.0, 0.5, 0.4);
EXPECT_EQ(sketch.face_count(), 1u);
EXPECT_EQ(Sketch_access::get_edge_count(sketch), 4u);
- EXPECT_EQ(Sketch_access::count_permanent_nodes(sketch), 3u) << "Origin plus Bone A and Bone B";
+ EXPECT_EQ(Sketch_access::count_permanent_nodes(sketch), 9u)
+ << "Origin, Bone A/B, four axis-tangent nodes, and two tip nodes";
ASSERT_GT(view().undo_stack_size(), 0u);
EXPECT_TRUE(view().undo());
EXPECT_EQ(Sketch_access::get_edge_count(sketch), 0u);
- EXPECT_EQ(Sketch_access::count_permanent_nodes(sketch), 1u) << "Undo should remove Bone A/B centers";
+ EXPECT_EQ(Sketch_access::count_permanent_nodes(sketch), 1u) << "Undo should remove Bone A/B and tangent nodes";
EXPECT_TRUE(view().redo());
EXPECT_EQ(Sketch_access::get_edge_count(sketch), 4u);
- EXPECT_EQ(Sketch_access::count_permanent_nodes(sketch), 3u);
+ EXPECT_EQ(Sketch_access::count_permanent_nodes(sketch), 9u);
bool found_a = false;
bool found_b = false;
diff --git a/tests/skt_topo_tests.cpp b/tests/skt_topo_tests.cpp
index f1ebcd56..78d5c29f 100644
--- a/tests/skt_topo_tests.cpp
+++ b/tests/skt_topo_tests.cpp
@@ -1,12 +1,14 @@
#include "skt_test_fixture.h"
#include
+#include
#include
#include
#include
#include
#include
#include
+#include
#include
#include
#include
@@ -1333,3 +1335,294 @@ TEST_F(Sketch_test, SplitEdge_HasMidpoints)
EXPECT_NEAR(midpoint_x_coords[0], 5.0, Precision::Confusion()) << "First split edge should have midpoint at x=5";
EXPECT_NEAR(midpoint_x_coords[1], 15.0, Precision::Confusion()) << "Second split edge should have midpoint at x=15";
}
+
+namespace
+{
+bool has_linear_seg_(Sketch& sketch, const gp_Pnt2d& a, const gp_Pnt2d& b)
+{
+ const double tol = Precision::Confusion();
+ for (const auto& e : Sketch_access::get_edges(sketch))
+ {
+ if (!sketch_edge_is_linear(e) || !e.node_idx_b.has_value())
+ continue;
+
+ const gp_Pnt2d ea = sketch.get_nodes()[e.node_idx_a];
+ const gp_Pnt2d eb = sketch.get_nodes()[*e.node_idx_b];
+ if ((ea.Distance(a) <= tol && eb.Distance(b) <= tol) || (ea.Distance(b) <= tol && eb.Distance(a) <= tol))
+ return true;
+ }
+
+ return false;
+}
+} // namespace
+
+TEST(Geom2d, CollinearOverlapHelpers)
+{
+ const gp_Pnt2d a(0.0, 0.0);
+ const gp_Pnt2d b(2.0, 0.0);
+ const gp_Pnt2d c(1.0, 0.0);
+ const gp_Pnt2d d(3.0, 0.0);
+
+ EXPECT_TRUE(same_line_support_2d(a, b, c, d));
+ EXPECT_FALSE(same_line_support_2d(a, b, gp_Pnt2d(0.0, 1.0), gp_Pnt2d(2.0, 1.0)));
+
+ const std::vector cuts = collinear_overlap_cut_points_2d(a, b, c, d);
+ ASSERT_EQ(cuts.size(), 2u);
+ EXPECT_TRUE((cuts[0].Distance(c) <= Precision::Confusion() && cuts[1].Distance(b) <= Precision::Confusion()) ||
+ (cuts[0].Distance(b) <= Precision::Confusion() && cuts[1].Distance(c) <= Precision::Confusion()));
+
+ EXPECT_TRUE(collinear_overlap_cut_points_2d(a, b, a, b).empty());
+ EXPECT_TRUE(collinear_overlap_cut_points_2d(a, gp_Pnt2d(1.0, 0.0), gp_Pnt2d(2.0, 0.0), d).empty());
+}
+
+TEST_F(Sketch_test, Overlap_IdenticalLinearEdgeIsKeptOnce)
+{
+ gp_Pln default_plane(gp::Origin(), gp::DZ());
+ Sketch sketch("test_sketch", view(), default_plane);
+
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(10.0, 0.0));
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(10.0, 0.0));
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(10.0, 0.0), gp_Pnt2d(0.0, 0.0));
+
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 1u);
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(10.0, 0.0)));
+}
+
+TEST_F(Sketch_test, Overlap_PartialCollinearSplitsAndMerges)
+{
+ gp_Pln default_plane(gp::Origin(), gp::DZ());
+ Sketch sketch("test_sketch", view(), default_plane);
+
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(2.0, 0.0));
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(1.0, 0.0), gp_Pnt2d(3.0, 0.0));
+
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 3u);
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(1.0, 0.0)));
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(1.0, 0.0), gp_Pnt2d(2.0, 0.0)));
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(2.0, 0.0), gp_Pnt2d(3.0, 0.0)));
+}
+
+TEST_F(Sketch_test, Overlap_SubsetCollinearDoesNotDuplicate)
+{
+ gp_Pln default_plane(gp::Origin(), gp::DZ());
+ Sketch sketch("test_sketch", view(), default_plane);
+
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(3.0, 0.0));
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(1.0, 0.0), gp_Pnt2d(2.0, 0.0));
+
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 3u);
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(1.0, 0.0)));
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(1.0, 0.0), gp_Pnt2d(2.0, 0.0)));
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(2.0, 0.0), gp_Pnt2d(3.0, 0.0)));
+}
+
+TEST_F(Sketch_test, Overlap_CoveringSegmentUsesExistingPieces)
+{
+ gp_Pln default_plane(gp::Origin(), gp::DZ());
+ Sketch sketch("test_sketch", view(), default_plane);
+
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(1.0, 0.0));
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(1.0, 0.0), gp_Pnt2d(2.0, 0.0));
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(2.0, 0.0));
+
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 2u);
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(1.0, 0.0)));
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(1.0, 0.0), gp_Pnt2d(2.0, 0.0)));
+}
+
+TEST_F(Sketch_test, Overlap_CollinearDisjointStaysTwoEdges)
+{
+ gp_Pln default_plane(gp::Origin(), gp::DZ());
+ Sketch sketch("test_sketch", view(), default_plane);
+
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(1.0, 0.0));
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(2.0, 0.0), gp_Pnt2d(3.0, 0.0));
+
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 2u);
+}
+
+TEST_F(Sketch_test, Overlap_DuplicateLineDoesNotPushUndo)
+{
+ gp_Pln default_plane(gp::Origin(), gp::DZ());
+ Sketch sketch("test_sketch", view(), default_plane);
+
+ {
+ Sketch_op_recorder rec(view(), sketch);
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(10.0, 0.0), rec);
+ rec.commit();
+ }
+
+ const size_t undo_before = view().undo_stack_size();
+ {
+ Sketch_op_recorder rec(view(), sketch);
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(10.0, 0.0), rec);
+ rec.commit();
+ }
+
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 1u);
+ EXPECT_EQ(view().undo_stack_size(), undo_before) << "Redrawing an existing line must not push a no-op undo";
+ EXPECT_TRUE(view().undo());
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 0u)
+ << "First undo after a duplicate add must remove the original line";
+}
+
+TEST_F(Sketch_test, Overlap_CoveringSegmentDoesNotPushUndo)
+{
+ gp_Pln default_plane(gp::Origin(), gp::DZ());
+ Sketch sketch("test_sketch", view(), default_plane);
+
+ {
+ Sketch_op_recorder rec(view(), sketch);
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(1.0, 0.0), rec);
+ rec.commit();
+ }
+ {
+ Sketch_op_recorder rec(view(), sketch);
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(1.0, 0.0), gp_Pnt2d(2.0, 0.0), rec);
+ rec.commit();
+ }
+
+ const size_t undo_before = view().undo_stack_size();
+ {
+ Sketch_op_recorder rec(view(), sketch);
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(2.0, 0.0), rec);
+ rec.commit();
+ }
+
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 2u);
+ EXPECT_EQ(view().undo_stack_size(), undo_before) << "A span already covered by pieces must not push a no-op undo";
+ EXPECT_TRUE(view().undo());
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 1u);
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(1.0, 0.0)));
+}
+
+TEST_F(Sketch_test, Overlap_CollinearExtensionUndoKeepsOriginal)
+{
+ gp_Pln default_plane(gp::Origin(), gp::DZ());
+ Sketch sketch("test_sketch", view(), default_plane);
+
+ {
+ Sketch_op_recorder rec(view(), sketch);
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(1.0, 0.0), rec);
+ rec.commit();
+ }
+
+ {
+ Sketch_op_recorder rec(view(), sketch);
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(2.0, 0.0), rec);
+ rec.commit();
+ }
+
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 2u);
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(1.0, 0.0)));
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(1.0, 0.0), gp_Pnt2d(2.0, 0.0)));
+ EXPECT_TRUE(view().undo());
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 1u);
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(1.0, 0.0)));
+ EXPECT_TRUE(view().redo());
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 2u);
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(1.0, 0.0)));
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(1.0, 0.0), gp_Pnt2d(2.0, 0.0)));
+}
+
+TEST_F(Sketch_test, Overlap_PartialCollinearUndoRestoresFirstEdge)
+{
+ gp_Pln default_plane(gp::Origin(), gp::DZ());
+ Sketch sketch("test_sketch", view(), default_plane);
+
+ {
+ Sketch_op_recorder rec(view(), sketch);
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(2.0, 0.0), rec);
+ rec.commit();
+ }
+
+ {
+ Sketch_op_recorder rec(view(), sketch);
+ Sketch_access::add_edge_(sketch, gp_Pnt2d(1.0, 0.0), gp_Pnt2d(3.0, 0.0), rec);
+ rec.commit();
+ }
+
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 3u);
+ EXPECT_TRUE(view().undo());
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 1u);
+ EXPECT_TRUE(has_linear_seg_(sketch, gp_Pnt2d(0.0, 0.0), gp_Pnt2d(2.0, 0.0)));
+ EXPECT_TRUE(view().redo());
+ EXPECT_EQ(Sketch_access::get_linear_edge_count(sketch), 3u);
+}
+
+TEST_F(Sketch_test, Overlap_IdenticalCircleArcsAreKeptOnce)
+{
+ gp_Pln default_plane(gp::Origin(), gp::DZ());
+ Sketch sketch("test_sketch", view(), default_plane);
+
+ const std::array pts = xy_stencil_pnts(gp_Pnt2d(0.0, 0.0), gp_Pnt2d(0.5, 0.0));
+ Sketch_access::add_arc_circle_(sketch, pts[0], pts[2], pts[1]);
+ Sketch_access::add_arc_circle_(sketch, pts[0], pts[3], pts[1]);
+ EXPECT_EQ(Sketch_access::get_arc_internal_edge_count(sketch), 2u);
+
+ Sketch_access::add_arc_circle_(sketch, pts[0], pts[2], pts[1]);
+ Sketch_access::add_arc_circle_(sketch, pts[0], pts[3], pts[1]);
+ EXPECT_EQ(Sketch_access::get_arc_internal_edge_count(sketch), 2u);
+}
+
+TEST_F(Sketch_test, Overlap_SameCircleCapPlusFullCircleDoesNotDuplicateCap)
+{
+ gp_Pln default_plane(gp::Origin(), gp::DZ());
+ Sketch sketch("test_sketch", view(), default_plane);
+
+ // Semicircle on r=0.5, then the full circle of the same support (the reported crash case).
+ Sketch_access::add_arc_circle_(sketch, gp_Pnt2d(-0.5, 0.0), gp_Pnt2d(0.0, 0.5), gp_Pnt2d(0.5, 0.0));
+ EXPECT_EQ(Sketch_access::get_arc_internal_edge_count(sketch), 1u);
+
+ const std::array pts = xy_stencil_pnts(gp_Pnt2d(0.0, 0.0), gp_Pnt2d(0.5, 0.0));
+ Sketch_access::add_arc_circle_(sketch, pts[0], pts[2], pts[1]);
+ Sketch_access::add_arc_circle_(sketch, pts[0], pts[3], pts[1]);
+
+ EXPECT_EQ(Sketch_access::get_arc_internal_edge_count(sketch), 2u);
+}
+
+TEST_F(Sketch_test, Overlap_QuarterThenFullCircleKeepsThreeArcs)
+{
+ gp_Pln default_plane(gp::Origin(), gp::DZ());
+ Sketch sketch("test_sketch", view(), default_plane);
+
+ Sketch_access::add_arc_circle_(sketch, gp_Pnt2d(0.5, 0.0), gp_Pnt2d(0.5 / std::sqrt(2.0), 0.5 / std::sqrt(2.0)),
+ gp_Pnt2d(0.0, 0.5));
+ EXPECT_EQ(Sketch_access::get_arc_internal_edge_count(sketch), 1u);
+
+ const std::array pts = xy_stencil_pnts(gp_Pnt2d(0.0, 0.0), gp_Pnt2d(0.5, 0.0));
+ Sketch_access::add_arc_circle_(sketch, pts[0], pts[2], pts[1]);
+ Sketch_access::add_arc_circle_(sketch, pts[0], pts[3], pts[1]);
+
+ EXPECT_EQ(Sketch_access::get_arc_internal_edge_count(sketch), 3u);
+}
+
+TEST_F(Sketch_test, Overlap_SameCircleCapPlusFullCircleUndoRemovesLeftovers)
+{
+ gp_Pln default_plane(gp::Origin(), gp::DZ());
+ Sketch sketch("test_sketch", view(), default_plane);
+
+ {
+ Sketch_op_recorder rec(view(), sketch);
+ Sketch_access::add_arc_circle_(sketch, gp_Pnt2d(0.5, 0.0), gp_Pnt2d(0.5 / std::sqrt(2.0), 0.5 / std::sqrt(2.0)),
+ gp_Pnt2d(0.0, 0.5), rec);
+ rec.commit();
+ }
+ EXPECT_EQ(Sketch_access::get_arc_internal_edge_count(sketch), 1u);
+
+ {
+ Sketch_op_recorder rec(view(), sketch);
+ const std::array pts = xy_stencil_pnts(gp_Pnt2d(0.0, 0.0), gp_Pnt2d(0.5, 0.0));
+ Sketch_access::add_arc_circle_(sketch, pts[0], pts[2], pts[1], rec);
+ Sketch_access::add_arc_circle_(sketch, pts[0], pts[3], pts[1], rec);
+ rec.commit();
+ }
+ EXPECT_EQ(Sketch_access::get_arc_internal_edge_count(sketch), 3u);
+
+ EXPECT_TRUE(view().undo());
+ EXPECT_EQ(Sketch_access::get_arc_internal_edge_count(sketch), 1u)
+ << "Undo must remove leftover same-circle pieces, not look up the original circle triple";
+
+ EXPECT_TRUE(view().redo());
+ EXPECT_EQ(Sketch_access::get_arc_internal_edge_count(sketch), 3u);
+}