From 821aa1e6de6d07a1a1ef067d48124258ec3c9dab Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 26 Feb 2025 11:32:11 -0500 Subject: [PATCH 001/124] Initial commit with colors and locations working for simple assemblies --- cadquery/assembly.py | 14 +++ cadquery/occ_impl/importers/assembly.py | 119 ++++++++++++++++++++++++ tests/test_assembly.py | 37 ++++++++ 3 files changed, 170 insertions(+) create mode 100644 cadquery/occ_impl/importers/assembly.py diff --git a/cadquery/assembly.py b/cadquery/assembly.py index bed1fdc8a..22d9ab068 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -34,6 +34,7 @@ exportGLTF, STEPExportModeLiterals, ) +from .occ_impl.importers.assembly import importStep as importStepTopLevel from .selectors import _expression_grammar as _selector_grammar from .utils import deprecate @@ -564,6 +565,19 @@ def export( return self + @staticmethod + def importStep(path: str) -> "Assembly": + """ + Reads an assembly from a STEP file. + + :param path: Path and filename for writing. + :return: An Assembly object. + """ + + assy = importStepTopLevel(path) + + return assy + @classmethod def load(cls, path: str) -> "Assembly": diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py new file mode 100644 index 000000000..410ece33c --- /dev/null +++ b/cadquery/occ_impl/importers/assembly.py @@ -0,0 +1,119 @@ +from OCP.TCollection import TCollection_ExtendedString +from OCP.Quantity import Quantity_Color, Quantity_ColorRGBA +from OCP.TDocStd import TDocStd_Document +from OCP.IFSelect import IFSelect_RetDone +from OCP.STEPCAFControl import STEPCAFControl_Reader +from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ColorGen, XCAFDoc_ColorSurf +from OCP.TDF import TDF_Label, TDF_LabelSequence + +import cadquery as cq +from ..assembly import AssemblyProtocol + + +def importStep(path: str) -> AssemblyProtocol: + """ + Import a step file into an assembly. + """ + + # The assembly that is being built from the step file + assy = cq.Assembly() + + # Document that the step file will be read into + doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf")) + + # Create and configure a STEP reader + step_reader = STEPCAFControl_Reader() + step_reader.SetColorMode(True) + step_reader.SetNameMode(True) + step_reader.SetLayerMode(True) + step_reader.SetSHUOMode(True) + + # Read the STEP file + status = step_reader.ReadFile(path) + if status != IFSelect_RetDone: + raise ValueError(f"Error reading STEP file: {path}") + + # Transfer the contents of the STEP file to the document + step_reader.Transfer(doc) + + # Shape and color tools for extracting XCAF data + shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()) + color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main()) + + def process_label(label, parent_location=None): + """ + Recursive function that allows us to process the hierarchy of the assembly as represented + in the step file. + """ + + # Handle reference labels + if shape_tool.IsReference_s(label): + ref_label = TDF_Label() + shape_tool.GetReferredShape_s(label, ref_label) + process_label(ref_label, parent_location) + return + + # Process components + comp_labels = TDF_LabelSequence() + shape_tool.GetComponents_s(label, comp_labels) + for i in range(comp_labels.Length()): + sub_label = comp_labels.Value(i + 1) + + # The component level holds the location for its shapes + loc = shape_tool.GetLocation_s(sub_label) + if loc: + location = cq.Location(loc) + + # Make sure that the location object is actually doing something interesting + # This is done because the location may have to go through multiple levels of + # components before the shapes are found. This allows the top-level component + # to specify the location/rotation of the shapes. + if location.toTuple()[0] == (0, 0, 0) and location.toTuple()[1] == ( + 0, + 0, + 0, + ): + location = parent_location + else: + location = parent_location + + process_label(sub_label, location) + + # Check to see if we have an endpoint shape + if shape_tool.IsSimpleShape_s(label): + shape = shape_tool.GetShape_s(label) + + # Process the color for the shape, which could be of different types + color = Quantity_Color() + if color_tool.GetColor_s(label, XCAFDoc_ColorSurf, color): + r = color.Red() + g = color.Green() + b = color.Blue() + cq_color = cq.Color(r, g, b) + elif color_tool.GetColor_s(label, XCAFDoc_ColorGen, color): + r = color.Red() + g = color.Green() + b = color.Blue() + cq_color = cq.Color(r, g, b) + else: + cq_color = cq.Color(0.5, 0.5, 0.5) + + # Handle the location if it was passed down form a parent component + if parent_location is not None: + assy.add(cq.Shape.cast(shape), color=cq_color, loc=parent_location) + else: + assy.add(cq.Shape.cast(shape), color=cq_color) + + # Grab the labels, which should hold the assembly parent + labels = TDF_LabelSequence() + shape_tool.GetFreeShapes(labels) + + # Make sure that we are working with an assembly + if shape_tool.IsAssembly_s(labels.Value(1)): + # Start the recursive processing of the assembly + process_label(labels.Value(1)) + + else: + raise ValueError("Step file does not contain an assembly") + + return assy diff --git a/tests/test_assembly.py b/tests/test_assembly.py index d66da7467..4ba94dd3f 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -616,6 +616,43 @@ def test_step_export(nested_assy, tmp_path_factory): assert pytest.approx(c2.toTuple()) == (0, 4, 0) +def test_step_import(tmp_path_factory): + """ + Exports a simple assembly to step with locations and colors and ensures that information is + preserved when the assembly is re-imported from the step file. + """ + + # Use a temporary directory + tmpdir = tmp_path_factory.mktemp("out") + metadata_path = os.path.join(tmpdir, "metadata.step") + + # Assembly with all the metadata added that needs to be read by the STEP importer + cube_1 = cq.Workplane().box(10, 10, 10) + cube_2 = cq.Workplane().box(15, 15, 15) + assy = cq.Assembly() + assy.add(cube_1, name="cube_1", color=cq.Color(1.0, 0.0, 0.0)) + assy.add( + cube_2, + name="cube_2", + color=cq.Color(0.0, 1.0, 0.0), + loc=cq.Location((15, 15, 15)), + ) + exportAssembly(assy, metadata_path) + + assy = cq.Assembly.importStep(path=metadata_path) + + # Make sure that there are the correct number of parts in the assembly + assert len(assy.children) == 2 + + # Make sure that the colors are correct + assert assy.children[0].color == cq.Color(1.0, 0.0, 0.0) + assert assy.children[1].color == cq.Color(0.0, 1.0, 0.0) + + # Make sure that the locations are correct + assert assy.children[0].loc.toTuple()[0] == (0, 0, 0) + assert assy.children[1].loc.toTuple()[0] == (15, 15, 15) + + @pytest.mark.parametrize( "assy_fixture, expected", [ From c3d557329f06224546c81e858a0dd9ad606834f5 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 26 Feb 2025 11:56:17 -0500 Subject: [PATCH 002/124] Trying to fix mypy error --- cadquery/occ_impl/importers/assembly.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 410ece33c..be068d044 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -7,10 +7,9 @@ from OCP.TDF import TDF_Label, TDF_LabelSequence import cadquery as cq -from ..assembly import AssemblyProtocol -def importStep(path: str) -> AssemblyProtocol: +def importStep(path: str) -> "Assembly": """ Import a step file into an assembly. """ From da9844ac3fb77b2817fe7bd1b56f0dde0159fbbd Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 26 Feb 2025 15:42:17 -0500 Subject: [PATCH 003/124] Jumping through hoops to make mypy happy --- cadquery/assembly.py | 2 +- cadquery/occ_impl/importers/assembly.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index 22d9ab068..29ec18698 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -574,7 +574,7 @@ def importStep(path: str) -> "Assembly": :return: An Assembly object. """ - assy = importStepTopLevel(path) + assy = cast(Assembly, importStepTopLevel(path)) return assy diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index be068d044..b3cf64289 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -7,9 +7,9 @@ from OCP.TDF import TDF_Label, TDF_LabelSequence import cadquery as cq +from ..assembly import AssemblyProtocol - -def importStep(path: str) -> "Assembly": +def importStep(path: str) -> AssemblyProtocol: """ Import a step file into an assembly. """ From 7b674c85c270c92c866300b85798e045e1a5d486 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 26 Feb 2025 16:01:49 -0500 Subject: [PATCH 004/124] Lint fix --- cadquery/occ_impl/importers/assembly.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index b3cf64289..410ece33c 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -9,6 +9,7 @@ import cadquery as cq from ..assembly import AssemblyProtocol + def importStep(path: str) -> AssemblyProtocol: """ Import a step file into an assembly. From 288c0a001acd9c509f4768b5fa9211a781146547 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Thu, 27 Feb 2025 08:51:39 -0500 Subject: [PATCH 005/124] Refactored method interface based on suggestion --- cadquery/assembly.py | 3 ++- cadquery/occ_impl/importers/assembly.py | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index 29ec18698..8dda14f37 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -574,7 +574,8 @@ def importStep(path: str) -> "Assembly": :return: An Assembly object. """ - assy = cast(Assembly, importStepTopLevel(path)) + assy = Assembly() + importStepTopLevel(assy, path) return assy diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 410ece33c..cbdaa8b70 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -10,13 +10,15 @@ from ..assembly import AssemblyProtocol -def importStep(path: str) -> AssemblyProtocol: +def importStep(assy: AssemblyProtocol, path: str): """ Import a step file into an assembly. - """ - # The assembly that is being built from the step file - assy = cq.Assembly() + :param assy: An Assembly object that will be packed with the contents of the STEP file. + :param path: Path and filename to the STEP file to read. + + :return: None + """ # Document that the step file will be read into doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf")) @@ -115,5 +117,3 @@ def process_label(label, parent_location=None): else: raise ValueError("Step file does not contain an assembly") - - return assy From 4045c5813708dbbd497f2504c5ece94927b8a689 Mon Sep 17 00:00:00 2001 From: AU Date: Mon, 3 Mar 2025 08:07:57 +0100 Subject: [PATCH 006/124] Use Self and @classmethod --- cadquery/assembly.py | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index 8dda14f37..dfe8cb9ef 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -11,7 +11,7 @@ cast, get_args, ) -from typing_extensions import Literal +from typing_extensions import Literal, Self from typish import instance_of from uuid import uuid1 as uuid @@ -79,7 +79,6 @@ def _define_grammar(): _grammar = _define_grammar() - class Assembly(object): """Nested assembly of Workplane and Shape objects defining their relative positions.""" @@ -140,7 +139,7 @@ def __init__( self._solve_result = None - def _copy(self) -> "Assembly": + def _copy(self) -> Self: """ Make a deep copy of an assembly """ @@ -164,7 +163,7 @@ def add( loc: Optional[Location] = None, name: Optional[str] = None, color: Optional[Color] = None, - ) -> "Assembly": + ) -> Self: """ Add a subassembly to the current assembly. @@ -186,7 +185,7 @@ def add( name: Optional[str] = None, color: Optional[Color] = None, metadata: Optional[Dict[str, Any]] = None, - ) -> "Assembly": + ) -> Self: """ Add a subassembly to the current assembly with explicit location and name. @@ -299,11 +298,11 @@ def _subloc(self, name: str) -> Tuple[Location, str]: @overload def constrain( self, q1: str, q2: str, kind: ConstraintKind, param: Any = None - ) -> "Assembly": + ) -> Self: ... @overload - def constrain(self, q1: str, kind: ConstraintKind, param: Any = None) -> "Assembly": + def constrain(self, q1: str, kind: ConstraintKind, param: Any = None) -> Self: ... @overload @@ -315,13 +314,13 @@ def constrain( s2: Shape, kind: ConstraintKind, param: Any = None, - ) -> "Assembly": + ) -> Self: ... @overload def constrain( self, id1: str, s1: Shape, kind: ConstraintKind, param: Any = None, - ) -> "Assembly": + ) -> Self: ... def constrain(self, *args, param=None): @@ -366,7 +365,7 @@ def constrain(self, *args, param=None): return self - def solve(self, verbosity: int = 0) -> "Assembly": + def solve(self, verbosity: int = 0) -> Self: """ Solve the constraints. """ @@ -461,7 +460,7 @@ def save( tolerance: float = 0.1, angularTolerance: float = 0.1, **kwargs, - ) -> "Assembly": + ) -> Self: """ Save assembly to a file. @@ -517,7 +516,7 @@ def export( tolerance: float = 0.1, angularTolerance: float = 0.1, **kwargs, - ) -> "Assembly": + ) -> Self: """ Save assembly to a file. @@ -565,8 +564,8 @@ def export( return self - @staticmethod - def importStep(path: str) -> "Assembly": + @classmethod + def importStep(cls, path: str) -> Self: """ Reads an assembly from a STEP file. @@ -574,13 +573,13 @@ def importStep(path: str) -> "Assembly": :return: An Assembly object. """ - assy = Assembly() + assy = cls() importStepTopLevel(assy, path) return assy @classmethod - def load(cls, path: str) -> "Assembly": + def load(cls, path: str) -> Self: raise NotImplementedError From d708acd858480eee6e5b6e79c7c674a965c1791a Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 3 Mar 2025 07:15:04 -0500 Subject: [PATCH 007/124] Lint fix --- cadquery/assembly.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index dfe8cb9ef..0b79f6420 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -79,6 +79,7 @@ def _define_grammar(): _grammar = _define_grammar() + class Assembly(object): """Nested assembly of Workplane and Shape objects defining their relative positions.""" From 47e039997100033050a267bbf97a489c5ff800c0 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Mon, 10 Mar 2025 08:09:55 +0100 Subject: [PATCH 008/124] Adding fig --- cadquery/fig.py | 90 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 cadquery/fig.py diff --git a/cadquery/fig.py b/cadquery/fig.py new file mode 100644 index 000000000..0c7843a7f --- /dev/null +++ b/cadquery/fig.py @@ -0,0 +1,90 @@ +from trame.app import get_server, Server +from trame.widgets import html, vtk as vtk_widgets, client +from trame.ui.html import DivLayout + +from cadquery import Shape +from cadquery.vis import style + +from vtkmodules.vtkRenderingCore import ( + vtkRenderer, + vtkRenderWindow, + vtkRenderWindowInteractor, + vtkActor, +) + +FULL_SCREEN = "position:absolute; left:0; top:0; width:100vw; height:100vh;" + + +class Figure: + + server: Server + win: vtkRenderWindow + ren: vtkRenderer + shapes: dict[Shape, tuple[vtkActor, ...]] + actors: list[vtkActor] + + def __init__(self, port: int): + + # vtk boilerplate + renderer = vtkRenderer() + win = vtkRenderWindow() + win.AddRenderer(renderer) + win.OffScreenRenderingOn() + + inter = vtkRenderWindowInteractor() + inter.SetRenderWindow(win) + inter.GetInteractorStyle().SetCurrentStyleToTrackballCamera() + + self.win = win + self.ren = renderer + + self.shapes = {} + self.actors = [] + + # server + server = get_server(123) + server.client_type = "vue3" + + # layout + with DivLayout(server): + client.Style("body { margin: 0; }") + + with html.Div(style=FULL_SCREEN): + vtk_widgets.VtkRemoteView( + win, interactive_ratio=1, interactive_quality=100 + ) + + server.state.flush() + server.start(thread=True, exec_mode="task", port=port, open_browser=True) + + self.server = server + + def show(self, s: Shape | vtkActor, *args, **kwargs): + + if isinstance(s, Shape): + actors = style(s, *args, **kwargs) + self.shapes[s] = actors + + for a in actors: + self.ren.AddActor(a) + else: + self.actors.append(s) + self.ren.AddActor(s) + + self.ren.ResetCamera() + + def hide(self, s: Shape | vtkActor): + + if isinstance(s, Shape): + actors = self.shapes[s] + + for a in actors: + self.ren.RemoveActor(a) + + del self.shapes[s] + + else: + self.actors.remove(s) + self.ren.RemoveActor(s) + + self.ren.ResetCamera() From a918517c3d99d4fec910c1f641df70f163a850ee Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Tue, 11 Mar 2025 08:10:08 +0100 Subject: [PATCH 009/124] Mypy fixes --- cadquery/fig.py | 69 +++++++++++++++++++++++++++-------- cadquery/occ_impl/assembly.py | 11 +++--- cadquery/vis.py | 64 +++++++++++++++++--------------- mypy.ini | 3 ++ 4 files changed, 96 insertions(+), 51 deletions(-) diff --git a/cadquery/fig.py b/cadquery/fig.py index 0c7843a7f..759777e17 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -1,3 +1,7 @@ +import asyncio + +from typing import Any, cast + from trame.app import get_server, Server from trame.widgets import html, vtk as vtk_widgets, client from trame.ui.html import DivLayout @@ -10,8 +14,15 @@ vtkRenderWindow, vtkRenderWindowInteractor, vtkActor, + vtkProp3D, ) + +from vtkmodules.vtkInteractionWidgets import vtkOrientationMarkerWidget +from vtkmodules.vtkRenderingAnnotation import vtkAxesActor + +from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera + FULL_SCREEN = "position:absolute; left:0; top:0; width:100vw; height:100vh;" @@ -20,21 +31,44 @@ class Figure: server: Server win: vtkRenderWindow ren: vtkRenderer - shapes: dict[Shape, tuple[vtkActor, ...]] - actors: list[vtkActor] + view: vtk_widgets.VtkRemoteView + shapes: dict[Shape, vtkProp3D] + actors: list[vtkProp3D] + loop: Any def __init__(self, port: int): + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + # vtk boilerplate renderer = vtkRenderer() win = vtkRenderWindow() + w, h = win.GetScreenSize() + win.SetSize(w, h) win.AddRenderer(renderer) win.OffScreenRenderingOn() inter = vtkRenderWindowInteractor() + inter.SetInteractorStyle(vtkInteractorStyleTrackballCamera()) inter.SetRenderWindow(win) - inter.GetInteractorStyle().SetCurrentStyleToTrackballCamera() + # axes + axes = vtkAxesActor() + axes.SetDragable(0) + + orient_widget = vtkOrientationMarkerWidget() + + orient_widget.SetOrientationMarker(axes) + orient_widget.SetViewport(0.9, 0.0, 1.0, 0.2) + orient_widget.SetZoom(1.1) + orient_widget.SetInteractor(inter) + orient_widget.SetCurrentRenderer(renderer) + orient_widget.EnabledOn() + orient_widget.InteractiveOff() + + self.axes = axes + self.orient_widget = orient_widget self.win = win self.ren = renderer @@ -50,7 +84,7 @@ def __init__(self, port: int): client.Style("body { margin: 0; }") with html.Div(style=FULL_SCREEN): - vtk_widgets.VtkRemoteView( + self.view = vtk_widgets.VtkRemoteView( win, interactive_ratio=1, interactive_quality=100 ) @@ -59,27 +93,29 @@ def __init__(self, port: int): self.server = server - def show(self, s: Shape | vtkActor, *args, **kwargs): + def show(self, s: Shape | vtkActor | list[vtkProp3D], *args, **kwargs): if isinstance(s, Shape): - actors = style(s, *args, **kwargs) - self.shapes[s] = actors - - for a in actors: - self.ren.AddActor(a) - else: + actor = style(s, *args, **kwargs)[0] + self.shapes[s] = actor + self.ren.AddActor(actor) + elif isinstance(s, vtkActor): self.actors.append(s) self.ren.AddActor(s) + else: + self.actors.extend(s) + + for el in s: + self.ren.AddActor(el) self.ren.ResetCamera() + self.view.update() - def hide(self, s: Shape | vtkActor): + def clear(self, s: Shape | vtkActor): if isinstance(s, Shape): - actors = self.shapes[s] - - for a in actors: - self.ren.RemoveActor(a) + actor = self.shapes[s] + self.ren.RemoveActor(actor) del self.shapes[s] @@ -88,3 +124,4 @@ def hide(self, s: Shape | vtkActor): self.ren.RemoveActor(s) self.ren.ResetCamera() + self.view.update() diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index 24a9518fa..2cdb0e3a7 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -31,7 +31,7 @@ vtkActor, vtkPolyDataMapper as vtkMapper, vtkRenderer, - vtkAssembly, + vtkProp3D, ) from vtkmodules.vtkFiltersExtraction import vtkExtractCellsByType @@ -295,9 +295,9 @@ def toVTKAssy( linewidth: float = 2, tolerance: float = 1e-3, angularTolerance: float = 0.1, -) -> vtkAssembly: +) -> List[vtkProp3D]: - rv = vtkAssembly() + rv: List[vtkProp3D] = [] for shape, _, loc, col_ in assy: @@ -338,7 +338,7 @@ def toVTKAssy( actor.GetProperty().SetColor(*col[:3]) actor.GetProperty().SetOpacity(col[3]) - rv.AddPart(actor) + rv.append(actor) mapper = vtkMapper() mapper.AddInputDataObject(data_edges) @@ -350,9 +350,8 @@ def toVTKAssy( actor.GetProperty().SetLineWidth(linewidth) actor.SetVisibility(edges) actor.GetProperty().SetColor(*edgecolor[:3]) - actor.GetProperty().SetLineWidth(edgecolor[3]) - rv.AddPart(actor) + rv.append(actor) return rv diff --git a/cadquery/vis.py b/cadquery/vis.py index 5fbedb12f..c6c1f42d5 100644 --- a/cadquery/vis.py +++ b/cadquery/vis.py @@ -145,12 +145,12 @@ def _to_vtk_pts( return rv -def _to_vtk_axs(locs: List[Location], scale: float = 0.1) -> vtkAssembly: +def _to_vtk_axs(locs: List[Location], scale: float = 0.1) -> List[vtkProp3D]: """ Convert Locations to vtkActor. """ - rv = vtkAssembly() + rv: List[vtkProp3D] = [] for l in locs: trans, rot = _loc2vtk(l) @@ -161,7 +161,7 @@ def _to_vtk_axs(locs: List[Location], scale: float = 0.1) -> vtkAssembly: ax.SetOrientation(*rot) ax.SetScale(scale) - rv.AddPart(ax) + rv.append(ax) return rv @@ -174,7 +174,7 @@ def _to_vtk_shapes( linewidth: float = 2, alpha: float = 1, tolerance: float = 1e-3, -) -> vtkAssembly: +) -> List[vtkProp3D]: """ Convert Shapes to vtkAssembly. """ @@ -279,21 +279,18 @@ def ctrlPts( return rv -def _iterate_actors(obj: Union[vtkProp3D, vtkActor, vtkAssembly]) -> Iterable[vtkActor]: +def _iterate_actors( + obj: Union[vtkProp3D, vtkActor, List[vtkProp3D]] +) -> Iterable[vtkActor]: """ Iterate over vtkActors, other props are ignored. """ if isinstance(obj, vtkActor): yield obj - elif isinstance(obj, vtkAssembly): - coll = vtkPropCollection() - obj.GetActors(coll) - - coll.InitTraversal() - for i in range(0, coll.GetNumberOfItems()): - prop = coll.GetNextProp() - if isinstance(prop, vtkActor): - yield prop + elif isinstance(obj, list): + for el in obj: + if isinstance(el, vtkActor): + yield el def style( @@ -313,7 +310,7 @@ def style( meshcolor: str = "lightgrey", vertexcolor: str = "cyan", **kwargs, -) -> Union[vtkActor, vtkAssembly]: +) -> List[vtkProp3D]: """ Apply styling to CQ objects. To be used in conjunction with show. """ @@ -343,7 +340,7 @@ def _apply_color(actor): shapes, vecs, locs, actors = _split_showables([obj,]) # convert to a prop - rv: Union[vtkActor, vtkAssembly] + rv: Union[vtkActor, List[vtkProp3D]] if shapes: rv = _to_vtk_shapes( @@ -361,19 +358,22 @@ def _apply_color(actor): _apply_style(a) elif vecs: - rv = _to_vtk_pts(vecs) - _apply_style(rv) - _apply_color(rv) + tmp = _to_vtk_pts(vecs) + _apply_style(tmp) + _apply_color(tmp) + rv = [tmp] + elif locs: rv = _to_vtk_axs(locs, scale=scale) + else: - rv = vtkAssembly() + rv = [] for p in actors: for a in _iterate_actors(p): _apply_style(a) _apply_color(a) - rv.AddPart(a) + rv.append(a) return rv @@ -400,7 +400,7 @@ def show( gradient: bool = True, xpos: Union[int, float] = 0, ypos: Union[int, float] = 0, -): +) -> vtkRenderWindow: """ Show CQ objects using VTK. This functions optionally allows to make screenshots. """ @@ -417,7 +417,9 @@ def show( # assy+renderer renderer = vtkRenderer() - renderer.AddActor(toVTKAssy(assy, tolerance=tolerance)) + + for act in toVTKAssy(assy, tolerance=tolerance): + renderer.AddActor(act) # VTK window boilerplate win = vtkRenderWindow() @@ -455,12 +457,12 @@ def show( # construct an axes indicator axes = vtkAxesActor() axes.SetDragable(0) + axes.SetAxisLabels(0) + # tp = axes.GetXAxisCaptionActor2D().GetCaptionTextProperty() + # tp.SetColor(0, 0, 0) - tp = axes.GetXAxisCaptionActor2D().GetCaptionTextProperty() - tp.SetColor(0, 0, 0) - - axes.GetYAxisCaptionActor2D().GetCaptionTextProperty().ShallowCopy(tp) - axes.GetZAxisCaptionActor2D().GetCaptionTextProperty().ShallowCopy(tp) + # axes.GetYAxisCaptionActor2D().GetCaptionTextProperty().ShallowCopy(tp) + # axes.GetZAxisCaptionActor2D().GetCaptionTextProperty().ShallowCopy(tp) # add to an orientation widget if trihedron: @@ -483,7 +485,9 @@ def show( # add pts and locs renderer.AddActor(pts) - renderer.AddActor(axs) + + for ax in axs: + renderer.AddActor(ax) # add other vtk actors for p in props: @@ -538,6 +542,8 @@ def show( if interact: inter.Start() + return win + # alias show_object = show diff --git a/mypy.ini b/mypy.ini index 97bbf2b5d..7bc958faf 100644 --- a/mypy.ini +++ b/mypy.ini @@ -37,3 +37,6 @@ ignore_missing_imports = True [mypy-casadi.*] ignore_missing_imports = True +[mypy-trame.*] +ignore_missing_imports = True + From 3b0a8709021a1eddb116b6ecd0cbd28bae5f3c90 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Tue, 11 Mar 2025 19:28:03 +0100 Subject: [PATCH 010/124] Fix tests --- tests/test_vis.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/test_vis.py b/tests/test_vis.py index 513ec2671..e22ff2eba 100644 --- a/tests/test_vis.py +++ b/tests/test_vis.py @@ -10,6 +10,7 @@ vtkWindowToImageFilter, vtkActor, vtkAssembly, + vtkProp3D, ) from vtkmodules.vtkRenderingAnnotation import vtkAnnotatedCubeActor from vtkmodules.vtkIOImage import vtkPNGWriter @@ -17,6 +18,9 @@ from pytest import fixture, raises from path import Path +from typish import instance_of +from typing import List + @fixture(scope="module") def tmpdir(tmp_path_factory): @@ -178,39 +182,39 @@ def test_style(wp, assy): # Shape act = style(t, color="red", alpha=0.5, tubes=True, spheres=True) - assert isinstance(act, (vtkActor, vtkAssembly)) + assert instance_of(act, List[vtkProp3D]) # Assy act = style(assy, color="red", alpha=0.5, tubes=True, spheres=True) - assert isinstance(act, (vtkActor, vtkAssembly)) + assert instance_of(act, List[vtkProp3D]) # Workplane act = style(wp, color="red", alpha=0.5, tubes=True, spheres=True) - assert isinstance(act, (vtkActor, vtkAssembly)) + assert instance_of(act, List[vtkProp3D]) # Shape act = style(e) - assert isinstance(act, (vtkActor, vtkAssembly)) + assert instance_of(act, List[vtkProp3D]) # Sketch act = style(Sketch().circle(1)) - assert isinstance(act, (vtkActor, vtkAssembly)) + assert instance_of(act, List[vtkProp3D]) # list[Vector] act = style(pts) - assert isinstance(act, (vtkActor, vtkAssembly)) + assert instance_of(act, List[vtkProp3D]) # list[Location] act = style(locs) - assert isinstance(act, (vtkActor, vtkAssembly)) + assert instance_of(act, List[vtkProp3D]) # vtkAssembly act = style(style(t)) - assert isinstance(act, (vtkActor, vtkAssembly)) + assert instance_of(act, List[vtkProp3D]) # vtkActor act = style(ctrlPts(e.toNURBS())) - assert isinstance(act, (vtkActor, vtkAssembly)) + assert instance_of(act, List[vtkProp3D]) def test_camera_position(wp, patch_vtk): From fef77aa023a426e2cdb84511e3e8318c9e05ae7d Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Tue, 11 Mar 2025 19:29:43 +0100 Subject: [PATCH 011/124] Run the asyncio loop in a thread --- cadquery/fig.py | 106 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 74 insertions(+), 32 deletions(-) diff --git a/cadquery/fig.py b/cadquery/fig.py index 759777e17..46423a947 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -1,6 +1,10 @@ -import asyncio - -from typing import Any, cast +from asyncio import ( + new_event_loop, + set_event_loop, + run_coroutine_threadsafe, + AbstractEventLoop, +) +from threading import Thread from trame.app import get_server, Server from trame.widgets import html, vtk as vtk_widgets, client @@ -32,14 +36,15 @@ class Figure: win: vtkRenderWindow ren: vtkRenderer view: vtk_widgets.VtkRemoteView - shapes: dict[Shape, vtkProp3D] + shapes: dict[Shape, list[vtkProp3D]] actors: list[vtkProp3D] - loop: Any + loop: AbstractEventLoop + thread: Thread def __init__(self, port: int): - self.loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.loop) + self.loop = new_event_loop() + set_event_loop(self.loop) # vtk boilerplate renderer = vtkRenderer() @@ -76,7 +81,7 @@ def __init__(self, port: int): self.actors = [] # server - server = get_server(123) + server = get_server("CQ") server.client_type = "vue3" # layout @@ -89,39 +94,76 @@ def __init__(self, port: int): ) server.state.flush() - server.start(thread=True, exec_mode="task", port=port, open_browser=True) + coro = server.start( + thread=True, exec_mode="coroutine", port=port, open_browser=True + ) self.server = server + self.loop = new_event_loop() + + def _run(): + set_event_loop(self.loop) + self.loop.run_forever() + + self.thread = Thread(target=_run, daemon=True) + self.thread.start() + + run_coroutine_threadsafe(coro, self.loop) + + def _run(self, coro): + + run_coroutine_threadsafe(coro, self.loop) def show(self, s: Shape | vtkActor | list[vtkProp3D], *args, **kwargs): + async def _show(): + + if isinstance(s, Shape): + # do not show markers by default + if "markersize" not in kwargs: + kwargs["markersize"] = 0 + + actors = style(s, *args, **kwargs) + self.shapes[s] = actors + + for actor in actors: + self.ren.AddActor(actor) + + elif isinstance(s, vtkActor): + self.actors.append(s) + self.ren.AddActor(s) + else: + self.actors.extend(s) + + for el in s: + self.ren.AddActor(el) + + self.ren.ResetCamera() + self.view.update() - if isinstance(s, Shape): - actor = style(s, *args, **kwargs)[0] - self.shapes[s] = actor - self.ren.AddActor(actor) - elif isinstance(s, vtkActor): - self.actors.append(s) - self.ren.AddActor(s) - else: - self.actors.extend(s) + self._run(_show()) - for el in s: - self.ren.AddActor(el) + def clear(self, *shapes: Shape | vtkActor): + async def _clear(): - self.ren.ResetCamera() - self.view.update() + if len(shapes) == 0: + for a in self.actors: + self.ren.RemoveActor(a) - def clear(self, s: Shape | vtkActor): + for actors in self.shapes.values(): + for a in actors: + self.ren.RemoveActor(a) - if isinstance(s, Shape): - actor = self.shapes[s] - self.ren.RemoveActor(actor) + for s in shapes: + if isinstance(s, Shape): + for a in self.shapes[s]: + self.ren.RemoveActor(a) - del self.shapes[s] + del self.shapes[s] + else: + self.actors.remove(s) + self.ren.RemoveActor(s) - else: - self.actors.remove(s) - self.ren.RemoveActor(s) + self.ren.ResetCamera() + self.view.update() - self.ren.ResetCamera() - self.view.update() + self._run(_clear()) From 524884e7674e7d2a0e6fab60ca759913f6c69794 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Wed, 12 Mar 2025 19:01:26 +0100 Subject: [PATCH 012/124] Revert some changes in vis --- cadquery/vis.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/cadquery/vis.py b/cadquery/vis.py index c6c1f42d5..47b522370 100644 --- a/cadquery/vis.py +++ b/cadquery/vis.py @@ -150,7 +150,7 @@ def _to_vtk_axs(locs: List[Location], scale: float = 0.1) -> List[vtkProp3D]: Convert Locations to vtkActor. """ - rv: List[vtkProp3D] = [] + rv = vtkAssembly() for l in locs: trans, rot = _loc2vtk(l) @@ -161,9 +161,9 @@ def _to_vtk_axs(locs: List[Location], scale: float = 0.1) -> List[vtkProp3D]: ax.SetOrientation(*rot) ax.SetScale(scale) - rv.append(ax) + rv.AddPart(ax) - return rv + return [rv] def _to_vtk_shapes( @@ -400,7 +400,7 @@ def show( gradient: bool = True, xpos: Union[int, float] = 0, ypos: Union[int, float] = 0, -) -> vtkRenderWindow: +): """ Show CQ objects using VTK. This functions optionally allows to make screenshots. """ @@ -458,11 +458,11 @@ def show( axes = vtkAxesActor() axes.SetDragable(0) axes.SetAxisLabels(0) - # tp = axes.GetXAxisCaptionActor2D().GetCaptionTextProperty() - # tp.SetColor(0, 0, 0) + tp = axes.GetXAxisCaptionActor2D().GetCaptionTextProperty() + tp.SetColor(0, 0, 0) - # axes.GetYAxisCaptionActor2D().GetCaptionTextProperty().ShallowCopy(tp) - # axes.GetZAxisCaptionActor2D().GetCaptionTextProperty().ShallowCopy(tp) + axes.GetYAxisCaptionActor2D().GetCaptionTextProperty().ShallowCopy(tp) + axes.GetZAxisCaptionActor2D().GetCaptionTextProperty().ShallowCopy(tp) # add to an orientation widget if trihedron: @@ -542,8 +542,6 @@ def show( if interact: inter.Start() - return win - # alias show_object = show From 3bb10364f0d0e6e0add28f302a33f0ed574063ff Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Wed, 12 Mar 2025 19:02:09 +0100 Subject: [PATCH 013/124] Smaller coros and bg color --- cadquery/fig.py | 54 ++++++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/cadquery/fig.py b/cadquery/fig.py index 46423a947..5b121b4de 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -17,7 +17,6 @@ vtkRenderer, vtkRenderWindow, vtkRenderWindowInteractor, - vtkActor, vtkProp3D, ) @@ -58,6 +57,10 @@ def __init__(self, port: int): inter.SetInteractorStyle(vtkInteractorStyleTrackballCamera()) inter.SetRenderWindow(win) + # background + renderer.SetBackground(1, 1, 1) + renderer.GradientBackgroundOn() + # axes axes = vtkAxesActor() axes.SetDragable(0) @@ -94,55 +97,56 @@ def __init__(self, port: int): ) server.state.flush() - coro = server.start( - thread=True, exec_mode="coroutine", port=port, open_browser=True - ) self.server = server self.loop = new_event_loop() - def _run(): + def _run_loop(): set_event_loop(self.loop) self.loop.run_forever() - self.thread = Thread(target=_run, daemon=True) + self.thread = Thread(target=_run_loop, daemon=True) self.thread.start() - run_coroutine_threadsafe(coro, self.loop) + coro = server.start( + thread=True, exec_mode="coroutine", port=port, open_browser=True + ) + + self._run(coro) def _run(self, coro): run_coroutine_threadsafe(coro, self.loop) - def show(self, s: Shape | vtkActor | list[vtkProp3D], *args, **kwargs): - async def _show(): + def show(self, s: Shape | vtkProp3D | list[vtkProp3D], *args, **kwargs): - if isinstance(s, Shape): - # do not show markers by default - if "markersize" not in kwargs: - kwargs["markersize"] = 0 + if isinstance(s, Shape): + # do not show markers by default + if "markersize" not in kwargs: + kwargs["markersize"] = 0 - actors = style(s, *args, **kwargs) - self.shapes[s] = actors + actors = style(s, *args, **kwargs) + self.shapes[s] = actors - for actor in actors: - self.ren.AddActor(actor) + for actor in actors: + self.ren.AddActor(actor) - elif isinstance(s, vtkActor): - self.actors.append(s) - self.ren.AddActor(s) - else: - self.actors.extend(s) + elif isinstance(s, vtkProp3D): + self.actors.append(s) + self.ren.AddActor(s) + else: + self.actors.extend(s) - for el in s: - self.ren.AddActor(el) + for el in s: + self.ren.AddActor(el) + async def _show(): self.ren.ResetCamera() self.view.update() self._run(_show()) - def clear(self, *shapes: Shape | vtkActor): + def clear(self, *shapes: Shape | vtkProp3D): async def _clear(): if len(shapes) == 0: From ca12abaa5b34d7b6e05fe85421488fec644fb8e7 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 14 Mar 2025 22:11:50 +0100 Subject: [PATCH 014/124] Refactor into a singleton --- cadquery/fig.py | 98 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 77 insertions(+), 21 deletions(-) diff --git a/cadquery/fig.py b/cadquery/fig.py index 5b121b4de..d7528feff 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -5,13 +5,15 @@ AbstractEventLoop, ) from threading import Thread +from itertools import chain +from uuid import uuid1 as uuid from trame.app import get_server, Server from trame.widgets import html, vtk as vtk_widgets, client from trame.ui.html import DivLayout -from cadquery import Shape -from cadquery.vis import style +from . import Shape +from .vis import style, Showable, ShapeLike, _split_showables, _to_vtk_pts, _to_vtk_axs from vtkmodules.vtkRenderingCore import ( vtkRenderer, @@ -35,12 +37,26 @@ class Figure: win: vtkRenderWindow ren: vtkRenderer view: vtk_widgets.VtkRemoteView - shapes: dict[Shape, list[vtkProp3D]] + shapes: dict[ShapeLike, list[vtkProp3D]] actors: list[vtkProp3D] loop: AbstractEventLoop thread: Thread + empty: bool - def __init__(self, port: int): + _instance = None + _initialized: bool = False + + def __new__(cls, *args, **kwargs): + + if not cls._instance: + cls._instance = object.__new__(cls) + + return cls._instance + + def __init__(self, port: int = 18081): + + if self._initialized: + return self.loop = new_event_loop() set_event_loop(self.loop) @@ -84,7 +100,7 @@ def __init__(self, port: int): self.actors = [] # server - server = get_server("CQ") + server = get_server("CQ-server") server.client_type = "vue3" # layout @@ -112,33 +128,65 @@ def _run_loop(): thread=True, exec_mode="coroutine", port=port, open_browser=True ) - self._run(coro) + if coro: + self._run(coro) + + # prevent reinitialization + self._initialized = True + + # view is initialized as empty + self.empty = True def _run(self, coro): run_coroutine_threadsafe(coro, self.loop) - def show(self, s: Shape | vtkProp3D | list[vtkProp3D], *args, **kwargs): + def show(self, *showables: Showable | vtkProp3D | list[vtkProp3D], **kwargs): + """ + Show objects. + """ + + # split objects + shapes, vecs, locs, props = _split_showables(showables) + + pts = _to_vtk_pts(vecs) + axs = _to_vtk_axs(locs) - if isinstance(s, Shape): + for s in shapes: # do not show markers by default if "markersize" not in kwargs: kwargs["markersize"] = 0 - actors = style(s, *args, **kwargs) + actors = style(s, **kwargs) self.shapes[s] = actors for actor in actors: self.ren.AddActor(actor) - elif isinstance(s, vtkProp3D): - self.actors.append(s) - self.ren.AddActor(s) - else: - self.actors.extend(s) + for prop in chain(props, axs): + self.actors.append(prop) + self.ren.AddActor(prop) - for el in s: - self.ren.AddActor(el) + if vecs: + self.actors.append(pts) + self.ren.AddActor(pts) + + async def _show(): + self.view.update() + + self._run(_show()) + + # zoom to fit on 1st object added + if self.empty: + self.fit() + self.empty = False + + return self + + def fit(self): + """ + Update view to fit all objects. + """ async def _show(): self.ren.ResetCamera() @@ -146,16 +194,23 @@ async def _show(): self._run(_show()) + return self + def clear(self, *shapes: Shape | vtkProp3D): + """ + Clear specified objects. If no arguments are passed, clears all objects. + """ + async def _clear(): if len(shapes) == 0: - for a in self.actors: + for a in self.ren.GetActors(): self.ren.RemoveActor(a) - for actors in self.shapes.values(): - for a in actors: - self.ren.RemoveActor(a) + self.actors.clear() + self.shapes.clear() + + self.empty = True for s in shapes: if isinstance(s, Shape): @@ -167,7 +222,8 @@ async def _clear(): self.actors.remove(s) self.ren.RemoveActor(s) - self.ren.ResetCamera() self.view.update() self._run(_clear()) + + return self From 31f8c2854abd1447a4b7ad8aeb5237a63b564fca Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 14 Mar 2025 22:42:51 +0100 Subject: [PATCH 015/124] Update deps --- conda/meta.yaml | 2 ++ environment.yml | 2 ++ setup.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/conda/meta.yaml b/conda/meta.yaml index e3f6d3a28..2f976f844 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -26,6 +26,8 @@ requirements: - multimethod >=1.11,<2.0 - casadi - typish + - trame + - trame-vtk test: requires: diff --git a/environment.yml b/environment.yml index 6d03a2359..3d5f4ef69 100644 --- a/environment.yml +++ b/environment.yml @@ -25,6 +25,8 @@ dependencies: - pathspec - click - appdirs + - trame + - trame-vtk - pip - pip: - --editable=. diff --git a/setup.py b/setup.py index 45442d3e1..c8f162df8 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,8 @@ "typish", "casadi", "path", + "trame", + "trame-vtk" ] From 44fbb64b06b861d976e01047132126ecdaa437bc Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sat, 15 Mar 2025 15:52:35 +0100 Subject: [PATCH 016/124] Implemented pop and fixed clear --- cadquery/fig.py | 46 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/cadquery/fig.py b/cadquery/fig.py index d7528feff..f013dcb20 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -4,9 +4,9 @@ run_coroutine_threadsafe, AbstractEventLoop, ) +from typing import Optional from threading import Thread from itertools import chain -from uuid import uuid1 as uuid from trame.app import get_server, Server from trame.widgets import html, vtk as vtk_widgets, client @@ -20,6 +20,7 @@ vtkRenderWindow, vtkRenderWindowInteractor, vtkProp3D, + vtkActor, ) @@ -42,6 +43,9 @@ class Figure: loop: AbstractEventLoop thread: Thread empty: bool + last: Optional[ + tuple[list[ShapeLike], list[vtkProp3D], Optional[vtkActor], list[vtkProp3D]] + ] _instance = None _initialized: bool = False @@ -136,6 +140,7 @@ def _run_loop(): # view is initialized as empty self.empty = True + self.last = None def _run(self, coro): @@ -150,7 +155,9 @@ def show(self, *showables: Showable | vtkProp3D | list[vtkProp3D], **kwargs): shapes, vecs, locs, props = _split_showables(showables) pts = _to_vtk_pts(vecs) - axs = _to_vtk_axs(locs) + axs = _to_vtk_axs( + locs, **({"scale": kwargs["scale"]} if "scale" in kwargs else {}) + ) for s in shapes: # do not show markers by default @@ -171,6 +178,9 @@ def show(self, *showables: Showable | vtkProp3D | list[vtkProp3D], **kwargs): self.actors.append(pts) self.ren.AddActor(pts) + # store to enable pop + self.last = (shapes, axs, pts if vecs else None, props) + async def _show(): self.view.update() @@ -204,8 +214,7 @@ def clear(self, *shapes: Shape | vtkProp3D): async def _clear(): if len(shapes) == 0: - for a in self.ren.GetActors(): - self.ren.RemoveActor(a) + self.ren.RemoveAllViewProps() self.actors.clear() self.shapes.clear() @@ -224,6 +233,35 @@ async def _clear(): self.view.update() + # reset last, bc we don't want to keep track of what was removed + self.last = None self._run(_clear()) return self + + def pop(self): + """ + Clear the last showable. + """ + + async def _pop(): + + (shapes, axs, pts, props) = self.last + + for s in shapes: + for act in self.shapes.pop(s): + self.ren.RemoveActor(act) + + for act in chain(axs, props): + self.ren.RemoveActor(act) + self.actors.remove(act) + + if pts: + self.ren.RemoveActor(pts) + self.actors.remove(pts) + + self.view.update() + + self._run(_pop()) + + return self From 125de0921f133d1dae122d0cb307d77f3bffe197 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sat, 15 Mar 2025 16:10:55 +0100 Subject: [PATCH 017/124] Styling fix --- cadquery/fig.py | 14 +++++++------- cadquery/vis.py | 9 ++++++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/cadquery/fig.py b/cadquery/fig.py index f013dcb20..9e5e625eb 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -44,7 +44,9 @@ class Figure: thread: Thread empty: bool last: Optional[ - tuple[list[ShapeLike], list[vtkProp3D], Optional[vtkActor], list[vtkProp3D]] + tuple[ + list[ShapeLike], list[vtkProp3D], Optional[list[vtkProp3D]], list[vtkProp3D] + ] ] _instance = None @@ -154,10 +156,8 @@ def show(self, *showables: Showable | vtkProp3D | list[vtkProp3D], **kwargs): # split objects shapes, vecs, locs, props = _split_showables(showables) - pts = _to_vtk_pts(vecs) - axs = _to_vtk_axs( - locs, **({"scale": kwargs["scale"]} if "scale" in kwargs else {}) - ) + pts = style(vecs, **kwargs) + axs = style(locs, **kwargs) for s in shapes: # do not show markers by default @@ -175,8 +175,8 @@ def show(self, *showables: Showable | vtkProp3D | list[vtkProp3D], **kwargs): self.ren.AddActor(prop) if vecs: - self.actors.append(pts) - self.ren.AddActor(pts) + self.actors.append(*pts) + self.ren.AddActor(*pts) # store to enable pop self.last = (shapes, axs, pts if vecs else None, props) diff --git a/cadquery/vis.py b/cadquery/vis.py index 47b522370..93e9194be 100644 --- a/cadquery/vis.py +++ b/cadquery/vis.py @@ -53,7 +53,14 @@ ShapeLike = Union[Shape, Workplane, Assembly, Sketch, TopoDS_Shape] Showable = Union[ - ShapeLike, List[ShapeLike], Vector, List[Vector], vtkProp3D, List[vtkProp3D] + ShapeLike, + List[ShapeLike], + Vector, + List[Vector], + vtkProp3D, + List[vtkProp3D], + Location, + List[Location], ] From c3a4c634572981a9cbea5fe8fff0188906d59420 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sat, 15 Mar 2025 16:16:50 +0100 Subject: [PATCH 018/124] Fix pop --- cadquery/fig.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cadquery/fig.py b/cadquery/fig.py index 9e5e625eb..ea6991aa0 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -257,8 +257,8 @@ async def _pop(): self.actors.remove(act) if pts: - self.ren.RemoveActor(pts) - self.actors.remove(pts) + self.ren.RemoveActor(*pts) + self.actors.remove(*pts) self.view.update() From 6f78783191eac0a8f9030916ab03b59e47cecf35 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sun, 16 Mar 2025 13:51:11 +0100 Subject: [PATCH 019/124] Add simple data --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c8f162df8..80b5c3812 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ "casadi", "path", "trame", - "trame-vtk" + "trame-vtk", ] From 4a98f6abe979f3b5aeec20b9edc38fb23404d453 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Mon, 17 Mar 2025 07:47:29 +0100 Subject: [PATCH 020/124] Get rid of vtk msgs --- cadquery/fig.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cadquery/fig.py b/cadquery/fig.py index ea6991aa0..3784608eb 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -7,6 +7,7 @@ from typing import Optional from threading import Thread from itertools import chain +from webbrowser import open_new_tab from trame.app import get_server, Server from trame.widgets import html, vtk as vtk_widgets, client @@ -131,7 +132,11 @@ def _run_loop(): self.thread.start() coro = server.start( - thread=True, exec_mode="coroutine", port=port, open_browser=True + thread=True, + exec_mode="coroutine", + port=port, + open_browser=False, + show_connection_info=False, ) if coro: @@ -144,6 +149,9 @@ def _run_loop(): self.empty = True self.last = None + # open webbrowser + open_new_tab(f"http://localhost:{port}") + def _run(self, coro): run_coroutine_threadsafe(coro, self.loop) From f88ad3597dcac37d5e725a3646a5ec97fbfa553b Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 18 Mar 2025 15:25:32 -0400 Subject: [PATCH 021/124] Added a test specifically for testing metadata --- tests/test_assembly.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 1d7b7c387..8977c0e38 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -653,6 +653,21 @@ def test_step_import(tmp_path_factory): assert assy.children[1].loc.toTuple()[0] == (15, 15, 15) +def test_assembly_meta_step_import(tmp_path_factory): + """ + Test import of an assembly with metadata from a STEP file. + """ + + # Use a temporary directory + tmpdir = tmp_path_factory.mktemp("out") + metadata_path = os.path.join(tmpdir, "metadata.step") + + assy = cq.Assembly.importStep(path=metadata_path) + + # Make sure we got the correct number of children + assert len(assy.children) == 6 + + @pytest.mark.parametrize( "assy_fixture, expected", [ From b45fa6928b6e26d8a55b5d421048af689ea36104 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Mon, 31 Mar 2025 07:40:09 +0200 Subject: [PATCH 022/124] Wait for clear --- cadquery/fig.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cadquery/fig.py b/cadquery/fig.py index 3784608eb..bbfa5502d 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -3,6 +3,7 @@ set_event_loop, run_coroutine_threadsafe, AbstractEventLoop, + Future, ) from typing import Optional from threading import Thread @@ -152,9 +153,9 @@ def _run_loop(): # open webbrowser open_new_tab(f"http://localhost:{port}") - def _run(self, coro): + def _run(self, coro) -> Future: - run_coroutine_threadsafe(coro, self.loop) + return run_coroutine_threadsafe(coro, self.loop) def show(self, *showables: Showable | vtkProp3D | list[vtkProp3D], **kwargs): """ @@ -243,7 +244,8 @@ async def _clear(): # reset last, bc we don't want to keep track of what was removed self.last = None - self._run(_clear()) + future = self._run(_clear()) + future.result() return self From 56c6cfc79e8f874a63248a55cfe660c241132e81 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Mon, 31 Mar 2025 07:42:47 +0200 Subject: [PATCH 023/124] Display axis labels --- cadquery/vis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadquery/vis.py b/cadquery/vis.py index 93e9194be..fb644386d 100644 --- a/cadquery/vis.py +++ b/cadquery/vis.py @@ -464,7 +464,7 @@ def show( # construct an axes indicator axes = vtkAxesActor() axes.SetDragable(0) - axes.SetAxisLabels(0) + tp = axes.GetXAxisCaptionActor2D().GetCaptionTextProperty() tp.SetColor(0, 0, 0) From e51a7cbcc2ba5ca2d05f2a1307ae42be1bbbb7a4 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Fri, 18 Apr 2025 09:50:19 -0400 Subject: [PATCH 024/124] Seeing if coverage and tests will pass --- tests/test_assembly.py | 62 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 0a46d414d..ea36cfc76 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -366,6 +366,41 @@ def chassis0_assy(): return chassis +def subshape_assy(): + """ + Builds an assembly with the needed subshapes to test the export and import of STEP files. + """ + + # Create a simple assembly + assy = cq.Assembly(name="top-level") + cube_1 = cq.Workplane().box(10.0, 10.0, 10.0) + assy.add(cube_1, name="cube_1", color=cq.Color("green")) + + # Add subshape name, color and layer + assy.addSubshape( + cube_1.faces(">Z").val(), + name="cube_1_top_face", + color=cq.Color("red"), + layer="cube_1_top_face", + ) + + # Add a cylinder to the assembly + cyl_1 = cq.Workplane().cylinder(10.0, 2.5) + assy.add( + cyl_1, name="cyl_1", color=cq.Color("blue"), loc=cq.Location((0.0, 0.0, -10.0)) + ) + + # Add a subshape face for the cylinder + assy.addSubshape( + cyl_1.faces(" TDocStd_Document: """Read STEP file, return XCAF document""" @@ -785,6 +820,33 @@ def test_meta_step_export_edge_cases(tmp_path_factory): assert success +def test_assembly_step_import(tmp_path_factory): + """ + Test if the STEP import works correctly for an assembly with subshape data attached. + """ + assy = subshape_assy() + + # Use a temporary directory + tmpdir = tmp_path_factory.mktemp("out") + assy_step_path = os.path.join(tmpdir, "assembly_with_subshapes.step") + + success = exportStepMeta(assy, assy_step_path) + assert success + + # Import the STEP file back in + imported_assy = cq.Assembly.importStep(assy_step_path) + + # Check that the assembly was imported successfully + assert imported_assy is not None + + # Check for appropriate part names and colors + # assert imported_assy.children[0].name == "cube_1" + assert imported_assy.children[0].color.toTuple() == (0.0, 1.0, 0.0, 1.0) + # assert imported_assy.children[1].name == "cyl_2" + assert imported_assy.children[1].color.toTuple() == (0.0, 0.0, 1.0, 1.0) + # assert imported_assy.name == "top-level" + + @pytest.mark.parametrize( "assy_fixture, expected", [ From 36d70356f4373a968c2175d88fa8899b052bb4a8 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 21 Apr 2025 15:18:08 -0400 Subject: [PATCH 025/124] Added name loading from STEP file --- cadquery/occ_impl/importers/assembly.py | 17 +++++++++++++++-- tests/test_assembly.py | 6 +++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index cbdaa8b70..757d78b27 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -5,6 +5,7 @@ from OCP.STEPCAFControl import STEPCAFControl_Reader from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ColorGen, XCAFDoc_ColorSurf from OCP.TDF import TDF_Label, TDF_LabelSequence +from OCP.TDataStd import TDataStd_Name import cadquery as cq from ..assembly import AssemblyProtocol @@ -85,6 +86,12 @@ def process_label(label, parent_location=None): if shape_tool.IsSimpleShape_s(label): shape = shape_tool.GetShape_s(label) + # Load the name of the part in the assembly, if it is present + name = None + name_attr = TDataStd_Name() + if label.FindAttribute(TDataStd_Name.GetID_s(), name_attr): + name = str(name_attr.Get().ToExtString()) + # Process the color for the shape, which could be of different types color = Quantity_Color() if color_tool.GetColor_s(label, XCAFDoc_ColorSurf, color): @@ -102,9 +109,11 @@ def process_label(label, parent_location=None): # Handle the location if it was passed down form a parent component if parent_location is not None: - assy.add(cq.Shape.cast(shape), color=cq_color, loc=parent_location) + assy.add( + cq.Shape.cast(shape), name=name, color=cq_color, loc=parent_location + ) else: - assy.add(cq.Shape.cast(shape), color=cq_color) + assy.add(cq.Shape.cast(shape), name=name, color=cq_color) # Grab the labels, which should hold the assembly parent labels = TDF_LabelSequence() @@ -115,5 +124,9 @@ def process_label(label, parent_location=None): # Start the recursive processing of the assembly process_label(labels.Value(1)) + # Load the top-level name of the assembly, if it is present + name_attr = TDataStd_Name() + if labels.Value(1).FindAttribute(TDataStd_Name.GetID_s(), name_attr): + assy.name = str(name_attr.Get().ToExtString()) else: raise ValueError("Step file does not contain an assembly") diff --git a/tests/test_assembly.py b/tests/test_assembly.py index ea36cfc76..23473c2d6 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -840,11 +840,11 @@ def test_assembly_step_import(tmp_path_factory): assert imported_assy is not None # Check for appropriate part names and colors - # assert imported_assy.children[0].name == "cube_1" + assert imported_assy.children[0].name == "cube_1" assert imported_assy.children[0].color.toTuple() == (0.0, 1.0, 0.0, 1.0) - # assert imported_assy.children[1].name == "cyl_2" + assert imported_assy.children[1].name == "cyl_1" assert imported_assy.children[1].color.toTuple() == (0.0, 0.0, 1.0, 1.0) - # assert imported_assy.name == "top-level" + assert imported_assy.name == "top-level" @pytest.mark.parametrize( From f29056e62a009f24c4b85218ba36e3f8ddbc1b36 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 22 Apr 2025 12:59:26 -0400 Subject: [PATCH 026/124] Made name a public property of Assembly --- cadquery/occ_impl/assembly.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index f2224634b..2c149efe3 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -140,6 +140,10 @@ def loc(self, value: Location) -> None: def name(self) -> str: ... + @name.setter + def name(self, value: str) -> None: + ... + @property def parent(self) -> Optional["AssemblyProtocol"]: ... From 687bc2b230115bf75246c4344d9c429f95fd9b49 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 29 Apr 2025 12:40:03 -0400 Subject: [PATCH 027/124] Trying to increase test coverage a bit --- cadquery/occ_impl/importers/assembly.py | 3 +-- tests/test_assembly.py | 9 +++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 757d78b27..4bd7e0c2d 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -63,6 +63,7 @@ def process_label(label, parent_location=None): sub_label = comp_labels.Value(i + 1) # The component level holds the location for its shapes + location = parent_location loc = shape_tool.GetLocation_s(sub_label) if loc: location = cq.Location(loc) @@ -77,8 +78,6 @@ def process_label(label, parent_location=None): 0, ): location = parent_location - else: - location = parent_location process_label(sub_label, location) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 23473c2d6..7bec8c540 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -846,6 +846,15 @@ def test_assembly_step_import(tmp_path_factory): assert imported_assy.children[1].color.toTuple() == (0.0, 0.0, 1.0, 1.0) assert imported_assy.name == "top-level" + # Test a STEP file that does not contain an assembly + wp_step_path = os.path.join(tmpdir, "plain_workplane.step") + res = cq.Workplane().box(10, 10, 10) + res.export(wp_step_path) + + # Import the STEP file back in + with pytest.raises(ValueError): + imported_assy = cq.Assembly.importStep(wp_step_path) + @pytest.mark.parametrize( "assy_fixture, expected", From 6b360c2d56494182e00f00be0855ed3e61f486c6 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 13 May 2025 11:44:47 -0400 Subject: [PATCH 028/124] Syncing up some experiments --- cadquery/occ_impl/importers/assembly.py | 43 +++++++++++++++++++++++-- tests/test_assembly.py | 32 ++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 4bd7e0c2d..5ea88019c 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -3,12 +3,13 @@ from OCP.TDocStd import TDocStd_Document from OCP.IFSelect import IFSelect_RetDone from OCP.STEPCAFControl import STEPCAFControl_Reader -from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ColorGen, XCAFDoc_ColorSurf -from OCP.TDF import TDF_Label, TDF_LabelSequence +from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ColorGen, XCAFDoc_ColorSurf, XCAFDoc_GraphNode +from OCP.TDF import TDF_Label, TDF_LabelSequence, TDF_AttributeIterator from OCP.TDataStd import TDataStd_Name import cadquery as cq from ..assembly import AssemblyProtocol +from tkinter.constants import CURRENT def importStep(assy: AssemblyProtocol, path: str): @@ -114,6 +115,44 @@ def process_label(label, parent_location=None): else: assy.add(cq.Shape.cast(shape), name=name, color=cq_color) + # if label.NbChildren() > 0: + # child_label = label.FindChild(1) + + # # Create an attribute iterator + # attr_iterator = TDF_AttributeIterator(child_label) + + # # Iterate through all attributes + # while attr_iterator.More(): + # current_attr = attr_iterator.Value() + # # Get the ID of the attribute + # attr_id = current_attr.ID() + # print(f"Found attribute with ID: {attr_id}") + # print(f"Attribute type: {current_attr.DynamicType().Name()}") + # if current_attr.DynamicType().Name() == "XCAFDoc_GraphNode": + # graph_node = XCAFDoc_GraphNode() + # if child_label.FindAttribute(XCAFDoc_GraphNode.GetID_s(), graph_node): + # # Follow the graph node to its referenced labels + # for i in range(1, graph_node.NbChildren() + 1): + # child_graph_node = graph_node.GetChild(i) + # if not child_graph_node.IsNull(): + # referenced_label = child_graph_node.Label() + # if not referenced_label.IsNull(): + # # Check for name on the referenced label + # name_attr = TDataStd_Name() + # if referenced_label.FindAttribute(TDataStd_Name.GetID_s(), name_attr): + # name = name_attr.Get().ToExtString() + # print(f"Found name via GraphNode reference: {name}") + # if current_attr.DynamicType().Name() == "TNaming_NamedShape": + # shape = current_attr.Get() + # if not shape.IsNull(): + # name = shape_tool.GetName_s(shape) + # if name: + # print(f"Shape name: {name}") + # if child_label.FindAttribute(TDataStd_Name.GetID_s(), name_attr): + # print(name_attr) + # Move to next attribute + # attr_iterator.Next() + # Grab the labels, which should hold the assembly parent labels = TDF_LabelSequence() shape_tool.GetFreeShapes(labels) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 7bec8c540..41ee8804e 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -856,6 +856,38 @@ def test_assembly_step_import(tmp_path_factory): imported_assy = cq.Assembly.importStep(wp_step_path) +def test_assembly_subshape_step_import(tmpdir): + """ + Test if a STEP file containing subshape information can be imported correctly. + """ + + assy_step_path = os.path.join(tmpdir, "subshape_assy.step") + + # Create a basic assembly + cube_1 = cq.Workplane().box(10, 10, 10) + assy = cq.Assembly(name="top_level") + assy.add(cube_1, name="cube_1") + + # Add subshape name, color and layer + assy.addSubshape( + cube_1.faces(">Z").val(), + name="cube_1_top_face", + color=cq.Color("red"), + layer="cube_1_top_face" + ) + + # Export the assembly + success = exportStepMeta(assy, assy_step_path) + assert success + + # Import the STEP file back in + imported_assy = cq.Assembly.importStep(assy_step_path) + assert imported_assy.name == "top_level" + assert len(imported_assy._subshape_names) == 1 + # assert imported_assy.subshapes["cube_1_top_face"].name == "cube_1_top_face" + # assert imported_assy.subshapes["cube_1_top_face"].color == cq.Color("red") + # assert imported_assy.subshapes["cube_1_top_face"].layer == "cube_1_top_face" + @pytest.mark.parametrize( "assy_fixture, expected", [ From 5e193323e3be34abfdde501657900f7a1b160e55 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Thu, 22 May 2025 15:43:22 -0400 Subject: [PATCH 029/124] Got color and layer search working, still need to get name search working through indirect lookup --- cadquery/occ_impl/importers/assembly.py | 83 +++++++++++++------------ tests/test_assembly.py | 5 +- 2 files changed, 47 insertions(+), 41 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 5ea88019c..d56671d3a 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -3,9 +3,14 @@ from OCP.TDocStd import TDocStd_Document from OCP.IFSelect import IFSelect_RetDone from OCP.STEPCAFControl import STEPCAFControl_Reader -from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ColorGen, XCAFDoc_ColorSurf, XCAFDoc_GraphNode +from OCP.XCAFDoc import ( + XCAFDoc_DocumentTool, + XCAFDoc_ColorGen, + XCAFDoc_ColorSurf, + XCAFDoc_GraphNode, +) from OCP.TDF import TDF_Label, TDF_LabelSequence, TDF_AttributeIterator -from OCP.TDataStd import TDataStd_Name +from OCP.TDataStd import TDataStd_Name, TDataStd_TreeNode import cadquery as cq from ..assembly import AssemblyProtocol @@ -43,6 +48,7 @@ def importStep(assy: AssemblyProtocol, path: str): # Shape and color tools for extracting XCAF data shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()) color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main()) + layer_tool = XCAFDoc_DocumentTool.LayerTool_s(doc.Main()) def process_label(label, parent_location=None): """ @@ -115,43 +121,42 @@ def process_label(label, parent_location=None): else: assy.add(cq.Shape.cast(shape), name=name, color=cq_color) - # if label.NbChildren() > 0: - # child_label = label.FindChild(1) - - # # Create an attribute iterator - # attr_iterator = TDF_AttributeIterator(child_label) - - # # Iterate through all attributes - # while attr_iterator.More(): - # current_attr = attr_iterator.Value() - # # Get the ID of the attribute - # attr_id = current_attr.ID() - # print(f"Found attribute with ID: {attr_id}") - # print(f"Attribute type: {current_attr.DynamicType().Name()}") - # if current_attr.DynamicType().Name() == "XCAFDoc_GraphNode": - # graph_node = XCAFDoc_GraphNode() - # if child_label.FindAttribute(XCAFDoc_GraphNode.GetID_s(), graph_node): - # # Follow the graph node to its referenced labels - # for i in range(1, graph_node.NbChildren() + 1): - # child_graph_node = graph_node.GetChild(i) - # if not child_graph_node.IsNull(): - # referenced_label = child_graph_node.Label() - # if not referenced_label.IsNull(): - # # Check for name on the referenced label - # name_attr = TDataStd_Name() - # if referenced_label.FindAttribute(TDataStd_Name.GetID_s(), name_attr): - # name = name_attr.Get().ToExtString() - # print(f"Found name via GraphNode reference: {name}") - # if current_attr.DynamicType().Name() == "TNaming_NamedShape": - # shape = current_attr.Get() - # if not shape.IsNull(): - # name = shape_tool.GetName_s(shape) - # if name: - # print(f"Shape name: {name}") - # if child_label.FindAttribute(TDataStd_Name.GetID_s(), name_attr): - # print(name_attr) - # Move to next attribute - # attr_iterator.Next() + if label.NbChildren() > 0: + child_label = label.FindChild(1) + attr_iterator = TDF_AttributeIterator(child_label) + while attr_iterator.More(): + current_attr = attr_iterator.Value() + + # Get the type name of the attribute so that we can decide how to handle it + if current_attr.DynamicType().Name() == "TNaming_NamedShape": + # Save the shape so that we can add it to the subshape data + cur_shape = current_attr.Get() + cur_shape_type = cur_shape.ShapeType() + + # Find the layer name, if there is one set for this shape + layers = TDF_LabelSequence() + layer_tool.GetLayers(child_label, layers) + for i in range(1, layers.Length() + 1): + lbl = layers.Value(i) + name_attr = TDataStd_Name() + lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + + # Extract the layer name for the shape here + layer_name = name_attr.Get().ToExtString() + + # Find the subshape color, if there is one set for this shape + color = Quantity_ColorRGBA() + if color_tool.GetColor(cur_shape, XCAFDoc_ColorSurf, color): + rgb = color.GetRGB() + cq_color = cq.Color( + rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha() + ) + elif current_attr.DynamicType().Name() == "TDataStd_TreeNode": + print("TreeNode") + elif current_attr.DynamicType().Name() == "XCAFDoc_GraphNode": + print("GraphNode") + + attr_iterator.Next() # Grab the labels, which should hold the assembly parent labels = TDF_LabelSequence() diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 41ee8804e..b7c502a58 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -872,8 +872,8 @@ def test_assembly_subshape_step_import(tmpdir): assy.addSubshape( cube_1.faces(">Z").val(), name="cube_1_top_face", - color=cq.Color("red"), - layer="cube_1_top_face" + color=cq.Color("blue"), + layer="cube_1_top_face", ) # Export the assembly @@ -888,6 +888,7 @@ def test_assembly_subshape_step_import(tmpdir): # assert imported_assy.subshapes["cube_1_top_face"].color == cq.Color("red") # assert imported_assy.subshapes["cube_1_top_face"].layer == "cube_1_top_face" + @pytest.mark.parametrize( "assy_fixture, expected", [ From 5f4c4c258d1c33768862a275d31789d7192ea53c Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Fri, 23 May 2025 08:56:58 -0400 Subject: [PATCH 030/124] Got tests working for layer and color info import --- cadquery/occ_impl/importers/assembly.py | 11 ++++++++++- tests/test_assembly.py | 18 +++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index d56671d3a..6a3d3ad6b 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -131,7 +131,9 @@ def process_label(label, parent_location=None): if current_attr.DynamicType().Name() == "TNaming_NamedShape": # Save the shape so that we can add it to the subshape data cur_shape = current_attr.Get() - cur_shape_type = cur_shape.ShapeType() + + # The shape type can be obtained with the following + # cur_shape_type = cur_shape.ShapeType() # Find the layer name, if there is one set for this shape layers = TDF_LabelSequence() @@ -144,13 +146,20 @@ def process_label(label, parent_location=None): # Extract the layer name for the shape here layer_name = name_attr.Get().ToExtString() + # Add the layer as a subshape entry on the assembly + assy.addSubshape(cur_shape, layer=layer_name) + # Find the subshape color, if there is one set for this shape color = Quantity_ColorRGBA() + # Extract the color, if present on the shape if color_tool.GetColor(cur_shape, XCAFDoc_ColorSurf, color): rgb = color.GetRGB() cq_color = cq.Color( rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha() ) + + # Save the color info via the assembly subshape mechanism + assy.addSubshape(cur_shape, color=cq_color) elif current_attr.DynamicType().Name() == "TDataStd_TreeNode": print("TreeNode") elif current_attr.DynamicType().Name() == "XCAFDoc_GraphNode": diff --git a/tests/test_assembly.py b/tests/test_assembly.py index b7c502a58..ac330e446 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -872,7 +872,7 @@ def test_assembly_subshape_step_import(tmpdir): assy.addSubshape( cube_1.faces(">Z").val(), name="cube_1_top_face", - color=cq.Color("blue"), + color=cq.Color("red"), layer="cube_1_top_face", ) @@ -883,10 +883,18 @@ def test_assembly_subshape_step_import(tmpdir): # Import the STEP file back in imported_assy = cq.Assembly.importStep(assy_step_path) assert imported_assy.name == "top_level" - assert len(imported_assy._subshape_names) == 1 - # assert imported_assy.subshapes["cube_1_top_face"].name == "cube_1_top_face" - # assert imported_assy.subshapes["cube_1_top_face"].color == cq.Color("red") - # assert imported_assy.subshapes["cube_1_top_face"].layer == "cube_1_top_face" + + # Check the advanced face name + # assert len(imported_assy._subshape_names) == 1 + # assert list(imported_assy._subshape_names.values())[0] == "cube_1_top_face" + + # Check the color + color = list(imported_assy._subshape_colors.values())[0].toTuple() + assert color == cq.Color("red").toTuple() + + # Check the layer info + layer_name = list(imported_assy._subshape_layers.values())[0] + assert layer_name == "cube_1_top_face" @pytest.mark.parametrize( From e5bccb29ad2f18a98404bd00c9c894491de6729e Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 27 May 2025 11:22:57 -0400 Subject: [PATCH 031/124] Got shape name loading to work --- cadquery/occ_impl/importers/assembly.py | 133 ++++++++++++++++-------- tests/test_assembly.py | 7 +- 2 files changed, 93 insertions(+), 47 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 6a3d3ad6b..97af54c33 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -9,8 +9,8 @@ XCAFDoc_ColorSurf, XCAFDoc_GraphNode, ) -from OCP.TDF import TDF_Label, TDF_LabelSequence, TDF_AttributeIterator -from OCP.TDataStd import TDataStd_Name, TDataStd_TreeNode +from OCP.TDF import TDF_Label, TDF_LabelSequence, TDF_AttributeIterator, TDF_DataSet +from OCP.TDataStd import TDataStd_Name import cadquery as cq from ..assembly import AssemblyProtocol @@ -92,6 +92,9 @@ def process_label(label, parent_location=None): if shape_tool.IsSimpleShape_s(label): shape = shape_tool.GetShape_s(label) + # Tracks the RGB color value and whether or not it was found + cq_color = None + # Load the name of the part in the assembly, if it is present name = None name_attr = TDataStd_Name() @@ -122,50 +125,92 @@ def process_label(label, parent_location=None): assy.add(cq.Shape.cast(shape), name=name, color=cq_color) if label.NbChildren() > 0: - child_label = label.FindChild(1) - attr_iterator = TDF_AttributeIterator(child_label) - while attr_iterator.More(): - current_attr = attr_iterator.Value() - - # Get the type name of the attribute so that we can decide how to handle it - if current_attr.DynamicType().Name() == "TNaming_NamedShape": - # Save the shape so that we can add it to the subshape data - cur_shape = current_attr.Get() - - # The shape type can be obtained with the following - # cur_shape_type = cur_shape.ShapeType() - - # Find the layer name, if there is one set for this shape - layers = TDF_LabelSequence() - layer_tool.GetLayers(child_label, layers) - for i in range(1, layers.Length() + 1): - lbl = layers.Value(i) - name_attr = TDataStd_Name() - lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) - - # Extract the layer name for the shape here - layer_name = name_attr.Get().ToExtString() - - # Add the layer as a subshape entry on the assembly - assy.addSubshape(cur_shape, layer=layer_name) - - # Find the subshape color, if there is one set for this shape - color = Quantity_ColorRGBA() - # Extract the color, if present on the shape - if color_tool.GetColor(cur_shape, XCAFDoc_ColorSurf, color): - rgb = color.GetRGB() - cq_color = cq.Color( - rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha() + for j in range(label.NbChildren()): + child_label = label.FindChild(j + 1) + attr_iterator = TDF_AttributeIterator(child_label) + while attr_iterator.More(): + current_attr = attr_iterator.Value() + + # Get the type name of the attribute so that we can decide how to handle it + if current_attr.DynamicType().Name() == "TNaming_NamedShape": + # Save the shape so that we can add it to the subshape data + cur_shape = current_attr.Get() + + # Find the layer name, if there is one set for this shape + layers = TDF_LabelSequence() + layer_tool.GetLayers(child_label, layers) + for i in range(1, layers.Length() + 1): + lbl = layers.Value(i) + name_attr = TDataStd_Name() + lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + + # Extract the layer name for the shape here + layer_name = name_attr.Get().ToExtString() + + # Add the layer as a subshape entry on the assembly + assy.addSubshape(cur_shape, layer=layer_name) + + # Find the subshape color, if there is one set for this shape + color = Quantity_ColorRGBA() + # Extract the color, if present on the shape + if color_tool.GetColor(cur_shape, XCAFDoc_ColorSurf, color): + rgb = color.GetRGB() + cq_color = cq.Color( + rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha() + ) + + # Save the color info via the assembly subshape mechanism + assy.addSubshape(cur_shape, color=cq_color) + elif current_attr.DynamicType().Name() == "TDataStd_TreeNode": + # Holds the color name, if found, and tells us whether or not it was found + color_name = None + + # Get the attributes of the father node + father_attr = current_attr.Father() + + # Iterate theough the attributes to see if there is a color name + lbl = father_attr.Label() + it = TDF_AttributeIterator(lbl) + while it.More(): + new_attr = it.Value() + if new_attr.DynamicType().Name() == "TDataStd_Name": + # Retrieve the name + name_string = new_attr.Get().ToExtString() + + # Make sure that we have a color name + if "#" in name_string: + color_name = name_string.split(" ")[0] + + it.Next() + + # If we found a color name, save it on the subshape + # Perfer the RGB value because when importing, OCCT will try to round + # RGB values to fit color names. + if cq_color is not None: + assy.addSubshape(cur_shape, color=cq_color) + elif color_name is not None: + assy.addSubshape(cur_shape, color=cq.Color(color_name)) + elif current_attr.DynamicType().Name() == "XCAFDoc_GraphNode": + # Step up one level to try to get the name from the parent + lbl = current_attr.GetFather(1).Label() + + # Step through and search for the name attribute + it = TDF_AttributeIterator(lbl) + while it.More(): + new_attr = it.Value() + if new_attr.DynamicType().Name() == "TDataStd_Name": + # Save this as the name of the subshape + assy.addSubshape( + cur_shape, name=new_attr.Get().ToExtString(), + ) + it.Next() + else: + print( + "Unknown attribute type:", + current_attr.DynamicType().Name(), ) - # Save the color info via the assembly subshape mechanism - assy.addSubshape(cur_shape, color=cq_color) - elif current_attr.DynamicType().Name() == "TDataStd_TreeNode": - print("TreeNode") - elif current_attr.DynamicType().Name() == "XCAFDoc_GraphNode": - print("GraphNode") - - attr_iterator.Next() + attr_iterator.Next() # Grab the labels, which should hold the assembly parent labels = TDF_LabelSequence() diff --git a/tests/test_assembly.py b/tests/test_assembly.py index ac330e446..3f5baad12 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -856,11 +856,12 @@ def test_assembly_step_import(tmp_path_factory): imported_assy = cq.Assembly.importStep(wp_step_path) -def test_assembly_subshape_step_import(tmpdir): +def test_assembly_subshape_step_import(tmp_path_factory): """ Test if a STEP file containing subshape information can be imported correctly. """ + tmpdir = tmp_path_factory.mktemp("out") assy_step_path = os.path.join(tmpdir, "subshape_assy.step") # Create a basic assembly @@ -885,8 +886,8 @@ def test_assembly_subshape_step_import(tmpdir): assert imported_assy.name == "top_level" # Check the advanced face name - # assert len(imported_assy._subshape_names) == 1 - # assert list(imported_assy._subshape_names.values())[0] == "cube_1_top_face" + assert len(imported_assy._subshape_names) == 1 + assert list(imported_assy._subshape_names.values())[0] == "cube_1_top_face" # Check the color color = list(imported_assy._subshape_colors.values())[0].toTuple() From d5bc317318e121145b1a804300be181c991f41fa Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 27 May 2025 15:38:34 -0400 Subject: [PATCH 032/124] Trying to get approximate tuple comparison working --- tests/test_assembly.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 3f5baad12..e916c238e 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -839,11 +839,14 @@ def test_assembly_step_import(tmp_path_factory): # Check that the assembly was imported successfully assert imported_assy is not None - # Check for appropriate part names and colors + # Check for appropriate part name assert imported_assy.children[0].name == "cube_1" - assert imported_assy.children[0].color.toTuple() == (0.0, 1.0, 0.0, 1.0) + # Check for approximate color match + assert pytest.approx(imported_assy.children[0].color.toTuple(), rel=0.01) == (0.0, 1.0, 0.0, 1.0) + # Check for appropriate part name assert imported_assy.children[1].name == "cyl_1" - assert imported_assy.children[1].color.toTuple() == (0.0, 0.0, 1.0, 1.0) + # Check for approximate color match + assert pytest.approx(imported_assy.children[1].color.toTuple(), rel=0.01) == (0.0, 0.0, 1.0, 1.0) assert imported_assy.name == "top-level" # Test a STEP file that does not contain an assembly From 9b48507e5341ecb4d9cc6edb30a8e55c6dbf7acf Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 27 May 2025 16:01:19 -0400 Subject: [PATCH 033/124] Black fix --- tests/test_assembly.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index e916c238e..73689c3f6 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -842,11 +842,21 @@ def test_assembly_step_import(tmp_path_factory): # Check for appropriate part name assert imported_assy.children[0].name == "cube_1" # Check for approximate color match - assert pytest.approx(imported_assy.children[0].color.toTuple(), rel=0.01) == (0.0, 1.0, 0.0, 1.0) + assert pytest.approx(imported_assy.children[0].color.toTuple(), rel=0.01) == ( + 0.0, + 1.0, + 0.0, + 1.0, + ) # Check for appropriate part name assert imported_assy.children[1].name == "cyl_1" # Check for approximate color match - assert pytest.approx(imported_assy.children[1].color.toTuple(), rel=0.01) == (0.0, 0.0, 1.0, 1.0) + assert pytest.approx(imported_assy.children[1].color.toTuple(), rel=0.01) == ( + 0.0, + 0.0, + 1.0, + 1.0, + ) assert imported_assy.name == "top-level" # Test a STEP file that does not contain an assembly From 65f38c87f9d6d6a5741976f5524d18c9a13d8b79 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 28 May 2025 08:34:20 -0400 Subject: [PATCH 034/124] Added a test for a bad filename, and added a custom color --- tests/test_assembly.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 73689c3f6..4cfe48120 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -880,7 +880,7 @@ def test_assembly_subshape_step_import(tmp_path_factory): # Create a basic assembly cube_1 = cq.Workplane().box(10, 10, 10) assy = cq.Assembly(name="top_level") - assy.add(cube_1, name="cube_1") + assy.add(cube_1, name="cube_1", color=cq.Color(0.76512, 0.23491, 0.91301)) # Add subshape name, color and layer assy.addSubshape( @@ -911,6 +911,20 @@ def test_assembly_subshape_step_import(tmp_path_factory): assert layer_name == "cube_1_top_face" +def test_bad_step_file_import(tmp_path_factory): + """ + Test if a bad STEP file raises an error when importing. + """ + + tmpdir = tmp_path_factory.mktemp("out") + bad_step_path = os.path.join(tmpdir, "bad_step.step") + + # Check that an error is raised when trying to import a non-existent STEP file + with pytest.raises(ValueError): + # Export the assembly + imported_assy = cq.Assembly.importStep(bad_step_path) + + @pytest.mark.parametrize( "assy_fixture, expected", [ From 2810bc22b325c2890d363f82c9f3f2e06fb7c1c7 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 28 May 2025 08:56:13 -0400 Subject: [PATCH 035/124] Increase test coverage a bit and improve color name check --- cadquery/occ_impl/importers/assembly.py | 7 +------ tests/test_assembly.py | 8 ++++---- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 97af54c33..3c29baf40 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -184,7 +184,7 @@ def process_label(label, parent_location=None): it.Next() # If we found a color name, save it on the subshape - # Perfer the RGB value because when importing, OCCT will try to round + # Perfer the RGB value because when importing, OCCT tries to round # RGB values to fit color names. if cq_color is not None: assy.addSubshape(cur_shape, color=cq_color) @@ -204,11 +204,6 @@ def process_label(label, parent_location=None): cur_shape, name=new_attr.Get().ToExtString(), ) it.Next() - else: - print( - "Unknown attribute type:", - current_attr.DynamicType().Name(), - ) attr_iterator.Next() diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 5bcea5927..3357aa267 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -34,7 +34,7 @@ from OCP.STEPCAFControl import STEPCAFControl_Reader from OCP.IFSelect import IFSelect_RetDone from OCP.TDF import TDF_ChildIterator -from OCP.Quantity import Quantity_ColorRGBA, Quantity_TOC_sRGB +from OCP.Quantity import Quantity_ColorRGBA, Quantity_TOC_sRGB, Quantity_NameOfColor from OCP.TopAbs import TopAbs_ShapeEnum @@ -880,7 +880,7 @@ def test_assembly_subshape_step_import(tmp_path_factory): # Create a basic assembly cube_1 = cq.Workplane().box(10, 10, 10) assy = cq.Assembly(name="top_level") - assy.add(cube_1, name="cube_1", color=cq.Color(0.76512, 0.23491, 0.91301)) + assy.add(cube_1, name="cube_1") # Add subshape name, color and layer assy.addSubshape( @@ -903,8 +903,8 @@ def test_assembly_subshape_step_import(tmp_path_factory): assert list(imported_assy._subshape_names.values())[0] == "cube_1_top_face" # Check the color - color = list(imported_assy._subshape_colors.values())[0].toTuple() - assert color == cq.Color("red").toTuple() + color = list(imported_assy._subshape_colors.values())[0] + assert Quantity_NameOfColor.Quantity_NOC_RED == color.wrapped.GetRGB().Name() # Check the layer info layer_name = list(imported_assy._subshape_layers.values())[0] From 3438df71a3c088008db337f14e768d131d04e1fa Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 28 May 2025 17:25:01 -0400 Subject: [PATCH 036/124] Removing code that should never be hit --- cadquery/occ_impl/importers/assembly.py | 37 +------------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 3c29baf40..649db1e93 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -103,18 +103,12 @@ def process_label(label, parent_location=None): # Process the color for the shape, which could be of different types color = Quantity_Color() + cq_color = cq.Color(0.0, 0.0, 0.0) if color_tool.GetColor_s(label, XCAFDoc_ColorSurf, color): r = color.Red() g = color.Green() b = color.Blue() cq_color = cq.Color(r, g, b) - elif color_tool.GetColor_s(label, XCAFDoc_ColorGen, color): - r = color.Red() - g = color.Green() - b = color.Blue() - cq_color = cq.Color(r, g, b) - else: - cq_color = cq.Color(0.5, 0.5, 0.5) # Handle the location if it was passed down form a parent component if parent_location is not None: @@ -161,35 +155,6 @@ def process_label(label, parent_location=None): # Save the color info via the assembly subshape mechanism assy.addSubshape(cur_shape, color=cq_color) - elif current_attr.DynamicType().Name() == "TDataStd_TreeNode": - # Holds the color name, if found, and tells us whether or not it was found - color_name = None - - # Get the attributes of the father node - father_attr = current_attr.Father() - - # Iterate theough the attributes to see if there is a color name - lbl = father_attr.Label() - it = TDF_AttributeIterator(lbl) - while it.More(): - new_attr = it.Value() - if new_attr.DynamicType().Name() == "TDataStd_Name": - # Retrieve the name - name_string = new_attr.Get().ToExtString() - - # Make sure that we have a color name - if "#" in name_string: - color_name = name_string.split(" ")[0] - - it.Next() - - # If we found a color name, save it on the subshape - # Perfer the RGB value because when importing, OCCT tries to round - # RGB values to fit color names. - if cq_color is not None: - assy.addSubshape(cur_shape, color=cq_color) - elif color_name is not None: - assy.addSubshape(cur_shape, color=cq.Color(color_name)) elif current_attr.DynamicType().Name() == "XCAFDoc_GraphNode": # Step up one level to try to get the name from the parent lbl = current_attr.GetFather(1).Label() From 086fa8ac7d190961197bd0bb0e1674fdc39a049d Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Thu, 29 May 2025 14:44:07 -0400 Subject: [PATCH 037/124] Still trying to increase test coverage --- cadquery/occ_impl/importers/assembly.py | 111 ++++++++++++------------ 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 649db1e93..0f4596c5f 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -72,6 +72,7 @@ def process_label(label, parent_location=None): # The component level holds the location for its shapes location = parent_location loc = shape_tool.GetLocation_s(sub_label) + location = cq.Location((0.0, 0.0, 0.0)) if loc: location = cq.Location(loc) @@ -98,8 +99,8 @@ def process_label(label, parent_location=None): # Load the name of the part in the assembly, if it is present name = None name_attr = TDataStd_Name() - if label.FindAttribute(TDataStd_Name.GetID_s(), name_attr): - name = str(name_attr.Get().ToExtString()) + label.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + name = str(name_attr.Get().ToExtString()) # Process the color for the shape, which could be of different types color = Quantity_Color() @@ -118,59 +119,59 @@ def process_label(label, parent_location=None): else: assy.add(cq.Shape.cast(shape), name=name, color=cq_color) - if label.NbChildren() > 0: - for j in range(label.NbChildren()): - child_label = label.FindChild(j + 1) - attr_iterator = TDF_AttributeIterator(child_label) - while attr_iterator.More(): - current_attr = attr_iterator.Value() - - # Get the type name of the attribute so that we can decide how to handle it - if current_attr.DynamicType().Name() == "TNaming_NamedShape": - # Save the shape so that we can add it to the subshape data - cur_shape = current_attr.Get() - - # Find the layer name, if there is one set for this shape - layers = TDF_LabelSequence() - layer_tool.GetLayers(child_label, layers) - for i in range(1, layers.Length() + 1): - lbl = layers.Value(i) - name_attr = TDataStd_Name() - lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) - - # Extract the layer name for the shape here - layer_name = name_attr.Get().ToExtString() - - # Add the layer as a subshape entry on the assembly - assy.addSubshape(cur_shape, layer=layer_name) - - # Find the subshape color, if there is one set for this shape - color = Quantity_ColorRGBA() - # Extract the color, if present on the shape - if color_tool.GetColor(cur_shape, XCAFDoc_ColorSurf, color): - rgb = color.GetRGB() - cq_color = cq.Color( - rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha() + # Check all the attributes of all the children to find the subshapes and any names + for j in range(label.NbChildren()): + child_label = label.FindChild(j + 1) + attr_iterator = TDF_AttributeIterator(child_label) + while attr_iterator.More(): + current_attr = attr_iterator.Value() + + # Get the type name of the attribute so that we can decide how to handle it + if current_attr.DynamicType().Name() == "TNaming_NamedShape": + # Save the shape so that we can add it to the subshape data + cur_shape = current_attr.Get() + + # Find the layer name, if there is one set for this shape + layers = TDF_LabelSequence() + layer_tool.GetLayers(child_label, layers) + for i in range(1, layers.Length() + 1): + lbl = layers.Value(i) + name_attr = TDataStd_Name() + lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + + # Extract the layer name for the shape here + layer_name = name_attr.Get().ToExtString() + + # Add the layer as a subshape entry on the assembly + assy.addSubshape(cur_shape, layer=layer_name) + + # Find the subshape color, if there is one set for this shape + color = Quantity_ColorRGBA() + # Extract the color, if present on the shape + if color_tool.GetColor(cur_shape, XCAFDoc_ColorSurf, color): + rgb = color.GetRGB() + cq_color = cq.Color( + rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha() + ) + + # Save the color info via the assembly subshape mechanism + assy.addSubshape(cur_shape, color=cq_color) + elif current_attr.DynamicType().Name() == "XCAFDoc_GraphNode": + # Step up one level to try to get the name from the parent + lbl = current_attr.GetFather(1).Label() + + # Step through and search for the name attribute + it = TDF_AttributeIterator(lbl) + while it.More(): + new_attr = it.Value() + if new_attr.DynamicType().Name() == "TDataStd_Name": + # Save this as the name of the subshape + assy.addSubshape( + cur_shape, name=new_attr.Get().ToExtString(), ) + it.Next() - # Save the color info via the assembly subshape mechanism - assy.addSubshape(cur_shape, color=cq_color) - elif current_attr.DynamicType().Name() == "XCAFDoc_GraphNode": - # Step up one level to try to get the name from the parent - lbl = current_attr.GetFather(1).Label() - - # Step through and search for the name attribute - it = TDF_AttributeIterator(lbl) - while it.More(): - new_attr = it.Value() - if new_attr.DynamicType().Name() == "TDataStd_Name": - # Save this as the name of the subshape - assy.addSubshape( - cur_shape, name=new_attr.Get().ToExtString(), - ) - it.Next() - - attr_iterator.Next() + attr_iterator.Next() # Grab the labels, which should hold the assembly parent labels = TDF_LabelSequence() @@ -183,7 +184,7 @@ def process_label(label, parent_location=None): # Load the top-level name of the assembly, if it is present name_attr = TDataStd_Name() - if labels.Value(1).FindAttribute(TDataStd_Name.GetID_s(), name_attr): - assy.name = str(name_attr.Get().ToExtString()) + labels.Value(1).FindAttribute(TDataStd_Name.GetID_s(), name_attr) + assy.name = str(name_attr.Get().ToExtString()) else: raise ValueError("Step file does not contain an assembly") From 636551238818a31a105bf9a8a947acf0da664f5e Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Thu, 29 May 2025 15:43:05 -0400 Subject: [PATCH 038/124] Added a test for a plain assembly --- tests/test_assembly.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 3357aa267..8c7c687d6 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -925,6 +925,28 @@ def test_bad_step_file_import(tmp_path_factory): imported_assy = cq.Assembly.importStep(bad_step_path) +def test_plain_assembly_import(tmp_path_factory): + """ + Test to make sure that importing plain assemblies has not been broken. + """ + + tmpdir = tmp_path_factory.mktemp("out") + plain_step_path = os.path.join(tmpdir, "plain_assembly_step.step") + + # Create a basic assembly + cube_1 = cq.Workplane().box(10, 10, 10) + assy = cq.Assembly(name="top_level") + assy.add(cube_1) + + # Export the assembly + success = exportStepMeta(assy, plain_step_path) + assert success + + # Import the STEP file back in + imported_assy = cq.Assembly.importStep(plain_step_path) + assert imported_assy.name == "top_level" + + @pytest.mark.parametrize( "assy_fixture, expected", [ From 578fd60060f01c3930da97b78200327d6fb71b41 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 3 Jun 2025 09:16:44 -0400 Subject: [PATCH 039/124] Refactored a bit to support nested assemblies better in the future --- cadquery/occ_impl/importers/assembly.py | 229 ++++++++++++------------ 1 file changed, 112 insertions(+), 117 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 0f4596c5f..78fba0fc5 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -50,6 +50,89 @@ def importStep(assy: AssemblyProtocol, path: str): color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main()) layer_tool = XCAFDoc_DocumentTool.LayerTool_s(doc.Main()) + def _process_simple_shape(label, parent_location=None): + shape = shape_tool.GetShape_s(label) + + # Tracks the RGB color value and whether or not it was found + cq_color = None + + # Load the name of the part in the assembly, if it is present + name = None + name_attr = TDataStd_Name() + label.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + name = str(name_attr.Get().ToExtString()) + + # Process the color for the shape, which could be of different types + color = Quantity_Color() + cq_color = cq.Color(0.0, 0.0, 0.0) + if color_tool.GetColor_s(label, XCAFDoc_ColorSurf, color): + r = color.Red() + g = color.Green() + b = color.Blue() + cq_color = cq.Color(r, g, b) + + # Handle the location if it was passed down form a parent component + if parent_location is not None: + assy.add( + cq.Shape.cast(shape), name=name, color=cq_color, loc=parent_location + ) + else: + assy.add(cq.Shape.cast(shape), name=name, color=cq_color) + + # Check all the attributes of all the children to find the subshapes and any names + for j in range(label.NbChildren()): + child_label = label.FindChild(j + 1) + attr_iterator = TDF_AttributeIterator(child_label) + while attr_iterator.More(): + current_attr = attr_iterator.Value() + + # Get the type name of the attribute so that we can decide how to handle it + if current_attr.DynamicType().Name() == "TNaming_NamedShape": + # Save the shape so that we can add it to the subshape data + cur_shape = current_attr.Get() + + # Find the layer name, if there is one set for this shape + layers = TDF_LabelSequence() + layer_tool.GetLayers(child_label, layers) + for i in range(1, layers.Length() + 1): + lbl = layers.Value(i) + name_attr = TDataStd_Name() + lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + + # Extract the layer name for the shape here + layer_name = name_attr.Get().ToExtString() + + # Add the layer as a subshape entry on the assembly + assy.addSubshape(cur_shape, layer=layer_name) + + # Find the subshape color, if there is one set for this shape + color = Quantity_ColorRGBA() + # Extract the color, if present on the shape + if color_tool.GetColor(cur_shape, XCAFDoc_ColorSurf, color): + rgb = color.GetRGB() + cq_color = cq.Color( + rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha() + ) + + # Save the color info via the assembly subshape mechanism + assy.addSubshape(cur_shape, color=cq_color) + elif current_attr.DynamicType().Name() == "XCAFDoc_GraphNode": + # Step up one level to try to get the name from the parent + lbl = current_attr.GetFather(1).Label() + + # Step through and search for the name attribute + it = TDF_AttributeIterator(lbl) + while it.More(): + new_attr = it.Value() + if new_attr.DynamicType().Name() == "TDataStd_Name": + # Save this as the name of the subshape + assy.addSubshape( + cur_shape, name=new_attr.Get().ToExtString(), + ) + it.Next() + + attr_iterator.Next() + def process_label(label, parent_location=None): """ Recursive function that allows us to process the hierarchy of the assembly as represented @@ -61,130 +144,42 @@ def process_label(label, parent_location=None): ref_label = TDF_Label() shape_tool.GetReferredShape_s(label, ref_label) process_label(ref_label, parent_location) + + # Load the top-level name of the assembly, if it is present + name_attr = TDataStd_Name() + labels.Value(1).FindAttribute(TDataStd_Name.GetID_s(), name_attr) + assy.name = str(name_attr.Get().ToExtString()) + return - # Process components - comp_labels = TDF_LabelSequence() - shape_tool.GetComponents_s(label, comp_labels) - for i in range(comp_labels.Length()): - sub_label = comp_labels.Value(i + 1) - - # The component level holds the location for its shapes - location = parent_location - loc = shape_tool.GetLocation_s(sub_label) - location = cq.Location((0.0, 0.0, 0.0)) - if loc: - location = cq.Location(loc) - - # Make sure that the location object is actually doing something interesting - # This is done because the location may have to go through multiple levels of - # components before the shapes are found. This allows the top-level component - # to specify the location/rotation of the shapes. - if location.toTuple()[0] == (0, 0, 0) and location.toTuple()[1] == ( - 0, - 0, - 0, - ): - location = parent_location - - process_label(sub_label, location) - - # Check to see if we have an endpoint shape - if shape_tool.IsSimpleShape_s(label): - shape = shape_tool.GetShape_s(label) + # See if this is an assembly (or sub-assembly) + if shape_tool.IsAssembly_s(label): + # Recursively process its components (children) + comp_labels = TDF_LabelSequence() + shape_tool.GetComponents_s(label, comp_labels) + for i in range(comp_labels.Length()): + sub_label = comp_labels.Value(i + 1) - # Tracks the RGB color value and whether or not it was found - cq_color = None + # Pass down the location or other context as needed + process_label(sub_label, parent_location) + return - # Load the name of the part in the assembly, if it is present - name = None - name_attr = TDataStd_Name() - label.FindAttribute(TDataStd_Name.GetID_s(), name_attr) - name = str(name_attr.Get().ToExtString()) - - # Process the color for the shape, which could be of different types - color = Quantity_Color() - cq_color = cq.Color(0.0, 0.0, 0.0) - if color_tool.GetColor_s(label, XCAFDoc_ColorSurf, color): - r = color.Red() - g = color.Green() - b = color.Blue() - cq_color = cq.Color(r, g, b) - - # Handle the location if it was passed down form a parent component - if parent_location is not None: - assy.add( - cq.Shape.cast(shape), name=name, color=cq_color, loc=parent_location - ) - else: - assy.add(cq.Shape.cast(shape), name=name, color=cq_color) - - # Check all the attributes of all the children to find the subshapes and any names - for j in range(label.NbChildren()): - child_label = label.FindChild(j + 1) - attr_iterator = TDF_AttributeIterator(child_label) - while attr_iterator.More(): - current_attr = attr_iterator.Value() - - # Get the type name of the attribute so that we can decide how to handle it - if current_attr.DynamicType().Name() == "TNaming_NamedShape": - # Save the shape so that we can add it to the subshape data - cur_shape = current_attr.Get() - - # Find the layer name, if there is one set for this shape - layers = TDF_LabelSequence() - layer_tool.GetLayers(child_label, layers) - for i in range(1, layers.Length() + 1): - lbl = layers.Value(i) - name_attr = TDataStd_Name() - lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) - - # Extract the layer name for the shape here - layer_name = name_attr.Get().ToExtString() - - # Add the layer as a subshape entry on the assembly - assy.addSubshape(cur_shape, layer=layer_name) - - # Find the subshape color, if there is one set for this shape - color = Quantity_ColorRGBA() - # Extract the color, if present on the shape - if color_tool.GetColor(cur_shape, XCAFDoc_ColorSurf, color): - rgb = color.GetRGB() - cq_color = cq.Color( - rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha() - ) + # Check to see if we have an endpoint shape and process it + if shape_tool.IsSimpleShape_s(label): + _process_simple_shape(label, parent_location) - # Save the color info via the assembly subshape mechanism - assy.addSubshape(cur_shape, color=cq_color) - elif current_attr.DynamicType().Name() == "XCAFDoc_GraphNode": - # Step up one level to try to get the name from the parent - lbl = current_attr.GetFather(1).Label() - - # Step through and search for the name attribute - it = TDF_AttributeIterator(lbl) - while it.More(): - new_attr = it.Value() - if new_attr.DynamicType().Name() == "TDataStd_Name": - # Save this as the name of the subshape - assy.addSubshape( - cur_shape, name=new_attr.Get().ToExtString(), - ) - it.Next() - - attr_iterator.Next() - - # Grab the labels, which should hold the assembly parent + # Look for the top-level assembly + found_top_level_assembly = False labels = TDF_LabelSequence() shape_tool.GetFreeShapes(labels) + for i in range(labels.Length()): + # Make sure that we have an assembly at the top level + if shape_tool.IsTopLevel(labels.Value(i + 1)): + if shape_tool.IsAssembly_s(labels.Value(i + 1)): + found_top_level_assembly = True - # Make sure that we are working with an assembly - if shape_tool.IsAssembly_s(labels.Value(1)): - # Start the recursive processing of the assembly - process_label(labels.Value(1)) + process_label(labels.Value(i + 1)) - # Load the top-level name of the assembly, if it is present - name_attr = TDataStd_Name() - labels.Value(1).FindAttribute(TDataStd_Name.GetID_s(), name_attr) - assy.name = str(name_attr.Get().ToExtString()) - else: + # If we did not find a top-level assembly, raise an error + if not found_top_level_assembly: raise ValueError("Step file does not contain an assembly") From c90e47655f7e34c4bf11387b43d9c1ee0a743ec6 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 3 Jun 2025 16:23:00 -0400 Subject: [PATCH 040/124] Fixed location handling for components of the assembly --- cadquery/occ_impl/importers/assembly.py | 9 +++- tests/test_assembly.py | 57 +++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 78fba0fc5..5f15baddd 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -74,7 +74,10 @@ def _process_simple_shape(label, parent_location=None): # Handle the location if it was passed down form a parent component if parent_location is not None: assy.add( - cq.Shape.cast(shape), name=name, color=cq_color, loc=parent_location + cq.Shape.cast(shape), + name=name, + color=cq_color, + loc=cq.Location(parent_location), ) else: assy.add(cq.Shape.cast(shape), name=name, color=cq_color) @@ -159,9 +162,11 @@ def process_label(label, parent_location=None): shape_tool.GetComponents_s(label, comp_labels) for i in range(comp_labels.Length()): sub_label = comp_labels.Value(i + 1) + # Get the location of the sub-label, if it exists + loc = shape_tool.GetLocation_s(sub_label) # Pass down the location or other context as needed - process_label(sub_label, parent_location) + process_label(sub_label, loc) return # Check to see if we have an endpoint shape and process it diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 8c7c687d6..bc295a115 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -857,6 +857,11 @@ def test_assembly_step_import(tmp_path_factory): 1.0, 1.0, ) + + # Make sure the shape locations were applied correctly + assert imported_assy.children[1].loc.toTuple()[0] == (0.0, 0.0, -10.0) + + # Check the top-level assembly name assert imported_assy.name == "top-level" # Test a STEP file that does not contain an assembly @@ -911,6 +916,58 @@ def test_assembly_subshape_step_import(tmp_path_factory): assert layer_name == "cube_1_top_face" +def test_assembly_multi_subshape_step_import(tmp_path_factory): + """ + Test if a STEP file containing subshape information can be imported correctly. + """ + + tmpdir = tmp_path_factory.mktemp("out") + assy_step_path = os.path.join(tmpdir, "multi_subshape_assy.step") + + # Create a basic assembly + cube_1 = cq.Workplane().box(10, 10, 10) + assy = cq.Assembly(name="top_level") + assy.add(cube_1, name="cube_1", color=cq.Color("green")) + cube_2 = cq.Workplane().box(5, 5, 5) + assy.add(cube_2, name="cube_2", color=cq.Color("blue"), loc=cq.Location(10, 10, 10)) + + # Add subshape name, color and layer + assy.addSubshape( + cube_1.faces(">Z").val(), + name="cube_1_top_face", + color=cq.Color("red"), + layer="cube_1_top_face", + ) + assy.addSubshape( + cube_2.faces(">X").val(), + name="cube_2_right_face", + color=cq.Color("red"), + layer="cube_2_right_face", + ) + + # Export the assembly + success = exportStepMeta(assy, assy_step_path) + assert success + + # Import the STEP file back in + imported_assy = cq.Assembly.importStep(assy_step_path) + + # Check that the top-level assembly name is correct + assert imported_assy.name == "top_level" + + # Check the advanced face name + assert len(imported_assy._subshape_names) == 2 + assert list(imported_assy._subshape_names.values())[0] == "cube_1_top_face" + + # Check the color + color = list(imported_assy._subshape_colors.values())[0] + assert Quantity_NameOfColor.Quantity_NOC_RED == color.wrapped.GetRGB().Name() + + # Check the layer info + layer_name = list(imported_assy._subshape_layers.values())[0] + assert layer_name == "cube_1_top_face" + + def test_bad_step_file_import(tmp_path_factory): """ Test if a bad STEP file raises an error when importing. From 83a433f05674ba497adfe8d159082adc5081b95c Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 10 Jun 2025 11:08:05 -0400 Subject: [PATCH 041/124] Fix the default color to not be black --- cadquery/occ_impl/importers/assembly.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 5f15baddd..380facf16 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -64,7 +64,7 @@ def _process_simple_shape(label, parent_location=None): # Process the color for the shape, which could be of different types color = Quantity_Color() - cq_color = cq.Color(0.0, 0.0, 0.0) + cq_color = cq.Color(0.50, 0.50, 0.50) if color_tool.GetColor_s(label, XCAFDoc_ColorSurf, color): r = color.Red() g = color.Green() From dd7803e479d2f84b5e97ea3743048ecdd1be57cc Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Thu, 12 Jun 2025 17:50:31 +0200 Subject: [PATCH 042/124] Adding smoke test for fig --- tests/conftest.py | 22 ++++++++++++++++++++++ tests/test_fig.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_fig.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..d30b369de --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +import pytest + + +def pytest_addoption(parser): + parser.addoption("--gui", action="store_true", default=False, help="run gui tests") + + +def pytest_configure(config): + config.addinivalue_line("markers", "gui: mark gui test") + + +def pytest_collection_modifyitems(config, items): + + # run gui tests --gui option is proveded + if config.getoption("--gui"): + return + + # skip gui tests otherwise + skip_gui = pytest.mark.skip(reason="need --gui option to run") + for item in items: + if "gui" in item.keywords: + item.add_marker(skip_gui) diff --git a/tests/test_fig.py b/tests/test_fig.py new file mode 100644 index 000000000..c9cc5bf37 --- /dev/null +++ b/tests/test_fig.py @@ -0,0 +1,44 @@ +from cadquery import Workplane, Assembly, Sketch, Vector, Location +from cadquery.func import box +from cadquery.vis import vtkAxesActor, ctrlPts +from cadquery.fig import Figure + +from pytest import fixture, mark + + +@fixture(scope="module") +def fig(): + return Figure() + + +@mark.gui +def test_fig(fig): + + # showables + s = box(1, 1, 1) + wp = Workplane().box(1, 1, 1) + assy = Assembly().add(box(1, 1, 1)) + sk = Sketch().rect(1, 1) + ctrl_pts = ctrlPts(sk.val().toNURBS()) + v = Vector() + loc = Location() + act = vtkAxesActor() + + # individual showables + fig.show(s, wp, assy, sk, ctrl_pts, v, loc, act) + + # fit + fig.fit() + + # clear + fig.clear() + + # lists of showables + fig.show(s.Edges()).show([Vector(), Vector(0, 1)]) + + # displaying nonsense does not throw + fig.show("a").show(["a", 1234]) + + # pop + fig.show(s, color="red") + fig.pop() From 34a499afa4044f66793b99cc24cc418cfca26cb8 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Thu, 12 Jun 2025 17:52:15 +0200 Subject: [PATCH 043/124] Run gui tests in appveyor --- appveyor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index 5364fb68a..6c1632e00 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -37,7 +37,7 @@ build: false test_script: - mamba run -n cadquery black . --diff --check - mamba run -n cadquery mypy cadquery - - mamba run -n cadquery pytest -v --cov + - mamba run -n cadquery pytest -v --gui --cov on_success: - mamba run -n cadquery codecov From 7d3df36cb7b992301da4f197ba6ee4a3864aea1c Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:15:20 +0200 Subject: [PATCH 044/124] mypy fix --- cadquery/fig.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadquery/fig.py b/cadquery/fig.py index bbfa5502d..e9447ddf4 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -3,8 +3,8 @@ set_event_loop, run_coroutine_threadsafe, AbstractEventLoop, - Future, ) +from concurrent.futures import Future from typing import Optional from threading import Thread from itertools import chain From 30edd4a2ac5a51c2598a88e1573a3b3dc8712d61 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:32:05 +0200 Subject: [PATCH 045/124] Misc fixes --- cadquery/fig.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cadquery/fig.py b/cadquery/fig.py index e9447ddf4..5143794e5 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -10,19 +10,19 @@ from itertools import chain from webbrowser import open_new_tab -from trame.app import get_server, Server +from trame.app import get_server +from trame.app.core import Server from trame.widgets import html, vtk as vtk_widgets, client from trame.ui.html import DivLayout from . import Shape -from .vis import style, Showable, ShapeLike, _split_showables, _to_vtk_pts, _to_vtk_axs +from .vis import style, Showable, ShapeLike, _split_showables from vtkmodules.vtkRenderingCore import ( vtkRenderer, vtkRenderWindow, vtkRenderWindowInteractor, vtkProp3D, - vtkActor, ) From 2ad988424266d9c86b7a50d3765b8e69256077d7 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Thu, 12 Jun 2025 19:15:12 +0200 Subject: [PATCH 046/124] Test GUI only on win --- tests/test_fig.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/test_fig.py b/tests/test_fig.py index c9cc5bf37..6789a57c7 100644 --- a/tests/test_fig.py +++ b/tests/test_fig.py @@ -5,6 +5,8 @@ from pytest import fixture, mark +from sys import platform + @fixture(scope="module") def fig(): @@ -12,6 +14,7 @@ def fig(): @mark.gui +@mark.skipif(platform != "win32", reason="CI with UI only works on win for now") def test_fig(fig): # showables @@ -24,8 +27,10 @@ def test_fig(fig): loc = Location() act = vtkAxesActor() + showables = (s, wp, assy, sk, ctrl_pts, v, loc, act) + # individual showables - fig.show(s, wp, assy, sk, ctrl_pts, v, loc, act) + fig.show(*showables) # fit fig.fit() @@ -33,6 +38,9 @@ def test_fig(fig): # clear fig.clear() + # clear with an arg + fig.show(s).clear(s) + # lists of showables fig.show(s.Edges()).show([Vector(), Vector(0, 1)]) @@ -40,5 +48,10 @@ def test_fig(fig): fig.show("a").show(["a", 1234]) # pop - fig.show(s, color="red") - fig.pop() + for el in showables: + fig.show(el, color="red") + fig.pop() + + # test singleton behavior of fig + fig2 = Figure() + assert fig is fig2 From d1c726a87ac6c13a4a5336827c15ab21bfd3a693 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Thu, 12 Jun 2025 19:42:17 +0200 Subject: [PATCH 047/124] Coverage tweak --- tests/test_fig.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_fig.py b/tests/test_fig.py index 6789a57c7..3d810f840 100644 --- a/tests/test_fig.py +++ b/tests/test_fig.py @@ -39,7 +39,8 @@ def test_fig(fig): fig.clear() # clear with an arg - fig.show(s).clear(s) + for el in showables: + fig.show(el).clear(el) # lists of showables fig.show(s.Edges()).show([Vector(), Vector(0, 1)]) From cafad47ef2806354b743b7d967d65db53674d816 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Thu, 12 Jun 2025 15:15:28 -0400 Subject: [PATCH 048/124] Fixed bug with parent location not being applied when needed --- cadquery/occ_impl/importers/assembly.py | 4 ++ tests/test_assembly.py | 52 ++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 380facf16..f03384b78 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -160,12 +160,16 @@ def process_label(label, parent_location=None): # Recursively process its components (children) comp_labels = TDF_LabelSequence() shape_tool.GetComponents_s(label, comp_labels) + for i in range(comp_labels.Length()): sub_label = comp_labels.Value(i + 1) # Get the location of the sub-label, if it exists loc = shape_tool.GetLocation_s(sub_label) # Pass down the location or other context as needed + # Add the parent location if it exists + if parent_location is not None: + loc = parent_location * loc process_label(sub_label, loc) return diff --git a/tests/test_assembly.py b/tests/test_assembly.py index bc295a115..853d5b6c0 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -987,22 +987,62 @@ def test_plain_assembly_import(tmp_path_factory): Test to make sure that importing plain assemblies has not been broken. """ + from cadquery.func import box, rect + tmpdir = tmp_path_factory.mktemp("out") plain_step_path = os.path.join(tmpdir, "plain_assembly_step.step") - # Create a basic assembly + # Simple cubes cube_1 = cq.Workplane().box(10, 10, 10) + cube_2 = cq.Workplane().box(5, 5, 5) + cube_3 = cq.Workplane().box(5, 5, 5) + cube_4 = cq.Workplane().box(5, 5, 5) + assy = cq.Assembly(name="top_level") - assy.add(cube_1) + assy.add(cube_1, color=cq.Color("green")) + assy.add(cube_2, loc=cq.Location((10, 10, 10)), color=cq.Color("red")) + assy.add(cube_3, loc=cq.Location((-10, -10, -10)), color=cq.Color("red")) + assy.add(cube_4, loc=cq.Location((10, -10, -10)), color=cq.Color("red")) - # Export the assembly - success = exportStepMeta(assy, plain_step_path) - assert success + # Export the assembly, but do not use the meta STEP export method + assy.export(plain_step_path) - # Import the STEP file back in + # # Import the STEP file back in imported_assy = cq.Assembly.importStep(plain_step_path) assert imported_assy.name == "top_level" + # Check the locations + assert imported_assy.children[0].loc.toTuple()[0] == (0.0, 0.0, 0.0) + assert imported_assy.children[1].loc.toTuple()[0] == (10.0, 10.0, 10.0) + assert imported_assy.children[2].loc.toTuple()[0] == (-10.0, -10.0, -10.0) + assert imported_assy.children[3].loc.toTuple()[0] == (10.0, -10.0, -10.0) + + # Check the colors + assert pytest.approx(imported_assy.children[0].color.toTuple(), rel=0.01) == ( + 0.0, + 1.0, + 0.0, + 1.0, + ) # green + assert pytest.approx(imported_assy.children[1].color.toTuple(), rel=0.01) == ( + 1.0, + 0.0, + 0.0, + 1.0, + ) # red + assert pytest.approx(imported_assy.children[2].color.toTuple(), rel=0.01) == ( + 1.0, + 0.0, + 0.0, + 1.0, + ) # red + assert pytest.approx(imported_assy.children[3].color.toTuple(), rel=0.01) == ( + 1.0, + 0.0, + 0.0, + 1.0, + ) # red + @pytest.mark.parametrize( "assy_fixture, expected", From 99af75fdd71e625ab9b0d8ef234ca6421083e486 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Thu, 12 Jun 2025 21:26:44 +0200 Subject: [PATCH 049/124] Fix test --- cadquery/fig.py | 2 +- tests/test_fig.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cadquery/fig.py b/cadquery/fig.py index 5143794e5..5a3298946 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -231,7 +231,7 @@ async def _clear(): self.empty = True for s in shapes: - if isinstance(s, Shape): + if isinstance(s, ShapeLike): for a in self.shapes[s]: self.ren.RemoveActor(a) diff --git a/tests/test_fig.py b/tests/test_fig.py index 3d810f840..ffa09264c 100644 --- a/tests/test_fig.py +++ b/tests/test_fig.py @@ -39,7 +39,7 @@ def test_fig(fig): fig.clear() # clear with an arg - for el in showables: + for el in (s, wp, assy, sk, ctrl_pts): fig.show(el).clear(el) # lists of showables From f7bf1bf070ce421b11b5ff145b8eb7df3d0f954d Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 13 Jun 2025 08:05:07 +0200 Subject: [PATCH 050/124] Mypy fix --- cadquery/fig.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cadquery/fig.py b/cadquery/fig.py index 5a3298946..4d08201c9 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -10,6 +10,8 @@ from itertools import chain from webbrowser import open_new_tab +from typish import instance_of + from trame.app import get_server from trame.app.core import Server from trame.widgets import html, vtk as vtk_widgets, client @@ -231,7 +233,7 @@ async def _clear(): self.empty = True for s in shapes: - if isinstance(s, ShapeLike): + if instance_of(s, ShapeLike): for a in self.shapes[s]: self.ren.RemoveActor(a) From 204df308ea090c553e01d589c050f58eb56c66e9 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Mon, 16 Jun 2025 08:04:41 +0200 Subject: [PATCH 051/124] Change zoom reset behavior --- cadquery/fig.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cadquery/fig.py b/cadquery/fig.py index 4d08201c9..a68a43e59 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -230,8 +230,6 @@ async def _clear(): self.actors.clear() self.shapes.clear() - self.empty = True - for s in shapes: if instance_of(s, ShapeLike): for a in self.shapes[s]: From 43e2e00648ed182583e8d5389f28df9ab7eeb330 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 23 Jun 2025 13:54:30 -0400 Subject: [PATCH 052/124] Fixed importer for Assembly.export method --- cadquery/occ_impl/importers/assembly.py | 53 ++++++++++++++----------- tests/test_assembly.py | 42 +++++++++++++++++++- 2 files changed, 69 insertions(+), 26 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index f03384b78..aa75e75a1 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -50,7 +50,7 @@ def importStep(assy: AssemblyProtocol, path: str): color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main()) layer_tool = XCAFDoc_DocumentTool.LayerTool_s(doc.Main()) - def _process_simple_shape(label, parent_location=None): + def _process_simple_shape(label, parent_location=None, parent_name=None): shape = shape_tool.GetShape_s(label) # Tracks the RGB color value and whether or not it was found @@ -58,9 +58,12 @@ def _process_simple_shape(label, parent_location=None): # Load the name of the part in the assembly, if it is present name = None - name_attr = TDataStd_Name() - label.FindAttribute(TDataStd_Name.GetID_s(), name_attr) - name = str(name_attr.Get().ToExtString()) + if parent_name is not None and parent_name != assy.name: + name = parent_name + else: + name_attr = TDataStd_Name() + label.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + name = str(name_attr.Get().ToExtString()) # Process the color for the shape, which could be of different types color = Quantity_Color() @@ -136,7 +139,7 @@ def _process_simple_shape(label, parent_location=None): attr_iterator.Next() - def process_label(label, parent_location=None): + def process_label(label, parent_location=None, parent_name=None): """ Recursive function that allows us to process the hierarchy of the assembly as represented in the step file. @@ -146,17 +149,16 @@ def process_label(label, parent_location=None): if shape_tool.IsReference_s(label): ref_label = TDF_Label() shape_tool.GetReferredShape_s(label, ref_label) - process_label(ref_label, parent_location) - - # Load the top-level name of the assembly, if it is present - name_attr = TDataStd_Name() - labels.Value(1).FindAttribute(TDataStd_Name.GetID_s(), name_attr) - assy.name = str(name_attr.Get().ToExtString()) + process_label(ref_label, parent_location, parent_name) return # See if this is an assembly (or sub-assembly) if shape_tool.IsAssembly_s(label): + name_attr = TDataStd_Name() + label.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + name = name_attr.Get().ToExtString() + # Recursively process its components (children) comp_labels = TDF_LabelSequence() shape_tool.GetComponents_s(label, comp_labels) @@ -170,25 +172,28 @@ def process_label(label, parent_location=None): # Add the parent location if it exists if parent_location is not None: loc = parent_location * loc - process_label(sub_label, loc) + + process_label(sub_label, loc, name) + return # Check to see if we have an endpoint shape and process it if shape_tool.IsSimpleShape_s(label): - _process_simple_shape(label, parent_location) + _process_simple_shape(label, parent_location, parent_name) - # Look for the top-level assembly - found_top_level_assembly = False + # Get the shapes in the assembly labels = TDF_LabelSequence() shape_tool.GetFreeShapes(labels) - for i in range(labels.Length()): - # Make sure that we have an assembly at the top level - if shape_tool.IsTopLevel(labels.Value(i + 1)): - if shape_tool.IsAssembly_s(labels.Value(i + 1)): - found_top_level_assembly = True - - process_label(labels.Value(i + 1)) - # If we did not find a top-level assembly, raise an error - if not found_top_level_assembly: + # Use the first label to pull the top-level assembly information + name_attr = TDataStd_Name() + labels.Value(1).FindAttribute(TDataStd_Name.GetID_s(), name_attr) + assy.name = str(name_attr.Get().ToExtString()) + + # Make sure there is a top-level assembly + if shape_tool.IsTopLevel(labels.Value(1)) and shape_tool.IsAssembly_s( + labels.Value(1) + ): + process_label(labels.Value(1)) + else: raise ValueError("Step file does not contain an assembly") diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 853d5b6c0..5f8a74179 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -987,8 +987,6 @@ def test_plain_assembly_import(tmp_path_factory): Test to make sure that importing plain assemblies has not been broken. """ - from cadquery.func import box, rect - tmpdir = tmp_path_factory.mktemp("out") plain_step_path = os.path.join(tmpdir, "plain_assembly_step.step") @@ -1044,6 +1042,46 @@ def test_plain_assembly_import(tmp_path_factory): ) # red +def test_copied_assembly_import(tmp_path_factory): + """ + Tests to make sure that copied children in assemblies work correctly. + """ + from cadquery import Assembly, Location, Color + from cadquery.func import box, rect + + # Create the temporary directory + tmpdir = tmp_path_factory.mktemp("out") + + # prepare the model + def make_model(name: str, COPY: bool): + name = os.path.join(tmpdir, name) + + b = box(1, 1, 1) + + assy = Assembly(name="test_assy") + assy.add(box(1, 2, 5), color=Color("green")) + + for v in rect(10, 10).vertices(): + assy.add( + b.copy() if COPY else b, loc=Location(v.Center()), color=Color("red") + ) + + assy.export(name) + + return assy + + make_model("test_assy_copy.step", True) + make_model("test_assy.step", False) + + # import the assy with copies + assy_copy = Assembly.importStep(os.path.join(tmpdir, "test_assy_copy.step")) + assert 5 == len(assy_copy.children) + + # import the assy without copies - this throws + assy_normal = Assembly.importStep(os.path.join(tmpdir, "test_assy.step")) + assert 5 == len(assy_normal.children) + + @pytest.mark.parametrize( "assy_fixture, expected", [ From 26a64f3b4b5cb190e1e4e8edf551ab57b7437b51 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 23 Jun 2025 16:08:29 -0400 Subject: [PATCH 053/124] Fixed comment --- tests/test_assembly.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 5f8a74179..860d79cc2 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -1077,7 +1077,7 @@ def make_model(name: str, COPY: bool): assy_copy = Assembly.importStep(os.path.join(tmpdir, "test_assy_copy.step")) assert 5 == len(assy_copy.children) - # import the assy without copies - this throws + # import the assy without copies assy_normal = Assembly.importStep(os.path.join(tmpdir, "test_assy.step")) assert 5 == len(assy_normal.children) From 4d025d0db1c57394fc95d8af563ad91e26ed0e7e Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 27 Jun 2025 17:46:36 +0200 Subject: [PATCH 054/124] Initial commit of b-spline/nurbs tools --- cadquery/occ_impl/nurbs.py | 345 +++++++++++++++++++++++++++++++++++++ conda/meta.yaml | 1 + environment.yml | 1 + mypy.ini | 3 + tests/test_nurbs.py | 55 ++++++ 5 files changed, 405 insertions(+) create mode 100644 cadquery/occ_impl/nurbs.py create mode 100644 tests/test_nurbs.py diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py new file mode 100644 index 000000000..8c97f5b5c --- /dev/null +++ b/cadquery/occ_impl/nurbs.py @@ -0,0 +1,345 @@ +#%% imports +from numba import njit, prange +from numpy import linspace, array, empty_like, atleast_1d +import numpy as np +import math + +from numba import njit as _njit, prange +from typing import NamedTuple, Optional +from numpy.typing import NDArray + +njit = _njit(cache=False, error_model="numpy", fastmath=True, parallel=False) + +njiti = _njit( + cache=True, inline="always", error_model="numpy", fastmath=True, parallel=False +) + + +#%% vocabulary types + +Array = NDArray[np.float64] +ArrayI = NDArray[np.int_] + + +class COO(NamedTuple): + """ + COO sparse matrix container. + """ + + i: ArrayI + j: ArrayI + v: Array + + +#%% basis functions + + +@njiti +def nbFindSpan( + u: float, + order: int, + knots: Array, + low: Optional[int] = None, + high: Optional[int] = None, +) -> int: + """ + NURBS book A2.1 with modifications to handle periodic usecases. + + Parameters + ---------- + u : float + Parameter value. + order : int + Spline order. + knots : ndarray + Knot vectr. + + Returns + ------- + Span index. + + """ + + if low is None: + low = order + + if high is None: + high = knots.shape[0] - order - 1 + + mid = (low + high) // 2 + + if u >= knots[-1]: + return high - 1 # handle last span + elif u < knots[0]: + return low + + while u < knots[mid] or u >= knots[mid + 1]: + if u < knots[mid]: + high = mid + else: + low = mid + + mid = (low + high) // 2 + + return mid + + +@njiti +def nbBasis(i: int, u: float, order: int, knots: Array, out: Array): + """ + NURBS book A2.2 + + Parameters + ---------- + i : int + Span index. + u : float + Parameter value. + order : int + B-spline order. + knots : ndarray + Knot vectr. + out : ndarray + B-spline basis function values. + + Returns + ------- + None. + + """ + + out[0] = 1.0 + + left = np.zeros_like(out) + right = np.zeros_like(out) + + for j in range(1, order + 1): + left[j] = u - knots[i + 1 - j] + right[j] = knots[i + j] - u + + saved = 0.0 + + for r in range(j): + temp = out[r] / (right[r + 1] + left[j - r]) + out[r] = saved + right[r + 1] * temp + saved = left[j - r] * temp + + out[j] = saved + + +def nbBasisDer(i: int, u: float, order: int, dorder: int, knots: Array, out: Array): + """ + NURBS book A2.3 + + Parameters + ---------- + i : int + Span index. + u : float + Parameter value. + order : int + B-spline order. + dorder : int + Derivative order. + knots : ndarray + Knot vectr. + out : ndarray + B-spline basis function and derivative values. + + Returns + ------- + None. + + """ + + ndu = np.zeros((order + 1, order + 1)) + + left = np.zeros(order + 1) + right = np.zeros(order + 1) + + a = np.zeros((2, order + 1)) + + ndu[0, 0] = 1 + + for j in range(1, order + 1): + left[j] = u - knots[i + 1 - j] + right[j] = knots[i + j] - u + + saved = 0.0 + + for r in range(j): + ndu[j, r] = right[r + 1] + left[j - r] + temp = ndu[r, j - 1] / ndu[j, r] + + ndu[r, j] = saved + right[r + 1] * temp + saved = left[j - r] * temp + + ndu[j, j] = saved + + # store the basis functions + out[0, :] = ndu[:, order] + + # calculate and store derivatives + + # loop over basis functions + for r in range(order + 1): + s1 = 0 + s2 = 1 + + a[0, 0] = 1 + + # loop over derivative orders + for k in range(1, dorder + 1): + d = 0.0 + rk = r - k + pk = order - k + + if r >= k: + a[s2, 0] = a[s1, 0] / ndu[pk + 1, rk] + d = a[s2, 0] * ndu[rk, pk] + + if rk >= -1: + j1 = 1 + else: + j1 = -rk + + if r - 1 <= pk: + j2 = k - 1 + else: + j2 = order - r + + for j in range(j1, j2 + 1): + a[s2, j] = (a[s1, j] - a[s1, j - 1]) / ndu[pk + 1, rk + j] + d += a[s2, j] * ndu[rk + j, pk] + + if r <= pk: + a[s2, k] = -a[s1, k - 1] / ndu[pk + 1, r] + d += a[s2, k] * ndu[r, pk] + + # store the kth derivative of rth basis + out[k, r] = d + + # switch + s1, s2 = s2, s1 + + # multiply recursively by the order + r = order + + for k in range(1, dorder + 1): + out[k, :] *= r + r *= order - k + + +@njit +def designMatrix(u: Array, order: int, knots: Array) -> COO: + """ + Create a sparse design matrix. + """ + + # number of param values + nu = np.size(u) + + # chunck size + n = order + 1 + + # temp chunck storage + temp = np.zeros(n) + + # initialize the empty matrix + rv = COO( + i=np.empty(n * nu, dtype=np.int64), + j=np.empty(n * nu, dtype=np.int64), + v=np.empty(n * nu), + ) + + # loop over param values + for i in range(nu): + ui = u[i] + + # find the supporting span + span = nbFindSpan(ui, order, knots) + + # evaluate non-zero functions + nbBasis(span, ui, order, knots, temp) + + # update the matrix + rv.i[i * n : (i + 1) * n] = i + rv.j[i * n : (i + 1) * n] = span - order + np.arange(n) + rv.v[i * n : (i + 1) * n] = temp + + return rv + + +@njit +def periodicDesignMatrix(u: Array, order: int, knots: Array) -> COO: + """ + Create a sparse periodic design matrix. + """ + + # extend the knots + knots_ext = np.concat( + (knots[-order:-1] - knots[-1], knots, knots[-1] + knots[1:order]) + ) + + # number of param values + nu = len(u) + + # number of basis functions + nb = len(knots) - 1 + + # chunck size + n = order + 1 + + # temp chunck storage + temp = np.zeros(n) + + # initialize the empty matrix + rv = COO( + i=np.empty(n * nu, dtype=np.int64), + j=np.empty(n * nu, dtype=np.int64), + v=np.empty(n * nu), + ) + + # loop over param values + for i in range(nu): + ui = u[i] + + # find the supporting span + # span = np.clip(findSpan(ui, knots), None, nb - 1) + order - 1 + span = nbFindSpan(ui, order, knots, 0, nb) + order - 1 + + # evaluate non-zero functions + nbBasis(span, ui, order, knots_ext, temp) + + # update the matrix + rv.i[i * n : (i + 1) * n] = i + rv.j[i * n : (i + 1) * n] = ( + span - order + np.arange(n) + ) % nb # NB: this is due to peridicity + rv.v[i * n : (i + 1) * n] = temp + + return rv + + +@njit +def findSpan(v, knots): + + return np.searchsorted(knots, v, "right") - 1 + + +@njit +def findSpanLinear(v, knots): + + for rv in range(len(knots)): + if knots[rv] <= v and knots[rv + 1] > v: + return rv + + return -1 + + +@njit +def periodicKnots(degree: int, n_pts: int): + rv = np.arange(0.0, n_pts + degree + 1, 1.0) + rv /= rv[-1] + + return rv diff --git a/conda/meta.yaml b/conda/meta.yaml index e3f6d3a28..7fa50dc50 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -26,6 +26,7 @@ requirements: - multimethod >=1.11,<2.0 - casadi - typish + - numba test: requires: diff --git a/environment.yml b/environment.yml index 6d03a2359..3e6dfefff 100644 --- a/environment.yml +++ b/environment.yml @@ -25,6 +25,7 @@ dependencies: - pathspec - click - appdirs + - numba - pip - pip: - --editable=. diff --git a/mypy.ini b/mypy.ini index 97bbf2b5d..a49c4d4c0 100644 --- a/mypy.ini +++ b/mypy.ini @@ -37,3 +37,6 @@ ignore_missing_imports = True [mypy-casadi.*] ignore_missing_imports = True +[mypy-numba.*] +ignore_missing_imports = True + diff --git a/tests/test_nurbs.py b/tests/test_nurbs.py new file mode 100644 index 000000000..490d42d77 --- /dev/null +++ b/tests/test_nurbs.py @@ -0,0 +1,55 @@ +from cadquery.occ_impl.nurbs import ( + designMatrix, + periodicDesignMatrix, + nbFindSpan, + nbBasis, + nbBasisDer, +) + +import numpy as np +import scipy.sparse as sp + + +def test_periodic_dm(): + + knots = np.linspace(0, 1, 5) + params = np.linspace(0, 1, 100) + order = 3 + + res = periodicDesignMatrix(params, order, knots) + + C = sp.coo_array((res.v, (res.i, res.j))) + + assert C.shape[0] == len(params) + assert C.shape[1] == len(knots) - 1 + + +def test_dm(): + + knots = np.array([0, 0, 0, 0, 0.25, 0.5, 0.75, 1, 1, 1, 1]) + params = np.linspace(0, 1, 100) + order = 3 + + res = designMatrix(params, order, knots) + + C = sp.coo_array((res.v, (res.i, res.j))) + + assert C.shape[0] == len(params) + assert C.shape[1] == len(knots) - order - 1 + + +def test_der(): + + knots = np.array([0, 0, 0, 0, 0.25, 0.5, 0.75, 1, 1, 1, 1]) + params = np.linspace(0, 1, 100) + order = 3 + + out_der = np.zeros((order + 1, order + 1)) + out = np.zeros(order + 1) + + for p in params: + nbBasisDer(nbFindSpan(p, order, knots), p, order, order - 1, knots, out_der) + nbBasis(nbFindSpan(p, order, knots), p, order, knots, out) + + # sanity check + assert np.allclose(out_der[0, :], out) From cd52759083a05c5abb97e1ccc364d9885f048e37 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 27 Jun 2025 17:58:07 +0200 Subject: [PATCH 055/124] Add scipy --- conda/meta.yaml | 1 + environment.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/conda/meta.yaml b/conda/meta.yaml index 7fa50dc50..70ffe6d94 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -27,6 +27,7 @@ requirements: - casadi - typish - numba + - scipy test: requires: diff --git a/environment.yml b/environment.yml index 3e6dfefff..01b92f9ec 100644 --- a/environment.yml +++ b/environment.yml @@ -26,6 +26,7 @@ dependencies: - click - appdirs - numba + - scipy - pip - pip: - --editable=. From 048cef5c1d7becf6c11a07321a8338e11f04e4c0 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 27 Jun 2025 18:36:26 +0200 Subject: [PATCH 056/124] Add derivative matrices --- cadquery/occ_impl/nurbs.py | 104 ++++++++++++++++++++++++++++++++++++- tests/test_nurbs.py | 3 +- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 8c97f5b5c..4d488742c 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -127,6 +127,7 @@ def nbBasis(i: int, u: float, order: int, knots: Array, out: Array): out[j] = saved +@njiti def nbBasisDer(i: int, u: float, order: int, dorder: int, knots: Array, out: Array): """ NURBS book A2.3 @@ -305,7 +306,6 @@ def periodicDesignMatrix(u: Array, order: int, knots: Array) -> COO: ui = u[i] # find the supporting span - # span = np.clip(findSpan(ui, knots), None, nb - 1) + order - 1 span = nbFindSpan(ui, order, knots, 0, nb) + order - 1 # evaluate non-zero functions @@ -321,6 +321,108 @@ def periodicDesignMatrix(u: Array, order: int, knots: Array) -> COO: return rv +@njit +def derMatrix(u: Array, order: int, dorder: int, knots: Array) -> list[COO]: + """ + Create a sparse design matrix and corresponding derivative matrices. + """ + + # number of param values + nu = np.size(u) + + # chunck size + n = order + 1 + + # temp chunck storage + temp = np.zeros((n, n)) + + # initialize the empty matrix + rv = [] + + for _ in range(dorder + 1): + rv.append( + COO( + i=np.empty(n * nu, dtype=np.int64), + j=np.empty(n * nu, dtype=np.int64), + v=np.empty(n * nu), + ) + ) + + # loop over param values + for i in range(nu): + ui = u[i] + + # find the supporting span + span = nbFindSpan(ui, order, knots) + + # evaluate non-zero functions + nbBasisDer(span, ui, order, dorder, knots, temp) + + # update the matrices + for di in range(dorder + 1): + rv[di].i[i * n : (i + 1) * n] = i + rv[di].j[i * n : (i + 1) * n] = span - order + np.arange(n) + rv[di].v[i * n : (i + 1) * n] = temp[di, :] + + return rv + + +@njit +def periodicDerMatrix(u: Array, order: int, dorder: int, knots: Array) -> list[COO]: + """ + Create a sparse periodic design matrix and corresponding derivative matrices. + """ + + # extend the knots + knots_ext = np.concat( + (knots[-order:-1] - knots[-1], knots, knots[-1] + knots[1:order]) + ) + + # number of param values + nu = len(u) + + # number of basis functions + nb = len(knots) - 1 + + # chunck size + n = order + 1 + + # temp chunck storage + temp = np.zeros((n, n)) + + # initialize the empty matrix + rv = [] + + for _ in range(dorder + 1): + rv.append( + COO( + i=np.empty(n * nu, dtype=np.int64), + j=np.empty(n * nu, dtype=np.int64), + v=np.empty(n * nu), + ) + ) + + # loop over param values + for i in range(nu): + ui = u[i] + + # find the supporting span + span = nbFindSpan(ui, order, knots, 0, nb) + order - 1 + + # evaluate non-zero functions + nbBasisDer(span, ui, order, dorder, knots_ext, temp) + + # update the matrices + for di in range(dorder + 1): + rv[di].i[i * n : (i + 1) * n] = i + rv[di].j[i * n : (i + 1) * n] = ( + span - order + np.arange(n) + ) % nb # NB: this is due to peridicity + rv[di].v[i * n : (i + 1) * n] = temp[di, :] + + return rv + + @njit def findSpan(v, knots): diff --git a/tests/test_nurbs.py b/tests/test_nurbs.py index 490d42d77..6854995c1 100644 --- a/tests/test_nurbs.py +++ b/tests/test_nurbs.py @@ -48,7 +48,8 @@ def test_der(): out = np.zeros(order + 1) for p in params: - nbBasisDer(nbFindSpan(p, order, knots), p, order, order - 1, knots, out_der) + nbBasisDer(nbFindSpan(p, order, knots), p, + order, order - 1, knots, out_der) nbBasis(nbFindSpan(p, order, knots), p, order, knots, out) # sanity check From 97fef3f72a83d93e525bb37c97452c07bdca2aee Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:05:12 +0200 Subject: [PATCH 057/124] Black fix --- tests/test_nurbs.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_nurbs.py b/tests/test_nurbs.py index 6854995c1..490d42d77 100644 --- a/tests/test_nurbs.py +++ b/tests/test_nurbs.py @@ -48,8 +48,7 @@ def test_der(): out = np.zeros(order + 1) for p in params: - nbBasisDer(nbFindSpan(p, order, knots), p, - order, order - 1, knots, out_der) + nbBasisDer(nbFindSpan(p, order, knots), p, order, order - 1, knots, out_der) nbBasis(nbFindSpan(p, order, knots), p, order, knots, out) # sanity check From c85ec9cd114e2a7cd2ee9aee397fa56c827ca4ea Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 30 Jun 2025 11:26:57 -0400 Subject: [PATCH 058/124] Removed a stray import that was probably added by AI somewhere along the line. --- cadquery/occ_impl/importers/assembly.py | 1 - 1 file changed, 1 deletion(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index aa75e75a1..e66f96e5d 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -14,7 +14,6 @@ import cadquery as cq from ..assembly import AssemblyProtocol -from tkinter.constants import CURRENT def importStep(assy: AssemblyProtocol, path: str): From 46c84c643ba2276766aa10d80190a69614ad0b2f Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 2 Jul 2025 15:42:07 -0400 Subject: [PATCH 059/124] Implement some of the suggestions --- cadquery/assembly.py | 4 ++-- cadquery/occ_impl/importers/assembly.py | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index a7bc545f1..401bac658 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -34,7 +34,7 @@ exportGLTF, STEPExportModeLiterals, ) -from .occ_impl.importers.assembly import importStep as importStepTopLevel +from .occ_impl.importers.assembly import importStep as _importStep from .selectors import _expression_grammar as _selector_grammar from .utils import deprecate @@ -619,7 +619,7 @@ def importStep(cls, path: str) -> Self: """ assy = cls() - importStepTopLevel(assy, path) + _importStep(assy, path) return assy diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index e66f96e5d..380ff98ce 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -52,11 +52,7 @@ def importStep(assy: AssemblyProtocol, path: str): def _process_simple_shape(label, parent_location=None, parent_name=None): shape = shape_tool.GetShape_s(label) - # Tracks the RGB color value and whether or not it was found - cq_color = None - # Load the name of the part in the assembly, if it is present - name = None if parent_name is not None and parent_name != assy.name: name = parent_name else: @@ -92,6 +88,8 @@ def _process_simple_shape(label, parent_location=None, parent_name=None): current_attr = attr_iterator.Value() # Get the type name of the attribute so that we can decide how to handle it + # TNaming_NamedShape is used to store and manage references to topological shapes, and its attributes can be accessed directly. + # XCAFDoc_GraphNode contains a graph of labels, and so we must follow the branch back to a father. if current_attr.DynamicType().Name() == "TNaming_NamedShape": # Save the shape so that we can add it to the subshape data cur_shape = current_attr.Get() From be7d8db823f625220b8d14767159f605807ae628 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 4 Jul 2025 18:02:02 +0200 Subject: [PATCH 060/124] Curve, Surface, lofting --- cadquery/occ_impl/nurbs.py | 479 ++++++++++++++++++++++++++++++++++++- tests/test_nurbs.py | 158 ++++++++++++ 2 files changed, 635 insertions(+), 2 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 4d488742c..d9f07a92f 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -1,12 +1,28 @@ #%% imports -from numba import njit, prange -from numpy import linspace, array, empty_like, atleast_1d import numpy as np +import scipy.sparse as sp import math from numba import njit as _njit, prange + from typing import NamedTuple, Optional + from numpy.typing import NDArray +from numpy import linspace, array, empty_like, atleast_1d + +from casadi import ldl, ldl_solve + +from OCP.Geom import Geom_BSplineCurve, Geom_BSplineSurface +from OCP.TColgp import TColgp_Array1OfPnt, TColgp_Array2OfPnt +from OCP.TColStd import ( + TColStd_Array1OfInteger, + TColStd_Array1OfReal, + TColStd_Array2OfReal, +) +from OCP.gp import gp_Pnt +from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace + +from .shapes import Face, Edge njit = _njit(cache=False, error_model="numpy", fastmath=True, parallel=False) @@ -15,6 +31,54 @@ ) +#%% internal helpers + + +def _colPtsArray(pts: NDArray): + + rv = TColgp_Array1OfPnt(1, pts.shape[0]) + + for i, p in enumerate(pts): + rv.SetValue(i + 1, gp_Pnt(*p)) + + return rv + + +def _colPtsArray2(pts: NDArray) -> TColStd_Array2OfReal: + + assert pts.ndim == 3 + + nu, nv, _ = pts.shape + + rv = TColgp_Array2OfPnt(1, len(pts), 1, len(pts[0])) + + for i, row in enumerate(pts): + for j, pt in enumerate(row): + rv.SetValue(i + 1, j + 1, gp_Pnt(*pt)) + + return rv + + +def _colRealArray(knots: NDArray): + + rv = TColStd_Array1OfReal(1, len(knots)) + + for i, el in enumerate(knots): + rv.SetValue(i + 1, el) + + return rv + + +def _colIntArray(knots: NDArray): + + rv = TColStd_Array1OfInteger(1, len(knots)) + + for i, el in enumerate(knots): + rv.SetValue(i + 1, el) + + return rv + + #%% vocabulary types Array = NDArray[np.float64] @@ -30,6 +94,119 @@ class COO(NamedTuple): j: ArrayI v: Array + def coo(self): + + return sp.coo_matrix((self.v, (self.i, self.j))) + + def csc(self): + + return self.coo().tocsc() + + def csr(self): + + return self.coo().tocsr() + + +class Curve(NamedTuple): + """ + B-spline curve container. + """ + + pts: Array + knots: Array + order: int + periodic: bool + + def curve(self) -> Geom_BSplineCurve: + + if self.periodic: + mults = _colIntArray(np.ones_like(self.knots, dtype=int)) + knots = _colRealArray(self.knots) + else: + unique_knots, mults_arr = np.unique(self.knots, return_counts=True) + knots = _colRealArray(unique_knots) + mults = _colIntArray(mults_arr) + + return Geom_BSplineCurve( + _colPtsArray(self.pts), knots, mults, self.order, self.periodic, + ) + + def edge(self) -> Edge: + + return Edge(BRepBuilderAPI_MakeEdge(self.curve()).Shape()) + + @classmethod + def fromEdge(cls, e: Edge): + + assert ( + e.geomType() == "BSPLINE" + ), "B-spline geometry required, try converting first." + + g = e._geomAdaptor().BSpline() + + knots = np.array(list(e._geomAdaptor().BSpline().KnotSequence())) + pts = np.array([(p.X(), p.Y(), p.Z()) for p in g.Poles()]) + order = g.Degree() + periodic = g.IsPeriodic() + + return cls(pts, knots, order, periodic) + + def __call__(self, us: NDArray) -> NDArray: + + pass + + def der(self, us: NDArray) -> NDArray: + + pass + + +class Surface(NamedTuple): + """ + B-spline surface container. + """ + + pts: Array + uknots: Array + vknots: Array + uorder: int + vorder: int + uperiodic: bool + vperiodic: bool + + def surface(self) -> Geom_BSplineSurface: + + if self.uperiodic: + umults = _colIntArray(np.ones_like(self.uknots, dtype=int)) + uknots = _colRealArray(self.uknots) + else: + unique_knots, mults_arr = np.unique(self.uknots, return_counts=True) + uknots = _colRealArray(unique_knots) + umults = _colIntArray(mults_arr) + + if self.vperiodic: + vmults = _colIntArray(np.ones_like(self.vknots, dtype=int)) + vknots = _colRealArray(self.vknots) + else: + unique_knots, mults_arr = np.unique(self.vknots, return_counts=True) + vknots = _colRealArray(unique_knots) + vmults = _colIntArray(mults_arr) + + return Geom_BSplineSurface( + _colPtsArray2(self.pts), + uknots, + vknots, + umults, + vmults, + self.uorder, + self.vorder, + self.uperiodic, + self.vperiodic, + ) + + def face(self, tol: float = 1e-3) -> Face: + + return Face(BRepBuilderAPI_MakeFace(self.surface(), tol).Shape()) + #%% basis functions @@ -423,6 +600,304 @@ def periodicDerMatrix(u: Array, order: int, dorder: int, knots: Array) -> list[C return rv +@njit +def periodicDiscretePenalty(us: Array, order: int) -> COO: + + if order not in (1, 2): + raise ValueError( + f"Only 1st and 2nd order penalty is supported, requested order {order}" + ) + + # number of rows + nb = len(us) + + # number of elements per row + ne = order + 1 + + # initialize the penlaty matrix + rv = COO( + i=np.empty(nb * ne, dtype=np.int64), + j=np.empty(nb * ne, dtype=np.int64), + v=np.empty(nb * ne), + ) + + if order == 1: + for ix in range(nb): + rv.i[ne * ix] = ix + rv.j[ne * ix] = (ix - 1) % nb + rv.v[ne * ix] = -0.5 + + rv.i[ne * ix + 1] = ix + rv.j[ne * ix + 1] = (ix + 1) % nb + rv.v[ne * ix + 1] = 0.5 + + elif order == 2: + for ix in range(nb): + rv.i[ne * ix] = ix + rv.j[ne * ix] = (ix - 1) % nb + rv.v[ne * ix] = 1 + + rv.i[ne * ix + 1] = ix + rv.j[ne * ix + 1] = ix + rv.v[ne * ix + 1] = -2 + + rv.i[ne * ix + 2] = ix + rv.j[ne * ix + 2] = (ix + 1) % nb + rv.v[ne * ix + 2] = 1 + + return rv + + +@njit +def discretePenalty(us: Array, order: int, splineorder: int = 3) -> COO: + + if order not in (1, 2): + raise ValueError( + f"Only 1st and 2nd order penalty is supported, requested order {order}" + ) + + # number of rows + nb = len(us) + + # number of elements per row + ne = order + 1 + + # initialize the penlaty matrix + rv = COO( + i=np.empty(nb * ne, dtype=np.int64), + j=np.empty(nb * ne, dtype=np.int64), + v=np.empty(nb * ne), + ) + + if order == 1: + for ix in range(nb): + if ix == 0: + rv.i[ne * ix] = ix + rv.j[ne * ix] = ix + rv.v[ne * ix] = -1 + + rv.i[ne * ix + 1] = ix + rv.j[ne * ix + 1] = ix + 1 + rv.v[ne * ix + 1] = 1 + elif ix < nb - 1: + rv.i[ne * ix] = ix + rv.j[ne * ix] = ix - 1 + rv.v[ne * ix] = -0.5 + + rv.i[ne * ix + 1] = ix + rv.j[ne * ix + 1] = ix + 1 + rv.v[ne * ix + 1] = 0.5 + else: + rv.i[ne * ix] = ix + rv.j[ne * ix] = ix - 1 + rv.v[ne * ix] = -1 + + rv.i[ne * ix + 1] = ix + rv.j[ne * ix + 1] = ix + rv.v[ne * ix + 1] = 1 + + elif order == 2: + for ix in range(nb): + if ix == 0: + rv.i[ne * ix] = ix + rv.j[ne * ix] = ix + rv.v[ne * ix] = 1 + + rv.i[ne * ix + 1] = ix + rv.j[ne * ix + 1] = ix + 1 + rv.v[ne * ix + 1] = -2 + + rv.i[ne * ix + 2] = ix + rv.j[ne * ix + 2] = ix + 2 + rv.v[ne * ix + 2] = 1 + elif ix < nb - 1: + rv.i[ne * ix] = ix + rv.j[ne * ix] = ix - 1 + rv.v[ne * ix] = 1 + + rv.i[ne * ix + 1] = ix + rv.j[ne * ix + 1] = ix + rv.v[ne * ix + 1] = -2 + + rv.i[ne * ix + 2] = ix + rv.j[ne * ix + 2] = ix + 1 + rv.v[ne * ix + 2] = 1 + else: + rv.i[ne * ix] = ix + rv.j[ne * ix] = ix - 2 + rv.v[ne * ix] = 1 + + rv.i[ne * ix + 1] = ix + rv.j[ne * ix + 1] = ix - 1 + rv.v[ne * ix + 1] = -2 + + rv.i[ne * ix + 2] = ix + rv.j[ne * ix + 2] = ix + rv.v[ne * ix + 2] = 1 + + return rv + + +def periodicApproximate( + data: NDArray, + us: Optional[NDArray] = None, + knots: int | NDArray = 50, + order: int = 3, +) -> Curve: + + npts = data.shape[0] + + # parametrize the points + us = linspace(0, 1, npts, endpoint=False) + + # construct the knot vector + if isinstance(knots, int): + knots_ = linspace(0, 1, knots) + else: + knots_ = np.array(knots) + + # construct the design matrix + C = periodicDesignMatrix(us, order, knots_).csc() + CtC = C.T @ C + + # factorize + D, L, P = ldl(CtC, True) + + # invert + pts = ldl_solve(C.T @ data, D, L, P).toarray() + + # convert to an edge + rv = Curve(pts, knots_, order, periodic=True) + + return rv + + +def approximate( + data: NDArray, + us: Optional[NDArray] = None, + knots: int | NDArray = 50, + order: int = 3, + penalty: int = 4, + lam: float = 0, +) -> Curve: + + npts = data.shape[0] + + # parametrize the points + us = linspace(0, 1, npts) + + # construct the knot vector + if isinstance(knots, int): + knots_ = np.concatenate( + (np.repeat(0, order), linspace(0, 1, knots), np.repeat(1, order)) + ) + else: + knots_ = np.array(knots) + + # construct the design matrix + C = designMatrix(us, order, knots_).csc() + CtC = C.T @ C + + # add a penalty term if requested + if lam: + up = linspace(0, 1, order * npts) + + assert penalty <= order + 2 + + # discrete + exact derivatives + if penalty > order: + Pexact = derMatrix(up, order, order - 1, knots_)[-1].csc() + Pdiscrete = discretePenalty(up, penalty - order, order).csc() + + P = Pdiscrete @ Pexact + + # only exact derivatives + else: + P = derMatrix(up, order, penalty, knots_)[-1].csc() + + CtC += lam * P.T @ P + + # clamp first and last point + Cc = C[[0, -1], :] + bc = data[[0, -1], :] + + # final matrix and vector + Aug = sp.bmat([[CtC, Cc.T], [Cc, None]]) + data_aug = np.vstack((C.T @ data, bc)) + + # factorize + D, L, P = ldl(Aug, False) + + # invert + pts = ldl_solve(data_aug, D, L, P).toarray()[:-2, :] + + # convert to an edge + rv = Curve(pts, knots_, order, periodic=False) + + return rv + + +def periodicLoft(*curves: Curve, order: int = 3) -> Curve: + + nknots: int = len(curves) + 1 + + # collect control pts + pts = np.stack([c.pts for c in curves]) + + # approximate + pts_new = [] + + for j in range(pts.shape[1]): + pts_new.append(periodicApproximate(pts[:, j, :], knots=nknots, order=order).pts) + + # construct the final surface + rv = Surface( + np.stack(pts_new).swapaxes(0, 1), + linspace(0, 1, nknots), + curves[0].knots, + order, + curves[0].order, + True, + curves[0].periodic, + ) + + return rv + + +def loft(*curves: Curve, order: int = 3, lam: float = 1e-9, penalty: int = 4): + + nknots: int = len(curves) + + # collect control pts + pts = np.stack([c.pts for c in curves]) + + # approximate + pts_new = [] + + for j in range(pts.shape[1]): + pts_new.append( + approximate( + pts[:, j, :], knots=nknots, order=order, lam=lam, penalty=penalty + ).pts + ) + + # construct the final surface + rv = Surface( + np.stack(pts_new).swapaxes(0, 1), + np.concatenate( + (np.repeat(0, order), linspace(0, 1, nknots), np.repeat(1, order)) + ), + curves[0].knots, + order, + curves[0].order, + False, + curves[0].periodic, + ) + + return rv + + +#%% for removal? @njit def findSpan(v, knots): diff --git a/tests/test_nurbs.py b/tests/test_nurbs.py index 490d42d77..20308831c 100644 --- a/tests/test_nurbs.py +++ b/tests/test_nurbs.py @@ -4,11 +4,50 @@ nbFindSpan, nbBasis, nbBasisDer, + Curve, + Surface, + approximate, + periodicApproximate, + periodicLoft, + loft, ) +from cadquery.func import circle + import numpy as np import scipy.sparse as sp +from pytest import approx, fixture + + +@fixture +def circles() -> list[Curve]: + + # u,v periodic + c1 = circle(1).toSplines() + c2 = circle(5) + + cs = [ + Curve.fromEdge(c1.moved(loc)) + for loc in c2.locations(np.linspace(0, 1, 10, False)) + ] + + return cs + + +@fixture +def trimmed_circles() -> list[Curve]: + + c1 = circle(1).trim(0, 1).toSplines() + c2 = circle(5) + + cs = [ + Curve.fromEdge(c1.moved(loc)) + for loc in c2.locations(np.linspace(0, 1, 10, False)) + ] + + return cs + def test_periodic_dm(): @@ -53,3 +92,122 @@ def test_der(): # sanity check assert np.allclose(out_der[0, :], out) + + +def test_periodic_curve(): + + knots = np.linspace(0, 1, 5) + pts = np.array([[0, 0, 0], [0, 0, 1], [0, 1, 2], [0, 2, 0]]) + + crv = Curve(pts, knots, 3, True) + + # is it indeed periodic? + assert crv.curve().IsPeriodic() + + # convert to an edge + e = crv.edge() + + assert e.isValid() + assert e.ShapeType() == "Edge" + + +def test_curve(): + + knots = np.array([0, 0, 0, 0, 1, 1, 1, 1]) + pts = np.array([[0, 0, 0], [0, 0, 1], [0, 1, 2], [0, 2, 0]]) + + crv = Curve(pts, knots, 3, False) + + # sanity check + assert not crv.curve().IsPeriodic() + + # convert to an edge + e = crv.edge() + + assert e.isValid() + assert e.ShapeType() == "Edge" + + # edge to curve + crv2 = Curve.fromEdge(e) + e2 = crv2.edge() + + assert e2.isValid() + + +def test_surface(): + + uknots = vknots = np.array([0, 0, 1, 1]) + pts = np.array([[[0, 0, 0], [0, 1, 0]], [[1, 0, 0], [1, 1, 0]]]) + + srf = Surface(pts, uknots, vknots, 1, 1, False, False) + + # convert to a face + f = srf.face() + + assert f.isValid() + assert f.Area() == approx(1) + + +def test_approximate(): + + pts_ = circle(1).trim(0, 1).sample(100)[0] + pts = np.array([list(p) for p in pts_]) + + # regular approximate + crv = approximate(pts) + e = crv.edge() + + assert e.isValid() + assert e.Length() == approx(1) + + # approximate with a double penalty + crv = approximate(pts, penalty=4, lam=1e-9) + e = crv.edge() + + assert e.isValid() + assert e.Length() == approx(1) + + # approximate with a single penalty + crv = approximate(pts, penalty=2, lam=1e-9) + e = crv.edge() + + assert e.isValid() + assert e.Length() == approx(1) + + +def test_periodic_approximate(): + + pts_ = circle(1).sample(100)[0] + pts = np.array([list(p) for p in pts_]) + + crv = periodicApproximate(pts) + e = crv.edge() + + assert e.isValid() + assert e.Length() == approx(2 * np.pi) + + +def test_periodic_loft(circles, trimmed_circles): + + # u,v periodic + surf1 = periodicLoft(*circles) + + assert surf1.face().isValid() + + # u periodic + surf2 = periodicLoft(*trimmed_circles) + + assert surf2.face().isValid() + + +def test_loft(circles, trimmed_circles): + + # v periodic + surf1 = loft(*circles) + + assert surf1.face().isValid() + + # non-periodic + surf2 = loft(*trimmed_circles) + + assert surf2.face().isValid() From 4ffe9ff78e589dfc3fc6663442de01d0f9e04420 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 4 Jul 2025 18:23:09 +0200 Subject: [PATCH 061/124] Mypy fixes --- cadquery/occ_impl/nurbs.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index d9f07a92f..5f37e2b49 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -5,7 +5,7 @@ from numba import njit as _njit, prange -from typing import NamedTuple, Optional +from typing import NamedTuple, Optional, Tuple from numpy.typing import NDArray from numpy import linspace, array, empty_like, atleast_1d @@ -34,7 +34,7 @@ #%% internal helpers -def _colPtsArray(pts: NDArray): +def _colPtsArray(pts: NDArray) -> TColgp_Array1OfPnt: rv = TColgp_Array1OfPnt(1, pts.shape[0]) @@ -44,7 +44,7 @@ def _colPtsArray(pts: NDArray): return rv -def _colPtsArray2(pts: NDArray) -> TColStd_Array2OfReal: +def _colPtsArray2(pts: NDArray) -> TColgp_Array2OfPnt: assert pts.ndim == 3 @@ -59,7 +59,7 @@ def _colPtsArray2(pts: NDArray) -> TColStd_Array2OfReal: return rv -def _colRealArray(knots: NDArray): +def _colRealArray(knots: NDArray) -> TColStd_Array1OfReal: rv = TColStd_Array1OfReal(1, len(knots)) @@ -69,7 +69,7 @@ def _colRealArray(knots: NDArray): return rv -def _colIntArray(knots: NDArray): +def _colIntArray(knots: NDArray) -> TColStd_Array1OfInteger: rv = TColStd_Array1OfInteger(1, len(knots)) @@ -153,11 +153,11 @@ def fromEdge(cls, e: Edge): def __call__(self, us: NDArray) -> NDArray: - pass + raise NotImplementedError() def der(self, us: NDArray) -> NDArray: - pass + raise NotImplementedError() class Surface(NamedTuple): @@ -779,6 +779,7 @@ def approximate( order: int = 3, penalty: int = 4, lam: float = 0, + tangents: Optional[Tuple[NDArray, NDArray]] = None, ) -> Curve: npts = data.shape[0] @@ -820,6 +821,16 @@ def approximate( # clamp first and last point Cc = C[[0, -1], :] bc = data[[0, -1], :] + nc = 2 # number of constraints + + # handle tangent constraints if needed + if tangents: + nc += 2 + + Cc2 = derMatrix(us[[0, -1]], order, 1, knots_)[-1].csc() + + Cc = sp.vstack((Cc, Cc2)) + bc = np.vstack((bc, *tangents)) # final matrix and vector Aug = sp.bmat([[CtC, Cc.T], [Cc, None]]) @@ -829,7 +840,7 @@ def approximate( D, L, P = ldl(Aug, False) # invert - pts = ldl_solve(data_aug, D, L, P).toarray()[:-2, :] + pts = ldl_solve(data_aug, D, L, P).toarray()[:-nc, :] # convert to an edge rv = Curve(pts, knots_, order, periodic=False) @@ -837,7 +848,7 @@ def approximate( return rv -def periodicLoft(*curves: Curve, order: int = 3) -> Curve: +def periodicLoft(*curves: Curve, order: int = 3) -> Surface: nknots: int = len(curves) + 1 @@ -864,7 +875,9 @@ def periodicLoft(*curves: Curve, order: int = 3) -> Curve: return rv -def loft(*curves: Curve, order: int = 3, lam: float = 1e-9, penalty: int = 4): +def loft( + *curves: Curve, order: int = 3, lam: float = 1e-9, penalty: int = 4 +) -> Surface: nknots: int = len(curves) From 0ec8ee5d6618cd91f437af264b84179742065710 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 4 Jul 2025 18:33:30 +0200 Subject: [PATCH 062/124] Add tangents to loft --- cadquery/occ_impl/nurbs.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 5f37e2b49..434fb1371 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -5,7 +5,7 @@ from numba import njit as _njit, prange -from typing import NamedTuple, Optional, Tuple +from typing import NamedTuple, Optional, Tuple, List from numpy.typing import NDArray from numpy import linspace, array, empty_like, atleast_1d @@ -876,7 +876,11 @@ def periodicLoft(*curves: Curve, order: int = 3) -> Surface: def loft( - *curves: Curve, order: int = 3, lam: float = 1e-9, penalty: int = 4 + *curves: Curve, + order: int = 3, + lam: float = 1e-9, + penalty: int = 4, + tangents: Optional[List[Tuple[NDArray, NDArray]]] = None, ) -> Surface: nknots: int = len(curves) @@ -890,7 +894,12 @@ def loft( for j in range(pts.shape[1]): pts_new.append( approximate( - pts[:, j, :], knots=nknots, order=order, lam=lam, penalty=penalty + pts[:, j, :], + knots=nknots, + order=order, + lam=lam, + penalty=penalty, + tangents=tangents[j] if tangents else None, ).pts ) From c75d0c89fd8fd56177801a8ba54b1603588f7241 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Fri, 4 Jul 2025 12:45:39 -0400 Subject: [PATCH 063/124] Added a test for nested subassemblies on import --- tests/test_assembly.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 860d79cc2..f8dabfd85 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -1005,7 +1005,7 @@ def test_plain_assembly_import(tmp_path_factory): # Export the assembly, but do not use the meta STEP export method assy.export(plain_step_path) - # # Import the STEP file back in + # Import the STEP file back in imported_assy = cq.Assembly.importStep(plain_step_path) assert imported_assy.name == "top_level" @@ -1082,6 +1082,38 @@ def make_model(name: str, COPY: bool): assert 5 == len(assy_normal.children) +def test_nested_subassembly_step_import(tmp_path_factory): + """ + Tests if the STEP import works correctly with nested subassemblies. + """ + + tmpdir = tmp_path_factory.mktemp("out") + nested_step_path = os.path.join(tmpdir, "plain_assembly_step.step") + + # Create a simple assembly + assy = cq.Assembly() + assy.add(cq.Workplane().box(10, 10, 10), name="box_1") + + # Create a simple subassembly + subassy = cq.Assembly() + subassy.add(cq.Workplane().box(5, 5, 5), name="box_2", loc=cq.Location(10, 10, 10)) + + # Nest the subassembly + assy.add(subassy) + + # Export and then re-import the nested assembly STEP + assy.export(nested_step_path) + imported_assy = cq.Assembly.importStep(nested_step_path) + + # Check the locations + assert imported_assy.children[0].loc.toTuple()[0] == (0.0, 0.0, 0.0) + assert imported_assy.children[1].objects["box_2"].loc.toTuple()[0] == ( + 10.0, + 10.0, + 10.0, + ) + + @pytest.mark.parametrize( "assy_fixture, expected", [ From 0a81707e425dcd19437808b6695350df52ce7bbc Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sat, 5 Jul 2025 18:39:33 +0200 Subject: [PATCH 064/124] Mypy fix --- cadquery/occ_impl/nurbs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 434fb1371..e2ef464e2 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -81,7 +81,7 @@ def _colIntArray(knots: NDArray) -> TColStd_Array1OfInteger: #%% vocabulary types -Array = NDArray[np.float64] +Array = NDArray[np.floating] ArrayI = NDArray[np.int_] From 1b7f1f71e5bf4c4184d557d62df682c5e76f7778 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Thu, 10 Jul 2025 15:21:02 +0200 Subject: [PATCH 065/124] Add overloads --- cadquery/occ_impl/nurbs.py | 200 ++++++++++++++++++++++++++++++++++--- 1 file changed, 185 insertions(+), 15 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index e2ef464e2..f9de5f506 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -1,14 +1,13 @@ #%% imports import numpy as np import scipy.sparse as sp -import math -from numba import njit as _njit, prange +from numba import njit as _njit -from typing import NamedTuple, Optional, Tuple, List +from typing import NamedTuple, Optional, Tuple, List, Union, cast, Type from numpy.typing import NDArray -from numpy import linspace, array, empty_like, atleast_1d +from numpy import linspace, ndarray, dtype from casadi import ldl, ldl_solve @@ -17,13 +16,14 @@ from OCP.TColStd import ( TColStd_Array1OfInteger, TColStd_Array1OfReal, - TColStd_Array2OfReal, ) from OCP.gp import gp_Pnt from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace from .shapes import Face, Edge +from multimethod import multidispatch, parametric + njit = _njit(cache=False, error_model="numpy", fastmath=True, parallel=False) njiti = _njit( @@ -81,8 +81,8 @@ def _colIntArray(knots: NDArray) -> TColStd_Array1OfInteger: #%% vocabulary types -Array = NDArray[np.floating] -ArrayI = NDArray[np.int_] +Array = ndarray # NDArray[np.floating] +ArrayI = ndarray # NDArray[np.int_] class COO(NamedTuple): @@ -738,11 +738,14 @@ def discretePenalty(us: Array, order: int, splineorder: int = 3) -> COO: return rv +@multidispatch def periodicApproximate( - data: NDArray, - us: Optional[NDArray] = None, - knots: int | NDArray = 50, + data: Array, + us: Optional[Array] = None, + knots: int | Array = 50, order: int = 3, + penalty: int = 4, + lam: float = 0, ) -> Curve: npts = data.shape[0] @@ -760,6 +763,25 @@ def periodicApproximate( C = periodicDesignMatrix(us, order, knots_).csc() CtC = C.T @ C + # add the penalty if requested + if lam: + up = linspace(0, 1, order * npts, endpoint=False) + + assert penalty <= order + 2 + + # discrete + exact derivatives + if penalty > order: + Pexact = periodicDerMatrix(up, order, order - 1, knots_)[-1].csc() + Pdiscrete = periodicDiscretePenalty(up, penalty - order).csc() + + P = Pdiscrete @ Pexact + + # only exact derivatives + else: + P = periodicDerMatrix(up, order, penalty, knots_)[-1].csc() + + CtC += lam * P.T @ P + # factorize D, L, P = ldl(CtC, True) @@ -772,14 +794,74 @@ def periodicApproximate( return rv +@periodicApproximate.register +def _( + data: List[Array], + us: Optional[Array] = None, + knots: int | Array = 50, + order: int = 3, + penalty: int = 4, + lam: float = 0, +) -> List[Curve]: + + rv = [] + + npts = data[0].shape[0] + + # parametrize the points + us = linspace(0, 1, npts, endpoint=False) + + # construct the knot vector + if isinstance(knots, int): + knots_ = linspace(0, 1, knots) + else: + knots_ = np.array(knots) + + # construct the design matrix + C = periodicDesignMatrix(us, order, knots_).csc() + CtC = C.T @ C + + # add the penalty if requested + if lam: + up = linspace(0, 1, order * npts, endpoint=False) + + assert penalty <= order + 2 + + # discrete + exact derivatives + if penalty > order: + Pexact = periodicDerMatrix(up, order, order - 1, knots_)[-1].csc() + Pdiscrete = periodicDiscretePenalty(up, penalty - order).csc() + + P = Pdiscrete @ Pexact + + # only exact derivatives + else: + P = periodicDerMatrix(up, order, penalty, knots_)[-1].csc() + + CtC += lam * P.T @ P + + # factorize + D, L, P = ldl(CtC, True) + + # invert every dataset + for dataset in data: + pts = ldl_solve(C.T @ dataset, D, L, P).toarray() + + # convert to an edge and store + rv.append(Curve(pts, knots_, order, periodic=True)) + + return rv + + +@multidispatch def approximate( - data: NDArray, - us: Optional[NDArray] = None, - knots: int | NDArray = 50, + data: Array, + us: Optional[Array] = None, + knots: int | Array = 50, order: int = 3, penalty: int = 4, lam: float = 0, - tangents: Optional[Tuple[NDArray, NDArray]] = None, + tangents: Optional[Tuple[Array, Array]] = None, ) -> Curve: npts = data.shape[0] @@ -848,6 +930,94 @@ def approximate( return rv +@approximate.register +def _( + data: List[Array], + us: Optional[Array] = None, + knots: int | Array = 50, + order: int = 3, + penalty: int = 4, + lam: float = 0, + tangents: Optional[Union[Tuple[Array, Array], List[Tuple[Array, Array]]]] = None, +) -> List[Curve]: + + rv = [] + + npts = data[0].shape[0] + + # parametrize the points + us = linspace(0, 1, npts) + + # construct the knot vector + if isinstance(knots, int): + knots_ = np.concatenate( + (np.repeat(0, order), linspace(0, 1, knots), np.repeat(1, order)) + ) + else: + knots_ = np.array(knots) + + # construct the design matrix + C = designMatrix(us, order, knots_).csc() + CtC = C.T @ C + + # add a penalty term if requested + if lam: + up = linspace(0, 1, order * npts) + + assert penalty <= order + 2 + + # discrete + exact derivatives + if penalty > order: + Pexact = derMatrix(up, order, order - 1, knots_)[-1].csc() + Pdiscrete = discretePenalty(up, penalty - order, order).csc() + + P = Pdiscrete @ Pexact + + # only exact derivatives + else: + P = derMatrix(up, order, penalty, knots_)[-1].csc() + + CtC += lam * P.T @ P + + # clamp first and last point + Cc = C[[0, -1], :] + + nc = 2 # number of constraints + + # handle tangent constraints if needed + if tangents: + nc += 2 + Cc2 = derMatrix(us[[0, -1]], order, 1, knots_)[-1].csc() + Cc = sp.vstack((Cc, Cc2)) + + # final matrix and vector + Aug = sp.bmat([[CtC, Cc.T], [Cc, None]]) + + # factorize + D, L, P = ldl(Aug, False) + + # invert all datasets + for ix, dataset in enumerate(data): + bc = dataset[[0, -1], :] # first and last point for clamping + + if tangents: + if len(tangents) == len(data): + bc = np.vstack((bc, *tangents[ix])) + else: + bc = np.vstack((bc, *tangents)) + + # construct the LHS of the linear system + dataset_aug = np.vstack((C.T @ dataset, bc)) + + # actual solver + pts = ldl_solve(dataset_aug, D, L, P).toarray()[:-nc, :] + + # convert to an edge + rv.append(Curve(pts, knots_, order, periodic=False)) + + return rv + + def periodicLoft(*curves: Curve, order: int = 3) -> Surface: nknots: int = len(curves) + 1 @@ -880,7 +1050,7 @@ def loft( order: int = 3, lam: float = 1e-9, penalty: int = 4, - tangents: Optional[List[Tuple[NDArray, NDArray]]] = None, + tangents: Optional[List[Tuple[Array, Array]]] = None, ) -> Surface: nknots: int = len(curves) From 17e18c57a504c8eff042843e2d09a00ef4b933c8 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Thu, 10 Jul 2025 16:24:50 +0200 Subject: [PATCH 066/124] Faster periodic loft --- cadquery/occ_impl/nurbs.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index f9de5f506..e8c7fb47b 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -1023,13 +1023,10 @@ def periodicLoft(*curves: Curve, order: int = 3) -> Surface: nknots: int = len(curves) + 1 # collect control pts - pts = np.stack([c.pts for c in curves]) + pts = [el for el in np.stack([c.pts for c in curves]).swapaxes(0, 1)] # approximate - pts_new = [] - - for j in range(pts.shape[1]): - pts_new.append(periodicApproximate(pts[:, j, :], knots=nknots, order=order).pts) + pts_new = [el.pts for el in periodicApproximate(pts, knots=nknots, order=order)] # construct the final surface rv = Surface( From 6b82f65a5dc9bad2016b14b2430ab31fb73a9b51 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:46:45 +0200 Subject: [PATCH 067/124] Mypy fix --- cadquery/occ_impl/nurbs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index e8c7fb47b..9fd22334b 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -795,7 +795,7 @@ def periodicApproximate( @periodicApproximate.register -def _( +def periodicApproximate( data: List[Array], us: Optional[Array] = None, knots: int | Array = 50, @@ -931,7 +931,7 @@ def approximate( @approximate.register -def _( +def approximate( data: List[Array], us: Optional[Array] = None, knots: int | Array = 50, From 553078762112c6a6650670983549d4323a9d5f24 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Tue, 15 Jul 2025 16:12:29 +0200 Subject: [PATCH 068/124] Start with evaluation --- cadquery/occ_impl/nurbs.py | 199 ++++++++++++++++++++++++++++++++++++- 1 file changed, 194 insertions(+), 5 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 9fd22334b..25f04cd77 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -211,6 +211,28 @@ def face(self, tol: float = 1e-3) -> Face: #%% basis functions +@njiti +def extendKnots(order: int, knots: Array) -> Array: + """ + Knot vector extension for periodic b-splines. + + Parameters + ---------- + order : int + B-spline order. + knots : Array + Knot vector. + + Returns + ------- + knots_ext : Array + Extended knots vector. + + """ + + return np.concat((knots[-order:-1] - knots[-1], knots, knots[-1] + knots[1:order])) + + @njiti def nbFindSpan( u: float, @@ -229,7 +251,7 @@ def nbFindSpan( order : int Spline order. knots : ndarray - Knot vectr. + Knot vector. Returns ------- @@ -275,7 +297,7 @@ def nbBasis(i: int, u: float, order: int, knots: Array, out: Array): order : int B-spline order. knots : ndarray - Knot vectr. + Knot vector. out : ndarray B-spline basis function values. @@ -320,7 +342,7 @@ def nbBasisDer(i: int, u: float, order: int, dorder: int, knots: Array, out: Arr dorder : int Derivative order. knots : ndarray - Knot vectr. + Knot vector. out : ndarray B-spline basis function and derivative values. @@ -408,6 +430,170 @@ def nbBasisDer(i: int, u: float, order: int, dorder: int, knots: Array, out: Arr r *= order - k +#%% evaluation + + +@njit +def nbCurve( + u: Array, order: int, knots: Array, pts: Array, periodic: bool = False +) -> Array: + """ + NURBS book A3.1 with modifications to handle periodicity. + + Parameters + ---------- + u : Array + Parameter values. + order : int + B-spline order. + knots : Array + Knot vector. + pts : Array + Control points. + periodic : bool, optional + Peridocity flag. The default is False. + + Returns + ------- + Array + Curve values. + + """ + + # number of control points + nb = pts.shape[0] + + # handle periodicity + if periodic: + period = knots[-1] - knots[0] + u_ = u % period + knots_ext = extendKnots(order, knots) + minspan = 0 + maxspan = len(knots) - 1 + deltaspan = order - 1 + else: + u_ = u + knots_ext = knots + minspan = None + maxspan = None + deltaspan = 0 + + # number of param values + nu = np.size(u) + + # chunck size + n = order + 1 + + # temp chunck storage + temp = np.zeros(n) + + # initialize + out = np.zeros((nu, 3)) + + for i in range(nu): + ui = u_[i] + + # find span + span = nbFindSpan(ui, order, knots, minspan, maxspan) + deltaspan + + # evaluate chunk + nbBasis(span, ui, order, knots_ext, temp) + + # multiply by ctrl points + for j in range(order + 1): + out[i, :] += temp[j] * pts[(span - order + j) % nb, :] + + return out + + +def nbCurveDer( + u: Array, order: int, dorder: int, knots: Array, pts: Array, periodic: bool = False +) -> Array: + """ + NURBS book A3.2 with modifications to handle periodicity. + + Parameters + ---------- + u : Array + Parameter values. + order : int + B-spline order. + dorder : int + Derivative order. + knots : Array + Knot vector. + pts : Array + Control points. + periodic : bool, optional + Peridocity flag. The default is False. + + + Returns + ------- + Array + Curve values and derivatives. + + """ + # number of control points + nb = pts.shape[0] + + # handle periodicity + if periodic: + period = knots[-1] - knots[0] + u_ = u % period + knots_ext = extendKnots(order, knots) + minspan = 0 + maxspan = len(knots) - 1 + deltaspan = order - 1 + else: + u_ = u + knots_ext = knots + minspan = None + maxspan = None + deltaspan = 0 + + # number of param values + nu = np.size(u) + + # chunck size + n = order + 1 + + # temp chunck storage + temp = np.zeros((dorder + 1, n)) + + # initialize + out = np.zeros((nu, dorder + 1, 3)) + + for i in range(nu): + ui = u_[i] + + # find span + span = nbFindSpan(ui, order, knots, minspan, maxspan) + deltaspan + + # evaluate chunk + nbBasisDer(span, ui, order, dorder, knots_ext, temp) + + # multiply by ctrl points + for j in range(order + 1): + for k in range(dorder + 1): + out[i, k, :] += temp[k, j] * pts[(span - order + j) % nb, :] + + return out + + +def nbSurface(): + + pass + + +def nbSurfaceDer(): + + pass + + +#%% matrices + + @njit def designMatrix(u: Array, order: int, knots: Array) -> COO: """ @@ -511,7 +697,7 @@ def derMatrix(u: Array, order: int, dorder: int, knots: Array) -> list[COO]: n = order + 1 # temp chunck storage - temp = np.zeros((n, n)) + temp = np.zeros((dorder + 1, n)) # initialize the empty matrix rv = [] @@ -565,7 +751,7 @@ def periodicDerMatrix(u: Array, order: int, dorder: int, knots: Array) -> list[C n = order + 1 # temp chunck storage - temp = np.zeros((n, n)) + temp = np.zeros((dorder + 1, n)) # initialize the empty matrix rv = [] @@ -738,6 +924,9 @@ def discretePenalty(us: Array, order: int, splineorder: int = 3) -> COO: return rv +#%% construction + + @multidispatch def periodicApproximate( data: Array, From e96a10fe8473e16ffb6eade8e7d9ea0d30cd111a Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Tue, 15 Jul 2025 16:23:00 +0200 Subject: [PATCH 069/124] Add decorator --- cadquery/occ_impl/nurbs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 25f04cd77..756f78611 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -506,6 +506,7 @@ def nbCurve( return out +@njit def nbCurveDer( u: Array, order: int, dorder: int, knots: Array, pts: Array, periodic: bool = False ) -> Array: From 4f96f6be349b5b831fb914f9f6f5e9fd8b74af59 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:31:47 +0200 Subject: [PATCH 070/124] Add params to nbSurface --- cadquery/occ_impl/nurbs.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 756f78611..8a17bb7a4 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -582,7 +582,17 @@ def nbCurveDer( return out -def nbSurface(): +def nbSurface( + u: Array, + v: Array, + uorder: int, + vorder: int, + uknots: Array, + vknots: Array, + pts: Array, + uperiodic: bool = False, + vperiodic: bool = False, +): pass From 9f98643e682c4b4199065e31585be795554f061d Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Wed, 16 Jul 2025 23:47:49 +0200 Subject: [PATCH 071/124] Start with surface evaluation --- cadquery/occ_impl/nurbs.py | 204 ++++++++++++++++++++++++++++++++++--- 1 file changed, 190 insertions(+), 14 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 8a17bb7a4..6c9c3f97f 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -4,10 +4,10 @@ from numba import njit as _njit -from typing import NamedTuple, Optional, Tuple, List, Union, cast, Type +from typing import NamedTuple, Optional, Tuple, List, Union, cast from numpy.typing import NDArray -from numpy import linspace, ndarray, dtype +from numpy import linspace, ndarray from casadi import ldl, ldl_solve @@ -19,12 +19,13 @@ ) from OCP.gp import gp_Pnt from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace +from OCP.Geom import Geom_BSplineSurface from .shapes import Face, Edge -from multimethod import multidispatch, parametric +from multimethod import multidispatch -njit = _njit(cache=False, error_model="numpy", fastmath=True, parallel=False) +njit = _njit(cache=True, error_model="numpy", fastmath=True, nogil=True, parallel=False) njiti = _njit( cache=True, inline="always", error_model="numpy", fastmath=True, parallel=False @@ -151,13 +152,13 @@ def fromEdge(cls, e: Edge): return cls(pts, knots, order, periodic) - def __call__(self, us: NDArray) -> NDArray: + def __call__(self, us: Array) -> Array: - raise NotImplementedError() + return nbCurve(np.atleast_1d(us), self.order, self.knots, self.pts) - def der(self, us: NDArray) -> NDArray: + def der(self, us: NDArray, dorder: int) -> NDArray: - raise NotImplementedError() + return nbCurveDer(np.atleast_1d(us), self.order, self.knots, self.pts) class Surface(NamedTuple): @@ -207,6 +208,64 @@ def face(self, tol: float = 1e-3) -> Face: return Face(BRepBuilderAPI_MakeFace(self.surface(), tol).Shape()) + @classmethod + def fromFace(cls, f: Face): + """ + Construct a surface from a face. + """ + + assert ( + f.geomType() == "BSPLINE" + ), "B-spline geometry required, try converting first." + + g = cast(Geom_BSplineSurface, f._geomAdaptor()) + + uknots = np.array(list(g.UKnotSequence())) + vknots = np.array(list(g.VKnotSequence())) + + tmp = [] + for i in range(1, g.NbUPoles() + 1): + tmp.append( + [ + [g.Pole(i, j).X(), g.Pole(i, j).Y(), g.Pole(i, j).Z(),] + for j in range(1, g.NbVPoles() + 1) + ] + ) + + pts = np.array(tmp) + + uorder = g.UDegree() + vorder = g.VDegree() + + uperiodic = g.IsUPeriodic() + vperiodic = g.IsVPeriodic() + + return cls(pts, uknots, vknots, uorder, vorder, uperiodic, vperiodic) + + def __call__(self, u: Array, v: Array) -> Array: + """ + Evaluate surface at (u,v) points. + """ + + return nbSurface( + np.atleast_1d(u), + np.atleast_1d(v), + self.uorder, + self.vorder, + self.uknots, + self.vknots, + self.pts, + self.uperiodic, + self.vperiodic, + ) + + def der(self, u: Array, v: Array, dorder: int) -> Array: + """ + Evaluate surface and derivatives at (u,v) points. + """ + + raise NotImplementedError + #%% basis functions @@ -451,7 +510,7 @@ def nbCurve( pts : Array Control points. periodic : bool, optional - Peridocity flag. The default is False. + Periodicity flag. The default is False. Returns ------- @@ -526,7 +585,7 @@ def nbCurveDer( pts : Array Control points. periodic : bool, optional - Peridocity flag. The default is False. + Periodicity flag. The default is False. Returns @@ -582,6 +641,7 @@ def nbCurveDer( return out +@njit def nbSurface( u: Array, v: Array, @@ -592,14 +652,130 @@ def nbSurface( pts: Array, uperiodic: bool = False, vperiodic: bool = False, -): +) -> Array: + """ + NURBS book A3.5 with modifications to handle periodicity. + + Parameters + ---------- + u : Array + U parameter values. + v : Array + V parameter values. + uorder : int + B-spline u order. + vorder : int + B-spline v order. + uknots : Array + U knot vector.. + vknots : Array + V knot vector.. + pts : Array + Control points. + uperiodic : bool, optional + U periodicity flag. The default is False. + vperiodic : bool, optional + V periodicity flag. The default is False. + + Returns + ------- + Array + Surface values. + + """ + + # number of control points + nub = pts.shape[0] + nvb = pts.shape[1] + + # handle periodicity + if uperiodic: + uperiod = uknots[-1] - uknots[0] + u_ = u % uperiod + uknots_ext = extendKnots(uorder, uknots) + minspanu = 0 + maxspanu = len(uknots) - 1 + deltaspanu = uorder - 1 + else: + u_ = u + uknots_ext = uknots + minspanu = None + maxspanu = None + deltaspanu = 0 + + if vperiodic: + vperiod = vknots[-1] - vknots[0] + v_ = v % vperiod + vknots_ext = extendKnots(vorder, vknots) + minspanv = 0 + maxspanv = len(vknots) - 1 + deltaspanv = vorder - 1 + else: + v_ = v + vknots_ext = vknots + minspanv = None + maxspanv = None + deltaspanv = 0 + + # number of param values + nu = np.size(u) + + # chunck sizes + un = uorder + 1 + vn = vorder + 1 + + # temp chunck storage + utemp = np.zeros(un) + vtemp = np.zeros(vn) + + # initialize + out = np.zeros((nu, 3)) + + for i in range(nu): + ui = u_[i] + vi = v_[i] + + # find span + uspan = nbFindSpan(ui, uorder, uknots, minspanu, maxspanu) + deltaspanu + vspan = nbFindSpan(vi, vorder, vknots, minspanv, maxspanv) + deltaspanv + + # evaluate chunk + nbBasis(uspan, ui, uorder, uknots_ext, utemp) + nbBasis(vspan, vi, vorder, vknots_ext, vtemp) + + uind = uspan - uorder + temp = np.empty(3) + + # multiply by ctrl points: Nu.T*P*Nv + for j in range(vorder + 1): - pass + temp[:] = 0.0 + vind = vspan - vorder + j + # calculate Nu.T*P + for k in range(uorder + 1): + temp += utemp[k] * pts[(uind + k) % nub, vind % nvb, :] -def nbSurfaceDer(): + # multiple by Nv + out[i, :] += vtemp[j] * temp + + return out + + +def nbSurfaceDer( + u: Array, + v: Array, + uorder: int, + vorder: int, + dorder: int, + uknots: Array, + vknots: Array, + pts: Array, + uperiodic: bool = False, + vperiodic: bool = False, +) -> Array: - pass + raise NotImplementedError #%% matrices From a9ce0c70a521b586326b347b8af699c4027d57c9 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Thu, 17 Jul 2025 13:01:13 +0200 Subject: [PATCH 072/124] Add surface derivatives --- cadquery/occ_impl/nurbs.py | 140 +++++++++++++++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 4 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 6c9c3f97f..f2389e845 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -154,11 +154,15 @@ def fromEdge(cls, e: Edge): def __call__(self, us: Array) -> Array: - return nbCurve(np.atleast_1d(us), self.order, self.knots, self.pts) + return nbCurve( + np.atleast_1d(us), self.order, self.knots, self.pts, self.periodic + ) def der(self, us: NDArray, dorder: int) -> NDArray: - return nbCurveDer(np.atleast_1d(us), self.order, self.knots, self.pts) + return nbCurveDer( + np.atleast_1d(us), self.order, self.knots, self.pts, self.periodic + ) class Surface(NamedTuple): @@ -264,7 +268,18 @@ def der(self, u: Array, v: Array, dorder: int) -> Array: Evaluate surface and derivatives at (u,v) points. """ - raise NotImplementedError + return nbSurfaceDer( + np.atleast_1d(u), + np.atleast_1d(v), + self.uorder, + self.vorder, + dorder, + self.uknots, + self.vknots, + self.pts, + self.uperiodic, + self.vperiodic, + ) #%% basis functions @@ -762,6 +777,7 @@ def nbSurface( return out +@njit def nbSurfaceDer( u: Array, v: Array, @@ -774,8 +790,124 @@ def nbSurfaceDer( uperiodic: bool = False, vperiodic: bool = False, ) -> Array: + """ + NURBS book A3.6 with modifications to handle periodicity. + + Parameters + ---------- + u : Array + U parameter values. + v : Array + V parameter values. + uorder : int + B-spline u order. + vorder : int + B-spline v order. + dorder : int + Maximum derivative order. + uknots : Array + U knot vector.. + vknots : Array + V knot vector.. + pts : Array + Control points. + uperiodic : bool, optional + U periodicity flag. The default is False. + vperiodic : bool, optional + V periodicity flag. The default is False. + + Returns + ------- + Array + Surface and derivative values. + + """ + + # max derivative orders + du = min(dorder, uorder) + dv = min(dorder, vorder) + + # number of control points + nub = pts.shape[0] + nvb = pts.shape[1] + + # handle periodicity + if uperiodic: + uperiod = uknots[-1] - uknots[0] + u_ = u % uperiod + uknots_ext = extendKnots(uorder, uknots) + minspanu = 0 + maxspanu = len(uknots) - 1 + deltaspanu = uorder - 1 + else: + u_ = u + uknots_ext = uknots + minspanu = None + maxspanu = None + deltaspanu = 0 + + if vperiodic: + vperiod = vknots[-1] - vknots[0] + v_ = v % vperiod + vknots_ext = extendKnots(vorder, vknots) + minspanv = 0 + maxspanv = len(vknots) - 1 + deltaspanv = vorder - 1 + else: + v_ = v + vknots_ext = vknots + minspanv = None + maxspanv = None + deltaspanv = 0 + + # number of param values + nu = np.size(u) + + # chunck sizes + un = uorder + 1 + vn = vorder + 1 + + # temp chunck storage + + utemp = np.zeros((du + 1, un)) + vtemp = np.zeros((dv + 1, vn)) + + # initialize + out = np.zeros((nu, du + 1, dv + 1, 3)) + + for i in range(nu): + ui = u_[i] + vi = v_[i] + + # find span + uspan = nbFindSpan(ui, uorder, uknots, minspanu, maxspanu) + deltaspanu + vspan = nbFindSpan(vi, vorder, vknots, minspanv, maxspanv) + deltaspanv + + # evaluate chunk + nbBasisDer(uspan, ui, uorder, du, uknots_ext, utemp) + nbBasisDer(vspan, vi, vorder, dv, vknots_ext, vtemp) - raise NotImplementedError + for k in range(du + 1): + + temp = np.zeros((vorder + 1, 3)) + + # Nu.T^(k)*pts + for s in range(vorder + 1): + for r in range(uorder + 1): + temp[s, :] += ( + utemp[k, r] + * pts[(uspan - uorder + r) % nub, (vspan - vorder + s) % nvb, :] + ) + + # ramaining derivative orders: dk + du <= dorder + dd = min(dorder - k, dv) + + # .. * Nv^(l) + for l in range(dd + 1): + for s in range(vorder + 1): + out[i, k, l, :] += vtemp[l, s] * temp[s, :] + + return out #%% matrices From a3ca1e298ba2109c8438c85994dfa1bf6e291557 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Thu, 17 Jul 2025 16:11:22 +0200 Subject: [PATCH 073/124] Roundtrip fixes --- cadquery/occ_impl/nurbs.py | 9 +++++++++ tests/test_nurbs.py | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index f2389e845..f6b36c46b 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -150,6 +150,9 @@ def fromEdge(cls, e: Edge): order = g.Degree() periodic = g.IsPeriodic() + if periodic: + knots = knots[order:-order] + return cls(pts, knots, order, periodic) def __call__(self, us: Array) -> Array: @@ -244,6 +247,12 @@ def fromFace(cls, f: Face): uperiodic = g.IsUPeriodic() vperiodic = g.IsVPeriodic() + if uperiodic: + uknots = uknots[uorder:-uorder] + + if vperiodic: + vknots = vknots[vorder:-vorder] + return cls(pts, uknots, vknots, uorder, vorder, uperiodic, vperiodic) def __call__(self, u: Array, v: Array) -> Array: diff --git a/tests/test_nurbs.py b/tests/test_nurbs.py index 20308831c..2ccab0c13 100644 --- a/tests/test_nurbs.py +++ b/tests/test_nurbs.py @@ -133,6 +133,12 @@ def test_curve(): assert e2.isValid() + # check roundtrip + crv3 = Curve.fromEdge(e2) + + assert np.allclose(crv2.knots, crv3.knots) + assert np.allclose(crv2.pts, crv3.pts) + def test_surface(): @@ -147,6 +153,13 @@ def test_surface(): assert f.isValid() assert f.Area() == approx(1) + # roundtrip + srf2 = Surface.fromFace(f) + + assert np.allclose(srf.uknots, srf2.uknots) + assert np.allclose(srf.vknots, srf2.vknots) + assert np.allclose(srf.pts, srf2.pts) + def test_approximate(): From b12d67789fc6f1e88c33861d009876b65fc43dba Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 18 Jul 2025 09:04:36 +0200 Subject: [PATCH 074/124] Rough version of reparametrize --- cadquery/occ_impl/nurbs.py | 41 +++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index f6b36c46b..94360c7a6 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -164,7 +164,7 @@ def __call__(self, us: Array) -> Array: def der(self, us: NDArray, dorder: int) -> NDArray: return nbCurveDer( - np.atleast_1d(us), self.order, self.knots, self.pts, self.periodic + np.atleast_1d(us), self.order, dorder, self.knots, self.pts, self.periodic ) @@ -1603,6 +1603,45 @@ def loft( return rv +def reparametrize( + *curves: Curve, n: int = 100, w1: float = 1, w2: float = 1e-2 +) -> List[Curve]: + + from scipy.optimize import fmin_l_bfgs_b + + n_curves = len(curves) + + u0 = np.tile(np.linspace(0, 1, n, False), n_curves) + + def cost(u: Array) -> float: + + rv1 = 0 + us = np.split(u, n_curves) + + pts = [] + + for i, ui in enumerate(us): + pts.append(curves[i](ui)) + + # parametric distance between points on the same curve + rv1 += np.sum((ui[:-1] - ui[1:]) ** 2) + np.sum((u[0] + 1 - u[-1]) ** 2) + + rv2 = 0 + + for p1, p2 in zip(pts, pts[1:]): + + # geometric distance between points on adjecent curves + rv2 += np.sum((p1 - p2) ** 2) + + return w1 * rv1 + w2 * rv2 + + usol, _, _ = fmin_l_bfgs_b(cost, u0, approx_grad=True) + + us = np.split(usol, n_curves) + + return periodicApproximate([crv(u) for crv, u in zip(curves, us)], lam=1e-3) + + #%% for removal? @njit def findSpan(v, knots): From 03716b792d7cc332ba110137a7f522b73cc6a9fc Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 25 Jul 2025 10:29:09 +0200 Subject: [PATCH 075/124] Adding 2D design matrix --- cadquery/occ_impl/nurbs.py | 96 ++++++++++++++++++++++++++++++++------ tests/test_nurbs.py | 23 ++++++++- 2 files changed, 104 insertions(+), 15 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 94360c7a6..566bb6b26 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -294,6 +294,33 @@ def der(self, u: Array, v: Array, dorder: int) -> Array: #%% basis functions +@njiti +def _preprocess( + u: Array, order: int, knots: Array, periodic: float +) -> Tuple[Array, Array, Optional[int], Optional[int], int]: + """ + Helper for handling peridocity. This function extends the knot vector, + wraps the parameters and calculates the delta span. + """ + + # handle periodicity + if periodic: + period = knots[-1] - knots[0] + u_ = u % period + knots_ext = extendKnots(order, knots) + minspan = 0 + maxspan = len(knots) - 1 + deltaspan = order - 1 + else: + u_ = u + knots_ext = knots + minspan = None + maxspan = None + deltaspan = 0 + + return u_, knots_ext, minspan, maxspan, deltaspan + + @njiti def extendKnots(order: int, knots: Array) -> Array: """ @@ -546,20 +573,7 @@ def nbCurve( # number of control points nb = pts.shape[0] - # handle periodicity - if periodic: - period = knots[-1] - knots[0] - u_ = u % period - knots_ext = extendKnots(order, knots) - minspan = 0 - maxspan = len(knots) - 1 - deltaspan = order - 1 - else: - u_ = u - knots_ext = knots - minspan = None - maxspan = None - deltaspan = 0 + u_, knots_ext, minspan, maxspan, deltaspan = _preprocess(u, order, knots, periodic) # number of param values nu = np.size(u) @@ -962,6 +976,60 @@ def designMatrix(u: Array, order: int, knots: Array) -> COO: return rv +# @njit +def designMatrix2D( + uv: Array, uorder: int, vorder: int, uknots: Array, vknots: Array +) -> COO: + """ + Create a sparse tensor product design matrix. + """ + + # number of param values + ni = uv.shape[0] + + # chunck size + nu = uorder + 1 + nv = vorder + 1 + nj = nu * nv + + # number of basis + nu_total = len(uknots) - uorder - 1 + nv_total = len(vknots) - vorder - 1 + + # temp chunck storage + utemp = np.zeros(nu) + vtemp = np.zeros(nv) + + # initialize the empty matrix + rv = COO( + i=np.empty(ni * nj, dtype=np.int64), + j=np.empty(ni * nj, dtype=np.int64), + v=np.empty(ni * nj), + ) + + # loop over param values + for i in range(ni): + ui, vi = uv[i, :] + + # find the supporting span + uspan = nbFindSpan(ui, uorder, uknots) + vspan = nbFindSpan(vi, vorder, vknots) + + # evaluate non-zero functions + nbBasis(uspan, ui, uorder, uknots, utemp) + nbBasis(vspan, vi, vorder, vknots, vtemp) + + # update the matrix + rv.i[i * nj : (i + 1) * nj] = i + rv.j[i * nj : (i + 1) * nj] = ( + (uspan - uorder + np.arange(nu)) * nv_total + + (vspan - vorder + np.arange(nv))[:, np.newaxis] + ).ravel() + rv.v[i * nj : (i + 1) * nj] = (utemp * vtemp[:, np.newaxis]).ravel() + + return rv + + @njit def periodicDesignMatrix(u: Array, order: int, knots: Array) -> COO: """ diff --git a/tests/test_nurbs.py b/tests/test_nurbs.py index 2ccab0c13..31b561abd 100644 --- a/tests/test_nurbs.py +++ b/tests/test_nurbs.py @@ -1,6 +1,7 @@ from cadquery.occ_impl.nurbs import ( designMatrix, periodicDesignMatrix, + designMatrix2D, nbFindSpan, nbBasis, nbBasisDer, @@ -17,7 +18,7 @@ import numpy as np import scipy.sparse as sp -from pytest import approx, fixture +from pytest import approx, fixture, mark @fixture @@ -63,6 +64,26 @@ def test_periodic_dm(): assert C.shape[1] == len(knots) - 1 +def test_dm_2d(): + + uknots = np.array([0, 0, 0, 0, 0.25, 0.5, 0.75, 1, 1, 1, 1]) + uparams = np.linspace(0, 1, 100) + uorder = 3 + + vknots = np.array([0, 0, 0, 0.5, 1, 1, 1]) + vparams = np.linspace(0, 1, 100) + vorder = 2 + + params = np.column_stack((uparams, vparams)) + + res = designMatrix2D(params, uorder, vorder, uknots, vknots) + + C = res.coo() + + assert C.shape[0] == len(uparams) + assert C.shape[1] == (len(uknots) - uorder - 1) * (len(vknots) - vorder - 1) + + def test_dm(): knots = np.array([0, 0, 0, 0, 0.25, 0.5, 0.75, 1, 1, 1, 1]) From 0db4c093e52e7c4d2c540a57fd234606b81a67ae Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:04:47 +0200 Subject: [PATCH 076/124] Use _preprocess everywhere --- cadquery/occ_impl/nurbs.py | 80 +++++++------------------------------- 1 file changed, 13 insertions(+), 67 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 566bb6b26..2cf40b138 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -636,19 +636,7 @@ def nbCurveDer( nb = pts.shape[0] # handle periodicity - if periodic: - period = knots[-1] - knots[0] - u_ = u % period - knots_ext = extendKnots(order, knots) - minspan = 0 - maxspan = len(knots) - 1 - deltaspan = order - 1 - else: - u_ = u - knots_ext = knots - minspan = None - maxspan = None - deltaspan = 0 + u_, knots_ext, minspan, maxspan, deltaspan = _preprocess(u, order, knots, periodic) # number of param values nu = np.size(u) @@ -727,33 +715,12 @@ def nbSurface( nvb = pts.shape[1] # handle periodicity - if uperiodic: - uperiod = uknots[-1] - uknots[0] - u_ = u % uperiod - uknots_ext = extendKnots(uorder, uknots) - minspanu = 0 - maxspanu = len(uknots) - 1 - deltaspanu = uorder - 1 - else: - u_ = u - uknots_ext = uknots - minspanu = None - maxspanu = None - deltaspanu = 0 - - if vperiodic: - vperiod = vknots[-1] - vknots[0] - v_ = v % vperiod - vknots_ext = extendKnots(vorder, vknots) - minspanv = 0 - maxspanv = len(vknots) - 1 - deltaspanv = vorder - 1 - else: - v_ = v - vknots_ext = vknots - minspanv = None - maxspanv = None - deltaspanv = 0 + u_, uknots_ext, minspanu, maxspanu, deltaspanu = _preprocess( + u, uorder, uknots, uperiodic + ) + v_, vknots_ext, minspanv, maxspanv, deltaspanv = _preprocess( + v, vorder, vknots, vperiodic + ) # number of param values nu = np.size(u) @@ -855,33 +822,12 @@ def nbSurfaceDer( nvb = pts.shape[1] # handle periodicity - if uperiodic: - uperiod = uknots[-1] - uknots[0] - u_ = u % uperiod - uknots_ext = extendKnots(uorder, uknots) - minspanu = 0 - maxspanu = len(uknots) - 1 - deltaspanu = uorder - 1 - else: - u_ = u - uknots_ext = uknots - minspanu = None - maxspanu = None - deltaspanu = 0 - - if vperiodic: - vperiod = vknots[-1] - vknots[0] - v_ = v % vperiod - vknots_ext = extendKnots(vorder, vknots) - minspanv = 0 - maxspanv = len(vknots) - 1 - deltaspanv = vorder - 1 - else: - v_ = v - vknots_ext = vknots - minspanv = None - maxspanv = None - deltaspanv = 0 + u_, uknots_ext, minspanu, maxspanu, deltaspanu = _preprocess( + u, uorder, uknots, uperiodic + ) + v_, vknots_ext, minspanv, maxspanv, deltaspanv = _preprocess( + v, vorder, vknots, vperiodic + ) # number of param values nu = np.size(u) From a90756f5f6d0c1c31b4b7d5e491f15b7c707abbf Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 25 Jul 2025 12:39:12 +0200 Subject: [PATCH 077/124] Reparametrize fixes --- cadquery/occ_impl/nurbs.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 2cf40b138..9eccca1a2 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -1618,14 +1618,18 @@ def loft( def reparametrize( - *curves: Curve, n: int = 100, w1: float = 1, w2: float = 1e-2 + *curves: Curve, n: int = 100, w1: float = 1, w2: float = 1e-1 ) -> List[Curve]: from scipy.optimize import fmin_l_bfgs_b n_curves = len(curves) - u0 = np.tile(np.linspace(0, 1, n, False), n_curves) + u0_0 = np.linspace(0, 1, n, False) + u0 = np.tile(u0_0, n_curves) + + # scaling for the second cost term + scale = n * np.linalg.norm(curves[0](u0[0]) - curves[1](u0[n])) def cost(u: Array) -> float: @@ -1638,14 +1642,14 @@ def cost(u: Array) -> float: pts.append(curves[i](ui)) # parametric distance between points on the same curve - rv1 += np.sum((ui[:-1] - ui[1:]) ** 2) + np.sum((u[0] + 1 - u[-1]) ** 2) + rv1 += np.sum((ui[:-1] - ui[1:]) ** 2) + np.sum((ui[0] + 1 - ui[-1]) ** 2) rv2 = 0 for p1, p2 in zip(pts, pts[1:]): # geometric distance between points on adjecent curves - rv2 += np.sum((p1 - p2) ** 2) + rv2 += np.sum(((p1 - p2) / scale) ** 2) return w1 * rv1 + w2 * rv2 @@ -1653,7 +1657,7 @@ def cost(u: Array) -> float: us = np.split(usol, n_curves) - return periodicApproximate([crv(u) for crv, u in zip(curves, us)], lam=1e-3) + return periodicApproximate([crv(u) for crv, u in zip(curves, us)], knots=n, lam=0) #%% for removal? From 3461b4ac2aa5a6135df0068a1d60e566b7c63f20 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:50:19 +0200 Subject: [PATCH 078/124] Unify periodic design matrix handling --- cadquery/occ_impl/nurbs.py | 101 +++++++++++++++---------------------- 1 file changed, 42 insertions(+), 59 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 9eccca1a2..0be0cd373 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -314,8 +314,8 @@ def _preprocess( else: u_ = u knots_ext = knots - minspan = None - maxspan = None + minspan = order + maxspan = knots.shape[0] - order - 1 deltaspan = 0 return u_, knots_ext, minspan, maxspan, deltaspan @@ -883,13 +883,23 @@ def nbSurfaceDer( @njit -def designMatrix(u: Array, order: int, knots: Array) -> COO: +def designMatrix(u: Array, order: int, knots: Array, periodic: bool = False) -> COO: """ - Create a sparse design matrix. + Create a sparse (possibly periodic) design matrix. """ + # extend the knots + knots_ext = np.concat( + (knots[-order:-1] - knots[-1], knots, knots[-1] + knots[1:order]) + ) + + u_, knots_ext, minspan, maxspan, deltaspan = _preprocess(u, order, knots, periodic) + # number of param values - nu = np.size(u) + nu = len(u) + + # number of basis functions + nb = maxspan # chunck size n = order + 1 @@ -906,17 +916,19 @@ def designMatrix(u: Array, order: int, knots: Array) -> COO: # loop over param values for i in range(nu): - ui = u[i] + ui = u_[i] # find the supporting span - span = nbFindSpan(ui, order, knots) + span = nbFindSpan(ui, order, knots, minspan, maxspan) + deltaspan # evaluate non-zero functions - nbBasis(span, ui, order, knots, temp) + nbBasis(span, ui, order, knots_ext, temp) # update the matrix rv.i[i * n : (i + 1) * n] = i - rv.j[i * n : (i + 1) * n] = span - order + np.arange(n) + rv.j[i * n : (i + 1) * n] = ( + span - order + np.arange(n) + ) % nb # NB: this is due to peridicity rv.v[i * n : (i + 1) * n] = temp return rv @@ -924,12 +936,25 @@ def designMatrix(u: Array, order: int, knots: Array) -> COO: # @njit def designMatrix2D( - uv: Array, uorder: int, vorder: int, uknots: Array, vknots: Array + uv: Array, + uorder: int, + vorder: int, + uknots: Array, + vknots: Array, + uperiodic: bool = False, + vperiodic: bool = False, ) -> COO: """ Create a sparse tensor product design matrix. """ + u_, uknots_ext, minspanu, maxspanu, deltaspanu = _preprocess( + uv[:, 0], uorder, uknots, uperiodic + ) + v_, vknots_ext, minspanv, maxspanv, deltaspanv = _preprocess( + uv[:, 1], vorder, vknots, vperiodic + ) + # number of param values ni = uv.shape[0] @@ -939,8 +964,7 @@ def designMatrix2D( nj = nu * nv # number of basis - nu_total = len(uknots) - uorder - 1 - nv_total = len(vknots) - vorder - 1 + nv_total = maxspanv # temp chunck storage utemp = np.zeros(nu) @@ -955,15 +979,15 @@ def designMatrix2D( # loop over param values for i in range(ni): - ui, vi = uv[i, :] + ui, vi = u_[i], v_[i] # find the supporting span - uspan = nbFindSpan(ui, uorder, uknots) - vspan = nbFindSpan(vi, vorder, vknots) + uspan = nbFindSpan(ui, uorder, uknots, minspanu, maxspanu) + deltaspanu + vspan = nbFindSpan(vi, vorder, vknots, minspanv, maxspanv) + deltaspanv # evaluate non-zero functions - nbBasis(uspan, ui, uorder, uknots, utemp) - nbBasis(vspan, vi, vorder, vknots, vtemp) + nbBasis(uspan, ui, uorder, uknots_ext, utemp) + nbBasis(vspan, vi, vorder, vknots_ext, vtemp) # update the matrix rv.i[i * nj : (i + 1) * nj] = i @@ -982,48 +1006,7 @@ def periodicDesignMatrix(u: Array, order: int, knots: Array) -> COO: Create a sparse periodic design matrix. """ - # extend the knots - knots_ext = np.concat( - (knots[-order:-1] - knots[-1], knots, knots[-1] + knots[1:order]) - ) - - # number of param values - nu = len(u) - - # number of basis functions - nb = len(knots) - 1 - - # chunck size - n = order + 1 - - # temp chunck storage - temp = np.zeros(n) - - # initialize the empty matrix - rv = COO( - i=np.empty(n * nu, dtype=np.int64), - j=np.empty(n * nu, dtype=np.int64), - v=np.empty(n * nu), - ) - - # loop over param values - for i in range(nu): - ui = u[i] - - # find the supporting span - span = nbFindSpan(ui, order, knots, 0, nb) + order - 1 - - # evaluate non-zero functions - nbBasis(span, ui, order, knots_ext, temp) - - # update the matrix - rv.i[i * n : (i + 1) * n] = i - rv.j[i * n : (i + 1) * n] = ( - span - order + np.arange(n) - ) % nb # NB: this is due to peridicity - rv.v[i * n : (i + 1) * n] = temp - - return rv + return designMatrix(u, order, knots, periodic=True) @njit From 05b5fe38a5cf4f3d1f18b1aabc3ef7d3e97ad97c Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 25 Jul 2025 16:18:43 +0200 Subject: [PATCH 079/124] 2D dm fix --- cadquery/occ_impl/nurbs.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 0be0cd373..61fe863a3 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -964,6 +964,7 @@ def designMatrix2D( nj = nu * nv # number of basis + nu_total = maxspanu nv_total = maxspanv # temp chunck storage @@ -992,8 +993,8 @@ def designMatrix2D( # update the matrix rv.i[i * nj : (i + 1) * nj] = i rv.j[i * nj : (i + 1) * nj] = ( - (uspan - uorder + np.arange(nu)) * nv_total - + (vspan - vorder + np.arange(nv))[:, np.newaxis] + ((uspan - uorder + np.arange(nu)) % nu_total) * nv_total + + ((vspan - vorder + np.arange(nv)) % nv_total)[:, np.newaxis] ).ravel() rv.v[i * nj : (i + 1) * nj] = (utemp * vtemp[:, np.newaxis]).ravel() From d69856a397281b3481ec4f4b3f87bf772b698675 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Fri, 25 Jul 2025 14:50:34 -0400 Subject: [PATCH 080/124] Rework which covers everything except subshapes and layers --- cadquery/occ_impl/importers/assembly.py | 244 ++++++++++-------------- tests/test_assembly.py | 40 ++-- 2 files changed, 125 insertions(+), 159 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 380ff98ce..4899c39b0 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -1,16 +1,12 @@ from OCP.TCollection import TCollection_ExtendedString -from OCP.Quantity import Quantity_Color, Quantity_ColorRGBA -from OCP.TDocStd import TDocStd_Document +from OCP.Quantity import Quantity_ColorRGBA +from OCP.TDF import TDF_Label, TDF_LabelSequence from OCP.IFSelect import IFSelect_RetDone -from OCP.STEPCAFControl import STEPCAFControl_Reader -from OCP.XCAFDoc import ( - XCAFDoc_DocumentTool, - XCAFDoc_ColorGen, - XCAFDoc_ColorSurf, - XCAFDoc_GraphNode, -) -from OCP.TDF import TDF_Label, TDF_LabelSequence, TDF_AttributeIterator, TDF_DataSet +from OCP.TDocStd import TDocStd_Document from OCP.TDataStd import TDataStd_Name +from OCP.STEPCAFControl import STEPCAFControl_Reader +from OCP.XCAFDoc import XCAFDoc_ColorSurf +from OCP.XCAFDoc import XCAFDoc_DocumentTool import cadquery as cq from ..assembly import AssemblyProtocol @@ -26,6 +22,75 @@ def importStep(assy: AssemblyProtocol, path: str): :return: None """ + def _process_label(lbl: TDF_Label): + """ + Recursive method to process the assembly in a top-down manner. + """ + # If we have an assembly, extract all of the information out of it that we can + if shape_tool.IsAssembly_s(lbl): + # Instantiate the new assembly + new_assy = cq.Assembly() + + # Look for components + comp_labels = TDF_LabelSequence() + shape_tool.GetComponents_s(lbl, comp_labels) + + for i in range(comp_labels.Length()): + comp_label = comp_labels.Value(i + 1) + + # Get the location of the component label + loc = shape_tool.GetLocation_s(comp_label) + cq_loc = cq.Location(loc) if loc else None + + if shape_tool.IsReference_s(comp_label): + ref_label = TDF_Label() + shape_tool.GetReferredShape_s(comp_label, ref_label) + + # Find the name of this referenced part + ref_name_attr = TDataStd_Name() + if ref_label.FindAttribute(TDataStd_Name.GetID_s(), ref_name_attr): + ref_name = str(ref_name_attr.Get().ToExtString()) + + if shape_tool.IsAssembly_s(ref_label): + # Recursively process subassemblies + sub_assy = _process_label(ref_label) + + # Add the appropriate attributes to the subassembly + if sub_assy: + if cq_loc: + new_assy.add(sub_assy, name=f"{ref_name}", loc=cq_loc) + else: + new_assy.add(sub_assy, name=f"{ref_name}") + elif shape_tool.IsSimpleShape_s(ref_label): + # A single shape needs to be added to the assembly + final_shape = shape_tool.GetShape_s(ref_label) + cq_shape = cq.Shape.cast(final_shape) + + # Find the subshape color, if there is one set for this shape + color = Quantity_ColorRGBA() + # Extract the color, if present on the shape + if color_tool.GetColor(final_shape, XCAFDoc_ColorSurf, color): + rgb = color.GetRGB() + cq_color = cq.Color( + rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha() + ) + else: + cq_color = None + + if cq_loc: + new_assy.add( + cq_shape, name=f"{ref_name}", loc=cq_loc, color=cq_color + ) + else: + new_assy.add(cq_shape, name=f"{ref_name}", color=cq_color) + + return new_assy + elif shape_tool.IsSimpleShape_s(lbl): + shape = shape_tool.GetShape_s(lbl) + return cq.Shape.cast(shape) + else: + return None + # Document that the step file will be read into doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf")) @@ -49,148 +114,33 @@ def importStep(assy: AssemblyProtocol, path: str): color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main()) layer_tool = XCAFDoc_DocumentTool.LayerTool_s(doc.Main()) - def _process_simple_shape(label, parent_location=None, parent_name=None): - shape = shape_tool.GetShape_s(label) - - # Load the name of the part in the assembly, if it is present - if parent_name is not None and parent_name != assy.name: - name = parent_name - else: - name_attr = TDataStd_Name() - label.FindAttribute(TDataStd_Name.GetID_s(), name_attr) - name = str(name_attr.Get().ToExtString()) - - # Process the color for the shape, which could be of different types - color = Quantity_Color() - cq_color = cq.Color(0.50, 0.50, 0.50) - if color_tool.GetColor_s(label, XCAFDoc_ColorSurf, color): - r = color.Red() - g = color.Green() - b = color.Blue() - cq_color = cq.Color(r, g, b) - - # Handle the location if it was passed down form a parent component - if parent_location is not None: - assy.add( - cq.Shape.cast(shape), - name=name, - color=cq_color, - loc=cq.Location(parent_location), - ) - else: - assy.add(cq.Shape.cast(shape), name=name, color=cq_color) - - # Check all the attributes of all the children to find the subshapes and any names - for j in range(label.NbChildren()): - child_label = label.FindChild(j + 1) - attr_iterator = TDF_AttributeIterator(child_label) - while attr_iterator.More(): - current_attr = attr_iterator.Value() - - # Get the type name of the attribute so that we can decide how to handle it - # TNaming_NamedShape is used to store and manage references to topological shapes, and its attributes can be accessed directly. - # XCAFDoc_GraphNode contains a graph of labels, and so we must follow the branch back to a father. - if current_attr.DynamicType().Name() == "TNaming_NamedShape": - # Save the shape so that we can add it to the subshape data - cur_shape = current_attr.Get() - - # Find the layer name, if there is one set for this shape - layers = TDF_LabelSequence() - layer_tool.GetLayers(child_label, layers) - for i in range(1, layers.Length() + 1): - lbl = layers.Value(i) - name_attr = TDataStd_Name() - lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) - - # Extract the layer name for the shape here - layer_name = name_attr.Get().ToExtString() - - # Add the layer as a subshape entry on the assembly - assy.addSubshape(cur_shape, layer=layer_name) - - # Find the subshape color, if there is one set for this shape - color = Quantity_ColorRGBA() - # Extract the color, if present on the shape - if color_tool.GetColor(cur_shape, XCAFDoc_ColorSurf, color): - rgb = color.GetRGB() - cq_color = cq.Color( - rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha() - ) - - # Save the color info via the assembly subshape mechanism - assy.addSubshape(cur_shape, color=cq_color) - elif current_attr.DynamicType().Name() == "XCAFDoc_GraphNode": - # Step up one level to try to get the name from the parent - lbl = current_attr.GetFather(1).Label() - - # Step through and search for the name attribute - it = TDF_AttributeIterator(lbl) - while it.More(): - new_attr = it.Value() - if new_attr.DynamicType().Name() == "TDataStd_Name": - # Save this as the name of the subshape - assy.addSubshape( - cur_shape, name=new_attr.Get().ToExtString(), - ) - it.Next() - - attr_iterator.Next() - - def process_label(label, parent_location=None, parent_name=None): - """ - Recursive function that allows us to process the hierarchy of the assembly as represented - in the step file. - """ - - # Handle reference labels - if shape_tool.IsReference_s(label): - ref_label = TDF_Label() - shape_tool.GetReferredShape_s(label, ref_label) - process_label(ref_label, parent_location, parent_name) - - return - - # See if this is an assembly (or sub-assembly) - if shape_tool.IsAssembly_s(label): - name_attr = TDataStd_Name() - label.FindAttribute(TDataStd_Name.GetID_s(), name_attr) - name = name_attr.Get().ToExtString() - - # Recursively process its components (children) - comp_labels = TDF_LabelSequence() - shape_tool.GetComponents_s(label, comp_labels) - - for i in range(comp_labels.Length()): - sub_label = comp_labels.Value(i + 1) - # Get the location of the sub-label, if it exists - loc = shape_tool.GetLocation_s(sub_label) - - # Pass down the location or other context as needed - # Add the parent location if it exists - if parent_location is not None: - loc = parent_location * loc - - process_label(sub_label, loc, name) - - return - - # Check to see if we have an endpoint shape and process it - if shape_tool.IsSimpleShape_s(label): - _process_simple_shape(label, parent_location, parent_name) - - # Get the shapes in the assembly + # Collect all the labels representing shapes in the document labels = TDF_LabelSequence() shape_tool.GetFreeShapes(labels) + if labels.Length() == 0: + raise ValueError("No assembly found in STEP file") - # Use the first label to pull the top-level assembly information - name_attr = TDataStd_Name() - labels.Value(1).FindAttribute(TDataStd_Name.GetID_s(), name_attr) - assy.name = str(name_attr.Get().ToExtString()) + # Get the top-level label, which should represent an assembly + top_level_label = labels.Value(1) # Make sure there is a top-level assembly - if shape_tool.IsTopLevel(labels.Value(1)) and shape_tool.IsAssembly_s( - labels.Value(1) + if shape_tool.IsTopLevel(top_level_label) and shape_tool.IsAssembly_s( + top_level_label ): - process_label(labels.Value(1)) + # Set the name of the top-level assembly to match the top-level label + name_attr = TDataStd_Name() + top_level_label.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + assy.name = str(name_attr.Get().ToExtString()) + + # Start the recursive processing of labels + whole_assy = _process_label(top_level_label) + + # Copy contents instead of adding the whole assembly + # assy.name = whole_assy.name + if whole_assy and hasattr(whole_assy, "children"): + for child in whole_assy.children: + assy.add(child, name=child.name, color=child.color, loc=child.loc) + elif whole_assy: + assy.add(whole_assy) else: raise ValueError("Step file does not contain an assembly") diff --git a/tests/test_assembly.py b/tests/test_assembly.py index f8dabfd85..7a3da384b 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -1010,31 +1010,47 @@ def test_plain_assembly_import(tmp_path_factory): assert imported_assy.name == "top_level" # Check the locations - assert imported_assy.children[0].loc.toTuple()[0] == (0.0, 0.0, 0.0) - assert imported_assy.children[1].loc.toTuple()[0] == (10.0, 10.0, 10.0) - assert imported_assy.children[2].loc.toTuple()[0] == (-10.0, -10.0, -10.0) - assert imported_assy.children[3].loc.toTuple()[0] == (10.0, -10.0, -10.0) + assert imported_assy.children[0].children[0].loc.toTuple()[0] == (0.0, 0.0, 0.0,) + assert imported_assy.children[0].children[1].loc.toTuple()[0] == (10.0, 10.0, 10.0,) + assert imported_assy.children[0].children[2].loc.toTuple()[0] == ( + -10.0, + -10.0, + -10.0, + ) + assert imported_assy.children[0].children[3].loc.toTuple()[0] == ( + 10.0, + -10.0, + -10.0, + ) # Check the colors - assert pytest.approx(imported_assy.children[0].color.toTuple(), rel=0.01) == ( + assert pytest.approx( + imported_assy.children[0].children[0].children[0].color.toTuple(), rel=0.01 + ) == ( 0.0, 1.0, 0.0, 1.0, ) # green - assert pytest.approx(imported_assy.children[1].color.toTuple(), rel=0.01) == ( + assert pytest.approx( + imported_assy.children[0].children[1].children[0].color.toTuple(), rel=0.01 + ) == ( 1.0, 0.0, 0.0, 1.0, ) # red - assert pytest.approx(imported_assy.children[2].color.toTuple(), rel=0.01) == ( + assert pytest.approx( + imported_assy.children[0].children[2].children[0].color.toTuple(), rel=0.01 + ) == ( 1.0, 0.0, 0.0, 1.0, ) # red - assert pytest.approx(imported_assy.children[3].color.toTuple(), rel=0.01) == ( + assert pytest.approx( + imported_assy.children[0].children[3].children[0].color.toTuple(), rel=0.01 + ) == ( 1.0, 0.0, 0.0, @@ -1075,11 +1091,11 @@ def make_model(name: str, COPY: bool): # import the assy with copies assy_copy = Assembly.importStep(os.path.join(tmpdir, "test_assy_copy.step")) - assert 5 == len(assy_copy.children) + assert 5 == len(assy_copy.children[0].children) # import the assy without copies assy_normal = Assembly.importStep(os.path.join(tmpdir, "test_assy.step")) - assert 5 == len(assy_normal.children) + assert 5 == len(assy_normal.children[0].children) def test_nested_subassembly_step_import(tmp_path_factory): @@ -1106,8 +1122,8 @@ def test_nested_subassembly_step_import(tmp_path_factory): imported_assy = cq.Assembly.importStep(nested_step_path) # Check the locations - assert imported_assy.children[0].loc.toTuple()[0] == (0.0, 0.0, 0.0) - assert imported_assy.children[1].objects["box_2"].loc.toTuple()[0] == ( + assert imported_assy.children[0].children[0].loc.toTuple()[0] == (0.0, 0.0, 0.0) + assert imported_assy.children[0].children[1].objects["box_2"].loc.toTuple()[0] == ( 10.0, 10.0, 10.0, From c8e0c08e60bb0d3d7f9c7bec8e03cb0b92d52f35 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Fri, 25 Jul 2025 15:33:35 -0400 Subject: [PATCH 081/124] Added layer name support back in --- cadquery/occ_impl/importers/assembly.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 4899c39b0..2d8f94efe 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -84,6 +84,20 @@ def _process_label(lbl: TDF_Label): else: new_assy.add(cq_shape, name=f"{ref_name}", color=cq_color) + # Find the layer name, if there is one set for this shape + layers = TDF_LabelSequence() + layer_tool.GetLayers(ref_label, layers) + for i in range(1, layers.Length() + 1): + lbl = layers.Value(i) + name_attr = TDataStd_Name() + lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + + # Extract the layer name for the shape here + layer_name = name_attr.Get().ToExtString() + + # Add the layer as a subshape entry on the assembly + new_assy.addSubshape(final_shape, layer=layer_name) + return new_assy elif shape_tool.IsSimpleShape_s(lbl): shape = shape_tool.GetShape_s(lbl) From 7639fdf4bcd3697154f8c8ba2401bf5c8db5e18d Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 28 Jul 2025 09:21:56 -0400 Subject: [PATCH 082/124] Added a round-trip test and fixed issues that it revealed --- cadquery/occ_impl/importers/assembly.py | 101 ++++++++++++++++++++---- tests/test_assembly.py | 58 ++++++++++++++ 2 files changed, 143 insertions(+), 16 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 2d8f94efe..d9a3bcc12 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -1,6 +1,6 @@ from OCP.TCollection import TCollection_ExtendedString from OCP.Quantity import Quantity_ColorRGBA -from OCP.TDF import TDF_Label, TDF_LabelSequence +from OCP.TDF import TDF_Label, TDF_LabelSequence, TDF_AttributeIterator from OCP.IFSelect import IFSelect_RetDone from OCP.TDocStd import TDocStd_Document from OCP.TDataStd import TDataStd_Name @@ -84,19 +84,82 @@ def _process_label(lbl: TDF_Label): else: new_assy.add(cq_shape, name=f"{ref_name}", color=cq_color) - # Find the layer name, if there is one set for this shape - layers = TDF_LabelSequence() - layer_tool.GetLayers(ref_label, layers) - for i in range(1, layers.Length() + 1): - lbl = layers.Value(i) - name_attr = TDataStd_Name() - lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) - - # Extract the layer name for the shape here - layer_name = name_attr.Get().ToExtString() - - # Add the layer as a subshape entry on the assembly - new_assy.addSubshape(final_shape, layer=layer_name) + # Search for subshape names, layers and colors + for j in range(ref_label.NbChildren()): + child_label = ref_label.FindChild(j + 1) + + # Iterate through all the attributes looking for subshapes + attr_iterator = TDF_AttributeIterator(child_label) + while attr_iterator.More(): + current_attr = attr_iterator.Value() + + # TNaming_NamedShape is used to store and manage references to + # topological shapes, and its attributes can be accessed directly. + # XCAFDoc_GraphNode contains a graph of labels, and so we must + # follow the branch back to a father. + if ( + current_attr.DynamicType().Name() + == "XCAFDoc_GraphNode" + ): + # Step up one level to try to get the name from the parent + lbl = current_attr.GetFather(1).Label() + + # Step through and search for the name attribute + it = TDF_AttributeIterator(lbl) + while it.More(): + new_attr = it.Value() + if ( + new_attr.DynamicType().Name() + == "TDataStd_Name" + ): + # Save this as the name of the subshape + assy.addSubshape( + cur_shape, + name=new_attr.Get().ToExtString(), + ) + break + it.Next() + elif ( + current_attr.DynamicType().Name() + == "TNaming_NamedShape" + ): + # Save the shape so that we can add it to the subshape data + cur_shape = current_attr.Get() + + # Find the layer name, if there is one set for this shape + layers = TDF_LabelSequence() + layer_tool.GetLayers(child_label, layers) + for i in range(1, layers.Length() + 1): + lbl = layers.Value(i) + name_attr = TDataStd_Name() + lbl.FindAttribute( + TDataStd_Name.GetID_s(), name_attr + ) + + # Extract the layer name for the shape here + layer_name = name_attr.Get().ToExtString() + + # Add the layer as a subshape entry on the assembly + assy.addSubshape(cur_shape, layer=layer_name) + + # Find the subshape color, if there is one set for this shape + color = Quantity_ColorRGBA() + # Extract the color, if present on the shape + if color_tool.GetColor( + cur_shape, XCAFDoc_ColorSurf, color + ): + rgb = color.GetRGB() + cq_color = cq.Color( + rgb.Red(), + rgb.Green(), + rgb.Blue(), + color.Alpha(), + ) + + # Save the color info via the assembly subshape mechanism + assy.addSubshape(cur_shape, color=cq_color) + + attr_iterator.Next() return new_assy elif shape_tool.IsSimpleShape_s(lbl): @@ -149,9 +212,15 @@ def _process_label(lbl: TDF_Label): # Start the recursive processing of labels whole_assy = _process_label(top_level_label) - # Copy contents instead of adding the whole assembly - # assy.name = whole_assy.name if whole_assy and hasattr(whole_assy, "children"): + # Check to see if there is an extra top-level node. This is done because + # cq.Assembly.export adds an extra top-level node which will cause a cascade of + # extras on successive round-trips. exportStepMeta does not add the extra top-level + # node and so does not exhibit this behavior. + if assy.name == whole_assy.children[0].name: + whole_assy = whole_assy.children[0] + + # Copy all of the children over to the main assembly object for child in whole_assy.children: assy.add(child, name=child.name, color=child.color, loc=child.loc) elif whole_assy: diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 7a3da384b..e251db332 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -1130,6 +1130,64 @@ def test_nested_subassembly_step_import(tmp_path_factory): ) +def test_assembly_step_import_roundtrip(tmp_path_factory): + """ + Tests that the assembly does not mutate during successive export-import round trips. + """ + + # Set up the temporary directory + tmpdir = tmp_path_factory.mktemp("out") + round_trip_step_path = os.path.join(tmpdir, "round_trip.step") + + # Create a sample assembly + assy = cq.Assembly(name="top-level") + assy.add(cq.Workplane().box(10, 10, 10), name="cube_1", color=cq.Color("red")) + subshape_assy = cq.Assembly(name="nested-assy") + subshape_assy.add( + cq.Workplane().cylinder(height=10.0, radius=2.5), + name="cylinder_1", + color=cq.Color("blue"), + loc=cq.Location((20, 20, 20)), + ) + assy.add(subshape_assy) + + # First export + assy.export(round_trip_step_path) + + # First import + assy = cq.Assembly.importStep(round_trip_step_path) + + # Second export + assy.export(round_trip_step_path) + + # Second import + assy = cq.Assembly.importStep(round_trip_step_path) + + # Check some general aspects of the assembly structure now + assert len(assy.children) == 2 + assert assy.name == "top-level" + assert assy.children[0].name == "cube_1" + assert assy.children[1].children[0].name == "cylinder_1" + + # First meta export + exportStepMeta(assy, round_trip_step_path) + + # First meta import + assy = cq.Assembly.importStep(round_trip_step_path) + + # Second meta export + exportStepMeta(assy, round_trip_step_path) + + # Second meta import + assy = cq.Assembly.importStep(round_trip_step_path) + + # Check some general aspects of the assembly structure now + assert len(assy.children) == 2 + assert assy.name == "top-level" + assert assy.children[0].name == "cube_1" + assert assy.children[1].children[0].name == "cylinder_1" + + @pytest.mark.parametrize( "assy_fixture, expected", [ From eedc16f0db1d66146017c852613464fe9b50cd0b Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 28 Jul 2025 15:55:31 -0400 Subject: [PATCH 083/124] mypy fixes --- cadquery/occ_impl/assembly.py | 33 +++++++++++++++++++++++++ cadquery/occ_impl/importers/assembly.py | 33 ++++++++++++++++--------- tests/test_assembly.py | 32 +++++++++--------------- 3 files changed, 67 insertions(+), 31 deletions(-) diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index d06282e53..4cc30f8e0 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -184,6 +184,39 @@ def _subshape_colors(self) -> Dict[Shape, Color]: def _subshape_layers(self) -> Dict[Shape, str]: ... + @overload + def add( + self, + obj: "Assembly", + loc: Optional[Location] = None, + name: Optional[str] = None, + color: Optional[Color] = None, + ): + ... + + @overload + def add( + self, + obj: AssemblyObjects, + loc: Optional[Location] = None, + name: Optional[str] = None, + color: Optional[Color] = None, + metadata: Optional[Dict[str, Any]] = None, + ): + ... + + def add(self, arg, **kwargs): + ... + + def addSubshape( + self, + s: Shape, + name: Optional[str] = None, + color: Optional[Color] = None, + layer: Optional[str] = None, + ) -> "Assembly": + ... + def traverse(self) -> Iterable[Tuple[str, "AssemblyProtocol"]]: ... diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index d9a3bcc12..0d4f001a6 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -1,3 +1,4 @@ +from OCP.TopoDS import TopoDS_Shape from OCP.TCollection import TCollection_ExtendedString from OCP.Quantity import Quantity_ColorRGBA from OCP.TDF import TDF_Label, TDF_LabelSequence, TDF_AttributeIterator @@ -113,7 +114,7 @@ def _process_label(lbl: TDF_Label): == "TDataStd_Name" ): # Save this as the name of the subshape - assy.addSubshape( + new_assy.addSubshape( cur_shape, name=new_attr.Get().ToExtString(), ) @@ -124,7 +125,7 @@ def _process_label(lbl: TDF_Label): == "TNaming_NamedShape" ): # Save the shape so that we can add it to the subshape data - cur_shape = current_attr.Get() + cur_shape: TopoDS_Shape = current_attr.Get() # Find the layer name, if there is one set for this shape layers = TDF_LabelSequence() @@ -140,7 +141,9 @@ def _process_label(lbl: TDF_Label): layer_name = name_attr.Get().ToExtString() # Add the layer as a subshape entry on the assembly - assy.addSubshape(cur_shape, layer=layer_name) + new_assy.addSubshape( + cur_shape, layer=layer_name + ) # Find the subshape color, if there is one set for this shape color = Quantity_ColorRGBA() @@ -157,7 +160,7 @@ def _process_label(lbl: TDF_Label): ) # Save the color info via the assembly subshape mechanism - assy.addSubshape(cur_shape, color=cq_color) + new_assy.addSubshape(cur_shape, color=cq_color) attr_iterator.Next() @@ -210,20 +213,28 @@ def _process_label(lbl: TDF_Label): assy.name = str(name_attr.Get().ToExtString()) # Start the recursive processing of labels - whole_assy = _process_label(top_level_label) + imported_assy = _process_label(top_level_label) - if whole_assy and hasattr(whole_assy, "children"): + if imported_assy and hasattr(imported_assy, "children"): # Check to see if there is an extra top-level node. This is done because # cq.Assembly.export adds an extra top-level node which will cause a cascade of # extras on successive round-trips. exportStepMeta does not add the extra top-level # node and so does not exhibit this behavior. - if assy.name == whole_assy.children[0].name: - whole_assy = whole_assy.children[0] + if assy.name == imported_assy.children[0].name: + imported_assy = imported_assy.children[0] # Copy all of the children over to the main assembly object - for child in whole_assy.children: + for child in imported_assy.children: assy.add(child, name=child.name, color=child.color, loc=child.loc) - elif whole_assy: - assy.add(whole_assy) + elif imported_assy: + assy.add(imported_assy) + + # Copy across subshape data + for shape, name in imported_assy._subshape_names.items(): + assy.addSubshape(shape, name=name) + for shape, color in imported_assy._subshape_colors.items(): + assy.addSubshape(shape, color=color) + for shape, layer in imported_assy._subshape_layers.items(): + assy.addSubshape(shape, layer=layer) else: raise ValueError("Step file does not contain an assembly") diff --git a/tests/test_assembly.py b/tests/test_assembly.py index e251db332..f045671d5 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -1010,22 +1010,14 @@ def test_plain_assembly_import(tmp_path_factory): assert imported_assy.name == "top_level" # Check the locations - assert imported_assy.children[0].children[0].loc.toTuple()[0] == (0.0, 0.0, 0.0,) - assert imported_assy.children[0].children[1].loc.toTuple()[0] == (10.0, 10.0, 10.0,) - assert imported_assy.children[0].children[2].loc.toTuple()[0] == ( - -10.0, - -10.0, - -10.0, - ) - assert imported_assy.children[0].children[3].loc.toTuple()[0] == ( - 10.0, - -10.0, - -10.0, - ) + assert imported_assy.children[0].loc.toTuple()[0] == (0.0, 0.0, 0.0,) + assert imported_assy.children[1].loc.toTuple()[0] == (10.0, 10.0, 10.0,) + assert imported_assy.children[2].loc.toTuple()[0] == (-10.0, -10.0, -10.0,) + assert imported_assy.children[3].loc.toTuple()[0] == (10.0, -10.0, -10.0,) # Check the colors assert pytest.approx( - imported_assy.children[0].children[0].children[0].color.toTuple(), rel=0.01 + imported_assy.children[0].children[0].color.toTuple(), rel=0.01 ) == ( 0.0, 1.0, @@ -1033,7 +1025,7 @@ def test_plain_assembly_import(tmp_path_factory): 1.0, ) # green assert pytest.approx( - imported_assy.children[0].children[1].children[0].color.toTuple(), rel=0.01 + imported_assy.children[1].children[0].color.toTuple(), rel=0.01 ) == ( 1.0, 0.0, @@ -1041,7 +1033,7 @@ def test_plain_assembly_import(tmp_path_factory): 1.0, ) # red assert pytest.approx( - imported_assy.children[0].children[2].children[0].color.toTuple(), rel=0.01 + imported_assy.children[2].children[0].color.toTuple(), rel=0.01 ) == ( 1.0, 0.0, @@ -1049,7 +1041,7 @@ def test_plain_assembly_import(tmp_path_factory): 1.0, ) # red assert pytest.approx( - imported_assy.children[0].children[3].children[0].color.toTuple(), rel=0.01 + imported_assy.children[3].children[0].color.toTuple(), rel=0.01 ) == ( 1.0, 0.0, @@ -1091,11 +1083,11 @@ def make_model(name: str, COPY: bool): # import the assy with copies assy_copy = Assembly.importStep(os.path.join(tmpdir, "test_assy_copy.step")) - assert 5 == len(assy_copy.children[0].children) + assert 5 == len(assy_copy.children) # import the assy without copies assy_normal = Assembly.importStep(os.path.join(tmpdir, "test_assy.step")) - assert 5 == len(assy_normal.children[0].children) + assert 5 == len(assy_normal.children) def test_nested_subassembly_step_import(tmp_path_factory): @@ -1122,8 +1114,8 @@ def test_nested_subassembly_step_import(tmp_path_factory): imported_assy = cq.Assembly.importStep(nested_step_path) # Check the locations - assert imported_assy.children[0].children[0].loc.toTuple()[0] == (0.0, 0.0, 0.0) - assert imported_assy.children[0].children[1].objects["box_2"].loc.toTuple()[0] == ( + assert imported_assy.children[0].loc.toTuple()[0] == (0.0, 0.0, 0.0) + assert imported_assy.children[1].objects["box_2"].loc.toTuple()[0] == ( 10.0, 10.0, 10.0, From ee2b83475704c02409952e5af1c6c0693a95c410 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 28 Jul 2025 16:40:00 -0400 Subject: [PATCH 084/124] More mypy fixes --- cadquery/occ_impl/assembly.py | 4 ++-- cadquery/occ_impl/importers/assembly.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index 4cc30f8e0..9f5b1af8c 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -187,7 +187,7 @@ def _subshape_layers(self) -> Dict[Shape, str]: @overload def add( self, - obj: "Assembly", + obj: "AssemblyProtocol", loc: Optional[Location] = None, name: Optional[str] = None, color: Optional[Color] = None, @@ -214,7 +214,7 @@ def addSubshape( name: Optional[str] = None, color: Optional[Color] = None, layer: Optional[str] = None, - ) -> "Assembly": + ) -> "AssemblyProtocol": ... def traverse(self) -> Iterable[Tuple[str, "AssemblyProtocol"]]: diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 0d4f001a6..84d587ad0 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -142,7 +142,7 @@ def _process_label(lbl: TDF_Label): # Add the layer as a subshape entry on the assembly new_assy.addSubshape( - cur_shape, layer=layer_name + cq.Shape.cast(cur_shape), layer=layer_name ) # Find the subshape color, if there is one set for this shape @@ -160,7 +160,9 @@ def _process_label(lbl: TDF_Label): ) # Save the color info via the assembly subshape mechanism - new_assy.addSubshape(cur_shape, color=cq_color) + new_assy.addSubshape( + cq.Shape.cast(cur_shape), color=cq_color + ) attr_iterator.Next() From 2b8a8eb61437b690fd146436ad32803361d8e607 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 28 Jul 2025 17:00:50 -0400 Subject: [PATCH 085/124] Missed a cast --- cadquery/occ_impl/importers/assembly.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 84d587ad0..a390d5198 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -115,7 +115,7 @@ def _process_label(lbl: TDF_Label): ): # Save this as the name of the subshape new_assy.addSubshape( - cur_shape, + cq.Shape.cast(cur_shape), name=new_attr.Get().ToExtString(), ) break From c7821ee2f3f993f7c32d3d2a9ea032610dcba2ee Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Tue, 29 Jul 2025 17:47:47 -0400 Subject: [PATCH 086/124] More mypy fixes --- cadquery/occ_impl/assembly.py | 23 +++++++++++---- cadquery/occ_impl/importers/assembly.py | 38 ++++++++++++------------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index 9f5b1af8c..4b00bb1d0 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -10,7 +10,7 @@ List, cast, ) -from typing_extensions import Protocol +from typing_extensions import Protocol, Self from math import degrees, radians from OCP.TDocStd import TDocStd_Document @@ -187,11 +187,11 @@ def _subshape_layers(self) -> Dict[Shape, str]: @overload def add( self, - obj: "AssemblyProtocol", + obj: Self, loc: Optional[Location] = None, name: Optional[str] = None, color: Optional[Color] = None, - ): + ) -> Self: ... @overload @@ -202,10 +202,21 @@ def add( name: Optional[str] = None, color: Optional[Color] = None, metadata: Optional[Dict[str, Any]] = None, - ): + ) -> Self: ... - def add(self, arg, **kwargs): + def add( + self, + obj: Union[Self, AssemblyObjects], + loc: Optional[Location] = None, + name: Optional[str] = None, + color: Optional[Color] = None, + metadata: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> Self: + """ + Add a subassembly to the current assembly. + """ ... def addSubshape( @@ -214,7 +225,7 @@ def addSubshape( name: Optional[str] = None, color: Optional[Color] = None, layer: Optional[str] = None, - ) -> "AssemblyProtocol": + ) -> Self: ... def traverse(self) -> Iterable[Tuple[str, "AssemblyProtocol"]]: diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index a390d5198..c88e132a4 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -102,30 +102,30 @@ def _process_label(lbl: TDF_Label): current_attr.DynamicType().Name() == "XCAFDoc_GraphNode" ): - # Step up one level to try to get the name from the parent - lbl = current_attr.GetFather(1).Label() - - # Step through and search for the name attribute - it = TDF_AttributeIterator(lbl) - while it.More(): - new_attr = it.Value() - if ( - new_attr.DynamicType().Name() - == "TDataStd_Name" - ): - # Save this as the name of the subshape - new_assy.addSubshape( - cq.Shape.cast(cur_shape), - name=new_attr.Get().ToExtString(), - ) - break - it.Next() + # Compatibility check + if hasattr(current_attr, "GetFather"): + lbl = current_attr.GetFather(1).Label() + else: + lbl = current_attr.Label().Father() + + # Find the name attribute and add it for the subshape + name_attr = TDataStd_Name() + if lbl.FindAttribute( + TDataStd_Name.GetID_s(), name_attr + ): + # Save this as the name of the subshape + new_assy.addSubshape( + cq.Shape.cast(cur_shape), + name=name_attr.Get().ToExtString(), + ) elif ( current_attr.DynamicType().Name() == "TNaming_NamedShape" ): # Save the shape so that we can add it to the subshape data - cur_shape: TopoDS_Shape = current_attr.Get() + cur_shape: TopoDS_Shape = shape_tool.GetShape_s( + child_label + ) # Find the layer name, if there is one set for this shape layers = TDF_LabelSequence() From f88aa9a57fdbcda585e3fac7c7e7f8e4b9e865dc Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 30 Jul 2025 12:32:59 -0400 Subject: [PATCH 087/124] Tried to remove the attribute iterator and could not, but moved some code out of loop --- cadquery/occ_impl/importers/assembly.py | 80 +++++++++++++------------ 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index c88e132a4..ed4e5d98b 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -89,7 +89,48 @@ def _process_label(lbl: TDF_Label): for j in range(ref_label.NbChildren()): child_label = ref_label.FindChild(j + 1) - # Iterate through all the attributes looking for subshapes + # Save the shape so that we can add it to the subshape data + cur_shape: TopoDS_Shape = shape_tool.GetShape_s(child_label) + if cur_shape.IsNull(): + continue + + # Validate the child label before using it + if not child_label or child_label.IsNull(): + continue + + # Find the layer name, if there is one set for this shape + layers = TDF_LabelSequence() + layer_tool.GetLayers(child_label, layers) + for i in range(1, layers.Length() + 1): + lbl = layers.Value(i) + name_attr = TDataStd_Name() + lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + + # Extract the layer name for the shape here + layer_name = name_attr.Get().ToExtString() + + # Add the layer as a subshape entry on the assembly + new_assy.addSubshape( + cq.Shape.cast(cur_shape), layer=layer_name + ) + + # Find the subshape color, if there is one set for this shape + color = Quantity_ColorRGBA() + # Extract the color, if present on the shape + if color_tool.GetColor(cur_shape, XCAFDoc_ColorSurf, color): + rgb = color.GetRGB() + cq_color = cq.Color( + rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha(), + ) + + # Save the color info via the assembly subshape mechanism + new_assy.addSubshape( + cq.Shape.cast(cur_shape), color=cq_color + ) + + # Iterate through all the attributes looking for subshape names. + # This is safer than trying to access the attributes directly with + # FindAttribute because it will cause a segfault in certain cases. attr_iterator = TDF_AttributeIterator(child_label) while attr_iterator.More(): current_attr = attr_iterator.Value() @@ -127,43 +168,6 @@ def _process_label(lbl: TDF_Label): child_label ) - # Find the layer name, if there is one set for this shape - layers = TDF_LabelSequence() - layer_tool.GetLayers(child_label, layers) - for i in range(1, layers.Length() + 1): - lbl = layers.Value(i) - name_attr = TDataStd_Name() - lbl.FindAttribute( - TDataStd_Name.GetID_s(), name_attr - ) - - # Extract the layer name for the shape here - layer_name = name_attr.Get().ToExtString() - - # Add the layer as a subshape entry on the assembly - new_assy.addSubshape( - cq.Shape.cast(cur_shape), layer=layer_name - ) - - # Find the subshape color, if there is one set for this shape - color = Quantity_ColorRGBA() - # Extract the color, if present on the shape - if color_tool.GetColor( - cur_shape, XCAFDoc_ColorSurf, color - ): - rgb = color.GetRGB() - cq_color = cq.Color( - rgb.Red(), - rgb.Green(), - rgb.Blue(), - color.Alpha(), - ) - - # Save the color info via the assembly subshape mechanism - new_assy.addSubshape( - cq.Shape.cast(cur_shape), color=cq_color - ) - attr_iterator.Next() return new_assy From 71e309ab2df95a6aeacd4aac109c4cc66ec9ace2 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 30 Jul 2025 13:05:38 -0400 Subject: [PATCH 088/124] Fixes and simplifications based on codecov checks --- cadquery/occ_impl/importers/assembly.py | 56 +++++++------------------ 1 file changed, 15 insertions(+), 41 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index ed4e5d98b..62b4b212c 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -57,11 +57,7 @@ def _process_label(lbl: TDF_Label): sub_assy = _process_label(ref_label) # Add the appropriate attributes to the subassembly - if sub_assy: - if cq_loc: - new_assy.add(sub_assy, name=f"{ref_name}", loc=cq_loc) - else: - new_assy.add(sub_assy, name=f"{ref_name}") + new_assy.add(sub_assy, name=f"{ref_name}", loc=cq_loc) elif shape_tool.IsSimpleShape_s(ref_label): # A single shape needs to be added to the assembly final_shape = shape_tool.GetShape_s(ref_label) @@ -78,12 +74,9 @@ def _process_label(lbl: TDF_Label): else: cq_color = None - if cq_loc: - new_assy.add( - cq_shape, name=f"{ref_name}", loc=cq_loc, color=cq_color - ) - else: - new_assy.add(cq_shape, name=f"{ref_name}", color=cq_color) + new_assy.add( + cq_shape, name=f"{ref_name}", loc=cq_loc, color=cq_color + ) # Search for subshape names, layers and colors for j in range(ref_label.NbChildren()): @@ -91,12 +84,6 @@ def _process_label(lbl: TDF_Label): # Save the shape so that we can add it to the subshape data cur_shape: TopoDS_Shape = shape_tool.GetShape_s(child_label) - if cur_shape.IsNull(): - continue - - # Validate the child label before using it - if not child_label or child_label.IsNull(): - continue # Find the layer name, if there is one set for this shape layers = TDF_LabelSequence() @@ -143,11 +130,9 @@ def _process_label(lbl: TDF_Label): current_attr.DynamicType().Name() == "XCAFDoc_GraphNode" ): - # Compatibility check + # Only the GraphNode should have this method if hasattr(current_attr, "GetFather"): lbl = current_attr.GetFather(1).Label() - else: - lbl = current_attr.Label().Father() # Find the name attribute and add it for the subshape name_attr = TDataStd_Name() @@ -171,11 +156,6 @@ def _process_label(lbl: TDF_Label): attr_iterator.Next() return new_assy - elif shape_tool.IsSimpleShape_s(lbl): - shape = shape_tool.GetShape_s(lbl) - return cq.Shape.cast(shape) - else: - return None # Document that the step file will be read into doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf")) @@ -203,12 +183,9 @@ def _process_label(lbl: TDF_Label): # Collect all the labels representing shapes in the document labels = TDF_LabelSequence() shape_tool.GetFreeShapes(labels) - if labels.Length() == 0: - raise ValueError("No assembly found in STEP file") # Get the top-level label, which should represent an assembly top_level_label = labels.Value(1) - # Make sure there is a top-level assembly if shape_tool.IsTopLevel(top_level_label) and shape_tool.IsAssembly_s( top_level_label @@ -221,19 +198,16 @@ def _process_label(lbl: TDF_Label): # Start the recursive processing of labels imported_assy = _process_label(top_level_label) - if imported_assy and hasattr(imported_assy, "children"): - # Check to see if there is an extra top-level node. This is done because - # cq.Assembly.export adds an extra top-level node which will cause a cascade of - # extras on successive round-trips. exportStepMeta does not add the extra top-level - # node and so does not exhibit this behavior. - if assy.name == imported_assy.children[0].name: - imported_assy = imported_assy.children[0] - - # Copy all of the children over to the main assembly object - for child in imported_assy.children: - assy.add(child, name=child.name, color=child.color, loc=child.loc) - elif imported_assy: - assy.add(imported_assy) + # Handle a possible extra top-level node. This is done because cq.Assembly.export + # adds an extra top-level node which will cause a cascade of + # extras on successive round-trips. exportStepMeta does not add the extra top-level + # node and so does not exhibit this behavior. + if assy.name == imported_assy.children[0].name: + imported_assy = imported_assy.children[0] + + # Copy all of the children over to the main assembly object + for child in imported_assy.children: + assy.add(child, name=child.name, color=child.color, loc=child.loc) # Copy across subshape data for shape, name in imported_assy._subshape_names.items(): From 7da1eab99c93b3a2f05a4d372da4d8154e1e9b42 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 30 Jul 2025 15:26:30 -0400 Subject: [PATCH 089/124] More cleanup to try to get code coverage high enough without creating a contrived STEP file --- cadquery/occ_impl/importers/assembly.py | 223 +++++++++++------------- 1 file changed, 106 insertions(+), 117 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 62b4b212c..431d2b4a8 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -5,9 +5,9 @@ from OCP.IFSelect import IFSelect_RetDone from OCP.TDocStd import TDocStd_Document from OCP.TDataStd import TDataStd_Name +from OCP.TNaming import TNaming_NamedShape from OCP.STEPCAFControl import STEPCAFControl_Reader -from OCP.XCAFDoc import XCAFDoc_ColorSurf -from OCP.XCAFDoc import XCAFDoc_DocumentTool +from OCP.XCAFDoc import XCAFDoc_ColorSurf, XCAFDoc_DocumentTool, XCAFDoc_GraphNode import cadquery as cq from ..assembly import AssemblyProtocol @@ -27,135 +27,124 @@ def _process_label(lbl: TDF_Label): """ Recursive method to process the assembly in a top-down manner. """ - # If we have an assembly, extract all of the information out of it that we can - if shape_tool.IsAssembly_s(lbl): - # Instantiate the new assembly - new_assy = cq.Assembly() - - # Look for components - comp_labels = TDF_LabelSequence() - shape_tool.GetComponents_s(lbl, comp_labels) - - for i in range(comp_labels.Length()): - comp_label = comp_labels.Value(i + 1) - - # Get the location of the component label - loc = shape_tool.GetLocation_s(comp_label) - cq_loc = cq.Location(loc) if loc else None - - if shape_tool.IsReference_s(comp_label): - ref_label = TDF_Label() - shape_tool.GetReferredShape_s(comp_label, ref_label) - - # Find the name of this referenced part - ref_name_attr = TDataStd_Name() - if ref_label.FindAttribute(TDataStd_Name.GetID_s(), ref_name_attr): - ref_name = str(ref_name_attr.Get().ToExtString()) - - if shape_tool.IsAssembly_s(ref_label): - # Recursively process subassemblies - sub_assy = _process_label(ref_label) - - # Add the appropriate attributes to the subassembly - new_assy.add(sub_assy, name=f"{ref_name}", loc=cq_loc) - elif shape_tool.IsSimpleShape_s(ref_label): - # A single shape needs to be added to the assembly - final_shape = shape_tool.GetShape_s(ref_label) - cq_shape = cq.Shape.cast(final_shape) + + # Instantiate the new assembly + new_assy = cq.Assembly() + + # Look for components + comp_labels = TDF_LabelSequence() + shape_tool.GetComponents_s(lbl, comp_labels) + + for i in range(comp_labels.Length()): + comp_label = comp_labels.Value(i + 1) + + # Get the location of the component label + loc = shape_tool.GetLocation_s(comp_label) + cq_loc = cq.Location(loc) if loc else None + + if shape_tool.IsReference_s(comp_label): + ref_label = TDF_Label() + shape_tool.GetReferredShape_s(comp_label, ref_label) + + # Find the name of this referenced part + ref_name_attr = TDataStd_Name() + if ref_label.FindAttribute(TDataStd_Name.GetID_s(), ref_name_attr): + ref_name = str(ref_name_attr.Get().ToExtString()) + + if shape_tool.IsAssembly_s(ref_label): + # Recursively process subassemblies + sub_assy = _process_label(ref_label) + + # Add the appropriate attributes to the subassembly + new_assy.add(sub_assy, name=f"{ref_name}", loc=cq_loc) + elif shape_tool.IsSimpleShape_s(ref_label): + # A single shape needs to be added to the assembly + final_shape = shape_tool.GetShape_s(ref_label) + cq_shape = cq.Shape.cast(final_shape) + + # Find the subshape color, if there is one set for this shape + color = Quantity_ColorRGBA() + # Extract the color, if present on the shape + if color_tool.GetColor(final_shape, XCAFDoc_ColorSurf, color): + rgb = color.GetRGB() + cq_color = cq.Color( + rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha() + ) + else: + cq_color = None + + new_assy.add( + cq_shape, name=f"{ref_name}", loc=cq_loc, color=cq_color + ) + + # Search for subshape names, layers and colors + for j in range(ref_label.NbChildren()): + child_label = ref_label.FindChild(j + 1) + + # Save the shape so that we can add it to the subshape data + cur_shape: TopoDS_Shape = shape_tool.GetShape_s(child_label) + + # Find the layer name, if there is one set for this shape + layers = TDF_LabelSequence() + layer_tool.GetLayers(child_label, layers) + for i in range(1, layers.Length() + 1): + lbl = layers.Value(i) + name_attr = TDataStd_Name() + lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + + # Extract the layer name for the shape here + layer_name = name_attr.Get().ToExtString() + + # Add the layer as a subshape entry on the assembly + new_assy.addSubshape( + cq.Shape.cast(cur_shape), layer=layer_name + ) # Find the subshape color, if there is one set for this shape color = Quantity_ColorRGBA() # Extract the color, if present on the shape - if color_tool.GetColor(final_shape, XCAFDoc_ColorSurf, color): + if color_tool.GetColor(cur_shape, XCAFDoc_ColorSurf, color): rgb = color.GetRGB() cq_color = cq.Color( - rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha() + rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha(), ) - else: - cq_color = None - - new_assy.add( - cq_shape, name=f"{ref_name}", loc=cq_loc, color=cq_color - ) - - # Search for subshape names, layers and colors - for j in range(ref_label.NbChildren()): - child_label = ref_label.FindChild(j + 1) - # Save the shape so that we can add it to the subshape data - cur_shape: TopoDS_Shape = shape_tool.GetShape_s(child_label) + # Save the color info via the assembly subshape mechanism + new_assy.addSubshape( + cq.Shape.cast(cur_shape), color=cq_color + ) - # Find the layer name, if there is one set for this shape - layers = TDF_LabelSequence() - layer_tool.GetLayers(child_label, layers) - for i in range(1, layers.Length() + 1): - lbl = layers.Value(i) + # Iterate through all the attributes looking for subshape names. + # This is safer than trying to access the attributes directly with + # FindAttribute because it will cause a segfault in certain cases. + attr_iterator = TDF_AttributeIterator(child_label) + while attr_iterator.More(): + current_attr = attr_iterator.Value() + + # TNaming_NamedShape is used to store and manage references to + # topological shapes, and its attributes can be accessed directly. + # XCAFDoc_GraphNode contains a graph of labels, and so we must + # follow the branch back to a father. + if isinstance(current_attr, XCAFDoc_GraphNode): + lbl = current_attr.GetFather(1).Label() + + # Find the name attribute and add it for the subshape name_attr = TDataStd_Name() - lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) - - # Extract the layer name for the shape here - layer_name = name_attr.Get().ToExtString() - - # Add the layer as a subshape entry on the assembly - new_assy.addSubshape( - cq.Shape.cast(cur_shape), layer=layer_name - ) - - # Find the subshape color, if there is one set for this shape - color = Quantity_ColorRGBA() - # Extract the color, if present on the shape - if color_tool.GetColor(cur_shape, XCAFDoc_ColorSurf, color): - rgb = color.GetRGB() - cq_color = cq.Color( - rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha(), - ) - - # Save the color info via the assembly subshape mechanism - new_assy.addSubshape( - cq.Shape.cast(cur_shape), color=cq_color - ) - - # Iterate through all the attributes looking for subshape names. - # This is safer than trying to access the attributes directly with - # FindAttribute because it will cause a segfault in certain cases. - attr_iterator = TDF_AttributeIterator(child_label) - while attr_iterator.More(): - current_attr = attr_iterator.Value() - - # TNaming_NamedShape is used to store and manage references to - # topological shapes, and its attributes can be accessed directly. - # XCAFDoc_GraphNode contains a graph of labels, and so we must - # follow the branch back to a father. - if ( - current_attr.DynamicType().Name() - == "XCAFDoc_GraphNode" - ): - # Only the GraphNode should have this method - if hasattr(current_attr, "GetFather"): - lbl = current_attr.GetFather(1).Label() - - # Find the name attribute and add it for the subshape - name_attr = TDataStd_Name() - if lbl.FindAttribute( - TDataStd_Name.GetID_s(), name_attr - ): - # Save this as the name of the subshape - new_assy.addSubshape( - cq.Shape.cast(cur_shape), - name=name_attr.Get().ToExtString(), - ) - elif ( - current_attr.DynamicType().Name() - == "TNaming_NamedShape" + if lbl.FindAttribute( + TDataStd_Name.GetID_s(), name_attr ): - # Save the shape so that we can add it to the subshape data - cur_shape: TopoDS_Shape = shape_tool.GetShape_s( - child_label + # Save this as the name of the subshape + new_assy.addSubshape( + cq.Shape.cast(cur_shape), + name=name_attr.Get().ToExtString(), ) + elif isinstance(current_attr, TNaming_NamedShape): + # Save the shape so that we can add it to the subshape data + cur_shape = shape_tool.GetShape_s(child_label) - attr_iterator.Next() + attr_iterator.Next() - return new_assy + return new_assy # Document that the step file will be read into doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf")) From 8b5082c05ecfdcb5fc9eea5015f03fd5f3010d3d Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 1 Aug 2025 12:44:10 +0200 Subject: [PATCH 090/124] Add gradient to reparametrize --- cadquery/occ_impl/nurbs.py | 56 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 61fe863a3..3462c4c65 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -19,7 +19,6 @@ ) from OCP.gp import gp_Pnt from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace -from OCP.Geom import Geom_BSplineSurface from .shapes import Face, Edge @@ -1623,6 +1622,8 @@ def cost(u: Array) -> float: pts = [] for i, ui in enumerate(us): + + # evaluate pts.append(curves[i](ui)) # parametric distance between points on the same curve @@ -1637,7 +1638,58 @@ def cost(u: Array) -> float: return w1 * rv1 + w2 * rv2 - usol, _, _ = fmin_l_bfgs_b(cost, u0, approx_grad=True) + def grad(u: Array) -> Array: + + rv1 = np.zeros_like(u) + us = np.split(u, n_curves) + + pts = [] + tgts = [] + + for i, ui in enumerate(us): + + # evaluate up to 1st derivative + tmp = curves[i].der(ui, 1) + + pts.append(tmp[:, 0, :].squeeze()) + tgts.append(tmp[:, 1, :].squeeze()) + + # parametric distance between points on the same curve + delta = np.roll(ui, -1) - ui + delta[-1] += 1 + delta *= -2 + delta -= np.roll(delta, 1) + + rv1[i * n : (i + 1) * n] = delta + + rv2 = np.zeros_like(u) + + for i, _ in enumerate(us): + # geometric distance between points on adjecent curves + + # first profile + if i == 0: + p1, p2, t = pts[i], pts[i + 1], tgts[i] + + rv2[i * n : (i + 1) * n] = (2 / scale ** 2 * (p1 - p2) * t).sum(1) + + # middle profile + elif i + 1 < n_curves: + p1, p2, t = pts[i], pts[i + 1], tgts[i] + p0 = pts[i - 1] + + rv2[i * n : (i + 1) * n] = (2 / scale ** 2 * (p1 - p2) * t).sum(1) + rv2[i * n : (i + 1) * n] += (-2 / scale ** 2 * (p0 - p1) * t).sum(1) + + # last profile + else: + p1, p2, t = pts[i - 1], pts[i], tgts[i] + + rv2[i * n : (i + 1) * n] = (-2 / scale ** 2 * (p1 - p2) * t).sum(1) + + return w1 * rv1 + w2 * rv2 + + usol, _, _ = fmin_l_bfgs_b(cost, u0, grad) us = np.split(usol, n_curves) From 64a2eaa4a6886dacb462719517036c097a3087b9 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 1 Aug 2025 14:52:35 +0200 Subject: [PATCH 091/124] Add test for reparametrize --- tests/test_nurbs.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_nurbs.py b/tests/test_nurbs.py index 31b561abd..19931463a 100644 --- a/tests/test_nurbs.py +++ b/tests/test_nurbs.py @@ -11,6 +11,7 @@ periodicApproximate, periodicLoft, loft, + reparametrize, ) from cadquery.func import circle @@ -50,6 +51,18 @@ def trimmed_circles() -> list[Curve]: return cs +@fixture +def rotated_circles() -> list[Curve]: + + pts1 = np.array([v.toTuple() for v in circle(1).sample(100)[0]]) + pts2 = np.array([v.toTuple() for v in circle(1).moved(z=1, rz=90).sample(100)[0]]) + + c1 = periodicApproximate(pts1) + c2 = periodicApproximate(pts2) + + return [c1, c2] + + def test_periodic_dm(): knots = np.linspace(0, 1, 5) @@ -245,3 +258,21 @@ def test_loft(circles, trimmed_circles): surf2 = loft(*trimmed_circles) assert surf2.face().isValid() + + +def test_reparametrize(rotated_circles): + + c1, c2 = rotated_circles + + # this surface will be twisted + surf = loft(c1, c2, order=2, lam=1e-6) + + # this should adjust the paramatrizations + c1r, c2r = reparametrize(c1, c2) + + # resulting loft should not be twisted + surfr = loft(c1r, c2r, order=2, lam=1e-6) + + # assert that the surface is indeed not twisted + assert surfr.face().Area() == approx(2 * np.pi, 1e-3) + assert surfr.face().Area() >= 1.01 * surf.face().Area() From 8251d92be2d5a26e2b85afa5c3bf58d1612b4a8b Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 4 Aug 2025 11:32:36 -0400 Subject: [PATCH 092/124] Fix lack of application to the top-level assembly --- cadquery/occ_impl/importers/assembly.py | 8 ++++++++ tests/test_assembly.py | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 431d2b4a8..ffc1023d3 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -184,6 +184,14 @@ def _process_label(lbl: TDF_Label): top_level_label.FindAttribute(TDataStd_Name.GetID_s(), name_attr) assy.name = str(name_attr.Get().ToExtString()) + # Get the location of the top-level component + comp_labels = TDF_LabelSequence() + shape_tool.GetComponents_s(top_level_label, comp_labels) + comp_label = comp_labels.Value(1) + loc = shape_tool.GetLocation_s(comp_label) + if loc and not loc.IsIdentity(): + assy.loc = cq.Location(loc) + # Start the recursive processing of labels imported_assy = _process_label(top_level_label) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index f045671d5..c33b6e361 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -996,7 +996,7 @@ def test_plain_assembly_import(tmp_path_factory): cube_3 = cq.Workplane().box(5, 5, 5) cube_4 = cq.Workplane().box(5, 5, 5) - assy = cq.Assembly(name="top_level") + assy = cq.Assembly(name="top_level", loc=cq.Location(10, 10, 10)) assy.add(cube_1, color=cq.Color("green")) assy.add(cube_2, loc=cq.Location((10, 10, 10)), color=cq.Color("red")) assy.add(cube_3, loc=cq.Location((-10, -10, -10)), color=cq.Color("red")) @@ -1015,6 +1015,9 @@ def test_plain_assembly_import(tmp_path_factory): assert imported_assy.children[2].loc.toTuple()[0] == (-10.0, -10.0, -10.0,) assert imported_assy.children[3].loc.toTuple()[0] == (10.0, -10.0, -10.0,) + # Make sure the location of the top-level assembly was preserved + assert imported_assy.loc.toTuple() == cq.Location((10, 10, 10)).toTuple() + # Check the colors assert pytest.approx( imported_assy.children[0].children[0].color.toTuple(), rel=0.01 From 1543908cc516039a85752137a2c7fe80731df474 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 4 Aug 2025 13:07:14 -0400 Subject: [PATCH 093/124] Trying to increase test coverage high enough --- cadquery/occ_impl/importers/assembly.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index ffc1023d3..182578160 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -189,8 +189,7 @@ def _process_label(lbl: TDF_Label): shape_tool.GetComponents_s(top_level_label, comp_labels) comp_label = comp_labels.Value(1) loc = shape_tool.GetLocation_s(comp_label) - if loc and not loc.IsIdentity(): - assy.loc = cq.Location(loc) + assy.loc = cq.Location(loc) # Start the recursive processing of labels imported_assy = _process_label(top_level_label) From 57af33b6dd5288b1c5ad9034d42056a63e78a184 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 8 Aug 2025 13:51:15 +0200 Subject: [PATCH 094/124] Enable oversampling in reparametrize --- cadquery/occ_impl/nurbs.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 3462c4c65..8363918ca 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -1,4 +1,4 @@ -#%% imports +# %% imports import numpy as np import scipy.sparse as sp @@ -31,7 +31,7 @@ ) -#%% internal helpers +# %% internal helpers def _colPtsArray(pts: NDArray) -> TColgp_Array1OfPnt: @@ -79,7 +79,7 @@ def _colIntArray(knots: NDArray) -> TColStd_Array1OfInteger: return rv -#%% vocabulary types +# %% vocabulary types Array = ndarray # NDArray[np.floating] ArrayI = ndarray # NDArray[np.int_] @@ -290,7 +290,7 @@ def der(self, u: Array, v: Array, dorder: int) -> Array: ) -#%% basis functions +# %% basis functions @njiti @@ -539,7 +539,7 @@ def nbBasisDer(i: int, u: float, order: int, dorder: int, knots: Array, out: Arr r *= order - k -#%% evaluation +# %% evaluation @njit @@ -878,7 +878,7 @@ def nbSurfaceDer( return out -#%% matrices +# %% matrices @njit @@ -1249,7 +1249,7 @@ def discretePenalty(us: Array, order: int, splineorder: int = 3) -> COO: return rv -#%% construction +# %% construction @multidispatch @@ -1601,7 +1601,7 @@ def loft( def reparametrize( - *curves: Curve, n: int = 100, w1: float = 1, w2: float = 1e-1 + *curves: Curve, n: int = 100, knots: int = 100, w1: float = 1, w2: float = 1e-1 ) -> List[Curve]: from scipy.optimize import fmin_l_bfgs_b @@ -1693,10 +1693,12 @@ def grad(u: Array) -> Array: us = np.split(usol, n_curves) - return periodicApproximate([crv(u) for crv, u in zip(curves, us)], knots=n, lam=0) + return periodicApproximate( + [crv(u) for crv, u in zip(curves, us)], knots=knots, lam=0 + ) -#%% for removal? +# %% for removal? @njit def findSpan(v, knots): From 820efcc309edd1515ea1caebee93ca8c15d90309 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:05:15 +0200 Subject: [PATCH 095/124] Adding approximate 2D --- cadquery/occ_impl/nurbs.py | 41 +++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 8363918ca..36429e8a5 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -933,7 +933,7 @@ def designMatrix(u: Array, order: int, knots: Array, periodic: bool = False) -> return rv -# @njit +@njit def designMatrix2D( uv: Array, uorder: int, @@ -1532,6 +1532,45 @@ def approximate( return rv +def approximate2D( + data: Array, + uv: Array, + uorder: int, + vorder: int, + uknots: int | Array = 50, + vknots: int | Array = 50, + uperiodic: bool = False, + vperiodic: bool = False, +) -> Surface: + """ + Simple 2D surface approximation (without any penalty). + """ + + # process the knots + uknots_ = uknots if isinstance(uknots, Array) else np.linspace(0, 1, uknots) + vknots_ = vknots if isinstance(vknots, Array) else np.linspace(0, 1, vknots) + + # create the desing matrix + C = designMatrix2D(uv, uorder, vorder, uknots_, vknots_, uperiodic, vperiodic).csc() + + # solve normal equations + D, L, P = ldl(C.T @ C, False) + pts = ldl_solve(C.T @ data, D, L, P).toarray() + + # construt the result + rv = Surface( + pts.reshape((len(uknots_) - int(uperiodic), len(vknots_) - int(vperiodic), 3)), + uknots_, + vknots_, + uorder, + vorder, + uperiodic, + vperiodic, + ) + + return rv + + def periodicLoft(*curves: Curve, order: int = 3) -> Surface: nknots: int = len(curves) + 1 From 93e0e998f718ff7f90214365f14a7442468eb0ae Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Mon, 11 Aug 2025 07:47:13 +0200 Subject: [PATCH 096/124] Add penalty matrix 2D --- cadquery/occ_impl/nurbs.py | 84 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 36429e8a5..4a3fa6a29 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -888,10 +888,6 @@ def designMatrix(u: Array, order: int, knots: Array, periodic: bool = False) -> """ # extend the knots - knots_ext = np.concat( - (knots[-order:-1] - knots[-1], knots, knots[-1] + knots[1:order]) - ) - u_, knots_ext, minspan, maxspan, deltaspan = _preprocess(u, order, knots, periodic) # number of param values @@ -947,6 +943,7 @@ def designMatrix2D( Create a sparse tensor product design matrix. """ + # extend the knots and preprocess u_, uknots_ext, minspanu, maxspanu, deltaspanu = _preprocess( uv[:, 0], uorder, uknots, uperiodic ) @@ -1249,6 +1246,85 @@ def discretePenalty(us: Array, order: int, splineorder: int = 3) -> COO: return rv +@njit +def penaltyMatrix2D( + uv: Array, + uorder: int, + vorder: int, + dorder: int, + uknots: Array, + vknots: Array, + uperiodic: bool = False, + vperiodic: bool = False, +) -> list[COO]: + """ + Create sparse tensor product 2D derivative matrices. + """ + + # extend the knots and preprocess + u_, uknots_ext, minspanu, maxspanu, deltaspanu = _preprocess( + uv[:, 0], uorder, uknots, uperiodic + ) + v_, vknots_ext, minspanv, maxspanv, deltaspanv = _preprocess( + uv[:, 1], vorder, vknots, vperiodic + ) + + # number of param values + ni = uv.shape[0] + + # chunck size + nu = uorder + 1 + nv = vorder + 1 + nj = nu * nv + + # number of basis + nu_total = maxspanu + nv_total = maxspanv + + # temp chunck storage + utemp = np.zeros((dorder + 1, nu)) + vtemp = np.zeros((dorder + 1, nv)) + + # initialize the emptry matrices + rv = [] + for i in range(dorder + 1): + rv.append( + COO( + i=np.empty(ni * nj, dtype=np.int64), + j=np.empty(ni * nj, dtype=np.int64), + v=np.empty(ni * nj), + ) + ) + + # loop over param values + for i in range(ni): + ui, vi = u_[i], v_[i] + + # find the supporting span + uspan = nbFindSpan(ui, uorder, uknots, minspanu, maxspanu) + deltaspanu + vspan = nbFindSpan(vi, vorder, vknots, minspanv, maxspanv) + deltaspanv + + # evaluate non-zero functions + nbBasisDer(uspan, ui, uorder, dorder, uknots_ext, utemp) + nbBasisDer(vspan, vi, vorder, dorder, vknots_ext, vtemp) + + # update the matrices - iterate over all derivative paris + for dv in range(dorder + 1): + + du = dorder - dv # NB: du + dv == dorder + + rv[dv].i[i * nj : (i + 1) * nj] = i + rv[dv].j[i * nj : (i + 1) * nj] = ( + ((uspan - uorder + np.arange(nu)) % nu_total) * nv_total + + ((vspan - vorder + np.arange(nv)) % nv_total)[:, np.newaxis] + ).ravel() + rv[dv].v[i * nj : (i + 1) * nj] = ( + utemp[du, :] * vtemp[dv, :, np.newaxis] + ).ravel() + + return rv + + # %% construction From 5b66418952599cb51ce0987b5959ceb01c590b61 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Wed, 13 Aug 2025 08:02:21 +0200 Subject: [PATCH 097/124] Add penalties to approximate2D --- cadquery/occ_impl/nurbs.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 4a3fa6a29..1ae55f184 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -6,6 +6,8 @@ from typing import NamedTuple, Optional, Tuple, List, Union, cast +from math import comb + from numpy.typing import NDArray from numpy import linspace, ndarray @@ -1617,6 +1619,8 @@ def approximate2D( vknots: int | Array = 50, uperiodic: bool = False, vperiodic: bool = False, + penalty: int = 3, + lam: float = 0, ) -> Surface: """ Simple 2D surface approximation (without any penalty). @@ -1629,8 +1633,34 @@ def approximate2D( # create the desing matrix C = designMatrix2D(uv, uorder, vorder, uknots_, vknots_, uperiodic, vperiodic).csc() + # handle penalties if requested + if lam: + # construct the penalty grid + Up, Vp = np.meshgrid( + np.linspace(uknots_[0], uknots_[-1], 2 * len(uknots_) * uorder), + np.linspace(vknots_[0], vknots_[-1], 2 * len(vknots_) * vorder), + ) + up = Up.ravel() + vp = Vp.ravel() + uvp = np.column_stack((up, vp)) + + # construct the derivative matrices + penalties = penaltyMatrix2D( + uvp, uorder, vorder, penalty, uknots_, vknots_, uperiodic, vperiodic, + ) + + # augment the design matrix + tmp = [comb(penalty, i) * penalties[i].csc() for i in range(penalty + 1)] + Lu = uknots_[-1] - uknots_[0] # v lenght of the parametric domain + Lv = vknots_[-1] - vknots_[0] # u lenght of the parametric domain + P = Lu * Lv / len(up) * sp.vstack(tmp) + + CtC = C.T @ C + lam * P.T @ P + else: + CtC = C.T @ C + # solve normal equations - D, L, P = ldl(C.T @ C, False) + D, L, P = ldl(CtC, False) pts = ldl_solve(C.T @ data, D, L, P).toarray() # construt the result From 281fe44e30d5cacc175ee333fc96a8b54e7f9ddd Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 15 Aug 2025 15:52:37 +0200 Subject: [PATCH 098/124] Add fairing, normals and offset --- cadquery/occ_impl/nurbs.py | 169 ++++++++++++++++++++++++++++++++----- 1 file changed, 149 insertions(+), 20 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 1ae55f184..cc8793e33 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -291,6 +291,21 @@ def der(self, u: Array, v: Array, dorder: int) -> Array: self.vperiodic, ) + def normal(self, u: Array, v: Array) -> Tuple[Array, Array]: + """ + Evaluate surface normals. + """ + + ders = self.der(u, v, 1) + + du = ders[:, 1, 0, :].squeeze() + dv = ders[:, 0, 1, :].squeeze() + + rv = np.atleast_2d(np.cross(du, dv)) + rv /= np.linalg.norm(rv, axis=1)[:, np.newaxis] + + return rv, ders[:, 0, 0, :].squeeze() + # %% basis functions @@ -933,7 +948,8 @@ def designMatrix(u: Array, order: int, knots: Array, periodic: bool = False) -> @njit def designMatrix2D( - uv: Array, + u: Array, + v: Array, uorder: int, vorder: int, uknots: Array, @@ -947,14 +963,14 @@ def designMatrix2D( # extend the knots and preprocess u_, uknots_ext, minspanu, maxspanu, deltaspanu = _preprocess( - uv[:, 0], uorder, uknots, uperiodic + u, uorder, uknots, uperiodic ) v_, vknots_ext, minspanv, maxspanv, deltaspanv = _preprocess( - uv[:, 1], vorder, vknots, vperiodic + v, vorder, vknots, vperiodic ) # number of param values - ni = uv.shape[0] + ni = len(u) # chunck size nu = uorder + 1 @@ -1250,7 +1266,8 @@ def discretePenalty(us: Array, order: int, splineorder: int = 3) -> COO: @njit def penaltyMatrix2D( - uv: Array, + u: Array, + v: Array, uorder: int, vorder: int, dorder: int, @@ -1265,14 +1282,14 @@ def penaltyMatrix2D( # extend the knots and preprocess u_, uknots_ext, minspanu, maxspanu, deltaspanu = _preprocess( - uv[:, 0], uorder, uknots, uperiodic + u, uorder, uknots, uperiodic ) v_, vknots_ext, minspanv, maxspanv, deltaspanv = _preprocess( - uv[:, 1], vorder, vknots, vperiodic + v, vorder, vknots, vperiodic ) # number of param values - ni = uv.shape[0] + ni = len(u) # chunck size nu = uorder + 1 @@ -1327,6 +1344,32 @@ def penaltyMatrix2D( return rv +def uniformGrid( + uknots: Array, + vknots: Array, + uorder: int, + vorder: int, + uperiodic: bool, + vperiodic: bool, +) -> Tuple[Array, Array]: + """ + Create a uniform grid for evaluating penalties. + """ + + Up, Vp = np.meshgrid( + np.linspace( + uknots[0], uknots[-1], 2 * len(uknots) * uorder, endpoint=not uperiodic + ), + np.linspace( + vknots[0], vknots[-1], 2 * len(vknots) * vorder, endpoint=not vperiodic + ), + ) + up = Up.ravel() + vp = Vp.ravel() + + return up, vp + + # %% construction @@ -1342,8 +1385,9 @@ def periodicApproximate( npts = data.shape[0] - # parametrize the points - us = linspace(0, 1, npts, endpoint=False) + # parametrize the points if needed + if us is None: + us = linspace(0, 1, npts, endpoint=False) # construct the knot vector if isinstance(knots, int): @@ -1612,7 +1656,8 @@ def approximate( def approximate2D( data: Array, - uv: Array, + u: Array, + v: Array, uorder: int, vorder: int, uknots: int | Array = 50, @@ -1631,22 +1676,18 @@ def approximate2D( vknots_ = vknots if isinstance(vknots, Array) else np.linspace(0, 1, vknots) # create the desing matrix - C = designMatrix2D(uv, uorder, vorder, uknots_, vknots_, uperiodic, vperiodic).csc() + C = designMatrix2D( + u, v, uorder, vorder, uknots_, vknots_, uperiodic, vperiodic + ).csc() # handle penalties if requested if lam: # construct the penalty grid - Up, Vp = np.meshgrid( - np.linspace(uknots_[0], uknots_[-1], 2 * len(uknots_) * uorder), - np.linspace(vknots_[0], vknots_[-1], 2 * len(vknots_) * vorder), - ) - up = Up.ravel() - vp = Vp.ravel() - uvp = np.column_stack((up, vp)) + up, vp = uniformGrid(uknots_, vknots_, uorder, vorder, uperiodic, vperiodic) # construct the derivative matrices penalties = penaltyMatrix2D( - uvp, uorder, vorder, penalty, uknots_, vknots_, uperiodic, vperiodic, + up, vp, uorder, vorder, penalty, uknots_, vknots_, uperiodic, vperiodic, ) # augment the design matrix @@ -1677,6 +1718,60 @@ def approximate2D( return rv +def fairPenalty(surf: Surface, penalty: int, lam: float) -> Surface: + """ + Penalty-based surface fairing. + """ + + uknots = surf.uknots + vknots = surf.vknots + pts = surf.pts.reshape((-1, 3)) + + # generate penalty grid + up, vp = uniformGrid( + uknots, vknots, surf.uorder, surf.vorder, surf.uperiodic, surf.vperiodic + ) + + # generate penalty matrix + penalties = penaltyMatrix2D( + up, + vp, + surf.uorder, + surf.vorder, + penalty, + surf.uknots, + surf.vknots, + surf.uperiodic, + surf.vperiodic, + ) + + tmp = [comb(penalty, i) * penalties[i].csc() for i in range(penalty + 1)] + Lu = uknots[-1] - uknots[0] # v lenght of the parametric domain + Lv = vknots[-1] - vknots[0] # u lenght of the parametric domain + P = Lu * Lv / len(up) * sp.vstack(tmp) + + # form and solve normal equations + CtC = sp.identity(pts.shape[0]) + lam * P.T @ P + + D, L, P = ldl(CtC, False) + pts_new = ldl_solve(pts, D, L, P).toarray() + + # construt the result + rv = Surface( + pts_new.reshape( + (len(uknots) - int(surf.uperiodic), len(vknots) - int(surf.vperiodic), 3) + ), + uknots, + vknots, + surf.uorder, + surf.vorder, + surf.uperiodic, + surf.vperiodic, + ) + + return rv + + def periodicLoft(*curves: Curve, order: int = 3) -> Surface: nknots: int = len(curves) + 1 @@ -1843,6 +1938,40 @@ def grad(u: Array) -> Array: ) +def offset(surf: Surface, d: float, lam: float = 1e-3) -> Surface: + """ + Simple approximate offset. + """ + + # construct the knot grid + U, V = np.meshgrid( + np.linspace(surf.uknots[0], surf.uknots[-1], surf.uorder * len(surf.uknots)), + np.linspace(surf.vknots[0], surf.vknots[-1], surf.vorder * len(surf.uknots)), + ) + + us = U.ravel() + vs = V.ravel() + + # evaluate the normals + ns, pts = surf.normal(us, vs) + + # move the control points + pts += d * ns + + return approximate2D( + pts, + us, + vs, + surf.uorder, + surf.vorder, + surf.uknots, + surf.vknots, + surf.uperiodic, + surf.vperiodic, + lam=lam, + ) + + # %% for removal? @njit def findSpan(v, knots): From fa52065bea92dffc39bd9c720928d4ea40feab36 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 15 Aug 2025 16:47:14 +0200 Subject: [PATCH 099/124] fix test --- tests/test_nurbs.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_nurbs.py b/tests/test_nurbs.py index 19931463a..9f57a8a46 100644 --- a/tests/test_nurbs.py +++ b/tests/test_nurbs.py @@ -87,9 +87,7 @@ def test_dm_2d(): vparams = np.linspace(0, 1, 100) vorder = 2 - params = np.column_stack((uparams, vparams)) - - res = designMatrix2D(params, uorder, vorder, uknots, vknots) + res = designMatrix2D(uparams, vparams, uorder, vorder, uknots, vknots) C = res.coo() From f7ce6c07fbabbf0fedb015fd904f0c7f27a98905 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Mon, 25 Aug 2025 15:39:47 -0400 Subject: [PATCH 100/124] Implemented changes based on PR from Adam --- cadquery/assembly.py | 4 ++ cadquery/occ_impl/importers/assembly.py | 36 +++++++++++----- tests/test_assembly.py | 55 +++++++++++++++---------- 3 files changed, 63 insertions(+), 32 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index 401bac658..e13e2416a 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -156,6 +156,10 @@ def _copy(self) -> "Assembly": rv = self.__class__(self.obj, self.loc, self.name, self.color, self.metadata) + rv._subshape_colors = dict(self._subshape_colors) + rv._subshape_names = dict(self._subshape_names) + rv._subshape_layers = dict(self._subshape_layers) + for ch in self.children: ch_copy = ch._copy() ch_copy.parent = rv diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 182578160..979714ce4 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -23,7 +23,7 @@ def importStep(assy: AssemblyProtocol, path: str): :return: None """ - def _process_label(lbl: TDF_Label): + def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): """ Recursive method to process the assembly in a top-down manner. """ @@ -52,8 +52,13 @@ def _process_label(lbl: TDF_Label): ref_name = str(ref_name_attr.Get().ToExtString()) if shape_tool.IsAssembly_s(ref_label): + sub_assy = cq.Assembly(name=ref_name) + # Recursively process subassemblies - sub_assy = _process_label(ref_label) + _ = _process_label(ref_label, sub_assy) + + # Add the subassy + parent.add(sub_assy, name=ref_name, loc=cq_loc) # Add the appropriate attributes to the subassembly new_assy.add(sub_assy, name=f"{ref_name}", loc=cq_loc) @@ -73,9 +78,19 @@ def _process_label(lbl: TDF_Label): else: cq_color = None - new_assy.add( - cq_shape, name=f"{ref_name}", loc=cq_loc, color=cq_color - ) + if ref_name.endswith("_part"): + parent.obj = cq_shape + parent.loc = cq_loc + parent.color = cq_color + + current = parent + else: + tmp = cq.Assembly( + cq_shape, loc=cq_loc, name=ref_name, color=cq_color + ) + parent.add(tmp) + # FIXME + current = parent.children[-1] # Search for subshape names, layers and colors for j in range(ref_label.NbChildren()): @@ -96,7 +111,7 @@ def _process_label(lbl: TDF_Label): layer_name = name_attr.Get().ToExtString() # Add the layer as a subshape entry on the assembly - new_assy.addSubshape( + current.addSubshape( cq.Shape.cast(cur_shape), layer=layer_name ) @@ -110,7 +125,7 @@ def _process_label(lbl: TDF_Label): ) # Save the color info via the assembly subshape mechanism - new_assy.addSubshape( + current.addSubshape( cq.Shape.cast(cur_shape), color=cq_color ) @@ -134,7 +149,7 @@ def _process_label(lbl: TDF_Label): TDataStd_Name.GetID_s(), name_attr ): # Save this as the name of the subshape - new_assy.addSubshape( + current.addSubshape( cq.Shape.cast(cur_shape), name=name_attr.Get().ToExtString(), ) @@ -144,7 +159,7 @@ def _process_label(lbl: TDF_Label): attr_iterator.Next() - return new_assy + return parent # Document that the step file will be read into doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf")) @@ -192,7 +207,8 @@ def _process_label(lbl: TDF_Label): assy.loc = cq.Location(loc) # Start the recursive processing of labels - imported_assy = _process_label(top_level_label) + imported_assy = cq.Assembly() + _process_label(top_level_label, imported_assy) # Handle a possible extra top-level node. This is done because cq.Assembly.export # adds an extra top-level node which will cause a cascade of diff --git a/tests/test_assembly.py b/tests/test_assembly.py index c33b6e361..4add70ee0 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -904,15 +904,17 @@ def test_assembly_subshape_step_import(tmp_path_factory): assert imported_assy.name == "top_level" # Check the advanced face name - assert len(imported_assy._subshape_names) == 1 - assert list(imported_assy._subshape_names.values())[0] == "cube_1_top_face" + assert len(imported_assy.children[0]._subshape_names) == 1 + assert ( + list(imported_assy.children[0]._subshape_names.values())[0] == "cube_1_top_face" + ) # Check the color - color = list(imported_assy._subshape_colors.values())[0] + color = list(imported_assy.children[0]._subshape_colors.values())[0] assert Quantity_NameOfColor.Quantity_NOC_RED == color.wrapped.GetRGB().Name() # Check the layer info - layer_name = list(imported_assy._subshape_layers.values())[0] + layer_name = list(imported_assy.children[0]._subshape_layers.values())[0] assert layer_name == "cube_1_top_face" @@ -955,17 +957,34 @@ def test_assembly_multi_subshape_step_import(tmp_path_factory): # Check that the top-level assembly name is correct assert imported_assy.name == "top_level" - # Check the advanced face name - assert len(imported_assy._subshape_names) == 2 - assert list(imported_assy._subshape_names.values())[0] == "cube_1_top_face" + # Check the advanced face name for the first cube + assert len(imported_assy.children[0]._subshape_names) == 1 + assert ( + list(imported_assy.children[0]._subshape_names.values())[0] == "cube_1_top_face" + ) + + # Check the color for the first cube + color = list(imported_assy.children[0]._subshape_colors.values())[0] + assert Quantity_NameOfColor.Quantity_NOC_RED == color.wrapped.GetRGB().Name() + + # Check the layer info for the first cube + layer_name = list(imported_assy.children[0]._subshape_layers.values())[0] + assert layer_name == "cube_1_top_face" + + # Check the advanced face name for the second cube + assert len(imported_assy.children[1]._subshape_names) == 1 + assert ( + list(imported_assy.children[1]._subshape_names.values())[0] + == "cube_2_right_face" + ) # Check the color - color = list(imported_assy._subshape_colors.values())[0] + color = list(imported_assy.children[1]._subshape_colors.values())[0] assert Quantity_NameOfColor.Quantity_NOC_RED == color.wrapped.GetRGB().Name() # Check the layer info - layer_name = list(imported_assy._subshape_layers.values())[0] - assert layer_name == "cube_1_top_face" + layer_name = list(imported_assy.children[1]._subshape_layers.values())[0] + assert layer_name == "cube_2_right_face" def test_bad_step_file_import(tmp_path_factory): @@ -1019,33 +1038,25 @@ def test_plain_assembly_import(tmp_path_factory): assert imported_assy.loc.toTuple() == cq.Location((10, 10, 10)).toTuple() # Check the colors - assert pytest.approx( - imported_assy.children[0].children[0].color.toTuple(), rel=0.01 - ) == ( + assert pytest.approx(imported_assy.children[0].color.toTuple(), rel=0.01) == ( 0.0, 1.0, 0.0, 1.0, ) # green - assert pytest.approx( - imported_assy.children[1].children[0].color.toTuple(), rel=0.01 - ) == ( + assert pytest.approx(imported_assy.children[1].color.toTuple(), rel=0.01) == ( 1.0, 0.0, 0.0, 1.0, ) # red - assert pytest.approx( - imported_assy.children[2].children[0].color.toTuple(), rel=0.01 - ) == ( + assert pytest.approx(imported_assy.children[2].color.toTuple(), rel=0.01) == ( 1.0, 0.0, 0.0, 1.0, ) # red - assert pytest.approx( - imported_assy.children[3].children[0].color.toTuple(), rel=0.01 - ) == ( + assert pytest.approx(imported_assy.children[3].color.toTuple(), rel=0.01) == ( 1.0, 0.0, 0.0, From d958c2b287ba1ba1f9234dc56d01907290d09d10 Mon Sep 17 00:00:00 2001 From: Jeremy Wright Date: Wed, 27 Aug 2025 11:37:27 -0400 Subject: [PATCH 101/124] Fix mypy errors --- cadquery/assembly.py | 40 +++++++++++++++++++++---- cadquery/occ_impl/assembly.py | 8 +++++ cadquery/occ_impl/importers/assembly.py | 4 +-- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index e13e2416a..da90d5bda 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -85,10 +85,10 @@ class Assembly(object): loc: Location name: str - color: Optional[Color] + _color: Optional[Color] metadata: Dict[str, Any] - obj: AssemblyObjects + _obj: AssemblyObjects parent: Optional["Assembly"] children: List["Assembly"] @@ -132,10 +132,10 @@ def __init__( """ - self.obj = obj - self.loc = loc if loc else Location() + self._obj = obj + self._loc = loc if loc else Location() self.name = name if name else str(uuid()) - self.color = color if color else None + self._color = color if color else None self.metadata = metadata if metadata else {} self.parent = None @@ -149,6 +149,36 @@ def __init__( self._subshape_colors = {} self._subshape_layers = {} + @property + def obj(self) -> AssemblyObjects: + """Get the root object of the assembly.""" + return self._obj + + @obj.setter + def obj(self, value: AssemblyObjects) -> None: + """Set the root object of the assembly.""" + self._obj = value + + @property + def loc(self) -> Location: + """Get the location of this assembly.""" + return self._loc + + @loc.setter + def loc(self, value: Location) -> None: + """Set the location of this assembly.""" + self._loc = value + + @property + def color(self) -> Optional[Color]: + """Get the color assigned to this assembly.""" + return self._color + + @color.setter + def color(self, value: Optional[Color]) -> None: + """Set the color assigned to this assembly.""" + self._color = value + def _copy(self) -> "Assembly": """ Make a deep copy of an assembly diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index 4b00bb1d0..dab4dfea1 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -160,10 +160,18 @@ def parent(self) -> Optional["AssemblyProtocol"]: def color(self) -> Optional[Color]: ... + @color.setter + def color(self, value: Optional[Color]) -> None: + ... + @property def obj(self) -> AssemblyObjects: ... + @obj.setter + def obj(self, value: AssemblyObjects) -> None: + ... + @property def shapes(self) -> Iterable[Shape]: ... diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 979714ce4..45736e671 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -40,7 +40,7 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): # Get the location of the component label loc = shape_tool.GetLocation_s(comp_label) - cq_loc = cq.Location(loc) if loc else None + cq_loc = cq.Location(loc) if loc else cq.Location() if shape_tool.IsReference_s(comp_label): ref_label = TDF_Label() @@ -90,7 +90,7 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): ) parent.add(tmp) # FIXME - current = parent.children[-1] + current = list(parent.children)[-1] # Search for subshape names, layers and colors for j in range(ref_label.NbChildren()): From b6fa75422b0077d53eaf628d6255cc3ed1d7b6bd Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 5 Sep 2025 12:42:50 +0200 Subject: [PATCH 102/124] Add chord parametrization --- cadquery/occ_impl/nurbs.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index cc8793e33..13ef1e292 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -1373,6 +1373,17 @@ def uniformGrid( # %% construction +def parametrizeChord(data: Array) -> Array: + """ + Chord length parametrization. + """ + + dists = np.linalg.norm(data - np.roll(data, 1), axis=1) + params = np.cumulative_sum(dists) + + return params / params[-1] + + @multidispatch def periodicApproximate( data: Array, From a32f0fa4217ce3aa9fadc2362bd40d492cdb9408 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 5 Sep 2025 18:15:31 +0200 Subject: [PATCH 103/124] Add isSubshape --- cadquery/func.py | 1 + cadquery/occ_impl/shapes.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/cadquery/func.py b/cadquery/func.py index 65f0c8266..d1fc8450e 100644 --- a/cadquery/func.py +++ b/cadquery/func.py @@ -51,4 +51,5 @@ setThreads, project, faceOn, + isSubshape, ) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 5e4e8b22b..fe90077e1 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -6558,6 +6558,20 @@ def check( return rv +def isSubshape(s1: Shape, s2: Shape) -> bool: + """ + Check is s1 is a subshape of s2. + """ + + shape_map = TopTools_IndexedDataMapOfShapeListOfShape() + + TopExp.MapShapesAndAncestors_s( + s2.wrapped, shapetype(s1.wrapped), inverse_shape_LUT[s2.ShapeType()], shape_map + ) + + return shape_map.Contains(s1.wrapped) + + #%% properties From 4326a5984e1340731eee5cef5146c5796e6f1480 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 5 Sep 2025 18:16:33 +0200 Subject: [PATCH 104/124] Add __getitem__ --- cadquery/assembly.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index da90d5bda..2f22f749e 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -770,3 +770,11 @@ def addSubshape( self._subshape_layers[s] = layer return self + + def __getitem__(self, name: str) -> Self: + + return self.objects[name] + + def _ipython_key_completions_(self) -> List[str]: + + return list(self.objects.keys()) From e9bd7e460f3516eb69ba49138de002fa0ec1e2bb Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sat, 6 Sep 2025 11:22:34 +0200 Subject: [PATCH 105/124] Mypy fix and a usability tweak. --- cadquery/assembly.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index 2f22f749e..c0e4d1256 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -659,8 +659,11 @@ def importStep(cls, path: str) -> Self: @classmethod def load(cls, path: str) -> Self: + """ + Alias of importStep for now. + """ - raise NotImplementedError + return cls.importStep(path) @property def shapes(self) -> List[Shape]: @@ -771,10 +774,16 @@ def addSubshape( return self - def __getitem__(self, name: str) -> Self: + def __getitem__(self, name: str) -> "Assembly": + """ + [] based access to children. + """ return self.objects[name] def _ipython_key_completions_(self) -> List[str]: + """ + IPython autocompletion helper. + """ return list(self.objects.keys()) From 8b895e4baed58e6ca0fb794d6748e438f6e6ab9f Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sat, 6 Sep 2025 11:23:16 +0200 Subject: [PATCH 106/124] Generic support for subshape metadata --- cadquery/occ_impl/assembly.py | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index dab4dfea1..0d70e5ed7 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -275,6 +275,7 @@ def toCAF( tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()) tool.SetAutoNaming_s(False) ctool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main()) + ltool = XCAFDoc_DocumentTool.LayerTool_s(doc.Main()) # used to store labels with unique part-color combinations unique_objs: Dict[Tuple[Color, AssemblyObjects], TDF_Label] = {} @@ -317,6 +318,42 @@ def _toCAF(el, ancestor, color) -> TDF_Label: if coloredSTEP and current_color: setColor(lab, current_color, ctool) + # handle subshape names/colors/layers + subshape_colors = el._subshape_colors + subshape_names = el._subshape_names + subshape_layers = el._subshape_layers + + for k in ( + subshape_colors.keys() | subshape_names.keys() | subshape_layers.keys() + ): + + subshape_label = tool.AddSubShape(lab, k.wrapped) + + # In some cases the face may not be considered part of the shape, so protect + # against that + if not subshape_label.IsNull(): + # Set the name + if k in subshape_names: + TDataStd_Name.Set_s( + subshape_label, + TCollection_ExtendedString(subshape_names[k]), + ) + + # Set the individual face color + if k in subshape_colors: + ctool.SetColor( + subshape_label, + subshape_colors[k].wrapped, + XCAFDoc_ColorGen, + ) + + # Also add a layer to hold the face label data + if k in subshape_layers: + layer_label = ltool.AddLayer( + TCollection_ExtendedString(subshape_layers[k]) + ) + ltool.SetLayer(subshape_label, layer_label) + tool.AddComponent(subassy, lab, TopLoc_Location()) # handle colors when *not* exporting to STEP From ba8b452c4e5af9619140bd6180313344aedc1f6f Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sat, 6 Sep 2025 11:49:40 +0200 Subject: [PATCH 107/124] Rework assy import --- cadquery/occ_impl/assembly.py | 12 ++++++ cadquery/occ_impl/importers/assembly.py | 49 +++++++++++++------------ 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index 0d70e5ed7..4a23587fa 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -136,6 +136,15 @@ def __setstate__(self, data: Tuple[float, float, float, float]): class AssemblyProtocol(Protocol): + def __init__( + self, + obj: AssemblyObjects = None, + loc: Optional[Location] = None, + name: Optional[str] = None, + color: Optional[Color] = None, + ): + ... + @property def loc(self) -> Location: ... @@ -247,6 +256,9 @@ def __iter__( ) -> Iterator[Tuple[Shape, str, Location, Optional[Color]]]: ... + def __getitem__(self, name: str) -> Self: + ... + def setName(l: TDF_Label, name: str, tool): diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 45736e671..bc73cdbbe 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -10,7 +10,9 @@ from OCP.XCAFDoc import XCAFDoc_ColorSurf, XCAFDoc_DocumentTool, XCAFDoc_GraphNode import cadquery as cq -from ..assembly import AssemblyProtocol +from ..assembly import AssemblyProtocol, Color +from ..geom import Location +from ..shapes import Shape def importStep(assy: AssemblyProtocol, path: str): @@ -28,9 +30,6 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): Recursive method to process the assembly in a top-down manner. """ - # Instantiate the new assembly - new_assy = cq.Assembly() - # Look for components comp_labels = TDF_LabelSequence() shape_tool.GetComponents_s(lbl, comp_labels) @@ -40,7 +39,7 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): # Get the location of the component label loc = shape_tool.GetLocation_s(comp_label) - cq_loc = cq.Location(loc) if loc else cq.Location() + cq_loc = Location(loc) if loc else Location() if shape_tool.IsReference_s(comp_label): ref_label = TDF_Label() @@ -52,7 +51,8 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): ref_name = str(ref_name_attr.Get().ToExtString()) if shape_tool.IsAssembly_s(ref_label): - sub_assy = cq.Assembly(name=ref_name) + + sub_assy = assy.__class__(name=ref_name) # Recursively process subassemblies _ = _process_label(ref_label, sub_assy) @@ -60,40 +60,45 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): # Add the subassy parent.add(sub_assy, name=ref_name, loc=cq_loc) - # Add the appropriate attributes to the subassembly - new_assy.add(sub_assy, name=f"{ref_name}", loc=cq_loc) elif shape_tool.IsSimpleShape_s(ref_label): + # A single shape needs to be added to the assembly final_shape = shape_tool.GetShape_s(ref_label) - cq_shape = cq.Shape.cast(final_shape) + cq_shape = Shape.cast(final_shape) - # Find the subshape color, if there is one set for this shape + # Find the shape color, if there is one set for this shape color = Quantity_ColorRGBA() + # Extract the color, if present on the shape if color_tool.GetColor(final_shape, XCAFDoc_ColorSurf, color): rgb = color.GetRGB() - cq_color = cq.Color( + cq_color = Color( rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha() ) else: cq_color = None - if ref_name.endswith("_part"): + # this if/else is needed to handle different structures of STEP files + # "*"/"*_part" based naming is the default strucutre produced by CQ + if ref_name.endswith("_part") and ref_name.startswith(parent.name): parent.obj = cq_shape parent.loc = cq_loc parent.color = cq_color + # change the current assy to handle subshape data current = parent else: - tmp = cq.Assembly( + tmp = assy.__class__( cq_shape, loc=cq_loc, name=ref_name, color=cq_color ) parent.add(tmp) - # FIXME - current = list(parent.children)[-1] + + # change the current assy to handle subshape data + current = parent[ref_name] # Search for subshape names, layers and colors for j in range(ref_label.NbChildren()): + child_label = ref_label.FindChild(j + 1) # Save the shape so that we can add it to the subshape data @@ -111,23 +116,19 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): layer_name = name_attr.Get().ToExtString() # Add the layer as a subshape entry on the assembly - current.addSubshape( - cq.Shape.cast(cur_shape), layer=layer_name - ) + current.addSubshape(Shape.cast(cur_shape), layer=layer_name) # Find the subshape color, if there is one set for this shape color = Quantity_ColorRGBA() # Extract the color, if present on the shape if color_tool.GetColor(cur_shape, XCAFDoc_ColorSurf, color): rgb = color.GetRGB() - cq_color = cq.Color( + cq_color = Color( rgb.Red(), rgb.Green(), rgb.Blue(), color.Alpha(), ) # Save the color info via the assembly subshape mechanism - current.addSubshape( - cq.Shape.cast(cur_shape), color=cq_color - ) + current.addSubshape(Shape.cast(cur_shape), color=cq_color) # Iterate through all the attributes looking for subshape names. # This is safer than trying to access the attributes directly with @@ -150,7 +151,7 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): ): # Save this as the name of the subshape current.addSubshape( - cq.Shape.cast(cur_shape), + Shape.cast(cur_shape), name=name_attr.Get().ToExtString(), ) elif isinstance(current_attr, TNaming_NamedShape): @@ -204,7 +205,7 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): shape_tool.GetComponents_s(top_level_label, comp_labels) comp_label = comp_labels.Value(1) loc = shape_tool.GetLocation_s(comp_label) - assy.loc = cq.Location(loc) + assy.loc = Location(loc) # Start the recursive processing of labels imported_assy = cq.Assembly() From 2cc39893ee610f0cdd3dcaacb0eb217a70ad7f3f Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sat, 6 Sep 2025 12:54:59 +0200 Subject: [PATCH 108/124] Start reworking tests --- tests/test_assembly.py | 51 +++++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 4add70ee0..4a04049fa 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -366,18 +366,19 @@ def chassis0_assy(): return chassis +@pytest.fixture def subshape_assy(): """ Builds an assembly with the needed subshapes to test the export and import of STEP files. """ # Create a simple assembly - assy = cq.Assembly(name="top-level") + assy = cq.Assembly(name="top_level") cube_1 = cq.Workplane().box(10.0, 10.0, 10.0) assy.add(cube_1, name="cube_1", color=cq.Color("green")) # Add subshape name, color and layer - assy.addSubshape( + assy["cube_1"].addSubshape( cube_1.faces(">Z").val(), name="cube_1_top_face", color=cq.Color("red"), @@ -391,13 +392,21 @@ def subshape_assy(): ) # Add a subshape face for the cylinder - assy.addSubshape( + assy["cyl_1"].addSubshape( cyl_1.faces("Z").val(), - name="cube_1_top_face", - color=cq.Color("red"), - layer="cube_1_top_face", - ) - # Export the assembly - success = exportStepMeta(assy, assy_step_path) - assert success + subshape_assy.export(assy_step_path) # Import the STEP file back in - imported_assy = cq.Assembly.importStep(assy_step_path) + imported_assy = cq.Assembly.load(assy_step_path) assert imported_assy.name == "top_level" # Check the advanced face name @@ -914,9 +907,15 @@ def test_assembly_subshape_step_import(tmp_path_factory): assert Quantity_NameOfColor.Quantity_NOC_RED == color.wrapped.GetRGB().Name() # Check the layer info - layer_name = list(imported_assy.children[0]._subshape_layers.values())[0] + layer_name = list(imported_assy["cube_1"]._subshape_layers.values())[0] assert layer_name == "cube_1_top_face" + layer_name = list(imported_assy["cyl_1"]._subshape_layers.values())[0] + assert layer_name == "cylinder_bottom_face" + + layer_name = list(imported_assy["cyl_1"]._subshape_layers.values())[1] + assert layer_name == "cylinder_bottom_wire" + def test_assembly_multi_subshape_step_import(tmp_path_factory): """ From d73fb20dee358bdfa687f187583f902e02296377 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sat, 6 Sep 2025 15:39:17 +0200 Subject: [PATCH 109/124] Streamline a bit --- cadquery/assembly.py | 4 ++++ cadquery/occ_impl/assembly.py | 3 +++ cadquery/occ_impl/importers/assembly.py | 14 +++----------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index c0e4d1256..de6e83e93 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -787,3 +787,7 @@ def _ipython_key_completions_(self) -> List[str]: """ return list(self.objects.keys()) + + def __contains__(self, name: str) -> bool: + + return name in self.objects diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index 4a23587fa..ecfa0a100 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -259,6 +259,9 @@ def __iter__( def __getitem__(self, name: str) -> Self: ... + def __contains__(self, name: str) -> bool: + ... + def setName(l: TDF_Label, name: str, tool): diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index bc73cdbbe..f6d4b2e7c 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -9,7 +9,6 @@ from OCP.STEPCAFControl import STEPCAFControl_Reader from OCP.XCAFDoc import XCAFDoc_ColorSurf, XCAFDoc_DocumentTool, XCAFDoc_GraphNode -import cadquery as cq from ..assembly import AssemblyProtocol, Color from ..geom import Location from ..shapes import Shape @@ -208,26 +207,19 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): assy.loc = Location(loc) # Start the recursive processing of labels - imported_assy = cq.Assembly() + imported_assy = assy.__class__() _process_label(top_level_label, imported_assy) # Handle a possible extra top-level node. This is done because cq.Assembly.export # adds an extra top-level node which will cause a cascade of # extras on successive round-trips. exportStepMeta does not add the extra top-level # node and so does not exhibit this behavior. - if assy.name == imported_assy.children[0].name: - imported_assy = imported_assy.children[0] + if assy.name in imported_assy: + imported_assy = imported_assy[assy.name] # Copy all of the children over to the main assembly object for child in imported_assy.children: assy.add(child, name=child.name, color=child.color, loc=child.loc) - # Copy across subshape data - for shape, name in imported_assy._subshape_names.items(): - assy.addSubshape(shape, name=name) - for shape, color in imported_assy._subshape_colors.items(): - assy.addSubshape(shape, color=color) - for shape, layer in imported_assy._subshape_layers.items(): - assy.addSubshape(shape, layer=layer) else: raise ValueError("Step file does not contain an assembly") From 705aafb695b343103ee73da993ed21fe58bcaba7 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sat, 6 Sep 2025 16:49:16 +0200 Subject: [PATCH 110/124] Add validation when adding subshapes --- cadquery/assembly.py | 46 ++++++++++++++++++++++++++++++++++++++---- tests/test_assembly.py | 40 +++++++++++++++++++----------------- 2 files changed, 63 insertions(+), 23 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index de6e83e93..5606e239f 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -14,9 +14,10 @@ from typing_extensions import Literal, Self from typish import instance_of from uuid import uuid1 as uuid +from warnings import warn from .cq import Workplane -from .occ_impl.shapes import Shape, Compound +from .occ_impl.shapes import Shape, Compound, isSubshape, compound from .occ_impl.geom import Location from .occ_impl.assembly import Color from .occ_impl.solver import ( @@ -764,13 +765,33 @@ def addSubshape( :return: The modified assembly. """ + # check if the subshape belongs to the stored object + if any(isSubshape(s, obj) for obj in self.shapes): + assy = self + else: + warn( + "Current node does not contain any Shapes, searching in subnodes. In the future this will result in an error." + ) + + found = False + for ch in self.children: + if any(isSubshape(s, obj) for obj in ch.shapes): + assy = ch + found = True + break + + if not found: + raise ValueError( + f"{s} is not a subshape of the current node or its children" + ) + # Handle any metadata we were passed if name: - self._subshape_names[s] = name + assy._subshape_names[s] = name if color: - self._subshape_colors[s] = color + assy._subshape_colors[s] = color if layer: - self._subshape_layers[s] = layer + assy._subshape_layers[s] = layer return self @@ -791,3 +812,20 @@ def _ipython_key_completions_(self) -> List[str]: def __contains__(self, name: str) -> bool: return name in self.objects + + def __getattr__(self, name: str) -> "Assembly": + """ + . based access to children. + """ + + if name in self.objects: + return self.objects[name] + + raise AttributeError + + def __dir__(self): + """ + Modified __dir__ for autocompletion. + """ + + return list(self.__dict__) + list(ch.name for ch in self.children) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 4a04049fa..c3286f881 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -17,7 +17,7 @@ exportVRML, ) from cadquery.occ_impl.assembly import toJSON, toCAF, toFusedCAF -from cadquery.occ_impl.shapes import Face, box, cone +from cadquery.occ_impl.shapes import Face, box, cone, plane from OCP.gp import gp_XYZ from OCP.TDocStd import TDocStd_Document @@ -723,21 +723,24 @@ def test_meta_step_export(tmp_path_factory): assy.addSubshape(cube_1.faces(">Z").val(), name="cube_1_top_face") assy.addSubshape(cube_1.faces(">Z").val(), color=cq.Color(1.0, 0.0, 0.0)) assy.addSubshape(cube_1.faces(">Z").val(), layer="cube_1_top_face") - assy.addSubshape(cube_2.faces("Z").val(), name="cylinder_1_top_face") - assy.addSubshape(cylinder_1.faces(">Z").val(), color=cq.Color(1.0, 0.0, 0.0)) - assy.addSubshape(cylinder_1.faces(">Z").val(), layer="cylinder_1_top_face") - assy.addSubshape(cylinder_2.faces("Z").val(), name="cone_1_top_face") - assy.addSubshape(cone_1.faces(">Z").val(), color=cq.Color(1.0, 0.0, 0.0)) - assy.addSubshape(cone_1.faces(">Z").val(), layer="cone_1_top_face") - assy.addSubshape(cone_2.faces("Z").val(), name="cylinder_1_top_face") + assy.addSubshape(cylinder_1.faces(">Z").val(), color=cq.Color(1.0, 0.0, 0.0)) + assy.addSubshape(cylinder_1.faces(">Z").val(), layer="cylinder_1_top_face") + assy.addSubshape(cylinder_2.faces("Z").val(), name="cone_1_top_face") + assy.addSubshape(cone_1.faces(">Z").val(), color=cq.Color(1.0, 0.0, 0.0)) + assy.addSubshape(cone_1.faces(">Z").val(), layer="cone_1_top_face") + assy.addSubshape(cone_2.faces("Z").val(), name="cube_top_face") From 74c31da0d2c429b21d1e46a9b7f006479e2e8d72 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sun, 7 Sep 2025 09:56:33 +0200 Subject: [PATCH 111/124] Fix pickling --- cadquery/assembly.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index 5606e239f..7baef8296 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -829,3 +829,17 @@ def __dir__(self): """ return list(self.__dict__) + list(ch.name for ch in self.children) + + def __getstate__(self): + """ + Explicit getstate needed due to getattr. + """ + + return self.__dict__ + + def __setstate__(self, d): + """ + Explicit setstate needed due to getattr. + """ + + self.__dict__ = d From 330101e092b8dd63a28b7c4c435210356d8d84d3 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sun, 7 Sep 2025 15:08:55 +0200 Subject: [PATCH 112/124] Simplify assy import --- cadquery/occ_impl/importers/assembly.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index f6d4b2e7c..03a8da53a 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -95,10 +95,11 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): # change the current assy to handle subshape data current = parent[ref_name] - # Search for subshape names, layers and colors - for j in range(ref_label.NbChildren()): + # iterate over subshape and handle names, layers and colors + subshape_labels = TDF_LabelSequence() + shape_tool.GetSubShapes_s(ref_label, subshape_labels) - child_label = ref_label.FindChild(j + 1) + for child_label in subshape_labels: # Save the shape so that we can add it to the subshape data cur_shape: TopoDS_Shape = shape_tool.GetShape_s(child_label) @@ -106,8 +107,8 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): # Find the layer name, if there is one set for this shape layers = TDF_LabelSequence() layer_tool.GetLayers(child_label, layers) - for i in range(1, layers.Length() + 1): - lbl = layers.Value(i) + + for lbl in layers: name_attr = TDataStd_Name() lbl.FindAttribute(TDataStd_Name.GetID_s(), name_attr) @@ -136,10 +137,9 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): while attr_iterator.More(): current_attr = attr_iterator.Value() - # TNaming_NamedShape is used to store and manage references to - # topological shapes, and its attributes can be accessed directly. - # XCAFDoc_GraphNode contains a graph of labels, and so we must + # XCAFDoc_GraphNode contains a reference to the node, and so we must # follow the branch back to a father. + # After XDE STEP import father contains the name of the subshape. if isinstance(current_attr, XCAFDoc_GraphNode): lbl = current_attr.GetFather(1).Label() @@ -153,9 +153,8 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): Shape.cast(cur_shape), name=name_attr.Get().ToExtString(), ) - elif isinstance(current_attr, TNaming_NamedShape): - # Save the shape so that we can add it to the subshape data - cur_shape = shape_tool.GetShape_s(child_label) + + break attr_iterator.Next() From 59f44d1764a8afad77a8ccedb7b5afcea8c7a60a Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sun, 7 Sep 2025 17:09:54 +0200 Subject: [PATCH 113/124] Fix subshape name handling --- cadquery/occ_impl/exporters/assembly.py | 1 + cadquery/occ_impl/importers/assembly.py | 44 +++++++++---------------- 2 files changed, 17 insertions(+), 28 deletions(-) diff --git a/cadquery/occ_impl/exporters/assembly.py b/cadquery/occ_impl/exporters/assembly.py index 328d8c04a..336c57b9e 100644 --- a/cadquery/occ_impl/exporters/assembly.py +++ b/cadquery/occ_impl/exporters/assembly.py @@ -98,6 +98,7 @@ def exportAssembly( writer.SetNameMode(True) Interface_Static.SetIVal_s("write.surfacecurve.mode", pcurves) Interface_Static.SetIVal_s("write.precision.mode", precision_mode) + Interface_Static.SetIVal_s("write.stepcaf.subshapes.name", 1) writer.Transfer(doc, STEPControl_StepModelType.STEPControl_AsIs) status = writer.Write(path) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 03a8da53a..95892427b 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -8,6 +8,7 @@ from OCP.TNaming import TNaming_NamedShape from OCP.STEPCAFControl import STEPCAFControl_Reader from OCP.XCAFDoc import XCAFDoc_ColorSurf, XCAFDoc_DocumentTool, XCAFDoc_GraphNode +from OCP.Interface import Interface_Static from ..assembly import AssemblyProtocol, Color from ..geom import Location @@ -104,6 +105,19 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): # Save the shape so that we can add it to the subshape data cur_shape: TopoDS_Shape = shape_tool.GetShape_s(child_label) + # Handle subshape name + name_attr = TDataStd_Name() + + if child_label.IsAttribute(TDataStd_Name.GetID_s()): + child_label.FindAttribute( + TDataStd_Name.GetID_s(), name_attr + ) + + current.addSubshape( + Shape.cast(cur_shape), + name=name_attr.Get().ToExtString(), + ) + # Find the layer name, if there is one set for this shape layers = TDF_LabelSequence() layer_tool.GetLayers(child_label, layers) @@ -130,34 +144,6 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): # Save the color info via the assembly subshape mechanism current.addSubshape(Shape.cast(cur_shape), color=cq_color) - # Iterate through all the attributes looking for subshape names. - # This is safer than trying to access the attributes directly with - # FindAttribute because it will cause a segfault in certain cases. - attr_iterator = TDF_AttributeIterator(child_label) - while attr_iterator.More(): - current_attr = attr_iterator.Value() - - # XCAFDoc_GraphNode contains a reference to the node, and so we must - # follow the branch back to a father. - # After XDE STEP import father contains the name of the subshape. - if isinstance(current_attr, XCAFDoc_GraphNode): - lbl = current_attr.GetFather(1).Label() - - # Find the name attribute and add it for the subshape - name_attr = TDataStd_Name() - if lbl.FindAttribute( - TDataStd_Name.GetID_s(), name_attr - ): - # Save this as the name of the subshape - current.addSubshape( - Shape.cast(cur_shape), - name=name_attr.Get().ToExtString(), - ) - - break - - attr_iterator.Next() - return parent # Document that the step file will be read into @@ -170,6 +156,8 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): step_reader.SetLayerMode(True) step_reader.SetSHUOMode(True) + Interface_Static.SetIVal_s("read.stepcaf.subshapes.name", 1) + # Read the STEP file status = step_reader.ReadFile(path) if status != IFSelect_RetDone: From a8f2fda23ca010bb3cf57d7c483d2ea76b6fc18a Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sun, 7 Sep 2025 18:12:52 +0200 Subject: [PATCH 114/124] Simplify --- cadquery/occ_impl/assembly.py | 45 ++++++++++++++++------------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index ecfa0a100..6fcaefc2d 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -344,30 +344,27 @@ def _toCAF(el, ancestor, color) -> TDF_Label: subshape_label = tool.AddSubShape(lab, k.wrapped) - # In some cases the face may not be considered part of the shape, so protect - # against that - if not subshape_label.IsNull(): - # Set the name - if k in subshape_names: - TDataStd_Name.Set_s( - subshape_label, - TCollection_ExtendedString(subshape_names[k]), - ) - - # Set the individual face color - if k in subshape_colors: - ctool.SetColor( - subshape_label, - subshape_colors[k].wrapped, - XCAFDoc_ColorGen, - ) - - # Also add a layer to hold the face label data - if k in subshape_layers: - layer_label = ltool.AddLayer( - TCollection_ExtendedString(subshape_layers[k]) - ) - ltool.SetLayer(subshape_label, layer_label) + # Sanity check, this is in principle enforced when calling addSubshape + assert not subshape_label.IsNull(), "Invalid subshape" + + # Set the name + if k in subshape_names: + TDataStd_Name.Set_s( + subshape_label, TCollection_ExtendedString(subshape_names[k]), + ) + + # Set the individual subshape color + if k in subshape_colors: + ctool.SetColor( + subshape_label, subshape_colors[k].wrapped, XCAFDoc_ColorGen, + ) + + # Also add a layer to hold the subshape label data + if k in subshape_layers: + layer_label = ltool.AddLayer( + TCollection_ExtendedString(subshape_layers[k]) + ) + ltool.SetLayer(subshape_label, layer_label) tool.AddComponent(subassy, lab, TopLoc_Location()) From 16e2dc02da4625fc0be5f9f81c8ccb18276acde7 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sun, 7 Sep 2025 18:17:29 +0200 Subject: [PATCH 115/124] Additional test --- tests/test_assembly.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index c3286f881..c880b87cf 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -2410,3 +2410,23 @@ def test_step_color(tmp_path_factory): assert "0.47" in line assert "0.25" in line assert "0.18" in line + + +def test_special_methods(subshape_assy): + """ + Smoke-test some special methods. + """ + + subshape_assy.__dir__() + subshape_assy._ipython_key_completions_() + + assert "cube_1" in subshape_assy + + subshape_assy["cube_1"] + subshape_assy.cube_1 + + with pytest.raises(KeyError): + subshape_assy["123456"] + + with pytest.raises(AttributeError): + subshape_assy.cube_123456 From 3c9cc06fc7dcce0fa0674bde2c2ffac164375a10 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sun, 7 Sep 2025 18:18:48 +0200 Subject: [PATCH 116/124] Better test --- tests/test_assembly.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index c880b87cf..8b66eb132 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -2417,9 +2417,8 @@ def test_special_methods(subshape_assy): Smoke-test some special methods. """ - subshape_assy.__dir__() - subshape_assy._ipython_key_completions_() - + assert "cube_1" in subshape_assy.__dir__() + assert "cube_1" in subshape_assy._ipython_key_completions_() assert "cube_1" in subshape_assy subshape_assy["cube_1"] From 99aab0b419a814f7e82e104add7c321befa65b85 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Mon, 8 Sep 2025 07:30:10 +0200 Subject: [PATCH 117/124] Remove some changes --- cadquery/assembly.py | 40 +++++----------------------------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index 7baef8296..88735d478 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -86,10 +86,10 @@ class Assembly(object): loc: Location name: str - _color: Optional[Color] + color: Optional[Color] metadata: Dict[str, Any] - _obj: AssemblyObjects + obj: AssemblyObjects parent: Optional["Assembly"] children: List["Assembly"] @@ -133,10 +133,10 @@ def __init__( """ - self._obj = obj - self._loc = loc if loc else Location() + self.obj = obj + self.loc = loc if loc else Location() self.name = name if name else str(uuid()) - self._color = color if color else None + self.color = color if color else None self.metadata = metadata if metadata else {} self.parent = None @@ -150,36 +150,6 @@ def __init__( self._subshape_colors = {} self._subshape_layers = {} - @property - def obj(self) -> AssemblyObjects: - """Get the root object of the assembly.""" - return self._obj - - @obj.setter - def obj(self, value: AssemblyObjects) -> None: - """Set the root object of the assembly.""" - self._obj = value - - @property - def loc(self) -> Location: - """Get the location of this assembly.""" - return self._loc - - @loc.setter - def loc(self, value: Location) -> None: - """Set the location of this assembly.""" - self._loc = value - - @property - def color(self) -> Optional[Color]: - """Get the color assigned to this assembly.""" - return self._color - - @color.setter - def color(self, value: Optional[Color]) -> None: - """Set the color assigned to this assembly.""" - self._color = value - def _copy(self) -> "Assembly": """ Make a deep copy of an assembly From 6e1651c75bcd00ec1bce23419ed51082de927476 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Tue, 9 Sep 2025 08:53:36 +0200 Subject: [PATCH 118/124] Fix some warnings --- tests/test_assembly.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 8b66eb132..21aa7451c 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -595,8 +595,6 @@ def test_color(): assert c3.wrapped.GetRGB().Red() == 1 assert c3.wrapped.Alpha() == 0.5 - c4 = cq.Color() - with pytest.raises(ValueError): cq.Color("?????") @@ -999,7 +997,7 @@ def test_bad_step_file_import(tmp_path_factory): # Check that an error is raised when trying to import a non-existent STEP file with pytest.raises(ValueError): # Export the assembly - imported_assy = cq.Assembly.importStep(bad_step_path) + cq.Assembly.importStep(bad_step_path) def test_plain_assembly_import(tmp_path_factory): @@ -1317,7 +1315,7 @@ def test_save_stl_formats(nested_assy_sphere): assert os.path.exists("nested.stl") # Trying to read a binary file as UTF-8/ASCII should throw an error - with pytest.raises(UnicodeDecodeError) as info: + with pytest.raises(UnicodeDecodeError): with open("nested.stl", "r") as file: file.read() @@ -1334,7 +1332,7 @@ def test_save_gltf(nested_assy_sphere): assert os.path.exists("nested.glb") # Trying to read a binary file as UTF-8/ASCII should throw an error - with pytest.raises(UnicodeDecodeError) as info: + with pytest.raises(UnicodeDecodeError): with open("nested.glb", "r") as file: file.read() @@ -1349,7 +1347,7 @@ def test_exportGLTF(nested_assy_sphere): # Test binary export inferred from file extension cq.exporters.assembly.exportGLTF(nested_assy_sphere, "nested_export_gltf.glb") - with pytest.raises(UnicodeDecodeError) as info: + with pytest.raises(UnicodeDecodeError): with open("nested_export_gltf.glb", "r") as file: file.read() @@ -1357,7 +1355,7 @@ def test_exportGLTF(nested_assy_sphere): cq.exporters.assembly.exportGLTF( nested_assy_sphere, "nested_export_gltf_2.glb", binary=True ) - with pytest.raises(UnicodeDecodeError) as info: + with pytest.raises(UnicodeDecodeError): with open("nested_export_gltf_2.glb", "r") as file: file.read() From a41a4faba57206a54d38cada6990d2e04619249c Mon Sep 17 00:00:00 2001 From: AU Date: Thu, 11 Sep 2025 18:09:51 +0200 Subject: [PATCH 119/124] Fix top level key in .objects --- cadquery/occ_impl/importers/assembly.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cadquery/occ_impl/importers/assembly.py b/cadquery/occ_impl/importers/assembly.py index 95892427b..7951b1366 100644 --- a/cadquery/occ_impl/importers/assembly.py +++ b/cadquery/occ_impl/importers/assembly.py @@ -184,7 +184,11 @@ def _process_label(lbl: TDF_Label, parent: AssemblyProtocol): # Set the name of the top-level assembly to match the top-level label name_attr = TDataStd_Name() top_level_label.FindAttribute(TDataStd_Name.GetID_s(), name_attr) + + # Manipulation of .objects is needed to maintain consistency + assy.objects.pop(assy.name) assy.name = str(name_attr.Get().ToExtString()) + assy.objects[assy.name] = assy # Get the location of the top-level component comp_labels = TDF_LabelSequence() From f9bc9f5c3bf35623137f1063902be1fe42e44936 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 12 Sep 2025 08:12:56 +0200 Subject: [PATCH 120/124] mypy fix --- cadquery/occ_impl/assembly.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index 6fcaefc2d..11f24ec05 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -181,6 +181,10 @@ def obj(self) -> AssemblyObjects: def obj(self, value: AssemblyObjects) -> None: ... + @property + def objects(self) -> Dict[str, Self]: + ... + @property def shapes(self) -> Iterable[Shape]: ... From 20edf705a97a0cffd21a1795b1551c5e18491be6 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 12 Sep 2025 08:19:09 +0200 Subject: [PATCH 121/124] Check .objects keys in the test --- tests/test_assembly.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 21aa7451c..9ad059afa 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -1145,8 +1145,8 @@ def test_assembly_step_import_roundtrip(tmp_path_factory): round_trip_step_path = os.path.join(tmpdir, "round_trip.step") # Create a sample assembly - assy = cq.Assembly(name="top-level") - assy.add(cq.Workplane().box(10, 10, 10), name="cube_1", color=cq.Color("red")) + assy_orig = cq.Assembly(name="top-level") + assy_orig.add(cq.Workplane().box(10, 10, 10), name="cube_1", color=cq.Color("red")) subshape_assy = cq.Assembly(name="nested-assy") subshape_assy.add( cq.Workplane().cylinder(height=10.0, radius=2.5), @@ -1154,10 +1154,10 @@ def test_assembly_step_import_roundtrip(tmp_path_factory): color=cq.Color("blue"), loc=cq.Location((20, 20, 20)), ) - assy.add(subshape_assy) + assy_orig.add(subshape_assy) # First export - assy.export(round_trip_step_path) + assy_orig.export(round_trip_step_path) # First import assy = cq.Assembly.importStep(round_trip_step_path) @@ -1169,6 +1169,12 @@ def test_assembly_step_import_roundtrip(tmp_path_factory): assy = cq.Assembly.importStep(round_trip_step_path) # Check some general aspects of the assembly structure now + for k in assy_orig.objects: + assert k in assy + + for k in assy.objects: + assert k in assy_orig + assert len(assy.children) == 2 assert assy.name == "top-level" assert assy.children[0].name == "cube_1" From 054f7b2b8d71963640459d04bad1cb5f6cf71dda Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 12 Sep 2025 09:14:56 +0200 Subject: [PATCH 122/124] Black fix --- tests/test_assembly.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_assembly.py b/tests/test_assembly.py index 9ad059afa..cee324a70 100644 --- a/tests/test_assembly.py +++ b/tests/test_assembly.py @@ -1171,10 +1171,10 @@ def test_assembly_step_import_roundtrip(tmp_path_factory): # Check some general aspects of the assembly structure now for k in assy_orig.objects: assert k in assy - + for k in assy.objects: assert k in assy_orig - + assert len(assy.children) == 2 assert assy.name == "top-level" assert assy.children[0].name == "cube_1" From cbbf71deaa6095076d057037b2d253cd6e0f890d Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:01:55 +0200 Subject: [PATCH 123/124] Better conversion --- cadquery/occ_impl/nurbs.py | 37 ++++++++++--------------------------- 1 file changed, 10 insertions(+), 27 deletions(-) diff --git a/cadquery/occ_impl/nurbs.py b/cadquery/occ_impl/nurbs.py index 13ef1e292..4e8bc7fbf 100644 --- a/cadquery/occ_impl/nurbs.py +++ b/cadquery/occ_impl/nurbs.py @@ -146,14 +146,11 @@ def fromEdge(cls, e: Edge): g = e._geomAdaptor().BSpline() - knots = np.array(list(e._geomAdaptor().BSpline().KnotSequence())) + knots = np.repeat(list(g.Knots()), list(g.Multiplicities())) pts = np.array([(p.X(), p.Y(), p.Z()) for p in g.Poles()]) order = g.Degree() periodic = g.IsPeriodic() - if periodic: - knots = knots[order:-order] - return cls(pts, knots, order, periodic) def __call__(self, us: Array) -> Array: @@ -184,21 +181,13 @@ class Surface(NamedTuple): def surface(self) -> Geom_BSplineSurface: - if self.uperiodic: - umults = _colIntArray(np.ones_like(self.uknots, dtype=int)) - uknots = _colRealArray(self.uknots) - else: - unique_knots, mults_arr = np.unique(self.uknots, return_counts=True) - uknots = _colRealArray(unique_knots) - umults = _colIntArray(mults_arr) + unique_knots, mults_arr = np.unique(self.uknots, return_counts=True) + uknots = _colRealArray(unique_knots) + umults = _colIntArray(mults_arr) - if self.vperiodic: - vmults = _colIntArray(np.ones_like(self.vknots, dtype=int)) - vknots = _colRealArray(self.vknots) - else: - unique_knots, mults_arr = np.unique(self.vknots, return_counts=True) - vknots = _colRealArray(unique_knots) - vmults = _colIntArray(mults_arr) + unique_knots, mults_arr = np.unique(self.vknots, return_counts=True) + vknots = _colRealArray(unique_knots) + vmults = _colIntArray(mults_arr) return Geom_BSplineSurface( _colPtsArray2(self.pts), @@ -228,8 +217,8 @@ def fromFace(cls, f: Face): g = cast(Geom_BSplineSurface, f._geomAdaptor()) - uknots = np.array(list(g.UKnotSequence())) - vknots = np.array(list(g.VKnotSequence())) + uknots = np.repeat(list(g.UKnots()), list(g.UMultiplicities())) + vknots = np.repeat(list(g.VKnots()), list(g.VMultiplicities())) tmp = [] for i in range(1, g.NbUPoles() + 1): @@ -248,12 +237,6 @@ def fromFace(cls, f: Face): uperiodic = g.IsUPeriodic() vperiodic = g.IsVPeriodic() - if uperiodic: - uknots = uknots[uorder:-uorder] - - if vperiodic: - vknots = vknots[vorder:-vorder] - return cls(pts, uknots, vknots, uorder, vorder, uperiodic, vperiodic) def __call__(self, u: Array, v: Array) -> Array: @@ -1852,7 +1835,7 @@ def loft( def reparametrize( - *curves: Curve, n: int = 100, knots: int = 100, w1: float = 1, w2: float = 1e-1 + *curves: Curve, n: int = 100, knots: int = 100, w1: float = 1, w2: float = 1 ) -> List[Curve]: from scipy.optimize import fmin_l_bfgs_b From a4d5fc53f38372b23e1c7cc5a7232965ee3458b7 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Wed, 22 Oct 2025 08:36:40 +0200 Subject: [PATCH 124/124] Use upstream assy impl --- cadquery/occ_impl/assembly.py | 67 +++++++++++++++++++------ cadquery/occ_impl/exporters/assembly.py | 59 ++++++++++++++-------- 2 files changed, 89 insertions(+), 37 deletions(-) diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index 8bfd7c6fb..d76422bb7 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -15,8 +15,14 @@ from OCP.TDocStd import TDocStd_Document from OCP.TCollection import TCollection_ExtendedString -from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ColorType, XCAFDoc_ColorGen +from OCP.XCAFDoc import ( + XCAFDoc_DocumentTool, + XCAFDoc_ColorType, + XCAFDoc_ColorGen, +) from OCP.XCAFApp import XCAFApp_Application +from OCP.BinXCAFDrivers import BinXCAFDrivers +from OCP.XmlXCAFDrivers import XmlXCAFDrivers from OCP.TDataStd import TDataStd_Name from OCP.TDF import TDF_Label from OCP.TopLoc import TopLoc_Location @@ -24,6 +30,7 @@ Quantity_ColorRGBA, Quantity_Color, Quantity_TOC_sRGB, + Quantity_TOC_RGB, ) from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse from OCP.TopTools import TopTools_ListOfShape @@ -67,7 +74,7 @@ def __init__(self, name: str): ... @overload - def __init__(self, r: float, g: float, b: float, a: float = 0): + def __init__(self, r: float, g: float, b: float, a: float = 0, srgb: bool = True): """ Construct a Color from RGB(A) values. @@ -75,6 +82,7 @@ def __init__(self, r: float, g: float, b: float, a: float = 0): :param g: green value, 0-1 :param b: blue value, 0-1 :param a: alpha value, 0-1 (default: 0) + :param srgb: srgb/linear rgb switch, bool (default: True) """ ... @@ -106,6 +114,14 @@ def __init__(self, *args, **kwargs): self.wrapped = Quantity_ColorRGBA( Quantity_Color(r, g, b, Quantity_TOC_sRGB), a ) + elif len(args) == 5: + r, g, b, a, srgb = args + self.wrapped = Quantity_ColorRGBA( + Quantity_Color( + r, g, b, Quantity_TOC_sRGB if srgb else Quantity_TOC_RGB + ), + a, + ) else: raise ValueError(f"Unsupported arguments: {args}, {kwargs}") @@ -283,12 +299,19 @@ def toCAF( mesh: bool = False, tolerance: float = 1e-3, angularTolerance: float = 0.1, + binary: bool = True, ) -> Tuple[TDF_Label, TDocStd_Document]: # prepare a doc app = XCAFApp_Application.GetApplication_s() - doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf")) + if binary: + BinXCAFDrivers.DefineFormat_s(app) + doc = TDocStd_Document(TCollection_ExtendedString("BinXCAF")) + else: + XmlXCAFDrivers.DefineFormat_s(app) + doc = TDocStd_Document(TCollection_ExtendedString("XmlXCAF")) + app.InitDocument(doc) tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main()) @@ -297,18 +320,19 @@ def toCAF( ltool = XCAFDoc_DocumentTool.LayerTool_s(doc.Main()) # used to store labels with unique part-color combinations - unique_objs: Dict[Tuple[Color, AssemblyObjects], TDF_Label] = {} + unique_objs: Dict[Tuple[Color | None, AssemblyObjects], TDF_Label] = {} # used to cache unique, possibly meshed, compounds; allows to avoid redundant meshing operations if same object is referenced multiple times in an assy compounds: Dict[AssemblyObjects, Compound] = {} - def _toCAF(el, ancestor, color) -> TDF_Label: + def _toCAF(el: AssemblyProtocol, ancestor: TDF_Label | None) -> TDF_Label: - # create a subassy - subassy = tool.NewShape() - setName(subassy, el.name, tool) + # create a subassy if needed + if el.children: + subassy = tool.NewShape() + setName(subassy, el.name, tool) # define the current color - current_color = el.color if el.color else color + current_color = el.color if el.color else None # add a leaf with the actual part if needed if el.obj: @@ -330,7 +354,7 @@ def _toCAF(el, ancestor, color) -> TDF_Label: compounds[key1] = compound tool.SetShape(lab, compound.wrapped) - setName(lab, f"{el.name}_part", tool) + setName(lab, f"{el.name}_part" if el.children else el.name, tool) unique_objs[key0] = lab # handle colors when exporting to STEP @@ -370,29 +394,42 @@ def _toCAF(el, ancestor, color) -> TDF_Label: ) ltool.SetLayer(subshape_label, layer_label) - tool.AddComponent(subassy, lab, TopLoc_Location()) + if el.children: + lab = tool.AddComponent(subassy, lab, TopLoc_Location()) + setName(lab, f"{el.name}_part", tool) + elif ancestor is not None: + lab = tool.AddComponent(ancestor, lab, el.loc.wrapped) + setName(lab, f"{el.name}", tool) # handle colors when *not* exporting to STEP if not coloredSTEP and current_color: - setColor(subassy, current_color, ctool) + if el.children: + setColor(subassy, current_color, ctool) + + if el.obj: + setColor(lab, current_color, ctool) # add children recursively for child in el.children: - _toCAF(child, subassy, current_color) + _toCAF(child, subassy) - if ancestor: + if ancestor and el.children: tool.AddComponent(ancestor, subassy, el.loc.wrapped) rv = subassy + elif ancestor: + rv = ancestor else: # update the top level location rv = TDF_Label() # NB: additional label is needed to apply the location + + # set location, is location is identity return subassy tool.SetLocation(subassy, assy.loc.wrapped, rv) setName(rv, assy.name, tool) return rv # process the whole assy recursively - top = _toCAF(assy, None, None) + top = _toCAF(assy, None) tool.UpdateAssemblies() diff --git a/cadquery/occ_impl/exporters/assembly.py b/cadquery/occ_impl/exporters/assembly.py index 336c57b9e..4d34342cf 100644 --- a/cadquery/occ_impl/exporters/assembly.py +++ b/cadquery/occ_impl/exporters/assembly.py @@ -3,12 +3,11 @@ from tempfile import TemporaryDirectory from shutil import make_archive -from itertools import chain from typing import Optional from typing_extensions import Literal from vtkmodules.vtkIOExport import vtkJSONSceneExporter, vtkVRMLExporter -from vtkmodules.vtkRenderingCore import vtkRenderer, vtkRenderWindow +from vtkmodules.vtkRenderingCore import vtkRenderWindow from OCP.XSControl import XSControl_WorkSession from OCP.STEPCAFControl import STEPCAFControl_Writer @@ -19,10 +18,16 @@ from OCP.TDocStd import TDocStd_Document from OCP.XCAFApp import XCAFApp_Application from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ColorGen -from OCP.XmlDrivers import ( - XmlDrivers_DocumentStorageDriver, - XmlDrivers_DocumentRetrievalDriver, +from OCP.XmlXCAFDrivers import ( + XmlXCAFDrivers_DocumentRetrievalDriver, + XmlXCAFDrivers_DocumentStorageDriver, ) +from OCP.BinXCAFDrivers import ( + BinXCAFDrivers_DocumentRetrievalDriver, + BinXCAFDrivers_DocumentStorageDriver, +) + + from OCP.TCollection import TCollection_ExtendedString, TCollection_AsciiString from OCP.PCDM import PCDM_StoreStatus from OCP.RWGltf import RWGltf_CafWriter @@ -33,7 +38,6 @@ from ..assembly import AssemblyProtocol, toCAF, toVTK, toFusedCAF from ..geom import Location from ..shapes import Shape, Compound -from ..assembly import Color class ExportModes: @@ -82,9 +86,6 @@ def exportAssembly( fuzzy_tol = kwargs["fuzzy_tol"] if "fuzzy_tol" in kwargs else None glue = kwargs["glue"] if "glue" in kwargs else False - # Use the assembly name if the user set it - assembly_name = assy.name if assy.name else str(uuid.uuid1()) - # Handle the doc differently based on which mode we are using if mode == "fused": _, doc = toFusedCAF(assy, glue, fuzzy_tol) @@ -175,10 +176,14 @@ def _process_child(child: AssemblyProtocol, assy_label: TDF_Label): # Handle shape name, color and location part_label = shape_tool.AddShape(shape.wrapped, False) + # NB: this might overwrite the name if shape is referenced multiple times TDataStd_Name.Set_s(part_label, TCollection_ExtendedString(name)) + if color: color_tool.SetColor(part_label, color.wrapped, XCAFDoc_ColorGen) - shape_tool.AddComponent(assy_label, part_label, loc.wrapped) + + comp_label = shape_tool.AddComponent(assy_label, part_label, loc.wrapped) + TDataStd_Name.Set_s(comp_label, TCollection_ExtendedString(name)) # If this assembly has shape metadata, add it to the shape if ( @@ -268,29 +273,39 @@ def _process_assembly( return status == IFSelect_ReturnStatus.IFSelect_RetDone -def exportCAF(assy: AssemblyProtocol, path: str) -> bool: +def exportCAF(assy: AssemblyProtocol, path: str, binary: bool = False) -> bool: """ - Export an assembly to a OCAF xml file (internal OCCT format). + Export an assembly to an XCAF xml or xbf file (internal OCCT formats). """ folder, fname = os.path.split(path) name, ext = os.path.splitext(fname) ext = ext[1:] if ext[0] == "." else ext - _, doc = toCAF(assy) + _, doc = toCAF(assy, binary=binary) app = XCAFApp_Application.GetApplication_s() - store = XmlDrivers_DocumentStorageDriver( - TCollection_ExtendedString("Copyright: Open Cascade, 2001-2002") - ) - ret = XmlDrivers_DocumentRetrievalDriver() + store: BinXCAFDrivers_DocumentStorageDriver | XmlXCAFDrivers_DocumentStorageDriver + ret: BinXCAFDrivers_DocumentRetrievalDriver | XmlXCAFDrivers_DocumentRetrievalDriver + + # XBF + if binary: + ret = XmlXCAFDrivers_DocumentRetrievalDriver() + format_name = TCollection_AsciiString("BinXCAF") + format_desc = TCollection_AsciiString("Binary XCAF Document") + store = BinXCAFDrivers_DocumentStorageDriver() + ret = BinXCAFDrivers_DocumentRetrievalDriver() + # XML + else: + format_name = TCollection_AsciiString("XmlXCAF") + format_desc = TCollection_AsciiString("Xml XCAF Document") + store = XmlXCAFDrivers_DocumentStorageDriver( + TCollection_ExtendedString("Copyright: Open Cascade, 2001-2002") + ) + ret = XmlXCAFDrivers_DocumentRetrievalDriver() app.DefineFormat( - TCollection_AsciiString("XmlOcaf"), - TCollection_AsciiString("Xml XCAF Document"), - TCollection_AsciiString(ext), - ret, - store, + format_name, format_desc, TCollection_AsciiString(ext), ret, store, ) doc.SetRequestedFolder(TCollection_ExtendedString(folder))