From 01a5bcf188e529ee6f63c3898b4f9db6a0fb324c Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 10 Aug 2026 20:31:15 +0200 Subject: [PATCH] =?UTF-8?q?10C=20Inc=203=20+=204:=20I/O=20and=20text=20?= =?UTF-8?q?=E2=80=94=20the=20app's=20whole=20OCP=20surface=20is=20bound?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 138/138 symbols across 53 modules. New: IFSelect, Interface, XSControl, IGESData, STEPControl, IGESControl, RWStl, Graphic3d, NCollection, StdPrs. **The full app suite passes against this wheel: 1797 passed, 1 skipped — the same result as the stock wheel**, run from the parity venv. Getting there needed six methods that no static analysis could have found: `inventory --check` only sees symbols reached through an import, so a method called on an instance is invisible to it. The suite found them in one pass, and one of them (gp_Vec.Reverse, which every outward-normal probe calls) accounted for 311 of the 255 failing tests on its own. The others: gp_Trsf.SetMirror over a plane and a point, Geom_Surface.D0, BRep_Builder.MakeFace from a triangulation, MakePipeShell.SetMode with a fixed binormal, and MakeFace from a surface plus tolerance. Open question **S5 is settled: no**. The wheel does not need OCCT's share/ resources. test_inc3_io.py asserts no CSF_* variable is set and then round-trips STEP and IGES, reading the declared units back off both — which is exactly the resource-less container the question was about. The XSTEP readers keep the GIL, amending the blanket "file readers and writers" line in design.md's GIL policy. STEP and IGES traffic in process-global Interface_Static state, the IGES reader is documented as not thread-safe, and the app already serialises imports behind a lock — so holding it costs nothing and removes a class of question. RWStl, which touches no global state, releases. XSControl_Reader and IGESData are registered although the app imports neither: they are the reader base both concrete readers inherit their transfer surface from, and the model-to-global-section chain the IGES unit probe walks. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DfriM8XUkn7uYf5Dwe2xo6 --- CMakeLists.txt | 9 ++ pyproject.toml | 2 +- src/core.cpp | 26 ++++ src/modules/mod_BRep.cpp | 7 + src/modules/mod_BRepBuilderAPI.cpp | 3 + src/modules/mod_BRepOffsetAPI.cpp | 4 + src/modules/mod_Geom.cpp | 1 + src/modules/mod_Graphic3d.cpp | 31 ++++ src/modules/mod_IFSelect.cpp | 23 +++ src/modules/mod_IGESControl.cpp | 57 +++++++ src/modules/mod_IGESData.cpp | 38 +++++ src/modules/mod_Interface.cpp | 43 ++++++ src/modules/mod_NCollection.cpp | 32 ++++ src/modules/mod_RWStl.cpp | 27 ++++ src/modules/mod_STEPControl.cpp | 87 +++++++++++ src/modules/mod_StdPrs.cpp | 57 +++++++ src/modules/mod_XSControl.cpp | 33 ++++ src/modules/mod_gp.cpp | 5 + tests/test_inc3_io.py | 234 +++++++++++++++++++++++++++++ tools/sigdiff.py | 5 + 20 files changed, 723 insertions(+), 1 deletion(-) create mode 100644 src/modules/mod_Graphic3d.cpp create mode 100644 src/modules/mod_IFSelect.cpp create mode 100644 src/modules/mod_IGESControl.cpp create mode 100644 src/modules/mod_IGESData.cpp create mode 100644 src/modules/mod_Interface.cpp create mode 100644 src/modules/mod_NCollection.cpp create mode 100644 src/modules/mod_RWStl.cpp create mode 100644 src/modules/mod_STEPControl.cpp create mode 100644 src/modules/mod_StdPrs.cpp create mode 100644 src/modules/mod_XSControl.cpp create mode 100644 tests/test_inc3_io.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 268e73e..2e6b4d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,6 +65,15 @@ target_link_libraries(_OCP PRIVATE TKOffset # BRepOffsetAPI TKShHealing # ShapeFix, ShapeAnalysis, ShapeUpgrade TKBool # BRepAlgo helpers pulled in by the above + # Inc 3 + TKXSBase # XSControl, Interface, IFSelect + TKDESTEP # STEPControl + TKDEIGES # IGESControl, IGESData + TKDESTL # RWStl + # Inc 4 — TKService carries the BRep font/text builders. The builder image + # asserts it links without libGL/libX11, so this costs the runtime image + # nothing. + TKService ) target_compile_definitions(_OCP PRIVATE diff --git a/pyproject.toml b/pyproject.toml index c68be4c..8e48224 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.dev3" +version = "7.9.3.1.dev4" 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 7b0fb31..27ce59d 100644 --- a/src/core.cpp +++ b/src/core.cpp @@ -66,6 +66,18 @@ void register_BRepLib(nb::module_ &); void register_BRepExtrema(nb::module_ &); void register_BRepClass3d(nb::module_ &); void register_IntCurvesFace(nb::module_ &); +// Inc 3 — I/O +void register_IFSelect(nb::module_ &); +void register_Interface(nb::module_ &); +void register_XSControl(nb::module_ &); +void register_IGESData(nb::module_ &); +void register_STEPControl(nb::module_ &); +void register_IGESControl(nb::module_ &); +void register_RWStl(nb::module_ &); +// Inc 4 — remainder +void register_Graphic3d(nb::module_ &); +void register_NCollection(nb::module_ &); +void register_StdPrs(nb::module_ &); void register_ext(nb::module_ &); NB_MODULE(_OCP, m) { @@ -124,6 +136,20 @@ NB_MODULE(_OCP, m) { register_BRepClass3d(m); register_IntCurvesFace(m); + // Inc 3. XSControl declares the reader base both concrete readers use. + register_IFSelect(m); + register_Interface(m); + register_XSControl(m); + register_IGESData(m); + register_STEPControl(m); + register_IGESControl(m); + register_RWStl(m); + + // Inc 4. Graphic3d's alignment enums are arguments to StdPrs. + register_Graphic3d(m); + register_NCollection(m); + register_StdPrs(m); + register_ext(m); m.attr("__all_modules__") = ocp_module_registry(); diff --git a/src/modules/mod_BRep.cpp b/src/modules/mod_BRep.cpp index b72e001..87c2fdb 100644 --- a/src/modules/mod_BRep.cpp +++ b/src/modules/mod_BRep.cpp @@ -34,6 +34,13 @@ void register_BRep(nb::module_ &root) { .def(nb::init<>()) .def("MakeCompound", &BRep_Builder::MakeCompound, "C"_a) .def("MakeShell", &BRep_Builder::MakeShell, "S"_a) + .def( + "MakeFace", + [](const BRep_Builder &self, TopoDS_Face &F, + const opencascade::handle &T) { + self.MakeFace(F, T); + }, + "F"_a, "T"_a) .def("MakeSolid", &BRep_Builder::MakeSolid, "S"_a) .def("MakeWire", &BRep_Builder::MakeWire, "W"_a) .def("Add", diff --git a/src/modules/mod_BRepBuilderAPI.cpp b/src/modules/mod_BRepBuilderAPI.cpp index c6c8287..e9d5dd6 100644 --- a/src/modules/mod_BRepBuilderAPI.cpp +++ b/src/modules/mod_BRepBuilderAPI.cpp @@ -105,6 +105,9 @@ void register_BRepBuilderAPI(nb::module_ &root) { "P"_a, "UMin"_a, "UMax"_a, "VMin"_a, "VMax"_a) .def(nb::init(), "W"_a, "OnlyPlane"_a = Standard_False) + .def(nb::init &, + const Standard_Real>(), + "S"_a, "TolDegen"_a) .def(nb::init &, const TopoDS_Wire &, const Standard_Boolean>(), "S"_a, "W"_a, "Inside"_a = Standard_True) diff --git a/src/modules/mod_BRepOffsetAPI.cpp b/src/modules/mod_BRepOffsetAPI.cpp index 3b9af93..0dd4b0f 100644 --- a/src/modules/mod_BRepOffsetAPI.cpp +++ b/src/modules/mod_BRepOffsetAPI.cpp @@ -50,6 +50,10 @@ void register_BRepOffsetAPI(nb::module_ &root) { nb::overload_cast( &BRepOffsetAPI_MakePipeShell::SetMode), "IsFrenet"_a = Standard_False) + .def("SetMode", + nb::overload_cast( + &BRepOffsetAPI_MakePipeShell::SetMode), + "BiNormal"_a) .def("Add", nb::overload_cast( diff --git a/src/modules/mod_Geom.cpp b/src/modules/mod_Geom.cpp index c544e3c..5aa7c88 100644 --- a/src/modules/mod_Geom.cpp +++ b/src/modules/mod_Geom.cpp @@ -64,6 +64,7 @@ void register_Geom(nb::module_ &root) { nb::class_(m, "Geom_Surface") .def("Value", &Geom_Surface::Value, "U"_a, "V"_a, OCP_RETURN_COPY) + .def("D0", &Geom_Surface::D0, "U"_a, "V"_a, "P"_a) .def("IsUPeriodic", &Geom_Surface::IsUPeriodic) .def("IsVPeriodic", &Geom_Surface::IsVPeriodic) .def("IsUClosed", &Geom_Surface::IsUClosed) diff --git a/src/modules/mod_Graphic3d.cpp b/src/modules/mod_Graphic3d.cpp new file mode 100644 index 0000000..71adea6 --- /dev/null +++ b/src/modules/mod_Graphic3d.cpp @@ -0,0 +1,31 @@ +/* + OCP.Graphic3d — text alignment. + + Only the two alignment enums, which the text-emboss feature passes to the + BRep text builder. Nothing else in Graphic3d is a modelling concept, and + the app draws nothing through OCCT. +*/ + +#include "../common/occt_module.h" + +#include +#include + +void register_Graphic3d(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "Graphic3d"); + + nb::enum_( + m, "Graphic3d_HorizontalTextAlignment", nb::is_arithmetic()) + .value("Graphic3d_HTA_LEFT", Graphic3d_HTA_LEFT) + .value("Graphic3d_HTA_CENTER", Graphic3d_HTA_CENTER) + .value("Graphic3d_HTA_RIGHT", Graphic3d_HTA_RIGHT) + .export_values(); + + nb::enum_( + m, "Graphic3d_VerticalTextAlignment", nb::is_arithmetic()) + .value("Graphic3d_VTA_BOTTOM", Graphic3d_VTA_BOTTOM) + .value("Graphic3d_VTA_CENTER", Graphic3d_VTA_CENTER) + .value("Graphic3d_VTA_TOP", Graphic3d_VTA_TOP) + .value("Graphic3d_VTA_TOPFIRSTLINE", Graphic3d_VTA_TOPFIRSTLINE) + .export_values(); +} diff --git a/src/modules/mod_IFSelect.cpp b/src/modules/mod_IFSelect.cpp new file mode 100644 index 0000000..f8c54ba --- /dev/null +++ b/src/modules/mod_IFSelect.cpp @@ -0,0 +1,23 @@ +/* + OCP.IFSelect — the return status every XSTEP read reports through. + + The app only ever compares against IFSelect_RetDone, but the whole enum is + bound so a failure prints as something other than a bare integer. +*/ + +#include "../common/occt_module.h" + +#include + +void register_IFSelect(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "IFSelect"); + + nb::enum_(m, "IFSelect_ReturnStatus", + nb::is_arithmetic()) + .value("IFSelect_RetVoid", IFSelect_RetVoid) + .value("IFSelect_RetDone", IFSelect_RetDone) + .value("IFSelect_RetError", IFSelect_RetError) + .value("IFSelect_RetFail", IFSelect_RetFail) + .value("IFSelect_RetStop", IFSelect_RetStop) + .export_values(); +} diff --git a/src/modules/mod_IGESControl.cpp b/src/modules/mod_IGESControl.cpp new file mode 100644 index 0000000..a282bef --- /dev/null +++ b/src/modules/mod_IGESControl.cpp @@ -0,0 +1,57 @@ +/* + OCP.IGESControl — IGES read and write. + + The reader inherits its transfer surface from XSControl_Reader. What is + specific here is the unit probe: the app reads the file's declared units + off the model's global section (IGESModel().GlobalSection().UnitName()), + which is why IGESData_IGESModel and IGESData_GlobalSection have to be + registered even though the app imports neither by name. + + The writer is used only by the test suite, to produce IGES payloads to + import back; it is bound because those tests are part of the Inc 4 gate. + + GIL held, per mod_XSControl.cpp — the IGES reader is explicitly documented + as not thread-safe. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +void register_IGESControl(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "IGESControl"); + + nb::class_ controller(m, "IGESControl_Controller"); + OCP_DEF_S( + controller, "Init", []() { return IGESControl_Controller::Init(); }); + + nb::class_(m, "IGESControl_Reader") + .def(nb::init<>()) + .def("IGESModel", &IGESControl_Reader::IGESModel); + + nb::class_(m, "IGESControl_Writer") + .def(nb::init<>()) + .def(nb::init(), + "unit_name"_a, "modecr"_a = 0) + // AddShape carries a Message_ProgressRange the app never supplies. + .def( + "AddShape", + [](IGESControl_Writer &self, const TopoDS_Shape &sh) { + return self.AddShape(sh); + }, + "sh"_a, OCP_NOGIL) + .def("ComputeModel", &IGESControl_Writer::ComputeModel, OCP_NOGIL) + .def( + "Write", + [](IGESControl_Writer &self, const Standard_CString file, + const Standard_Boolean fnes) { return self.Write(file, fnes); }, + "file"_a, "fnes"_a = Standard_False); +} diff --git a/src/modules/mod_IGESData.cpp b/src/modules/mod_IGESData.cpp new file mode 100644 index 0000000..6518eb1 --- /dev/null +++ b/src/modules/mod_IGESData.cpp @@ -0,0 +1,38 @@ +/* + OCP.IGESData — the IGES model's global section. + + Not imported by the app, but reached through it: the unit probe walks + reader.IGESModel().GlobalSection().UnitName(), so both classes need to be + registered for that chain to convert. Only the two unit accessors are + bound. + + UnitName hands back a Handle(TCollection_HAsciiString), which is why that + transient is registered here too rather than in mod_TCollection.cpp — it + has no other consumer. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include +#include +#include + +void register_IGESData(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "IGESData"); + + nb::class_( + m, "TCollection_HAsciiString") + .def("ToCString", &TCollection_HAsciiString::ToCString) + .def("Length", &TCollection_HAsciiString::Length) + .def("__str__", &TCollection_HAsciiString::ToCString); + + nb::class_(m, "IGESData_GlobalSection") + .def(nb::init<>()) + .def("UnitName", &IGESData_GlobalSection::UnitName) + .def("UnitValue", &IGESData_GlobalSection::UnitValue); + + nb::class_(m, "IGESData_IGESModel") + .def("GlobalSection", &IGESData_IGESModel::GlobalSection, + OCP_RETURN_COPY); +} diff --git a/src/modules/mod_Interface.cpp b/src/modules/mod_Interface.cpp new file mode 100644 index 0000000..57eb0a2 --- /dev/null +++ b/src/modules/mod_Interface.cpp @@ -0,0 +1,43 @@ +/* + OCP.Interface — XSTEP's process-global settings table. + + The app reads and writes "xstep.cascade.unit" around an IGES import to make + the reader interpret the file in its own units, then puts the previous + value back. That is genuinely global state, which is why the app holds a + lock across the whole read — and why these keep the GIL. +*/ + +#include "../common/occt_module.h" + +#include + +void register_Interface(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "Interface"); + + nb::class_ cls(m, "Interface_Static"); + + OCP_DEF_S( + cls, "CVal", + [](const Standard_CString name) { return Interface_Static::CVal(name); }, + "name"_a); + OCP_DEF_S( + cls, "SetCVal", + [](const Standard_CString name, const Standard_CString val) { + return Interface_Static::SetCVal(name, val); + }, + "name"_a, "val"_a); + OCP_DEF_S( + cls, "IVal", + [](const Standard_CString name) { return Interface_Static::IVal(name); }, + "name"_a); + OCP_DEF_S( + cls, "SetIVal", + [](const Standard_CString name, const Standard_Integer val) { + return Interface_Static::SetIVal(name, val); + }, + "name"_a, "val"_a); + OCP_DEF_S( + cls, "RVal", + [](const Standard_CString name) { return Interface_Static::RVal(name); }, + "name"_a); +} diff --git a/src/modules/mod_NCollection.cpp b/src/modules/mod_NCollection.cpp new file mode 100644 index 0000000..6ef5e4f --- /dev/null +++ b/src/modules/mod_NCollection.cpp @@ -0,0 +1,32 @@ +/* + OCP.NCollection — the UTF-8 string the font API takes. + + NCollection_Utf8String is the only NCollection type the app names directly: + it wraps the font path and the text to emboss. Constructed from a Python + str, which nanobind hands over as UTF-8 already. +*/ + +#include "../common/occt_module.h" + +#include + +void register_NCollection(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "NCollection"); + + nb::class_(m, "NCollection_Utf8String") + .def(nb::init<>()) + // Upstream also offers an explicit-length overload; the app always + // passes a whole Python str, which arrives NUL-terminated, so only the + // one-argument form is bound. + .def( + "__init__", + [](NCollection_Utf8String *self, const char *text) { + new (self) NCollection_Utf8String(text); + }, + "theCopy"_a) + .def("ToCString", &NCollection_Utf8String::ToCString) + .def("Size", &NCollection_Utf8String::Size) + .def("Length", &NCollection_Utf8String::Length) + .def("IsEmpty", &NCollection_Utf8String::IsEmpty) + .def("__str__", &NCollection_Utf8String::ToCString); +} diff --git a/src/modules/mod_RWStl.cpp b/src/modules/mod_RWStl.cpp new file mode 100644 index 0000000..5eb7f5d --- /dev/null +++ b/src/modules/mod_RWStl.cpp @@ -0,0 +1,27 @@ +/* + OCP.RWStl — STL read. + + Returns a bare Poly_Triangulation, which the app wraps into a shell of + triangle faces itself. Nothing writes STL through OCCT: the app has its + own writer, which welds and de-duplicates first. + + Unlike the XSTEP readers this touches no global state, so it releases the + GIL. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include +#include + +void register_RWStl(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "RWStl"); + + nb::class_ cls(m, "RWStl"); + + OCP_DEF_S( + cls, "ReadFile", + [](const Standard_CString file) { return RWStl::ReadFile(file); }, + "theFile"_a, OCP_NOGIL); +} diff --git a/src/modules/mod_STEPControl.cpp b/src/modules/mod_STEPControl.cpp new file mode 100644 index 0000000..7905f9e --- /dev/null +++ b/src/modules/mod_STEPControl.cpp @@ -0,0 +1,87 @@ +/* + OCP.STEPControl — STEP read and write. + + The reader inherits its transfer surface from XSControl_Reader; what is + added here is the unit handling the app needs (FileUnits reports what the + file declares, SetSystemLengthUnit rescales the transfer) and ReadStream, + which takes a Python file-like. + + ReadStream reuses the slurp bridge from occt_stream.h rather than wrapping + a streambuf that calls back into Python — same reasoning as BinTools, and + the same one extra copy. + + The GIL stays held throughout, per the note in mod_XSControl.cpp: XSTEP is + built on process-global state and the app serialises imports anyway. + + This is also where the S5 question lands (design.md): whether the wheel has + to ship OCCT's share/ resources for STEP to resolve units in a container + with no CSF_* environment. tests/test_inc3_io.py answers it. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" +#include "../common/occt_stream.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +void register_STEPControl(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "STEPControl"); + + nb::enum_(m, "STEPControl_StepModelType", + nb::is_arithmetic()) + .value("STEPControl_AsIs", STEPControl_AsIs) + .value("STEPControl_ManifoldSolidBrep", STEPControl_ManifoldSolidBrep) + .value("STEPControl_BrepWithVoids", STEPControl_BrepWithVoids) + .value("STEPControl_FacetedBrep", STEPControl_FacetedBrep) + .value("STEPControl_FacetedBrepAndBrepWithVoids", + STEPControl_FacetedBrepAndBrepWithVoids) + .value("STEPControl_ShellBasedSurfaceModel", + STEPControl_ShellBasedSurfaceModel) + .value("STEPControl_GeometricCurveSet", STEPControl_GeometricCurveSet) + .value("STEPControl_Hybrid", STEPControl_Hybrid) + .export_values(); + + nb::class_ controller(m, "STEPControl_Controller"); + OCP_DEF_S( + controller, "Init", []() { return STEPControl_Controller::Init(); }); + + nb::class_(m, "STEPControl_Reader") + .def(nb::init<>()) + .def( + "ReadStream", + [](STEPControl_Reader &self, const char *name, nb::handle stream) { + std::istringstream buffer(ocp_slurp(stream)); + return self.ReadStream(name, buffer); + }, + "theName"_a, "theIStream"_a) + .def("FileUnits", &STEPControl_Reader::FileUnits, "theLengthUnits"_a, + "theAngleUnits"_a, "theSolidAngleUnits"_a) + .def("SetSystemLengthUnit", &STEPControl_Reader::SetSystemLengthUnit, + "theLengthUnit"_a) + .def("SystemLengthUnit", &STEPControl_Reader::SystemLengthUnit); + + nb::class_(m, "STEPControl_Writer") + .def(nb::init<>()) + .def( + "Transfer", + [](STEPControl_Writer &self, const TopoDS_Shape &sh, + const STEPControl_StepModelType mode, + const Standard_Boolean compgraph) { + return self.Transfer(sh, mode, compgraph); + }, + "sh"_a, "mode"_a, "compgraph"_a = Standard_True) + .def( + "Write", + [](STEPControl_Writer &self, const Standard_CString filename) { + return self.Write(filename); + }, + "filename"_a); +} diff --git a/src/modules/mod_StdPrs.cpp b/src/modules/mod_StdPrs.cpp new file mode 100644 index 0000000..d2a47d3 --- /dev/null +++ b/src/modules/mod_StdPrs.cpp @@ -0,0 +1,57 @@ +/* + OCP.StdPrs — glyph outlines as BRep. + + Text emboss loads a bundled font and asks the builder for the text as a + compound of face outlines, holes already subtracted, which it then extrudes + into or out of the model. + + These live in TKService, and both classes are real in 7.9.3 rather than the + deprecated typedefs of Font_BRepFont/Font_BRepTextBuilder that OCCT 8.0 + turns them into — so the 8.0 port is a rename here and nothing else. The + builder image already asserts TKService links without libGL or libX11, so + binding them costs the runtime image nothing. + + Rendering a glyph is FreeType work with no Python in it, so it releases the + GIL. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" +#include "../common/occt_transient.h" + +#include +#include +#include +#include +#include + +void register_StdPrs(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "StdPrs"); + + nb::class_(m, "StdPrs_BRepFont") + .def(ocp_new()) + .def( + "Init", + [](StdPrs_BRepFont &self, const NCollection_String &path, + const Standard_Real size, const Standard_Integer faceId) { + return self.Init(path, size, faceId); + }, + "theFontPath"_a, "theSize"_a, "theFaceId"_a, OCP_NOGIL) + .def("Release", &StdPrs_BRepFont::Release) + .def("Ascender", &StdPrs_BRepFont::Ascender) + .def("Descender", &StdPrs_BRepFont::Descender); + + nb::class_(m, "StdPrs_BRepTextBuilder") + .def(nb::init<>()) + .def( + "Perform", + [](StdPrs_BRepTextBuilder &self, StdPrs_BRepFont &font, + const NCollection_String &text, const gp_Ax3 &penLoc, + const Graphic3d_HorizontalTextAlignment hAlign, + const Graphic3d_VerticalTextAlignment vAlign) { + return self.Perform(font, text, penLoc, hAlign, vAlign); + }, + "theFont"_a, "theString"_a, "thePenLoc"_a = gp_Ax3(), + "theHAlign"_a = Graphic3d_HTA_LEFT, + "theVAlign"_a = Graphic3d_VTA_BOTTOM, OCP_RETURN_COPY, OCP_NOGIL); +} diff --git a/src/modules/mod_XSControl.cpp b/src/modules/mod_XSControl.cpp new file mode 100644 index 0000000..3c83a02 --- /dev/null +++ b/src/modules/mod_XSControl.cpp @@ -0,0 +1,33 @@ +/* + OCP.XSControl — the reader base STEP and IGES share. + + Not imported by the app, but ReadFile/NbRootsForTransfer/TransferRoots/ + OneShape are declared here, so binding them once means neither concrete + reader has to. + + These keep the GIL, deliberately. The XSTEP layer traffics in + process-global Interface_Static state, the IGES reader is documented as not + thread-safe, and the app already serialises imports behind a lock — so + holding the GIL costs nothing and removes a whole class of question. It is + also what upstream does, which releases nowhere. +*/ + +#include "../common/occt_module.h" +#include "../common/occt_policies.h" + +#include +#include + +void register_XSControl(nb::module_ &root) { + nb::module_ m = ocp_submodule(root, "XSControl"); + + nb::class_(m, "XSControl_Reader") + .def("ReadFile", &XSControl_Reader::ReadFile, "filename"_a) + .def("NbRootsForTransfer", &XSControl_Reader::NbRootsForTransfer) + .def( + "TransferRoots", + [](XSControl_Reader &self) { return self.TransferRoots(); }) + .def("NbShapes", &XSControl_Reader::NbShapes) + .def("Shape", &XSControl_Reader::Shape, "num"_a = 1, OCP_RETURN_COPY) + .def("OneShape", &XSControl_Reader::OneShape, OCP_RETURN_COPY); +} diff --git a/src/modules/mod_gp.cpp b/src/modules/mod_gp.cpp index b64d430..ca99fc1 100644 --- a/src/modules/mod_gp.cpp +++ b/src/modules/mod_gp.cpp @@ -92,6 +92,7 @@ void register_gp(nb::module_ &root) { .def("Crossed", &gp_Vec::Crossed, "Right"_a, OCP_RETURN_COPY) .def("Normalize", &gp_Vec::Normalize) .def("Normalized", &gp_Vec::Normalized, OCP_RETURN_COPY) + .def("Reverse", &gp_Vec::Reverse) .def("Reversed", &gp_Vec::Reversed, OCP_RETURN_COPY) .def("Multiplied", &gp_Vec::Multiplied, "Scalar"_a, OCP_RETURN_COPY) .def("Angle", &gp_Vec::Angle, "Other"_a) @@ -146,6 +147,10 @@ void register_gp(nb::module_ &root) { .def("SetScale", &gp_Trsf::SetScale, "P"_a, "S"_a) .def("SetMirror", nb::overload_cast(&gp_Trsf::SetMirror), "A1"_a) + .def("SetMirror", nb::overload_cast(&gp_Trsf::SetMirror), + "A2"_a) + .def("SetMirror", nb::overload_cast(&gp_Trsf::SetMirror), + "P"_a) .def("Form", &gp_Trsf::Form) .def("ScaleFactor", &gp_Trsf::ScaleFactor) .def("TranslationPart", &gp_Trsf::TranslationPart, OCP_RETURN_COPY) diff --git a/tests/test_inc3_io.py b/tests/test_inc3_io.py new file mode 100644 index 0000000..41f828d --- /dev/null +++ b/tests/test_inc3_io.py @@ -0,0 +1,234 @@ +"""Inc 3 + 4 gate: file I/O and the text tail. + +Round-trips rather than digests: STEP and IGES payloads carry timestamps and a +generator banner, so byte comparison is meaningless — what has to survive is +the geometry, which these check by measuring the shape that comes back. + +This file also settles open question **S5** from docs/design.md: whether the +wheel has to ship OCCT's share/ resources for the XSTEP layer to resolve units +when no CSF_* variable is set. These tests run in exactly that situation — a +container with the wheel and nothing else — so a pass means the answer is no. +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + +from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox +from OCP.IFSelect import IFSelect_RetDone +from OCP.IGESControl import IGESControl_Controller, IGESControl_Reader, IGESControl_Writer +from OCP.Interface import Interface_Static +from OCP.STEPControl import ( + STEPControl_AsIs, + STEPControl_Controller, + STEPControl_Reader, + STEPControl_Writer, +) +from OCP.TColStd import TColStd_SequenceOfAsciiString + +from .test_inc1_modeling import assert_close, measure + +BOX = (10.0, 20.0, 30.0) + + +@pytest.fixture(scope="module") +def initialized(): + """The app's own import bootstrap, and the S5 check in one. + + If the controllers could not initialise without OCCT's resource files, this + is where it would fail — there is no CSF_* in this environment. + """ + assert not any(k.startswith("CSF_") for k in os.environ), ( + "a CSF_* variable is set, so this run cannot answer S5" + ) + assert STEPControl_Controller.Init_s() + assert IGESControl_Controller.Init_s() + assert Interface_Static.SetCVal_s("xstep.cascade.unit", "MM") + return True + + +def _box(): + return BRepPrimAPI_MakeBox(*BOX).Shape() + + +def _same_geometry(got, expected_volume: float, what: str) -> None: + m = measure(got) + assert m["n_solids"] == 1, f"{what}: solid count" + assert m["n_faces"] == 6, f"{what}: face count" + assert_close(m["volume"], expected_volume, f"{what}: volume") + + +def test_step_round_trip(initialized, tmp_path): + """Write a box to STEP and read it back with the geometry intact.""" + path = tmp_path / "roundtrip.step" + writer = STEPControl_Writer() + assert writer.Transfer(_box(), STEPControl_AsIs) == IFSelect_RetDone + assert writer.Write(str(path)) == IFSelect_RetDone + assert path.stat().st_size > 0 + + reader = STEPControl_Reader() + assert reader.ReadFile(str(path)) == IFSelect_RetDone + assert reader.NbRootsForTransfer() >= 1 + assert reader.TransferRoots() >= 1 + _same_geometry(reader.OneShape(), BOX[0] * BOX[1] * BOX[2], "STEP round trip") + + +def test_step_read_stream_takes_a_python_file_like(initialized, tmp_path): + """ReadStream is how the app imports an uploaded payload without touching + the filesystem. It goes through the same slurp bridge BinTools uses.""" + import io + + path = tmp_path / "stream.step" + writer = STEPControl_Writer() + writer.Transfer(_box(), STEPControl_AsIs) + writer.Write(str(path)) + + reader = STEPControl_Reader() + assert ( + reader.ReadStream("stream.step", io.BytesIO(path.read_bytes())) + == IFSelect_RetDone + ) + assert reader.TransferRoots() >= 1 + _same_geometry(reader.OneShape(), BOX[0] * BOX[1] * BOX[2], "STEP stream") + + +def test_step_reports_its_file_units(initialized, tmp_path): + """FileUnits fills three sequences by reference — the out-parameter shape + the app relies on to decide whether to rescale the transfer.""" + path = tmp_path / "units.step" + writer = STEPControl_Writer() + writer.Transfer(_box(), STEPControl_AsIs) + writer.Write(str(path)) + + reader = STEPControl_Reader() + reader.ReadFile(str(path)) + + length, angle, solid_angle = ( + TColStd_SequenceOfAsciiString(), + TColStd_SequenceOfAsciiString(), + TColStd_SequenceOfAsciiString(), + ) + reader.FileUnits(length, angle, solid_angle) + assert length.Length() >= 1, "no length unit reported — S5 would be live" + names = [length.Value(i).ToCString() for i in range(1, length.Length() + 1)] + assert any("MM" in n.upper() or "MILLI" in n.upper() for n in names), names + + +def test_interface_static_round_trips_a_setting(initialized): + """The unit override the IGES import brackets its read with.""" + previous = Interface_Static.CVal_s("xstep.cascade.unit") + try: + assert Interface_Static.SetCVal_s("xstep.cascade.unit", "INCH") + assert Interface_Static.CVal_s("xstep.cascade.unit") == "INCH" + finally: + Interface_Static.SetCVal_s("xstep.cascade.unit", previous or "MM") + assert Interface_Static.CVal_s("xstep.cascade.unit") == (previous or "MM") + + +def test_iges_round_trip_and_unit_probe(initialized, tmp_path): + """The IGES half, including the model→global-section→unit-name chain that + forces IGESData to be registered.""" + path = tmp_path / "roundtrip.igs" + writer = IGESControl_Writer("MM", 0) + assert writer.AddShape(_box()) + writer.ComputeModel() + assert writer.Write(str(path)) + + reader = IGESControl_Reader() + assert reader.ReadFile(str(path)) == IFSelect_RetDone + assert reader.NbRootsForTransfer() >= 1 + + section = reader.IGESModel().GlobalSection() + unit_name = section.UnitName() + assert unit_name is not None + assert "MM" in unit_name.ToCString().upper() + assert section.UnitValue() > 0 + + assert reader.TransferRoots() >= 1 + shape = reader.OneShape() + assert not shape.IsNull() + # IGES carries surfaces, not solids, so only the face count is meaningful. + assert measure(shape)["n_faces"] == 6 + + +def test_stl_read_returns_a_triangulation(tmp_path): + """RWStl hands back a bare Poly_Triangulation; the app wraps it into a + shell itself, which is why nothing here builds a shape.""" + from OCP.RWStl import RWStl + + path = tmp_path / "cube.stl" + # A minimal ASCII STL: one triangle is enough to prove the reader. + path.write_text( + "solid t\n" + "facet normal 0 0 1\n outer loop\n" + " vertex 0 0 0\n vertex 1 0 0\n vertex 0 1 0\n" + " endloop\nendfacet\n" + "endsolid t\n" + ) + triangulation = RWStl.ReadFile_s(str(path)) + assert triangulation is not None + assert triangulation.NbTriangles() == 1 + assert triangulation.NbNodes() == 3 + + +# -------------------------------------------------------------------------- +# Inc 4 — text +# -------------------------------------------------------------------------- + + +def _a_font_path() -> str | None: + """Any TrueType face on the box, since no font is committed here.""" + roots = ("/usr/share/fonts", "/usr/local/share/fonts") + for root in roots: + for dirpath, _dirs, files in os.walk(root): + for name in sorted(files): + if name.lower().endswith((".ttf", ".otf")): + return os.path.join(dirpath, name) + return None + + +def test_text_builder_renders_glyphs_as_faces(): + """Text emboss builds its glyph outlines this way. The whole chain crosses + four modules: NCollection carries the strings, Graphic3d the alignment, + StdPrs does the work, gp positions the pen.""" + from OCP.Graphic3d import Graphic3d_HTA_CENTER, Graphic3d_VTA_CENTER + from OCP.NCollection import NCollection_Utf8String + from OCP.StdPrs import StdPrs_BRepFont, StdPrs_BRepTextBuilder + from OCP.gp import gp_Ax3 + from OCP.TopAbs import TopAbs_FACE + + from .test_inc1_modeling import sub_shapes + + path = _a_font_path() + if path is None: + pytest.skip("no TrueType font in the image to render with") + + font = StdPrs_BRepFont() + assert font.Init(NCollection_Utf8String(path), 10.0, 0), path + + builder = StdPrs_BRepTextBuilder() + shape = builder.Perform( + font, + NCollection_Utf8String("Ab"), + gp_Ax3(), + Graphic3d_HTA_CENTER, + Graphic3d_VTA_CENTER, + ) + assert not shape.IsNull() + # Two glyphs, each at least one face; 'A' contributes a counter too. + assert len(sub_shapes(shape, TopAbs_FACE)) >= 2 + + +def test_font_loading_reports_failure_rather_than_raising(): + """The app treats a bad font as a user error, so Init must return False.""" + from OCP.NCollection import NCollection_Utf8String + from OCP.StdPrs import StdPrs_BRepFont + + with tempfile.NamedTemporaryFile(suffix=".ttf") as bogus: + bogus.write(b"not a font") + bogus.flush() + font = StdPrs_BRepFont() + assert font.Init(NCollection_Utf8String(bogus.name), 10.0, 0) is False diff --git a/tools/sigdiff.py b/tools/sigdiff.py index 9c1ba37..43acec6 100644 --- a/tools/sigdiff.py +++ b/tools/sigdiff.py @@ -45,6 +45,11 @@ def _signatures(owner, name: str) -> list[tuple[str, ...]]: doc = getattr(getattr(owner, name, None), "__doc__", None) if not doc: return [] + # A class with no bound constructor still exposes object's __init__ stub, + # whose docstring carries no signature — that would otherwise read as a + # nullary overload the binding does not actually have. + if name == "__init__" and doc.startswith("Initialize self."): + return [] out = set() for line in doc.splitlines(): line = re.sub(r"^\d+\.\s*", "", line.strip())