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 diff --git a/cadquery/fig.py b/cadquery/fig.py new file mode 100644 index 000000000..a68a43e59 --- /dev/null +++ b/cadquery/fig.py @@ -0,0 +1,277 @@ +from asyncio import ( + new_event_loop, + set_event_loop, + run_coroutine_threadsafe, + AbstractEventLoop, +) +from concurrent.futures import Future +from typing import Optional +from threading import Thread +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 +from trame.ui.html import DivLayout + +from . import Shape +from .vis import style, Showable, ShapeLike, _split_showables + +from vtkmodules.vtkRenderingCore import ( + vtkRenderer, + vtkRenderWindow, + vtkRenderWindowInteractor, + 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;" + + +class Figure: + + server: Server + win: vtkRenderWindow + ren: vtkRenderer + view: vtk_widgets.VtkRemoteView + shapes: dict[ShapeLike, list[vtkProp3D]] + actors: list[vtkProp3D] + loop: AbstractEventLoop + thread: Thread + empty: bool + last: Optional[ + tuple[ + list[ShapeLike], list[vtkProp3D], Optional[list[vtkProp3D]], list[vtkProp3D] + ] + ] + + _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) + + # 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) + + # background + renderer.SetBackground(1, 1, 1) + renderer.GradientBackgroundOn() + + # 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 + + self.shapes = {} + self.actors = [] + + # server + server = get_server("CQ-server") + server.client_type = "vue3" + + # layout + with DivLayout(server): + client.Style("body { margin: 0; }") + + with html.Div(style=FULL_SCREEN): + self.view = vtk_widgets.VtkRemoteView( + win, interactive_ratio=1, interactive_quality=100 + ) + + server.state.flush() + + self.server = server + self.loop = new_event_loop() + + def _run_loop(): + set_event_loop(self.loop) + self.loop.run_forever() + + self.thread = Thread(target=_run_loop, daemon=True) + self.thread.start() + + coro = server.start( + thread=True, + exec_mode="coroutine", + port=port, + open_browser=False, + show_connection_info=False, + ) + + if coro: + self._run(coro) + + # prevent reinitialization + self._initialized = True + + # view is initialized as empty + self.empty = True + self.last = None + + # open webbrowser + open_new_tab(f"http://localhost:{port}") + + def _run(self, coro) -> Future: + + return run_coroutine_threadsafe(coro, self.loop) + + def show(self, *showables: Showable | vtkProp3D | list[vtkProp3D], **kwargs): + """ + Show objects. + """ + + # split objects + shapes, vecs, locs, props = _split_showables(showables) + + pts = style(vecs, **kwargs) + axs = style(locs, **kwargs) + + for s in shapes: + # do not show markers by default + if "markersize" not in kwargs: + kwargs["markersize"] = 0 + + actors = style(s, **kwargs) + self.shapes[s] = actors + + for actor in actors: + self.ren.AddActor(actor) + + for prop in chain(props, axs): + self.actors.append(prop) + self.ren.AddActor(prop) + + if vecs: + 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() + + 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() + self.view.update() + + 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: + self.ren.RemoveAllViewProps() + + self.actors.clear() + self.shapes.clear() + + for s in shapes: + if instance_of(s, ShapeLike): + for a in self.shapes[s]: + self.ren.RemoveActor(a) + + del self.shapes[s] + else: + self.actors.remove(s) + self.ren.RemoveActor(s) + + self.view.update() + + # reset last, bc we don't want to keep track of what was removed + self.last = None + future = self._run(_clear()) + future.result() + + 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 diff --git a/cadquery/occ_impl/assembly.py b/cadquery/occ_impl/assembly.py index 4ef486e26..61a6bac4b 100644 --- a/cadquery/occ_impl/assembly.py +++ b/cadquery/occ_impl/assembly.py @@ -35,7 +35,7 @@ vtkActor, vtkPolyDataMapper as vtkMapper, vtkRenderer, - vtkAssembly, + vtkProp3D, ) from vtkmodules.vtkFiltersExtraction import vtkExtractCellsByType @@ -315,9 +315,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: @@ -358,7 +358,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) @@ -370,9 +370,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..fb644386d 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], ] @@ -145,7 +152,7 @@ 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. """ @@ -163,7 +170,7 @@ def _to_vtk_axs(locs: List[Location], scale: float = 0.1) -> vtkAssembly: rv.AddPart(ax) - return rv + return [rv] def _to_vtk_shapes( @@ -174,7 +181,7 @@ def _to_vtk_shapes( linewidth: float = 2, alpha: float = 1, tolerance: float = 1e-3, -) -> vtkAssembly: +) -> List[vtkProp3D]: """ Convert Shapes to vtkAssembly. """ @@ -279,21 +286,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 +317,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 +347,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 +365,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 @@ -417,7 +424,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() @@ -483,7 +492,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: 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/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 + diff --git a/setup.py b/setup.py index 45442d3e1..80b5c3812 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,8 @@ "typish", "casadi", "path", + "trame", + "trame-vtk", ] 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..ffa09264c --- /dev/null +++ b/tests/test_fig.py @@ -0,0 +1,58 @@ +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 + +from sys import platform + + +@fixture(scope="module") +def fig(): + return Figure() + + +@mark.gui +@mark.skipif(platform != "win32", reason="CI with UI only works on win for now") +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() + + showables = (s, wp, assy, sk, ctrl_pts, v, loc, act) + + # individual showables + fig.show(*showables) + + # fit + fig.fit() + + # clear + fig.clear() + + # clear with an arg + for el in (s, wp, assy, sk, ctrl_pts): + fig.show(el).clear(el) + + # lists of showables + fig.show(s.Edges()).show([Vector(), Vector(0, 1)]) + + # displaying nonsense does not throw + fig.show("a").show(["a", 1234]) + + # pop + for el in showables: + fig.show(el, color="red") + fig.pop() + + # test singleton behavior of fig + fig2 = Figure() + assert fig is fig2 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):