Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/skill/references/docs/api/cut_rsolid.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## API Definition

```python
def cut_rsolid(*solids, skip_non_intersecting=True, tracking_policy='full', on_split='pieces')
def cut_rsolid(*solids, skip_non_intersecting=True, tracking_policy='full', on_split='pieces', clean=True)
```

*Source: operators/boolean.py*
Expand Down Expand Up @@ -33,6 +33,10 @@ sequences, and returns a single `Solid`.

- **Description**: FULL computes topology history and lineage. GRAPH keeps the replayable operation node without computing a TopoDelta.

### clean

- **Description**: When True (the default) the faces and edges the cut leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result.

### on_split

- **Description**: What a cut that splits the body (a slot through a bar) returns.
Expand Down
6 changes: 5 additions & 1 deletion docs/skill/references/docs/api/cut_rsolid_list.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## API Definition

```python
def cut_rsolid_list(*solids, skip_non_intersecting=True, tracking_policy='full')
def cut_rsolid_list(*solids, skip_non_intersecting=True, tracking_policy='full', clean=True)
```

*Source: operators/boolean.py*
Expand Down Expand Up @@ -39,6 +39,10 @@ a through slot. Use ``cut_rsolid`` when the result must be one solid.

- **Description**: FULL computes topology history and lineage. GRAPH keeps the replayable operation node without computing a TopoDelta.

### clean

- **Description**: When True (the default) the faces and edges the cut leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result.

## Returns

list[Solid]: Every piece of the cut, in kernel order; empty when the tools
Expand Down
6 changes: 5 additions & 1 deletion docs/skill/references/docs/api/intersect_rsolid.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## API Definition

```python
def intersect_rsolid(*solids)
def intersect_rsolid(*solids, clean=True)
```

*Source: operators/boolean.py*
Expand All @@ -26,6 +26,10 @@ error instead of returning an empty list.

- **Description**: One or more Solid objects or sequences of Solid. Nested sequences are flattened before processing.

### clean

- **Description**: When True (the default) the faces and edges the intersection leaves split are unified, as union does: a cylinder wall cut along its seam stays one face, so a later fillet or chamfer of its edges works. False keeps the raw kernel result.

## Returns

Solid: The overlap region as a single solid.
3 changes: 3 additions & 0 deletions docs/skill/references/docs/core/operation_graph_json_spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1567,12 +1567,14 @@ Notes:
| --- | --- | --- |
| `tool_count` | `int` | number of subtractive tool solids |
| `skip_non_intersecting` | `bool` | whether non-intersecting tools are skipped |
| `clean` | `bool` | whether the faces and edges the cut leaves split were unified |

Notes:

- Public `cut_rsolid(...)` defaults this flag to `true` for interactive convenience.
- Graph replay defaults missing `skip_non_intersecting` to `false` so malformed or drifting graphs fail diagnostically instead of silently skipping tools.
- Multi-tool cut preserves the full topology delta chain across all performed tool cuts.
- `clean` has no replay default: a graph without it is rejected like one without `tool_count`.

#### `make_intersect_rsolid`

Expand All @@ -1583,6 +1585,7 @@ Notes:
| Key | Type | Meaning |
| --- | --- | --- |
| `input_count` | `int` | number of intersected solids |
| `clean` | `bool` | whether the faces and edges the intersection leaves split were unified |

Notes:

Expand Down
19 changes: 11 additions & 8 deletions src/cpp/bindings/python/bind_ops_features.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -683,9 +683,10 @@ void bind_booleans(py::module_& m) {
m.def(
"cut_rsolid",
[](const py::args& solids, bool skip_non_intersecting, const Policy& tracking_policy,
const Choice<OnSplitTag>& on_split) -> AnyOf<SolidOrPiecesTag> {
const Choice<OnSplitTag>& on_split, bool clean) -> AnyOf<SolidOrPiecesTag> {
const std::vector<Solid> operands = flattened(solids, "cut_rsolid");
const CutOptions options{skip_non_intersecting, policy_text(tracking_policy)};
CutOptions options{skip_non_intersecting, policy_text(tracking_policy)};
options.clean = clean;
if (on_split.value == "error")
return {py::cast(unlocked(false, [&] { return cut_rsolid(operands, options); }))};
if (on_split.value != "pieces")
Expand All @@ -696,27 +697,29 @@ void bind_booleans(py::module_& m) {
return {solids_to_py(outcome.pieces)}; // the cut split the body: every piece, ordered by centroid
},
py::kw_only(), py::arg("skip_non_intersecting") = true, py::arg("tracking_policy") = "full",
py::arg("on_split") = "pieces",
py::arg("on_split") = "pieces", py::arg("clean") = true,
"Compute the boolean difference of solids: the first is the base, the rest are subtracted in order.");

m.def(
"cut_rsolid_list",
[](const py::args& solids, bool skip_non_intersecting, const Policy& tracking_policy) {
[](const py::args& solids, bool skip_non_intersecting, const Policy& tracking_policy, bool clean) {
const std::vector<Solid> operands = flattened(solids, "cut_rsolid_list");
const CutOptions options{skip_non_intersecting, policy_text(tracking_policy)};
CutOptions options{skip_non_intersecting, policy_text(tracking_policy)};
options.clean = clean;
return solids_to_py(unlocked(false, [&] { return cut_rsolid_list(operands, options); }));
},
py::kw_only(), py::arg("skip_non_intersecting") = true, py::arg("tracking_policy") = "full",
py::arg("clean") = true,
"Compute the boolean difference of solids and keep every piece: a cut that splits the body returns each part, "
"and one that consumes the body returns an empty list.");

m.def(
"intersect_rsolid",
[](const py::args& solids) {
[](const py::args& solids, bool clean) {
const std::vector<Solid> operands = flattened(solids, "intersect_rsolid");
return unlocked(false, [&] { return intersect_rsolid(operands); });
return unlocked(false, [&] { return intersect_rsolid(operands, clean); });
},
"Compute the boolean intersection of solids.");
py::kw_only(), py::arg("clean") = true, "Compute the boolean intersection of solids.");

m.def(
"boolean_rsolidlist",
Expand Down
12 changes: 8 additions & 4 deletions src/cpp/include/scad/kernel/booleans.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ struct BooleanOptions {
bool fill_history = false; // keep BRepTools_History + section edges
std::optional<double> fuzzy; // fuzzy tolerance (SetFuzzyValue)
bool glue = false; // BOPAlgo_GlueShift instead of GlueOff
// Unify the same-domain faces and edges of the result (what a cut leaves behind: a cylinder wall split along its
// seam, an edge split in two). The history, when kept, and the section edges follow the clean-up. A clean-up that
// fails, or that turns a valid result into an invalid one, is dropped: the raw result is the answer.
bool clean = false;
};

struct BooleanResult {
Expand Down Expand Up @@ -55,10 +59,10 @@ FuseHistoryResult fuse_shapes_with_history(const std::vector<TopoDS_Shape>& shap
TopoDS_Shape fuse_shapes(const std::vector<TopoDS_Shape>& shapes, bool glue = false,
std::optional<double> tol = std::nullopt, bool clean = true);

// body - every tool. `body` itself when there are no tools.
TopoDS_Shape cut_shapes(const TopoDS_Shape& body, const std::vector<TopoDS_Shape>& tools);
// body - every tool. `body` itself when there are no tools. With `clean`, the result is unified (see BooleanOptions).
TopoDS_Shape cut_shapes(const TopoDS_Shape& body, const std::vector<TopoDS_Shape>& tools, bool clean = false);

// Intersection of all shapes, folded left to right.
TopoDS_Shape common_shapes(const std::vector<TopoDS_Shape>& shapes);
// Intersection of all shapes, folded left to right. With `clean`, each step's result is unified.
TopoDS_Shape common_shapes(const std::vector<TopoDS_Shape>& shapes, bool clean = false);

} // namespace scad::kernel
7 changes: 5 additions & 2 deletions src/cpp/include/scad/ops/features/booleans.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ struct CutOptions {
// cut_rsolid_list: order the pieces by centroid (x, then y, then z) instead of kernel order, so piece i is the same
// piece on every OCCT version. Recorded as params.piece_order = "centroid" only when set.
bool order_pieces = false;
// Unify the faces and edges the cut leaves split (a cylinder wall cut along its seam), as union does: OCCT fails to
// fillet such a split face, or returns an invalid solid. Recorded as params.clean.
bool clean = true;
};

// What a cut that may split leaves: one Solid, or (`solid` empty) every piece, ordered by centroid.
Expand All @@ -47,8 +50,8 @@ std::vector<Solid> cut_rsolid_list(const std::vector<Solid>& solids, const CutOp
// cut_rsolid_list with order_pieces (recorded as make_cut_rsolid_list). An empty result is still an error.
CutOutcome cut_rsolid_keep_split(const std::vector<Solid>& solids, const CutOptions& options = {});

// The overlap of all `solids`. Error "intersect_rsolid".
Solid intersect_rsolid(const std::vector<Solid>& solids);
// The overlap of all `solids`; `clean` as for CutOptions. Error "intersect_rsolid".
Solid intersect_rsolid(const std::vector<Solid>& solids, bool clean = true);

// The error a non-Solid argument of union_rsolid / cut_rsolid / intersect_rsolid raises
// (a binding that flattens Python arguments throws it for an item that is not a solid).
Expand Down
13 changes: 12 additions & 1 deletion src/cpp/include/scad/ops/features/diagnostics.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ namespace scad {
// A classified fillet / chamfer failure.
struct BlendDiagnosis {
// "tangent_adjacent_faces" | "sliver_edges_in_selection" | "size_exceeds_face" |
// "partial_smooth_chain_on_periodic_face" | "blends_tangency_critical" | "vertex_blend_conflict"
// "partial_smooth_chain_on_periodic_face" | "blends_tangency_critical" | "vertex_blend_conflict" |
// "faces_split_on_one_surface"
std::string failure_kind;
std::string what_happened;
std::vector<std::string> possible_causes;
Expand Down Expand Up @@ -54,6 +55,16 @@ class BlendVolumeViolation : public ValueError {
void assert_blend_volume_monotonic(const Solid& source, const Solid& result, std::string_view operation_kind,
std::optional<double> size_value, const std::vector<Edge>& edges);

// Exit check of a blend, next to the volume check: the result must be a valid solid (BRepCheck_Analyzer) whenever
// the input was. OCCT reports `IsDone` for fillets it got wrong (a face split along a seam, issue #88) and hands back
// an invalid solid with a wrong volume, which only a later boolean or an export finds. Throws BlendValidityViolation.
class BlendValidityViolation : public BlendVolumeViolation {
public:
using BlendVolumeViolation::BlendVolumeViolation;
};
void assert_blend_valid(const Solid& source, const Solid& result, std::string_view operation_kind,
std::optional<double> size_value, const std::vector<Edge>& edges);

// A classified boolean failure.
struct BooleanDiagnosis {
std::string failure_kind; // "disjoint" | "non_manifold_contact"
Expand Down
9 changes: 5 additions & 4 deletions src/cpp/include/scad/semantic/tracking.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,9 @@ ModificationHistory modification_history(const BRepTools_History& history, const
// tracked_cut. Face history of body ("body") and tool ("tool"), plus the
// section edges. `solid` of the result is the *first* solid of the kernel's
// result, empty when the cut left none; membership is judged against the whole
// kernel result. ValueError when the algorithm did not complete.
TrackedBooleanResult tracked_cut(const Solid& body, const Solid& tool);
// kernel result. ValueError when the algorithm did not complete. With `clean` the result is unified (same-domain
// faces and edges merged, see kernel::BooleanOptions::clean) and the history follows the clean-up.
TrackedBooleanResult tracked_cut(const Solid& body, const Solid& tool, bool clean = false);

// tracked_union: a two-solid fuse with `track_union_history` on top. ValueError
// unless the fuse yields exactly one solid. Glue is opt-in (see kernel::fuse_shapes_with_history).
Expand All @@ -195,8 +196,8 @@ TrackedBooleanResult track_union_history(const std::vector<Solid>& solids, const
const BRepTools_History& history,
const std::vector<TopoDS_Shape>& section_edges = {});

// tracked_intersect.
TrackedBooleanResult tracked_intersect(const Solid& body, const Solid& tool);
// tracked_intersect. `clean` as for tracked_cut.
TrackedBooleanResult tracked_intersect(const Solid& body, const Solid& tool, bool clean = false);

namespace tracking {
// How many topology-history queries the tracked operations have made in this process. A graph-policy
Expand Down
95 changes: 68 additions & 27 deletions src/cpp/src/kernel/booleans.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include <BOPAlgo_GlueEnum.hxx>
#include <BOPAlgo_Operation.hxx>
#include <BRepAlgoAPI_BooleanOperation.hxx>
#include <BRepCheck_Analyzer.hxx>
#include <ShapeUpgrade_UnifySameDomain.hxx>
#include <Standard_Failure.hxx>
#include <TopTools_IndexedMapOfShape.hxx>
Expand Down Expand Up @@ -53,6 +54,62 @@ std::vector<TopoDS_Shape> tail_of(const std::vector<TopoDS_Shape>& shapes) {
return std::vector<TopoDS_Shape>(shapes.begin() + 1, shapes.end());
}

bool is_valid_shape(const TopoDS_Shape& shape) {
return !shape.IsNull() && BRepCheck_Analyzer(shape).IsValid() == Standard_True;
}

// Follow each section edge through a clean-up: it may have been merged into a longer edge or dropped with a removed
// face. Only the edges that are in `cleaned` survive, each once.
std::vector<TopoDS_Shape> follow_section_edges(const opencascade::handle<BRepTools_History>& clean_history,
const TopoDS_Shape& cleaned,
const std::vector<TopoDS_Shape>& section_edges) {
SubShapeIndex result(cleaned);
TopTools_IndexedMapOfShape seen;
std::vector<TopoDS_Shape> survivors;
for (const TopoDS_Shape& edge : section_edges) {
std::vector<TopoDS_Shape> candidates;
for (const TopoDS_Shape& s : clean_history->Modified(edge))
candidates.push_back(s);
for (const TopoDS_Shape& s : clean_history->Generated(edge))
candidates.push_back(s);
if (candidates.empty() && !clean_history->IsRemoved(edge))
candidates.push_back(edge);
for (const TopoDS_Shape& candidate : candidates) {
if (!result.contains(candidate))
continue;
const int before = seen.Extent();
seen.Add(candidate);
if (seen.Extent() > before)
survivors.push_back(candidate);
}
}
return survivors;
}

// BooleanOptions::clean. The unified shape replaces the result only when it is as good: the same number of solids, and
// valid whenever the raw result was (FreeCAD and build123d fall back to the raw result the same way; unifying every
// boolean result has been seen to invalidate some).
void unify_result(BooleanResult& result) {
try {
ShapeUpgrade_UnifySameDomain unifier(result.shape, /*UnifyEdges=*/true, /*UnifyFaces=*/true,
/*ConcatBSplines=*/true);
unifier.Build();
const TopoDS_Shape cleaned = unifier.Shape();
if (cleaned.IsNull() || solids_of(cleaned).size() != solids_of(result.shape).size())
return;
if (!is_valid_shape(cleaned) && is_valid_shape(result.shape))
return;
if (!result.history.IsNull()) {
const opencascade::handle<BRepTools_History> clean_history = unifier.History();
result.history->Merge(clean_history);
result.section_edges = follow_section_edges(clean_history, cleaned, result.section_edges);
}
result.shape = cleaned;
} catch (const Standard_Failure&) {
// the raw result stands
}
}

} // namespace

BooleanResult run_boolean(BooleanOp op, const TopoDS_Shape& argument, const std::vector<TopoDS_Shape>& tools,
Expand Down Expand Up @@ -86,6 +143,8 @@ BooleanResult run_boolean(BooleanOp op, const TopoDS_Shape& argument, const std:
out.section_edges.clear();
}
}
if (options.clean)
unify_result(out);
return out;
});
}
Expand Down Expand Up @@ -138,29 +197,7 @@ FuseHistoryResult fuse_shapes_with_history(const std::vector<TopoDS_Shape>& shap
const opencascade::handle<BRepTools_History> clean_history = unifier.History();
out.history->Merge(clean_history);

// Follow each section edge through the clean-up: it may have been merged
// into a longer edge or dropped with a removed face.
SubShapeIndex result(out.shape);
TopTools_IndexedMapOfShape seen;
std::vector<TopoDS_Shape> survivors;
for (const TopoDS_Shape& edge : out.section_edges) {
std::vector<TopoDS_Shape> candidates;
for (const TopoDS_Shape& s : clean_history->Modified(edge))
candidates.push_back(s);
for (const TopoDS_Shape& s : clean_history->Generated(edge))
candidates.push_back(s);
if (candidates.empty() && !clean_history->IsRemoved(edge))
candidates.push_back(edge);
for (const TopoDS_Shape& candidate : candidates) {
if (!result.contains(candidate))
continue;
const int before = seen.Extent();
seen.Add(candidate);
if (seen.Extent() > before)
survivors.push_back(candidate);
}
}
out.section_edges = std::move(survivors);
out.section_edges = follow_section_edges(clean_history, out.shape, out.section_edges);
return out;
});
}
Expand All @@ -182,21 +219,25 @@ TopoDS_Shape fuse_shapes(const std::vector<TopoDS_Shape>& shapes, bool glue, std
return clean ? clean_shape(fused.shape) : fused.shape;
}

TopoDS_Shape cut_shapes(const TopoDS_Shape& body, const std::vector<TopoDS_Shape>& tools) {
TopoDS_Shape cut_shapes(const TopoDS_Shape& body, const std::vector<TopoDS_Shape>& tools, bool clean) {
if (tools.empty())
return body;
const BooleanResult cut = run_boolean(BooleanOp::Cut, body, tools);
BooleanOptions options;
options.clean = clean;
const BooleanResult cut = run_boolean(BooleanOp::Cut, body, tools, options);
if (!cut.done)
throw ValueError("OCP cut failed");
return cut.shape;
}

TopoDS_Shape common_shapes(const std::vector<TopoDS_Shape>& shapes) {
TopoDS_Shape common_shapes(const std::vector<TopoDS_Shape>& shapes, bool clean) {
if (shapes.empty())
throw ValueError("common_shapes requires at least one shape");
TopoDS_Shape result = shapes.front();
for (std::size_t i = 1; i < shapes.size(); ++i) {
const BooleanResult common = run_boolean(BooleanOp::Common, result, {shapes[i]});
BooleanOptions options;
options.clean = clean;
const BooleanResult common = run_boolean(BooleanOp::Common, result, {shapes[i]}, options);
if (!common.done)
throw ValueError("OCP common failed");
result = common.shape;
Expand Down
Loading
Loading