Skip to content

Data Exchange, STEP Export - Natural bound written as VERTEX_LOOP drops the seam of a trimmed periodic face #1460

Description

@GabrielJMS

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions