diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 4d1d93381..d12f5bf46 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,3 +1,7 @@ # .git-blame-ignore-revs # Ran isort and black on the whole codebase 360c67fab09d172498b3014510ee3658643d12da +# Ran black with 2024 stable style +c6c83f95ff370b75c3ee7130dbd8071bfe8b285a +# Cleaned up test quote spacing +995cd182924dc9e3dbbc941c5b75454ea0cdaaca diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 633aa0bf1..2a5bb55d1 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -51,35 +51,6 @@ jobs: with: ref: ${{ github.ref }} - - name: Install non-Python dependencies (Linux) - if: ${{ matrix.os == 'ubuntu-latest' }} - uses: awalsh128/cache-apt-pkgs-action@latest - with: - packages: blender openscad - - - name: Restore cached non-Python dependencies (Windows) - id: windows-cache-deps - if: ${{ matrix.os == 'windows-latest' }} - uses: actions/cache@v3 - with: - path: downloads - key: windows-deps - - - name: Download non-Python dependencies (Windows) - if: ${{ matrix.os == 'windows-latest' && steps.windows-cache-deps.outputs.cache-hit != 'true' }} - run: | - New-Item -Path downloads -ItemType Directory -Force - Invoke-WebRequest https://github.com/openscad/openscad/releases/download/openscad-2021.01/OpenSCAD-2021.01-x86-64.zip -O downloads/openscad.zip - Invoke-WebRequest https://download.blender.org/release/Blender3.6/blender-3.6.0-windows-x64.zip -O downloads/blender.zip - - - name: Install non-Python dependencies (Windows) - if: ${{ matrix.os == 'windows-latest' }} - run: | - Expand-Archive -Path downloads/openscad.zip -DestinationPath openscad - Move-Item -Path openscad/openscad-2021.01 -Destination $Env:Programfiles\OpenSCAD - Expand-Archive -Path downloads/blender.zip -DestinationPath blender - Move-Item -Path blender/blender-3.6.0-windows-x64 -Destination "$Env:Programfiles\Blender Foundation\Blender" - - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: diff --git a/docs/_templates/installation.rst b/docs/_templates/installation.rst index daedbf981..bd1af936f 100644 --- a/docs/_templates/installation.rst +++ b/docs/_templates/installation.rst @@ -1,4 +1,4 @@ -Next, activate the `virtual environment `_ in which you want to install Scenic. +Activate the `virtual environment `_ in which you want to install Scenic. To create and activate a new virtual environment called :file:`venv`, you can run the following commands: .. venv-setup-start diff --git a/docs/developing.rst b/docs/developing.rst index 044f88ced..97bdfe8e0 100644 --- a/docs/developing.rst +++ b/docs/developing.rst @@ -58,6 +58,21 @@ If you're running the test suite on a headless server or just want to stop windo popping up during testing, use the :command:`--no-graphics` option to skip graphical tests. +Prior to finalizing a PR or other substantial changes, it's a good idea to run the test suite under all major versions of Python that Scenic supports, in fresh virtual environments. +You can do this automatically with the command :command:`tox`, which by default will test all supported major versions both with and without optional dependencies (this will take a long time). +Some variations: + +* :command:`tox -p` will run the various combinations in parallel. + +* :command:`tox -m basic` skips testing installations with the optional dependencies. + +* :command:`tox -- --fast` only runs the "fast" tests. In general, any arguments after the :command:`--` will get passed to ``pytest``. For example, + +* :command:`tox -- tests/syntax/test_specifiers.py` only runs the tests in the given file. + +See the `Tox `_ website for more information about the available options and how to configure Tox. + + .. _debugging: Debugging diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 2fe5bf7b5..34fe517d0 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -25,20 +25,16 @@ If you encounter any errors, please see our :doc:`install_notes` for suggestions .. tab:: macOS - Start by downloading `Blender `__ and `OpenSCAD `__ and installing them into your :file:`Applications` directory. - .. include:: _templates/installation.rst .. tab:: Linux - Start by installing the Python-Tk interface, Blender, and OpenSCAD. + Start by installing the Python-Tk interface. You can likely use your system's package manager; e.g. on Debian/Ubuntu run: .. code-block:: text - sudo apt-get install python3-tk blender openscad - - For other Linux distributions or if you need to install from source, see the download pages for `Blender `__ and `OpenSCAD `__. + sudo apt-get install python3-tk .. include:: _templates/installation.rst @@ -46,8 +42,6 @@ If you encounter any errors, please see our :doc:`install_notes` for suggestions These instructions cover installing Scenic natively on Windows; if you are using the `Windows Subsystem for Linux `_ (on Windows 10 and newer), see the WSL tab instead. - Start by downloading and running the installers for `Blender `__ and `OpenSCAD `__. - .. include:: _templates/installation.rst :end-before: .. venv-setup-start @@ -64,12 +58,12 @@ If you encounter any errors, please see our :doc:`install_notes` for suggestions These instructions cover installing Scenic on the Windows Subsystem for Linux (WSL). If you haven't already installed WSL, you can do that by running :command:`wsl --install` (in either Command Prompt or PowerShell) and restarting your computer. - Then open a WSL terminal and run the following commands to install Python, the Python-Tk interface, Blender, and OpenSCAD: + Then open a WSL terminal and run the following commands to install Python and the Python-Tk interface: .. code-block:: text sudo apt-get update - sudo apt-get install python3 python3-tk blender openscad + sudo apt-get install python3 python3-tk .. include:: _templates/installation.rst :end-before: .. venv-setup-start diff --git a/docs/reference/region_types.rst b/docs/reference/region_types.rst index ffe44d1aa..76507693c 100644 --- a/docs/reference/region_types.rst +++ b/docs/reference/region_types.rst @@ -78,8 +78,6 @@ When checking containment of an `Object` in a 2D region, Scenic will atuomatical Most 3D regions inherit from either `MeshVolumeRegion` or `MeshSurfaceRegion`, which represent the volume (of a watertight mesh) and the surface of a mesh respectively. Various region classes are also provided to create primitive shapes. `MeshVolumeRegion` can be converted to `MeshSurfaceRegion` (and vice versa) using the the ``getSurfaceRegion`` and ``getVolumeRegion`` methods. -Mesh regions can use one of two engines for mesh operations: Blender or OpenSCAD. This can be controlled using the ``engine`` parameter, passing ``"blender"`` or ``"scad"`` respectively. Blender is generally more tolerant but can produce unreliable output, such as meshes that have microscopic holes. OpenSCAD is generally more precise, but may crash on certain inputs that it considers ill-defined. By default, Scenic uses Blender internally. - PolygonalFootprintRegions represent the :term:`footprint` of a 2D region. See `2D Regions` for more details. .. autoclass:: scenic.core.regions.MeshVolumeRegion diff --git a/pyproject.toml b/pyproject.toml index cd7aa7698..c1997cb51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "dotmap ~= 1.3", "mapbox_earcut >= 0.12.10", "matplotlib ~= 3.2", + "manifold3d == 2.3.0", "networkx >= 2.6", "numpy ~= 1.24", "opencv-python ~= 4.5", @@ -46,7 +47,7 @@ dependencies = [ "scikit-image ~= 0.21", "scipy ~= 1.7", "shapely ~= 2.0", - "trimesh >=3.22.5, <4", + "trimesh >=4.0.9, <5", ] [project.optional-dependencies] @@ -75,11 +76,11 @@ test-full = [ # like 'test' but adds dependencies for optional features ] dev = [ "scenic[test-full]", - "black ~= 23.0", + "black ~= 24.0", "isort ~= 5.11", "pre-commit ~= 3.0", "pytest-cov >= 3.0.0", - "tox ~= 3.14", + "tox ~= 4.0", ] [project.urls] diff --git a/src/scenic/core/object_types.py b/src/scenic/core/object_types.py index 702f4a47d..46ec59cf1 100644 --- a/src/scenic/core/object_types.py +++ b/src/scenic/core/object_types.py @@ -27,6 +27,7 @@ import trimesh from scenic.core.distributions import ( + FunctionDistribution, MultiplexerDistribution, RandomControlFlowError, Samplable, @@ -869,9 +870,11 @@ class OrientedPoint(Point): "heading": PropertyDefault( {"orientation"}, {"dynamic", "final"}, - lambda self: self.yaw - if alwaysGlobalOrientation(self.parentOrientation) - else self.orientation.yaw, + lambda self: ( + self.yaw + if alwaysGlobalOrientation(self.parentOrientation) + else self.orientation.yaw + ), ), "viewAngle": math.tau, # Primarily for backwards compatibility. Set viewAngles instead. "viewAngles": PropertyDefault( @@ -1328,67 +1331,125 @@ def boundingBox(self): @cached_property def inradius(self): """A lower bound on the inradius of this object""" - # First check if all needed variables are defined. If so, we can - # compute the inradius exactly. - width, length, height = self.width, self.length, self.height - shape = self.shape - if not any(needsSampling(val) for val in (width, length, height, shape)): - shapeRegion = MeshVolumeRegion( - mesh=shape.mesh, dimensions=(width, length, height) - ) - return shapeRegion.inradius - # If we havea uniform distribution over shapes and a supportInterval for each dimension, - # we can compute a supportInterval for this object's inradius + # Define a helper function that computes the support of the inradius, + # given the sub supports. + def inradiusSupport(width_s, length_s, height_s, shape_s): + # Unpack the dimension supports (and ignore the shape support) + min_width, max_width = width_s + min_length, max_length = length_s + min_height, max_height = height_s + + if None in [ + min_width, + max_width, + min_length, + max_length, + min_height, + max_height, + ]: + # Can't get a bound on one or more dimensions, abort + return None, None + + min_bounds = np.array([min_width, min_length, min_height]) + max_bounds = np.array([max_width, max_length, max_height]) + + # Extract a list of possible shapes + if isinstance(self.shape, Shape): + shapes = [self.shape] + elif isinstance(self.shape, MultiplexerDistribution) and all( + isinstance(opt, Shape) for opt in self.shape.options + ): + shapes = self.shape.options + else: + # Something we don't recognize, abort + return None, None - # Define helper class - class InradiusHelper: - def __init__(self, support): - self.support = support + # Get the inradius for each shape with the min and max bounds + min_distances = [ + MeshVolumeRegion(mesh=shape.mesh, dimensions=min_bounds).inradius + for shape in shapes + ] + max_distances = [ + MeshVolumeRegion(mesh=shape.mesh, dimensions=max_bounds).inradius + for shape in shapes + ] - def supportInterval(self): - return self.support + distance_range = (min(min_distances), max(max_distances)) - # Extract bounds on all dimensions - min_width, max_width = supportInterval(width) - min_length, max_length = supportInterval(length) - min_height, max_height = supportInterval(height) + return distance_range - if None in [min_width, max_width, min_length, max_length, min_height, max_height]: - # Can't get a bound on one or more dimensions, abort - return 0 + # Define a helper function that computes the actual inradius + @distributionFunction(support=inradiusSupport) + def inradiusActual(width, length, height, shape): + return MeshVolumeRegion( + mesh=shape.mesh, dimensions=(width, length, height) + ).inradius - min_bounds = np.array([min_width, min_length, min_height]) - max_bounds = np.array([max_width, max_length, max_height]) + # Return the inradius (possibly a distribution) with proper support information + return inradiusActual(self.width, self.length, self.height, self.shape) - # Extract a list of possible shapes - if isinstance(shape, Shape): - shapes = [shape] - elif isinstance(shape, MultiplexerDistribution): - if all(isinstance(opt, Shape) for opt in shape.options): - shapes = shape.options + @cached_property + def planarInradius(self): + """A lower bound on the planar inradius of this object. + + This is defined as the inradius of the polygon of the occupiedSpace + of this object projected into the XY plane, assuming that pitch and + roll are both 0. + """ + + # Define a helper function that computes the support of the inradius, + # given the sub supports. + def planarInradiusSupport(width_s, length_s, shape_s): + # Unpack the dimension supports (and ignore the shape support) + min_width, max_width = width_s + min_length, max_length = length_s + + if None in [min_width, max_width, min_length, max_length]: + # Can't get a bound on one or more dimensions, abort + return None, None + + min_bounds = np.array([min_width, min_length, 1]) + max_bounds = np.array([max_width, max_length, 1]) + + # Extract a list of possible shapes + if isinstance(self.shape, Shape): + shapes = [self.shape] + elif isinstance(self.shape, MultiplexerDistribution) and all( + isinstance(opt, Shape) for opt in self.shape.options + ): + shapes = self.shape.options else: # Something we don't recognize, abort - return 0 + return None, None + + # Get the inradius of the projected for each shape with the min and max bounds + min_distances = [ + MeshVolumeRegion( + mesh=shape.mesh, dimensions=min_bounds + ).boundingPolygon.inradius + for shape in shapes + ] + max_distances = [ + MeshVolumeRegion( + mesh=shape.mesh, dimensions=max_bounds + ).boundingPolygon.inradius + for shape in shapes + ] - # Check that all possible shapes contain the origin - if not all(shape.containsCenter for shape in shapes): - # One or more shapes has inradius 0 - return 0 + distance_range = (min(min_distances), max(max_distances)) - # Get the inradius for each shape with the min and max bounds - min_distances = [ - MeshVolumeRegion(mesh=shape.mesh, dimensions=min_bounds).inradius - for shape in shapes - ] - max_distances = [ - MeshVolumeRegion(mesh=shape.mesh, dimensions=max_bounds).inradius - for shape in shapes - ] + return distance_range - distance_range = (min(min_distances), max(max_distances)) + # Define a helper function that computes the actual planarInradius + @distributionFunction(support=planarInradiusSupport) + def planarInradiusActual(width, length, shape): + return MeshVolumeRegion( + mesh=shape.mesh, dimensions=(width, length, 1) + ).boundingPolygon.inradius - return InradiusHelper(support=distance_range) + # Return the planar inradius (possibly a distribution) with proper support information + return planarInradiusActual(self.width, self.length, self.shape) @cached_property def surface(self): diff --git a/src/scenic/core/pruning.py b/src/scenic/core/pruning.py index 00d28d486..60f7a93c6 100644 --- a/src/scenic/core/pruning.py +++ b/src/scenic/core/pruning.py @@ -5,11 +5,14 @@ """ import builtins +import collections import math import time +import numpy import shapely.geometry import shapely.geos +from trimesh.transformations import translation_matrix from scenic.core.distributions import ( AttributeDistribution, @@ -17,6 +20,7 @@ MethodDistribution, OperatorDistribution, Samplable, + dependencies, needsSampling, supportInterval, underlyingFunction, @@ -25,10 +29,17 @@ from scenic.core.geometry import hypot, normalizeAngle, plotPolygon, polygonUnion from scenic.core.object_types import Object, Point import scenic.core.regions as regions -from scenic.core.regions import EmptyRegion, MeshSurfaceRegion, MeshVolumeRegion +from scenic.core.regions import ( + EmptyRegion, + MeshSurfaceRegion, + MeshVolumeRegion, + PolygonalRegion, + VoxelRegion, +) from scenic.core.type_support import TypecheckedDistribution from scenic.core.vectors import ( PolygonalVectorField, + Vector, VectorField, VectorMethodDistribution, VectorOperatorDistribution, @@ -36,9 +47,11 @@ from scenic.core.workspaces import Workspace from scenic.syntax.relations import DistanceRelation, RelativeHeadingRelation -### Utilities +### Constants +PRUNING_PITCH = 0.01 +### Utilities def currentPropValue(obj, prop): """Get the current value of an object's property, taking into account prior pruning.""" value = getattr(obj, prop) @@ -62,8 +75,7 @@ def isFunctionCall(thing, function): def matchInRegion(position): """Match uniform samples from a `Region` - Returns the Region, if any, and a lower and upper bound - on the distance the object will be placed along with any + Returns the Region, if any, along with any offset that should be added to the base. """ # Case 1: Position is simply a point in a region @@ -71,7 +83,7 @@ def matchInRegion(position): reg = position.region if isinstance(reg, Workspace): reg = reg.region - return reg, 0, 0, None + return reg, None # Case 2: Position is a point in a region with a vector offset. if isinstance(position, VectorOperatorDistribution) and position.operator in ( @@ -80,15 +92,14 @@ def matchInRegion(position): ): if isinstance(position.object, regions.PointInRegionDistribution): reg = position.object.region + if isinstance(reg, Workspace): + reg = reg.region assert len(position.operands) == 1 offset = position.operands[0] - # TODO: Proper vector supportInterval calculations. Right now this gives us None - # if value is not exact - lower, upper = supportInterval(offset.norm()) - return reg, lower, upper, offset + return reg, offset - return None, 0, 0, None + return None, None def matchPolygonalField(heading, position): @@ -155,6 +166,7 @@ def prune(scenario, verbosity=1): pruneContainment(scenario, verbosity) pruneRelativeHeading(scenario, verbosity) + pruneVisibility(scenario, verbosity) if verbosity >= 1: totalTime = time.time() - startTime @@ -162,8 +174,6 @@ def prune(scenario, verbosity=1): ## Pruning based on containment - - def pruneContainment(scenario, verbosity): """Prune based on the requirement that individual Objects fit within their container. @@ -174,7 +184,7 @@ def pruneContainment(scenario, verbosity): """ for obj in scenario.objects: # Extract the base region and container region, while doing minor checks. - base, _, maxDistance, offset = matchInRegion(obj.position) + base, offset = matchInRegion(obj.position) if base is None or needsSampling(base): continue @@ -190,19 +200,70 @@ def pruneContainment(scenario, verbosity): if isinstance(container, regions.EmptyRegion): raise InvalidScenarioError(f"Object {obj} contained in empty region") - # Erode the container region if possible. - minRadius, _ = supportInterval(obj.inradius) - + # Compute the maximum distance the object can be from the sampled point + if offset is not None: + # TODO: Support interval doesn't really work here for random values. + if isinstance(base, PolygonalRegion): + # Special handling for 2D regions that ignores vertical component of offset + offset_2d = Vector(offset.x, offset.y, 0) + _, maxDistance = supportInterval(offset_2d.norm()) + else: + _, maxDistance = supportInterval(offset.norm()) + else: + maxDistance = 0 + + # Compute the minimum radius of the object, with respect to the + # bounded dimensions of the container. if ( - hasattr(container, "buffer") - and maxDistance is not None + isinstance(base, PolygonalRegion) + and supportInterval(obj.pitch) == (0, 0) + and supportInterval(obj.roll) == (0, 0) + ): + # Special handling for 2D regions with no pitch or roll, + # using planar inradius instead. + minRadius, _ = supportInterval(obj.planarInradius) + else: + # For most regions, use full object inradius. + minRadius, _ = supportInterval(obj.inradius) + + # Erode the container if possible and productive + if ( + maxDistance is not None and minRadius is not None + and (maxErosion := minRadius - maxDistance) > 0 ): - maxErosion = minRadius - maxDistance - if maxErosion > 0: + if hasattr(container, "buffer"): + # We can do an exact erosion container = container.buffer(-maxErosion) + elif isinstance(container, MeshVolumeRegion): + # We can attempt to erode a voxel approximation of the MeshVolumeRegion. + # Compute a voxel overapproximation of the mesh. Technically this is not + # an overapproximation, but one dilation with a rank 3 structuring unit + # with connectivity 3 is. To simplify, we just erode one less time than + # needed. + target_pitch = PRUNING_PITCH * max(container.mesh.extents) + voxelized_container = container.voxelized(target_pitch, lazy=True) + + # Erode the voxel region. Erosion is done with a rank 3 structuring unit with + # connectivity 3 (a 3x3x3 cube of voxels). Each erosion pass can erode by at + # most math.hypot([pitch]*3). Therefore we can safely make at most + # floor(maxErosion/math.hypot([pitch]*3)) passes without eroding more + # than maxErosion. We also subtract 1 iteration for the reasons above. + iterations = ( + math.floor(maxErosion / math.hypot(*([target_pitch] * 3))) - 1 + ) - # Restrict the base region to the container, unless + if iterations > 0: + eroded_container = voxelized_container.dilation( + iterations=-iterations + ) + + # Now check if this erosion is useful, i.e. do we have less volume to sample from. + # If so, replace the original container. + if eroded_container.size < container.size: + container = eroded_container + + # Restrict the base region to the possibly eroded container, unless # they're the same in which case we're done if base is container: continue @@ -215,30 +276,28 @@ def pruneContainment(scenario, verbosity): if isinstance(base, MeshVolumeRegion) and isinstance(newBase, MeshSurfaceRegion): continue + # Check newBase properties if isinstance(newBase, EmptyRegion): raise InvalidScenarioError(f"Object {obj} does not fit in container") - if verbosity >= 1: - if ( - base.dimensionality is None - or newBase.dimensionality is None - or base.dimensionality != newBase.dimensionality - ): + percentage_pruned = percentagePruned(base, newBase) + + if percentage_pruned is None: + if verbosity >= 1: print( f" Region containment constraint pruning attempted but could not compute percentage for {base} and {newBase}." ) - elif base.dimensionality == newBase.dimensionality: - ratio = newBase.size / base.size - percent = max(0, 100 * (1.0 - ratio)) - - if percent <= 0.001: - # We didn't really prune anything, don't bother setting new position - continue + else: + if percentage_pruned <= 0.001: + # We didn't really prune anything, don't bother setting new position + continue + if verbosity >= 1: print( - f" Region containment constraint pruned {percent:.1f}% of space." + f" Region containment constraint pruned {percentage_pruned:.1f}% of space." ) + # Condition object to pruned position newPos = regions.Region.uniformPointIn(newBase) if offset is not None: @@ -248,8 +307,6 @@ def pruneContainment(scenario, verbosity): ## Pruning based on orientation - - def pruneRelativeHeading(scenario, verbosity): """Prune based on requirements bounding the relative heading of an Object. @@ -279,7 +336,7 @@ def pruneRelativeHeading(scenario, verbosity): # Check for relative heading relations among such objects for obj, (field, offsetL, offsetR) in fields.items(): position = currentPropValue(obj, "position") - base, _, _, offset = matchInRegion(position) + base, offset = matchInRegion(position) # obj must be positioned uniformly in a Region if base is None or needsSampling(base): @@ -333,6 +390,102 @@ def pruneRelativeHeading(scenario, verbosity): obj.position.conditionTo(newPos) +# Pruning based on visibility +def pruneVisibility(scenario, verbosity): + ego = scenario.egoObject + + for obj in scenario.objects: + # Extract the base region if it exists + position = currentPropValue(obj, "position") + base, offset = matchInRegion(position) + + if base is None or needsSampling(base): + continue + + newBase = base + + # Define a helper function to buffer an oberver's visibleRegion, resulting + # in a region that contains all points that could feasibly be the position + # of obj, if it is visible from the observer. + def bufferHelper(viewRegion): + # Compute a voxel overapproximation of the mesh. Technically this is not + # an overapproximation, but one dilation with a rank 3 structuring unit + # with connectivity 3 is. To simplify, we just dilate one additional time. + target_pitch = PRUNING_PITCH * max(viewRegion.mesh.extents) + voxelized_vr = viewRegion.voxelized(target_pitch, lazy=True) + + # Dilate the voxel region. Dilation is done with a rank 3 structuring unit with + # connectivity 3 (a 3x3x3 cube of voxels). Each dilation pass must dilate by at + # least pitch. Therefore we must make at least ceiling((radius/2)/pitch) passes + # to ensure we have dilated by the half the circumradius of the object. We also + # add 1 iteration for the reasons above. + iterations = math.ceil((obj.radius / 2) / target_pitch) + 1 + dilated_vr = voxelized_vr.dilation(iterations=iterations) + + return dilated_vr + + # Prune based off visibility/non-visibility requirements + if obj.requireVisible: + # We can restrict the base region to the buffered visible region + # of the ego. + if ( + base is not ego.visibleRegion + and not needsSampling(ego.visibleRegion) + and not checkCyclical(base, ego.visibleRegion) + ): + if verbosity >= 1: + print( + f" Pruning restricted base region of {obj} to visible region of ego." + ) + newBase = newBase.intersect(bufferHelper(ego.visibleRegion)) + + if obj._observingEntity: + # We can restrict the base region to the buffered visible region + # of the observing entity. Only do this if the visible + # region is fixed, to avoid creating it at every timestep. + if ( + base is not obj._observingEntity.visibleRegion + and not needsSampling(obj._observingEntity.visibleRegion) + and not checkCyclical(base, obj._observingEntity.visibleRegion) + ): + if verbosity >= 1: + print( + f" Pruning restricted base region of {obj} to visible region of {obj._observingEntity}." + ) + newBase = newBase.intersect( + bufferHelper(obj._observingEntity.visibleRegion) + ) + + # Check newBase properties + if isinstance(newBase, EmptyRegion): + raise InvalidScenarioError( + f"Object {obj} can not satisfy visibility/non-visibility constraints." + ) + + percentage_pruned = percentagePruned(base, newBase) + + if percentage_pruned is None: + if verbosity >= 1: + print( + f" Visibility pruning attempted but could not compute percentage for {base} and {newBase}." + ) + else: + if percentage_pruned <= 0.001: + # We didn't really prune anything, don't bother setting new position + continue + + if verbosity >= 1: + print(f" Visibility pruning pruned {percentage_pruned:.1f}% of space.") + + # Condition object to pruned position + newPos = regions.Region.uniformPointIn(newBase) + + if offset is not None: + newPos += offset + + obj.position.conditionTo(newPos) + + def maxDistanceBetween(scenario, obj, target): """Upper bound the distance between the given Objects.""" # visDist is initialized to infinity. Then we can use @@ -428,3 +581,56 @@ def relativeHeadingRange( tPoints.extend((math.pi, -math.pi)) rhs = [tp - p for tp in tPoints for p in points] # TODO improve return min(rhs), max(rhs) + + +def percentagePruned(base, newBase): + if ( + base.dimensionality + and newBase.dimensionality + and base.dimensionality == newBase.dimensionality + ): + ratio = newBase.size / base.size + percent = max(0, 100 * (1.0 - ratio)) + return percent + + return None + + +def checkCyclical(A, B): + """Check for a potential circular dependency + + Returns True if the scenario would have a circular dependency + if A depended on B. + """ + state = collections.defaultdict(lambda: 0) + + def dfs(target): + # Check if the target is already completed/in-process + if state[target] == 2: + return False + elif state[target] == 1: + return True + + # Set to in-process + state[target] = 1 + + # Recurse on children + deps = conditionedDeps(target) + + for child in deps: + if child is A: + return True + + if dfs(child): + return True + + # Set to completed + state[target] = 2 + + return False + + return dfs(B) + + +def conditionedDeps(samp): + return list(dependencies(samp._conditioned if isinstance(samp, Samplable) else samp)) diff --git a/src/scenic/core/regions.py b/src/scenic/core/regions.py index 417b50cfd..f05d17e8b 100644 --- a/src/scenic/core/regions.py +++ b/src/scenic/core/regions.py @@ -21,10 +21,12 @@ import shapely.ops import trimesh from trimesh.transformations import ( - concatenate_matrices, + compose_matrix, + identity_matrix, quaternion_matrix, translation_matrix, ) +import trimesh.voxel warnings.filterwarnings( "ignore", module="trimesh" @@ -54,7 +56,7 @@ ) from scenic.core.lazy_eval import isLazy, valueInContext from scenic.core.type_support import toOrientation, toScalar, toVector -from scenic.core.utils import cached, cached_method, cached_property, loadMesh, unifyMesh +from scenic.core.utils import cached, cached_method, cached_property, unifyMesh from scenic.core.vectors import ( Orientation, OrientedVector, @@ -767,7 +769,6 @@ class MeshRegion(Region): tolerance: Tolerance for internal computations. centerMesh: Whether or not to center the mesh after copying and before transformations. onDirection: The direction to use if an object being placed on this region doesn't specify one. - engine: Which engine to use for mesh operations. Either "blender" or "scad". additionalDeps: Any additional sampling dependencies this region relies on. """ @@ -781,7 +782,6 @@ def __init__( tolerance=1e-6, centerMesh=True, onDirection=None, - engine="scad", name=None, additionalDeps=[], ): @@ -794,7 +794,6 @@ def __init__( self.tolerance = tolerance self.centerMesh = centerMesh self.onDirection = onDirection - self.engine = engine # Initialize superclass with samplables super().__init__( @@ -812,7 +811,7 @@ def __init__( return # Convert extract mesh - if isinstance(mesh, trimesh.primitives._Primitive): + if isinstance(mesh, trimesh.primitives.Primitive): self._mesh = mesh.to_mesh() elif isinstance(mesh, trimesh.base.Trimesh): self._mesh = mesh.copy() @@ -849,9 +848,7 @@ def __init__( self.orientation = orientation @classmethod - def fromFile( - cls, path, filetype=None, compressed=None, binary=False, unify=True, **kwargs - ): + def fromFile(cls, path, unify=True, **kwargs): """Load a mesh region from a file, attempting to infer filetype and compression. For example: "foo.obj.bz2" is assumed to be a compressed .obj file. @@ -868,7 +865,7 @@ def fromFile( unify (bool): Whether or not to attempt to unify this mesh. kwargs: Additional arguments to the MeshRegion initializer. """ - mesh = loadMesh(path, filetype, compressed, binary) + mesh = trimesh.load(path, force="mesh") if unify and issubclass(cls, MeshVolumeRegion): mesh = unifyMesh(mesh, verbose=True) @@ -889,11 +886,14 @@ def sampleGiven(self, value): dimensions=value[self.dimensions], position=value[self.position], rotation=value[self.rotation], - orientation=value[self.orientation], + orientation=( + True + if self.__dict__.get("_usingDefaultOrientation", False) + else value[self.orientation] + ), tolerance=self.tolerance, centerMesh=self.centerMesh, onDirection=self.onDirection, - engine=self.engine, name=self.name, ) @@ -909,7 +909,11 @@ def evaluateInner(self, context): dimensions = valueInContext(self.dimensions, context) position = valueInContext(self.position, context) rotation = valueInContext(self.rotation, context) - orientation = valueInContext(self.orientation, context) + orientation = ( + True + if self.__dict__.get("_usingDefaultOrientation", False) + else valueInContext(self.orientation, context) + ) return cls( mesh, @@ -920,7 +924,6 @@ def evaluateInner(self, context): tolerance=self.tolerance, centerMesh=self.centerMesh, onDirection=self.onDirection, - engine=self.engine, name=self.name, ) @@ -987,9 +990,9 @@ def isConvex(self): @property def AABB(self): return ( - tuple(self.mesh.bounds[0]), - tuple(self.mesh.bounds[1]), - tuple(self.mesh.bounds[2]), + tuple(self.mesh.bounds[:, 0]), + tuple(self.mesh.bounds[:, 1]), + tuple(self.mesh.bounds[:, 2]), ) @cached_property @@ -1057,7 +1060,6 @@ class MeshVolumeRegion(MeshRegion): tolerance: Tolerance for internal computations. centerMesh: Whether or not to center the mesh after copying and before transformations. onDirection: The direction to use if an object being placed on this region doesn't specify one. - engine: Which engine to use for mesh operations. Either "blender" or "scad". """ def __init__(self, *args, **kwargs): @@ -1418,12 +1420,7 @@ def intersect(self, other, triedReversed=False): other_mesh = other.mesh # Compute intersection using Trimesh - try: - new_mesh = self.mesh.intersection(other_mesh, engine=self.engine) - except ValueError as exc: - raise ValueError( - "Unable to compute mesh boolean operation. Do you have the Blender and OpenSCAD installed on your system?" - ) from exc + new_mesh = self.mesh.intersection(other_mesh) if new_mesh.is_empty: return nowhere @@ -1432,7 +1429,6 @@ def intersect(self, other, triedReversed=False): new_mesh, tolerance=min(self.tolerance, other.tolerance), centerMesh=False, - engine=self.engine, ) else: # Something went wrong, abort @@ -1629,12 +1625,7 @@ def union(self, other, triedReversed=False): other_mesh = other.mesh # Compute union using Trimesh - try: - new_mesh = self.mesh.union(other_mesh, engine=self.engine) - except ValueError as exc: - raise ValueError( - "Unable to compute mesh boolean operation. Do you have the Blender and OpenSCAD installed on your system?" - ) from exc + new_mesh = self.mesh.union(other_mesh) if new_mesh.is_empty: return nowhere @@ -1643,7 +1634,6 @@ def union(self, other, triedReversed=False): new_mesh, tolerance=min(self.tolerance, other.tolerance), centerMesh=False, - engine=self.engine, ) else: # Something went wrong, abort @@ -1669,14 +1659,7 @@ def difference(self, other, debug=False): other_mesh = other.mesh # Compute difference using Trimesh - try: - new_mesh = self.mesh.difference( - other_mesh, engine=self.engine, debug=debug - ) - except ValueError as exc: - raise ValueError( - "Unable to compute mesh boolean operation. Do you have the Blender and OpenSCAD installed on your system?" - ) from exc + new_mesh = self.mesh.difference(other_mesh) if new_mesh.is_empty: return nowhere @@ -1685,7 +1668,6 @@ def difference(self, other, debug=False): new_mesh, tolerance=min(self.tolerance, other.tolerance), centerMesh=False, - engine=self.engine, ) else: # Something went wrong, abort @@ -1735,11 +1717,12 @@ def distanceTo(self, point): return abs(dist) @cached_property + @distributionFunction def inradius(self): center_point = self.mesh.bounding_box.center_mass pq = trimesh.proximity.ProximityQuery(self.mesh) - region_distance = abs(pq.signed_distance([center_point])[0]) + region_distance = pq.signed_distance([center_point])[0] if region_distance < 0: return 0 @@ -1755,17 +1738,19 @@ def size(self): return self.mesh.mass / self.mesh.density ## Utility Methods ## + def voxelized(self, pitch, lazy=False): + """Returns a VoxelRegion representing a filled voxelization of this mesh""" + return VoxelRegion(voxelGrid=self.mesh.voxelized(pitch).fill(), lazy=lazy) + @cached_method def getSurfaceRegion(self): """Return a region equivalent to this one, except as a MeshSurfaceRegion""" return MeshSurfaceRegion( self.mesh, self.name, - orientation=self.orientation, tolerance=self.tolerance, centerMesh=False, onDirection=self.onDirection, - engine=self.engine, ) def getVolumeRegion(self): @@ -1801,8 +1786,16 @@ class MeshSurfaceRegion(MeshRegion): onDirection: The direction to use if an object being placed on this region doesn't specify one. """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, *args, orientation=True, **kwargs): + if orientation is True: + orientation = VectorField( + "DefaultSurfaceVectorField", self.getFlatOrientation + ) + self._usingDefaultOrientation = True + else: + self._usingDefaultOrientation = False + + super().__init__(*args, orientation=orientation, **kwargs) # Validate dimensions if self.dimensions is not None: @@ -1810,12 +1803,6 @@ def __init__(self, *args, **kwargs): if dim < 0: raise ValueError(f"{name} of MeshSurfaceRegion must be nonnegative") - # Set default orientation to one inferred from face norms if none is provided. - if self.orientation is None: - self.orientation = VectorField( - "DefaultSurfaceVectorField", lambda pos: self.getFlatOrientation(pos) - ) - # Property testing methods # @distributionFunction def intersects(self, other, triedReversed=False): @@ -1953,11 +1940,9 @@ def getVolumeRegion(self): return MeshVolumeRegion( self.mesh, self.name, - orientation=self.orientation, tolerance=self.tolerance, centerMesh=False, onDirection=self.onDirection, - engine=self.engine, ) def getSurfaceRegion(self): @@ -1989,7 +1974,6 @@ def sampleGiven(self, value): rotation=value[self.rotation], orientation=value[self.orientation], tolerance=self.tolerance, - engine=self.engine, name=self.name, ) @@ -2005,7 +1989,6 @@ def evaluateInner(self, context): rotation=rotation, orientation=orientation, tolerance=self.tolerance, - engine=self.engine, name=self.name, ) @@ -2034,7 +2017,6 @@ def sampleGiven(self, value): rotation=value[self.rotation], orientation=value[self.orientation], tolerance=self.tolerance, - engine=self.engine, name=self.name, ) @@ -2050,11 +2032,136 @@ def evaluateInner(self, context): rotation=rotation, orientation=orientation, tolerance=self.tolerance, - engine=self.engine, name=self.name, ) +class VoxelRegion(Region): + """Region represented by a voxel grid in 3D space. + + Args: + voxelGrid: The Trimesh voxelGrid to be used. + orientation: An optional vector field describing the preferred orientation at every point in + the region. + name: An optional name to help with debugging. + lazy: Whether or not to be lazy about pre-computing internal values. Set this to True if this + VoxelRegion is unlikely to be used outside of an intermediate step in compiling/pruning. + """ + + def __init__(self, voxelGrid, orientation=None, name=None, lazy=False): + # Initialize superclass + super().__init__(name, orientation=orientation) + + # Check that the encoding isn't empty. In that case, raise an error. + if voxelGrid.encoding.is_empty: + raise ValueError("Tried to create an empty VoxelRegion.") + + # Store voxel grid and extract points and scale + self.voxelGrid = voxelGrid + self.voxel_points = self.voxelGrid.points + self.scale = self.voxelGrid.scale + + @cached_property + def kdTree(self): + return scipy.spatial.KDTree(self.voxel_points) + + def containsPoint(self, point): + point = toVector(point) + + # Find closest voxel point + _, index = self.kdTree.query(point) + closest_point = self.voxel_points[index] + + # Check voxel containment + voxel_low = closest_point - self.scale / 2 + voxel_high = closest_point + self.scale / 2 + + return numpy.all(voxel_low <= point) & numpy.all(point <= voxel_high) + + def containsObject(self, obj): + raise NotImplementedError + + def containsRegionInner(self, reg): + raise NotImplementedError + + def distanceTo(self, point): + raise NotImplementedError + + def projectVector(self, point, onDirection): + raise NotImplementedError + + def uniformPointInner(self): + # First generate a point uniformly in a box with dimensions + # equal to scale, centered at the origin. + base_pt = numpy.random.random_sample(3) - 0.5 + scaled_pt = base_pt * self.scale + + # Pick a random voxel point and add it to base_pt. + voxel_base = self.voxel_points[random.randrange(len(self.voxel_points))] + offset_pt = voxel_base + scaled_pt + + return Vector(*offset_pt) + + def dilation(self, iterations, structure=None): + """Returns a dilated/eroded version of this VoxelRegion. + + Args: + iterations: How many times repeat the dilation/erosion. A positive + number indicates a dilation and a negative number indicates an + erosion. + structure: The structure to use. If none is provided, a rank 3 + structuring unit with connectivity 3 is used. + """ + # Parse parameters + if iterations == 0: + return self + + if iterations > 0: + morphology_func = scipy.ndimage.binary_dilation + else: + morphology_func = scipy.ndimage.binary_erosion + + iterations = abs(iterations) + + if structure == None: + structure = scipy.ndimage.generate_binary_structure(3, 3) + + # Compute a dilated/eroded encoding + new_encoding = trimesh.voxel.encoding.DenseEncoding( + morphology_func( + trimesh.voxel.morphology._dense(self.voxelGrid.encoding, rank=3), + structure=structure, + iterations=iterations, + ) + ) + + # Check if the encoding is empty, in which case we should return the empty region. + if new_encoding.is_empty: + return nowhere + + # Otherwise, return a VoxelRegion representing the eroded region. + new_voxel_grid = trimesh.voxel.VoxelGrid( + new_encoding, transform=self.voxelGrid.transform + ) + return VoxelRegion(voxelGrid=new_voxel_grid) + + @property + def AABB(self): + return ( + tuple(self.voxelGrid.bounds[:, 0]), + tuple(self.voxelGrid.bounds[:, 1]), + tuple(self.voxelGrid.bounds[:, 2]), + ) + + @property + def size(self): + return self.voxelGrid.volume + + @property + def dimensionality(self): + return 3 + + class PolygonalFootprintRegion(Region): """Region that contains all points in a polygonal footprint, regardless of their z value. @@ -2298,11 +2405,22 @@ class PathRegion(Region): Args: points: A list of points defining a single polyline. polylines: A list of list of points, defining multiple polylines. + orientation (optional): :term:`preferred orientation` to use, or `True` to use an + orientation aligned with the direction of the path (the default). tolerance: Tolerance used internally. """ - def __init__(self, points=None, polylines=None, tolerance=1e-8, name=None): - super().__init__(name) + def __init__( + self, points=None, polylines=None, tolerance=1e-8, orientation=True, name=None + ): + if orientation is True: + orientation = VectorField("Path", self.defaultOrientation) + self._usingDefaultOrientation = True + else: + self._usingDefaultOrientation = False + + super().__init__(name, orientation=orientation) + # Standardize inputs if points is not None and polylines is not None: raise ValueError("Both points and polylines passed to PathRegion initializer") @@ -2374,6 +2492,16 @@ def containsRegionInner(self, reg, tolerance): raise NotImplementedError def distanceTo(self, point): + return self._segmentDistanceHelper(point).min() + + def nearestSegmentTo(self, point): + nearest_segment = self._edgeVectorArray[ + self._segmentDistanceHelper(point).argmin() + ] + return toVector(nearest_segment[0:3]), toVector(nearest_segment[3:6]) + + def _segmentDistanceHelper(self, point): + """Returns distance to point from each line segment""" p = numpy.asarray(toVector(point)) a = self._edgeVectorArray[:, 0:3] b = self._edgeVectorArray[:, 3:6] @@ -2391,7 +2519,11 @@ def distanceTo(self, point): ) perp_dist = numpy.linalg.norm(numpy.cross(a_min_p, d), axis=1) - return numpy.hypot(parallel_dist, perp_dist).min() + return numpy.hypot(parallel_dist, perp_dist) + + def defaultOrientation(self, point): + start, end = self.nearestSegmentTo(point) + return Orientation.fromEuler(start.azimuthTo(end), start.altitudeTo(end), 0) def projectVector(self, point, onDirection): raise NotImplementedError @@ -2685,6 +2817,19 @@ def distanceTo(self, point): dist2D = shapely.distance(self.polygons, makeShapelyPoint(point)) return math.hypot(dist2D, point[2] - self.z) + @cached_property + @distributionFunction + def inradius(self): + minx, miny, maxx, maxy = self.polygons.bounds + center = makeShapelyPoint(((minx + maxx) / 2, (maxy + miny) / 2)) + + # Check if center is contained + if not self.polygons.contains(center): + return 0 + + # Return the distance to the nearest boundary + return shapely.distance(self.polygons.boundary, center) + def projectVector(self, point, onDirection): raise NotImplementedError( f'{type(self).__name__} does not yet support projection using "on"' @@ -3028,9 +3173,9 @@ class PolylineRegion(Region): def __init__(self, points=None, polyline=None, orientation=True, name=None): if orientation is True: orientation = VectorField("Polyline", self.defaultOrientation) - self.usingDefaultOrientation = True + self._usingDefaultOrientation = True else: - self.usingDefaultOrientation = False + self._usingDefaultOrientation = False super().__init__(name, orientation=orientation) if points is not None: @@ -3120,7 +3265,7 @@ def start(self): there is one (the default orientation pointing along the polyline). """ pointA, pointB = self.segments[0] - if self.usingDefaultOrientation: + if self._usingDefaultOrientation: orientation = headingOfSegment(pointA, pointB) elif self.orientation is not None: orientation = self.orientation[Vector(*pointA)] @@ -3144,7 +3289,7 @@ def end(self): there is one (the default orientation pointing along the polyline). """ pointA, pointB = self.segments[-1] - if self.usingDefaultOrientation: + if self._usingDefaultOrientation: orientation = headingOfSegment(pointA, pointB) elif self.orientation is not None: orientation = self.orientation[Vector(*pointB)].yaw @@ -3172,7 +3317,7 @@ def uniformPointInner(self): )[0] interpolation = random.random() x, y = averageVectors(pointA, pointB, weight=interpolation) - if self.usingDefaultOrientation: + if self._usingDefaultOrientation: return OrientedVector(x, y, 0, headingOfSegment(pointA, pointB)) else: return self.orient(Vector(x, y, 0)) @@ -3596,13 +3741,10 @@ class ViewRegion(MeshVolumeRegion): * Case 1: viewAngles[1] = 180 degrees - * Case 2.a viewAngles[0] = 360 degrees => Sphere - * Case 2.b viewAngles[0] < 360 degrees => Sphere & CylinderSectionRegion - - * Case 2: viewAngles[1] < 180 degrees + * Case 1.a viewAngles[0] = 360 degrees => Sphere + * Case 1.b viewAngles[0] < 360 degrees => Sphere & CylinderSectionRegion - * Case 2.a viewAngles[0] = 360 degrees => Sphere - (Cone + Cone) (Cones on z axis expanding from origin) - * Case 2.b viewAngles[0] < 360 degrees => Sphere & ViewSectionRegion + * Case 2: viewAngles[1] < 180 degrees => Sphere & ViewSectionRegion When making changes to this class you should run ``pytest -k test_viewRegion --exhaustive``. @@ -3612,8 +3754,6 @@ class ViewRegion(MeshVolumeRegion): name: An optional name to help with debugging. position: An optional position, which determines where the center of the region will be. rotation: An optional Orientation object which determines the rotation of the object in space. - orientation: An optional vector field describing the preferred orientation at every point in - the region. angleCutoff: How close to 180/360 degrees an angle has to be to be mapped to that value. tolerance: Tolerance for collision computations. """ @@ -3625,61 +3765,45 @@ def __init__( name=None, position=Vector(0, 0, 0), rotation=None, - orientation=None, - angleCutoff=0.01, + angleCutoff=0.017, tolerance=1e-8, ): # Bound viewAngles from either side. if min(viewAngles) <= 0: raise ValueError("viewAngles cannot have a component less than or equal to 0") + # TODO True surface representation + viewAngles = (max(viewAngles[0], angleCutoff), max(viewAngles[1], angleCutoff)) + + if math.tau - angleCutoff <= viewAngles[0]: + viewAngles = (math.tau, viewAngles[1]) + + if math.pi - angleCutoff <= viewAngles[1]: + viewAngles = (viewAngles[0], math.pi) + view_region = None diameter = 2 * visibleDistance - base_sphere = SpheroidRegion( - dimensions=(diameter, diameter, diameter), engine="scad" - ) + base_sphere = SpheroidRegion(dimensions=(diameter, diameter, diameter)) if math.pi - angleCutoff <= viewAngles[1]: # Case 1 - if math.tau - angleCutoff <= viewAngles[0]: + if viewAngles[0] == math.tau: # Case 1.a view_region = base_sphere else: + # Case 1.b view_region = base_sphere.intersect( CylinderSectionRegion(visibleDistance, viewAngles[0]) ) else: # Case 2 - if math.tau - angleCutoff <= viewAngles[0]: - # Case 2.a - # Create cone with yaw oriented around (0,0,-1) - padded_height = visibleDistance * 2 - radius = padded_height * math.tan((math.pi - viewAngles[1]) / 2) - - cone_mesh = trimesh.creation.cone(radius=radius, height=padded_height) - - position_matrix = translation_matrix((0, 0, -1 * padded_height)) - cone_mesh.apply_transform(position_matrix) - - # Create two cones around the yaw axis - orientation_1 = Orientation._fromEuler(0, 0, 0) - orientation_2 = Orientation._fromEuler(0, 0, math.pi) - - cone_1 = MeshVolumeRegion( - mesh=cone_mesh, rotation=orientation_1, centerMesh=False - ) - cone_2 = MeshVolumeRegion( - mesh=cone_mesh, rotation=orientation_2, centerMesh=False - ) - - view_region = base_sphere.difference(cone_1).difference(cone_2) - else: - # Case 2.b - view_region = base_sphere.intersect( - ViewSectionRegion(visibleDistance, viewAngles) - ) + view_region = base_sphere.intersect( + ViewSectionRegion(visibleDistance, viewAngles) + ) assert view_region is not None + assert isinstance(view_region, MeshVolumeRegion) + assert view_region.containsPoint(Vector(0, 0, 0)) # Initialize volume region super().__init__( @@ -3687,7 +3811,6 @@ def __init__( name=name, position=position, rotation=rotation, - orientation=orientation, tolerance=tolerance, centerMesh=False, ) @@ -3717,8 +3840,9 @@ def __init__(self, visibleDistance, viewAngles, rotation=None, resolution=32): triangles.append((bot_line[li], bot_line[li + 1], top_line[li + 1])) # Side triangles - triangles.append((bot_line[0], top_line[0], (0, 0, 0))) - triangles.append((top_line[-1], bot_line[-1], (0, 0, 0))) + if viewAngles[0] < math.tau: + triangles.append((bot_line[0], top_line[0], (0, 0, 0))) + triangles.append((top_line[-1], bot_line[-1], (0, 0, 0))) # Top/Bottom triangles for li in range(len(top_line) - 1): diff --git a/src/scenic/core/shapes.py b/src/scenic/core/shapes.py index 8b01f36d1..2a57c1f37 100644 --- a/src/scenic/core/shapes.py +++ b/src/scenic/core/shapes.py @@ -11,7 +11,7 @@ ) from scenic.core.type_support import toOrientation -from scenic.core.utils import cached_property, loadMesh, unifyMesh +from scenic.core.utils import cached_property, unifyMesh from scenic.core.vectors import Orientation ################################################################################################### @@ -122,9 +122,7 @@ def __init__(self, mesh, dimensions=None, scale=1, initial_rotation=None): super().__init__(dimensions, scale) @classmethod - def fromFile( - cls, path, filetype=None, compressed=None, binary=False, unify=True, **kwargs - ): + def fromFile(cls, path, unify=True, **kwargs): """Load a mesh shape from a file, attempting to infer filetype and compression. For example: "foo.obj.bz2" is assumed to be a compressed .obj file. @@ -141,7 +139,7 @@ def fromFile( unify (bool): Whether or not to attempt to unify this mesh. kwargs: Additional arguments to the MeshShape initializer. """ - mesh = loadMesh(path, filetype, compressed, binary) + mesh = trimesh.load(path, force="mesh") if not mesh.is_volume: raise ValueError( "A MeshShape cannot be defined with a mesh that does not have a well defined volume." diff --git a/src/scenic/core/utils.py b/src/scenic/core/utils.py index 123cbe4d2..4549afdad 100644 --- a/src/scenic/core/utils.py +++ b/src/scenic/core/utils.py @@ -124,49 +124,16 @@ def alarm(seconds, handler=None, noNesting=False): signal.signal(signal.SIGALRM, signal.SIG_DFL) -def loadMesh(path, filetype, compressed, binary): - working_path = path - - if binary: - mode = "rb" - else: - mode = "r" - - # Check if file is compressed - if compressed is None: - root, ext = os.path.splitext(working_path) - - if ext == ".bz2": - compressed = True - working_path = root - else: - compressed = False - - # Check mesh filetype - if filetype is None: - root, ext = os.path.splitext(working_path) - - if ext == "": - raise ValueError("Mesh filetype not provided, but could not be extracted") - - filetype = ext - - if compressed: - open_function = bz2.open - else: - open_function = open - - with open_function(path, mode) as mesh_file: - mesh = trimesh.load(mesh_file, file_type=filetype, force="mesh") - - return mesh - - def unifyMesh(mesh, verbose=False): - """Attempt to merge mesh bodies, aborting if something fails. + """Attempt to merge mesh bodies, raising a `ValueError` if something fails. - Should only be used with meshes that are volumes. Returns the - original mesh if something goes wrong. + Should only be used with meshes that are volumes. + + If a mesh is composed of multiple bodies, the following process + is applied: + 1. Split mesh into volumes and holes. + 2. From each volume, subtract each hole that is fully contained. + 3. Union all the resulting volumes. """ assert mesh.is_volume @@ -176,30 +143,45 @@ def unifyMesh(mesh, verbose=False): mesh_bodies = mesh.split() - if not all(m.is_volume for m in mesh_bodies): - if verbose: - warnings.warn( - "The mesh that you loaded was composed of multiple bodies," - " but Scenic was unable to unify it because some of those bodies" - " are non-volumetric (e.g. hollow portions of a volume). This is probably" - " not an issue, but note that if any of these bodies have" - " intersecting faces, Scenic may give undefined resuls. To suppress" - " this warning in the future, consider adding the 'unify=False' parameter" - " to your fromFile call." - ) - return mesh + if all(m.is_volume for m in mesh_bodies): + # If all mesh bodies are volumes, we can just return the union. + unified_mesh = trimesh.boolean.union(mesh_bodies) - try: - unified_mesh = trimesh.boolean.union(mesh_bodies, engine="scad") - except CalledProcessError: - # Something went wrong, return the original mesh + else: + # Split the mesh bodies into volumes and holes. + volumes = [] + holes = [] + for m in mesh_bodies: + if m.is_volume: + volumes.append(m) + else: + m.fix_normals() + assert m.is_volume + holes.append(m) + + # For each volume, subtract all holes fully contained in the volume, + # keeping track of which holes are fully contained in at least one solid. + differenced_volumes = [] + contained_holes = set() + + for v in volumes: + for h in filter(lambda h: h.volume < v.volume, holes): + if h.difference(v).is_empty: + contained_holes.add(h) + v = v.difference(h) + differenced_volumes.append(v) + + # If one or more holes was not fully contained (and thus ignored), + # raise a warning. if verbose: - warnings.warn( - "The mesh that you loaded was composed of multiple bodies," - " but Scenic was unable to unify it because OpenSCAD raised" - " an error." - ) - return mesh + if contained_holes != set(holes): + warnings.warn( + "One or more holes in the provided mesh was not fully contained" + " in any solid (and was ignored)." + ) + + # Union all the differenced volumes together. + unified_mesh = trimesh.boolean.union(differenced_volumes) # Check that the output is still a valid mesh if unified_mesh.is_volume: @@ -207,30 +189,23 @@ def unifyMesh(mesh, verbose=False): if unified_mesh.body_count == 1: warnings.warn( "The mesh that you loaded was composed of multiple bodies," - " but Scenic was able to unify it into one single body. To save on compile" + " but Scenic was able to unify it into one single body (though" + " you should verify that the result is correct). To save on compile" " time in the future, consider running unifyMesh on your mesh outside" " of Scenic and using that output instead." ) elif unified_mesh.body_count < mesh.body_count: warnings.warn( "The mesh that you loaded was composed of multiple bodies," - " but Scenic was able to unify it into fewer bodies. To save on compile" + " but Scenic was able to unify it into fewer bodies (though" + " you should verify that the result is correct). To save on compile" " time in the future, consider running unifyMesh on your mesh outside" - " of Scenic and using that output instead. Note that if any of these" - " bodies have intersecting faces, Scenic may give undefined resuls." + " of Scenic and using that output instead." ) return unified_mesh else: - if verbose: - warnings.warn( - "The mesh that you loaded was composed of multiple bodies," - " and Scenic was unable to unify it into fewer bodies. To save on compile" - " time in the future, consider adding the 'unify=False' parameter to your" - " fromFile call. Note that if any of these bodies have intersecting faces," - " Scenic may give undefined resuls." - ) - return mesh + raise ValueError("Unable to unify mesh.") def repairMesh(mesh, pitch=(1 / 2) ** 6, verbose=True): diff --git a/src/scenic/syntax/compiler.py b/src/scenic/syntax/compiler.py index 3c5a1c7ef..9c8c567b4 100644 --- a/src/scenic/syntax/compiler.py +++ b/src/scenic/syntax/compiler.py @@ -495,9 +495,11 @@ def check_and_visit(self: "ScenicToPythonTransformer", node: ast.AST): ctx = "inside a compose block" if ctx: raise self.makeSyntaxError( - f'Cannot use "{node.__class__.__name__}" {ctx}' - if errorBuilder is None - else errorBuilder(ctx), + ( + f'Cannot use "{node.__class__.__name__}" {ctx}' + if errorBuilder is None + else errorBuilder(ctx) + ), node, ) return visitor(self, node) @@ -1108,9 +1110,11 @@ def visit_Mutate(self, node: s.Mutate): value=ast.Call( func=ast.Name(id="mutate", ctx=loadCtx), args=[self.visit(el) for el in node.elts], - keywords=[ast.keyword(arg="scale", value=self.visit(node.scale))] - if node.scale is not None - else [], + keywords=( + [ast.keyword(arg="scale", value=self.visit(node.scale))] + if node.scale is not None + else [] + ), ) ) @@ -1354,9 +1358,11 @@ def createRequirementLike( ast.Constant(lineno), # line number ast.Constant(name), # requirement name ], - keywords=[ast.keyword(arg="prob", value=ast.Constant(prob))] - if prob is not None - else [], + keywords=( + [ast.keyword(arg="prob", value=ast.Constant(prob))] + if prob is not None + else [] + ), ) ) @@ -1468,9 +1474,11 @@ def visit_BeyondSpecifier(self, node: s.BeyondSpecifier): return ast.Call( func=ast.Name(id="Beyond", ctx=loadCtx), args=[self.visit(node.position), self.visit(node.offset)], - keywords=[ast.keyword(arg="fromPt", value=self.visit(node.base))] - if node.base is not None - else [], + keywords=( + [ast.keyword(arg="fromPt", value=self.visit(node.base))] + if node.base is not None + else [] + ), ) def visit_VisibleSpecifier(self, node: s.VisibleSpecifier): @@ -1524,9 +1532,11 @@ def visit_FollowingSpecifier(self, node: s.FollowingSpecifier): return ast.Call( func=ast.Name(id="Following", ctx=loadCtx), args=[self.visit(node.field), self.visit(node.distance)], - keywords=[ast.keyword(arg="fromPt", value=self.visit(node.base))] - if node.base is not None - else [], + keywords=( + [ast.keyword(arg="fromPt", value=self.visit(node.base))] + if node.base is not None + else [] + ), ) def visit_FacingSpecifier(self, node: s.FacingSpecifier): @@ -1570,9 +1580,11 @@ def visit_ApparentlyFacingSpecifier(self, node: s.ApparentlyFacingSpecifier): return ast.Call( func=ast.Name(id="ApparentlyFacing", ctx=loadCtx), args=[self.visit(node.heading)], - keywords=[ast.keyword(arg="fromPt", value=self.visit(node.base))] - if node.base is not None - else [], + keywords=( + [ast.keyword(arg="fromPt", value=self.visit(node.base))] + if node.base is not None + else [] + ), ) # Operators @@ -1581,45 +1593,55 @@ def visit_RelativePositionOp(self, node: s.RelativePositionOp): return ast.Call( func=ast.Name(id="RelativePosition", ctx=loadCtx), args=[self.visit(node.target)], - keywords=[] - if node.base is None - else [ast.keyword(arg="Y", value=self.visit(node.base))], + keywords=( + [] + if node.base is None + else [ast.keyword(arg="Y", value=self.visit(node.base))] + ), ) def visit_RelativeHeadingOp(self, node: s.RelativeHeadingOp): return ast.Call( func=ast.Name(id="RelativeHeading", ctx=loadCtx), args=[self.visit(node.target)], - keywords=[] - if node.base is None - else [ast.keyword(arg="Y", value=self.visit(node.base))], + keywords=( + [] + if node.base is None + else [ast.keyword(arg="Y", value=self.visit(node.base))] + ), ) def visit_ApparentHeadingOp(self, node: s.ApparentHeadingOp): return ast.Call( func=ast.Name(id="ApparentHeading", ctx=loadCtx), args=[self.visit(node.target)], - keywords=[] - if node.base is None - else [ast.keyword(arg="Y", value=self.visit(node.base))], + keywords=( + [] + if node.base is None + else [ast.keyword(arg="Y", value=self.visit(node.base))] + ), ) def visit_DistanceFromOp(self, node: s.DistanceFromOp): return ast.Call( func=ast.Name(id="DistanceFrom", ctx=loadCtx), args=[self.visit(node.target)], - keywords=[ast.keyword(arg="Y", value=self.visit(node.base))] - if node.base is not None - else [], + keywords=( + [ast.keyword(arg="Y", value=self.visit(node.base))] + if node.base is not None + else [] + ), ) def visit_DistancePastOp(self, node: s.DistancePastOp): return ast.Call( func=ast.Name(id="DistancePast", ctx=loadCtx), args=[self.visit(node.target)], - keywords=[] - if node.base is None - else [ast.keyword(arg="Y", value=self.visit(node.base))], + keywords=( + [] + if node.base is None + else [ast.keyword(arg="Y", value=self.visit(node.base))] + ), ) def visit_AngleFromOp(self, node: s.AngleFromOp): diff --git a/tests/core/test_regions.py b/tests/core/test_regions.py index 81b998096..c990dd02c 100644 --- a/tests/core/test_regions.py +++ b/tests/core/test_regions.py @@ -3,6 +3,7 @@ import pytest import shapely.geometry +import trimesh.voxel from scenic.core.object_types import Object, OrientedPoint from scenic.core.regions import * @@ -273,18 +274,13 @@ def test_mesh_surface_region_negative_dimension(): MeshSurfaceRegion(mesh, dimensions=dims) -def test_mesh_operation_blender(): - r1 = BoxRegion(position=(0, 0, 0), dimensions=(1, 1, 1), engine="blender") - r2 = BoxRegion(position=(0, 0, 0), dimensions=(2, 2, 2), engine="blender") +def test_mesh_operation(): + r1 = BoxRegion(position=(0, 0, 0), dimensions=(2, 2, 2)) + r2 = BoxRegion(position=(0, 0, 0), dimensions=(1, 1, 1)) - r = r1.intersect(r2) - - -def test_mesh_operation_scad(): - r1 = BoxRegion(position=(0, 0, 0), dimensions=(1, 1, 1), engine="scad") - r2 = BoxRegion(position=(0, 0, 0), dimensions=(2, 2, 2), engine="scad") - - r = r1.intersect(r2) + r1.intersect(r2) + r1.union(r2) + r1.difference(r2) def test_mesh_volume_region_sampling(): @@ -316,19 +312,17 @@ def test_mesh_intersects(): def test_mesh_empty_intersection(): - for engine in ["blender", "scad"]: - r1 = BoxRegion(position=(0, 0, 0), engine=engine) - r2 = BoxRegion(position=(10, 10, 10), engine=engine) + r1 = BoxRegion(position=(0, 0, 0)) + r2 = BoxRegion(position=(10, 10, 10)) - assert isinstance(r1.intersect(r2), EmptyRegion) + assert isinstance(r1.intersect(r2), EmptyRegion) def test_mesh_empty_difference(): - for engine in ["blender", "scad"]: - r1 = BoxRegion(dimensions=(1, 1, 1), engine=engine) - r2 = BoxRegion(dimensions=(2, 2, 2), engine=engine) + r1 = BoxRegion(dimensions=(1, 1, 1)) + r2 = BoxRegion(dimensions=(2, 2, 2)) - assert isinstance(r1.difference(r2), EmptyRegion) + assert isinstance(r1.difference(r2), EmptyRegion) def test_path_region(): @@ -549,10 +543,80 @@ def test_pointset_region(): assert ps.AABB == ((1, 5), (2, 6), (0, 5)) +def test_voxel_region(): + encoding = [ + [[0, 0, 0], [0, 1, 0], [0, 0, 0]], + [[0, 1, 1], [0, 1, 0], [1, 1, 0]], + [[0, 0, 0], [0, 1, 0], [0, 0, 0]], + ] + + vg1 = trimesh.voxel.VoxelGrid(encoding=numpy.asarray(encoding)) + + centering_matrix = translation_matrix((vg1.scale - vg1.extents) / 2) + vg1.apply_transform(centering_matrix) + + scale = vg1.extents / numpy.array((3, 3, 3)) + scale_matrix = numpy.eye(4) + scale_matrix[:3, :3] /= scale + vg1.apply_transform(scale_matrix) + + position_matrix = translation_matrix((4, 5, 6)) + vg1.apply_transform(position_matrix) + + vr1 = VoxelRegion(vg1) + + assert vr1.containsPoint((4, 5, 6)) + assert vr1.containsPoint((4, 6, 5)) + assert vr1.containsPoint((4, 4, 7)) + assert not vr1.containsPoint((4, 6, 7)) + assert not vr1.containsPoint((4, 4, 5)) + assert not vr1.containsPoint((100, 100, 100)) + + for _ in range(100): + sampled_pt = vr1.uniformPointInner() + assert vr1.containsPoint(sampled_pt) + + assert vr1.AABB == ((2.5, 5.5), (3.5, 6.5), (4.5, 7.5)) + + vg2 = trimesh.voxel.VoxelGrid(encoding=numpy.asarray(encoding)) + + centering_matrix = translation_matrix((vg2.scale - vg2.extents) / 2) + vg2.apply_transform(centering_matrix) + + scale = vg2.extents / numpy.array((5, 5, 3)) + scale_matrix = numpy.eye(4) + scale_matrix[:3, :3] /= scale + vg2.apply_transform(scale_matrix) + + vr2 = VoxelRegion(vg2) + + assert vr2.size == pytest.approx((7 / 27) * 5 * 5 * 3) + assert vr2.dimensionality == 3 + + +def test_mesh_voxelization(getAssetPath): + plane_region = MeshVolumeRegion.fromFile(getAssetPath("meshes/classic_plane.obj.bz2")) + vr = plane_region.voxelized(max(plane_region.mesh.extents) / 100) + + for sampled_pt in trimesh.sample.volume_mesh(plane_region.mesh, 100): + assert vr.containsPoint(sampled_pt) + + for _ in range(100): + sampled_pt = vr.uniformPointInner() + assert vr.containsPoint(sampled_pt) + + +def test_empty_erosion(): + box_region = BoxRegion(position=(0, 0, 0), dimensions=(1, 1, 1)) + vr = box_region.voxelized(pitch=0.1) + erosion = vr.dilation(iterations=-6) + assert isinstance(erosion, EmptyRegion) + + # ViewRegion tests -H_ANGLES = [0.1, 45, 90, 135, 179.9, 180, 180.1, 225, 270, 315, 359.9, 360] +H_ANGLES = [0.95, 45, 90, 135, 177.5, 180, 180.01, 225, 270, 315, 358.99, 360] -V_ANGLES = [0.1, 45, 90, 135, 179.9, 180] +V_ANGLES = [0.95, 45, 90, 135, 177.5, 180] VISIBLE_DISTANCES = [1, 25, 50] diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index f82d44799..370dc29b8 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -4,18 +4,12 @@ import pytest import trimesh -from scenic.core.utils import loadMesh, repairMesh +from scenic.core.utils import repairMesh, unifyMesh @pytest.mark.slow def test_mesh_repair(getAssetPath): - plane_mesh = loadMesh( - path=getAssetPath("meshes/classic_plane.obj.bz2"), - filetype="obj", - compressed=True, - binary=False, - ) - + plane_mesh = trimesh.load(getAssetPath("meshes/classic_plane.obj.bz2"), force="mesh") # Test simple fix inverted_mesh = plane_mesh.copy() inverted_mesh.invert() @@ -46,3 +40,23 @@ def test_mesh_repair(getAssetPath): with pytest.raises(ValueError): repairMesh(plane) + + +@pytest.mark.slow +def test_unify_mesh(): + # Create nested sphere + nested_sphere = ( + trimesh.creation.icosphere(radius=5) + .difference(trimesh.creation.icosphere(radius=4)) + .union(trimesh.creation.icosphere(radius=3)) + .difference(trimesh.creation.icosphere(radius=2)) + ) + + # Manually append a box + bad_mesh = trimesh.util.concatenate( + nested_sphere, trimesh.creation.box(bounds=((0, 0, 0), (3, 5, 3))) + ) + + fixed_mesh = unifyMesh(bad_mesh) + assert fixed_mesh.is_volume + assert fixed_mesh.body_count == 3 diff --git a/tests/syntax/test_basic.py b/tests/syntax/test_basic.py index 842eeecf4..3fff22c63 100644 --- a/tests/syntax/test_basic.py +++ b/tests/syntax/test_basic.py @@ -98,7 +98,7 @@ def test_param_read(): ego = new Object param q = Range(3, 5) param p = globalParameters.q + 10 - """ + """ ) assert 13 <= p <= 15 @@ -113,7 +113,7 @@ def test_mutate(): """ ego = new Object at 3@1, facing 0 mutate - """ + """ ) ego1 = sampleEgo(scenario) assert ego1.position.x != pytest.approx(3) @@ -127,7 +127,7 @@ def test_mutate_object(): ego = new Object at 30@1, facing 0 other = new Object mutate other - """ + """ ) scene = sampleScene(scenario) ego, other = scene.objects @@ -144,7 +144,7 @@ def test_mutate_scaled(): """ ego = new Object at 3@1, facing 0 mutate ego by 4 - """ + """ ) ego1 = sampleEgo(scenario) assert ego1.position.x != pytest.approx(3) @@ -206,7 +206,7 @@ def test_show2D_zoom(): """ ego = new Object new Object at 10@20 - """ + """ ) scene = sampleScene(scenario) scene.show2D(zoom=1, block=False) @@ -226,7 +226,7 @@ def test_mode2D(): test_obj_1 = new Object in p.visibleRegion test_obj_2 = new Object in op.visibleRegion test_obj_3 = new Object in ego.visibleRegion - """, + """, mode2D=True, ) for _ in range(5): @@ -261,7 +261,7 @@ class TestClass: heading: 40 deg ego = new TestClass - """, + """, mode2D=True, ) scene, _ = scenario.generate() @@ -278,7 +278,7 @@ def test_mode2D_interference(): test_obj_1 = new Object in p.visibleRegion test_obj_2 = new Object in op.visibleRegion test_obj_3 = new Object in ego.visibleRegion - """ + """ scenario = compileScenic(program, mode2D=True) for _ in range(5): diff --git a/tests/syntax/test_classes.py b/tests/syntax/test_classes.py index 76fa84392..65628adc3 100644 --- a/tests/syntax/test_classes.py +++ b/tests/syntax/test_classes.py @@ -14,7 +14,7 @@ def test_old_constructor_statement(): constructor Foo: blah: (19, -3) ego = new Foo with blah 12 - """ + """ ) @@ -25,7 +25,7 @@ class Foo(object): def __init__(self, x): self.x = x ego = new Object with width Foo(4).x - """ + """ ) scene = sampleScene(scenario, maxIterations=1) ego = scene.egoObject @@ -38,7 +38,7 @@ def test_invalid_attribute(): """ class Foo:\n blah[baloney_attr]: 4 - """ + """ ) @@ -48,7 +48,7 @@ def test_invalid_attribute_2(): """ class Foo:\n blah[additive, baloney_attr]: 4 - """ + """ ) @@ -58,7 +58,7 @@ def test_invalid_attribute_3(): """ class Foo:\n blah[additive, 'dynamic']: 4 - """ + """ ) @@ -68,7 +68,7 @@ def test_invalid_attribute_4(): """ class Foo:\n blah[additive + dynamic]: 4 - """ + """ ) @@ -79,7 +79,7 @@ class Foo: position: (3, 9, 0) flubber: -12 ego = new Foo - """ + """ ) scene = sampleScene(scenario, maxIterations=1) ego = scene.egoObject @@ -95,7 +95,7 @@ class Foo: bar: self.position.x + self.baz baz: 5 ego = new Foo at (10, 3) - """ + """ ) assert ego.bar == 15 @@ -106,7 +106,7 @@ def test_property_raw_self(): """ class Foo: bar: self - """ + """ ) @@ -118,7 +118,7 @@ class Foo: class Bar(Foo): flubber: 7 ego = new Bar - """ + """ ) scene = sampleScene(scenario, maxIterations=1) ego = scene.egoObject @@ -134,7 +134,7 @@ class Foo: class Bar(Foo): flubber[additive]: 7 ego = new Bar - """ + """ ) scene = sampleScene(scenario, maxIterations=1) ego = scene.egoObject @@ -151,7 +151,7 @@ class Parent: class Child(Parent): foo[additive]: 2 ego = new Child - """ + """ ) assert ego.foo == (2, 1) @@ -195,7 +195,7 @@ class Foo: pass new Object at (20, 0) if issubclass(Foo, Point): new Object at (30, 0) - """ + """ ) scene = sampleScene(scenario) assert len(scene.objects) == 4 diff --git a/tests/syntax/test_distributions.py b/tests/syntax/test_distributions.py index 1dd7fc4ff..c7fda04fc 100644 --- a/tests/syntax/test_distributions.py +++ b/tests/syntax/test_distributions.py @@ -25,7 +25,7 @@ def lazyTestScenario(expr, offset="0"): vf = VectorField("Foo", lambda pos: 2 * pos.x) x = ({offset} relative to vf).yaw ego = new Object at 0.5 @ 0, with output {expr} - """ + """ ) @@ -225,7 +225,7 @@ def test_method(): field = VectorField("Foo", lambda pos: pos[1]) ang = field[0 @ Range(1, 2)].yaw ego = new Object with output ang - """ + """ ) angles = [sampleEgo(scenario).output for i in range(60)] assert all(1 <= x <= 2 for x in angles) @@ -243,7 +243,7 @@ def bar(self, arg): return -arg vf = VectorField("Baz", lambda pos: 1 + pos.x) ego = new Object with foo Foo().bar(Range(100, 200) * (0 relative to vf).yaw) - """ + """ ) values = [sampleEgo(scenario).foo for i in range(60)] assert all(-200 <= x <= -100 for x in values) @@ -262,7 +262,7 @@ def bar(self, arg): return -arg.yaw * Range(100, 200) vf = VectorField("Baz", lambda pos: 1 + pos.x) ego = new Object with foo Foo().bar(0 relative to vf) - """ + """ ) values = [sampleEgo(scenario).foo for i in range(60)] assert all(-200 <= x <= -100 for x in values) @@ -276,7 +276,7 @@ def test_method_lazy_3(): reg = PolylineRegion([0@0, 2@0]) vf = VectorField('Foo', lambda pos: 1 + pos.x) ego = new Object with foo reg.distanceTo((1 @ (Range(0, 1) relative to vf).yaw)) - """ + """ ) fs = [sampleEgo(scenario).foo for i in range(60)] assert all(1 <= f <= 2 for f in fs) @@ -294,7 +294,7 @@ def bar(self, *args): return sum(args) vs = Uniform([5], [-2, -3]) ego = new Object with baz Foo().bar(Range(0, 1), *vs) - """ + """ ) bs = [sampleEgo(scenario).baz for i in range(60)] assert all(5 <= b <= 6 or -5 <= b <= -4 for b in bs) @@ -307,7 +307,7 @@ def test_attribute(): """ place = Uniform(1 @ 1, 2 @ 4, 3 @ 9) ego = new Object at place.x @ place.y - """ + """ ) xs = [sampleEgo(scenario).position.x for i in range(100)] assert all(x == 1 or x == 2 or x == 3 for x in xs) @@ -383,7 +383,7 @@ def test_list_param(): """ ego = new Object param p = [3, Uniform(1, 2)] - """ + """ ) ts = [sampleParamP(scenario) for i in range(60)] assert all(type(t) is list for t in ts) @@ -401,7 +401,7 @@ def test_list_param_lazy(): x = 0 relative to vf param p = Uniform([0, x], [0, x*2])[1] ego = new Object - """ + """ ) @@ -419,7 +419,7 @@ def test_list_sliced(): x = Uniform([1, 2, 3, 4], [5, 6, 7]) i = DiscreteRange(0, 1) ego = new Object with foo x[i:i+2] - """ + """ ) ss = [sampleEgo(scenario).foo for i in range(60)] opts = ([1, 2], [2, 3], [5, 6], [6, 7]) @@ -444,7 +444,7 @@ def test_list_nested(): """ mylist = Uniform(list(range(1000)), [1000]) ego = new Object with foo Uniform(*mylist) - """ + """ ) vs = [sampleEgo(scenario).foo for i in range(60)] assert 5 <= sum((v == 1000) for v in vs) <= 55 @@ -455,7 +455,7 @@ def test_list_nested_argument(): """ mylist = Uniform(list(range(1000)), [1, 1, 1, 1, 2000]) ego = new Object with foo max(*mylist) - """ + """ ) vs = [sampleEgo(scenario).foo for i in range(60)] assert 5 <= sum((v == 2000) for v in vs) <= 55 @@ -467,7 +467,7 @@ def test_list_filtered(): mylist = [Range(-10, -5), Range(3, 7), Range(-1, 1)] filtered = filter(lambda x: x > 0, mylist) ego = new Object with foo Uniform(*filtered) - """ + """ ) vs = [sampleEgo(scenario).foo for i in range(60)] assert all(v > 0 for v in vs) @@ -488,7 +488,7 @@ def test_list_filtered_empty_1(): mylist = [Range(-10, -5), Range(-3, 1)] filtered = filter(lambda x: x > 0, mylist) ego = new Object with foo Uniform(*filtered) - """ + """ ) vs = [sampleEgo(scenario, maxIterations=100).foo for i in range(60)] assert all(0 <= v <= 1 for v in vs) @@ -502,7 +502,7 @@ def test_list_filtered_empty_2(): mylist = [Range(-10, -5), Range(-3, 1)] filtered = filter(lambda x: x > 0, mylist) ego = new Object with foo Uniform(*filtered, 2) - """ + """ ) vs = [sampleEgo(scenario).foo for i in range(150)] assert all(0 <= v <= 1 or v == 2 for v in vs) @@ -529,7 +529,7 @@ def test_tuple_iteration(): data.append(item) ego = new Object at 2@2, with foo data require other.foo[1] == 3 - """, + """, maxIterations=60, ) assert type(ego.foo) is list @@ -541,7 +541,7 @@ def test_tuple_param(): """ ego = new Object param p = tuple([3, Uniform(1, 2)]) - """ + """ ) ts = [sampleParamP(scenario) for i in range(60)] assert all(type(t) is tuple for t in ts) @@ -557,7 +557,7 @@ def test_namedtuple(): from collections import namedtuple Data = namedtuple("Data", ["bar", "baz"]) ego = new Object with foo Data(bar=3, baz=Uniform(1, 2)) - """ + """ ) ts = [sampleEgo(scenario).foo for i in range(60)] assert all(t.bar == 3 for t in ts) @@ -589,7 +589,7 @@ def test_iter(): """ for x in Uniform([1, 2], [3, 4]): ego = new Object at x@0 - """ + """ ) @@ -601,7 +601,7 @@ def test_control_flow(): ego = new Object else: ego = new Object at 1@1 - """ + """ ) @@ -648,7 +648,7 @@ def test_reproducibility(): param foo = Uniform(1, 4, 9, 16, 25, 36) x = Range(0, 1) require x > 0.8 - """ + """ ) seeds = [random.randint(0, 100000) for i in range(10)] for seed in seeds: @@ -714,7 +714,7 @@ def test_resample(): """ x = Range(0, 1) ego = new Object at x @ resample(x) - """ + """ ) pos = sampleEgo(scenario).position assert pos.x != pos.y @@ -733,7 +733,7 @@ def test_shared_dependency(): """ x = Range(-1, 1) ego = new Object at (x * x) @ 0 - """ + """ ) xs = [sampleEgo(scenario).position.x for i in range(60)] assert all(0 <= x <= 1 for x in xs) @@ -748,7 +748,7 @@ def test_shared_dependency_lazy_1(): x = (1 relative to vf).yaw y = Uniform(0, x) ego = new Object with foo y, with bar y - """ + """ ) for i in range(60): ego = sampleEgo(scenario) @@ -763,7 +763,7 @@ def test_shared_dependency_lazy_2(): x = Range(0, 1) relative to vf ego = new Object at 1 @ 0, facing x other = new Object at -1 @ 0, facing x - """ + """ ) for i in range(60): scene = sampleScene(scenario, maxIterations=1) @@ -792,7 +792,7 @@ def test_object_expression(): v = Uniform((new Object at Range(-2,-1) @ 0), new Object at Range(1,2) @ 5).position.x ego = new Object facing v, at 0 @ 10 require abs(v) > 1.5 - """ + """ ) for i in range(3): scene = sampleScene(scenario, maxIterations=50) diff --git a/tests/syntax/test_dynamics.py b/tests/syntax/test_dynamics.py index 8b9083dbe..513796cd1 100644 --- a/tests/syntax/test_dynamics.py +++ b/tests/syntax/test_dynamics.py @@ -33,7 +33,7 @@ def test_dynamic_property(): wait ego = new Object with behavior Foo terminate when ego.position.x >= 3 - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert len(actions) == 3 @@ -48,7 +48,7 @@ def test_dynamic_final_property(): wait ego = new Object with behavior Foo terminate when ego.heading >= 0.25 - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert len(actions) == 3 @@ -63,7 +63,7 @@ def test_dynamic_cached_property(): wait ego = new Object with behavior Foo terminate when ego.left.position.y >= 3 - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert len(actions) == 3 @@ -78,7 +78,7 @@ def test_dynamic_cached_method(): wait ego = new Object with behavior Foo terminate when ego.distanceTo(0@4) < 1 - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert len(actions) == 3 @@ -94,7 +94,7 @@ def test_current_time(): while True: take simulation().currentTime ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert tuple(actions) == (0, 1, 2) @@ -117,7 +117,7 @@ def test_behavior_actions(): take 3 take 5 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=2) assert tuple(actions) == (3, 5) @@ -130,7 +130,7 @@ def test_behavior_multiple_actions(): take 1, 4, 9 take 5 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=2, singleAction=False) assert tuple(actions) == ((1, 4, 9), (5,)) @@ -143,7 +143,7 @@ def test_behavior_tuple_actions(): take (1, 4, 9) take 5 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=2, singleAction=False) assert tuple(actions) == ((1, 4, 9), (5,)) @@ -156,7 +156,7 @@ def test_behavior_list_actions(): take [1, 4, 9] take 5 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=2, singleAction=False) assert tuple(actions) == ((1, 4, 9), (5,)) @@ -172,7 +172,7 @@ def test_invalid_behavior_name(): behavior 101(): wait ego = new Object - """ + """ ) @@ -185,7 +185,7 @@ def test_behavior_no_actions(): behavior Bar(): Foo() # forgot to use 'do' ego = new Object with behavior Bar - """ + """ ) @@ -199,7 +199,7 @@ def test_behavior_stuck(monkeypatch): time.sleep(1.5) wait ego = new Object with behavior Foo - """ + """ ) monkeypatch.setattr(dynamics, "stuckBehaviorWarningTimeout", 1) with pytest.warns(dynamics.StuckBehaviorWarning): @@ -214,7 +214,7 @@ def test_behavior_create_object(): new Object at 10@10 wait ego = new Object with behavior Bar - """ + """ ) sampleResultOnce(scenario) @@ -227,7 +227,7 @@ def test_behavior_define_param(): param foo = 3 wait ego = new Object with behavior Bar - """ + """ ) sampleResultOnce(scenario) @@ -240,7 +240,7 @@ def test_behavior_illegal_yield(): yield 1 wait ego = new Object with behavior Foo - """ + """ ) with pytest.raises(ScenicSyntaxError): compileScenic( @@ -249,7 +249,7 @@ def test_behavior_illegal_yield(): yield from [] wait ego = new Object with behavior Foo - """ + """ ) @@ -262,7 +262,7 @@ def test_behavior_nested_defn(): behavior Bar(): wait ego = new Object with behavior Foo - """ + """ ) @@ -312,7 +312,7 @@ def test_behavior_object_argument(): wait other = new Object with flag 0, with behavior Bar ego = new Object at (10, 0), with behavior Foo(other) - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=2) assert actions[1] == 1 @@ -329,7 +329,7 @@ def test_behavior_globals_read(): take other.position.x ego = new Object with behavior Foo other = new Object at Range(10, 20) @ 15 - """ + """ ) actions1 = sampleEgoActions(scenario, maxSteps=2) assert len(actions1) == 2 @@ -349,7 +349,7 @@ def test_behavior_globals_read_module(runLocally): while True: take helper4.foo ego = new Object with behavior Foo - """ + """ ) actions1 = sampleEgoActions(scenario, maxSteps=2) assert len(actions1) == 2 @@ -368,7 +368,7 @@ def test_behavior_globals_read_list(): take foo[1] ego = new Object with behavior Foo foo = [5, Range(10, 20)] - """ + """ ) actions1 = sampleEgoActions(scenario, maxSteps=2) assert len(actions1) == 2 @@ -393,7 +393,7 @@ def test_behavior_globals_write(): take (glob < 1) other = new Object with behavior Foo ego = new Object at 10@10, with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert len(actions) == 3 @@ -417,7 +417,7 @@ def test_behavior_namespace_interference(runLocally): behavior Foo(): take sub.subsub.myglobal ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario) assert len(actions) == 1 @@ -433,7 +433,7 @@ def test_behavior_self(): behavior Foo(): take self.bar ego = new Object with behavior Foo, with bar 3 - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=1) assert tuple(actions) == (3,) @@ -446,7 +446,7 @@ def test_behavior_lazy(): behavior Foo(): take (1 relative to vf).yaw ego = new Object at 0.5@0, with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=1) assert tuple(actions) == (pytest.approx(1.5),) @@ -465,7 +465,7 @@ def test_behavior_lazy_nested(): do Bar(); do Bar() new Object at -1.25@0, with behavior Baz ego = new Object at 0.5@0, with behavior Foo - """ + """ ) actions = sampleActions(scenario, maxSteps=2) assert tuple(actions) == (pytest.approx((1.5, -0.25)), pytest.approx((-0.5, -0.25))) @@ -480,7 +480,7 @@ def test_behavior_end_early(): behavior Foo(): take 5 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert tuple(actions) == (5, None, None) @@ -494,7 +494,7 @@ def test_terminate(): terminate take 2 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert tuple(actions) == (1,) @@ -511,7 +511,7 @@ def test_terminate_when(): take 2 ego = new Object with behavior Foo terminate when flag as termCond - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert tuple(actions) == (1, 2) @@ -559,7 +559,7 @@ def test_behavior_ordering_default(): new Object with name 'A', with behavior Foo new Object with name 'B', at 10@0, with behavior Foo ego = new Object with name 'C', at 20@0, with behavior Foo - """ + """ ) scene = sampleScene(scenario) objsByName = {} @@ -588,7 +588,7 @@ def test_behavior_nesting(): do Foo(2) take 3 ego = new Object with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (1, 2, 2, 3) @@ -604,7 +604,7 @@ def test_subbehavior_for_steps(): do Foo() for 3 steps take 2 ego = new Object with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (1, 1, 1, 2) @@ -620,7 +620,7 @@ def test_subbehavior_for_time(): do Foo() for 3 seconds take 2 ego = new Object with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=7, timestep=0.5) assert tuple(actions) == (1, 1, 1, 1, 1, 1, 2) @@ -636,7 +636,7 @@ def test_subbehavior_until(): do Foo() until simulation().currentTime == 2 take 2 ego = new Object with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (1, 1, 2, None) @@ -651,7 +651,7 @@ def test_subbehavior_incompatible_modifiers(): behavior Bar(): do Foo() for 5 steps until False ego = new Object with behavior Bar - """ + """ ) @@ -664,7 +664,7 @@ def test_subbehavior_misplaced_modifier(): behavior Bar(): do Foo() for 5 steps, Foo() ego = new Object with behavior Bar - """ + """ ) @@ -674,7 +674,7 @@ def test_behavior_invoke_mistyped(): behavior Foo(): do 12 ego = new Object with behavior Foo - """ + """ ) with pytest.raises(TypeError): sampleActions(scenario) @@ -689,7 +689,7 @@ def test_behavior_invoke_multiple(): behavior Bar(): do Foo(), Foo() ego = new Object with behavior Bar - """ + """ ) @@ -702,7 +702,7 @@ def func(a, *b, c=0, d=1, **e): behavior Foo(): take [func(4, 5, 6, blah=4, c=10)] ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=1) assert tuple(actions) == ([4, 2, 10, 1, 1],) @@ -719,7 +719,7 @@ def funcB(x): behavior Foo(): take funcA(funcB(5)) ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=1) assert tuple(actions) == (11,) @@ -737,7 +737,7 @@ def func(): while True: take func() ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (1, 2, 3, 4) @@ -753,7 +753,7 @@ def test_behavior_precondition(): precondition: self.position.x > 0 take self.position.x ego = new Object at Range(-1, 1) @ 0, with behavior Foo - """ + """ ) for i in range(30): actions = sampleEgoActions(scenario, maxSteps=1, maxIterations=1, maxScenes=50) @@ -769,7 +769,7 @@ def test_behavior_invariant(): take self.position.x self.position -= Range(0, 2) @ 0 ego = new Object at 1 @ 0, with behavior Foo - """ + """ ) for i in range(30): actions = sampleEgoActions(scenario, maxSteps=3, maxIterations=50) @@ -801,7 +801,7 @@ def test_precondition_rejection(): ego = new Object at (0,0,0), with foo 0, with bar 0, with behavior MetaBehavior() - """ + """ ) scene = sampleScene(scenario) results = [sampleEgoActions(scenario, maxSteps=1) for _ in range(40)] @@ -837,7 +837,7 @@ def test_invariant_rejection(): with behavior MetaBehavior() record final (ego.foo, ego.bar) as test_val - """ + """ ) scene = sampleScene(scenario) result = sampleResultFromScene(scene, maxSteps=20) @@ -871,7 +871,7 @@ def test_precondition_rejection_choose(): with behavior MetaBehavior() record final (ego.foo, ego.bar) as test_val - """ + """ ) scene = sampleScene(scenario) result = sampleResultFromScene(scene, maxSteps=20) @@ -905,7 +905,7 @@ def test_invariant_rejection_choose(): with behavior MetaBehavior() record final (ego.foo, ego.bar) as test_val - """ + """ ) scene = sampleScene(scenario) result = sampleResultFromScene(scene, maxSteps=20) @@ -939,7 +939,7 @@ def test_precondition_rejection_shuffle(): with behavior MetaBehavior() record final (ego.foo, ego.bar) as test_val - """ + """ ) for _ in range(20): scene = sampleScene(scenario) @@ -974,7 +974,7 @@ def test_invariant_rejection_shuffle(): with behavior MetaBehavior() record final (ego.foo, ego.bar) as test_val - """ + """ ) for _ in range(20): scene = sampleScene(scenario) @@ -995,7 +995,7 @@ def test_choose_1(): behavior Bar(x): take x ego = new Object with behavior Foo - """ + """ ) ts = [sampleEgoActions(scenario, maxSteps=2) for i in range(40)] assert any(t[0] == 1 for t in ts) @@ -1014,7 +1014,7 @@ def test_choose_2(): precondition: self.position.x == p take (self.position.x == p) ego = new Object at Uniform(1, 2) @ 0, with behavior Foo - """ + """ ) for i in range(30): actions = sampleEgoActions(scenario, maxSteps=2) @@ -1030,7 +1030,7 @@ def test_choose_3(): behavior Sub(x): take x ego = new Object with behavior Foo - """ + """ ) xs = [sampleEgoActions(scenario)[0] for i in range(200)] assert all(x == 0 or x == 1 for x in xs) @@ -1046,7 +1046,7 @@ def test_choose_deadlock(): precondition: self.position.x == p wait ego = new Object at 3 @ 0, with behavior Foo - """ + """ ) result = sampleResultOnce(scenario) assert result is None @@ -1062,7 +1062,7 @@ def test_shuffle_1(): precondition: simulation().currentTime >= x take x ego = new Object with behavior Foo - """ + """ ) for i in range(30): actions = sampleEgoActions(scenario, maxSteps=3) @@ -1077,7 +1077,7 @@ def test_shuffle_2(): behavior Sub(x): take x ego = new Object with behavior Foo - """ + """ ) ts = [sampleEgoActions(scenario, maxSteps=2) for i in range(30)] assert all(tuple(t) == (1, 3) or tuple(t) == (3, 1) for t in ts) @@ -1093,7 +1093,7 @@ def test_shuffle_3(): behavior Sub(x): take x ego = new Object with behavior Foo - """ + """ ) ts = [sampleEgoActions(scenario, maxSteps=2) for i in range(200)] assert all(tuple(t) == (0, 1) or tuple(t) == (1, 0) for t in ts) @@ -1109,7 +1109,7 @@ def test_shuffle_deadlock(): precondition: simulation().currentTime == 0 wait ego = new Object with behavior Foo - """ + """ ) result = sampleResultOnce(scenario, maxSteps=2) assert result is None @@ -1127,7 +1127,7 @@ def test_behavior_require(): take x require x < 0 ego = new Object with behavior Foo - """ + """ ) for i in range(50): actions = sampleEgoActions(scenario, maxSteps=2, maxIterations=50, maxScenes=1) @@ -1144,7 +1144,7 @@ def test_behavior_require_scene(): take self.foo require self.foo < 0 ego = new Object with foo Range(-1, 1), with behavior Foo - """ + """ ) for i in range(50): actions = sampleEgoActions(scenario, maxSteps=2, maxIterations=1, maxScenes=50) @@ -1161,7 +1161,7 @@ def test_behavior_require_call(): require len(x) > 0 take [x] ego = new Object with behavior Foo - """ + """ ) for i in range(30): actions = sampleEgoActions(scenario, maxSteps=1, maxIterations=30) @@ -1176,7 +1176,7 @@ def test_behavior_require_soft(): require[0.9] x >= 0 take x ego = new Object with behavior Foo - """ + """ ) xs = [] for i in range(350): @@ -1199,7 +1199,7 @@ def test_require_always(): self.blah += DiscreteRange(0, 1) ego = new Object with behavior Foo, with blah 0 require always ego.blah < 1 - """ + """ ) for i in range(30): actions = sampleEgoActions(scenario, maxSteps=2, maxIterations=50) @@ -1220,7 +1220,7 @@ def test_require_eventually(): self.blah += DiscreteRange(0, 1) ego = new Object with behavior Foo, with blah 0 require eventually ego.blah > 0 - """ + """ ) for i in range(30): actions = sampleEgoActions(scenario, maxSteps=2, maxIterations=50) @@ -1238,7 +1238,7 @@ def test_require_eventually_2(): require eventually ego.blah == 0 require eventually ego.blah == 1 require eventually ego.blah == 2 - """ + """ ) sampleEgoActions(scenario, maxSteps=3) @@ -1252,7 +1252,7 @@ def test_require_eventually_3(): self.blah += 1 ego = new Object with behavior Foo, with blah 0 require eventually ego.blah == -1 - """ + """ ) with pytest.raises(RejectSimulationException): sampleEgoActions(scenario, maxSteps=3) @@ -1267,7 +1267,7 @@ def test_require_next_1(): take self.blah ego = new Object with behavior Foo, with blah 0 require next ego.blah == 1 - """ + """ ) sampleEgoActions(scenario, maxSteps=5) @@ -1281,7 +1281,7 @@ def test_require_next_2(): take self.blah ego = new Object with behavior Foo, with blah 0 require next next ego.blah == 2 - """ + """ ) sampleEgoActions(scenario, maxSteps=5) @@ -1300,7 +1300,7 @@ def test_require_until(): take self.blah ego = new Object with behavior Foo, with blah 0 require ego.blah < 3 until ego.blah >= 3 - """ + """ ) sampleEgoActions(scenario, maxSteps=5) @@ -1314,7 +1314,7 @@ def test_require_until_2(): take self.blah ego = new Object with behavior Foo, with blah 0 require False until ego.blah > 3 - """ + """ ) with pytest.raises(RejectSimulationException): sampleEgoActions(scenario, maxSteps=5) @@ -1329,7 +1329,7 @@ def test_require_until_3(): take self.blah ego = new Object with behavior Foo, with blah 0 require True until False - """ + """ ) with pytest.raises(RejectSimulationException): sampleEgoActions(scenario, maxSteps=5) @@ -1344,7 +1344,7 @@ def test_require_implies_1(): take self.blah ego = new Object with behavior Foo, with blah 0 require ego.blah == 3 implies ego.blah % 2 == 1 - """ + """ ) sampleEgoActions(scenario, maxSteps=5) @@ -1358,7 +1358,7 @@ def test_require_implies_2(): take self.blah ego = new Object with behavior Foo, with blah 0 require always ego.blah % 2 == 0 implies next ego.blah % 2 == 1 - """ + """ ) sampleEgoActions(scenario, maxSteps=5) @@ -1372,7 +1372,7 @@ def test_require_implies_3(): take self.blah ego = new Object with behavior Foo, with blah 0 require always ego.blah % 2 == 0 implies ego.blah == 0 - """ + """ ) result = sampleResultOnce(scenario, maxSteps=5) assert result is None @@ -1395,7 +1395,7 @@ def test_monitor(): take self.blah self.blah += 1 ego = new Object with blah 0, with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=5) assert tuple(actions) == (0, 1, 2, 3) @@ -1416,7 +1416,7 @@ def test_monitor_arguments(): take self.blah self.blah += 1 ego = new Object with blah 0, with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=5) assert tuple(actions) == (0, 1, 2) @@ -1437,7 +1437,7 @@ def test_monitor_samplable_arguments(): take self.blah self.blah += 1 ego = new Object with blah 0, with behavior Foo - """ + """ ) lengths = [len(sampleEgoActions(scenario, maxSteps=5)) for i in range(60)] assert all(2 <= length <= 3 for length in lengths) @@ -1521,7 +1521,7 @@ def test_invocable_signature(ty): {ty} Blah(foo, *bar, baz=12, **qux): wait ego = new Object with thing Blah - """ + """ ) sig = inspect.signature(ego.thing) params = tuple(sig.parameters.items()) @@ -1556,7 +1556,7 @@ def test_interrupt(): interrupt when simulation().currentTime % 3 == 2: take 2 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=6) assert tuple(actions) == (1, 1, 2, 1, 1, 2) @@ -1572,7 +1572,7 @@ def test_interrupt_first(): interrupt when simulation().currentTime == 0: take 2 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert tuple(actions) == (2, 1, 1) @@ -1590,7 +1590,7 @@ def test_interrupt_priority(): interrupt when simulation().currentTime == 0: take 3 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert tuple(actions) == (3, 2, 1) @@ -1609,7 +1609,7 @@ def test_interrupt_interrupted(): interrupt when simulation().currentTime == 1: take 4 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=5) assert tuple(actions) == (2, 4, 3, 1, 1) @@ -1626,7 +1626,7 @@ def test_interrupt_actionless(): interrupt when i == 1: i = 2 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=5) assert tuple(actions) == (1, 1, 1, None, None) @@ -1642,7 +1642,7 @@ def test_interrupt_define_local(): pass take i ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=1) assert tuple(actions) == (1,) @@ -1659,7 +1659,7 @@ def test_interrupt_define_local_2(): abort take i ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=1) assert tuple(actions) == (1,) @@ -1680,7 +1680,7 @@ def test_interrupt_no_handlers(): except Exception: take 2 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=3) assert tuple(actions) == (1, 2, None) @@ -1700,7 +1700,7 @@ def test_interrupt_except(): except Exception: take 4 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (1, 2, 4, None) @@ -1721,7 +1721,7 @@ def test_interrupt_except_else(): else: take 5 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=7) assert tuple(actions) == (1, 2, 3, 1, 1, 5, None) @@ -1745,7 +1745,7 @@ def test_interrupt_nested(): interrupt when simulation().currentTime == 1: take 3 ego = new Object with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=6) assert tuple(actions) == (1, 3, 2, 1, 1, None) @@ -1767,7 +1767,7 @@ def test_interrupt_nested_2(): interrupt when simulation().currentTime == 2: take 4 ego = new Object with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=7) assert tuple(actions) == (1, 2, 4, 3, 1, 1, None) @@ -1786,7 +1786,7 @@ def test_interrupt_nested_3(): interrupt when simulation().currentTime == 1: take 3 ego = new Object with behavior Bar - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=6) assert tuple(actions) == (1, 3, 2, 1, 1, None) @@ -1808,7 +1808,7 @@ def test_interrupt_break(): break take 3 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (1, 1, 2, None) @@ -1827,7 +1827,7 @@ def test_interrupt_break_2(): take 2 break ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (1, 2, 1, 1) @@ -1847,7 +1847,7 @@ def test_interrupt_continue(): continue take 3 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=7) assert tuple(actions) == (4, 1, 2, 4, 1, 1, 4) @@ -1867,7 +1867,7 @@ def test_interrupt_continue_2(): continue take 2 ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=5) assert tuple(actions) == (1, 2, 2, 1, 1) @@ -1887,7 +1887,7 @@ def test_interrupt_abort(): take 2 abort ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=8) assert tuple(actions) == (3, 1, 2, 3, 1, 1, 1, 3) @@ -1918,7 +1918,7 @@ def test_interrupt_return(): take 2 return ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=4) assert tuple(actions) == (3, 1, 2, None) @@ -1965,7 +1965,7 @@ def test_interrupt_unassigned_local(): interrupt when i == 1: i = 2 ego = new Object with behavior Foo - """ + """ ) if sys.version_info >= (3, 10, 3): # see veneer.executeInBehavior exc_type = NameError @@ -1985,7 +1985,7 @@ def test_interrupt_guard_subbehavior(): interrupt when Foo(): wait ego = new Object with behavior Foo - """ + """ ) with pytest.raises(InvalidScenarioError): sampleEgoActions(scenario, maxSteps=1) @@ -1998,7 +1998,7 @@ def test_termination_reason_time(): scenario = compileScenic( """ ego = new Object - """ + """ ) result = sampleResult(scenario, maxSteps=2) assert result.terminationType == TerminationType.timeLimit @@ -2013,7 +2013,7 @@ def test_termination_reason_condition_1(): wait ego = new Object with behavior Foo terminate when ego.position.x >= 1 - """ + """ ) result = sampleResult(scenario, maxSteps=2) assert result.terminationType == TerminationType.scenarioComplete @@ -2028,7 +2028,7 @@ def test_termination_reason_condition_2(): wait ego = new Object with behavior Foo terminate simulation when ego.position.x >= 1 - """ + """ ) result = sampleResult(scenario, maxSteps=2) assert result.terminationType == TerminationType.simulationTerminationCondition @@ -2040,7 +2040,7 @@ def test_termination_reason_behavior(): behavior Foo(): terminate ego = new Object with behavior Foo - """ + """ ) result = sampleResult(scenario, maxSteps=2) assert result.terminationType == TerminationType.terminatedByBehavior @@ -2053,7 +2053,7 @@ def test_termination_reason_monitor(): terminate require monitor Foo() ego = new Object - """ + """ ) result = sampleResult(scenario, maxSteps=2) assert result.terminationType == TerminationType.terminatedByMonitor @@ -2074,7 +2074,7 @@ def test_record(): record initial ego.position as initial record final ego.position as final record ego.position as position - """ + """ ) result = sampleResult(scenario, maxSteps=4) assert result.records["initial"] == (0, 0, 0) diff --git a/tests/syntax/test_errors.py b/tests/syntax/test_errors.py index 18d80bdf0..fded4f7ac 100644 --- a/tests/syntax/test_errors.py +++ b/tests/syntax/test_errors.py @@ -34,7 +34,7 @@ def test_illegal_constructor_name(): f""" class 3: pass - """ + """ ) with pytest.raises(ScenicSyntaxError): @@ -42,7 +42,7 @@ class 3: f""" class +: pass - """ + """ ) @@ -52,7 +52,7 @@ def test_illegal_constructor_superclass(): f""" class Foo(3): pass - """ + """ ) with pytest.raises(ScenicSyntaxError): @@ -60,7 +60,7 @@ class Foo(3): f""" class Foo(+): pass - """ + """ ) @@ -70,14 +70,14 @@ def test_malformed_constructor(): """ class Foo pass - """ + """ ) with pytest.raises(ScenicSyntaxError): compileScenic( """ class Foo(Bar: pass - """ + """ ) @@ -88,7 +88,7 @@ def test_new_python_class(): class PyCls(object): pass new PyCls - """ + """ ) @@ -136,7 +136,7 @@ def test_incomplete_multiline_string(): ''' x = """foobar wog - ''' + ''' ) diff --git a/tests/syntax/test_functions.py b/tests/syntax/test_functions.py index 7ba79305a..f2ea885eb 100644 --- a/tests/syntax/test_functions.py +++ b/tests/syntax/test_functions.py @@ -11,7 +11,7 @@ def test_max_min(): a = Range(0, 1) b = Range(0, 1) ego = new Object with foo max(a, b), with bar min(a, b) - """ + """ ) assert ego.foo >= ego.bar ego = sampleEgoFrom("ego = new Object with foo min([], default=Range(1,2))") @@ -56,7 +56,7 @@ def test_unpacking(): def func(*args, **kwargs): return [args, kwargs] ego = new Object with foo func(*[1,2,3], func=4) - """ + """ ) assert ego.foo == [(1, 2, 3), {"func": 4}] @@ -68,7 +68,7 @@ def func(x, y): return [y, x] pairs = Uniform([1,2], [3,4]) ego = new Object with foo func(*pairs) - """ + """ ) assert ego.foo[0] > ego.foo[1] diff --git a/tests/syntax/test_imports.py b/tests/syntax/test_imports.py index dbc2b3d72..c5b3ca0a5 100644 --- a/tests/syntax/test_imports.py +++ b/tests/syntax/test_imports.py @@ -90,7 +90,7 @@ def test_multiple_imports(runLocally): import helper ego = new Object import helper - """ + """ ) assert len(scenario.objects) == 2 scene = sampleScene(scenario) @@ -107,7 +107,7 @@ def test_import_in_try(runLocally): finally: y = 4 ego = new Caerbannog at x @ y - """ + """ ) @@ -120,7 +120,7 @@ def test_import_in_except(runLocally): except ImportError: from helper import Caerbannog ego = new Caerbannog - """ + """ ) @@ -146,7 +146,7 @@ def test_import_override_param(): param helper_file = 'foo' import tests.syntax.helper ego = new Object - """ + """ ) assert scene.params["helper_file"] != "foo" @@ -178,7 +178,7 @@ def test_model_not_override_param(): param helper_file = 'foo' model tests.syntax.helper ego = new Object - """ + """ ) assert scene.params["helper_file"] == "foo" @@ -189,7 +189,7 @@ def test_model_respects_all(): """ model tests.syntax.helper4 ego = new Object with foo bar - """ + """ ) diff --git a/tests/syntax/test_modular.py b/tests/syntax/test_modular.py index 77558aadd..2a967831a 100644 --- a/tests/syntax/test_modular.py +++ b/tests/syntax/test_modular.py @@ -28,7 +28,7 @@ def test_single_scenario(): scenario Blob(): setup: ego = new Object at (1, 2, 3) - """ + """ ) assert tuple(ego.position) == (1, 2, 3) @@ -40,7 +40,7 @@ def test_simple_scenario(): behavior Foo(): wait ego = new Object at (1, 2, 3), with behavior Foo - """ + """ ) assert tuple(ego.position) == (1, 2, 3) @@ -52,7 +52,7 @@ def test_main_scenario(): ego = new Object at (10, 5) scenario Main(): ego = new Object at (1, 2) - """ + """ ) assert len(scene.objects) == 1 assert tuple(scene.egoObject.position) == (1, 2, 0) @@ -65,7 +65,7 @@ def test_requirement(): setup: ego = new Object with width Range(1, 3) require ego.width > 2 - """ + """ ) ws = [sampleEgo(scenario, maxIterations=60).width for i in range(60)] assert all(2 < w <= 3 for w in ws) @@ -78,7 +78,7 @@ def test_soft_requirement(): setup: ego = new Object with width Range(1, 3) require[0.9] ego.width >= 2 - """ + """ ) ws = [sampleEgo(scenario, maxIterations=60).width for i in range(350)] count = sum(w >= 2 for w in ws) @@ -115,7 +115,7 @@ def test_time_limit(): """ scenario Main(): ego = new Object - """ + """ ) result = sampleResult(scenario, maxSteps=3) assert len(result.trajectory) == 4 @@ -128,7 +128,7 @@ def test_terminate_when(): scenario Main(): ego = new Object terminate when simulation().currentTime > 1 - """ + """ ) result = sampleResult(scenario, maxSteps=5) assert len(result.trajectory) == 3 @@ -141,7 +141,7 @@ def test_terminate_after(): scenario Main(): ego = new Object terminate after 2 steps - """ + """ ) result = sampleResult(scenario, maxSteps=5) assert len(result.trajectory) == 3 @@ -156,7 +156,7 @@ def test_terminate_in_behavior(): wait terminate ego = new Object with behavior Foo - """ + """ ) result = sampleResult(scenario, maxSteps=5) assert len(result.trajectory) == 2 @@ -173,7 +173,7 @@ def test_top_level_precondition(): precondition: simulation().currentTime > 0 setup: ego = new Object - """ + """ ) sim = DummySimulator() scene = sampleScene(scenario) @@ -188,7 +188,7 @@ def test_top_level_invariant(): invariant: simulation().currentTime > 0 setup: ego = new Object - """ + """ ) sim = DummySimulator() scene = sampleScene(scenario) @@ -351,7 +351,7 @@ def test_subscenario_require_eventually(): ego = new Object require eventually simulation().currentTime == 2 terminate after 1 steps - """ + """ ) result = sampleResultOnce(scenario, maxSteps=2) assert result is None @@ -373,7 +373,7 @@ def test_subscenario_require_monitor(): ego = new Object require monitor TimeLimit() terminate after 2 steps - """ + """ ) result = sampleResultOnce(scenario, maxSteps=3) assert result is not None @@ -392,7 +392,7 @@ def test_subscenario_terminate_when(): ego = new Object require eventually simulation().currentTime == 2 terminate when simulation().currentTime == 1 - """ + """ ) result = sampleResultOnce(scenario, maxSteps=2) assert result is None @@ -414,7 +414,7 @@ def test_subscenario_terminate_with_parent(): do Bottom() scenario Bottom(): require eventually simulation().currentTime == 2 - """ + """ ) result = sampleResultOnce(scenario, maxSteps=2) assert result is None @@ -433,7 +433,7 @@ def test_subscenario_terminate_behavior(): take 1 terminate ego = new Object with behavior Foo - """ + """ ) actions = sampleEgoActions(scenario, maxSteps=2) assert tuple(actions) == (1, None) @@ -455,7 +455,7 @@ def test_subscenario_terminate_compose(): scenario Bottom(x): ego = new Object at (x, 0) terminate after 1 steps - """ + """ ) trajectory = sampleTrajectory(scenario, maxSteps=3) assert len(trajectory) == 3 @@ -1030,7 +1030,7 @@ def test_scenario_signature(body): scenario Blah(foo, *bar, baz=12, **qux): {body} ego = new Object with thing Blah - """ + """ ) sig = inspect.signature(ego.thing) params = tuple(sig.parameters.items()) diff --git a/tests/syntax/test_operators.py b/tests/syntax/test_operators.py index 13d499d4d..d73795b25 100644 --- a/tests/syntax/test_operators.py +++ b/tests/syntax/test_operators.py @@ -23,7 +23,7 @@ def test_relative_heading(): ego = new Object facing 30 deg other = new Object facing 65 deg, at 10@10 param p = relative heading of other - """ + """ ) assert p == pytest.approx(math.radians(65 - 30)) @@ -34,7 +34,7 @@ def test_relative_heading_no_ego(): """ other = new Object ego = new Object at 2@2, facing relative heading of other - """ + """ ) @@ -50,7 +50,7 @@ def test_apparent_heading(): ego = new Object facing 30 deg other = new Object facing 65 deg, at 10@10 param p = apparent heading of other - """ + """ ) assert p == pytest.approx(math.radians(65 + 45)) @@ -61,7 +61,7 @@ def test_apparent_heading_no_ego(): """ other = new Object ego = new Object at 2@2, facing apparent heading of other - """ + """ ) @@ -70,7 +70,7 @@ def test_apparent_heading_from(): """ OP = new OrientedPoint at 10@15, facing -60 deg ego = new Object facing apparent heading of OP from 15@10 - """ + """ ) assert ego.heading == pytest.approx(math.radians(-60 - 45)) @@ -82,7 +82,7 @@ def test_angle(): ego = new Object facing 30 deg other = new Object facing 65 deg, at 10@10 param p = angle to other - """ + """ ) assert p == pytest.approx(math.radians(-45)) @@ -93,7 +93,7 @@ def test_angle_3d(): ego = new Object facing (30 deg, 0 deg, 30 deg) other = new Object facing (65 deg, 0 deg, 65 deg), at (10, 10, 10) param p = angle to other - """ + """ ) assert p == pytest.approx(math.radians(-45)) @@ -139,7 +139,7 @@ def test_distance(): ego = new Object at 5@10 other = new Object at 7@-4 param p = distance to other - """ + """ ) assert p == pytest.approx(math.hypot(7 - 5, -4 - 10)) @@ -150,7 +150,7 @@ def test_distance_3d(): ego = new Object at (5, 10, 20) other = new Object at (7, -4, 15) param p = distance to other - """ + """ ) assert p == pytest.approx(math.hypot(7 - 5, -4 - 10, 15 - 20)) @@ -183,7 +183,7 @@ def test_distance_to_region(): r = CircularRegion(10@5, 3) ego = new Object at 13@9 param p = distance to r - """ + """ ) assert p == pytest.approx(2) @@ -338,7 +338,7 @@ def test_can_see_occlusion(): with occluding False require ego can see target_obj - """ + """ ) assert p == True @@ -369,7 +369,7 @@ def test_can_see_occlusion(): with name "wall" require ego can see target_obj - """ + """ ) assert p == False @@ -386,7 +386,7 @@ def test_can_see_distance_scaling(): target_obj = new Object at (0, 100000, 0) require ego can see target_obj - """ + """ ) assert p == True @@ -405,7 +405,7 @@ def test_can_see_distance_scaling(): with width 0.75, with length 0.75, with height 0.75 require ego can see target_obj - """ + """ ) assert p == True @@ -457,7 +457,7 @@ def test_point_in_region_2d(): ptB = new Point at 11@3.5 ptC = new Point at (11, 4.5, 1) param p = (9@5.5 in reg, 9@7 in reg, (11, 4.5, -1) in reg, ptA in reg, ptB in reg, ptC in reg) - """ + """ ) assert p == (True, False, True, True, False, True) @@ -470,7 +470,7 @@ def test_object_in_region_2d(): other_1 = new Object at 9@4.5, with width 2.5 other_2 = new Object at (11.5, 5.5, 2), with width 0.25, with length 0.25 param p = (ego in reg, other_1 in reg, other_2 in reg) - """ + """ ) assert p == (True, False, True) @@ -483,7 +483,7 @@ def test_point_in_region_3d(): ptA = new Point at (0.25,0.25,0.25) ptB = new Point at (1,1,1) param p = ((0,0,0) in reg, (0.49,0.49,0.49) in reg, (0.5,0.5,0.5) in reg, (0.51,0.51,0.51) in reg, ptA in reg, ptB in reg) - """ + """ ) assert p == (True, True, True, False, True, False) @@ -498,7 +498,7 @@ def test_object_in_region_3d(): obj_3 = new Object at (0.75, 0.75, 0.75), with allowCollisions True obj_4 = new Object at (3,3,3), with allowCollisions True param p = (obj_1 in reg, obj_2 in reg, obj_3 in reg, obj_4 in reg) - """ + """ ) assert p == (True, True, False, False) @@ -511,7 +511,7 @@ def test_intersects_obj_obj(): obj2 = new Object at (1,0,0), with allowCollisions True obj3 = new Object with width 10, with length 10, with height 10, with allowCollisions True param p = (obj1 intersects obj2, obj1 intersects obj3, obj2 intersects obj3) - """ + """ ) assert p == (False, True, True) @@ -525,7 +525,7 @@ def test_intersects_obj_obj(): obj1.position in obj2.occupiedSpace, obj2.position in obj1.occupiedSpace, any((c in obj2.occupiedSpace) for c in obj1.corners), any((c in obj1.occupiedSpace) for c in obj2.corners)) - """ + """ ) assert p == (True, False, False, False, False) @@ -537,7 +537,7 @@ def test_intersects_region_region(): reg2 = BoxRegion(position=(1,0,0)) reg3 = BoxRegion(dimensions=(10,10,10)) param p = (reg1 intersects reg2, reg1 intersects reg3, reg2 intersects reg3) - """ + """ ) assert p == (False, True, True) @@ -550,7 +550,7 @@ def test_intersects_obj_region(): obj3 = new Object with width 10, with length 10, with height 10, with allowCollisions True param p = (reg1 intersects obj2, obj2 intersects reg1, reg1 intersects obj3, obj3 intersects reg1) - """ + """ ) assert p == (False, False, True, True) @@ -562,7 +562,7 @@ def test_intersects_2d(): obj2 = new Object at (-0.2,0,0), with allowCollisions True reg = RectangularRegion((0,0,0), 0, 10, 10) param p = (obj1 intersects obj2, obj1 intersects reg) - """ + """ ) assert p == (True, True) @@ -574,7 +574,7 @@ def test_intersects_non_0_z(): obj2 = new Object at (-0.2,0,1), with allowCollisions True reg = RectangularRegion((0,0,1), 0, 10, 10) param p = (obj1 intersects obj2, obj1 intersects reg) - """ + """ ) assert p == (True, True) @@ -585,7 +585,7 @@ def test_intersects_overlap(): obj = new Object at (0,0,0), with allowCollisions True reg = RectangularRegion((0.5,0,0), 0, 1, 1) param p = obj intersects reg - """ + """ ) assert p == True @@ -597,7 +597,7 @@ def test_intersects_diff_z(): obj2 = new Object at (0,0,10), with allowCollisions True reg = RectangularRegion((0,0,0), 0, 10, 10) param p = (obj1 intersects reg, obj2 intersects reg, obj1 intersects obj2) - """ + """ ) assert p == (True, False, False) @@ -611,7 +611,7 @@ def test_field_at_vector(): """ vf = VectorField("Foo", lambda pos: (3 * pos.x) + pos.y) ego = new Object facing (vf at 0.02 @ -1) - """ + """ ) assert ego.heading == pytest.approx((3 * 0.02) - 1) @@ -632,7 +632,7 @@ def test_heading_relative_to_field(): """ vf = VectorField("Foo", lambda pos: 3 * pos.x) ego = new Object at 0.07 @ 0, facing 0.5 relative to vf - """ + """ ) assert ego.heading == pytest.approx(0.5 + (3 * 0.07)) @@ -642,7 +642,7 @@ def test_field_relative_to_heading(): """ vf = VectorField("Foo", lambda pos: 3 * pos.x) ego = new Object at 0.07 @ 0, facing vf relative to 0.5 - """ + """ ) assert ego.heading == pytest.approx(0.5 + (3 * 0.07)) @@ -652,7 +652,7 @@ def test_field_relative_to_field(): """ vf = VectorField("Foo", lambda pos: 3 * pos.x) ego = new Object at 0.07 @ 0, facing vf relative to vf - """ + """ ) assert ego.heading == pytest.approx(2 * (3 * 0.07)) @@ -672,7 +672,7 @@ def test_heading_relative_to_heading_lazy(): """ vf = VectorField("Foo", lambda pos: 0.5) ego = new Object facing 0.5 relative to (0.5 relative to vf) - """ + """ ) assert ego.heading == pytest.approx(1.5) @@ -683,7 +683,7 @@ def test_orientation_relative_to_orientation(): o1 = Orientation.fromEuler(90 deg, 0, 0) o2 = Orientation.fromEuler(0, 90 deg, 0) ego = new Object facing o2 relative to o1 - """ + """ ) assert ego.orientation.approxEq(Orientation.fromEuler(math.pi / 2, math.pi / 2, 0)) @@ -694,7 +694,7 @@ def test_heading_relative_to_orientation(): h1 = 90 deg o2 = Orientation.fromEuler(0, 90 deg, 0) ego = new Object facing o2 relative to h1 - """ + """ ) assert ego.orientation.approxEq(Orientation.fromEuler(math.pi / 2, math.pi / 2, 0)) @@ -705,7 +705,7 @@ def test_orientation_relative_to_heading(): o1 = Orientation.fromEuler(90 deg, 0, 0) h2 = 90 deg ego = new Object facing h2 relative to o1 - """ + """ ) assert ego.orientation.approxEq(Orientation.fromEuler(math.pi, 0, 0)) @@ -800,7 +800,7 @@ def test_offset_along_field(): """ vf = VectorField("Foo", lambda pos: 3 deg * pos.x) ego = new Object at 15@7 offset along vf by 2@-3 - """ + """ ) d = 1 / math.sqrt(2) assert tuple(ego.position) == pytest.approx( @@ -813,7 +813,7 @@ def test_offset_along_field_3d(): """ vf = VectorField("Foo", lambda pos: 3 deg * pos.x) ego = new Object at (15, 7, 5) offset along vf by (2, -3, 4) - """ + """ ) d = 1 / math.sqrt(2) assert tuple(ego.position) == pytest.approx( @@ -829,7 +829,7 @@ def test_follow(): minSteps=4, defaultStepSize=1) p = follow vf from 1@1 for 4 ego = new Object at p, facing p.heading - """ + """ ) assert tuple(ego.position) == pytest.approx((-1, 3, 0)) assert ego.heading == pytest.approx(math.radians(90)) @@ -842,7 +842,7 @@ def test_follow_3d(): minSteps=4, defaultStepSize=1) p = follow vf from (1, 1, 1) for 4 ego = new Object at p, facing p.heading - """ + """ ) assert tuple(ego.position) == pytest.approx((-1, 3, 1)) assert ego.heading == pytest.approx(math.radians(90)) @@ -856,7 +856,7 @@ def test_relative_position(): """ ego = new Object at 1@2 param p = relative position of 5@1 - """ + """ ) assert tuple(p) == (4, -1, 0) @@ -866,7 +866,7 @@ def test_relative_position_from(): """ ego = new Object at 1@2 param p = relative position of ego from 5@1 - """ + """ ) assert tuple(p) == (-4, 1, 0) @@ -882,7 +882,7 @@ def test_visible(): with visibleDistance 10, with viewAngle 90 deg reg = RectangularRegion(100@205, 0, 10, 20) param p = new Point in visible reg - """ + """ ) for i in range(30): p = sampleParamP(scenario, maxIterations=10) @@ -898,7 +898,7 @@ def test_not_visible(): with visibleDistance 30, with viewAngle 90 deg reg = RectangularRegion(100@200, 0, 10, 10) param p = new Point in not visible reg - """ + """ ) ps = [sampleParamP(scenario, maxIterations=10) for i in range(50)] assert all(p.x <= 100 or p.y <= 200 for p in ps) @@ -916,7 +916,7 @@ def test_visible_from(): with visibleDistance 10, with viewAngle 90 deg reg = RectangularRegion(100@205, 0, 10, 20) param p = new Point in reg visible from ego - """ + """ ) for i in range(30): p = sampleParamP(scenario, maxIterations=10) @@ -933,7 +933,7 @@ def test_not_visible_from(): with visibleDistance 30, with viewAngle 90 deg reg = RectangularRegion(100@200, 0, 10, 10) param p = new Point in reg not visible from ego - """ + """ ) ps = [sampleParamP(scenario, maxIterations=10) for i in range(50)] assert all(p.x <= 100 or p.y <= 200 for p in ps) @@ -971,7 +971,7 @@ def test_direction_ops(direction, loc): f""" ego = new Object facing (0, 180 deg, 0) param p = {direction} of ego - """ + """ ) oriented_loc = (loc[0], -loc[1], -loc[2]) assert tuple(p.position) == pytest.approx(oriented_loc) diff --git a/tests/syntax/test_properties.py b/tests/syntax/test_properties.py index f73663034..61b64a32b 100644 --- a/tests/syntax/test_properties.py +++ b/tests/syntax/test_properties.py @@ -1,8 +1,9 @@ import numpy as np import pytest +from scenic.core.distributions import Distribution, supportInterval from scenic.core.errors import SpecifierError -from tests.utils import compileScenic, sampleEgoFrom +from tests.utils import compileScenic, sampleEgo, sampleEgoFrom def test_position_wrong_type(): @@ -16,7 +17,7 @@ def test_position_oriented_point(): a = new OrientedPoint at 1@0 b = new OrientedPoint at 0@1 ego = new Object with position Uniform(a, b) - """ + """ ) @@ -25,7 +26,7 @@ def test_position_numpy_types(): """ import numpy as np ego = new Object with position np.single(3.4) @ np.single(7) - """ + """ ) assert tuple(ego.position) == pytest.approx((3.4, 7, 0)) @@ -40,7 +41,7 @@ def test_yaw_numpy_types(): """ import numpy as np ego = new Object with yaw np.single(3.1) - """ + """ ) assert ego.yaw == pytest.approx(3.1) @@ -50,7 +51,7 @@ def test_left(): """ other = new Object with width 4 ego = new Object at other.left offset by 0@5 - """ + """ ) assert tuple(ego.position) == pytest.approx((-2, 5, 0)) @@ -60,7 +61,7 @@ def test_right(): """ other = new Object with width 4 ego = new Object at other.right offset by 0@5 - """ + """ ) assert tuple(ego.position) == pytest.approx((2, 5, 0)) @@ -70,7 +71,7 @@ def test_front(): """ other = new Object with length 4 ego = new Object at other.front offset by 0@5 - """ + """ ) assert tuple(ego.position) == pytest.approx((0, 7, 0)) @@ -80,7 +81,7 @@ def test_back(): """ other = new Object with length 4 ego = new Object at other.back offset by 0@-5 - """ + """ ) assert tuple(ego.position) == pytest.approx((0, -7, 0)) @@ -90,7 +91,7 @@ def test_frontLeft(): """ other = new Object with length 4, with width 2 ego = new Object at other.frontLeft offset by 0@5 - """ + """ ) assert tuple(ego.position) == pytest.approx((-1, 7, 0)) @@ -100,7 +101,7 @@ def test_frontRight(): """ other = new Object with length 4, with width 2 ego = new Object at other.frontRight offset by 0@5 - """ + """ ) assert tuple(ego.position) == pytest.approx((1, 7, 0)) @@ -110,7 +111,7 @@ def test_backLeft(): """ other = new Object with length 4, with width 2 ego = new Object at other.backLeft offset by 0@-5 - """ + """ ) assert tuple(ego.position) == pytest.approx((-1, -7, 0)) @@ -120,7 +121,7 @@ def test_backRight(): """ other = new Object with length 4, with width 2 ego = new Object at other.backRight offset by 0@-5 - """ + """ ) assert tuple(ego.position) == pytest.approx((1, -7, 0)) @@ -128,3 +129,156 @@ def test_backRight(): def test_heading_set_directly(): with pytest.raises(SpecifierError): compileScenic("ego = new Object with heading 4") + + +def test_object_inradius(): + # Statically Sized Cube Example + scenario = compileScenic( + """ + ego = new Object with width 3, with length 3, with height 3, + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg) + """ + ) + ego = sampleEgo(scenario) + assert scenario.objects[0].inradius == 1.5 + assert supportInterval(scenario.objects[0].inradius) == (1.5, 1.5) + assert ego.inradius == 1.5 + + # Randomly Sized Cube Example + scenario = compileScenic( + """ + ego = new Object with width Range(1, 3), + with length Range(1, 3), with height Range(1, 3), + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg) + """ + ) + ego = sampleEgo(scenario) + assert isinstance(scenario.objects[0].inradius, Distribution) + assert supportInterval(scenario.objects[0].inradius) == (0.5, 1.5) + assert ego.inradius == pytest.approx(min(ego.width, ego.length, ego.height) / 2) + + # Hollow Static Object Example + scenario = compileScenic( + """ + import trimesh + hollow_mesh = trimesh.creation.box((1,1,1)).difference( + trimesh.creation.box((0.5,0.5,0.5))) + ego = new Object with width 3, with length 3, with height 3, + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg), + with shape MeshShape(hollow_mesh) + """ + ) + ego = sampleEgo(scenario) + assert scenario.objects[0].inradius == 0 + assert supportInterval(scenario.objects[0].inradius) == (0, 0) + assert ego.inradius == 0 + + # Hollow Random Object Example + scenario = compileScenic( + """ + import trimesh + hollow_mesh = trimesh.creation.box((1,1,1)).difference( + trimesh.creation.box((0.5,0.5,0.5))) + ego = new Object with width Range(1, 3), + with length Range(1, 3), with height Range(1, 3), + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg), + with shape MeshShape(hollow_mesh) + """ + ) + ego = sampleEgo(scenario) + assert supportInterval(scenario.objects[0].inradius) == (0, 0) + assert ego.inradius == 0 + + # Random Shape Example + scenario = compileScenic( + """ + import trimesh + annulus_shape = MeshShape(trimesh.creation.annulus(0.5,1,1)) + ego = new Object with width Range(1, 3), + with length Range(1, 3), with height Range(1, 3), + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg), + with shape Uniform(BoxShape(), annulus_shape) + """ + ) + ego = sampleEgo(scenario) + assert isinstance(scenario.objects[0].inradius, Distribution) + assert supportInterval(scenario.objects[0].inradius) == (0, 1.5) + + +def test_object_planarInradius(): + # Statically Sized Cube Example + scenario = compileScenic( + """ + ego = new Object with width 3, with length 3, with height 0.5, + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg) + """ + ) + ego = sampleEgo(scenario) + assert scenario.objects[0].planarInradius == 1.5 + assert supportInterval(scenario.objects[0].planarInradius) == (1.5, 1.5) + assert ego.planarInradius == 1.5 + + # Randomly Sized Cube Example + scenario = compileScenic( + """ + ego = new Object with width Range(1, 3), + with length Range(1, 3), with height Range(0.25, 0.5), + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg) + """ + ) + ego = sampleEgo(scenario) + assert isinstance(scenario.objects[0].planarInradius, Distribution) + assert supportInterval(scenario.objects[0].planarInradius) == (0.5, 1.5) + assert ego.planarInradius == pytest.approx(min(ego.width, ego.length) / 2) + + # Hollow Static Object Example + scenario = compileScenic( + """ + import trimesh + hollow_mesh = trimesh.creation.box((1,1,1)).difference( + trimesh.creation.box((0.5,0.5,0.5))) + ego = new Object with width 3, with length 3, with height 0.5, + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg), + with shape MeshShape(hollow_mesh) + """ + ) + ego = sampleEgo(scenario) + assert scenario.objects[0].planarInradius == pytest.approx(1.5) + assert supportInterval(scenario.objects[0].planarInradius) == pytest.approx( + (1.5, 1.5) + ) + assert ego.planarInradius == pytest.approx(1.5) + + # Hollow Random Object Example + scenario = compileScenic( + """ + import trimesh + hollow_mesh = trimesh.creation.box((1,1,1)).difference( + trimesh.creation.box((0.5,0.5,0.5))) + ego = new Object with width Range(1, 3), + with length Range(1, 3), with height Range(0.25, 0.5), + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg), + with shape MeshShape(hollow_mesh) + """ + ) + ego = sampleEgo(scenario) + assert isinstance(scenario.objects[0].planarInradius, Distribution) + assert supportInterval(scenario.objects[0].planarInradius) == pytest.approx( + (0.5, 1.5) + ) + assert ego.planarInradius == pytest.approx(min(ego.width, ego.length) / 2) + + # Random Shape Example + scenario = compileScenic( + """ + import trimesh + annulus_shape = MeshShape(trimesh.creation.annulus(0.5,1,1)) + ego = new Object with width Range(1, 3), + with length Range(1, 3), with height Range(1, 3), + facing (Range(0, 360) deg, Range(0, 360) deg, Range(0, 360) deg), + with shape Uniform(BoxShape(), annulus_shape) + """ + ) + ego = sampleEgo(scenario) + assert isinstance(scenario.objects[0].planarInradius, Distribution) + assert supportInterval(scenario.objects[0].planarInradius) == (0, 1.5) diff --git a/tests/syntax/test_pruning.py b/tests/syntax/test_pruning.py index ed4e54b0b..3a15d9183 100644 --- a/tests/syntax/test_pruning.py +++ b/tests/syntax/test_pruning.py @@ -4,7 +4,9 @@ import pytest from scenic.core.errors import InconsistentScenarioError -from tests.utils import compileScenic, sampleEgo +from scenic.core.pruning import checkCyclical +from scenic.core.vectors import Vector +from tests.utils import compileScenic, sampleEgo, sampleParamP def test_containment_in(): @@ -13,13 +15,64 @@ def test_containment_in(): """ workspace = Workspace(PolygonalRegion([0@0, 2@0, 2@2, 0@2])) ego = new Object in workspace + """ + ) + # Sampling should only require 1 iteration after pruning + xs = [sampleEgo(scenario).position.x for i in range(60)] + assert all(0.5 <= x <= 1.5 for x in xs) + assert any(0.5 <= x <= 0.7 or 1.3 <= x <= 1.5 for x in xs) + + +def test_containment_2d_region(): + """Test pruning based on object containment in a 2D region. + + Specifically tests that vertical portions of baseOffset are not added + to maxDistance, and that if objects are known to be flat in the plane, + their height is not considered as part of the minRadius. """ + # Tests the effect of the vertical portion of baseOffset in a 2D region. + scenario = compileScenic( + """ + workspace = Workspace(PolygonalRegion([0@0, 2@0, 2@2, 0@2])) + ego = new Object on workspace + """ + ) + # Sampling should only require 1 iteration after pruning + xs = [sampleEgo(scenario).position.x for i in range(60)] + assert all(0.5 <= x <= 1.5 for x in xs) + assert any(0.5 <= x <= 0.7 or 1.3 <= x <= 1.5 for x in xs) + + # Test height's effect in a 2D region. + scenario = compileScenic( + """ + workspace = Workspace(PolygonalRegion([0@0, 2@0, 2@2, 0@2])) + ego = new Object in workspace, with height 0.1 + """ ) # Sampling should only require 1 iteration after pruning xs = [sampleEgo(scenario).position.x for i in range(60)] assert all(0.5 <= x <= 1.5 for x in xs) assert any(0.5 <= x <= 0.7 or 1.3 <= x <= 1.5 for x in xs) + # Test both combined, in a slightly more complicated case. + # Specifically, there is a non vertical component to baseOffset + # that should be accounted for and the height is random. + scenario = compileScenic( + """ + class TestObject: + baseOffset: (0.1, 0, self.height/2) + + workspace = Workspace(PolygonalRegion([0@0, 2@0, 2@2, 0@2])) + ego = new TestObject on workspace, with height Range(0.1,0.5) + """ + ) + # Sampling should fail ~30.56% of the time, so + # 34 rejections are allowed to get the failure probability + # to ~1e-18. + xs = [sampleEgo(scenario, maxIterations=34).position.x for i in range(60)] + assert all(0.5 <= x <= 1.5 for x in xs) + assert any(0.5 <= x <= 0.7 or 1.3 <= x <= 1.5 for x in xs) + def test_containment_in_polyline(): """As above, but when the object is placed on a polyline.""" @@ -28,7 +81,7 @@ def test_containment_in_polyline(): workspace = Workspace(PolygonalRegion([0@0, 2@0, 2@2, 0@2])) line = PolylineRegion([0@0, 1@1, 2@0]) ego = new Object in line, facing 0 - """ + """ ) # Sampling should only require 1 iteration after pruning xs = [sampleEgo(scenario).position.x for i in range(60)] @@ -47,7 +100,7 @@ def test_relative_heading_require_visible(): ego = new Object in union, facing vf # Objects can be in either cell other = new Object in union, facing vf, with requireVisible True require (relative heading of other) >= 60 deg # Forces ego in cell 1, other in cell 2 - """ + """ ) # Sampling should only require 1 iteration after pruning xs = [sampleEgo(scenario).position.x for i in range(60)] @@ -66,7 +119,7 @@ def test_relative_heading_visible_from(): ego = new Object in union, facing vf # Objects can be in either cell other = new Object in union, facing vf, visible from ego require (relative heading of other) >= 60 deg # Forces ego in cell 1, other in cell 2 - """ + """ ) # Sampling should only require 1 iteration after pruning xs = [sampleEgo(scenario).position.x for i in range(60)] @@ -88,7 +141,7 @@ def test_relative_heading_distance(): other = new Object in union, facing vf require (relative heading of other) >= 60 deg # Forces ego in cell 1, other cell 2/3 require (distance to other) <= 35 # Forces other in cell 2 - """ + """ ) # Sampling should only require 1 iteration after pruning xs = [sampleEgo(scenario).position.x for i in range(60)] @@ -106,3 +159,88 @@ def test_relative_heading_inconsistent(): require abs(relative heading of other) < -1 """ ) + + +def test_visibility_pruning(): + """Test visibility pruning in general. + + The following scenarios are equivalent except for how they specify that foo + must be visible from ego. The size of the workspace and the visibleDistance + of ego are chosen such that without pruning the chance of sampling a valid + scene over 100 tries is 1-(1-Decimal(3.14)/Decimal(1e10**2))**100 = ~1e-18. + Assuming the approximately buffered volume of the viewRegion has a 50% chance of + rejecting (i.e. it is twice as large as the true buffered viewRegion, which testing + indicates in this case has about a 10% increase in volume for this case), the chance + of not finding a sample in 100 iterations is 1e-31. + + We also want to confirm that we aren't pruning too much, i.e. placing the position + in the viewRegion instead of at any point where the object intersects the view region. + Because of this, we want to see at least one sample where the position is outside + the viewRegion but the object intersects the viewRegion. The chance of this happening + per sample is 1 - (1 / 1.1)**3 = ~25%, so by repeating the process 30 times we have + a 1e-19 chance of not getting a single point in this zone. + """ + # requireVisible + scenario = compileScenic( + """ + workspace = Workspace(RectangularRegion(0@0, 0, 1e10, 1e10)) + ego = new Object at (0,0,0), with visibleDistance 1 + foo = new Object in workspace, with requireVisible True, + with shape SpheroidShape(dimensions=(0.2,0.2,0.2)) + param p = foo.position + """ + ) + positions = [sampleParamP(scenario, maxIterations=100) for i in range(30)] + assert all(pos.distanceTo(Vector(0, 0, 0)) <= 1.1 for pos in positions) + assert any(pos.distanceTo(Vector(0, 0, 0)) >= 1 for pos in positions) + + # visible + scenario = compileScenic( + """ + workspace = Workspace(RectangularRegion(0@0, 0, 1e10, 1e10)) + ego = new Object at (0,0,0), with visibleDistance 1 + foo = new Object in workspace, visible, + with shape SpheroidShape(dimensions=(0.2,0.2,0.2)) + param p = foo.position + """ + ) + positions = [sampleParamP(scenario, maxIterations=100) for i in range(30)] + assert all(pos.distanceTo(Vector(0, 0, 0)) <= 1.1 for pos in positions) + assert any(pos.distanceTo(Vector(0, 0, 0)) >= 1 for pos in positions) + + +def test_visibility_pruning_cyclical(): + """A case where a cyclical dependency could be introduced if pruning is not done carefully. + + NOTE: We don't currently prune this case so this test is a sentinel for future behavior. + """ + scenario = compileScenic( + """ + workspace = Workspace(PolygonalRegion([0@0, 100@0, 100@100, 0@100])) + foo = new Object with requireVisible True, in workspace + ego = new Object visible from foo, in workspace + """ + ) + + sampleEgo(scenario, maxIterations=100) + + +def test_checkCyclical(): + scenario = compileScenic( + """ + workspace = Workspace(PolygonalRegion([0@0, 100@0, 100@100, 0@100])) + foo = new Object in workspace + ego = new Object in workspace + """ + ) + assert not checkCyclical(scenario.objects[1].position, scenario.objects[0].position) + + scenario = compileScenic( + """ + workspace = Workspace(PolygonalRegion([0@0, 100@0, 100@100, 0@100])) + foo = new Object with requireVisible True, in workspace + ego = new Object visible from foo + """ + ) + + assert checkCyclical(scenario.objects[1].position, scenario.objects[0].visibleRegion) diff --git a/tests/syntax/test_regions.py b/tests/syntax/test_regions.py index 8c85b2183..e59542012 100644 --- a/tests/syntax/test_regions.py +++ b/tests/syntax/test_regions.py @@ -47,7 +47,7 @@ def test_circular_lazy(): vf = VectorField("Foo", lambda pos: 2 * pos.x) x = 0 relative to vf ego = new Object at Range(0, 1) @ 0, with foo CircularRegion(0@0, x.yaw) - """ + """ ) assert ego.foo.radius == pytest.approx(2 * ego.position.x) @@ -74,7 +74,7 @@ def test_sector_lazy(): vf = VectorField("Foo", lambda pos: 2 * pos.x) x = 0 relative to vf ego = new Object at Range(0, 1) @ 0, with foo SectorRegion(0@0, x.yaw, 0, 45 deg) - """ + """ ) assert ego.foo.radius == pytest.approx(2 * ego.position.x) @@ -103,7 +103,7 @@ def test_rectangular_lazy(): vf = VectorField("Foo", lambda pos: 2 * pos.x) x = 0 relative to vf ego = new Object at Range(-1, 1) @ 0, with foo RectangularRegion(0@0, 0, x.yaw, 1) - """ + """ ) assert ego.foo.width == pytest.approx(2 * ego.position.x) @@ -117,7 +117,7 @@ def test_polygonal_empty_intersection(): r1 = PolygonalRegion([0@0, 10@0, 10@10, 0@10]) ego = new Object at -10@0, facing Range(-90, 0) deg, with viewAngle 60 deg new Object in visible r1, with requireVisible False - """ + """ ) for i in range(10): sampleScene(scenario, maxIterations=1000) @@ -132,7 +132,7 @@ def test_polyline_start(): r = PolylineRegion([1@1, 3@-1, 6@2]) pt = r.start ego = new Object at pt, facing pt.heading - """ + """ ) assert tuple(ego.position) == pytest.approx((1, 1, 0)) assert ego.heading == pytest.approx(math.radians(-135)) @@ -144,7 +144,7 @@ def test_polyline_end(): r = PolylineRegion([1@1, 3@-1, 6@2]) pt = r.end ego = new Object at pt, facing pt.heading - """ + """ ) assert tuple(ego.position) == pytest.approx((6, 2, 0)) assert ego.heading == pytest.approx(math.radians(-45)) @@ -162,11 +162,24 @@ def test_mesh_region_distribution(): region = SpheroidRegion(position=position, dimensions=dimensions, rotation=rotation) ego = new Object in region - """, + """, maxIterations=100, ) +def test_path_region_default_orientation(): + ego = sampleEgoFrom( + """ + import math + region = PathRegion(points=[(0,0,0), (1,1,math.sqrt(2)), (2,2,2*math.sqrt(2))]) + ego = new Object in region + """ + ) + assert ego.orientation.yaw == pytest.approx(math.radians(-45)) + assert ego.orientation.pitch == pytest.approx(math.radians(45)) + assert ego.orientation.roll == pytest.approx(0) + + # View Regions def test_view_region_construction(): sampleSceneFrom( @@ -244,7 +257,7 @@ def test_view_region_construction(): with visibleDistance 5, with viewAngles (200 deg, 40 deg), with requireVisible True - """, + """, maxIterations=1000, ) @@ -259,7 +272,7 @@ def test_workspace(): ego = new Object in workspace require 6@11 in workspace require ego in workspace - """ + """ ) assert 3 <= ego.position.x <= 7 assert 8 <= ego.position.y <= 12 diff --git a/tests/syntax/test_requirements.py b/tests/syntax/test_requirements.py index c72bb33be..1495afc19 100644 --- a/tests/syntax/test_requirements.py +++ b/tests/syntax/test_requirements.py @@ -13,7 +13,7 @@ def test_requirement(): """ ego = new Object at Range(-10, 10) @ 0 require ego.position.x >= 0 - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] assert all(0 <= x <= 10 for x in xs) @@ -24,7 +24,7 @@ def test_soft_requirement(): """ ego = new Object at Range(-10, 10) @ 0 require[0.9] ego.position.x >= 0 - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(350)] count = sum(x >= 0 for x in xs) @@ -37,7 +37,7 @@ def test_illegal_soft_probability(): """ ego = new Object require[1.1] ego.position.x >= 0 - """ + """ ) @@ -48,7 +48,7 @@ def test_named_requirement(): require ego.position.x >= 5 as posReq require True as 'myReq' require True as 101 - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] assert all(5 <= x <= 10 for x in xs) @@ -62,7 +62,7 @@ def test_named_soft_requirement(): require[0.9] ego.position.x >= 5 as posReq require[0.8] True as 'myReq' require[0.75] True as 101 - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(350)] count = sum(x >= 5 for x in xs) @@ -75,7 +75,7 @@ def test_named_requirement_invalid(): """ ego = new Object require True as + - """ + """ ) @@ -85,7 +85,7 @@ def test_unexpected_keyword_arg(): """ ego = new Object require True, line=5 - """ + """ ) @@ -96,7 +96,7 @@ def test_unexpected_unpacking(): ego = new Object a = (True,) require *a - """ + """ ) @@ -108,7 +108,7 @@ def test_distribution_in_requirement(): """ require Range(0, 1) <= 1 ego = new Object - """ + """ ) with pytest.raises(InvalidScenarioError): sampleScene(scenario) @@ -119,7 +119,7 @@ def test_object_in_requirement(): """ require new Object ego = new Object - """ + """ ) with pytest.raises(InvalidScenarioError): sampleScene(scenario) @@ -131,7 +131,7 @@ def test_param_in_requirement_1(): """ require param x = 4 ego = new Object - """ + """ ) @@ -144,7 +144,7 @@ def func(): return True require func() ego = new Object - """ + """ ) sampleScene(scenario) @@ -155,7 +155,7 @@ def test_mutate_in_requirement_1(): """ require mutate ego = new Object - """ + """ ) with pytest.raises(ScenicSyntaxError): sampleScene(scenario) @@ -170,7 +170,7 @@ def func(): return True require func() ego = new Object - """ + """ ) sampleScene(scenario) @@ -181,7 +181,7 @@ def test_require_in_requirement(): """ require (require True) ego = new Object - """ + """ ) @@ -193,7 +193,7 @@ def test_exception_in_requirement(): """ require visible 4 ego = new Object - """ + """ ) with pytest.raises(TypeError): sampleScene(scenario) @@ -206,7 +206,7 @@ def test_soft_requirement_with_temporal_operators(): """ ego = new Object require[0.2] eventually ego - """ + """ ) @@ -218,7 +218,7 @@ def test_containment_requirement(): """ foo = RectangularRegion(0@0, 0, 10, 10) ego = new Object at Range(0, 10) @ 0, with regionContainedIn foo - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] assert all(0 <= x <= 5 for x in xs) @@ -229,7 +229,7 @@ def test_containment_workspace(): """ workspace = Workspace(RectangularRegion(0@0, 0, 10, 10)) ego = new Object at Range(0, 10) @ 0 - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] assert all(0 <= x <= 5 for x in xs) @@ -240,7 +240,7 @@ def test_visibility_requirement(): """ ego = new Object with visibleDistance 10, with viewAngle 90 deg, facing 45 deg other = new Object at Range(-10, 10) @ 0, with requireVisible True - """ + """ ) xs = [ sampleScene(scenario, maxIterations=60).objects[1].position.x for i in range(60) @@ -253,7 +253,7 @@ def test_visibility_requirement_disabled(): """ ego = new Object with visibleDistance 10, with viewAngle 90 deg, facing 45 deg other = new Object at Range(-10, 10) @ 0, with requireVisible False - """ + """ ) xs = [ sampleScene(scenario, maxIterations=60).objects[1].position.x for i in range(60) @@ -266,7 +266,7 @@ def test_intersection_requirement(): """ ego = new Object at Range(0, 2) @ 0 other = new Object - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] assert all(x >= 1 for x in xs) @@ -277,7 +277,7 @@ def test_intersection_requirement_disabled_1(): """ ego = new Object at Range(0, 2) @ 0, with allowCollisions True other = new Object - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] assert any(x < 1 for x in xs) @@ -288,7 +288,7 @@ def test_intersection_requirement_disabled_2(): """ ego = new Object at Range(0, 2) @ 0 other = new Object with allowCollisions True - """ + """ ) xs = [sampleEgo(scenario, maxIterations=60).position.x for i in range(60)] assert any(x < 1 for x in xs) @@ -303,7 +303,7 @@ def test_static_containment_violation(): """ foo = RectangularRegion(0@0, 0, 5, 5) ego = new Object at 10@10, with regionContainedIn foo - """ + """ ) @@ -313,7 +313,7 @@ def test_static_containment_workspace(): """ workspace = Workspace(RectangularRegion(0@0, 0, 5, 5)) ego = new Object at 10@10 - """ + """ ) @@ -323,7 +323,7 @@ def test_static_empty_container(): """ foo = PolylineRegion([0@0, 1@1]).intersect(PolylineRegion([1@0, 2@1])) ego = new Object at Range(0, 2) @ Range(0, 1), with regionContainedIn foo - """ + """ ) @@ -333,7 +333,7 @@ def test_static_visibility_violation_enabled(): """ ego = new Object at 10@0, facing -90 deg, with viewAngle 90 deg new Object at 0@10, with requireVisible True - """ + """ ) @@ -343,7 +343,7 @@ def test_static_visibility_violation_enabled_2d(): """ ego = new Object at 10@0, facing -90 deg, with viewAngle 90 deg new Object at 0@10, with requireVisible True - """, + """, mode2D=True, ) @@ -353,7 +353,7 @@ def test_static_visibility_violation_disabled(): """ ego = new Object at 10@0, facing -90 deg, with viewAngle 90 deg new Object at 0@10, with requireVisible False - """ + """ ) @@ -363,7 +363,7 @@ def test_static_intersection_violation(): """ ego = new Object at 0@0 new Object at 0.5@0 - """ + """ ) @@ -372,7 +372,7 @@ def test_static_intersection_violation_disabled(): """ ego = new Object at 0@0 new Object at 1@0, with allowCollisions True - """ + """ ) @@ -408,7 +408,7 @@ def test_can_see_object_occlusion_enabled(): with length 0.5, with height 6, with name "wall", - """, + """, maxIterations=1, ) @@ -442,7 +442,7 @@ def test_can_see_object_occlusion_disabled(): with height 6, with name "wall", with occluding False - """, + """, maxIterations=1, ) @@ -453,7 +453,7 @@ def test_random_allowCollisions(): """ new Object with allowCollisions Uniform(True, False) new Object with allowCollisions Uniform(True, False) - """, + """, maxIterations=30, ) @@ -489,7 +489,7 @@ def test_random_occlusion(): with height 6, with name "wall", with occluding Uniform(True, False) - """, + """, maxIterations=60, ) diff --git a/tests/syntax/test_specifiers.py b/tests/syntax/test_specifiers.py index 62be48597..6fada7a3c 100644 --- a/tests/syntax/test_specifiers.py +++ b/tests/syntax/test_specifiers.py @@ -33,14 +33,14 @@ def test_lazy_cyclic_dependency(): """ vf = VectorField("Foo", lambda pos: 3 * pos.x) ego = new Object at 0 @ (0 relative to vf) - """ + """ ) with pytest.raises(SpecifierError): compileScenic( """ vf = VectorField("Foo", lambda pos: 3 * pos.x) ego = new Object at (0, 0 relative to vf) - """ + """ ) @@ -467,7 +467,7 @@ def test_beyond_3d(): import math ego = new Object at (10, 5, 15) ego = new Object beyond (11, 6, 15 + math.sqrt(2)) by (0, 10, 0) - """ + """ ) assert tuple(ego.position) == pytest.approx( (16, 11, 15 + math.sqrt(2) + (10 / math.sqrt(2))) @@ -491,7 +491,7 @@ def test_beyond_from_3d(): """ import math ego = new Object beyond (11, 6, 15 + math.sqrt(2)) by (0, 10, 0) from (10, 5, 15) - """ + """ ) assert tuple(ego.position) == pytest.approx( (16, 11, 15 + math.sqrt(2) + (10 / math.sqrt(2))) @@ -579,7 +579,7 @@ def test_point_visible_from_object(): with height 6, with name "wall", with occluding False - """, + """, maxIterations=1, ) @@ -622,7 +622,7 @@ def test_not_visible(): ego = new Object at 100 @ 200, facing -45 deg, with visibleDistance 10, with viewAngle 90 deg ego = new Object not visible - """ + """ ) base = Vector(100, 200) for i in range(20): @@ -637,7 +637,7 @@ def test_not_visible_2d(): ego = new Object at 100 @ 200, facing -45 deg, with visibleDistance 10, with viewAngle 90 deg ego = new Object not visible - """, + """, mode2D=True, ) base = Vector(100, 200) @@ -653,7 +653,7 @@ def test_not_visible_from(): ego = new Object at 100 @ 200, facing -45 deg, with visibleDistance 10, with viewAngle 90 deg ego = new Object in workspace, not visible from ego - """ + """ ) base = Vector(100, 200) for i in range(20): @@ -668,7 +668,7 @@ def test_not_visible_from_2d(): ego = new Object at 100 @ 200, facing -45 deg, with visibleDistance 10, with viewAngle 90 deg ego = new Object not visible from ego - """, + """, mode2D=True, ) base = Vector(100, 200) @@ -811,7 +811,7 @@ def test_on_object(): """ floor = new Object with shape BoxShape(dimensions=(40,40,0.1)) ego = new Object on floor - """ + """ ) for i in range(30): ego = sampleEgo(scenario, maxIterations=1000) @@ -825,7 +825,7 @@ def test_on_position(): scenario = compileScenic( """ ego = new Object on (0,0,0) - """ + """ ) for i in range(30): ego = sampleEgo(scenario, maxIterations=1000) @@ -858,7 +858,7 @@ def test_on_parentOrientation(): box = new Object facing (Range(0, 360 deg), Range(0, 360 deg), Range(0, 360 deg)), with width 5, with length 5, with height 5 ego = new Object on box - """ + """ ) for _ in range(30): sampleScene(scenario, maxIterations=1) @@ -884,7 +884,7 @@ def test_on_modifying_object_side(): workspace = Workspace(BoxRegion(dimensions=(10,10,10))) box = new Object with width 5, with length 5, with height 5 ego = new Object in workspace, on box.frontSurface, with shape ConeShape() - """ + """ ) for i in range(30): ego = sampleEgo(scenario, maxIterations=1000) @@ -899,7 +899,7 @@ def test_on_modifying_object_onDirection(): box = new Object with width 5, with length 5, with height 5 ego = new Object in workspace, on box.surface, with shape ConeShape(), with onDirection (0,1,0) - """ + """ ) for i in range(30): ego = sampleEgo(scenario, maxIterations=1000) @@ -916,7 +916,7 @@ def test_on_modifying_surface_onDirection(): box = BoxRegion(dimensions=(5,5,5), onDirection=(0,1,0)).getSurfaceRegion() ego = new Object in workspace, on box, with shape ConeShape(), with onDirection (0,1,0) - """ + """ ) for i in range(30): ego = sampleEgo(scenario, maxIterations=1000) @@ -937,7 +937,7 @@ def test_on_incompatible(): """ box = BoxRegion() ego = new Object on box, on box - """ + """ ) @@ -949,7 +949,7 @@ def test_following(): minSteps=4, defaultStepSize=1) ego = new Object at 1@1 ego = new Object following vf for 4 - """ + """ ) assert tuple(ego.position) == pytest.approx((-1, 3, 0)) assert ego.heading == pytest.approx(math.radians(90)) @@ -961,7 +961,7 @@ def test_following_from(): vf = VectorField("Foo", lambda pos: 90 deg * (pos.x + pos.y - 1), minSteps=4, defaultStepSize=1) ego = new Object following vf from 1@1 for 4 - """ + """ ) assert tuple(ego.position) == pytest.approx((-1, 3, 0)) assert ego.heading == pytest.approx(math.radians(90)) @@ -973,7 +973,7 @@ def test_following_random(): vf = VectorField('Foo', lambda pos: -90 deg) x = Range(1, 2) ego = new Object following vf from 1@2 for x, facing x - """ + """ ) assert tuple(ego.position) == pytest.approx((1 + ego.heading, 2, 0)) @@ -999,7 +999,7 @@ def test_facing_random_parentOrientation(): """ ego = new Object facing (90 deg, 90 deg, 90 deg), with parentOrientation (Range(0, 360 deg), Range(0, 360 deg), Range(0, 360 deg)) - """ + """ ) for _ in range(10): ego = sampleEgo(scenario) diff --git a/tests/syntax/test_typing.py b/tests/syntax/test_typing.py index d2b5fdf88..e8b6205c5 100644 --- a/tests/syntax/test_typing.py +++ b/tests/syntax/test_typing.py @@ -10,7 +10,7 @@ def test_tuple_as_vector(): """ ego = new Object at 1 @ 2 param p = distance to (-2, -2) - """ + """ ) assert p == pytest.approx(5) @@ -20,7 +20,7 @@ def test_tuple_as_vector_2(): """ ego = new Object at 1 @ 2 param p = distance to (-2, -2, 12) - """ + """ ) assert p == pytest.approx(13) @@ -40,7 +40,7 @@ def test_list_as_vector(): """ ego = new Object at 1 @ 2 param p = distance to [-2, -2] - """ + """ ) assert p == pytest.approx(5) @@ -50,7 +50,7 @@ def test_list_as_vector_2(): """ ego = new Object at 1 @ 2 param p = distance to [-2, -2, 12] - """ + """ ) assert p == pytest.approx(13) @@ -61,5 +61,5 @@ def test_list_as_vector_3(): """ ego = new Object at 1 @ 2 param p = distance to [-2, -2, 0, 6] - """ + """ ) diff --git a/tox.ini b/tox.ini index 365ce524a..e08482b81 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ [tox] -isolated_build = true envlist = py{38,39,310,311,312}{,-extras} +labels = + basic = py{38,39,310,311,312} [testenv] extras =