From 0ce43a94aa8cc21eade03bf15bcf710a58b6d5ad Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 10 Aug 2026 20:09:29 +0200 Subject: [PATCH] 10C Inc 2: feature tail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 119 of the app's 138 symbols now resolve; only the I/O modules and the Inc 4 tail are left. New: GCE2d, GCPnts, BRepFilletAPI, BRepOffsetAPI, ShapeAnalysis, ShapeFix, ShapeUpgrade, BRepCheck, BRepTools, BRepLib, BRepExtrema, BRepClass3d, IntCurvesFace, plus TopTools_HSequenceOfShape. The fillet and chamfer builders derive BRepBuilderAPI_MakeShape, so their history comes from the base bound in Inc 1 — which is what lets the app read a blend's provenance the same way it reads a boolean's. The Inc 2 gate compares that history exactly, alongside the splitter's, which is what sketch-region attribution depends on. Two shapes of deviation, both commented where they are bound: - Trailing enum arguments are left off four constructors (fillet's ChFi3d_FilletShape, MakeThickSolidByJoin's mode/join pair, BRepExtrema's Extrema flags, and Inc 1's GeomAPI_ProjectPointOnSurf). An unregistered enum cannot serve as a default argument — nanobind converts defaults at binding time, so it fails the whole module's import with std::bad_cast. The app never overrides any of them, so OCCT's own defaults apply and behaviour is unchanged. - ShapeAnalysis_FreeBounds.ConnectEdgesToWires reassigns a Handle& out parameter, which the caster cannot honour because it hands C++ a copy of the handle. The lambda splices the result into the sequence the caller passed, so the pass-empty-then-read shape the app uses still works. This is the one place upstream's holder semantics and ours differ observably. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DfriM8XUkn7uYf5Dwe2xo6 --- CMakeLists.txt | 5 + pyproject.toml | 2 +- src/core.cpp | 29 ++++ src/modules/mod_BRepCheck.cpp | 89 ++++++++++++ src/modules/mod_BRepClass3d.cpp | 31 +++++ src/modules/mod_BRepExtrema.cpp | 34 +++++ src/modules/mod_BRepFilletAPI.cpp | 66 +++++++++ src/modules/mod_BRepLib.cpp | 31 +++++ src/modules/mod_BRepOffsetAPI.cpp | 86 ++++++++++++ src/modules/mod_BRepTools.cpp | 70 ++++++++++ src/modules/mod_GCE2d.cpp | 30 ++++ src/modules/mod_GCPnts.cpp | 61 ++++++++ src/modules/mod_IntCurvesFace.cpp | 42 ++++++ src/modules/mod_ShapeAnalysis.cpp | 61 ++++++++ src/modules/mod_ShapeFix.cpp | 63 +++++++++ src/modules/mod_ShapeUpgrade.cpp | 35 +++++ src/modules/mod_TopTools.cpp | 24 ++++ tests/data/manifest.json | 192 +++++++++++++++++++++++++ tests/test_inc2_features.py | 224 ++++++++++++++++++++++++++++++ tools/gen_fixtures.py | 87 ++++++++++++ 20 files changed, 1261 insertions(+), 1 deletion(-) create mode 100644 src/modules/mod_BRepCheck.cpp create mode 100644 src/modules/mod_BRepClass3d.cpp create mode 100644 src/modules/mod_BRepExtrema.cpp create mode 100644 src/modules/mod_BRepFilletAPI.cpp create mode 100644 src/modules/mod_BRepLib.cpp create mode 100644 src/modules/mod_BRepOffsetAPI.cpp create mode 100644 src/modules/mod_BRepTools.cpp create mode 100644 src/modules/mod_GCE2d.cpp create mode 100644 src/modules/mod_GCPnts.cpp create mode 100644 src/modules/mod_IntCurvesFace.cpp create mode 100644 src/modules/mod_ShapeAnalysis.cpp create mode 100644 src/modules/mod_ShapeFix.cpp create mode 100644 src/modules/mod_ShapeUpgrade.cpp create mode 100644 tests/test_inc2_features.py diff --git a/CMakeLists.txt b/CMakeLists.txt index aa2b75a..268e73e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,6 +60,11 @@ target_link_libraries(_OCP PRIVATE TKPrim # BRepPrimAPI TKBO # BRepAlgoAPI, BOPAlgo TKMesh # BRepMesh + # Inc 2 + TKFillet # BRepFilletAPI + TKOffset # BRepOffsetAPI + TKShHealing # ShapeFix, ShapeAnalysis, ShapeUpgrade + TKBool # BRepAlgo helpers pulled in by the above ) target_compile_definitions(_OCP PRIVATE diff --git a/pyproject.toml b/pyproject.toml index 3048c97..c68be4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ name = "n3xd-ocp" # .devN suffix: the registry never allows republishing a version, and its # cleanup rule collects only the dev ones. CMake asserts the prefix matches # the OCCT it found. -version = "7.9.3.1.dev2" +version = "7.9.3.1.dev3" description = "nanobind bindings for the OpenCASCADE geometry kernel (drop-in OCP)" readme = "README.md" requires-python = ">=3.12" diff --git a/src/core.cpp b/src/core.cpp index bb21826..7b0fb31 100644 --- a/src/core.cpp +++ b/src/core.cpp @@ -52,6 +52,20 @@ void register_BRepAlgoAPI(nb::module_ &); void register_BRepPrimAPI(nb::module_ &); void register_GC(nb::module_ &); void register_BRepMesh(nb::module_ &); +// Inc 2 — feature tail +void register_GCE2d(nb::module_ &); +void register_GCPnts(nb::module_ &); +void register_BRepFilletAPI(nb::module_ &); +void register_BRepOffsetAPI(nb::module_ &); +void register_ShapeAnalysis(nb::module_ &); +void register_ShapeFix(nb::module_ &); +void register_ShapeUpgrade(nb::module_ &); +void register_BRepCheck(nb::module_ &); +void register_BRepTools(nb::module_ &); +void register_BRepLib(nb::module_ &); +void register_BRepExtrema(nb::module_ &); +void register_BRepClass3d(nb::module_ &); +void register_IntCurvesFace(nb::module_ &); void register_ext(nb::module_ &); NB_MODULE(_OCP, m) { @@ -95,6 +109,21 @@ NB_MODULE(_OCP, m) { register_GC(m); register_BRepMesh(m); + // Inc 2. + register_GCE2d(m); + register_GCPnts(m); + register_BRepFilletAPI(m); + register_BRepOffsetAPI(m); + register_ShapeAnalysis(m); + register_ShapeFix(m); + register_ShapeUpgrade(m); + register_BRepCheck(m); + register_BRepTools(m); + register_BRepLib(m); + register_BRepExtrema(m); + register_BRepClass3d(m); + register_IntCurvesFace(m); + register_ext(m); m.attr("__all_modules__") = ocp_module_registry(); diff --git a/src/modules/mod_BRepCheck.cpp b/src/modules/mod_BRepCheck.cpp new file mode 100644 index 0000000..a4d3cb9 --- /dev/null +++ b/src/modules/mod_BRepCheck.cpp @@ -0,0 +1,89 @@ +/* + OCP.BRepCheck — shape validity. + + The analyzer runs on every preview and after every boolean. Its + constructor does the work, so that is what releases the GIL; the per-face + Result() lookups afterwards are cheap. + + Only the two status values the app tests for are worth calling out — + it names the self-intersecting/intersecting-wire pair when deciding whether + an imported shape is repairable — but the whole enum is bound, since it is + what Result() reports through. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include +#include +#include +#include + +void register_BRepCheck(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "BRepCheck"); + + nb::enum_(m, "BRepCheck_Status", nb::is_arithmetic()) + .value("BRepCheck_NoError", BRepCheck_NoError) + .value("BRepCheck_InvalidPointOnCurve", BRepCheck_InvalidPointOnCurve) + .value("BRepCheck_InvalidPointOnCurveOnSurface", + BRepCheck_InvalidPointOnCurveOnSurface) + .value("BRepCheck_InvalidPointOnSurface", BRepCheck_InvalidPointOnSurface) + .value("BRepCheck_No3DCurve", BRepCheck_No3DCurve) + .value("BRepCheck_Multiple3DCurve", BRepCheck_Multiple3DCurve) + .value("BRepCheck_Invalid3DCurve", BRepCheck_Invalid3DCurve) + .value("BRepCheck_NoCurveOnSurface", BRepCheck_NoCurveOnSurface) + .value("BRepCheck_InvalidCurveOnSurface", BRepCheck_InvalidCurveOnSurface) + .value("BRepCheck_InvalidCurveOnClosedSurface", + BRepCheck_InvalidCurveOnClosedSurface) + .value("BRepCheck_InvalidSameRangeFlag", BRepCheck_InvalidSameRangeFlag) + .value("BRepCheck_InvalidSameParameterFlag", + BRepCheck_InvalidSameParameterFlag) + .value("BRepCheck_InvalidDegeneratedFlag", BRepCheck_InvalidDegeneratedFlag) + .value("BRepCheck_FreeEdge", BRepCheck_FreeEdge) + .value("BRepCheck_InvalidMultiConnexity", BRepCheck_InvalidMultiConnexity) + .value("BRepCheck_InvalidRange", BRepCheck_InvalidRange) + .value("BRepCheck_EmptyWire", BRepCheck_EmptyWire) + .value("BRepCheck_RedundantEdge", BRepCheck_RedundantEdge) + .value("BRepCheck_SelfIntersectingWire", BRepCheck_SelfIntersectingWire) + .value("BRepCheck_NoSurface", BRepCheck_NoSurface) + .value("BRepCheck_InvalidWire", BRepCheck_InvalidWire) + .value("BRepCheck_RedundantWire", BRepCheck_RedundantWire) + .value("BRepCheck_IntersectingWires", BRepCheck_IntersectingWires) + .value("BRepCheck_InvalidImbricationOfWires", + BRepCheck_InvalidImbricationOfWires) + .value("BRepCheck_EmptyShell", BRepCheck_EmptyShell) + .value("BRepCheck_RedundantFace", BRepCheck_RedundantFace) + .value("BRepCheck_InvalidImbricationOfShells", + BRepCheck_InvalidImbricationOfShells) + .value("BRepCheck_UnorientableShape", BRepCheck_UnorientableShape) + .value("BRepCheck_NotClosed", BRepCheck_NotClosed) + .value("BRepCheck_NotConnected", BRepCheck_NotConnected) + .value("BRepCheck_SubshapeNotInShape", BRepCheck_SubshapeNotInShape) + .value("BRepCheck_BadOrientation", BRepCheck_BadOrientation) + .value("BRepCheck_BadOrientationOfSubshape", + BRepCheck_BadOrientationOfSubshape) + .value("BRepCheck_InvalidPolygonOnTriangulation", + BRepCheck_InvalidPolygonOnTriangulation) + .value("BRepCheck_InvalidToleranceValue", BRepCheck_InvalidToleranceValue) + .value("BRepCheck_EnclosedRegion", BRepCheck_EnclosedRegion) + .value("BRepCheck_CheckFail", BRepCheck_CheckFail) + .export_values(); + + nb::class_(m, "BRepCheck_Result") + .def("Status", &BRepCheck_Result::Status, OCP_RETURN_COPY) + .def("IsMinimum", &BRepCheck_Result::IsMinimum); + + nb::class_(m, "BRepCheck_Analyzer") + .def(nb::init(), + "S"_a, "GeomControls"_a = Standard_True, + "theIsParallel"_a = Standard_False, + "theIsExact"_a = Standard_False, OCP_NOGIL) + .def("IsValid", nb::overload_cast<>(&BRepCheck_Analyzer::IsValid, + nb::const_)) + .def("IsValid", + nb::overload_cast(&BRepCheck_Analyzer::IsValid, + nb::const_), + "S"_a) + .def("Result", &BRepCheck_Analyzer::Result, "theSubS"_a); +} diff --git a/src/modules/mod_BRepClass3d.cpp b/src/modules/mod_BRepClass3d.cpp new file mode 100644 index 0000000..31bacaa --- /dev/null +++ b/src/modules/mod_BRepClass3d.cpp @@ -0,0 +1,31 @@ +/* + OCP.BRepClass3d — point-in-solid classification. + + Answers "is this point inside the material", which the app uses to decide + which side of a face to grow a feature from and to verify that a thread + groove did not gouge its shoulder. The three-argument constructor + classifies immediately; the default one needs a separate Perform. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include +#include +#include + +void register_BRepClass3d(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "BRepClass3d"); + + nb::class_(m, "BRepClass3d_SolidClassifier") + .def(nb::init<>()) + .def(nb::init(), "S"_a, OCP_NOGIL) + .def(nb::init(), + "S"_a, "P"_a, "Tol"_a, OCP_NOGIL) + .def("Load", &BRepClass3d_SolidClassifier::Load, "S"_a, OCP_NOGIL) + .def("Perform", &BRepClass3d_SolidClassifier::Perform, "P"_a, "Tol"_a, + OCP_NOGIL) + .def("State", &BRepClass3d_SolidClassifier::State) + .def("IsOnAFace", &BRepClass3d_SolidClassifier::IsOnAFace); +} diff --git a/src/modules/mod_BRepExtrema.cpp b/src/modules/mod_BRepExtrema.cpp new file mode 100644 index 0000000..f7fc30a --- /dev/null +++ b/src/modules/mod_BRepExtrema.cpp @@ -0,0 +1,34 @@ +/* + OCP.BRepExtrema — minimum distance between two shapes. + + Used by the measurement tools and the interference check. The constructor + performs the search, so it releases the GIL. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include +#include +#include + +void register_BRepExtrema(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "BRepExtrema"); + + nb::class_(m, "BRepExtrema_DistShapeShape") + .def(nb::init<>()) + // Without the Extrema flag/algo overload: unregistered enums cannot + // serve as default arguments, and the app takes OCCT's defaults. + .def(nb::init(), "Shape1"_a, + "Shape2"_a, OCP_NOGIL) + .def( + "Perform", [](BRepExtrema_DistShapeShape &self) { self.Perform(); }, + OCP_NOGIL) + .def("IsDone", &BRepExtrema_DistShapeShape::IsDone) + .def("NbSolution", &BRepExtrema_DistShapeShape::NbSolution) + .def("Value", &BRepExtrema_DistShapeShape::Value) + .def("PointOnShape1", &BRepExtrema_DistShapeShape::PointOnShape1, "N"_a, + OCP_RETURN_COPY) + .def("PointOnShape2", &BRepExtrema_DistShapeShape::PointOnShape2, "N"_a, + OCP_RETURN_COPY); +} diff --git a/src/modules/mod_BRepFilletAPI.cpp b/src/modules/mod_BRepFilletAPI.cpp new file mode 100644 index 0000000..b1f8d8e --- /dev/null +++ b/src/modules/mod_BRepFilletAPI.cpp @@ -0,0 +1,66 @@ +/* + OCP.BRepFilletAPI — fillets and chamfers. + + Both derive BRepBuilderAPI_MakeShape, so Build/Shape and — importantly — + the Generated/Modified/IsDeleted history come from there. The app reads + that history to attribute the blend faces back to the feature that made + them, the same way it reads a boolean's. + + These are the two operations the app runs in an isolated subprocess, since + OCCT can hard-fault on a degenerate blend; nothing here changes that, but + releasing the GIL on Build() is what lets the pool overlap work. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include +#include +#include +#include +#include +#include + +void register_BRepFilletAPI(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "BRepFilletAPI"); + + nb::class_( + m, "BRepFilletAPI_MakeFillet") + // The ChFi3d_FilletShape overload is left out: it exists only to + // carry a default the app never overrides, and binding an unregistered + // enum as a default argument fails at module-init time. + .def(nb::init(), "S"_a) + .def("Add", + nb::overload_cast( + &BRepFilletAPI_MakeFillet::Add), + "Radius"_a, "E"_a) + .def("Add", + nb::overload_cast( + &BRepFilletAPI_MakeFillet::Add), + "R1"_a, "R2"_a, "E"_a) + .def("NbContours", &BRepFilletAPI_MakeFillet::NbContours) + .def("Reset", &BRepFilletAPI_MakeFillet::Reset); + + nb::class_( + m, "BRepFilletAPI_MakeChamfer") + .def(nb::init(), "S"_a) + .def("Add", + nb::overload_cast( + &BRepFilletAPI_MakeChamfer::Add), + "E"_a) + .def("Add", + nb::overload_cast( + &BRepFilletAPI_MakeChamfer::Add), + "Dis"_a, "E"_a) + .def("Add", + nb::overload_cast( + &BRepFilletAPI_MakeChamfer::Add), + "Dis1"_a, "Dis2"_a, "E"_a, "F"_a) + // Distance-and-angle, which is how the app words an asymmetric chamfer. + .def("AddDA", &BRepFilletAPI_MakeChamfer::AddDA, "Dis"_a, "Angle"_a, + "E"_a, "F"_a) + .def("NbContours", &BRepFilletAPI_MakeChamfer::NbContours) + .def("Reset", &BRepFilletAPI_MakeChamfer::Reset); +} diff --git a/src/modules/mod_BRepLib.cpp b/src/modules/mod_BRepLib.cpp new file mode 100644 index 0000000..00bec96 --- /dev/null +++ b/src/modules/mod_BRepLib.cpp @@ -0,0 +1,31 @@ +/* + OCP.BRepLib — repairs applied to shapes built edge by edge. + + BuildCurves3d recovers the 3D curve of an edge that only carries a pcurve, + which is how the thread's helical edges (laid out in the cylinder's UV + space) become real geometry. OrientClosedSolid fixes a solid built from a + sewn shell whose orientation came out inward. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include +#include +#include + +void register_BRepLib(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "BRepLib"); + + nb::class_ cls(m, "BRepLib"); + + OCP_DEF_S( + cls, "BuildCurves3d", + [](const TopoDS_Shape &S) { return BRepLib::BuildCurves3d(S); }, "S"_a, + OCP_NOGIL); + + OCP_DEF_S( + cls, "OrientClosedSolid", + [](TopoDS_Solid &solid) { return BRepLib::OrientClosedSolid(solid); }, + "solid"_a, OCP_NOGIL); +} diff --git a/src/modules/mod_BRepOffsetAPI.cpp b/src/modules/mod_BRepOffsetAPI.cpp new file mode 100644 index 0000000..3b9af93 --- /dev/null +++ b/src/modules/mod_BRepOffsetAPI.cpp @@ -0,0 +1,86 @@ +/* + OCP.BRepOffsetAPI — sweeps, lofts, shells and drafts. + + All five derive BRepBuilderAPI_MakeShape, so Build/Shape/history come from + there. MakeThickSolid is the one with an unusual shape: it is default + constructed and then told what to do by MakeThickSolidBySimple (a sheet + thickened into a wall) or MakeThickSolidByJoin (a solid hollowed with + faces removed), each of which does the work — so those get the GIL release + rather than Build(). +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void register_BRepOffsetAPI(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "BRepOffsetAPI"); + + nb::class_( + m, "BRepOffsetAPI_ThruSections") + .def(nb::init(), + "isSolid"_a = Standard_False, "ruled"_a = Standard_False, + "pres3d"_a = 1.0e-06) + .def("AddWire", &BRepOffsetAPI_ThruSections::AddWire, "wire"_a) + .def("CheckCompatibility", + &BRepOffsetAPI_ThruSections::CheckCompatibility, "check"_a = true); + + nb::class_( + m, "BRepOffsetAPI_MakePipe") + .def(nb::init(), "Spine"_a, + "Profile"_a, OCP_NOGIL); + + nb::class_( + m, "BRepOffsetAPI_MakePipeShell") + .def(nb::init(), "Spine"_a) + .def("SetMode", + nb::overload_cast( + &BRepOffsetAPI_MakePipeShell::SetMode), + "IsFrenet"_a = Standard_False) + .def("Add", + nb::overload_cast( + &BRepOffsetAPI_MakePipeShell::Add), + "Profile"_a, "WithContact"_a = Standard_False, + "WithCorrection"_a = Standard_False) + .def("MakeSolid", &BRepOffsetAPI_MakePipeShell::MakeSolid, OCP_NOGIL); + + nb::class_( + m, "BRepOffsetAPI_MakeThickSolid") + .def(nb::init<>()) + .def("MakeThickSolidBySimple", + &BRepOffsetAPI_MakeThickSolid::MakeThickSolidBySimple, "theS"_a, + "theOffsetValue"_a, OCP_NOGIL) + // The trailing mode/join arguments are left off: they are enums the + // app never overrides, and an unregistered enum used as a default + // argument fails at module-init time. OCCT's own defaults apply. + .def( + "MakeThickSolidByJoin", + [](BRepOffsetAPI_MakeThickSolid &self, const TopoDS_Shape &S, + const TopTools_ListOfShape &ClosingFaces, + const Standard_Real Offset, const Standard_Real Tol) { + self.MakeThickSolidByJoin(S, ClosingFaces, Offset, Tol); + }, + "S"_a, "ClosingFaces"_a, "Offset"_a, "Tol"_a, OCP_NOGIL); + + nb::class_( + m, "BRepOffsetAPI_DraftAngle") + .def(nb::init(), "S"_a) + .def("Add", &BRepOffsetAPI_DraftAngle::Add, "F"_a, "D"_a, "Angle"_a, + "NeutralPlane"_a, "Flag"_a = Standard_True) + .def("AddDone", &BRepOffsetAPI_DraftAngle::AddDone) + .def("Remove", &BRepOffsetAPI_DraftAngle::Remove, "F"_a); +} diff --git a/src/modules/mod_BRepTools.cpp b/src/modules/mod_BRepTools.cpp new file mode 100644 index 0000000..69c0316 --- /dev/null +++ b/src/modules/mod_BRepTools.cpp @@ -0,0 +1,70 @@ +/* + OCP.BRepTools — assorted topology utilities. + + Clean drops an existing triangulation, which the tessellation path does + before re-meshing at a different deflection (OCCT keeps whatever mesh a + shape already carries, so without this the second pass is a no-op). + OuterWire and UVBounds are queries; WireExplorer walks a wire's edges in + connection order rather than map order, which is what wire-following code + needs. + + UVBounds reports four scalar out-parameters, so it returns a tuple — + upstream does the same, and `umin, umax, vmin, vmax = ...` is how the app + calls it. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include + +#include +#include +#include +#include +#include + +void register_BRepTools(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "BRepTools"); + + nb::class_(m, "BRepTools_WireExplorer") + .def(nb::init<>()) + .def(nb::init(), "W"_a) + .def(nb::init(), "W"_a, "F"_a) + .def("Init", nb::overload_cast( + &BRepTools_WireExplorer::Init), + "W"_a) + .def("More", &BRepTools_WireExplorer::More) + .def("Next", &BRepTools_WireExplorer::Next) + .def("Current", &BRepTools_WireExplorer::Current, OCP_RETURN_COPY) + .def("Orientation", &BRepTools_WireExplorer::Orientation) + .def("CurrentVertex", &BRepTools_WireExplorer::CurrentVertex, + OCP_RETURN_COPY) + .def("Clear", &BRepTools_WireExplorer::Clear); + + nb::class_ cls(m, "BRepTools"); + + OCP_DEF_S( + cls, "Clean", + [](const TopoDS_Shape &S, const Standard_Boolean force) { + BRepTools::Clean(S, force); + }, + "S"_a, "force"_a = Standard_False, OCP_NOGIL); + + OCP_DEF_S( + cls, "OuterWire", + [](const TopoDS_Face &F) { return BRepTools::OuterWire(F); }, "F"_a); + + OCP_DEF_S( + cls, "UVBounds", + [](const TopoDS_Face &F) { + Standard_Real umin = 0.0, umax = 0.0, vmin = 0.0, vmax = 0.0; + BRepTools::UVBounds(F, umin, umax, vmin, vmax); + return std::make_tuple(umin, umax, vmin, vmax); + }, + "F"_a); + + OCP_DEF_S( + cls, "Update", + [](const TopoDS_Shape &S) { BRepTools::Update(S); }, "S"_a); +} diff --git a/src/modules/mod_GCE2d.cpp b/src/modules/mod_GCE2d.cpp new file mode 100644 index 0000000..cf387c3 --- /dev/null +++ b/src/modules/mod_GCE2d.cpp @@ -0,0 +1,30 @@ +/* + OCP.GCE2d — 2D geometry constructors. + + Used to lay curves out in a surface's UV space before lifting them onto it + with BRepBuilderAPI_MakeEdge: a thread's helix is a straight line in the + cylinder's parameter space, and curved text emboss draws its glyph + outlines the same way. +*/ + +#include "../common/occt_module.h" + +#include +#include +#include +#include + +void register_GCE2d(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "GCE2d"); + + nb::class_(m, "GCE2d_MakeLine") + .def(nb::init(), "P"_a, "V"_a) + .def(nb::init(), "P1"_a, "P2"_a) + .def("IsDone", &GCE2d_MakeLine::IsDone) + .def("Value", &GCE2d_MakeLine::Value); + + nb::class_(m, "GCE2d_MakeSegment") + .def(nb::init(), "P1"_a, "P2"_a) + .def("IsDone", &GCE2d_MakeSegment::IsDone) + .def("Value", &GCE2d_MakeSegment::Value); +} diff --git a/src/modules/mod_GCPnts.cpp b/src/modules/mod_GCPnts.cpp new file mode 100644 index 0000000..f34c7ca --- /dev/null +++ b/src/modules/mod_GCPnts.cpp @@ -0,0 +1,61 @@ +/* + OCP.GCPnts — curve discretisation. + + TangentialDeflection is what turns every model edge into the polyline the + viewport draws, so it runs once per edge on every tessellation; AbscissaPoint + measures arc length for the edge anchors. Both take an Adaptor3d_Curve, + whose methods are bound on the base in mod_Adaptor3d.cpp. + + The constructors compute — there is no deferred form — so they bind as + stock does. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include +#include +#include +#include +#include + +void register_GCPnts(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "GCPnts"); + + nb::class_(m, "GCPnts_TangentialDeflection") + .def(nb::init(), + "C"_a, "AngularDeflection"_a, "CurvatureDeflection"_a, + "MinimumOfPoints"_a = 2, "UTol"_a = 1.0e-9, + "theMinLen"_a = 1.0e-7, OCP_NOGIL) + .def("NbPoints", &GCPnts_TangentialDeflection::NbPoints) + .def("Value", &GCPnts_TangentialDeflection::Value, "I"_a, + OCP_RETURN_COPY) + .def("Parameter", &GCPnts_TangentialDeflection::Parameter, "I"_a); + + nb::class_(m, "GCPnts_UniformDeflection") + .def(nb::init<>()) + .def(nb::init(), + "C"_a, "Deflection"_a, "WithControl"_a = Standard_True, OCP_NOGIL) + .def("IsDone", &GCPnts_UniformDeflection::IsDone) + .def("NbPoints", &GCPnts_UniformDeflection::NbPoints) + .def("Value", &GCPnts_UniformDeflection::Value, "Index"_a, + OCP_RETURN_COPY) + .def("Parameter", &GCPnts_UniformDeflection::Parameter, "Index"_a); + + nb::class_ abscissa(m, "GCPnts_AbscissaPoint"); + abscissa.def(nb::init<>()); + OCP_DEF_S( + abscissa, "Length", + [](const Adaptor3d_Curve &C) { return GCPnts_AbscissaPoint::Length(C); }, + "C"_a, OCP_NOGIL); + OCP_DEF_S( + abscissa, "Length", + [](const Adaptor3d_Curve &C, const Standard_Real U1, + const Standard_Real U2) { + return GCPnts_AbscissaPoint::Length(C, U1, U2); + }, + "C"_a, "U1"_a, "U2"_a, OCP_NOGIL); +} diff --git a/src/modules/mod_IntCurvesFace.cpp b/src/modules/mod_IntCurvesFace.cpp new file mode 100644 index 0000000..55cb531 --- /dev/null +++ b/src/modules/mod_IntCurvesFace.cpp @@ -0,0 +1,42 @@ +/* + OCP.IntCurvesFace — ray casting against a shape. + + The surface-pattern feature fires a ray from each sample point along the + inverted normal to find the opposite wall, which is how it measures local + wall thickness. Load prepares the shape once; PerformNearest runs per ray + inside a Python loop, so both release the GIL but neither is a long call. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include +#include +#include +#include + +void register_IntCurvesFace(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "IntCurvesFace"); + + nb::class_(m, + "IntCurvesFace_ShapeIntersector") + .def(nb::init<>()) + .def("Load", &IntCurvesFace_ShapeIntersector::Load, "Sh"_a, "Tol"_a, + OCP_NOGIL) + .def("PerformNearest", + nb::overload_cast( + &IntCurvesFace_ShapeIntersector::PerformNearest), + "L"_a, "PInf"_a, "PSup"_a, OCP_NOGIL) + .def("Perform", + nb::overload_cast( + &IntCurvesFace_ShapeIntersector::Perform), + "L"_a, "PInf"_a, "PSup"_a, OCP_NOGIL) + .def("IsDone", &IntCurvesFace_ShapeIntersector::IsDone) + .def("NbPnt", &IntCurvesFace_ShapeIntersector::NbPnt) + .def("Pnt", &IntCurvesFace_ShapeIntersector::Pnt, "I"_a, OCP_RETURN_COPY) + .def("WParameter", &IntCurvesFace_ShapeIntersector::WParameter, "I"_a) + .def("Face", &IntCurvesFace_ShapeIntersector::Face, "I"_a, + OCP_RETURN_COPY); +} diff --git a/src/modules/mod_ShapeAnalysis.cpp b/src/modules/mod_ShapeAnalysis.cpp new file mode 100644 index 0000000..a687d72 --- /dev/null +++ b/src/modules/mod_ShapeAnalysis.cpp @@ -0,0 +1,61 @@ +/* + OCP.ShapeAnalysis — free-boundary detection. + + One use: import health counts the edges bounding a single face after a + virtual sew, which is how it tells a watertight solid from a holed shell. + + ConnectEdgesToWires is the one place upstream's holder semantics and this + binding's caster differ observably. Its `wires` parameter is a + Handle& that the function *reassigns*, and the caster hands C++ a copy of + the handle rather than a reference into the Python instance — so the + reassignment would be invisible. The lambda splices the result back into + the sequence the caller passed, preserving the pass-empty-then-read shape + the app uses. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include +#include +#include +#include + +void register_ShapeAnalysis(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "ShapeAnalysis"); + + nb::class_ cls(m, "ShapeAnalysis_FreeBounds"); + cls.def(nb::init<>()) + .def(nb::init(), + "shape"_a, "toler"_a, "splitclosed"_a = Standard_False, + "splitopen"_a = Standard_True, OCP_NOGIL) + .def(nb::init(), + "shape"_a, "splitclosed"_a = Standard_False, + "splitopen"_a = Standard_True, + "checkinternaledges"_a = Standard_False, OCP_NOGIL) + .def("GetClosedWires", &ShapeAnalysis_FreeBounds::GetClosedWires, + OCP_RETURN_COPY) + .def("GetOpenWires", &ShapeAnalysis_FreeBounds::GetOpenWires, + OCP_RETURN_COPY); + + OCP_DEF_S( + cls, "ConnectEdgesToWires", + [](opencascade::handle &edges, + const Standard_Real toler, const Standard_Boolean shared, + opencascade::handle &wires) { + opencascade::handle out; + ShapeAnalysis_FreeBounds::ConnectEdgesToWires(edges, toler, shared, + out); + // The caller keeps its own sequence object; fill that rather than + // rebinding a handle it cannot see. + if (!wires.IsNull()) { + wires->Clear(); + if (!out.IsNull()) { + wires->Append(out->ChangeSequence()); + } + } + }, + "edges"_a, "toler"_a, "shared"_a, "wires"_a, OCP_NOGIL); +} diff --git a/src/modules/mod_ShapeFix.cpp b/src/modules/mod_ShapeFix.cpp new file mode 100644 index 0000000..36432b5 --- /dev/null +++ b/src/modules/mod_ShapeFix.cpp @@ -0,0 +1,63 @@ +/* + OCP.ShapeFix — geometry healing. + + Used on imported geometry and after the operations that are known to + produce slightly invalid output (curved text emboss, thickened sheets). + These are transients, so their constructors go through ocp_new. + + Perform() is where the work happens, not Build(), so that is what releases + the GIL. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" +#include "../common/occt_transient.h" + +#include +#include +#include +#include +#include +#include +#include + +void register_ShapeFix(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "ShapeFix"); + + nb::class_(m, "ShapeFix_Shape") + .def(ocp_new()) + .def(ocp_new(), "shape"_a) + .def("Init", nb::overload_cast(&ShapeFix_Shape::Init), + "shape"_a) + .def( + "Perform", [](ShapeFix_Shape &self) { return self.Perform(); }, + OCP_NOGIL) + .def("Shape", &ShapeFix_Shape::Shape, OCP_RETURN_COPY) + .def("SetPrecision", &ShapeFix_Shape::SetPrecision, "preci"_a) + .def("SetMinTolerance", &ShapeFix_Shape::SetMinTolerance, "mintol"_a) + .def("SetMaxTolerance", &ShapeFix_Shape::SetMaxTolerance, "maxtol"_a); + + nb::class_(m, "ShapeFix_Face") + .def(ocp_new()) + .def(ocp_new(), "face"_a) + .def( + "Perform", [](ShapeFix_Face &self) { return self.Perform(); }, + OCP_NOGIL) + .def("FixOrientation", + nb::overload_cast<>(&ShapeFix_Face::FixOrientation), OCP_NOGIL) + .def("Face", &ShapeFix_Face::Face, OCP_RETURN_COPY); + + nb::class_(m, "ShapeFix_Solid") + .def(ocp_new()) + .def(ocp_new(), "solid"_a) + .def( + "Perform", [](ShapeFix_Solid &self) { return self.Perform(); }, + OCP_NOGIL) + .def( + "SolidFromShell", + [](ShapeFix_Solid &self, const TopoDS_Shell &shell) { + return self.SolidFromShell(shell); + }, + "shell"_a, OCP_RETURN_COPY, OCP_NOGIL) + .def("Shape", &ShapeFix_Solid::Shape, OCP_RETURN_COPY); +} diff --git a/src/modules/mod_ShapeUpgrade.cpp b/src/modules/mod_ShapeUpgrade.cpp new file mode 100644 index 0000000..eaa666d --- /dev/null +++ b/src/modules/mod_ShapeUpgrade.cpp @@ -0,0 +1,35 @@ +/* + OCP.ShapeUpgrade — merge faces and edges that share a surface. + + The app runs this after almost every fuse, so a bidirectional extrude ends + up with the face count of a single prism rather than two prisms glued at + the sketch plane. Not a MakeShape, so Build/Shape are its own. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" +#include "../common/occt_transient.h" + +#include +#include + +void register_ShapeUpgrade(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "ShapeUpgrade"); + + nb::class_( + m, "ShapeUpgrade_UnifySameDomain") + .def(ocp_new()) + .def(ocp_new(), + "aShape"_a, "UnifyEdges"_a = Standard_True, + "UnifyFaces"_a = Standard_True, + "ConcatBSplines"_a = Standard_False) + .def("Initialize", &ShapeUpgrade_UnifySameDomain::Initialize, "aShape"_a, + "UnifyEdges"_a = Standard_True, "UnifyFaces"_a = Standard_True, + "ConcatBSplines"_a = Standard_False) + .def("SetSafeInputMode", &ShapeUpgrade_UnifySameDomain::SetSafeInputMode, + "theValue"_a) + .def("Build", &ShapeUpgrade_UnifySameDomain::Build, OCP_NOGIL) + .def("Shape", &ShapeUpgrade_UnifySameDomain::Shape, OCP_RETURN_COPY); +} diff --git a/src/modules/mod_TopTools.cpp b/src/modules/mod_TopTools.cpp index 771e2fa..05a06a9 100644 --- a/src/modules/mod_TopTools.cpp +++ b/src/modules/mod_TopTools.cpp @@ -10,11 +10,13 @@ #include "../common/occt_module.h" #include "../common/occt_policies.h" +#include "../common/occt_transient.h" #include #include #include +#include #include void register_TopTools(nb::module_ &root) { @@ -97,4 +99,26 @@ void register_TopTools(nb::module_ &root) { self.Clear(Standard_True); }) .def("__len__", &TopTools_IndexedDataMapOfShapeListOfShape::Size); + + // A transient sequence, unlike the maps above — ShapeAnalysis_FreeBounds + // takes and returns it by handle. + nb::class_( + m, "TopTools_HSequenceOfShape") + .def(ocp_new()) + .def( + "Append", + [](TopTools_HSequenceOfShape &self, const TopoDS_Shape &item) { + self.Append(item); + }, + "theItem"_a) + .def("Length", &TopTools_HSequenceOfShape::Length) + .def("IsEmpty", &TopTools_HSequenceOfShape::IsEmpty) + .def( + "Value", + [](const TopTools_HSequenceOfShape &self, Standard_Integer index) { + return self.Value(index); + }, + "theIndex"_a, OCP_RETURN_COPY) + .def("Clear", [](TopTools_HSequenceOfShape &self) { self.Clear(); }) + .def("__len__", &TopTools_HSequenceOfShape::Length); } diff --git a/tests/data/manifest.json b/tests/data/manifest.json index 03a85c5..4f3da67 100644 --- a/tests/data/manifest.json +++ b/tests/data/manifest.json @@ -499,5 +499,197 @@ ] } } + }, + "inc2": { + "fillet": { + "sha256": "4da9eb256a1e012b94655f6ecd02b34590c4e058beb336794fb68c3047c0070c", + "volume": 5974.247779607694, + "area": 2172.530964914873, + "bbox": [ + -1.000000004440892e-07, + -1.000000004440892e-07, + -1e-07, + 10.0000001, + 20.0000001, + 30.0000001 + ], + "n_faces": 7, + "n_edges": 15, + "n_solids": 1, + "history": [ + { + "pre": 0, + "deleted": false, + "modified": [ + 3 + ], + "generated": [] + }, + { + "pre": 1, + "deleted": false, + "modified": [], + "generated": [] + }, + { + "pre": 2, + "deleted": false, + "modified": [ + 37 + ], + "generated": [] + }, + { + "pre": 3, + "deleted": false, + "modified": [], + "generated": [] + }, + { + "pre": 4, + "deleted": false, + "modified": [ + 32 + ], + "generated": [] + }, + { + "pre": 5, + "deleted": false, + "modified": [ + 13 + ], + "generated": [] + } + ] + }, + "chamfer": { + "sha256": "cdf8d040a239e6202f94e38fc0fc9595e475180c7067a5f1e2391d9ed95d03af", + "volume": 5966.25, + "area": 2171.389610306789, + "bbox": [ + -1.000000001110223e-07, + -1.000000001110223e-07, + -1e-07, + 10.0000001, + 20.0000001, + 30.000000100000005 + ], + "n_faces": 7, + "n_edges": 15, + "n_solids": 1 + }, + "splitter": { + "volume": 6000.0, + "area": 3400.0, + "bbox": [ + -1.5e-07, + -1.5e-07, + -1.5e-07, + 10.00000015, + 20.00000015, + 30.00000015 + ], + "n_faces": 16, + "n_edges": 28, + "n_solids": 3, + "history": [ + { + "pre": 0, + "deleted": false, + "modified": [ + 3, + 37, + 61 + ], + "generated": [ + 8, + 41 + ] + }, + { + "pre": 1, + "deleted": false, + "modified": [ + 33, + 57, + 81 + ], + "generated": [ + 24, + 53 + ] + }, + { + "pre": 2, + "deleted": false, + "modified": [], + "generated": [] + }, + { + "pre": 3, + "deleted": false, + "modified": [], + "generated": [] + }, + { + "pre": 4, + "deleted": false, + "modified": [ + 25, + 54, + 78 + ], + "generated": [ + 22, + 51 + ] + }, + { + "pre": 5, + "deleted": false, + "modified": [ + 13, + 44, + 68 + ], + "generated": [ + 15, + 46 + ] + } + ] + }, + "unified": { + "volume": 12500.0, + "area": 3650.0, + "bbox": [ + -1.5e-07, + -1.5e-07, + -1.5e-07, + 25.00000015, + 25.00000015, + 30.00000015 + ], + "n_faces": 12, + "n_edges": 30, + "n_solids": 1 + }, + "distance": { + "is_done": true, + "value": 30.0, + "n_solutions": 4 + }, + "classifier": { + "inside": 0, + "outside": 1 + }, + "uv_bounds": [ + 0.0, + 30.0, + -20.0, + 0.0 + ], + "outer_wire_edges": 4 } } diff --git a/tests/test_inc2_features.py b/tests/test_inc2_features.py new file mode 100644 index 0000000..b4de917 --- /dev/null +++ b/tests/test_inc2_features.py @@ -0,0 +1,224 @@ +"""Inc 2 gate: the feature tail reproduces the stock wheel's answers. + +Same contract as test_inc1_modeling.py — see its module docstring for why the +app's own suite cannot gate an increment and what the tolerances mean. + +The fillet history is the load-bearing check here: a blend's provenance is read +exactly like a boolean's, and it is what attributes the new faces back to the +feature that made them. +""" + +from __future__ import annotations + +import pytest + +from OCP.BRepAlgoAPI import BRepAlgoAPI_Splitter +from OCP.BRepClass3d import BRepClass3d_SolidClassifier +from OCP.BRepExtrema import BRepExtrema_DistShapeShape +from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet +from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox +from OCP.BRepTools import BRepTools, BRepTools_WireExplorer +from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain +from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE +from OCP.TopoDS import TopoDS +from OCP.TopTools import TopTools_ListOfShape + +from .test_inc1_modeling import assert_close, brep_sha, history, measure, sub_shapes + + +@pytest.fixture(scope="module") +def inc2(manifest): + if "inc2" not in manifest: + pytest.skip("manifest predates the Inc 2 reference block") + return manifest["inc2"] + + +def _box(): + return BRepPrimAPI_MakeBox(10.0, 20.0, 30.0).Shape() + + +def _compare(got: dict, expected: dict, what: str) -> None: + for key in ("n_faces", "n_edges", "n_solids"): + assert got[key] == expected[key], f"{what}.{key}" + for key in ("volume", "area", "bbox"): + assert_close(got[key], expected[key], f"{what}.{key}") + + +def test_fillet_matches_stock_including_history(inc2): + box = _box() + maker = BRepFilletAPI_MakeFillet(box) + maker.Add(2.0, TopoDS.Edge_s(sub_shapes(box, TopAbs_EDGE)[0])) + maker.Build() + assert maker.IsDone() + + filleted = maker.Shape() + expected = inc2["fillet"] + assert brep_sha(filleted) == expected["sha256"] + _compare(measure(filleted), expected, "fillet") + # The blend's provenance, read the same way a boolean's is. + assert history(maker, box, filleted) == expected["history"] + + +def test_chamfer_matches_stock(inc2): + box = _box() + maker = BRepFilletAPI_MakeChamfer(box) + maker.Add(1.5, TopoDS.Edge_s(sub_shapes(box, TopAbs_EDGE)[0])) + maker.Build() + assert maker.IsDone() + + expected = inc2["chamfer"] + assert brep_sha(maker.Shape()) == expected["sha256"] + _compare(measure(maker.Shape()), expected, "chamfer") + + +def test_splitter_matches_stock_including_history(inc2): + from OCP.gp import gp_Pnt + + box = _box() + splitter = BRepAlgoAPI_Splitter() + args, tools = TopTools_ListOfShape(), TopTools_ListOfShape() + args.Append(box) + tools.Append(BRepPrimAPI_MakeBox(gp_Pnt(-5, 5, -5), 20.0, 1.0, 40.0).Shape()) + splitter.SetArguments(args) + splitter.SetTools(tools) + splitter.Build() + assert splitter.IsDone() + + expected = inc2["splitter"] + _compare(measure(splitter.Shape()), expected, "splitter") + # Region attribution in the sketch builder reads exactly this. + assert history(splitter, box, splitter.Shape()) == expected["history"] + + +def test_unify_same_domain_matches_stock(inc2, fixture_shapes): + unify = ShapeUpgrade_UnifySameDomain(fixture_shapes["fused"], True, True, True) + unify.Build() + _compare(measure(unify.Shape()), inc2["unified"], "unified") + + +def test_distance_and_classifier_match_stock(inc2): + from OCP.gp import gp_Pnt + + dist = BRepExtrema_DistShapeShape( + _box(), BRepPrimAPI_MakeBox(gp_Pnt(40, 0, 0), 5.0, 5.0, 5.0).Shape() + ) + assert dist.IsDone() == inc2["distance"]["is_done"] + assert dist.NbSolution() == inc2["distance"]["n_solutions"] + assert_close(dist.Value(), inc2["distance"]["value"], "distance") + + box = _box() + inside = BRepClass3d_SolidClassifier(box, gp_Pnt(5, 10, 15), 1e-7) + outside = BRepClass3d_SolidClassifier(box, gp_Pnt(50, 10, 15), 1e-7) + assert int(inside.State()) == inc2["classifier"]["inside"] + assert int(outside.State()) == inc2["classifier"]["outside"] + + +def test_brep_tools_queries_match_stock(inc2): + face = TopoDS.Face_s(sub_shapes(_box(), TopAbs_FACE)[0]) + assert_close(list(BRepTools.UVBounds_s(face)), inc2["uv_bounds"], "uv_bounds") + outer = BRepTools.OuterWire_s(face) + assert len(sub_shapes(outer, TopAbs_EDGE)) == inc2["outer_wire_edges"] + + +def test_wire_explorer_walks_in_connection_order(): + """WireExplorer follows connectivity, which is the whole reason to use it + over the map order MapShapes gives.""" + face = TopoDS.Face_s(sub_shapes(_box(), TopAbs_FACE)[0]) + explorer = BRepTools_WireExplorer(BRepTools.OuterWire_s(face)) + walked = [] + while explorer.More(): + walked.append(explorer.Current()) + explorer.Next() + assert len(walked) == 4 + # Results outlive the explorer: sub-shapes come back by value. + del explorer + assert all(not e.IsNull() for e in walked) + + +def test_clean_drops_the_existing_triangulation(): + """OCCT keeps whatever mesh a shape already carries, so re-meshing at a + different deflection is a no-op without this — which is why the + tessellation path calls it before its second pass.""" + from OCP.BRep import BRep_Tool + from OCP.BRepMesh import BRepMesh_IncrementalMesh + from OCP.TopLoc import TopLoc_Location + + shape = _box() + BRepMesh_IncrementalMesh(shape, 0.1, False, 0.5, True) + face = TopoDS.Face_s(sub_shapes(shape, TopAbs_FACE)[0]) + assert BRep_Tool.Triangulation_s(face, TopLoc_Location()) is not None + + BRepTools.Clean_s(shape) + face = TopoDS.Face_s(sub_shapes(shape, TopAbs_FACE)[0]) + assert BRep_Tool.Triangulation_s(face, TopLoc_Location()) is None + + +def test_free_bounds_reports_an_open_shell(): + """A watertight solid has no free boundary; a shell missing one face + exposes that face's four edges. This is how import health tells the two + apart, and it exercises Sewing on the way. + + Note it wants a genuinely sewn shell: a bare face reports nothing, in this + binding and in the stock wheel alike. + """ + from OCP.BRepBuilderAPI import BRepBuilderAPI_Sewing + from OCP.ShapeAnalysis import ShapeAnalysis_FreeBounds + + def free_edges(shape) -> int: + bounds = ShapeAnalysis_FreeBounds(shape, 1e-6) + return sum( + len(sub_shapes(wires, TopAbs_EDGE)) + for wires in (bounds.GetClosedWires(), bounds.GetOpenWires()) + if not wires.IsNull() + ) + + assert free_edges(_box()) == 0 + + box = _box() + sewing = BRepBuilderAPI_Sewing(1e-6) + for face in sub_shapes(box, TopAbs_FACE)[:-1]: # leave one face off + sewing.Add(face) + sewing.Perform() + assert free_edges(sewing.SewedShape()) == 4 + + +def test_ray_intersector_finds_the_opposite_wall(): + """The surface-pattern feature measures wall thickness this way.""" + from OCP.gp import gp_Dir, gp_Lin, gp_Pnt + from OCP.IntCurvesFace import IntCurvesFace_ShapeIntersector + + inter = IntCurvesFace_ShapeIntersector() + inter.Load(_box(), 1e-7) + inter.PerformNearest( + gp_Lin(gp_Pnt(-5.0, 10.0, 15.0), gp_Dir(1.0, 0.0, 0.0)), 0.0, 1.0e6 + ) + assert inter.IsDone() + assert inter.NbPnt() > 0 + assert inter.WParameter(1) == pytest.approx(5.0, abs=1e-6) + + +def test_curve_length_matches_the_edge(): + from OCP.BRepAdaptor import BRepAdaptor_Curve + from OCP.GCPnts import GCPnts_AbscissaPoint, GCPnts_TangentialDeflection + + edge = TopoDS.Edge_s(sub_shapes(_box(), TopAbs_EDGE)[0]) + adaptor = BRepAdaptor_Curve(edge) + length = GCPnts_AbscissaPoint.Length_s(adaptor) + assert length in (pytest.approx(10.0), pytest.approx(20.0), pytest.approx(30.0)) + + # The discretiser the viewport's edge polylines come from. + points = GCPnts_TangentialDeflection(adaptor, 0.15, 0.05) + assert points.NbPoints() >= 2 + assert not points.Value(1).IsEqual(points.Value(points.NbPoints()), 1e-9) + + +def test_thick_solid_by_simple_thickens_a_face(): + """The shape rib and the open-profile extrude both lean on this.""" + from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid + + face = TopoDS.Face_s(sub_shapes(_box(), TopAbs_FACE)[0]) + maker = BRepOffsetAPI_MakeThickSolid() + maker.MakeThickSolidBySimple(face, 2.0) + maker.Build() + assert maker.IsDone() + assert measure(maker.Shape())["n_solids"] == 1 diff --git a/tools/gen_fixtures.py b/tools/gen_fixtures.py index 0c833aa..f4af08c 100644 --- a/tools/gen_fixtures.py +++ b/tools/gen_fixtures.py @@ -314,6 +314,92 @@ def _constructions() -> dict: } +def _inc2_reference() -> dict: + """Fillet, chamfer, splitter, healing and the query classes. + + The fillet history is the point of this block: a blend's provenance is read + exactly like a boolean's, and it is what attributes the new faces back to + the feature that made them. + """ + from OCP.BRepAlgoAPI import BRepAlgoAPI_Splitter + from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge + from OCP.BRepClass3d import BRepClass3d_SolidClassifier + from OCP.BRepExtrema import BRepExtrema_DistShapeShape + from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet + from OCP.BRepTools import BRepTools + from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain + from OCP.TopAbs import TopAbs_EDGE + from OCP.TopoDS import TopoDS + from OCP.TopTools import TopTools_ListOfShape + + box = _box() + edges = _sub_shapes(box, TopAbs_EDGE) + + fillet = BRepFilletAPI_MakeFillet(box) + fillet.Add(2.0, TopoDS.Edge_s(edges[0])) + fillet.Build() + filleted = fillet.Shape() + + # The edge must belong to the very shape the builder was given, not an + # equal one built separately — OCCT matches by identity. + chamfer_box = _box() + chamfer = BRepFilletAPI_MakeChamfer(chamfer_box) + chamfer.Add(1.5, TopoDS.Edge_s(_sub_shapes(chamfer_box, TopAbs_EDGE)[0])) + chamfer.Build() + chamfered = chamfer.Shape() + + # A splitter over an edge, mirroring how sketch regions are cut apart. + splitter = BRepAlgoAPI_Splitter() + split_box = _box() + args, tools = TopTools_ListOfShape(), TopTools_ListOfShape() + args.Append(split_box) + tools.Append( + BRepPrimAPI_MakeBox(gp_Pnt(-5, 5, -5), 20.0, 1.0, 40.0).Shape() + ) + splitter.SetArguments(args) + splitter.SetTools(tools) + splitter.Build() + + unify = ShapeUpgrade_UnifySameDomain(_fused(), True, True, True) + unify.Build() + + dist = BRepExtrema_DistShapeShape( + _box(), BRepPrimAPI_MakeBox(gp_Pnt(40, 0, 0), 5.0, 5.0, 5.0).Shape() + ) + + inside = BRepClass3d_SolidClassifier(box, gp_Pnt(5, 10, 15), 1e-7) + outside = BRepClass3d_SolidClassifier(box, gp_Pnt(50, 10, 15), 1e-7) + + face0 = TopoDS.Face_s(_sub_shapes(box, TopAbs_FACE)[0]) + + return { + "fillet": { + "sha256": hashlib.sha256(write_bytes(filleted)).hexdigest(), + **_measure(filleted), + "history": _history(fillet, box, filleted), + }, + "chamfer": { + "sha256": hashlib.sha256(write_bytes(chamfered)).hexdigest(), + **_measure(chamfered), + }, + "splitter": { + **_measure(splitter.Shape()), + "history": _history(splitter, split_box, splitter.Shape()), + }, + "unified": _measure(unify.Shape()), + "distance": { + "is_done": bool(dist.IsDone()), + "value": dist.Value(), + "n_solutions": dist.NbSolution(), + }, + "classifier": {"inside": int(inside.State()), "outside": int(outside.State())}, + "uv_bounds": list(BRepTools.UVBounds_s(face0)), + "outer_wire_edges": len( + _sub_shapes(BRepTools.OuterWire_s(face0), TopAbs_EDGE) + ), + } + + def _inc1_reference() -> dict: op, base, _tool = _cut_cylinder_op() return { @@ -359,6 +445,7 @@ def main() -> int: print(f"{name}: {len(data)} bytes, {manifest['shapes'][name]['faces']} faces") manifest["inc1"] = _inc1_reference() + manifest["inc2"] = _inc2_reference() (OUT / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") print(f"\nwrote {len(SHAPES)} fixtures + manifest to {OUT}")