Description
Writing a face that lies on a closed periodic surface and carries both its natural bound and additional (hole) bounds discards the seam: the natural-bound wire is emitted as a VERTEX_LOOP, so the file contains no seam edge, and the reader has to invent one. A STEP write + read is therefore not the identity on a valid shape — edges are lost or split and total edge length changes.
Why this matters beyond cosmetics. STEP is the interchange format, and consumers routinely verify that a part survived transport by fingerprinting its topology (edge counts and lengths, surface-type census) or by comparing measured quantities. When write → read is not coherent on a shape OCCT itself considers valid, every such consumer reports a change that did not happen — PDM de-duplication, geometry graders, metrology pipelines, regression suites. The divergence is silent: the file is schema-valid, the reader raises nothing, and the shape stays "valid". We hit this on production assemblies where geometrically identical parts compared as different after a single round trip.
The VERTEX_LOOP substitution itself is correct and deliberate for a complete periodic face (the CAX-IF TRJ4 case named in the code comment): if the face is the whole closed surface, any seam the reader picks is equivalent. That equivalence breaks as soon as the face has other bounds, because then the seam's placement determines how the remaining bounds are cut.
Expected Behavior
A round trip preserves the seam and the edge set of a periodic face that carries additional bounds. In the reproducer below, 5 edges totalling 10.483996 should come back as 5 edges totalling 10.483996.
Actual Behavior
The written file contains VERTEX_LOOP = 1, EDGE_CURVE = 2 (the two hole circles only) and no seam edge. Reading it back yields 11 edges totalling 8.064394: the seam is gone and both hole boundaries have been cut by the seam the reader re-derived in its place.
On imported production data the same defect manifests as edge splitting rather than loss: a bearing ball (spherical patch with two drilled holes) goes from 5 edges / 23.436445 mm to 8 edges / 18.989533 mm, the 4.7517 mm seam returning as two 0.1524 mm stubs while a 9.4137 mm hole boundary is chopped into 2.3539 + 4.7069 + 2.3530 by the re-derived seam, and the spherical patch comes back wrapped in Geom_RectangularTrimmedSurface.
Reproduced on OCCT 7.6.3 and 7.9.3.1 (Linux x86-64) with identical figures, so this is long-standing behaviour rather than a recent regression. Independent of write.precision.mode, write.surfacecurve.mode, and of both shape-processing sequences (read.step.sequence / write.step.sequence disabled changes nothing).
Root cause
src/DataExchange/TKDESTEP/TopoDSToStep/TopoDSToStep_MakeStepWire.cxx:182-234
//: abv 04.05.00: CAX-IF TRJ4: writing complete sphere with single vertex_loop
// check that whole wire is one seam (perhaps made of several seam edges)
...
if (ie > nb)
{
// make vertex_loop
...
myResult = vloop;
return;
}
The wire is examined in isolation: non-degenerate edges are collected, and if they pair up as seam edges the whole wire becomes a VERTEX_LOOP. Nothing checks whether the face has further bounds. TopoDSToStep_MakeStepWire::Init only receives a TopoDS_Wire, so the necessary context lives one level up, in the wire loop of TopoDSToStep_MakeStepFace.cxx:264-269.
Proposed fix
Restrict the substitution to faces whose only bound is the natural bound — a strict narrowing that leaves the TRJ4 complete-sphere behaviour untouched:
--- a/src/DataExchange/TKDESTEP/TopoDSToStep/TopoDSToStep_MakeStepFace.cxx
+++ b/src/DataExchange/TKDESTEP/TopoDSToStep/TopoDSToStep_MakeStepFace.cxx
@@ (before the wire loop, ~line 264)
+ // A seam-only wire may be collapsed to a VERTEX_LOOP only when it is the
+ // face's sole bound; otherwise the seam placement determines how the other
+ // bounds are trimmed and cannot be re-derived by the reader.
+ Standard_Integer aNbWires = 0;
+ for (TopoDS_Iterator aWIt(ForwardFace); aWIt.More(); aWIt.Next())
+ ++aNbWires;
TopoDSToStep_MakeStepWire MkWire;
for (WireExp.Init(ForwardFace, TopAbs_WIRE); WireExp.More(); WireExp.Next())
- MkWire.Init(CurrentWire, aTool, FP, theLocalFactors);
+ MkWire.Init(CurrentWire, aTool, FP, theLocalFactors, /*theAllowVertexLoop=*/ aNbWires == 1);
with the flag guarding the if (ie > nb) branch in MakeStepWire.
Complementarily on the read side: when the file does supply a seam, StepToTopoDS_TranslateFace should translate it as-is and leave ShapeFix_Face::FixMissingSeam for files that genuinely lack one.
Sample Code or DRAW Tcl Script
Self-contained — builds the geometry from primitives, no attachment needed. Compiled and run against OCCT 7.6.3; the same construction gives identical numbers on 7.9.3.1.
// A STEP round trip drops the seam of a periodic face that carries other bounds.
// Build (7.6.x lib names):
// g++ -std=c++17 -I/usr/include/opencascade repro_seam.cxx -o repro_seam \
// -lTKernel -lTKMath -lTKG2d -lTKG3d -lTKGeomBase -lTKBRep -lTKTopAlgo \
// -lTKGeomAlgo -lTKPrim -lTKSTEP -lTKSTEPBase -lTKXSBase
// On 7.8+ the STEP toolkits are merged: use -lTKDESTEP in place of -lTKSTEP -lTKSTEPBase.
#include <BRepBuilderAPI_MakeEdge.hxx>
#include <BRepBuilderAPI_MakeFace.hxx>
#include <BRepBuilderAPI_MakeWire.hxx>
#include <BRepGProp.hxx>
#include <BRepLib.hxx>
#include <GProp_GProps.hxx>
#include <Geom2d_Circle.hxx>
#include <Geom_SphericalSurface.hxx>
#include <Precision.hxx>
#include <STEPControl_Reader.hxx>
#include <STEPControl_Writer.hxx>
#include <TopExp.hxx>
#include <TopTools_IndexedMapOfShape.hxx>
#include <TopoDS_Face.hxx>
#include <TopoDS_Wire.hxx>
#include <gp_Ax2d.hxx>
#include <gp_Ax3.hxx>
#include <iostream>
static void Census(const TopoDS_Shape& theShape, const char* theTag)
{
TopTools_IndexedMapOfShape anEdges;
TopExp::MapShapes(theShape, TopAbs_EDGE, anEdges);
Standard_Real aTotal = 0.0;
for (Standard_Integer anI = 1; anI <= anEdges.Extent(); ++anI)
{
GProp_GProps aProps;
BRepGProp::LinearProperties(anEdges(anI), aProps);
aTotal += aProps.Mass();
}
std::cout << theTag << ": edges=" << anEdges.Extent() << " length=" << aTotal << std::endl;
}
int main()
{
const Standard_Real aRadius = 1.5125;
Handle(Geom_SphericalSurface) aSphere =
new Geom_SphericalSurface(gp_Ax3(gp_Pnt(0.0, 0.0, 0.0), gp_Dir(0.0, 0.0, 1.0)), aRadius);
// Full periodic face: its natural bound is the seam plus the two pole degeneracies.
TopoDS_Face aFace = BRepBuilderAPI_MakeFace(aSphere, Precision::Confusion()).Face();
// Two hole bounds, so the natural bound is no longer the face's only bound.
// Circles in the (u, v) parametric space, centred on the poles.
const Standard_Real aLatitude[2] = {1.1197695, -1.1197695}; // v = +/- asin(0.9)
for (Standard_Integer anI = 0; anI < 2; ++anI)
{
const gp_Ax2d aCentre(gp_Pnt2d(0.0, aLatitude[anI]), gp_Dir2d(1.0, 0.0));
Handle(Geom2d_Circle) aPCurve = new Geom2d_Circle(aCentre, 0.4);
TopoDS_Edge anEdge = BRepBuilderAPI_MakeEdge(aPCurve, aSphere).Edge();
TopoDS_Wire aWire = BRepBuilderAPI_MakeWire(anEdge).Wire();
aFace = BRepBuilderAPI_MakeFace(aFace, aWire).Face();
}
BRepLib::BuildCurves3d(aFace);
Census(aFace, "before");
STEPControl_Writer aWriter;
if (aWriter.Transfer(aFace, STEPControl_AsIs) != IFSelect_RetDone
|| aWriter.Write("sphere_with_holes.step") != IFSelect_RetDone)
{
std::cout << "write failed" << std::endl;
return 1;
}
STEPControl_Reader aReader;
if (aReader.ReadFile("sphere_with_holes.step") != IFSelect_RetDone)
{
std::cout << "read failed" << std::endl;
return 1;
}
aReader.TransferRoots();
Census(aReader.OneShape(), "after ");
return 0;
}
Output (identical on 7.6.3 and 7.9.3.1):
before: edges=5 length=10.484
after : edges=11 length=8.06439
and the written file contains VERTEX_LOOP = 1, EDGE_CURVE = 2, SEAM_CURVE = 0.
Workaround
ShapeUpgrade_ShapeDivideClosed before export removes closed faces, so no natural bound is written. On the production bearing-ball shape this makes the round trip exactly stable (28.188103 mm in and out, identical area, idempotent across a second trip) — at the cost of a different, canonical topology.
Related
Description
Writing a face that lies on a closed periodic surface and carries both its natural bound and additional (hole) bounds discards the seam: the natural-bound wire is emitted as a
VERTEX_LOOP, so the file contains no seam edge, and the reader has to invent one. A STEP write + read is therefore not the identity on a valid shape — edges are lost or split and total edge length changes.Why this matters beyond cosmetics. STEP is the interchange format, and consumers routinely verify that a part survived transport by fingerprinting its topology (edge counts and lengths, surface-type census) or by comparing measured quantities. When
write → readis not coherent on a shape OCCT itself considers valid, every such consumer reports a change that did not happen — PDM de-duplication, geometry graders, metrology pipelines, regression suites. The divergence is silent: the file is schema-valid, the reader raises nothing, and the shape stays "valid". We hit this on production assemblies where geometrically identical parts compared as different after a single round trip.The
VERTEX_LOOPsubstitution itself is correct and deliberate for a complete periodic face (the CAX-IF TRJ4 case named in the code comment): if the face is the whole closed surface, any seam the reader picks is equivalent. That equivalence breaks as soon as the face has other bounds, because then the seam's placement determines how the remaining bounds are cut.Expected Behavior
A round trip preserves the seam and the edge set of a periodic face that carries additional bounds. In the reproducer below, 5 edges totalling 10.483996 should come back as 5 edges totalling 10.483996.
Actual Behavior
The written file contains
VERTEX_LOOP = 1,EDGE_CURVE = 2(the two hole circles only) and no seam edge. Reading it back yields 11 edges totalling 8.064394: the seam is gone and both hole boundaries have been cut by the seam the reader re-derived in its place.On imported production data the same defect manifests as edge splitting rather than loss: a bearing ball (spherical patch with two drilled holes) goes from 5 edges / 23.436445 mm to 8 edges / 18.989533 mm, the 4.7517 mm seam returning as two 0.1524 mm stubs while a 9.4137 mm hole boundary is chopped into 2.3539 + 4.7069 + 2.3530 by the re-derived seam, and the spherical patch comes back wrapped in
Geom_RectangularTrimmedSurface.Reproduced on OCCT 7.6.3 and 7.9.3.1 (Linux x86-64) with identical figures, so this is long-standing behaviour rather than a recent regression. Independent of
write.precision.mode,write.surfacecurve.mode, and of both shape-processing sequences (read.step.sequence/write.step.sequencedisabled changes nothing).Root cause
src/DataExchange/TKDESTEP/TopoDSToStep/TopoDSToStep_MakeStepWire.cxx:182-234The wire is examined in isolation: non-degenerate edges are collected, and if they pair up as seam edges the whole wire becomes a
VERTEX_LOOP. Nothing checks whether the face has further bounds.TopoDSToStep_MakeStepWire::Initonly receives aTopoDS_Wire, so the necessary context lives one level up, in the wire loop ofTopoDSToStep_MakeStepFace.cxx:264-269.Proposed fix
Restrict the substitution to faces whose only bound is the natural bound — a strict narrowing that leaves the TRJ4 complete-sphere behaviour untouched:
with the flag guarding the
if (ie > nb)branch inMakeStepWire.Complementarily on the read side: when the file does supply a seam,
StepToTopoDS_TranslateFaceshould translate it as-is and leaveShapeFix_Face::FixMissingSeamfor files that genuinely lack one.Sample Code or DRAW Tcl Script
Self-contained — builds the geometry from primitives, no attachment needed. Compiled and run against OCCT 7.6.3; the same construction gives identical numbers on 7.9.3.1.
Output (identical on 7.6.3 and 7.9.3.1):
and the written file contains
VERTEX_LOOP = 1,EDGE_CURVE = 2,SEAM_CURVE = 0.Workaround
ShapeUpgrade_ShapeDivideClosedbefore export removes closed faces, so no natural bound is written. On the production bearing-ball shape this makes the round trip exactly stable (28.188103 mm in and out, identical area, idempotent across a second trip) — at the cost of a different, canonical topology.Related
ShapeFix_Face::FixMissingSeamproducing an extra null-area wire) is the reader-side counterpart of the reconstruction this defect forces.