Commit 57442cd5 authored by Leonhard Seidelmann's avatar Leonhard Seidelmann
Browse files

Merge branch 'gui-threading'

parents 53c6f12d 5fc767db
{
"configurations": [
{
"name": "Python 3.8 Win64",
"includePath": [
"${workspaceFolder}/**",
"C:\\Program Files\\Python38\\include"
],
"defines": [
"_DEBUG",
"UNICODE",
"_UNICODE"
],
"windowsSdkVersion": "10.0.18362.0",
"compilerPath": "C:/Program Files (x86)/Microsoft Visual Studio/2019/BuildTools/VC/Tools/MSVC/14.25.28610/bin/Hostx64/x64/cl.exe",
"cStandard": "c99",
"cppStandard": "c++17",
"intelliSenseMode": "msvc-x64"
}
],
"version": 4
}
\ No newline at end of file
......@@ -9,5 +9,6 @@
],
"python.testing.unittestEnabled": true,
"python.testing.pytestEnabled": false,
"python.testing.nosetestsEnabled": false
"python.testing.nosetestsEnabled": false,
"python.pythonPath": "D:\\Python37\\python.exe"
}
\ No newline at end of file
......@@ -6,7 +6,7 @@
{
"label": "Push to QGIS 3",
"type": "shell",
"command": "python",
"command": "${config:python.pythonPath}",
"args": [
"tools/push_to_qgis.py"
],
......@@ -18,6 +18,22 @@
"clear": true
},
"problemMatcher": []
},
{
"label": "Compile Qt Resources",
"type": "shell",
"command": "${config:python.pythonPath}",
"args": [
"tools/compile_resources.py"
],
"presentation": {
"echo": true,
"reveal": "always",
"showReuseMessage": false,
"panel": "shared",
"clear": true
},
"problemMatcher": []
}
]
}
\ No newline at end of file
"""Mesh generation toolkit for BASEMENT."""
from . import abc
from . import meshtool
from . import triangle
from .core import Node, Element, Mesh, Lattice
__version__ = '2.0.0b0'
__version__ = '2.0.0b1'
try:
from qgis.gui import QgisInterface
......
"""Shared algorithms which could be reused or optimised."""
import os
import sys
import warnings
from .algorithms_py import (counting_sort, dist_2d, dist_3d,
distance_to_polygon, get_intersections,
half_plane_distance, interpolate_line,
interpolate_triangle, line_intersection,
line_segments_intersection,
point_in_polygon_concave, point_in_polygon_convex,
point_on_line, point_within_range,
rectangle_intersection)
# NOTE: The potential name-shadowing from the wildcard import is by design. In
# fact, it is *only* supposed to overshadow the previously imported names.
# This solution still allows for introspection and offers clear references
# during development while making addition and removal of C implementations
# trivial.
#
# The developer of the C extensions is responsible for ensuring they are a
# perfect match for the native Python implementations.
sys.path.append(os.path.dirname(__file__))
try:
from algorithms_c import *
except ModuleNotFoundError:
warnings.warn('No compatible C extensions found, using the slower '
'pure-Python implementation')
......@@ -3,8 +3,8 @@
import math
from typing import Callable, List, Sequence, TypeVar
import numpy
from .types import (Line2D, Line3D, Point2D, Point3D, Polygon2D, Rectangle2D,
Triangle3D)
from ..types import (Line2D, Line3D, Point2D, Point3D, Polygon2D, Rectangle2D,
Triangle3D)
_T = TypeVar('_T')
......
......@@ -8,6 +8,7 @@ from ..algorithms import (dist_2d, dist_3d, interpolate_line,
line_segments_intersection, line_intersection,
point_on_line, point_within_range)
from ..errors import TopologyError
from ..feedback import Feedback
from ..log import logger
from ..triangle import PSLGNode, PSLGSegment
from ..types import Line2D, Line3D, Point3D
......@@ -286,6 +287,13 @@ class Lattice:
logger.debug('A segment between %s and %s already exists',
pos[0], pos[1])
return
# Only create the segment if it not degenerate
if node_1 == node_2:
pos = node_1.as_tuple_2d()
if warn_duplicate:
warnings.warn(f'Degenerate segment at {pos}')
logger.debug('Zero-length segment encountered at %s', pos)
return
new_segment = Segment(node_1, node_2)
logger.debug('Creating new segment at %s',
......@@ -299,18 +307,46 @@ class Lattice:
"""Return the contents of the lattice in Triangle format."""
# TODO: Convert to Triangle format
def conform(self) -> None:
def conform(self, *, feedback: Feedback = None) -> None:
"""Process all geometries to ensure they are compliant."""
# Step 1: Deduplicate nodes
if feedback is not None:
feedback.update(msg='Deduplicating nodes...')
self.deduplicate_nodes(feedback=feedback)
# Step 2: Find intersections
if feedback is not None:
feedback.update(msg='Finding intersections...')
self.add_intersection_nodes(feedback=feedback)
# Step 3: Split segments
if feedback is not None:
feedback.update(msg='Splitting segments...')
self.split_segments(feedback=feedback)
def deduplicate_nodes(self, *, feedback: Feedback = None) -> int:
"""Remove any duplicate nodes.
Returns the number of nodes removed.
"""
nodes_to_remove: List[Node] = []
# This generates every unique, unordered pair of values in an iterable
for node_a, node_b in itertools.combinations(self.nodes, 2):
# Get a list of all unique, unordered pairs of nodes
combinations = list(itertools.combinations(self.nodes, 2))
if feedback is not None:
total = len(combinations)
interval = int(total / 100) if total > 100 else 1
element: Tuple[Node, Node]
for index, element in enumerate(combinations):
node_a, node_b = element
pt_a = node_a.as_tuple_2d()
pt_b = node_b.as_tuple_2d()
if point_within_range(pt_a, pt_b, max_dist=self.precision):
logger.info('Removing duplicate node at %s', pt_b)
# Relink node segments
# NOTE: list cast used to allow mutation of the underlying set
for segment in list(node_b.attached_segments):
logger.debug('Relinking segment %s', segment.as_line_2d())
self.segments.remove(segment)
......@@ -323,17 +359,43 @@ class Lattice:
segment = Segment(node_a, other_node)
self.segments.append(segment)
node_a.attach_segment(segment)
# Unlink node segments
# Mark node segment for deletion
nodes_to_remove.append(node_b)
if feedback is not None and index % interval == 0:
feedback.update(feedback.scale((index+1)/total, 0.5))
if nodes_to_remove:
logger.info('Deleting %d duplicate nodes', len(nodes_to_remove))
_ = (self.nodes.remove(n) for n in nodes_to_remove)
# Step 2: Find intersections
for segment_a, segment_b in itertools.combinations(self.segments, 2):
# Return the number of deleted nodes
return len(nodes_to_remove)
def add_intersection_nodes(self, *, feedback: Feedback = None) -> int:
"""Add a node at each line segment intersection.
Call split_segments() afterwards to get a non-intersecting
lattice.
Return the number of added nodes.
"""
combinations = list(itertools.combinations(self.segments, 2))
if feedback is not None:
total = len(combinations)
added_nodes = 0
for index, tuple_ in enumerate(combinations):
segment_a, segment_b = tuple_
line_a = segment_a.as_line_2d()
line_b = segment_b.as_line_2d()
if feedback is not None:
feedback.update(feedback.scale((index+1)/total, 0.2, 0.5))
if not line_segments_intersection(line_a, line_b,
allow_collinear=False):
continue
......@@ -376,9 +438,21 @@ class Lattice:
f'{(line_b[0], intersection, line_b[1])} to lose '
f'their collinearity.')
self.add_node((*intersection, height_a), auto_conform=False)
added_nodes += 1
# Step 3: Split segments
for node in self.nodes:
return added_nodes
def split_segments(self, *, feedback: Feedback) -> int:
"""Split segments at nodes.
Return the number of splits performed.
"""
if feedback is not None:
total = len(self.nodes)
splits_performed = 0
for index, node in enumerate(self.nodes):
node_pt = node.as_tuple_2d()
segment_split = True
while segment_split:
......@@ -391,4 +465,10 @@ class Lattice:
new_segment = segment.split(node)
self.segments.append(new_segment)
segment_split = True
splits_performed += 1
break
if feedback is not None:
feedback.update(feedback.scale((index+1)/total, 0.3, 0.7))
return splits_performed
......@@ -769,36 +769,18 @@ class Mesh(ElevationSource):
except KeyError as err:
raise ValueError(f'Node {node} is not part of the mesh') from err
def save(self, filename: str, v2: bool = True, v3: bool = False,
element_elevation: Dict[str, Any] = None) -> Tuple[str, ...]:
def save(self, filename: str,
element_elevation: Dict[str, Any] = None) -> str:
"""Store the mesh as a 2DM file.
Returns the paths of all output files written.
"""
if v3 and element_elevation is None:
raise ValueError('Element elevations must be provided when saving '
'in BASEMENT 3 format')
base_name, ext = os.path.splitext(filename)
# If both formats are requried, add the "_v2-8" suffix
if v2 and v3:
filename = f'{base_name}_v2-8{ext}'
# Write the basic 2DM file
self._write_2dm(filename)
# If both formats are required, create a copy to alter to v3
if v2 and v3:
filename_v3 = f'{base_name}_v3{ext}'
shutil.copyfile(filename, filename_v3)
self._write_element_elevation(filename_v3, element_elevation)
return filename, filename_v3
# If only a v3 file is needed, overwrite the original
if v3:
# Add element elevations if provided
if element_elevation is not None:
self._write_element_elevation(filename, element_elevation)
return (filename,)
return filename
def _write_2dm(self, filename: str) -> None:
"""Write the actual 2DM file itself.
......
"""Helper classes and methods used to provide LRO feedback."""
from types import TracebackType
from typing import Callable, Optional, Type
# Type aliases
FeedbackCallback = Callable[[Optional[float], Optional[str], bool], None]
class Feedback:
"""Generic interface for interacting with long-running functions.
This class allows functions performing long-running operations
(aka. LROs, generally anything over 1-2 seconds of blocking time)
to communicate with their caller.
The function can share its currents status using the
`Feedback.update()` method. For processes of unknown length (such
as network activity or external programs), you can use the
`Feedback.busy()` context manager.
If your workload supports cancellation, be sure to check the
`Feedback.is_cancelled` flag as often as is sensible. It is set
when the caller requests the termination of the operation.
The callback function provided as part of the initialiser will be
passed three positional arguments: a float between 0.0 and 1.0 that
signifies the progress of the function, a status string, and a
Boolean flag that shows whether the function flagged itself as busy
(i.e. working but unable to give progress updates).
"""
class FeedbackBusyContext:
"""Context manager for unresponsive subtask states.
This only sets the `Feedback.is_busy` flag and is provided for
syntactic sugar. It performs no error handling or suppression
whatsoever.
"""
def __init__(self, feedback: 'Feedback', msg: str = None) -> None:
"""Create the feedback context.
This does not make any alterations yet. You must enter the
context manager to see changes.
Arguments:
feedback -- The feedback object to modify.
Keyword Arguments:
msg -- A status message to broadcast when entering the
context manager. (default None)
"""
self.feedback = feedback
self._msg = msg
def __enter__(self) -> None:
"""Flag the feedback object as busy.
This is called when the `feedback.busy()` context handler
is entered. Does not return a value.
"""
self.feedback._is_busy = True
msg = None
if self._msg is not None:
msg = self.feedback._prefix + self._msg
self.feedback._callback(None, msg, True)
def __exit__(self, exc_type: Type[BaseException],
exc_val: BaseException,
xc_traceback: TracebackType) -> bool:
"""Flag the feedback object as no longer busy.
This performs no error handling whatsoever.
Arguments:
exc_type {Type[BaseException]} -- The exception class that
was raised
exc_val {BaseException} -- The actual exception instance
exc_traceback {TracebackType} -- Exception traceback
Returns:
bool -- Whether the given exception should be suppressed
"""
self.feedback._is_busy = False
return False
def __init__(self, callback: FeedbackCallback, prefix: str = None) -> None:
"""Set up a new feedback wrapper using the given callback.
Arguments:
callback -- The function to call whenever the status is
updated, make sure it is very light-weight or rate
limited if necessary. See the docstring of the
`Feedback` class for a list of arguments that will be
passed.
Keyword Arguments:
prefix -- An optional prefix to use for status messages.
Can be reset at any time using the
`Feedback.set_prefix()` method. (default None)
"""
self._callback = callback
self._is_busy = False
self._is_cancelled = False
self._prefix = '' if prefix is None else prefix
self._scaling_span = 1.0
self._scaling_offset = 0.0
@property
def is_busy(self) -> bool:
"""Return whether the task is flagged as busy."""
return self._is_busy
@property
def is_cancelled(self) -> bool:
"""Return whether the task has been cancelled."""
return self._is_cancelled
def busy(self, msg: str = None) -> 'FeedbackBusyContext':
"""Return a context manager for the `Feedback.is_busy` flag.
Use this to flag a part of your LRO as busy, meaning that it is
working but unable to give progress updates, as may occur while
waiting for an external executable to exit, or while awaiting
network traffic.
with feedback.busy('Awaiting execution...'):
# Within this block, the feedback status will
# always report "busy" and any updates to the
# progress will be silently ignored.
subtask.part1()
# Updating the status message is still permissable
feedback.update(msg='Almost done...')
subtask.part2(b)
feedback.update(1.0, 'Done')
Keyword Arguments:
msg -- An optional status message to set as the context manager
is entered. (default None)
"""
context = self.FeedbackBusyContext(self, msg)
return context
def cancel(self) -> None:
"""Flag the current task as cancelled.
This informs the function that it should exit gracefully as
quickly as possible.
"""
self._is_cancelled = True
def clear_prefix(self) -> None:
"""Clear the status message prefix."""
self._prefix = ''
def clear_scaling(self) -> None:
"""Reset the scaling behaviour."""
self._scaling_span = 1.0
self._scaling_offset = 0.0
@staticmethod
def scale(value: float, total: float, offset: float = 0.0) -> float:
"""Rescale a given progress float to another range.
This is mostly used to calculate the total progress of multiple
sub-tasks, e.g. being 80% finished with a task that is itself
only half the total workload would mean 40% total progress:
>>> Feedback.scale(0.8, 0.5)
0.4
Arguments:
value -- The progress of the subtask.
total -- How big the sub-task is relative to the total.
Keyword Arguments:
offset -- Allows specifying a sub-task. Useful to add
percentages of other sub-tasks that already complted.
(default 0.0)
Returns:
float -- The scaled progress float.
"""
if value < 0.0:
value = 0.0
elif value > 1.0:
value = 1.0
if total < 0.0:
total = 0.0
elif total > 1.0-offset:
total = 1.0-offset
new_val = offset + value*total
if new_val < 0.0:
return 0.0
if new_val > 1.0:
return 1.0
return new_val
def set_busy(self, is_busy: bool = True) -> None:
"""Set the `Feedback.is_busy` flag.
This function is provided for compatibility, it is recommended
to use the `Feedback.busy()` context manager instead. See its
docstring for details.
"""
self._is_busy = is_busy
def set_prefix(self, prefix: str) -> None:
"""Set the status message prefix.
Use `Feedback.clear_prefix()` to remove the prefix entirely.
Arguments:
prefix -- A prefix to prepend to every status message sent.
"""
self._prefix = prefix
def set_scaling(self, span: float, offset: float = 0.0) -> None:
"""Set the scaling factor.
The scaling factor automatically applies the `scale()` method
to any status updates.
"""
if not 0.0 <= span <= 1.0:
raise ValueError('span must lie within 0.0 and than 1.0')
if not 0.0 <= offset <= 1.0-span:
raise ValueError('offset must lie within 0.0 and (1.0 - span)')
self._scaling_span = span
self._scaling_offset = offset
def update(self, value: float = None, msg: str = None) -> None:
"""Push the current progress and status update.
This is the primary endpoint for functions reporting status
updates to the class.
Keyword Arguments:
value -- A float between 0.0 and 1.0 that reflects the
approximate completion status of the operation.
(default None)
msg -- A status message to push, informing the caller what
the operation is currently doing. (default None)
Raises:
InterruptedError -- Raised if the cancel flag was set.
ValueError -- Raised if the progress value lies outside the
[0.0, 1.0] interval.
"""
if self._is_cancelled:
self._callback(0.0, 'Operation cancelled', False)
raise InterruptedError('The operation was cancelled by the user')
if msg is not None:
msg = self._prefix + msg
if value is not None and not self._is_busy:
if not 0.0 <= value <= 1.0:
raise ValueError('value must lie within 0.0 and 1.0')
value = self.scale(value, self._scaling_span, self._scaling_offset)
self._callback(value, msg, self._is_busy)
......@@ -3,6 +3,7 @@
from typing import Callable, Dict, Optional, Tuple
from .abc import ElevationSource
from .core import Mesh
from .feedback import Feedback
from .types import Triangle2D, Point2D
# Type aliases
......@@ -11,8 +12,8 @@ _TriangleSampler = Callable[[Triangle2D], Point2D]
def calculate_element_elevation(mesh: Mesh, *args: ElevationSource,
default: float = None,
sampler: _TriangleSampler = None
) -> Dict[int, float]:
sampler: _TriangleSampler = None,
feedback: Feedback) -> Dict[int, float]:
"""Calculate and add the element elevation attributes to the mesh.
This calculates mesh element elevations using a given element
......@@ -27,7 +28,6 @@ def calculate_element_elevation(mesh: Mesh, *args: ElevationSource,
descending order of priority. At least one source must be
provided
Keyword Arguments:
default {float} -- A fallback elevation to use when every
elevation source has failed. If `None`, a ValueError will
......@@ -54,9 +54,13 @@ def calculate_element_elevation(mesh: Mesh, *args: ElevationSource,
if sampler is None:
sampler = triangle_com
if feedback is not None:
total = len(mesh.elements)
interval = int(total / 100) if total > 100 else 1
# Iterate over all elements in the input mesh
result_dict: Dict[int, float] = {}
for element in mesh.elements: