From 957fab6bd143bb6c40f056985b56e06369f18ba6 Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Thu, 22 Jun 2023 19:57:53 +0200 Subject: [PATCH 01/22] chore: update format of pyproject.toml for Poetry 1.5 --- poetry.lock | 82 +++++++++++++++++++++----------------------------- pyproject.toml | 10 ++++-- 2 files changed, 42 insertions(+), 50 deletions(-) diff --git a/poetry.lock b/poetry.lock index 326114b1..bbe77306 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "jsonref" version = "1.1.0" description = "jsonref is a library for automatic dereferencing of JSON Reference objects for Python." -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -14,14 +13,13 @@ files = [ [[package]] name = "natsort" -version = "8.3.1" +version = "8.4.0" description = "Simple yet flexible natural sorting in Python." -category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "natsort-8.3.1-py3-none-any.whl", hash = "sha256:d583bc9050dd10538de36297c960b93f873f0cd01671a3c50df5bd86dd391dcb"}, - {file = "natsort-8.3.1.tar.gz", hash = "sha256:517595492dde570a4fd6b6a76f644440c1ba51e2338c8a671d7f0475fda8f9fd"}, + {file = "natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c"}, + {file = "natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581"}, ] [package.extras] @@ -30,47 +28,42 @@ icu = ["PyICU (>=1.0.0)"] [[package]] name = "numpy" -version = "1.24.3" +version = "1.25.0" description = "Fundamental package for array computing in Python" -category = "main" optional = true -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "numpy-1.24.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3c1104d3c036fb81ab923f507536daedc718d0ad5a8707c6061cdfd6d184e570"}, - {file = "numpy-1.24.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:202de8f38fc4a45a3eea4b63e2f376e5f2dc64ef0fa692838e31a808520efaf7"}, - {file = "numpy-1.24.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8535303847b89aa6b0f00aa1dc62867b5a32923e4d1681a35b5eef2d9591a463"}, - {file = "numpy-1.24.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d926b52ba1367f9acb76b0df6ed21f0b16a1ad87c6720a1121674e5cf63e2b6"}, - {file = "numpy-1.24.3-cp310-cp310-win32.whl", hash = "sha256:f21c442fdd2805e91799fbe044a7b999b8571bb0ab0f7850d0cb9641a687092b"}, - {file = "numpy-1.24.3-cp310-cp310-win_amd64.whl", hash = "sha256:ab5f23af8c16022663a652d3b25dcdc272ac3f83c3af4c02eb8b824e6b3ab9d7"}, - {file = "numpy-1.24.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9a7721ec204d3a237225db3e194c25268faf92e19338a35f3a224469cb6039a3"}, - {file = "numpy-1.24.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d6cc757de514c00b24ae8cf5c876af2a7c3df189028d68c0cb4eaa9cd5afc2bf"}, - {file = "numpy-1.24.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76e3f4e85fc5d4fd311f6e9b794d0c00e7002ec122be271f2019d63376f1d385"}, - {file = "numpy-1.24.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1d3c026f57ceaad42f8231305d4653d5f05dc6332a730ae5c0bea3513de0950"}, - {file = "numpy-1.24.3-cp311-cp311-win32.whl", hash = "sha256:c91c4afd8abc3908e00a44b2672718905b8611503f7ff87390cc0ac3423fb096"}, - {file = "numpy-1.24.3-cp311-cp311-win_amd64.whl", hash = "sha256:5342cf6aad47943286afa6f1609cad9b4266a05e7f2ec408e2cf7aea7ff69d80"}, - {file = "numpy-1.24.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7776ea65423ca6a15255ba1872d82d207bd1e09f6d0894ee4a64678dd2204078"}, - {file = "numpy-1.24.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ae8d0be48d1b6ed82588934aaaa179875e7dc4f3d84da18d7eae6eb3f06c242c"}, - {file = "numpy-1.24.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecde0f8adef7dfdec993fd54b0f78183051b6580f606111a6d789cd14c61ea0c"}, - {file = "numpy-1.24.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4749e053a29364d3452c034827102ee100986903263e89884922ef01a0a6fd2f"}, - {file = "numpy-1.24.3-cp38-cp38-win32.whl", hash = "sha256:d933fabd8f6a319e8530d0de4fcc2e6a61917e0b0c271fded460032db42a0fe4"}, - {file = "numpy-1.24.3-cp38-cp38-win_amd64.whl", hash = "sha256:56e48aec79ae238f6e4395886b5eaed058abb7231fb3361ddd7bfdf4eed54289"}, - {file = "numpy-1.24.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4719d5aefb5189f50887773699eaf94e7d1e02bf36c1a9d353d9f46703758ca4"}, - {file = "numpy-1.24.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0ec87a7084caa559c36e0a2309e4ecb1baa03b687201d0a847c8b0ed476a7187"}, - {file = "numpy-1.24.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea8282b9bcfe2b5e7d491d0bf7f3e2da29700cec05b49e64d6246923329f2b02"}, - {file = "numpy-1.24.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210461d87fb02a84ef243cac5e814aad2b7f4be953b32cb53327bb49fd77fbb4"}, - {file = "numpy-1.24.3-cp39-cp39-win32.whl", hash = "sha256:784c6da1a07818491b0ffd63c6bbe5a33deaa0e25a20e1b3ea20cf0e43f8046c"}, - {file = "numpy-1.24.3-cp39-cp39-win_amd64.whl", hash = "sha256:d5036197ecae68d7f491fcdb4df90082b0d4960ca6599ba2659957aafced7c17"}, - {file = "numpy-1.24.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:352ee00c7f8387b44d19f4cada524586f07379c0d49270f87233983bc5087ca0"}, - {file = "numpy-1.24.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7d6acc2e7524c9955e5c903160aa4ea083736fde7e91276b0e5d98e6332812"}, - {file = "numpy-1.24.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:35400e6a8d102fd07c71ed7dcadd9eb62ee9a6e84ec159bd48c28235bbb0f8e4"}, - {file = "numpy-1.24.3.tar.gz", hash = "sha256:ab344f1bf21f140adab8e47fdbc7c35a477dc01408791f8ba00d018dd0bc5155"}, + {file = "numpy-1.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8aa130c3042052d656751df5e81f6d61edff3e289b5994edcf77f54118a8d9f4"}, + {file = "numpy-1.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e3f2b96e3b63c978bc29daaa3700c028fe3f049ea3031b58aa33fe2a5809d24"}, + {file = "numpy-1.25.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6b267f349a99d3908b56645eebf340cb58f01bd1e773b4eea1a905b3f0e4208"}, + {file = "numpy-1.25.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aedd08f15d3045a4e9c648f1e04daca2ab1044256959f1f95aafeeb3d794c16"}, + {file = "numpy-1.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6d183b5c58513f74225c376643234c369468e02947b47942eacbb23c1671f25d"}, + {file = "numpy-1.25.0-cp310-cp310-win32.whl", hash = "sha256:d76a84998c51b8b68b40448ddd02bd1081bb33abcdc28beee6cd284fe11036c6"}, + {file = "numpy-1.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0dc071017bc00abb7d7201bac06fa80333c6314477b3d10b52b58fa6a6e38f6"}, + {file = "numpy-1.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c69fe5f05eea336b7a740e114dec995e2f927003c30702d896892403df6dbf0"}, + {file = "numpy-1.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c7211d7920b97aeca7b3773a6783492b5b93baba39e7c36054f6e749fc7490c"}, + {file = "numpy-1.25.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecc68f11404930e9c7ecfc937aa423e1e50158317bf67ca91736a9864eae0232"}, + {file = "numpy-1.25.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e559c6afbca484072a98a51b6fa466aae785cfe89b69e8b856c3191bc8872a82"}, + {file = "numpy-1.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6c284907e37f5e04d2412950960894b143a648dea3f79290757eb878b91acbd1"}, + {file = "numpy-1.25.0-cp311-cp311-win32.whl", hash = "sha256:95367ccd88c07af21b379be1725b5322362bb83679d36691f124a16357390153"}, + {file = "numpy-1.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:b76aa836a952059d70a2788a2d98cb2a533ccd46222558b6970348939e55fc24"}, + {file = "numpy-1.25.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b792164e539d99d93e4e5e09ae10f8cbe5466de7d759fc155e075237e0c274e4"}, + {file = "numpy-1.25.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7cd981ccc0afe49b9883f14761bb57c964df71124dcd155b0cba2b591f0d64b9"}, + {file = "numpy-1.25.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5aa48bebfb41f93043a796128854b84407d4df730d3fb6e5dc36402f5cd594c0"}, + {file = "numpy-1.25.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5177310ac2e63d6603f659fadc1e7bab33dd5a8db4e0596df34214eeab0fee3b"}, + {file = "numpy-1.25.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0ac6edfb35d2a99aaf102b509c8e9319c499ebd4978df4971b94419a116d0790"}, + {file = "numpy-1.25.0-cp39-cp39-win32.whl", hash = "sha256:7412125b4f18aeddca2ecd7219ea2d2708f697943e6f624be41aa5f8a9852cc4"}, + {file = "numpy-1.25.0-cp39-cp39-win_amd64.whl", hash = "sha256:26815c6c8498dc49d81faa76d61078c4f9f0859ce7817919021b9eba72b425e3"}, + {file = "numpy-1.25.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5b1b90860bf7d8a8c313b372d4f27343a54f415b20fb69dd601b7efe1029c91e"}, + {file = "numpy-1.25.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85cdae87d8c136fd4da4dad1e48064d700f63e923d5af6c8c782ac0df8044542"}, + {file = "numpy-1.25.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cc3fda2b36482891db1060f00f881c77f9423eead4c3579629940a3e12095fe8"}, + {file = "numpy-1.25.0.tar.gz", hash = "sha256:f1accae9a28dc3cda46a91de86acf69de0d1b5f4edd44a9b0c3ceb8036dfff19"}, ] [[package]] name = "pyclean" version = "2.7.3" description = "Pure Python cross-platform pyclean. Clean up your Python bytecode." -category = "dev" optional = false python-versions = "*" files = [ @@ -82,7 +75,6 @@ files = [ name = "pyledctrl" version = "4.1.0" description = "Compiler to convert LED light show scripts to bytecode format" -category = "main" optional = true python-versions = ">=3.9,<4.0" files = [ @@ -100,14 +92,13 @@ reference = "fury" [[package]] name = "skybrush-studio" -version = "4.4.2" +version = "4.4.3" description = "Drone show designer tools and scripting language" -category = "main" optional = true python-versions = ">=3.9,<4.0" files = [ - {file = "skybrush_studio-4.4.2-py3-none-any.whl", hash = "sha256:8193ab1981c732334426f69174467f633d556bc98c20956086440799cef584d1"}, - {file = "skybrush_studio-4.4.2.tar.gz", hash = "sha256:2e6d4b4f382baf4336d1647f92c352426b7b1b97f94485aafb5a18b31a5f6667"}, + {file = "skybrush_studio-4.4.3-py3-none-any.whl", hash = "sha256:d389ad921095cdb6199b3dd72eea4f758a06ab67f8a1b872a68cfe30e6f77c54"}, + {file = "skybrush_studio-4.4.3.tar.gz", hash = "sha256:694b94fe99c01793475107cdf72d827367f1204df69b48a2ef2c56f867af17b8"}, ] [package.dependencies] @@ -131,7 +122,6 @@ reference = "collmot" name = "svgpathtools" version = "1.6.1" description = "A collection of tools for manipulating and analyzing SVG Path objects and Bezier curves." -category = "main" optional = true python-versions = "*" files = [ @@ -155,7 +145,6 @@ reference = "fury" name = "svgwrite" version = "1.4.3" description = "A Python library to create SVG drawings." -category = "main" optional = true python-versions = ">=3.6" files = [ @@ -167,7 +156,6 @@ files = [ name = "webcolors" version = "1.13" description = "A library for working with the color formats defined by HTML and CSS." -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -185,4 +173,4 @@ standalone = ["skybrush-studio", "svgpathtools"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "f9c173ee33f0f4aacd4eac7d0a8112452f281ec56b51708287fa35de82dfdc77" +content-hash = "ebea1d20abfc33d5c6c9c4a0b3155a00d0d19596fbd87263b603b1369581c750" diff --git a/pyproject.toml b/pyproject.toml index 7c934aa2..86c683f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,15 +9,19 @@ homepage = "https://skybrush.io" repository = "https://github.com/skybrush-io/studio-plugin-blender" documentation = "https://doc.collmot.com/public/skybrush-plugin-blender/latest/" +[[tool.poetry.source]] +name = "PyPI" +priority = "primary" + [[tool.poetry.source]] name = "collmot" url = "https://pypi.collmot.com/simple/" -secondary = true +priority = "explicit" [[tool.poetry.source]] name = "fury" url = "https://pypi.fury.io/skybrush/" -secondary = true +priority = "supplemental" [tool.poetry.dependencies] python = "^3.9" @@ -28,7 +32,7 @@ svgpathtools = { version = "^1.6.1", optional = true, source = "fury" } [tool.poetry.extras] standalone = ["skybrush-studio", "svgpathtools"] -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] pyclean = "^2.0.0" [build-system] From 72ead892b4f16295ba0a5dafc83f7c73b6628fc0 Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Sun, 25 Jun 2023 21:05:31 +0200 Subject: [PATCH 02/22] refactor!: remove "Relative altitude" option from takeoff --- .../ROOT/pages/panels/formations/swarm.adoc | 2 -- .../sbstudio/plugin/operators/takeoff.py | 18 +++++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/doc/modules/ROOT/pages/panels/formations/swarm.adoc b/doc/modules/ROOT/pages/panels/formations/swarm.adoc index 457d1f58..d7441c4c 100644 --- a/doc/modules/ROOT/pages/panels/formations/swarm.adoc +++ b/doc/modules/ROOT/pages/panels/formations/swarm.adoc @@ -36,8 +36,6 @@ image::panels/swarm/takeoff.jpg[Takeoff] This button should ideally be pressed right after the takeoff grid is created before any other formations are added yet. It is also possible to use the operator later after having defined the first few formations, but you must ensure that there is enough time before the first formation to perform the takeoff _and_ get to the first formation in time. -The btn:[Relative Altitude] checkbox specifies whether the altitude is interpreted relative to the current altitude of each drone (when checked) or as an absolute altitude above ground level (when unchecked). Typically you can leave it unchecked; it makes a difference only if the drones are placed at different heights before takeoff. - NOTE: Skybrush requires you to specify the _average_ vertical velocity of the drones during takeoff. This lets you gauge easily how much time the takeoff will need (e.g., taking off to 6 meters with an average velocity of 1.5 m/s takes 4 seconds), but since the drones need time to accelerate and decelerate, their _maximum_ vertical velocity will be higher than the average velocity to compensate for the time lost during acceleration and deceleration. Make sure to take this into account in order not to overshoot the vertical velocity limits of the drones. == Return to home (RTH) diff --git a/src/modules/sbstudio/plugin/operators/takeoff.py b/src/modules/sbstudio/plugin/operators/takeoff.py index b5ab35ba..47754de8 100644 --- a/src/modules/sbstudio/plugin/operators/takeoff.py +++ b/src/modules/sbstudio/plugin/operators/takeoff.py @@ -5,6 +5,7 @@ from math import ceil from typing import List +from sbstudio.plugin.api import get_api from sbstudio.plugin.constants import Collections from sbstudio.plugin.model.formation import create_formation from sbstudio.plugin.utils.evaluator import create_position_evaluator @@ -49,10 +50,17 @@ class TakeoffOperator(StoryboardOperator): unit="LENGTH", ) + # TODO(ntamas): test whether it is safe to remove this property without + # breaking compatibility with older versions + altitude_is_relative = BoolProperty( name="Relative Altitude", - description="Specifies whether the takeoff altitude is relative to the current altitude of the drone", + description=( + "Specifies whether the takeoff altitude is relative to the current " + "altitude of the drone. Deprecated; not used any more." + ), default=False, + options={"HIDDEN"}, ) """ @@ -117,15 +125,11 @@ def _run(self, storyboard, *, context) -> bool: self._sort_drones(drones) - # Prepare the points of the target formation to take off to + # Evaluate the initial positions of the drones with create_position_evaluator() as get_positions_of: source = get_positions_of(drones, frame=self.start_frame) - if self.altitude_is_relative: - target = [(x, y, z + self.altitude) for x, y, z in source] - max_distance = self.altitude - else: - target = [(x, y, self.altitude) for x, y, z in source] + target = [(x, y, self.altitude) for x, y, z in source] diffs = [t[2] - s[2] for s, t in zip(source, target)] if min(diffs) < 0: From 6cd485f8c5a1a1da3a9198319e2c7acf2323e51f Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Sun, 25 Jun 2023 21:05:55 +0200 Subject: [PATCH 03/22] fix: don't use mutable default argument --- src/modules/sbstudio/api/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/sbstudio/api/base.py b/src/modules/sbstudio/api/base.py index 9ac352ab..4b3764de 100644 --- a/src/modules/sbstudio/api/base.py +++ b/src/modules/sbstudio/api/base.py @@ -358,7 +358,7 @@ def generate_plots( trajectories: Dict[str, Trajectory], output: Path, validation: SafetyCheckParams, - plots: List[str] = ["pos", "vel", "nn"], + plots: Sequence[str] = ("pos", "vel", "nn"), fps: float = 4, ndigits: int = 3, time_markers: Optional[TimeMarkers] = None, From 39c69a98e6c41417bf25afcab3b40fb8c5227d30 Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Sun, 25 Jun 2023 21:06:07 +0200 Subject: [PATCH 04/22] chore: fix line length --- src/modules/sbstudio/api/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/sbstudio/api/base.py b/src/modules/sbstudio/api/base.py index 4b3764de..91bfc78c 100644 --- a/src/modules/sbstudio/api/base.py +++ b/src/modules/sbstudio/api/base.py @@ -242,7 +242,8 @@ def _send_request(self, url: str, data: Any = None) -> Iterator[Response]: raise SkybrushStudioAPIError(str(decoded_body.get("detail"))) else: raise SkybrushStudioAPIError( - f"HTTP error {ex.status}. This is most likely a server-side issue; please contact us and let us know." + f"HTTP error {ex.status}. This is most likely a " + f"server-side issue; please contact us and let us know." ) from ex def _skip_ssl_checks(self) -> None: From 9b6179d779c4c7afb8cb1c921f6599a834e22feb Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Sun, 25 Jun 2023 21:06:22 +0200 Subject: [PATCH 05/22] doc: fix typo --- src/modules/sbstudio/plugin/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/sbstudio/plugin/api.py b/src/modules/sbstudio/plugin/api.py index d8f1684a..3303c0aa 100644 --- a/src/modules/sbstudio/plugin/api.py +++ b/src/modules/sbstudio/plugin/api.py @@ -70,7 +70,7 @@ def call_api_from_blender_operator( ) -> Iterator[SkybrushStudioAPI]: """Context manager that yields immediately back to the caller from a try-except block, catches all exceptions, and calls the ``report()`` method - of the given Blender operator with an approriate error message if there + of the given Blender operator with an appropriate error message if there was an error. All exceptions are then re-raised; the caller is expected to return ``{"CANCELLED"}`` from the operator immediately in response to an exception. From 6824dc5f3b7bdf0ddb6fd2aa821a0aa0a95ac322 Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Tue, 27 Jun 2023 14:35:08 +0200 Subject: [PATCH 06/22] fix: save addon preferences when switching between the local and the community server using buttons --- src/modules/sbstudio/plugin/operators/set_server_url.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/modules/sbstudio/plugin/operators/set_server_url.py b/src/modules/sbstudio/plugin/operators/set_server_url.py index 9234c8a7..da422880 100644 --- a/src/modules/sbstudio/plugin/operators/set_server_url.py +++ b/src/modules/sbstudio/plugin/operators/set_server_url.py @@ -1,3 +1,5 @@ +import bpy + from bpy.props import StringProperty from bpy.types import Operator @@ -20,4 +22,6 @@ def execute(self, context): prefs = prefs.addons[DroneShowAddonGlobalSettings.bl_idname].preferences prefs.server_url = self.url + bpy.ops.wm.save_userpref() + return {"FINISHED"} From e43991d30bfef094e0b5aec1dcd8f8012957157e Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Tue, 27 Jun 2023 14:38:03 +0200 Subject: [PATCH 07/22] fix: transition recalculation commits the new influence curves only when all curves have been designed correctly --- .../operators/recalculate_transitions.py | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/src/modules/sbstudio/plugin/operators/recalculate_transitions.py b/src/modules/sbstudio/plugin/operators/recalculate_transitions.py index 81f74c96..89f83033 100644 --- a/src/modules/sbstudio/plugin/operators/recalculate_transitions.py +++ b/src/modules/sbstudio/plugin/operators/recalculate_transitions.py @@ -1,7 +1,8 @@ from dataclasses import dataclass from enum import Enum +from functools import partial from math import inf -from typing import Iterable, List, Optional, Sequence, Tuple +from typing import Callable, Iterable, List, Optional, Sequence, Tuple import bpy @@ -430,6 +431,7 @@ def update_transition_for_storyboard_entry( # Now we have the index of the target point that each drone # should be mapped to, and we have `None` for those drones that # will not participate in the formation + todo: List[Callable[[], None]] = [] for drone_index, drone in enumerate(drones): target_index = mapping[drone_index] if target_index is None: @@ -445,6 +447,7 @@ def update_transition_for_storyboard_entry( windup_start_frame = end_of_previous start_frame = entry.frame_start + if entry.is_staggered: # Determine the index of the drone in the departure sequence # and in the arrival sequence @@ -487,16 +490,20 @@ def update_transition_for_storyboard_entry( # formation; this is easy arrival_index = objects_in_formation.find(obj) - windup_start_frame += ( - entry.pre_delay_per_drone_in_frames * departure_index - ) - start_frame -= entry.post_delay_per_drone_in_frames * ( + departure_delay = entry.pre_delay_per_drone_in_frames * departure_index + arrival_delay = -entry.post_delay_per_drone_in_frames * ( num_drones_transitioning - arrival_index - 1 ) + + windup_start_frame += departure_delay + start_frame += arrival_delay + if windup_start_frame >= start_frame: raise SkybrushStudioError( - f"Not enough time to plan staggered transition to formation {entry.name!r}. " - f"Try decreasing departure or arrival delay or allow more time for the transition." + f"Not enough time to plan staggered transition to " + f"formation {entry.name!r} at drone index {drone_index+1} " + f"(1-based). Try decreasing departure or arrival delay " + f"or allow more time for the transition." ) # start_frame can be earlier than entry.frame_start for @@ -507,7 +514,22 @@ def update_transition_for_storyboard_entry( start_frame=start_frame, end_frame=start_of_next, ) - update_transition_constraint_influence(drone, constraint, descriptor) + + # Do not update the influence curve now in case we have problems + # with drones coming later in the enumeration; just store the + # operation to call and then we'll do it in one batch at the end + todo.append( + partial( + update_transition_constraint_influence, + drone, + constraint, + descriptor, + ) + ) + + # Commit all the changes to the influence curves that we have planned above + for func in todo: + func() return mapping From 471e9992333849c69b8ec2d9f5875cfb025a3a30 Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Tue, 27 Jun 2023 22:29:28 +0200 Subject: [PATCH 08/22] feat: added option to individually override the departure and arrival times of drones in a transition --- CHANGELOG.md | 5 + src/addons/ui_skybrush_studio.py | 13 +- src/modules/sbstudio/plugin/lists/__init__.py | 3 +- .../plugin/lists/schedule_overrides.py | 34 +++++ src/modules/sbstudio/plugin/model/__init__.py | 3 +- .../sbstudio/plugin/model/storyboard.py | 141 +++++++++++++++++- .../sbstudio/plugin/operators/__init__.py | 4 + src/modules/sbstudio/plugin/operators/base.py | 18 +++ .../create_new_schedule_override_entry.py | 18 +++ .../operators/recalculate_transitions.py | 111 ++++++++++++-- .../remove_schedule_override_entry.py | 27 ++++ .../plugin/panels/transition_editor.py | 41 ++++- 12 files changed, 399 insertions(+), 19 deletions(-) create mode 100644 src/modules/sbstudio/plugin/lists/schedule_overrides.py create mode 100644 src/modules/sbstudio/plugin/operators/create_new_schedule_override_entry.py create mode 100644 src/modules/sbstudio/plugin/operators/remove_schedule_override_entry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ad6074a..9c0d3381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Transitions can now be marked as locked. Keyframes corresponding to the locked transitions are never modified by transition recalculations. +- You can now tweak the departure and arrival times of individual drones within + a transition with schedule overrides. This can be used to resolve collisions + during a staggered transition manually, or to manually create more complex + transition patterns. + ### Fixed - View scaling setting from the Blender preferences is now taken into account diff --git a/src/addons/ui_skybrush_studio.py b/src/addons/ui_skybrush_studio.py index 374cd2d3..d248f631 100644 --- a/src/addons/ui_skybrush_studio.py +++ b/src/addons/ui_skybrush_studio.py @@ -41,7 +41,10 @@ ############################################################################# # imports needed by the addon -from sbstudio.plugin.lists import SKYBRUSH_UL_lightfxlist +from sbstudio.plugin.lists import ( + SKYBRUSH_UL_lightfxlist, + SKYBRUSH_UL_scheduleoverridelist, +) from sbstudio.plugin.menus import GenerateMarkersMenu from sbstudio.plugin.model import ( DroneShowAddonFileSpecificSettings, @@ -53,6 +56,7 @@ LightEffect, LightEffectCollection, SafetyCheckProperties, + ScheduleOverride, StoryboardEntry, Storyboard, ) @@ -62,6 +66,7 @@ AppendFormationToStoryboardOperator, ApplyColorsToSelectedDronesOperator, CreateFormationOperator, + CreateNewScheduleOverrideEntryOperator, CreateNewStoryboardEntryOperator, CreateLightEffectOperator, CreateTakeoffGridOperator, @@ -80,6 +85,7 @@ RecalculateTransitionsOperator, RemoveFormationOperator, RemoveLightEffectOperator, + RemoveScheduleOverrideEntryOperator, RemoveStoryboardEntryOperator, ReorderFormationMarkersOperator, ReturnToHomeOperator, @@ -139,6 +145,7 @@ FormationsPanelProperties, LightEffect, LightEffectCollection, + ScheduleOverride, StoryboardEntry, Storyboard, LEDControlPanelProperties, @@ -165,6 +172,8 @@ MoveStoryboardEntryUpOperator, SelectStoryboardEntryForCurrentFrameOperator, RemoveStoryboardEntryOperator, + CreateNewScheduleOverrideEntryOperator, + RemoveScheduleOverrideEntryOperator, UpdateFrameRangeFromStoryboardOperator, UpdateTimeMarkersFromStoryboardOperator, CreateLightEffectOperator, @@ -194,7 +203,7 @@ ) #: List widgets in this addon. -lists = (SKYBRUSH_UL_lightfxlist,) +lists = (SKYBRUSH_UL_lightfxlist, SKYBRUSH_UL_scheduleoverridelist) #: Menus in this addon menus = (GenerateMarkersMenu,) diff --git a/src/modules/sbstudio/plugin/lists/__init__.py b/src/modules/sbstudio/plugin/lists/__init__.py index 7e8358a8..118042ce 100644 --- a/src/modules/sbstudio/plugin/lists/__init__.py +++ b/src/modules/sbstudio/plugin/lists/__init__.py @@ -1,3 +1,4 @@ from .light_effects import SKYBRUSH_UL_lightfxlist +from .schedule_overrides import SKYBRUSH_UL_scheduleoverridelist -__all__ = ("SKYBRUSH_UL_lightfxlist",) +__all__ = ("SKYBRUSH_UL_lightfxlist", "SKYBRUSH_UL_scheduleoverridelist") diff --git a/src/modules/sbstudio/plugin/lists/schedule_overrides.py b/src/modules/sbstudio/plugin/lists/schedule_overrides.py new file mode 100644 index 00000000..59375744 --- /dev/null +++ b/src/modules/sbstudio/plugin/lists/schedule_overrides.py @@ -0,0 +1,34 @@ +from bpy.types import UIList + +from sbstudio.plugin.model.storyboard import ScheduleOverride + +__all__ = ("SKYBRUSH_UL_scheduleoverridelist",) + + +class SKYBRUSH_UL_scheduleoverridelist(UIList): + """Customized Blender UI list for transition schedule overrides.""" + + def draw_item( + self, + context, + layout, + data, + item: ScheduleOverride, + icon, + active_data, + active_propname, + index, + ): + if self.layout_type in {"DEFAULT", "COMPACT"}: + layout.use_property_decorate = False + layout.alignment = "LEFT" + + checkbox = "CHECKBOX_HLT" if item.enabled else "CHECKBOX_DEHLT" + layout.prop(item, "enabled", emboss=False, text="", icon=checkbox) + layout.label(text=item.label) + elif self.layout_type in {"GRID"}: + layout.alignment = "CENTER" + + checkbox = "CHECKBOX_HLT" if item.enabled else "CHECKBOX_DEHLT" + layout.prop(item, "enabled", emboss=False, text="", icon=checkbox) + layout.label(text=item.label) diff --git a/src/modules/sbstudio/plugin/model/__init__.py b/src/modules/sbstudio/plugin/model/__init__.py index 8aa0c38a..b0f6ffa3 100644 --- a/src/modules/sbstudio/plugin/model/__init__.py +++ b/src/modules/sbstudio/plugin/model/__init__.py @@ -6,7 +6,7 @@ from .safety_check import SafetyCheckProperties from .settings import DroneShowAddonFileSpecificSettings from .show import DroneShowAddonProperties -from .storyboard import StoryboardEntry, Storyboard +from .storyboard import ScheduleOverride, StoryboardEntry, Storyboard __all__ = ( "DroneShowAddonFileSpecificSettings", @@ -18,6 +18,7 @@ "LightEffect", "LightEffectCollection", "SafetyCheckProperties", + "ScheduleOverride", "StoryboardEntry", "Storyboard", ) diff --git a/src/modules/sbstudio/plugin/model/storyboard.py b/src/modules/sbstudio/plugin/model/storyboard.py index 3cbaa11e..9a567b29 100644 --- a/src/modules/sbstudio/plugin/model/storyboard.py +++ b/src/modules/sbstudio/plugin/model/storyboard.py @@ -11,7 +11,7 @@ from bpy.types import Collection, Context, PropertyGroup from operator import attrgetter from uuid import uuid4 -from typing import Optional, Tuple +from typing import Dict, List, Optional, Tuple from sbstudio.plugin.constants import ( Collections, @@ -25,7 +25,58 @@ from .formation import count_markers_in_formation from .mixins import ListMixin -__all__ = ("StoryboardEntry", "Storyboard") +__all__ = ("ScheduleOverride", "StoryboardEntry", "Storyboard") + + +class ScheduleOverride(PropertyGroup): + """Blender property group representing overrides to the departure and + arrival delays of a drone in a transition. + """ + + enabled = BoolProperty( + name="Enabled", + description="Whether this override entry is enabled", + default=True, + options=set(), + ) + + index = IntProperty( + name="Index", + description=( + "0-based index of the marker in the source formation that the " + "override refers to" + ), + default=0, + min=0, + options=set(), + ) + + pre_delay = IntProperty( + name="Departure delay", + description=( + "Number of frames between the start of the entire transition and " + "the departure of the drone assigned to this source marker" + ), + min=0, + ) + + post_delay = IntProperty( + name="Arrival delay", + description=( + "Number of frames between the end of the entire transition and the " + "arrival of the drone assigned to this source marker" + ), + min=0, + ) + + @property + def label(self) -> str: + parts: List[str] = [f"@{self.index}"] + if self.pre_delay != 0: + parts.append(f"dep {self.pre_delay}") + if self.post_delay != 0: + parts.append(f"arr {self.post_delay}") + return " | ".join(parts) def _handle_formation_change(operator, context): @@ -124,6 +175,18 @@ class StoryboardEntry(PropertyGroup): unit="TIME", step=100, # button step is 1/100th of step ) + schedule_overrides = CollectionProperty(type=ScheduleOverride) + schedule_overrides_enabled = BoolProperty( + name="Schedule overrides enabled", + description=( + "Whether the schedule overrides associated to the current entry " + "are enabled" + ), + ) + active_schedule_override_entry_index = IntProperty( + name="Selected override entry index", + description="Index of the schedule override entry currently being edited", + ) is_locked = BoolProperty( name="Locked", description=( @@ -135,6 +198,47 @@ class StoryboardEntry(PropertyGroup): #: Sorting key for storyboard entries sort_key = attrgetter("frame_start", "frame_end") + @property + def active_schedule_override_entry(self) -> Optional[ScheduleOverride]: + """The active schedule override currently selected for editing, or + `None` if there is no such entry. + """ + index = self.active_schedule_override_entry_index + if index is not None and index >= 0 and index < len(self.schedule_overrides): + return self.schedule_overrides[index] + else: + return None + + @active_schedule_override_entry.setter + def active_schedule_override_entry(self, entry: Optional[ScheduleOverride]): + if entry is None: + self.active_schedule_override_entry_index = -1 + return + + for i, entry_in_collection in enumerate(self.schedule_overrides): + if entry_in_collection == entry: + self.active_schedule_override_entry_index = i + return + else: + self.active_schedule_override_entry_index = -1 + + @with_context + def add_new_schedule_override( + self, + *, + select: bool = False, + context: Optional[Context] = None, + ) -> ScheduleOverride: + """Appends a new schedule override to the end of the storyboard. + + Parameters: + select: whether to select the newly added entry after it was created + """ + entry = self.schedule_overrides.add() + if select: + self.active_schedule_override_entry = entry + return entry + def contains_frame(self, frame: int) -> bool: """Returns whether the storyboard entry contains the given frame. @@ -170,20 +274,49 @@ def is_staggered(self) -> bool: """Whether the transition is staggered.""" return self.transition_schedule == "STAGGERED" + def get_enabled_schedule_override_map(self) -> Dict[int, ScheduleOverride]: + """Returns a dictionary mapping indices of markers in the source + formation to the corresponding transition schedule overrides. + + Only enabled schedule overrides are considered. + """ + result: Dict[int, ScheduleOverride] = {} + + if self.schedule_overrides_enabled: + for override in self.schedule_overrides: + if override.enabled: + result[override.index] = override + + return result + + def remove_active_schedule_override_entry(self) -> None: + """Removes the active schedule override entry from the collection and + adjusts the active entry index as needed. + """ + entry = self.active_schedule_override_entry + if not entry: + return + + index = self.active_schedule_override_entry_index + self.schedule_overrides.remove(index) + self.active_schedule_override_entry_index = min( + max(0, index), len(self.schedule_overrides) + ) + class Storyboard(PropertyGroup, ListMixin): """Blender property group representing the entire storyboard of the drone show. """ - #: The entries in this storyboard entries = CollectionProperty(type=StoryboardEntry) + """The entries in this storyboard""" - #: Index of the active entry (currently being edited) in the storyboard active_entry_index = IntProperty( name="Selected index", description="Index of the storyboard entry currently being edited", ) + """Index of the active entry (currently being edited) in the storyboard""" @property def active_entry(self) -> Optional[StoryboardEntry]: diff --git a/src/modules/sbstudio/plugin/operators/__init__.py b/src/modules/sbstudio/plugin/operators/__init__.py index c0799afb..1171ea5e 100644 --- a/src/modules/sbstudio/plugin/operators/__init__.py +++ b/src/modules/sbstudio/plugin/operators/__init__.py @@ -6,6 +6,7 @@ from .apply_color import ApplyColorsToSelectedDronesOperator from .create_formation import CreateFormationOperator from .create_light_effect import CreateLightEffectOperator +from .create_new_schedule_override_entry import CreateNewScheduleOverrideEntryOperator from .create_new_storyboard_entry import CreateNewStoryboardEntryOperator from .create_takeoff_grid import CreateTakeoffGridOperator from .detach_materials_from_template import DetachMaterialsFromDroneTemplateOperator @@ -29,6 +30,7 @@ from .recalculate_transitions import RecalculateTransitionsOperator from .remove_formation import RemoveFormationOperator from .remove_light_effect import RemoveLightEffectOperator +from .remove_schedule_override_entry import RemoveScheduleOverrideEntryOperator from .remove_storyboard_entry import RemoveStoryboardEntryOperator from .reorder_formation_markers import ReorderFormationMarkersOperator from .return_to_home import ReturnToHomeOperator @@ -48,6 +50,7 @@ "ApplyColorsToSelectedDronesOperator", "CreateFormationOperator", "CreateLightEffectOperator", + "CreateNewScheduleOverrideEntryOperator", "CreateNewStoryboardEntryOperator", "CreateTakeoffGridOperator", "DeselectFormationOperator", @@ -63,6 +66,7 @@ "MoveStoryboardEntryUpOperator", "PrepareSceneOperator", "RecalculateTransitionsOperator", + "RemoveScheduleOverrideEntryOperator", "RemoveFormationOperator", "RemoveLightEffectOperator", "RemoveStoryboardEntryOperator", diff --git a/src/modules/sbstudio/plugin/operators/base.py b/src/modules/sbstudio/plugin/operators/base.py index 07cf5fe2..35babc19 100644 --- a/src/modules/sbstudio/plugin/operators/base.py +++ b/src/modules/sbstudio/plugin/operators/base.py @@ -74,3 +74,21 @@ def execute(self, context): return self.execute_on_storyboard(storyboard, entries, context) else: return self.execute_on_storyboard(storyboard, context) + + +class StoryboardEntryOperator(Operator): + """Operator mixin that allows an operator to be executed if we have a + selected storyboard entry in the current scene. + """ + + @classmethod + def poll(cls, context): + return ( + context.scene.skybrush + and context.scene.skybrush.storyboard + and context.scene.skybrush.storyboard.active_entry + ) + + def execute(self, context): + entry = context.scene.skybrush.storyboard.active_entry + return self.execute_on_storyboard_entry(entry, context) diff --git a/src/modules/sbstudio/plugin/operators/create_new_schedule_override_entry.py b/src/modules/sbstudio/plugin/operators/create_new_schedule_override_entry.py new file mode 100644 index 00000000..706cde16 --- /dev/null +++ b/src/modules/sbstudio/plugin/operators/create_new_schedule_override_entry.py @@ -0,0 +1,18 @@ +from .base import StoryboardEntryOperator + +__all__ = ("CreateNewScheduleOverrideEntryOperator",) + + +class CreateNewScheduleOverrideEntryOperator(StoryboardEntryOperator): + """Blender operator that creates a new, empty schedule override in the + currently selected storyboard entry.""" + + bl_idname = "skybrush.create_new_schedule_override_entry" + bl_label = "Create New Schedule Override" + bl_description = ( + "Creates a new schedule override for the currently selected storyboard entry." + ) + + def execute_on_storyboard_entry(self, entry, context): + entry.add_new_schedule_override() + return {"FINISHED"} diff --git a/src/modules/sbstudio/plugin/operators/recalculate_transitions.py b/src/modules/sbstudio/plugin/operators/recalculate_transitions.py index 89f83033..0ad45e06 100644 --- a/src/modules/sbstudio/plugin/operators/recalculate_transitions.py +++ b/src/modules/sbstudio/plugin/operators/recalculate_transitions.py @@ -265,6 +265,55 @@ def calculate_mapping_for_transition_into_storyboard_entry( return result +def calculate_departure_index_of_drone( + drone, + drone_index: int, + previous_entry: StoryboardEntry, + previous_entry_index: int, + previous_mapping: Optional[Mapping], + objects_in_previous_formation, +) -> int: + """Calculates the departure index of a drone (i.e. the index of the source + marker that a drone is associated to) in a transition. + """ + # In the departure sequence, the index of the drone is dictated + # by the index of its associated marker / object within the + # previous formation. + if previous_mapping: + previous_target_index = previous_mapping[drone_index] + if previous_target_index is None: + # drone did not participate in the previous formation + return 0 + else: + return previous_target_index + else: + # Previous mapping not known. All is not lost, however; we + # can find which point in the previous formation the drone + # must have belonged to by finding the constraint that binds + # the drone to the previous storyboard entry + if previous_entry is not None: + previous_constraint = find_transition_constraint_between( + drone=drone, storyboard_entry=previous_entry + ) + if previous_constraint is not None: + previous_obj = previous_constraint.target + else: + # Either the drone did not participate in the previous formation, + # or the previous storyboard entry is the first one in the + # storyboard, in which case there are no constraints + if previous_entry_index == 0: + return drone_index + + previous_obj = None + return objects_in_previous_formation.find(previous_obj) + else: + # This is the first entry. If we are calculating a + # transition _into_ the first entry, the drones are + # simply ordered according to how they are placed in the + # Drones collection + return drone_index + + def update_transition_constraint_properties(drone, entry: StoryboardEntry, marker, obj): """Updates the constraint that attaches a drone to its target in a transition. @@ -364,6 +413,7 @@ def update_transition_constraint_influence( def update_transition_for_storyboard_entry( entry: StoryboardEntry, + entry_index: int, drones, *, get_positions_of, @@ -377,6 +427,8 @@ def update_transition_for_storyboard_entry( Parameters: entry: the storyboard entry + entry_index: index of the storyboard entry + drones: the drones in the scene previous_entry: the storyboard entry that precedes the given entry; `None` if the given entry is the first one previous_mapping: the mapping from drone indices to target point @@ -384,7 +436,6 @@ def update_transition_for_storyboard_entry( not known or if the given entry is the first one. Used for staggered transitions to determine when a given drone should depart from the previous formation - drones: the drones in the scene start_of_scene: the first frame of the scene start_of_next: the frame where the _next_ storyboard entry starts; `None` if this is the last storyboard entry @@ -428,6 +479,10 @@ def update_transition_for_storyboard_entry( objects_in_formation = _LazyFormationObjectList(entry) objects_in_previous_formation = _LazyFormationObjectList(previous_entry) + # Create a mapping that maps indices of points in the source formation to + # the corresponding schedule overrides (if any) + schedule_override_map = entry.get_enabled_schedule_override_map() + # Now we have the index of the target point that each drone # should be mapped to, and we have `None` for those drones that # will not participate in the formation @@ -447,10 +502,21 @@ def update_transition_for_storyboard_entry( windup_start_frame = end_of_previous start_frame = entry.frame_start + departure_delay = 0 + arrival_delay = 0 + departure_index: Optional[int] = None if entry.is_staggered: # Determine the index of the drone in the departure sequence # and in the arrival sequence + departure_index = calculate_departure_index_of_drone( + drone, + drone_index, + previous_entry, + entry_index - 1, + previous_mapping, + objects_in_previous_formation, + ) # In the departure sequence, the index of the drone is dictated # by the index of its associated marker / object within the @@ -495,17 +561,37 @@ def update_transition_for_storyboard_entry( num_drones_transitioning - arrival_index - 1 ) - windup_start_frame += departure_delay - start_frame += arrival_delay - - if windup_start_frame >= start_frame: - raise SkybrushStudioError( - f"Not enough time to plan staggered transition to " - f"formation {entry.name!r} at drone index {drone_index+1} " - f"(1-based). Try decreasing departure or arrival delay " - f"or allow more time for the transition." + if schedule_override_map: + # Determine the index of the drone in the departure sequence + # so we can look up whether there is an override for it. Note + # that we do not need to do this again if we already have the + # departure index + if departure_index is None: + departure_index = calculate_departure_index_of_drone( + drone, + drone_index, + previous_entry, + entry_index - 1, + previous_mapping, + objects_in_previous_formation, ) + override = schedule_override_map.get(departure_index) + if override: + departure_delay = override.pre_delay + arrival_delay = -override.post_delay + + windup_start_frame += departure_delay + start_frame += arrival_delay + + if windup_start_frame >= start_frame: + raise SkybrushStudioError( + f"Not enough time to plan staggered transition to " + f"formation {entry.name!r} at drone index {drone_index+1} " + f"(1-based). Try decreasing departure or arrival delay " + f"or allow more time for the transition." + ) + # start_frame can be earlier than entry.frame_start for # staggered arrivals. descriptor = InfluenceCurveDescriptor( @@ -541,6 +627,9 @@ class RecalculationTask: entry: StoryboardEntry """The _target_ entry of the transition to recalculate.""" + entry_index: int + """Index of the target entry of the transition.""" + previous_entry: Optional[StoryboardEntry] = None """The entry that precedes the target entry in the storyboard; `None` if the target entry is the first one. @@ -555,6 +644,7 @@ class RecalculationTask: def for_entry_by_index(cls, entries: Sequence[StoryboardEntry], index: int): return cls( entries[index], + index, entries[index - 1] if index > 0 else None, entries[index + 1].frame_start if index + 1 < len(entries) else None, ) @@ -582,6 +672,7 @@ def recalculate_transitions( for task in tasks: previous_mapping = update_transition_for_storyboard_entry( task.entry, + task.entry_index, drones, get_positions_of=get_positions_of, previous_entry=task.previous_entry, diff --git a/src/modules/sbstudio/plugin/operators/remove_schedule_override_entry.py b/src/modules/sbstudio/plugin/operators/remove_schedule_override_entry.py new file mode 100644 index 00000000..3a55697b --- /dev/null +++ b/src/modules/sbstudio/plugin/operators/remove_schedule_override_entry.py @@ -0,0 +1,27 @@ +from .base import StoryboardEntryOperator + +__all__ = ("RemoveScheduleOverrideEntryOperator",) + + +class RemoveScheduleOverrideEntryOperator(StoryboardEntryOperator): + """Blender operator that removes the selected schedule override entry + from the current storyboard entry.""" + + bl_idname = "skybrush.remove_schedule_override_entry" + bl_label = "Remove Selected Schedule Override Entry" + bl_description = ( + "Remove the selected schedule override entry from the selected " + "storyboard entry" + ) + + @classmethod + def poll(cls, context): + if not StoryboardEntryOperator.poll(context): + return False + + entry = context.scene.skybrush.storyboard.active_entry + return entry.active_schedule_override_entry is not None + + def execute_on_storyboard_entry(self, entry, context): + entry.remove_active_schedule_override_entry() + return {"FINISHED"} diff --git a/src/modules/sbstudio/plugin/panels/transition_editor.py b/src/modules/sbstudio/plugin/panels/transition_editor.py index b1b0f4bb..b3db8543 100644 --- a/src/modules/sbstudio/plugin/panels/transition_editor.py +++ b/src/modules/sbstudio/plugin/panels/transition_editor.py @@ -3,7 +3,11 @@ from typing import List, Optional from sbstudio.plugin.model.storyboard import Storyboard, StoryboardEntry -from sbstudio.plugin.operators import RecalculateTransitionsOperator +from sbstudio.plugin.operators import ( + CreateNewScheduleOverrideEntryOperator, + RecalculateTransitionsOperator, + RemoveScheduleOverrideEntryOperator, +) def format_transition_duration(duration: int) -> str: @@ -67,6 +71,41 @@ def draw(self, context): layout.prop(entry, "pre_delay_per_drone_in_frames") layout.prop(entry, "post_delay_per_drone_in_frames") + layout.prop(entry, "schedule_overrides_enabled", text="Schedule overrides") + if entry.schedule_overrides_enabled: + row = layout.row() + + col = row.column() + col.template_list( + "SKYBRUSH_UL_scheduleoverridelist", + self.bl_idname, + entry, + "schedule_overrides", + entry, + "active_schedule_override_entry_index", + maxrows=6, + sort_lock=True, + ) + + col = row.column(align=True) + col.operator( + CreateNewScheduleOverrideEntryOperator.bl_idname, icon="ADD", text="" + ) + col.operator( + RemoveScheduleOverrideEntryOperator.bl_idname, icon="REMOVE", text="" + ) + + schedule_override = entry.active_schedule_override_entry + if schedule_override: + row = layout.row() + row.prop(schedule_override, "index", text="Marker index") + + row = layout.row() + row.prop(schedule_override, "pre_delay", text="Dep") + row.prop(schedule_override, "post_delay", text="Arr") + + layout.separator() + layout.prop(entry, "is_locked") props = layout.operator( From db709db50df0e52cee25f6b10c194c62dac42012 Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Wed, 28 Jun 2023 22:44:16 +0200 Subject: [PATCH 09/22] feat: takeoff operator now creates staggered takeoffs if needed --- src/modules/sbstudio/api/base.py | 25 +++++++++++ .../sbstudio/plugin/operators/takeoff.py | 42 ++++++++++++++++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/modules/sbstudio/api/base.py b/src/modules/sbstudio/api/base.py index 91bfc78c..76e3b249 100644 --- a/src/modules/sbstudio/api/base.py +++ b/src/modules/sbstudio/api/base.py @@ -258,6 +258,31 @@ def _skip_ssl_checks(self) -> None: ctx.verify_mode = CERT_NONE self._request_context = ctx + def decompose_points( + self, + points: Sequence[Coordinate3D], + *, + min_distance: float, + method: str = "greedy", + ) -> List[int]: + """Decomposes a set of points into multiple groups while ensuring that + the minimum distance of points within the same group is at least as + large as the given threshold. + """ + data = { + "version": 1, + "method": str(method), + "min_distance": float(min_distance), + "points": points, + } + with self._send_request("operations/decompose", data) as response: + result = response.as_json() + + if result.get("version") != 1: + raise SkybrushStudioAPIError("invalid response version") + + return result.get("groups") + def export( self, validation: SafetyCheckParams, diff --git a/src/modules/sbstudio/plugin/operators/takeoff.py b/src/modules/sbstudio/plugin/operators/takeoff.py index 47754de8..4777c7d5 100644 --- a/src/modules/sbstudio/plugin/operators/takeoff.py +++ b/src/modules/sbstudio/plugin/operators/takeoff.py @@ -8,6 +8,7 @@ from sbstudio.plugin.api import get_api from sbstudio.plugin.constants import Collections from sbstudio.plugin.model.formation import create_formation +from sbstudio.plugin.model.storyboard import Storyboard from sbstudio.plugin.utils.evaluator import create_position_evaluator from .base import StoryboardOperator @@ -113,7 +114,7 @@ def invoke(self, context, event): def execute_on_storyboard(self, storyboard, entries, context): return {"FINISHED"} if self._run(storyboard, context=context) else {"CANCELLED"} - def _run(self, storyboard, *, context) -> bool: + def _run(self, storyboard: Storyboard, *, context) -> bool: bpy.ops.skybrush.prepare() if not self._validate_start_frame(context): @@ -129,8 +130,24 @@ def _run(self, storyboard, *, context) -> bool: with create_position_evaluator() as get_positions_of: source = get_positions_of(drones, frame=self.start_frame) - target = [(x, y, self.altitude) for x, y, z in source] + # Figure out how many phases we will need, based on the current safety + # threshold and the arrangement of the drones + min_distance: float = ( + context.scene.skybrush.safety_check.proximity_warning_threshold + ) + groups = get_api().decompose_points( + source, min_distance=min_distance, method="balanced" + ) + num_groups = max(groups) + 1 + + # Prepare the points of the target formation to take off to + altitude_shift_per_group = 5 + target = [ + (x, y, self.altitude + (num_groups - group - 1) * altitude_shift_per_group) + for (x, y, _), group in zip(source, groups) + ] + # Calculate the Z distance to travel for each drone diffs = [t[2] - s[2] for s, t in zip(source, target)] if min(diffs) < 0: dist = abs(min(diffs)) @@ -140,11 +157,15 @@ def _run(self, storyboard, *, context) -> bool: ) return False - # Calculate takeoff duration from max distance to travel and the + # Calculate takeoff durations from distances to travel and the # average velocity - max_distance = max(diffs) fps = context.scene.render.fps - takeoff_duration = ceil((max_distance / self.velocity) * fps) + takeoff_durations = [ceil((diff / self.velocity) * fps) for diff in diffs] + + # We ensure that drones arrive at the same time, so calculate the + # takeoff delays for those drones that take off to lower altitudes + takeoff_duration = max(takeoff_durations) + delays = [takeoff_duration - d for d in takeoff_durations] # Calculate when the takeoff should end end_of_takeoff = self.start_frame + takeoff_duration @@ -160,7 +181,7 @@ def _run(self, storyboard, *, context) -> bool: return False # Add a new storyboard entry with the given formation - storyboard.add_new_entry( + entry = storyboard.add_new_entry( formation=create_formation("Takeoff", target), frame_start=end_of_takeoff, duration=0, @@ -168,6 +189,15 @@ def _run(self, storyboard, *, context) -> bool: context=context, ) + # Set up the custom departure delays for the drones + if delays and max(delays) > 0: + entry.schedule_overrides_enabled = True + for index, delay in enumerate(delays): + if delay > 0: + override = entry.add_new_schedule_override() + override.index = index + override.pre_delay = delay + # Recalculate the transitions leading from and to the target formation bpy.ops.skybrush.recalculate_transitions(scope="TO_SELECTED") if len(storyboard.entries) > 2: From fce50003eb27420863c1b758818f38269e380ed9 Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Thu, 29 Jun 2023 18:06:32 +0200 Subject: [PATCH 10/22] feat: added takeoff operator parameter to change the layer distance for multi-phase takeoffs --- src/modules/sbstudio/plugin/operators/takeoff.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/modules/sbstudio/plugin/operators/takeoff.py b/src/modules/sbstudio/plugin/operators/takeoff.py index 4777c7d5..be19a593 100644 --- a/src/modules/sbstudio/plugin/operators/takeoff.py +++ b/src/modules/sbstudio/plugin/operators/takeoff.py @@ -64,6 +64,19 @@ class TakeoffOperator(StoryboardOperator): options={"HIDDEN"}, ) + altitude_shift = FloatProperty( + name="Layer height", + description=( + "Specifies the difference between altitudes of takeoff layers " + "for multi-phase takeoffs when multiple drones occupy the same " + "takeoff slot within safety distance." + ), + default=5, + soft_min=0, + soft_max=50, + unit="LENGTH", + ) + """ delay = FloatProperty( name="Delay", @@ -141,7 +154,7 @@ def _run(self, storyboard: Storyboard, *, context) -> bool: num_groups = max(groups) + 1 # Prepare the points of the target formation to take off to - altitude_shift_per_group = 5 + altitude_shift_per_group = self.altitude_shift target = [ (x, y, self.altitude + (num_groups - group - 1) * altitude_shift_per_group) for (x, y, _), group in zip(source, groups) From 1edc113d41b18fa4e2f8e24143b49cdc32cd1ca4 Mon Sep 17 00:00:00 2001 From: Gabor Vasarhelyi Date: Tue, 4 Jul 2023 05:28:36 +0200 Subject: [PATCH 11/22] fix: resolve compatibility issue with Blender 3.6 that stores multiple script directories --- src/addons/io_import_skybrush_all.py | 13 +++++++++---- src/addons/io_import_skybrush_sky.py | 14 ++++++++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/addons/io_import_skybrush_all.py b/src/addons/io_import_skybrush_all.py index 7cc60a78..95ece242 100644 --- a/src/addons/io_import_skybrush_all.py +++ b/src/addons/io_import_skybrush_all.py @@ -30,10 +30,15 @@ # Note: This code needs to be harmonized with the plugin installer to have # the same target directory for all add-on specific dependencies. -candidates = [ - abspath(bpy.context.preferences.filepaths.script_directory), - Path(sys.modules[__name__].__file__).parent.parent, -] +if bpy.app.version >= (3, 6, 0): + candidates = [ + abspath(script_directory.directory) + for script_directory in bpy.context.preferences.filepaths.script_directories + ] +else: + candidates = [abspath(bpy.context.preferences.filepaths.script_directory)] +candidates.append(Path(sys.modules[__name__].__file__).parent.parent) + for candidate in candidates: path = (Path(candidate) / "vendor" / "skybrush").resolve() if path.exists(): diff --git a/src/addons/io_import_skybrush_sky.py b/src/addons/io_import_skybrush_sky.py index bb3f8575..14d82028 100644 --- a/src/addons/io_import_skybrush_sky.py +++ b/src/addons/io_import_skybrush_sky.py @@ -31,10 +31,16 @@ # Note: This code needs to be harmonized with the plugin installer to have # the same target directory for all add-on specific dependencies. -candidates = [ - abspath(bpy.context.preferences.filepaths.script_directory), - Path(sys.modules[__name__].__file__).parent.parent, -] + +if bpy.app.version >= (3, 6, 0): + candidates = [ + abspath(script_directory.directory) + for script_directory in bpy.context.preferences.filepaths.script_directories + ] +else: + candidates = [abspath(bpy.context.preferences.filepaths.script_directory)] +candidates.append(Path(sys.modules[__name__].__file__).parent.parent) + for candidate in candidates: path = (Path(candidate) / "vendor" / "skybrush").resolve() if path.exists(): From 4c491dbba2346c29b5deaaec884214bfcc19486e Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Fri, 7 Jul 2023 11:09:07 +0200 Subject: [PATCH 12/22] chore: remove unused code --- .../sbstudio/plugin/operators/takeoff.py | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/src/modules/sbstudio/plugin/operators/takeoff.py b/src/modules/sbstudio/plugin/operators/takeoff.py index be19a593..0524c83f 100644 --- a/src/modules/sbstudio/plugin/operators/takeoff.py +++ b/src/modules/sbstudio/plugin/operators/takeoff.py @@ -77,41 +77,6 @@ class TakeoffOperator(StoryboardOperator): unit="LENGTH", ) - """ - delay = FloatProperty( - name="Delay", - description="Delay between takeoffs of consecutive drones in the takeoff sequence", - default=0, - min=0, - soft_min=0, - soft_max=5, - unit="TIME", - subtype="TIME", - ) - - order = EnumProperty( - name="Order", - description="Order of drones in the takeoff sequence", - items=( - ( - "DEFAULT", - "Default", - "Use the order in which the drones appear in the drone collection", - ), - ("NAME", "Name", "Sort the drones alphabetically"), - ("XY", "X axis first", "Sort the drones by X axis first, then by Y axis"), - ("YX", "Y axis first", "Sort the drones by Y axis first, then by X axis"), - ), - default="DEFAULT", - ) - - reverse_order = BoolProperty( - name="Reverse ordering", - description="Whether to reverse the ordering of the takeoff sequence", - default=False, - ) - """ - @classmethod def poll(cls, context): if not super().poll(context): From 806e51e0f33ad497e19b0ba692d879658bdd6220 Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Fri, 7 Jul 2023 11:43:26 +0200 Subject: [PATCH 13/22] feat: RTH helper formation is now layered if needed --- .../plugin/operators/return_to_home.py | 44 ++++++------ .../sbstudio/plugin/operators/takeoff.py | 67 +++++++++++-------- 2 files changed, 63 insertions(+), 48 deletions(-) diff --git a/src/modules/sbstudio/plugin/operators/return_to_home.py b/src/modules/sbstudio/plugin/operators/return_to_home.py index bb66d4c0..60fc1066 100644 --- a/src/modules/sbstudio/plugin/operators/return_to_home.py +++ b/src/modules/sbstudio/plugin/operators/return_to_home.py @@ -3,21 +3,18 @@ from bpy.props import FloatProperty, IntProperty from bpy.types import Context, Object from math import ceil, sqrt -from typing import List from sbstudio.plugin.constants import Collections from sbstudio.plugin.model.formation import create_formation -from sbstudio.plugin.utils.evaluator import create_position_evaluator from .base import StoryboardOperator +from .takeoff import create_helper_formation_for_takeoff_and_landing __all__ = ("ReturnToHomeOperator",) class ReturnToHomeOperator(StoryboardOperator): - """Blender operator that adds a return-to-home transition to the show, starting at - the current frame. - """ + """Blender operator that adds a return-to-home transition to the show.""" bl_idname = "skybrush.rth" bl_label = "Return Drones to Home Positions" @@ -49,6 +46,19 @@ class ReturnToHomeOperator(StoryboardOperator): unit="LENGTH", ) + altitude_shift = FloatProperty( + name="Layer height", + description=( + "Specifies the difference between altitudes of landing layers " + "for multi-phase landings when multiple drones occupy the same " + "slot within safety distance." + ), + default=5, + soft_min=0, + soft_max=50, + unit="LENGTH", + ) + @classmethod def poll(cls, context): if not super().poll(context): @@ -75,14 +85,14 @@ def _run(self, storyboard, *, context) -> bool: if not drones: return False - self._sort_drones(drones) - - # Prepare the points of the target formation to move to first_frame = storyboard.frame_start - with create_position_evaluator() as get_positions_of: - source = get_positions_of(drones, frame=first_frame) - - target = [(x, y, self.altitude) for x, y, z in source] + source, target = create_helper_formation_for_takeoff_and_landing( + drones, + frame=first_frame, + base_altitude=self.altitude, + layer_height=self.altitude_shift, + min_distance=context.scene.skybrush.safety_check.proximity_warning_threshold, + ) diffs = [ sqrt((s[0] - t[0]) ** 2 + (s[1] - t[1]) ** 2 + (s[2] - t[2]) ** 2) @@ -116,13 +126,6 @@ def _run(self, storyboard, *, context) -> bool: bpy.ops.skybrush.recalculate_transitions(scope="TO_SELECTED") return True - def _sort_drones(self, drones: List[Object]): - """Sorts the given list of drones in-place according to the order - specified by the user in this operator. - """ - # TODO(ntamas): add support for ordering the drones - pass - def _validate_start_frame(self, context: Context) -> bool: """Returns whether the return to home time chosen by the user is valid.""" storyboard = context.scene.skybrush.storyboard @@ -138,7 +141,8 @@ def _validate_start_frame(self, context: Context) -> bool: if last_frame is not None and self.start_frame < last_frame: self.report( {"ERROR"}, - f"Return to home maneuver must not start before the last entry of the storyboard (frame {last_frame})", + f"Return to home maneuver must not start before the last entry " + f"of the storyboard (frame {last_frame})", ) return False diff --git a/src/modules/sbstudio/plugin/operators/takeoff.py b/src/modules/sbstudio/plugin/operators/takeoff.py index 0524c83f..043bf264 100644 --- a/src/modules/sbstudio/plugin/operators/takeoff.py +++ b/src/modules/sbstudio/plugin/operators/takeoff.py @@ -102,28 +102,13 @@ def _run(self, storyboard: Storyboard, *, context) -> bool: if not drones: return False - self._sort_drones(drones) - - # Evaluate the initial positions of the drones - with create_position_evaluator() as get_positions_of: - source = get_positions_of(drones, frame=self.start_frame) - - # Figure out how many phases we will need, based on the current safety - # threshold and the arrangement of the drones - min_distance: float = ( - context.scene.skybrush.safety_check.proximity_warning_threshold - ) - groups = get_api().decompose_points( - source, min_distance=min_distance, method="balanced" + source, target = create_helper_formation_for_takeoff_and_landing( + drones, + frame=self.start_frame, + base_altitude=self.altitude, + layer_height=self.altitude_shift, + min_distance=context.scene.skybrush.safety_check.proximity_warning_threshold, ) - num_groups = max(groups) + 1 - - # Prepare the points of the target formation to take off to - altitude_shift_per_group = self.altitude_shift - target = [ - (x, y, self.altitude + (num_groups - group - 1) * altitude_shift_per_group) - for (x, y, _), group in zip(source, groups) - ] # Calculate the Z distance to travel for each drone diffs = [t[2] - s[2] for s, t in zip(source, target)] @@ -183,13 +168,6 @@ def _run(self, storyboard: Storyboard, *, context) -> bool: return True - def _sort_drones(self, drones: List[Object]): - """Sorts the given list of drones in-place according to the order - specified by the user in this operator. - """ - # TODO(ntamas): add support for ordering the drones - pass - def _validate_start_frame(self, context: Context) -> bool: """Returns whether the takeoff time chosen by the user is valid.""" storyboard = context.scene.skybrush.storyboard @@ -212,3 +190,36 @@ def _validate_start_frame(self, context: Context) -> bool: return False return True + + +def create_helper_formation_for_takeoff_and_landing( + drones, + *, + frame: int, + base_altitude: float, + layer_height: float, + min_distance: float, +): + """Creates a layer helper formation for takeoff and landing where the drones + are placed directly above their positions at the given frame, at the given + base altitude plus an altitude shift per layer to ensure minimum distance + constraints. + """ + # Evaluate the initial positions of the drones + with create_position_evaluator() as get_positions_of: + source = get_positions_of(drones, frame=frame) + + # Figure out how many phases we will need, based on the current safety + # threshold and the arrangement of the drones + groups = get_api().decompose_points( + source, min_distance=min_distance, method="balanced" + ) + num_groups = max(groups) + 1 + + # Prepare the points of the target formation to take off to + target = [ + (x, y, base_altitude + (num_groups - group - 1) * layer_height) + for (x, y, _), group in zip(source, groups) + ] + + return source, target From 680a8ce33852f6e242d2f082310be6debbfcae70 Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Sat, 8 Jul 2023 17:26:53 +0200 Subject: [PATCH 14/22] refactor: remove dead code --- src/modules/sbstudio/plugin/operators/land.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/modules/sbstudio/plugin/operators/land.py b/src/modules/sbstudio/plugin/operators/land.py index 0bbffe85..9f0d9b5e 100644 --- a/src/modules/sbstudio/plugin/operators/land.py +++ b/src/modules/sbstudio/plugin/operators/land.py @@ -75,8 +75,6 @@ def _run(self, storyboard, *, context) -> bool: if not drones: return False - self._sort_drones(drones) - # Prepare the points of the target formation to land to with create_position_evaluator() as get_positions_of: source = get_positions_of(drones, frame=self.start_frame) @@ -120,13 +118,6 @@ def _run(self, storyboard, *, context) -> bool: bpy.ops.skybrush.recalculate_transitions(scope="TO_SELECTED") return True - def _sort_drones(self, drones: List[Object]): - """Sorts the given list of drones in-place according to the order - specified by the user in this operator. - """ - # TODO(ntamas): add support for ordering the drones - pass - def _validate_start_frame(self, context: Context) -> bool: """Returns whether the takeoff time chosen by the user is valid.""" storyboard = context.scene.skybrush.storyboard From 1ec4dd42e06af97ebffcc160068ebaae3ecd498e Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Sun, 9 Jul 2023 12:25:14 +0200 Subject: [PATCH 15/22] chore: line wrapping --- .../plugin/operators/recalculate_transitions.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/modules/sbstudio/plugin/operators/recalculate_transitions.py b/src/modules/sbstudio/plugin/operators/recalculate_transitions.py index 0ad45e06..90a293e1 100644 --- a/src/modules/sbstudio/plugin/operators/recalculate_transitions.py +++ b/src/modules/sbstudio/plugin/operators/recalculate_transitions.py @@ -723,7 +723,10 @@ class RecalculateTransitionsOperator(StoryboardOperator): ), ], name="Scope", - description="Scope of the operator that defines which transitions must be recalculated", + description=( + "Scope of the operator that defines which transitions must be " + "recalculated" + ), default="ALL", ) @@ -762,7 +765,10 @@ def execute_on_storyboard(self, storyboard: Storyboard, entries, context): except SkybrushStudioAPIError: self.report( {"ERROR"}, - "Error while invoking transition planner on the Skybrush Studio online service", + ( + "Error while invoking transition planner on the Skybrush " + "Studio online service" + ), ) return {"CANCELLED"} From 7c53783a75f07f4580c1b2f61b8e1381b6f38b3f Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Sun, 9 Jul 2023 12:26:00 +0200 Subject: [PATCH 16/22] feat: create_helper_formation_for_takeoff_and_landing() now returns the group assignment --- src/modules/sbstudio/plugin/operators/return_to_home.py | 2 +- src/modules/sbstudio/plugin/operators/takeoff.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/modules/sbstudio/plugin/operators/return_to_home.py b/src/modules/sbstudio/plugin/operators/return_to_home.py index 60fc1066..6db9b64f 100644 --- a/src/modules/sbstudio/plugin/operators/return_to_home.py +++ b/src/modules/sbstudio/plugin/operators/return_to_home.py @@ -86,7 +86,7 @@ def _run(self, storyboard, *, context) -> bool: return False first_frame = storyboard.frame_start - source, target = create_helper_formation_for_takeoff_and_landing( + source, target, _ = create_helper_formation_for_takeoff_and_landing( drones, frame=first_frame, base_altitude=self.altitude, diff --git a/src/modules/sbstudio/plugin/operators/takeoff.py b/src/modules/sbstudio/plugin/operators/takeoff.py index 043bf264..3a52a117 100644 --- a/src/modules/sbstudio/plugin/operators/takeoff.py +++ b/src/modules/sbstudio/plugin/operators/takeoff.py @@ -102,7 +102,7 @@ def _run(self, storyboard: Storyboard, *, context) -> bool: if not drones: return False - source, target = create_helper_formation_for_takeoff_and_landing( + source, target, _ = create_helper_formation_for_takeoff_and_landing( drones, frame=self.start_frame, base_altitude=self.altitude, @@ -151,6 +151,7 @@ def _run(self, storyboard: Storyboard, *, context) -> bool: select=True, context=context, ) + entry.transition_type = "MANUAL" # Set up the custom departure delays for the drones if delays and max(delays) > 0: @@ -204,6 +205,10 @@ def create_helper_formation_for_takeoff_and_landing( are placed directly above their positions at the given frame, at the given base altitude plus an altitude shift per layer to ensure minimum distance constraints. + + Returns: + the source points, the target points, and the assignment of target + points to layers (layer 0 being at the lowest altitude) """ # Evaluate the initial positions of the drones with create_position_evaluator() as get_positions_of: @@ -222,4 +227,4 @@ def create_helper_formation_for_takeoff_and_landing( for (x, y, _), group in zip(source, groups) ] - return source, target + return source, target, groups From 2f3c939626a1786ef217a5dbd49af7174c067a4b Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Sun, 9 Jul 2023 12:26:22 +0200 Subject: [PATCH 17/22] feat: landing redesigned to use phases if needed --- src/modules/sbstudio/api/base.py | 41 +++++++++++ src/modules/sbstudio/plugin/operators/land.py | 68 +++++++++++++++---- 2 files changed, 97 insertions(+), 12 deletions(-) diff --git a/src/modules/sbstudio/api/base.py b/src/modules/sbstudio/api/base.py index 76e3b249..750d8c8e 100644 --- a/src/modules/sbstudio/api/base.py +++ b/src/modules/sbstudio/api/base.py @@ -472,6 +472,47 @@ def match_points( return result.get("mapping"), result.get("clearance") + def plan_landing( + self, + points: Sequence[Coordinate3D], + *, + min_distance: float, + velocity: float, + target_altitude: float = 0, + spindown_time: float = 5, + ) -> Tuple[List[int], List[int]]: + """Plans the landing trajectories for a set of drones, assuming that + they should maintain a given minimum distance while the motors are + running and that they land with constant speed. + + Arguments: + points: coordinates of the drones to land + min_distance: minimum distance to maintain while the motors are + running + velocity: average vertical velocity during landing + target_altitude: altitude to land the drones to + spindown_time: number of seconds it takes for the motors to + shut down after a successful landing + + Returns: + the start times and durations of the landing operation for each drone + """ + data = { + "version": 1, + "points": points, + "min_distance": float(min_distance), + "velocity": float(velocity), + "target_altitude": float(target_altitude), + "spindown_time": float(spindown_time), + } + with self._send_request("operations/plan-landing", data) as response: + result = response.as_json() + + if result.get("version") != 1: + raise SkybrushStudioAPIError("invalid response version") + + return result["start_times"], result["durations"] + def plan_transition( self, source: Sequence[Coordinate3D], diff --git a/src/modules/sbstudio/plugin/operators/land.py b/src/modules/sbstudio/plugin/operators/land.py index 9f0d9b5e..36e1464a 100644 --- a/src/modules/sbstudio/plugin/operators/land.py +++ b/src/modules/sbstudio/plugin/operators/land.py @@ -1,10 +1,11 @@ +from math import ceil, floor + import bpy from bpy.props import FloatProperty, IntProperty -from bpy.types import Context, Object -from math import ceil -from typing import List +from bpy.types import Context +from sbstudio.plugin.api import get_api from sbstudio.plugin.constants import Collections from sbstudio.plugin.model.formation import create_formation from sbstudio.plugin.utils.evaluator import create_position_evaluator @@ -49,6 +50,18 @@ class LandOperator(StoryboardOperator): unit="LENGTH", ) + spindown_time = FloatProperty( + name="Motor spindown delay (sec)", + description=( + "Time it takes for the motors to spin down after a successful landing" + ), + default=5, + min=0, + soft_min=0, + soft_max=10, + unit="TIME", + ) + @classmethod def poll(cls, context): if not super().poll(context): @@ -75,12 +88,14 @@ def _run(self, storyboard, *, context) -> bool: if not drones: return False - # Prepare the points of the target formation to land to + # Get the current positions of the drones to land with create_position_evaluator() as get_positions_of: source = get_positions_of(drones, frame=self.start_frame) - target = [(x, y, self.altitude) for x, y, z in source] + # Construct the target formation as well + target = [(x, y, self.altitude) for x, y, _ in source] + # Calculate the Z distance to travel for each drone diffs = [s[2] - t[2] for s, t in zip(source, target)] if min(diffs) < 0: dist = abs(min(diffs)) @@ -90,11 +105,26 @@ def _run(self, storyboard, *, context) -> bool: ) return False - # Calculate landing duration from max distance to travel and the - # average velocity - max_distance = max(diffs) + # Ask the API to figure out the start times and durations for each drone + # TODO(ntamas): spindown time! fps = context.scene.render.fps - landing_duration = ceil((max_distance / self.velocity) * fps) + min_distance = context.scene.skybrush.safety_check.proximity_warning_threshold + delays, durations = get_api().plan_landing( + source, + min_distance=min_distance, + velocity=self.velocity, + target_altitude=self.altitude, + spindown_time=self.spindown_time, + ) + delays = [int(ceil(delay * fps)) for delay in delays] + durations = [int(floor(duration * fps)) for duration in durations] + max_duration = max( + delay + duration for delay, duration in zip(delays, durations) + ) + post_delays = [ + max_duration - delay - duration + for delay, duration in zip(delays, durations) + ] # Extend the duration of the last formation to the frame where we want # to start the landing @@ -103,16 +133,27 @@ def _run(self, storyboard, *, context) -> bool: last_entry.extend_until(self.start_frame) # Calculate when the landing should end - end_of_landing = self.start_frame + landing_duration + end_of_landing = self.start_frame + max_duration # Add a new storyboard entry with the given formation - storyboard.add_new_entry( + entry = storyboard.add_new_entry( formation=create_formation("Landing", target), frame_start=end_of_landing, duration=0, select=True, context=context, ) + entry.transition_type = "MANUAL" + + # Set up the custom departure delays for the drones + if max(delays) > 0 or max(post_delays) > 0: + entry.schedule_overrides_enabled = True + for index, (delay, post_delay) in enumerate(zip(delays, post_delays)): + if delay > 0 or post_delay > 0: + override = entry.add_new_schedule_override() + override.index = index + override.pre_delay = delay + override.post_delay = post_delay # Recalculate the transition leading to the target formation bpy.ops.skybrush.recalculate_transitions(scope="TO_SELECTED") @@ -133,7 +174,10 @@ def _validate_start_frame(self, context: Context) -> bool: if last_frame is not None and self.start_frame < last_frame: self.report( {"ERROR"}, - f"Landing maneuver must not start before the last entry of the storyboard (frame {last_frame})", + ( + f"Landing maneuver must not start before the last entry of " + f"the storyboard (frame {last_frame})" + ), ) return False From 038a7a3bad1ab3feb40024a0fb8af2d6e70dc28e Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Sun, 9 Jul 2023 12:30:59 +0200 Subject: [PATCH 18/22] refactor: one single function to retrieve the current proximity warning threshold --- src/modules/sbstudio/plugin/model/safety_check.py | 7 +++++++ src/modules/sbstudio/plugin/operators/land.py | 4 ++-- .../sbstudio/plugin/operators/reorder_formation_markers.py | 5 ++--- src/modules/sbstudio/plugin/operators/return_to_home.py | 3 ++- src/modules/sbstudio/plugin/operators/takeoff.py | 4 ++-- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/modules/sbstudio/plugin/model/safety_check.py b/src/modules/sbstudio/plugin/model/safety_check.py index 342f8e17..4c482882 100644 --- a/src/modules/sbstudio/plugin/model/safety_check.py +++ b/src/modules/sbstudio/plugin/model/safety_check.py @@ -470,3 +470,10 @@ def _refresh_overlay(self) -> None: ) overlay.markers = markers + + +def get_proximity_warning_threshold(context: Context) -> float: + """Shortcut to return the current proximity warning threshold, irrespectively + of whether proximity warnings are enabled or not. + """ + return float(context.scene.skybrush.safety_check.proximity_warning_threshold) diff --git a/src/modules/sbstudio/plugin/operators/land.py b/src/modules/sbstudio/plugin/operators/land.py index 36e1464a..8359eb41 100644 --- a/src/modules/sbstudio/plugin/operators/land.py +++ b/src/modules/sbstudio/plugin/operators/land.py @@ -8,6 +8,7 @@ from sbstudio.plugin.api import get_api from sbstudio.plugin.constants import Collections from sbstudio.plugin.model.formation import create_formation +from sbstudio.plugin.model.safety_check import get_proximity_warning_threshold from sbstudio.plugin.utils.evaluator import create_position_evaluator from .base import StoryboardOperator @@ -108,10 +109,9 @@ def _run(self, storyboard, *, context) -> bool: # Ask the API to figure out the start times and durations for each drone # TODO(ntamas): spindown time! fps = context.scene.render.fps - min_distance = context.scene.skybrush.safety_check.proximity_warning_threshold delays, durations = get_api().plan_landing( source, - min_distance=min_distance, + min_distance=get_proximity_warning_threshold(context), velocity=self.velocity, target_altitude=self.altitude, spindown_time=self.spindown_time, diff --git a/src/modules/sbstudio/plugin/operators/reorder_formation_markers.py b/src/modules/sbstudio/plugin/operators/reorder_formation_markers.py index b469d56c..2ee9f90b 100644 --- a/src/modules/sbstudio/plugin/operators/reorder_formation_markers.py +++ b/src/modules/sbstudio/plugin/operators/reorder_formation_markers.py @@ -8,6 +8,7 @@ from bpy.props import EnumProperty +from sbstudio.plugin.model.safety_check import get_proximity_warning_threshold from sbstudio.plugin.utils.collections import sort_collection from sbstudio.plugin.utils.evaluator import create_position_evaluator @@ -132,9 +133,7 @@ def _execute_on_formation_ENSURE_SAFETY_DISTANCE( skipped: List[int] = [] result: List[int] = [] - dist_threshold: float = ( - context.scene.skybrush.safety_check.proximity_warning_threshold - ) + dist_threshold: float = get_proximity_warning_threshold(context) while queue: # Reset the mask diff --git a/src/modules/sbstudio/plugin/operators/return_to_home.py b/src/modules/sbstudio/plugin/operators/return_to_home.py index 6db9b64f..b1939a7e 100644 --- a/src/modules/sbstudio/plugin/operators/return_to_home.py +++ b/src/modules/sbstudio/plugin/operators/return_to_home.py @@ -6,6 +6,7 @@ from sbstudio.plugin.constants import Collections from sbstudio.plugin.model.formation import create_formation +from sbstudio.plugin.model.safety_check import get_proximity_warning_threshold from .base import StoryboardOperator from .takeoff import create_helper_formation_for_takeoff_and_landing @@ -91,7 +92,7 @@ def _run(self, storyboard, *, context) -> bool: frame=first_frame, base_altitude=self.altitude, layer_height=self.altitude_shift, - min_distance=context.scene.skybrush.safety_check.proximity_warning_threshold, + min_distance=get_proximity_warning_threshold(context), ) diffs = [ diff --git a/src/modules/sbstudio/plugin/operators/takeoff.py b/src/modules/sbstudio/plugin/operators/takeoff.py index 3a52a117..923eb69a 100644 --- a/src/modules/sbstudio/plugin/operators/takeoff.py +++ b/src/modules/sbstudio/plugin/operators/takeoff.py @@ -3,11 +3,11 @@ from bpy.props import BoolProperty, FloatProperty, IntProperty from bpy.types import Context, Object from math import ceil -from typing import List from sbstudio.plugin.api import get_api from sbstudio.plugin.constants import Collections from sbstudio.plugin.model.formation import create_formation +from sbstudio.plugin.model.safety_check import get_proximity_warning_threshold from sbstudio.plugin.model.storyboard import Storyboard from sbstudio.plugin.utils.evaluator import create_position_evaluator @@ -107,7 +107,7 @@ def _run(self, storyboard: Storyboard, *, context) -> bool: frame=self.start_frame, base_altitude=self.altitude, layer_height=self.altitude_shift, - min_distance=context.scene.skybrush.safety_check.proximity_warning_threshold, + min_distance=get_proximity_warning_threshold(context), ) # Calculate the Z distance to travel for each drone From b2d2b415dc25529c1620144d59f03afda3f29aeb Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Sun, 9 Jul 2023 12:31:47 +0200 Subject: [PATCH 19/22] chore: fix line length --- src/modules/sbstudio/plugin/operators/takeoff.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/modules/sbstudio/plugin/operators/takeoff.py b/src/modules/sbstudio/plugin/operators/takeoff.py index 923eb69a..ba119e5f 100644 --- a/src/modules/sbstudio/plugin/operators/takeoff.py +++ b/src/modules/sbstudio/plugin/operators/takeoff.py @@ -178,7 +178,10 @@ def _validate_start_frame(self, context: Context) -> bool: if self.start_frame < frame: self.report( {"ERROR"}, - f"Takeoff maneuver must start after the first (takeoff grid) entry of the storyboard (frame {frame})", + ( + f"Takeoff maneuver must start after the first (takeoff " + f"grid) entry of the storyboard (frame {frame})" + ), ) return False if len(storyboard.entries) > 1: @@ -186,7 +189,10 @@ def _validate_start_frame(self, context: Context) -> bool: if frame is not None and self.start_frame >= frame: self.report( {"ERROR"}, - f"Takeoff maneuver must start before the second entry of the storyboard (frame {frame})", + ( + f"Takeoff maneuver must start before the second " + f"entry of the storyboard (frame {frame})" + ), ) return False From 4959e8801a8211625c7c0676cad409af22423a18 Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Sun, 9 Jul 2023 12:32:27 +0200 Subject: [PATCH 20/22] chore: add ruff configuration --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 86c683f1..8086669b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,11 @@ standalone = ["skybrush-studio", "svgpathtools"] [tool.poetry.group.dev.dependencies] pyclean = "^2.0.0" +[tool.ruff] +ignore = ["B905", "C901", "E402", "E501"] +line-length = 80 +select = ["B", "C", "E", "F", "W"] + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" From 16e3ddf1503a152bbaa99ca498bff061830a6e12 Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Sun, 9 Jul 2023 12:43:25 +0200 Subject: [PATCH 21/22] fix: do not call API during takeoff and landing for simple situations --- src/modules/sbstudio/plugin/operators/land.py | 24 ++++++++++++------- .../sbstudio/plugin/operators/takeoff.py | 15 ++++++++---- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/modules/sbstudio/plugin/operators/land.py b/src/modules/sbstudio/plugin/operators/land.py index 8359eb41..5d555b1f 100644 --- a/src/modules/sbstudio/plugin/operators/land.py +++ b/src/modules/sbstudio/plugin/operators/land.py @@ -5,6 +5,7 @@ from bpy.props import FloatProperty, IntProperty from bpy.types import Context +from sbstudio.math.nearest_neighbors import find_nearest_neighbors from sbstudio.plugin.api import get_api from sbstudio.plugin.constants import Collections from sbstudio.plugin.model.formation import create_formation @@ -107,15 +108,22 @@ def _run(self, storyboard, *, context) -> bool: return False # Ask the API to figure out the start times and durations for each drone - # TODO(ntamas): spindown time! fps = context.scene.render.fps - delays, durations = get_api().plan_landing( - source, - min_distance=get_proximity_warning_threshold(context), - velocity=self.velocity, - target_altitude=self.altitude, - spindown_time=self.spindown_time, - ) + min_distance = get_proximity_warning_threshold(context) + _, _, dist = find_nearest_neighbors(target) + if dist < min_distance: + delays, durations = get_api().plan_landing( + source, + min_distance=min_distance, + velocity=self.velocity, + target_altitude=self.altitude, + spindown_time=self.spindown_time, + ) + else: + # We can save an API call here + delays = [0] * len(source) + durations = [diff / self.velocity for diff in diffs] + delays = [int(ceil(delay * fps)) for delay in delays] durations = [int(floor(duration * fps)) for duration in durations] max_duration = max( diff --git a/src/modules/sbstudio/plugin/operators/takeoff.py b/src/modules/sbstudio/plugin/operators/takeoff.py index ba119e5f..8760532a 100644 --- a/src/modules/sbstudio/plugin/operators/takeoff.py +++ b/src/modules/sbstudio/plugin/operators/takeoff.py @@ -4,6 +4,7 @@ from bpy.types import Context, Object from math import ceil +from sbstudio.math.nearest_neighbors import find_nearest_neighbors from sbstudio.plugin.api import get_api from sbstudio.plugin.constants import Collections from sbstudio.plugin.model.formation import create_formation @@ -222,10 +223,16 @@ def create_helper_formation_for_takeoff_and_landing( # Figure out how many phases we will need, based on the current safety # threshold and the arrangement of the drones - groups = get_api().decompose_points( - source, min_distance=min_distance, method="balanced" - ) - num_groups = max(groups) + 1 + _, _, dist = find_nearest_neighbors(source) + if dist < min_distance: + groups = get_api().decompose_points( + source, min_distance=min_distance, method="balanced" + ) + else: + # We can save an API call here + groups = [0] * len(source) + + num_groups = max(groups) + 1 if groups else 0 # Prepare the points of the target formation to take off to target = [ From 374829451bed4edb87cd5c21cfdcc24ed06b4c81 Mon Sep 17 00:00:00 2001 From: Tamas Nepusz Date: Sun, 9 Jul 2023 20:33:33 +0200 Subject: [PATCH 22/22] fix: fix RTH transition duration --- src/modules/sbstudio/plugin/operators/return_to_home.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modules/sbstudio/plugin/operators/return_to_home.py b/src/modules/sbstudio/plugin/operators/return_to_home.py index b1939a7e..c74723ec 100644 --- a/src/modules/sbstudio/plugin/operators/return_to_home.py +++ b/src/modules/sbstudio/plugin/operators/return_to_home.py @@ -7,6 +7,7 @@ from sbstudio.plugin.constants import Collections from sbstudio.plugin.model.formation import create_formation from sbstudio.plugin.model.safety_check import get_proximity_warning_threshold +from sbstudio.plugin.utils.evaluator import create_position_evaluator from .base import StoryboardOperator from .takeoff import create_helper_formation_for_takeoff_and_landing @@ -86,8 +87,11 @@ def _run(self, storyboard, *, context) -> bool: if not drones: return False + with create_position_evaluator() as get_positions_of: + source = get_positions_of(drones, frame=self.start_frame) + first_frame = storyboard.frame_start - source, target, _ = create_helper_formation_for_takeoff_and_landing( + _, target, _ = create_helper_formation_for_takeoff_and_landing( drones, frame=first_frame, base_altitude=self.altitude,