diff --git a/cadquery/fig.py b/cadquery/fig.py index 2eb68e930..f6c4463b9 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -9,12 +9,12 @@ from threading import Thread from itertools import chain from webbrowser import open_new_tab +from uuid import uuid1 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 trame.widgets import vtk as vtk_widgets, client, trame, vuetify3 as v3 +from trame.ui.vuetify3 import SinglePageWithDrawerLayout from . import Shape from .vis import style, Showable, ShapeLike, _split_showables @@ -31,7 +31,7 @@ from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera -from .utils import instance_of +from .utils import instance_of, BiDict FULL_SCREEN = "position:absolute; left:0; top:0; width:100vw; height:100vh;" @@ -42,11 +42,12 @@ class Figure: win: vtkRenderWindow ren: vtkRenderer view: vtk_widgets.VtkRemoteView - shapes: dict[ShapeLike, list[vtkProp3D]] - actors: list[vtkProp3D] + shapes: BiDict[str, ShapeLike] + actors: BiDict[str, tuple[vtkProp3D, ...]] loop: AbstractEventLoop thread: Thread empty: bool + active: Optional[str] last: Optional[ tuple[ list[ShapeLike], list[vtkProp3D], Optional[list[vtkProp3D]], list[vtkProp3D] @@ -106,25 +107,104 @@ def __init__(self, port: int = 18081): self.win = win self.ren = renderer - self.shapes = {} - self.actors = [] + self.shapes = BiDict() + self.actors = BiDict() + self.active = None # server - server = get_server("CQ-server") - server.client_type = "vue3" + server = get_server("CQ-server", client_type="vue3") + self.server = server + + # state + self.state = self.server.state + + self.state.setdefault("actors", []) # layout - with DivLayout(server): + self.layout = SinglePageWithDrawerLayout(server, show_drawer=False) + with self.layout as layout: client.Style("body { margin: 0; }") - with html.Div(style=FULL_SCREEN): - self.view = vtk_widgets.VtkRemoteView( - win, interactive_ratio=1, interactive_quality=100 + layout.title.set_text("CQ viewer") + layout.footer.hide() + + with layout.toolbar: + + BSTYLE = "display: block;" + + v3.VBtn( + click=lambda: self._fit(), + flat=True, + density="compact", + icon="mdi-crop-free", + style=BSTYLE, + ) + + v3.VBtn( + click=lambda: self._view((0, 0, 0), (1, 1, 1), (0, 0, 1)), + flat=True, + density="compact", + icon="mdi-axis-arrow", + style=BSTYLE, + ) + + v3.VBtn( + click=lambda: self._view((0, 0, 0), (1, 0, 0), (0, 0, 1)), + flat=True, + density="compact", + icon="mdi-axis-x-arrow", + style=BSTYLE, + ) + + v3.VBtn( + click=lambda: self._view((0, 0, 0), (0, 1, 0), (0, 0, 1)), + flat=True, + density="compact", + icon="mdi-axis-y-arrow", + style=BSTYLE, + ) + + v3.VBtn( + click=lambda: self._view((0, 0, 0), (0, 0, 1), (0, 1, 0)), + flat=True, + density="compact", + icon="mdi-axis-z-arrow", + style=BSTYLE, + ) + + v3.VBtn( + click=lambda: self._pop(), + flat=True, + density="compact", + icon="mdi-file-document-remove-outline", + style=BSTYLE, + ) + + v3.VBtn( + click=lambda: self._clear([]), + flat=True, + density="compact", + icon="mdi-delete-outline", + style=BSTYLE, + ) + + with layout.content: + with v3.VContainer( + fluid=True, classes="pa-0 fill-height", + ): + self.view = vtk_widgets.VtkRemoteView( + win, interactive_ratio=1, interactive_quality=100 + ) + + with layout.drawer: + self.tree = trame.GitTree( + sources=("actors", []), + visibility_change=(self.onVisibility, "[$event]"), + actives_change=(self.onSelection, "[$event]"), ) server.state.flush() - self.server = server self.loop = new_event_loop() def _run_loop(): @@ -159,36 +239,63 @@ def _run(self, coro) -> Future: return run_coroutine_threadsafe(coro, self.loop) - def show(self, *showables: Showable | vtkProp3D | list[vtkProp3D], **kwargs): + def _update_state(self, name: str): + async def _(): + + self.state.dirty(name) + self.state.flush() + + self._run(_()) + + def show( + self, + *showables: Showable | vtkProp3D | list[vtkProp3D], + name: Optional[str] = None, + **kwargs, + ): """ Show objects. """ + # generate an uuid + uuid = str(uuid1()) + # split objects shapes, vecs, locs, props = _split_showables(showables) pts = style(vecs, **kwargs) axs = style(locs, **kwargs) + # to be added to state + new_actors = [] + 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 + self.shapes[uuid] = s for actor in actors: self.ren.AddActor(actor) + new_actors.extend(actors) + for prop in chain(props, axs): - self.actors.append(prop) self.ren.AddActor(prop) + new_actors.append(prop) + if vecs: - self.actors.append(*pts) self.ren.AddActor(*pts) + new_actors.append(*pts) + + # if nothing to show return early + if not new_actors: + return self + # store to enable pop self.last = (shapes, axs, pts if vecs else None, props) @@ -202,76 +309,173 @@ async def _show(): self.fit() self.empty = False + # update actors + self.state.actors.append( + { + "id": uuid, + "parent": "0", + "visible": 1, + "name": f"{name if name else type(showables[0]).__name__} at {id(showables[0]):x}", + } + ) + self._update_state("actors") + + self.actors[uuid] = tuple(new_actors) + return self + async def _fit(self): + self.ren.ResetCamera() + self.view.update() + def fit(self): """ Update view to fit all objects. """ - async def _show(): - self.ren.ResetCamera() - self.view.update() + self._run(self._fit()) - self._run(_show()) + return self + + async def _view(self, foc, pos, up): + + cam = self.ren.GetActiveCamera() + + cam.SetViewUp(*up) + cam.SetFocalPoint(*foc) + cam.SetPosition(*pos) + + self.ren.ResetCamera() + + self.view.update() + + def iso(self): + + self._run(self._view((0, 0, 0), (1, 1, 1), (0, 0, 1))) return self - def clear(self, *shapes: Shape | vtkProp3D): - """ - Clear specified objects. If no arguments are passed, clears all objects. - """ + def up(self): + + self._run(self._view((0, 0, 0), (0, 0, 1), (0, 1, 0))) + + return self + + pass + + def front(self): - async def _clear(): + self._run(self._view((0, 0, 0), (1, 0, 0), (0, 0, 1))) - if len(shapes) == 0: - self.ren.RemoveAllViewProps() + return self + + def side(self): + + self._run(self._view((0, 0, 0), (0, 1, 0), (0, 0, 1))) + + return self + + async def _clear(self, shapes): - self.actors.clear() - self.shapes.clear() + if len(shapes) == 0: + self.ren.RemoveAllViewProps() - for s in shapes: - if instance_of(s, ShapeLike): - for a in self.shapes[s]: + self.actors.clear() + self.shapes.clear() + + self.state.actors = [] + self.active = None + + for s in shapes: + # handle shapes + if instance_of(s, ShapeLike): + uuids = tuple(self.shapes.inv[s]) + for uuid in uuids: + for a in self.actors.pop(uuid): self.ren.RemoveActor(a) - del self.shapes[s] - else: - self.actors.remove(s) - self.ren.RemoveActor(s) + del self.shapes[ + uuid + ] # NB this will remove all uuids pointing to the shape - self.view.update() + # handle other actors + else: + for uuid, acts in self.actors.items(): + if s in acts: + for el in self.actors.pop(uuid): + self.ren.RemoveActor(el) + + # store the uuid for state update + uuids = [uuid] + + break + + # remove the id==k rows from actors + new_state = [] + for el in self.state.actors: + if el["id"] not in uuids: + new_state.append(el) + + self.state.actors = new_state + + self._update_state("actors") + self.view.update() + + def clear(self, *shapes: Shape | vtkProp3D): + """ + Clear specified objects. If no arguments are passed, clears all objects. + """ # reset last, bc we don't want to keep track of what was removed self.last = None - future = self._run(_clear()) + future = self._run(self._clear(shapes)) future.result() return self + async def _pop(self): + + if self.active is None: + self.active = self.state.actors[-1]["id"] + + if self.active in self.actors: + for act in self.actors[self.active]: + self.ren.RemoveActor(act) + + self.actors.pop(self.active) + + # update shapes if needed + self.shapes.pop(self.active) + + # update corresponding state + for i, el in enumerate(self.state.actors): + if el["id"] == self.active: + self.state.actors.pop(i) + self._update_state("actors") + break + + self.active = None + + self.view.update() + def pop(self): """ - Clear the last showable. + Clear the selected showable. """ - async def _pop(): + self._run(self._pop()) - (shapes, axs, pts, props) = self.last + return self - for s in shapes: - for act in self.shapes.pop(s): - self.ren.RemoveActor(act) + def onVisibility(self, event: dict): - for act in chain(axs, props): - self.ren.RemoveActor(act) - self.actors.remove(act) + actors = self.actors[event["id"]] - if pts: - self.ren.RemoveActor(*pts) - self.actors.remove(*pts) + for act in actors: + act.SetVisibility(event["visible"]) - self.view.update() + self.view.update() - self._run(_pop()) + def onSelection(self, event: list[str]): - return self + self.active = event[0] diff --git a/cadquery/utils.py b/cadquery/utils.py index 150320064..957dc1e21 100644 --- a/cadquery/utils.py +++ b/cadquery/utils.py @@ -118,6 +118,25 @@ def inv(self) -> dict[V, list[K]]: return self._inv + def clear(self): + + super().clear() + self._inv.clear() + + def __delitem__(self, k: K): + + v = self.data.pop(k) + + # if needed in one-many cases + if v in self._inv: + # remove the inverse mapping + inv = self._inv[v] + inv.remove(k) + + # if needed remove the item completely + if not inv: + del self._inv[v] + def instance_of(obj: object, *args: object) -> bool: """ diff --git a/conda/meta.yaml b/conda/meta.yaml index 0c04150c1..68761b45c 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -28,6 +28,8 @@ requirements: - runtype - trame - trame-vtk + - trame-components + - trame-vuetify test: requires: diff --git a/environment.yml b/environment.yml index 4d66209e4..590179b13 100644 --- a/environment.yml +++ b/environment.yml @@ -27,6 +27,8 @@ dependencies: - appdirs - trame - trame-vtk + - trame-components + - trame-vuetify - pip - pip: - sphinx_rtd_theme==3.1.0 diff --git a/setup.py b/setup.py index 2674ee82f..2bcbf98fd 100644 --- a/setup.py +++ b/setup.py @@ -35,6 +35,8 @@ "path", "trame", "trame-vtk", + "trame-components", + "trame-vuetify", "pyparsing>=3.0.0", ] diff --git a/tests/test_fig.py b/tests/test_fig.py index ffa09264c..cacee20c4 100644 --- a/tests/test_fig.py +++ b/tests/test_fig.py @@ -27,7 +27,7 @@ def test_fig(fig): loc = Location() act = vtkAxesActor() - showables = (s, wp, assy, sk, ctrl_pts, v, loc, act) + showables = (s, s.copy(), wp, assy, sk, ctrl_pts, v, loc, act) # individual showables fig.show(*showables) @@ -35,12 +35,28 @@ def test_fig(fig): # fit fig.fit() + # views + fig.iso() + fig.up() + fig.front() + fig.side() + # clear fig.clear() # clear with an arg + for showable in showables: + fig.show(showable) + for el in (s, wp, assy, sk, ctrl_pts): - fig.show(el).clear(el) + fig.clear(el) + + # show multiple showables at once + fig.clear() + fig.show(*showables) + + # more than one Solid showable -> more than 2 actors + assert len(list(fig.actors.values())[-1]) > 2 # lists of showables fig.show(s.Edges()).show([Vector(), Vector(0, 1)]) @@ -56,3 +72,9 @@ def test_fig(fig): # test singleton behavior of fig fig2 = Figure() assert fig is fig2 + + # test onSelection + fig.onVisibility(fig.state.actors[0]) + + # test onVisbility + fig.onSelection([fig.state.actors[0]])