From 993655bfe3b68b2f8134230ccfce9364cf8e16c8 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:23:38 +0200 Subject: [PATCH 1/8] Add history to the wire func --- cadquery/occ_impl/shapes.py | 69 ++++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index a95021c65..45f40769d 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -2078,6 +2078,12 @@ def curvatureAt( def paramsLength(self, locations: Iterable[float]) -> list[float]: ... + def startVertex(self) -> Vertex: + ... + + def endVertex(self) -> Vertex: + ... + T1D = TypeVar("T1D", bound=Mixin1DProtocol) @@ -2103,10 +2109,7 @@ def startPoint(self: Mixin1DProtocol) -> Vector: Note, circles may have the start and end points the same """ - v1, _ = TopoDS_Vertex(), TopoDS_Vertex() - ShapeAnalysis.FindBounds_s(self.wrapped, v1, _) - - return Vertex(v1).Center() + return self.startVertex().Center() def endPoint(self: Mixin1DProtocol) -> Vector: """ @@ -2116,10 +2119,33 @@ def endPoint(self: Mixin1DProtocol) -> Vector: Note, circles may have the start and end points the same """ + return self.endVertex().Center() + + def startVertex(self: Mixin1DProtocol) -> Vertex: + """ + + :return: a vertex representing the start point of this edge + + Note, circles may have the start and end vertex the same + """ + + v1, _ = TopoDS_Vertex(), TopoDS_Vertex() + ShapeAnalysis.FindBounds_s(self.wrapped, v1, _) + + return Vertex(v1) + + def endVertex(self: Mixin1DProtocol) -> Vertex: + """ + + :return: a vertex representing the end point of this edge. + + Note, circles may have the start and end vertex the same + """ + _, v2 = TopoDS_Vertex(), TopoDS_Vertex() ShapeAnalysis.FindBounds_s(self.wrapped, _, v2) - return Vertex(v2).Center() + return Vertex(v2) def _approxCurve(self: Mixin1DProtocol) -> Geom_BSplineCurve: """ @@ -5152,7 +5178,7 @@ def _get_wires(s: Shape) -> Iterable[Shape]: raise ValueError(f"Required type(s): Edge, Wire; encountered {t}") -def _get_edges(*shapes: Shape) -> Iterable[Shape]: +def _get_edges(*shapes: Shape) -> Iterable[Edge]: """ Get edges or edges from wires. """ @@ -5161,7 +5187,7 @@ def _get_edges(*shapes: Shape) -> Iterable[Shape]: t = s.ShapeType() if t == "Edge": - yield s + yield s.edge() elif t == "Wire": yield from _get_edges(s.edges()) elif t == "Compound": @@ -5760,6 +5786,7 @@ def _update_history( BRepBuilderAPI_MakeShape, BOPAlgo_Builder, BRepOffset_MakeOffset, + BRepBuilderAPI_MakeWire, ), ) has_modifidied = isinstance( @@ -5968,23 +5995,41 @@ def wireOn(base: Shape, w: Shape, tol: float = 1e-6, N: int = 20) -> Wire: @multidispatch -def wire(*s: Shape) -> Wire: +def wire(*s: Shape, history: History | None = None, name: str | None = None,) -> Wire: """ Build wire from edges. """ builder = BRepBuilderAPI_MakeWire() - edges = _shapes_to_toptools_list(e for el in s for e in _get_edges(el)) - builder.Add(edges) + new_edges = [] + edges: list[Edge] = [] + for el in s: + edges.extend(_get_edges(el)) + + for e in edges: + builder.Add(e.edge().wrapped) + new_edges.append(Edge(builder.Edge())) + + _update_history(history, name, s, builder) + + # handle OCCT lack of implementation + if history: + op = history[-1] + for k, v in zip(edges, new_edges): + op._modified[k] = v + op._modified[k.startVertex()] = v.startVertex() + op._modified[k.endVertex()] = v.endVertex() return _shape(builder.Shape(), Wire) @multidispatch -def wire(s: Sequence[Shape]) -> Wire: +def wire( + s: Sequence[Shape], history: History | None = None, name: str | None = None, +) -> Wire: - return wire(*s) + return wire(*s, history=history, name=name) @multidispatch From 23652ca02149b8a35477b67133393b0c4234a805 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:27:04 +0200 Subject: [PATCH 2/8] Add test --- tests/test_free_functions.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index d8380c6c6..0b682f97f 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -1460,3 +1460,21 @@ def test_chamfer2D_history(): assert new_edges.edges().size() == 4 assert mod_edges.edges().size() == 4 + + +def test_wire_history(): + + h = History() + pts = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] + + edges = [segment(p1, p2) for p1, p2 in zip(pts, pts[1:] + pts[:1])] + + _ = wire(edges, history=h) + + op = h[-1] + + assert op.modified().edges().size() == 4 + assert op.modified().vertices().size() == 4 + + for e in edges: + assert (e.Center() - op.modified(e).Center()).Length == approx(0) From e8fffdc3b0434587306626609a48fb976c680b84 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:37:39 +0200 Subject: [PATCH 3/8] Check status when constructing wires --- cadquery/occ_impl/shapes.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 45f40769d..614eac1fb 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -90,6 +90,7 @@ BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakePolygon, BRepBuilderAPI_MakeWire, + BRepBuilderAPI_WireError, BRepBuilderAPI_Sewing, BRepBuilderAPI_Copy, BRepBuilderAPI_GTransform, @@ -5614,7 +5615,7 @@ def modified(self, s: Shape | None = None) -> Shape: if s: return self._get(self._modified, s) - return _normalize(compound(*self._modified.values())) + return _normalize(compound(*set(self._modified.values()))) def generated(self, s: Shape | None = None) -> Shape: """ @@ -6009,6 +6010,12 @@ def wire(*s: Shape, history: History | None = None, name: str | None = None,) -> for e in edges: builder.Add(e.edge().wrapped) + status = builder.Error() + + assert ( + status == BRepBuilderAPI_WireError.BRepBuilderAPI_WireDone + ), f"Wire construction failed: {status}" + new_edges.append(Edge(builder.Edge())) _update_history(history, name, s, builder) From 7afdfe5a0d1e19710850d2cd3b6e2fec43420930 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:37:27 +0200 Subject: [PATCH 4/8] Add reordering befor wire construction --- cadquery/occ_impl/geom.py | 4 +++ cadquery/occ_impl/shapes.py | 53 +++++++++++++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/cadquery/occ_impl/geom.py b/cadquery/occ_impl/geom.py index 446ce14c5..1a9074c59 100644 --- a/cadquery/occ_impl/geom.py +++ b/cadquery/occ_impl/geom.py @@ -243,6 +243,10 @@ def __iter__(self) -> Iterator[float]: yield from (self.x, self.y, self.z) + def toXYZ(self) -> gp_XYZ: + + return self.wrapped.XYZ() + def toPnt(self) -> gp_Pnt: return gp_Pnt(self.wrapped.XYZ()) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 614eac1fb..af4a00bf5 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -277,6 +277,7 @@ ShapeAnalysis_Wire, ShapeAnalysis_Surface, ShapeAnalysis, + ShapeAnalysis_WireOrder, ) from OCP.TopTools import TopTools_HSequenceOfShape @@ -5995,6 +5996,33 @@ def wireOn(base: Shape, w: Shape, tol: float = 1e-6, N: int = 20) -> Wire: return wire(rvs) +def _reorder_edges(edges: list[Edge]) -> tuple[list[int], list[bool]]: + """ + Private helper for reordering of edges before wire construction. Returns order and + correct orientation. + """ + + n_edges = len(edges) + order = [0] * len(edges) + reverse = [False] * n_edges + + ana = ShapeAnalysis_WireOrder() + + for e in edges: + ana.Add(e.startPoint().toXYZ(), e.endPoint().toXYZ()) + + ana.Perform() + + for i in range(n_edges): + i_old = ana.Ordered(i + 1) + i_old_abs = abs(i_old) - 1 + + order[i] = i_old_abs + reverse[i] = i_old < 0 + + return order, reverse + + @multidispatch def wire(*s: Shape, history: History | None = None, name: str | None = None,) -> Wire: """ @@ -6008,8 +6036,17 @@ def wire(*s: Shape, history: History | None = None, name: str | None = None,) -> for el in s: edges.extend(_get_edges(el)) - for e in edges: - builder.Add(e.edge().wrapped) + # reorder and check orientation + order, reverse = _reorder_edges(edges) + + # build and store new edges for history construction + for i, rev in zip(order, reverse): + edge = edges[i].edge().wrapped + + if rev: + edge = TopoDS.Edge(edge.Reversed()) + + builder.Add(edge) status = builder.Error() assert ( @@ -6023,10 +6060,16 @@ def wire(*s: Shape, history: History | None = None, name: str | None = None,) -> # handle OCCT lack of implementation if history: op = history[-1] - for k, v in zip(edges, new_edges): + for ix, v, rev in zip(order, new_edges, reverse): + k = edges[ix] op._modified[k] = v - op._modified[k.startVertex()] = v.startVertex() - op._modified[k.endVertex()] = v.endVertex() + + if rev: + op._modified[k.endVertex()] = v.startVertex() + op._modified[k.startVertex()] = v.endVertex() + else: + op._modified[k.startVertex()] = v.startVertex() + op._modified[k.endVertex()] = v.endVertex() return _shape(builder.Shape(), Wire) From 8337137b695b6e94cf8d061b908f70063ddde828 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:37:42 +0200 Subject: [PATCH 5/8] Extend test --- tests/test_free_functions.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 0b682f97f..b0721d7fd 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -1469,6 +1469,7 @@ def test_wire_history(): edges = [segment(p1, p2) for p1, p2 in zip(pts, pts[1:] + pts[:1])] + # regular case _ = wire(edges, history=h) op = h[-1] @@ -1478,3 +1479,21 @@ def test_wire_history(): for e in edges: assert (e.Center() - op.modified(e).Center()).Length == approx(0) + + for v in e: + assert (v.Center() - op.modified(v).Center()).Length == approx(0) + + # test with wrong edge order + edges = [edges[0], edges[2].reverse(), edges[1], edges[3]] + _ = wire(edges, history=h) + + op = h[-1] + + assert op.modified().edges().size() == 4 + assert op.modified().vertices().size() == 4 + + for e in edges: + assert (e.Center() - op.modified(e).Center()).Length == approx(0) + + for v in e: + assert (v.Center() - op.modified(v).Center()).Length == approx(0) From 4964c1eba301c2ee8ceec749a8d1d3823be0a5ee Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:57:31 +0200 Subject: [PATCH 6/8] Update images too --- cadquery/occ_impl/shapes.py | 5 +++++ tests/test_free_functions.py | 31 ++++++++++++++++++++++--------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index af4a00bf5..59ea76930 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -6063,13 +6063,18 @@ def wire(*s: Shape, history: History | None = None, name: str | None = None,) -> for ix, v, rev in zip(order, new_edges, reverse): k = edges[ix] op._modified[k] = v + op._images[k] = v if rev: op._modified[k.endVertex()] = v.startVertex() op._modified[k.startVertex()] = v.endVertex() + op._images[k.endVertex()] = v.startVertex() + op._images[k.startVertex()] = v.endVertex() else: op._modified[k.startVertex()] = v.startVertex() op._modified[k.endVertex()] = v.endVertex() + op._images[k.startVertex()] = v.startVertex() + op._images[k.endVertex()] = v.endVertex() return _shape(builder.Shape(), Wire) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index b0721d7fd..958363254 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -1464,6 +1464,16 @@ def test_chamfer2D_history(): def test_wire_history(): + # private helper + def _check(op, edges): + for e in edges: + assert (e.Center() - op.modified(e).Center()).Length == approx(0) + assert (e.Center() - op.images(e).Center()).Length == approx(0) + + for v in e: + assert (v.Center() - op.modified(v).Center()).Length == approx(0) + assert (v.Center() - op.images(v).Center()).Length == approx(0) + h = History() pts = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0)] @@ -1477,11 +1487,7 @@ def test_wire_history(): assert op.modified().edges().size() == 4 assert op.modified().vertices().size() == 4 - for e in edges: - assert (e.Center() - op.modified(e).Center()).Length == approx(0) - - for v in e: - assert (v.Center() - op.modified(v).Center()).Length == approx(0) + _check(op, edges) # test with wrong edge order edges = [edges[0], edges[2].reverse(), edges[1], edges[3]] @@ -1492,8 +1498,15 @@ def test_wire_history(): assert op.modified().edges().size() == 4 assert op.modified().vertices().size() == 4 - for e in edges: - assert (e.Center() - op.modified(e).Center()).Length == approx(0) + _check(op, edges) + + # same but open wire + edges = edges[:-1] + _ = wire(edges, history=h) + + op = h[-1] + + assert op.modified().edges().size() == 3 + assert op.modified().vertices().size() == 4 - for v in e: - assert (v.Center() - op.modified(v).Center()).Length == approx(0) + _check(op, edges) From 5e8b9bb48eb8584fcf1b2d3bcdabf80b2bdded57 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:07:43 +0200 Subject: [PATCH 7/8] Comment tweaks --- cadquery/occ_impl/shapes.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 59ea76930..a68305b65 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -6031,6 +6031,7 @@ def wire(*s: Shape, history: History | None = None, name: str | None = None,) -> builder = BRepBuilderAPI_MakeWire() + # collect all edges and initialize new_edges = [] edges: list[Edge] = [] for el in s: @@ -6055,9 +6056,10 @@ def wire(*s: Shape, history: History | None = None, name: str | None = None,) -> new_edges.append(Edge(builder.Edge())) + # NB: this is a dummy update, the builder does not implement history correctly _update_history(history, name, s, builder) - # handle OCCT lack of implementation + # Update history manually if history: op = history[-1] for ix, v, rev in zip(order, new_edges, reverse): From 81a2cc5f02646058937ee8dc6e48a8dad01a1feb Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:06:28 +0200 Subject: [PATCH 8/8] Handle empty wire --- cadquery/occ_impl/shapes.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index a68305b65..86af4afe9 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -84,6 +84,7 @@ ) from OCP.BRepBuilderAPI import ( + BRepBuilderAPI_Command, BRepBuilderAPI_MakeShape, BRepBuilderAPI_MakeVertex, BRepBuilderAPI_MakeEdge, @@ -5754,8 +5755,6 @@ def _update_history( # construct the history step op = Op() - history.append(op, name) - # track all subshapes for shape in shapes: op._tracked.update(shape.Faces()) @@ -5765,6 +5764,12 @@ def _update_history( # iterate over all builders and collect history information builder: Any for builder in builders: + + if isinstance(builder, BRepBuilderAPI_Command): + assert ( + builder.IsDone() + ), f"Builder {builder} not done, cannot fill history." + has_first_last = isinstance( builder, (BRepPrimAPI_MakeRevol, BRepPrimAPI_MakePrism,), ) @@ -5852,6 +5857,8 @@ def _update_history( op._first_shape |= _compound_or_shape(builder.FirstShape()) op._last_shape |= _compound_or_shape(builder.LastShape()) + history.append(op, name) + def _remap_history_values(history: History | None, aux: History,) -> None: """