Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/usage/3d-modeling.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,19 @@ In LoopStructural stratigraphic surfaces can be modelled using implicit function

### Fault modelling
Faults are modelled in LoopStructural by building three implicit functions defining the fault surface, fault slip vector and the fault extent. Combined with a parametric representation of the fault displacement within these coordinates a kinematic model.

## Push the model to GeoViewer

GeoViewer is the advanced 3D viewer of Loop. It is a separate program. The plugin can send the
surfaces of the solved model to it. This is optional: the 3D view of the plugin does not change.

1. Start GeoViewer. Open its link popup (in the status bar) and note the port and the token.
2. In the plugin, build the model (step 4). Open **Export** in step 5 and select the
**GeoViewer** tab.
3. Type the port and the token, choose the surfaces to send, and select
**Push model to GeoViewer**.

The plugin saves the port and the token for the next push. The surfaces go into the group
`LoopStructural model` of the GeoViewer scene. A new push replaces the surfaces of the last push.
Other objects of the scene stay. The coordinates are in the CRS of the model, and the scene of
GeoViewer keeps its own origin.
77 changes: 77 additions & 0 deletions loopstructural/gui/modelling/steps/export_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
QDoubleSpinBox,
QFormLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSpinBox,
Expand All @@ -19,6 +20,9 @@
)

from loopstructural.main import model_export
from loopstructural.main.geoviewer import scene as geoviewer_scene
from loopstructural.main.geoviewer.link import DEFAULT_PORT, GeoViewerLink, LinkError
from loopstructural.toolbelt.preferences import PlgOptionsManager

from ...background_task import finish_background_task, start_background_task
from ...compatibility import configure_layer_combo
Expand Down Expand Up @@ -70,6 +74,7 @@ def __init__(self, parent=None, *, model_manager=None, data_manager=None):
self.tabs.addTab(self._surfaces_tab(), "Surfaces")
self.tabs.addTab(self._block_model_tab(), "Block model")
self.tabs.addTab(self._cross_section_tab(), "Cross-section")
self.tabs.addTab(self._geoviewer_tab(), "GeoViewer")
layout.addWidget(self.tabs)
self.set_model_solved(False)
self.setCollapsed(True)
Expand Down Expand Up @@ -149,6 +154,40 @@ def _cross_section_tab(self):
self._update_section_inputs()
return tab

def _geoviewer_tab(self):
tab = QWidget(self)
form = QFormLayout(tab)
note = QLabel(
"Send the surfaces of the model to GeoViewer, the advanced 3D viewer. "
"Start GeoViewer first, and copy the port and the token from its link popup. "
"A new push replaces the surfaces of the last push.",
tab,
)
note.setWordWrap(True)
self.geoviewer_port = _spin(tab, DEFAULT_PORT, minimum=1, maximum=65535)
self.geoviewer_token = QLineEdit(tab)
self.geoviewer_token.setEchoMode(QLineEdit.EchoMode.Password)
self.geoviewer_stratigraphy = QCheckBox("Stratigraphic surfaces", tab)
self.geoviewer_stratigraphy.setChecked(True)
self.geoviewer_faults = QCheckBox("Fault surfaces", tab)
self.geoviewer_faults.setChecked(True)
self.geoviewer_button = QPushButton("Push model to GeoViewer", tab)
self.geoviewer_button.clicked.connect(self.push_to_geoviewer)
self._load_geoviewer_settings()
form.addRow(note)
form.addRow("Port", self.geoviewer_port)
form.addRow("Token", self.geoviewer_token)
form.addRow(self.geoviewer_stratigraphy)
form.addRow(self.geoviewer_faults)
form.addRow(self.geoviewer_button)
return tab

def _load_geoviewer_settings(self):
port = PlgOptionsManager.get_value_from_key('geoviewer_port', DEFAULT_PORT, int)
token = PlgOptionsManager.get_value_from_key('geoviewer_token', '', str)
self.geoviewer_port.setValue(int(port or DEFAULT_PORT))
self.geoviewer_token.setText(token or '')

def _update_section_inputs(self, *_args):
is_line = self.section_kind.currentData() == 'line'
self.section_line.setVisible(is_line)
Expand Down Expand Up @@ -203,6 +242,44 @@ def _with_extension(self, file_widget, format_combo):
def _warn(self, text):
QMessageBox.warning(self, "Export", text)

def push_to_geoviewer(self):
"""Send the surfaces of the model to a running GeoViewer. The model does not change."""
strat = self.geoviewer_stratigraphy.isChecked()
faults = self.geoviewer_faults.isChecked()
if not (strat or faults):
return self._warn("Select the stratigraphic surfaces, the fault surfaces, or both.")
port, token = self.geoviewer_port.value(), self.geoviewer_token.text().strip()
PlgOptionsManager.set_value_from_key('geoviewer_port', port)
PlgOptionsManager.set_value_from_key('geoviewer_token', token)
manager = self.model_manager
crs = self.data_manager.get_model_crs() if self.data_manager else None
crs_text = crs.authid() if crs is not None and crs.isValid() else ''

def target(progress):
model = getattr(manager, 'model', None)
if model is None:
raise geoviewer_scene.PushError("Build the model in step 4 first.")
try:
result = GeoViewerLink(port, token).push(
crs_text,
geoviewer_scene.proposed_origin(model),
lambda origin: geoviewer_scene.model_messages(
manager, origin, stratigraphic=strat, faults=faults, progress=progress
),
replace_prefix=geoviewer_scene.ID_PREFIX,
progress=progress,
)
except (LinkError, geoviewer_scene.PushError) as error:
raise RuntimeError(str(error)) from error
if result.refused or result.other_errors:
details = "; ".join(text for _id, text in result.refused) or "; ".join(
result.other_errors
)
return f"{result.summary()} {details}"
return result.summary()

self._run(target, "Push model to GeoViewer")

def export_surfaces(self):
folder = self.surface_folder.filePath().strip()
if not folder:
Expand Down
1 change: 1 addition & 0 deletions loopstructural/main/geoviewer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""The link from the plugin to the GeoViewer application: push a model to a running viewer."""
159 changes: 159 additions & 0 deletions loopstructural/main/geoviewer/link.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""The connection of the plugin to a running GeoViewer.

``GeoViewerLink.push`` makes the connection, says ``hello``, sends the objects
of the model and closes the connection. Nothing keeps the link open, so the
plugin has no state to repair when the viewer stops. The viewer keeps the scene.
"""

from typing import Callable, Iterable, List, Optional, Sequence, Tuple

from . import protocol
from .websocket_client import WebSocketClient, WebSocketClosed, WebSocketError

DEFAULT_PORT = 47615
HOST = '127.0.0.1'

# Time to wait for the answer to the hello message
_HELLO_TIMEOUT = 5.0
# Time to wait for the errors of the viewer after the last message
_SETTLE_TIMEOUT = 0.5


class LinkError(Exception):
"""The push failed. The text tells the user why."""


class PushResult:
"""What happened in one push."""

def __init__(self):
self.sent = 0
self.removed = 0
# (object id, text) of each error that the viewer sent about an object
self.refused: List[Tuple[str, str]] = []
self.other_errors: List[str] = []

def summary(self) -> str:
text = f"{self.sent} objects sent to GeoViewer"
if self.removed:
text += f", {self.removed} old objects removed"
if self.refused or self.other_errors:
text += f". The viewer refused {len(self.refused) + len(self.other_errors)} messages"
return text + "."


class GeoViewerLink:
"""A short session with the viewer.

Parameters
----------
port, token : the port and the token of the link of the viewer. The viewer
shows both in its link popup.
"""

def __init__(self, port: int = DEFAULT_PORT, token: str = '', host: str = HOST):
self.port = int(port)
self.token = token or ''
self.host = host

def push(
self,
crs: str,
origin: Sequence[float],
build_messages: Callable[[Sequence[float]], Iterable[Tuple[str, bytes]]],
*,
replace_prefix: Optional[str] = None,
progress: Optional[Callable[[str], None]] = None,
) -> PushResult:
"""Send objects to the viewer.

Parameters
----------
crs, origin : the CRS and the origin that the plugin proposes for the
scene. If the scene of the viewer has an origin already, the viewer
keeps it and says so in its ``hello`` answer.
build_messages : called with the origin that the viewer uses. It yields
``(object id, frame)`` pairs. The coordinates must be relative to
this origin.
replace_prefix : after the push, remove the objects of the viewer whose
id starts with this text and that were not sent again.
progress : called with a text before each step.
"""
say = progress or (lambda _text: None)
say(f"Connecting to GeoViewer on port {self.port}...")
try:
client = WebSocketClient.connect(self.host, self.port, token=self.token)
except WebSocketError as error:
raise LinkError(
f"{error} Start GeoViewer, and check the port and the token in its link popup."
) from error
try:
return self._session(client, crs, origin, build_messages, replace_prefix, say)
except WebSocketError as error:
raise LinkError(str(error)) from error
finally:
client.close()

def _session(self, client, crs, origin, build_messages, replace_prefix, say) -> PushResult:
result = PushResult()
client.send_binary(protocol.hello_message(crs, origin))
scene_origin, existing = self._read_hello_answer(client, origin)
sent_ids = set()
for object_id, frame in build_messages(scene_origin):
say(f"Sending {object_id}...")
client.send_binary(frame)
sent_ids.add(object_id)
result.sent += 1
self._collect_errors(client, result, wait=0.0)
if replace_prefix:
for object_id in existing:
if object_id.startswith(replace_prefix) and object_id not in sent_ids:
client.send_binary(protocol.remove_message(object_id))
result.removed += 1
self._collect_errors(client, result, wait=_SETTLE_TIMEOUT)
return result

@staticmethod
def _read_hello_answer(client, origin):
"""Return the origin of the scene and the ids that are in the scene.

The viewer answers ``hello`` with its own ``hello`` and then with one
``scene.inventory``.
"""
scene_origin = [float(v) for v in origin]
ids: List[str] = []
got_hello = got_inventory = False
while not (got_hello and got_inventory):
data = client.receive(_HELLO_TIMEOUT)
if data is None:
raise LinkError("GeoViewer did not answer the hello message.")
header = protocol.decode_header(data)
kind = header.get('type')
if kind == 'hello':
scene_origin = [float(v) for v in header.get('origin', scene_origin)]
got_hello = True
elif kind == 'scene.inventory':
ids = protocol.inventory_ids(header)
got_inventory = True
elif kind == 'error':
raise LinkError(f"GeoViewer refused the link: {header.get('message', '')}")
return scene_origin, ids

@staticmethod
def _collect_errors(client, result: PushResult, *, wait: float):
"""Read the messages that wait in the socket and keep the errors."""
while True:
try:
data = client.receive(wait if wait > 0 else 0.001)
except WebSocketClosed:
return
if data is None:
return
header = protocol.decode_header(data)
if header.get('type') != 'error':
continue
text = header.get('message', header.get('code', 'error'))
if header.get('object'):
result.refused.append((header['object'], text))
else:
result.other_errors.append(text)
Loading
Loading