diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8252f80..225cce460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 its layer; generated content like the Material Test Grid restricts its step to the workpiece it creates, and that selection is now visible instead of hidden +- Vector editing: edit the nodes of imported paths: drag nodes and curve + handles, delete and insert nodes, and turn segments into curves or + lines (#521) ### Changed diff --git a/pixi.lock b/pixi.lock index b89d44d46..cd693c71f 100644 --- a/pixi.lock +++ b/pixi.lock @@ -244,6 +244,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/18/42/a945f65cc61c739ec80f4112c4b78ed1791f25d33f45f19389c9c9e247e2/pypdf-6.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3b/b2/a88e91e411f5b77f8d115c4e3852503a4bb0356af0ec87c1c8f026319d40/raygeo-1.67.0.tar.gz - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl @@ -253,7 +254,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/65/a9/55effa83e64b9ff4f379d9186236d50d03f6d4770d8346805c1b6620c370/fonttools-4.66.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/6f/34/8a43ea689fb44533a55dfebb7c421361cca038d72db69f09b89fa76a3f36/raygeo-1.66.0.tar.gz - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/c9/ddb896767808e3e022453d8dfae26fd52ed404b0aa6fb7f752d39c040208/msgpack-1.2.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -466,7 +466,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zlib-1.3.2-h8088a28_3.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - - pypi: https://files.pythonhosted.org/packages/02/fc/06c7952ef808bcfb1ab730d464a20fc8f1225d502b960320e9d4fe39344b/raygeo-1.66.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -480,6 +479,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/18/42/a945f65cc61c739ec80f4112c4b78ed1791f25d33f45f19389c9c9e247e2/pypdf-6.20.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/29/5c/969a1b72692055fd2a419590c41847ec9144fefb97b75ff1cde5b6372891/aiohttp-3.14.5-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/39/1d/00ab27d8ad18d8ac185d02fb3b969ce9d709126730580ad807e597e692db/raygeo-1.67.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl @@ -921,6 +921,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/3b/b2/a88e91e411f5b77f8d115c4e3852503a4bb0356af0ec87c1c8f026319d40/raygeo-1.67.0.tar.gz - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl @@ -936,7 +937,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/65/a9/55effa83e64b9ff4f379d9186236d50d03f6d4770d8346805c1b6620c370/fonttools-4.66.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/6f/34/8a43ea689fb44533a55dfebb7c421361cca038d72db69f09b89fa76a3f36/raygeo-1.66.0.tar.gz - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/80/c9/ddb896767808e3e022453d8dfae26fd52ed404b0aa6fb7f752d39c040208/msgpack-1.2.3-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -1167,7 +1167,6 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/02/fc/06c7952ef808bcfb1ab730d464a20fc8f1225d502b960320e9d4fe39344b/raygeo-1.66.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz @@ -1184,6 +1183,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/29/5c/969a1b72692055fd2a419590c41847ec9144fefb97b75ff1cde5b6372891/aiohttp-3.14.5-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/39/1d/00ab27d8ad18d8ac185d02fb3b969ce9d709126730580ad807e597e692db/raygeo-1.67.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/41/51/8041c2e70649e5b7f2a0aedbbbd0609ac099cfaa0cbde2014279c9c05756/cairosvg-2.9.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/44/b0/554d720d71083ccd24ba2f376c048544c073570e7bb18b34d769cef77f66/pyflakes-4.0.3-py2.py3-none-any.whl @@ -9494,7 +9494,7 @@ packages: name: rayforge requires_dist: - raydriver==0.5.1 - - raygeo==1.66.0 + - raygeo==1.67.0 - aiohttp==3.14.5 - asyncudp==0.11.0 - blinker==1.9.0 @@ -9531,26 +9531,6 @@ packages: name: distlib version: 0.4.3 sha256: 4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b -- pypi: https://files.pythonhosted.org/packages/02/fc/06c7952ef808bcfb1ab730d464a20fc8f1225d502b960320e9d4fe39344b/raygeo-1.66.0-cp311-abi3-macosx_11_0_arm64.whl - name: raygeo - version: 1.66.0 - sha256: 1d6b1db07abff0dac5aeadd39836d549604362a9ac3bc09d268067410b02c1d5 - requires_dist: - - msgpack - - numpy>=1.20.0 - - matplotlib ; extra == 'cli' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/03/43/344fbf03470b38c11c334fd1ea9f12212036c93f64887d1f31672a95fd8d/ruida_pa-0.22.0-py3-none-any.whl name: ruida-pa version: 0.22.0 @@ -9716,6 +9696,46 @@ packages: - railroad-diagrams ; extra == 'diagrams' - jinja2 ; extra == 'diagrams' requires_python: '>=3.9' +- pypi: https://files.pythonhosted.org/packages/39/1d/00ab27d8ad18d8ac185d02fb3b969ce9d709126730580ad807e597e692db/raygeo-1.67.0-cp311-abi3-macosx_11_0_arm64.whl + name: raygeo + version: 1.67.0 + sha256: 7858332b3a580f28a8d8a62588d10792c7a6b193fc1b8fb17ed7cacacd08282b + requires_dist: + - msgpack + - numpy>=1.20.0 + - matplotlib ; extra == 'cli' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/3b/b2/a88e91e411f5b77f8d115c4e3852503a4bb0356af0ec87c1c8f026319d40/raygeo-1.67.0.tar.gz + name: raygeo + version: 1.67.0 + sha256: fbcea5dc62e66c7029d131e191c7b7dfe91351db28eb1a7c16a4c3677bf1a815 + requires_dist: + - msgpack + - numpy>=1.20.0 + - pycairo ; extra == 'test' + - pytest ; extra == 'test' + - pytest-mock ; extra == 'test' + - streamlit ; extra == 'test' + - streamlit ; extra == 'visual' + - matplotlib ; extra == 'visual' + - matplotlib ; extra == 'docs' + - mdformat ; extra == 'docs' + - mdformat-frontmatter ; extra == 'docs' + - mdformat-tables ; extra == 'docs' + - pillow ; extra == 'docs' + - matplotlib ; extra == 'cli' + requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl name: rpyc version: 6.0.2 @@ -9874,26 +9894,6 @@ packages: version: '17.2' sha256: 3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8 requires_python: '>=3.11' -- pypi: https://files.pythonhosted.org/packages/6f/34/8a43ea689fb44533a55dfebb7c421361cca038d72db69f09b89fa76a3f36/raygeo-1.66.0.tar.gz - name: raygeo - version: 1.66.0 - sha256: 4e48a8a4be98dbb32ca793a0f8e83063910ae2e3f9d7a8c6c28dc05cb0c2ebaa - requires_dist: - - msgpack - - numpy>=1.20.0 - - pycairo ; extra == 'test' - - pytest ; extra == 'test' - - pytest-mock ; extra == 'test' - - streamlit ; extra == 'test' - - streamlit ; extra == 'visual' - - matplotlib ; extra == 'visual' - - matplotlib ; extra == 'docs' - - mdformat ; extra == 'docs' - - mdformat-frontmatter ; extra == 'docs' - - mdformat-tables ; extra == 'docs' - - pillow ; extra == 'docs' - - matplotlib ; extra == 'cli' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl name: aiohappyeyeballs version: 2.7.1 diff --git a/pixi.toml b/pixi.toml index ef4ada3ad..cc1b89a78 100644 --- a/pixi.toml +++ b/pixi.toml @@ -38,7 +38,7 @@ rust = "==1.99.0" patchelf = "==0.19.2" [feature.app.pypi-dependencies] -raygeo = "==1.66.0" +raygeo = "==1.67.0" raydriver = "==0.5.1" rayforge = { editable = true, path = "." } ruida-pa = "==0.22.0" diff --git a/rayforge/core/undo/property_cmd.py b/rayforge/core/undo/property_cmd.py index 736336504..396bd75a9 100644 --- a/rayforge/core/undo/property_cmd.py +++ b/rayforge/core/undo/property_cmd.py @@ -18,9 +18,11 @@ def __init__( setter_method_name: str | None = None, on_change_callback: Callable[[], None] | None = None, name: str | None = None, + coalesce: bool = True, ): super().__init__(name, on_change_callback) self.target = target + self.coalesce = coalesce self.property_name = property_name self.new_value = new_value self.setter_method_name = setter_method_name @@ -54,6 +56,8 @@ def undo(self) -> None: def can_coalesce_with(self, next_command: Command) -> bool: return ( isinstance(next_command, ChangePropertyCommand) + and self.coalesce + and next_command.coalesce and self.target is next_command.target and self.property_name == next_command.property_name ) diff --git a/rayforge/doceditor/boundaries.py b/rayforge/doceditor/boundaries.py new file mode 100644 index 000000000..f3faa1cad --- /dev/null +++ b/rayforge/doceditor/boundaries.py @@ -0,0 +1,36 @@ +""" +Undoable replacement of a workpiece's edited boundaries, shared by the +commands that edit vector paths: segment and contour deletion, path +cleanup, and node editing. +""" + +from raygeo.geo import Geometry + +from ..core.undo import ChangePropertyCommand +from ..core.workpiece import WorkPiece + + +def edited_boundaries_command( + workpiece: WorkPiece, new_geo: Geometry, name: str +) -> ChangePropertyCommand: + """ + Returns an undoable command that replaces the edited boundaries of + *workpiece* with *new_geo*. Executing it clears the render caches + and sends the updated signal. Every boundary edit is a discrete + user action, so the command opts out of coalescing: two quick edits + stay two undo steps. + """ + + def _on_changed(): + workpiece.clear_render_cache() + workpiece.updated.send(workpiece) + + return ChangePropertyCommand( + target=workpiece, + property_name="_edited_boundaries", + new_value=new_geo, + old_value=workpiece._edited_boundaries, + on_change_callback=_on_changed, + name=name, + coalesce=False, + ) diff --git a/rayforge/doceditor/edit_cmd.py b/rayforge/doceditor/edit_cmd.py index 42e7111bf..8f1467fa6 100644 --- a/rayforge/doceditor/edit_cmd.py +++ b/rayforge/doceditor/edit_cmd.py @@ -19,6 +19,7 @@ ) from ..core.workflow import Workflow from ..core.workpiece import WorkPiece +from .boundaries import edited_boundaries_command if TYPE_CHECKING: from ..core.asset import IAsset @@ -345,23 +346,10 @@ def delete_contours( for contour in remaining: new_geo.extend(contour) - old_value = workpiece._edited_boundaries - - def _on_changed(): - workpiece.clear_render_cache() - workpiece.updated.send(workpiece) - - history = self._editor.history_manager - with history.transaction(_("Delete contour(s)")) as t: - cmd = ChangePropertyCommand( - target=workpiece, - property_name="_edited_boundaries", - new_value=new_geo, - old_value=old_value, - on_change_callback=_on_changed, - name=_("Delete contour(s)"), - ) - t.execute(cmd) + command = edited_boundaries_command( + workpiece, new_geo, _("Delete contour(s)") + ) + self._editor.history_manager.execute(command) def delete_segments( self, @@ -394,23 +382,10 @@ def delete_segments( keep = set(range(len(data))) - to_remove new_geo = boundaries.filter(keep) - old_value = workpiece._edited_boundaries - - def _on_changed(): - workpiece.clear_render_cache() - workpiece.updated.send(workpiece) - - history = self._editor.history_manager - with history.transaction(_("Delete segment(s)")) as t: - cmd = ChangePropertyCommand( - target=workpiece, - property_name="_edited_boundaries", - new_value=new_geo, - old_value=old_value, - on_change_callback=_on_changed, - name=_("Delete segment(s)"), - ) - t.execute(cmd) + command = edited_boundaries_command( + workpiece, new_geo, _("Delete segment(s)") + ) + self._editor.history_manager.execute(command) def add_geometry_provider_instance( self, diff --git a/rayforge/doceditor/editor.py b/rayforge/doceditor/editor.py index 41fc8405a..e9850bb23 100644 --- a/rayforge/doceditor/editor.py +++ b/rayforge/doceditor/editor.py @@ -26,6 +26,7 @@ from .group_cmd import GroupCmd from .layer_cmd import LayerCmd from .layout_cmd import LayoutCmd +from .node_edit_cmd import NodeEditCmd from .path_cleanup_cmd import PathCleanupCmd from .reload_cmd import ReloadCmd from .split_cmd import SplitCmd @@ -154,6 +155,7 @@ def __init__( self.layer = LayerCmd(self) self.layout = LayoutCmd(self, self.task_manager) self.path_cleanup = PathCleanupCmd(self) + self.node_edit = NodeEditCmd(self) self.reload = ReloadCmd(self) self.split = SplitCmd(self) self.stock = StockCmd(self) diff --git a/rayforge/doceditor/node_edit_cmd.py b/rayforge/doceditor/node_edit_cmd.py new file mode 100644 index 000000000..5ef6043c9 --- /dev/null +++ b/rayforge/doceditor/node_edit_cmd.py @@ -0,0 +1,98 @@ +""" +NodeEditCmd: undoable node edits on the vector paths of imported +workpieces (move, delete and insert nodes, line/curve, Bezier handles). + +The geometry work happens in raygeo; this command writes the result to +the workpiece's edited boundaries, as segment deletion does. Workpieces +drawn by a geometry provider (sketches) are left alone. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo import Geometry + +from ..core.workpiece import WorkPiece +from .boundaries import edited_boundaries_command + +if TYPE_CHECKING: + from .editor import DocEditor + + +class NodeEditCmd: + """Node edits on the boundaries of imported workpieces.""" + + def __init__(self, editor: DocEditor): + self._editor = editor + + def _apply( + self, + workpiece: WorkPiece, + name: str, + edit: Callable[[Geometry], tuple[Geometry, int]], + ) -> int | None: + """Runs *edit* on the boundaries and commits the result.""" + if workpiece.geometry_provider_uid: + return None + boundaries = workpiece.boundaries + if boundaries is None or boundaries.is_empty(): + return None + new_geo, index = edit(boundaries) + self._editor.history_manager.execute( + edited_boundaries_command(workpiece, new_geo, name) + ) + return index + + def move_node( + self, workpiece: WorkPiece, index: int, x: float, y: float + ) -> int | None: + """Moves the node at *index* to (x, y), in boundary space.""" + return self._apply( + workpiece, _("Move node"), lambda g: g.move_node(index, x, y) + ) + + def delete_node(self, workpiece: WorkPiece, index: int) -> int | None: + """Deletes the node at *index*, joining its neighbours.""" + return self._apply( + workpiece, _("Delete node"), lambda g: g.delete_node(index) + ) + + def insert_node( + self, workpiece: WorkPiece, index: int, t: float + ) -> int | None: + """Adds a node on the segment at *index*, at parameter *t*.""" + return self._apply( + workpiece, _("Insert node"), lambda g: g.insert_node(index, t) + ) + + def set_segments_curve( + self, workpiece: WorkPiece, indices: Iterable[int], curve: bool + ) -> int | None: + """Turns the segments at *indices* into curves or lines.""" + + def edit(geo: Geometry) -> tuple[Geometry, int]: + last = -1 + for index in sorted(indices, reverse=True): + geo, last = geo.set_segment_curve(index, curve) + return geo, last + + name = _("Make curve") if curve else _("Make line") + return self._apply(workpiece, name, edit) + + def move_handle( + self, + workpiece: WorkPiece, + index: int, + handle: int, + x: float, + y: float, + ) -> int | None: + """Moves handle 1 or 2 of the Bezier at *index* to (x, y).""" + return self._apply( + workpiece, + _("Move handle"), + lambda g: (g.move_handle(index, handle, x, y), index), + ) diff --git a/rayforge/doceditor/path_cleanup_cmd.py b/rayforge/doceditor/path_cleanup_cmd.py index 1ca509333..75033df4f 100644 --- a/rayforge/doceditor/path_cleanup_cmd.py +++ b/rayforge/doceditor/path_cleanup_cmd.py @@ -6,8 +6,9 @@ from raygeo.geo import Geometry, Matrix from ..core.item import DocItem -from ..core.undo import ChangePropertyCommand, ListItemCommand +from ..core.undo import ListItemCommand from ..core.workpiece import WorkPiece +from .boundaries import edited_boundaries_command from .split_cmd import ContourSplitStrategy if TYPE_CHECKING: @@ -134,7 +135,7 @@ def delete_duplicates( ) ) for workpiece, new_geo, count in changes: - t.execute(self._boundaries_command(workpiece, new_geo, name)) + t.execute(edited_boundaries_command(workpiece, new_geo, name)) removed += count return removed @@ -162,7 +163,7 @@ def _apply( total = 0 with self._editor.history_manager.transaction(name) as t: for workpiece, new_geo, count in changes: - t.execute(self._boundaries_command(workpiece, new_geo, name)) + t.execute(edited_boundaries_command(workpiece, new_geo, name)) total += count return total @@ -192,22 +193,6 @@ def _compute_changes( changes.append((workpiece, new_geo, count)) return changes - def _boundaries_command( - self, workpiece: WorkPiece, new_geo: Geometry, name: str - ) -> ChangePropertyCommand: - def _on_changed(): - workpiece.clear_render_cache() - workpiece.updated.send(workpiece) - - return ChangePropertyCommand( - target=workpiece, - property_name="_edited_boundaries", - new_value=new_geo, - old_value=workpiece._edited_boundaries, - on_change_callback=_on_changed, - name=name, - ) - def _find_duplicate_workpieces( self, workpieces: list[WorkPiece], tolerance_mm: float ) -> list[WorkPiece]: diff --git a/rayforge/ui_gtk/canvas/canvas.py b/rayforge/ui_gtk/canvas/canvas.py index 206fbf0a6..6258027c1 100644 --- a/rayforge/ui_gtk/canvas/canvas.py +++ b/rayforge/ui_gtk/canvas/canvas.py @@ -16,7 +16,7 @@ from raygeo.geo import Matrix from ...core.color import ColorRGBA -from ..shared.keyboard import is_primary_keyval +from ..shared.keyboard import is_primary_keyval, is_primary_modifier from . import transform from .cursor import get_cursor_for_region from .element import CanvasElement @@ -1357,6 +1357,8 @@ def on_key_pressed( elif is_primary_keyval(keyval): self._ctrl_pressed = True # Allow propagation for accelerators + elif self._edit_context_handles_key(keyval, state): + return True elif keyval in (Gdk.KEY_Delete, Gdk.KEY_BackSpace): if self.edit_context and self.edit_context.handle_edit_key(keyval): return True @@ -1368,6 +1370,13 @@ def on_key_pressed( return True return False + def _edit_context_handles_key( + self, keyval: int, state: Gdk.ModifierType + ) -> bool: + if not self.edit_context or is_primary_modifier(state): + return False + return self.edit_context.handle_edit_key(keyval) + def on_key_released( self, controller, keyval: int, keycode: int, state: Gdk.ModifierType ): diff --git a/rayforge/ui_gtk/canvas2d/elements/workpiece.py b/rayforge/ui_gtk/canvas2d/elements/workpiece.py index e6405ef3f..5c1aa1e8b 100644 --- a/rayforge/ui_gtk/canvas2d/elements/workpiece.py +++ b/rayforge/ui_gtk/canvas2d/elements/workpiece.py @@ -15,6 +15,18 @@ WorkPieceArtifact, ) from ...canvas import CanvasElement +from ..node_editing import ( + NODE_HIT_PX, + Handle, + NodeDrag, + draw_handles, + draw_nodes, + handle_positions, + nearest, + node_positions, + visible_handles, + visible_nodes, +) from ..ops_cache_registry import registry from .tab_handle import TabHandleElement @@ -77,6 +89,9 @@ def __init__(self, geometry: Geometry): self.geometry = geometry self.selected_segments: set[int] = set() self.hovered_segment: int | None = None + self.hovered_t: float = 0.5 + self.selected_node: int | None = None + self.drag: NodeDrag | None = None self.frame_start: tuple[float, float] | None = None self.frame_end: tuple[float, float] | None = None self.frame_drag_start_world: tuple[float, float] | None = None @@ -797,9 +812,41 @@ def draw_edit_overlay(self, ctx: cairo.Context): ctx.restore() + self._draw_nodes_and_handles(ctx, screen_transform) + if self._edit_state.frame_start and self._edit_state.frame_end: self._draw_selection_frame(ctx, screen_transform) + def _shown_nodes(self) -> list[int]: + state = self._edit_state + if not state: + return [] + return visible_nodes( + state.geometry, state.selected_segments, state.selected_node + ) + + def _shown_handles(self) -> list[Handle]: + state = self._edit_state + if not state: + return [] + return visible_handles( + state.geometry, state.selected_segments, state.selected_node + ) + + def _draw_nodes_and_handles(self, ctx: cairo.Context, screen_transform): + state = self._edit_state + if not state: + return + data = state.geometry.data + + def to_screen(point): + return screen_transform.transform_point(point) + + draw_handles(ctx, to_screen, self._shown_handles()) + draw_nodes( + ctx, to_screen, data, self._shown_nodes(), state.selected_node + ) + def _draw_selection_frame(self, ctx, screen_transform): if not self._edit_state: return @@ -821,6 +868,33 @@ def _draw_selection_frame(self, ctx, screen_transform): ctx.stroke() ctx.restore() + def _local_tolerance(self, pixels: float) -> tuple[float, float]: + """A screen distance in pixels, in boundary units per axis.""" + work_surface = cast("WorkSurface", self.canvas) + ppm_x, ppm_y = work_surface.get_view_scale() + world_w, world_h = self.data.size + if min(ppm_x, ppm_y, world_w, world_h) < 1e-9: + return (0.0, 0.0) + return (pixels / (ppm_x * world_w), pixels / (ppm_y * world_h)) + + def _hit_test_node_or_handle( + self, world_x: float, world_y: float + ) -> int | Handle | None: + local = self._world_to_local(world_x, world_y) + if not self._edit_state or not self.canvas or local is None: + return None + tolerance = self._local_tolerance(NODE_HIT_PX) + handle = nearest( + handle_positions(self._shown_handles()), local, tolerance + ) + if isinstance(handle, Handle): + return handle + data = self._edit_state.geometry.data + node = nearest( + node_positions(data, self._shown_nodes()), local, tolerance + ) + return node if isinstance(node, int) else None + def _hit_test_segment(self, world_x: float, world_y: float) -> int | None: if not self._edit_state or not self.canvas: return None @@ -847,10 +921,11 @@ def _hit_test_segment(self, world_x: float, world_y: float) -> int | None: if result is None: return None - seg_idx, _, closest_pt = result + seg_idx, t, closest_pt = result dx = local_x - closest_pt[0] dy = local_y - closest_pt[1] if dx * dx + dy * dy <= threshold_sq: + self._edit_state.hovered_t = t return seg_idx return None @@ -879,6 +954,12 @@ def handle_edit_press( if n_press >= 2: return False + target = self._hit_test_node_or_handle(world_x, world_y) + if target is not None: + self._start_node_drag(target) + return True + + self._edit_state.selected_node = None hit = self._hit_test_segment(world_x, world_y) if hit is None: @@ -906,9 +987,106 @@ def handle_edit_press( self.canvas.queue_draw() return True + def _start_node_drag(self, target: int | Handle) -> None: + state = self._edit_state + if not state: + return + if isinstance(target, Handle): + state.drag = NodeDrag( + target.index, state.geometry, handle=target.which + ) + else: + state.selected_node = target + state.drag = NodeDrag(target, state.geometry) + state.frame_drag_start_world = None + if self.canvas: + self.canvas.queue_draw() + + def _drag_point(self, world_dx: float, world_dy: float): + """The dragged point's new position in boundary space.""" + state = self._edit_state + if not state or not state.drag or not self.canvas: + return None + drag = state.drag + data = drag.original.data + cmd = data[drag.index] + if drag.handle == 1: + start = (cmd.control1[0], cmd.control1[1]) + elif drag.handle == 2: + start = (cmd.control2[0], cmd.control2[1]) + else: + start = (cmd.end[0], cmd.end[1]) + transform = self.get_world_transform() + wx, wy = transform.transform_point(start) + return self._world_to_local(wx + world_dx, wy + world_dy) + + def _preview_drag(self, world_dx: float, world_dy: float) -> None: + state = self._edit_state + point = self._drag_point(world_dx, world_dy) + if not state or not state.drag or point is None: + return + drag = state.drag + x, y = point + if drag.handle is None: + state.geometry, _ = drag.original.move_node(drag.index, x, y) + else: + state.geometry = drag.original.move_handle( + drag.index, drag.handle, x, y + ) + drag.moved = True + if self.canvas: + self.canvas.queue_draw() + + def _commit_drag(self) -> None: + state = self._edit_state + if not state or not state.drag: + return + drag = state.drag + state.drag = None + if not drag.moved: + return + cmd = state.geometry.data[drag.index] + node_edit = self._node_edit() + if drag.handle is None: + x, y = cmd.end[0], cmd.end[1] + index = node_edit.move_node(self.data, drag.index, x, y) + self._resnapshot(selected_node=index) + return + control = cmd.control1 if drag.handle == 1 else cmd.control2 + node_edit.move_handle( + self.data, drag.index, drag.handle, control[0], control[1] + ) + self._resnapshot(selected_node=state.selected_node) + + def _resnapshot( + self, selected_node: int | None = None, keep_segments: bool = True + ) -> None: + """Takes the edited boundaries as the new edit state.""" + boundaries = self.data.boundaries + if boundaries is None or boundaries.is_empty(): + self.clear_all_ops_caches() + if self.canvas: + self.canvas.leave_edit_mode() + return + selected: set[int] = set() + if keep_segments and self._edit_state: + selected = self._edit_state.selected_segments + self._edit_state = VectorEditState(boundaries) + data = boundaries.data + self._edit_state.selected_segments = { + i for i in selected if i < len(data) + } + if selected_node is not None and selected_node < len(data): + self._edit_state.selected_node = selected_node + if self.canvas: + self.canvas.queue_draw() + def handle_edit_drag(self, world_dx: float, world_dy: float): if not self._edit_state or not self.canvas: return + if self._edit_state.drag is not None: + self._preview_drag(world_dx, world_dy) + return if self._edit_state.frame_drag_start_world is None: return @@ -931,6 +1109,9 @@ def handle_edit_drag(self, world_dx: float, world_dy: float): def handle_edit_release(self, world_x: float, world_y: float): if not self._edit_state: return + if self._edit_state.drag is not None: + self._commit_drag() + return if self._edit_state.frame_start and self._edit_state.frame_end: x1, y1 = self._edit_state.frame_start x2, y2 = self._edit_state.frame_end @@ -965,27 +1146,60 @@ def handle_edit_motion(self, world_x: float, world_y: float) -> bool: def handle_edit_key(self, keyval: int) -> bool: if not self._edit_state: return False - + if keyval in (Gdk.KEY_i, Gdk.KEY_I): + self._insert_node_at_hover() + return True + if keyval in (Gdk.KEY_c, Gdk.KEY_C): + self._toggle_selected_curves() + return True if keyval in (Gdk.KEY_Delete, Gdk.KEY_BackSpace): - if not self._edit_state.selected_segments: - return True - work_surface = cast("WorkSurface", self.canvas) - work_surface.editor.edit.delete_segments( - self.data, self._edit_state.selected_segments - ) - boundaries = self.data.boundaries - if boundaries is None or boundaries.is_empty(): - self.clear_all_ops_caches() - if self.canvas: - self.canvas.leave_edit_mode() - return True - self._edit_state = VectorEditState(boundaries) - if self.canvas: - self.canvas.queue_draw() + self._delete_selection() return True - return False + def _node_edit(self): + return cast("WorkSurface", self.canvas).editor.node_edit + + def _insert_node_at_hover(self) -> None: + state = self._edit_state + if not state or state.hovered_segment is None: + return + index = self._node_edit().insert_node( + self.data, state.hovered_segment, state.hovered_t + ) + self._resnapshot(selected_node=index, keep_segments=False) + + def _delete_selection(self) -> None: + state = self._edit_state + if not state: + return + if state.selected_node is not None: + index = self._node_edit().delete_node( + self.data, state.selected_node + ) + self._resnapshot(selected_node=index, keep_segments=False) + return + if not state.selected_segments: + return + work_surface = cast("WorkSurface", self.canvas) + work_surface.editor.edit.delete_segments( + self.data, state.selected_segments + ) + self._resnapshot(keep_segments=False) + + def _toggle_selected_curves(self) -> None: + state = self._edit_state + if not state or not state.selected_segments: + return + data = state.geometry.data + curve = any( + not isinstance(data[i], Bezier) for i in state.selected_segments + ) + self._node_edit().set_segments_curve( + self.data, state.selected_segments, curve + ) + self._resnapshot() + def handle_edit_select_all(self) -> bool: if not self._edit_state: return False diff --git a/rayforge/ui_gtk/canvas2d/node_editing.py b/rayforge/ui_gtk/canvas2d/node_editing.py new file mode 100644 index 000000000..2736b72df --- /dev/null +++ b/rayforge/ui_gtk/canvas2d/node_editing.py @@ -0,0 +1,164 @@ +""" +Helpers for editing the nodes of a workpiece's vector path: which nodes +and Bezier handles are shown, hit testing them, and drawing them. + +Indices are command indices into the boundaries geometry; a node is the +end point of a command. In a closed contour the start and end node are +one node, shown once at its Move index. Positions are in boundary space +(0..1). +""" + +import math +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass + +import cairo +from raygeo.geo import Bezier, Geometry + +NODE_HIT_PX = 7.0 +NODE_SIZE_PX = 7.0 +HANDLE_SIZE_PX = 6.0 + +Point2 = tuple[float, float] +ToScreen = Callable[[Point2], Point2] + + +@dataclass(frozen=True) +class Handle: + """A Bezier handle: control point ``which`` (1 or 2) of ``index``.""" + + index: int + which: int + point: Point2 + anchor: Point2 + + +@dataclass +class NodeDrag: + """An in-progress drag of a node or a handle.""" + + index: int + original: Geometry + handle: int | None = None + moved: bool = False + + +def _end(cmd) -> Point2: + return (cmd.end[0], cmd.end[1]) + + +def visible_nodes( + geometry: Geometry, segments: Iterable[int], node: int | None +) -> list[int]: + """Nodes of every contour holding a selected segment or node.""" + anchors = set(segments) + if node is not None: + anchors.add(node) + infos = { + geometry.contour_info(i) + for i in anchors + if 0 <= i < len(geometry.data) + } + nodes: list[int] = [] + for start, end, closed in sorted(infos): + nodes.extend(range(start, end) if closed else range(start, end + 1)) + return nodes + + +def visible_handles( + geometry: Geometry, segments: Iterable[int], node: int | None +) -> list[Handle]: + """Handles of the selected Beziers and of those meeting at the node.""" + data = geometry.data + indices = set(segments) + if node is not None: + indices.update((node, node + 1)) + if 0 <= node < len(data): + start, end, closed = geometry.contour_info(node) + if closed and node in (start, end): + indices.update((start + 1, end)) + handles = [] + for index in sorted(indices): + if not 0 < index < len(data): + continue + cmd = data[index] + if not isinstance(cmd, Bezier): + continue + node_start = _end(data[index - 1]) + c1 = (cmd.control1[0], cmd.control1[1]) + c2 = (cmd.control2[0], cmd.control2[1]) + handles.append(Handle(index, 1, c1, node_start)) + handles.append(Handle(index, 2, c2, _end(cmd))) + return handles + + +def nearest( + candidates: Iterable[tuple[object, Point2]], + local: Point2, + tolerance: Point2, +) -> object | None: + """The key of the closest candidate within an elliptic tolerance.""" + tx, ty = tolerance + if tx <= 0 or ty <= 0: + return None + best, best_distance = None, 1.0 + for key, (x, y) in candidates: + distance = ((x - local[0]) / tx) ** 2 + ((y - local[1]) / ty) ** 2 + if distance <= best_distance: + best, best_distance = key, distance + return best + + +def node_positions(data: Sequence, nodes: Iterable[int]): + """(index, position) pairs for hit testing nodes.""" + return [(i, _end(data[i])) for i in nodes] + + +def handle_positions(handles: Iterable[Handle]): + """(handle, position) pairs for hit testing handles.""" + return [(h, h.point) for h in handles] + + +def draw_handles( + ctx: cairo.Context, to_screen: ToScreen, handles: Iterable[Handle] +) -> None: + """Handle lines and round handle ends, in screen space.""" + ctx.save() + ctx.set_line_width(1.0) + for handle in handles: + ax, ay = to_screen(handle.anchor) + hx, hy = to_screen(handle.point) + ctx.set_source_rgba(0.4, 0.6, 1.0, 0.8) + ctx.move_to(ax, ay) + ctx.line_to(hx, hy) + ctx.stroke() + ctx.arc(hx, hy, HANDLE_SIZE_PX / 2.0, 0, math.tau) + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.95) + ctx.fill_preserve() + ctx.set_source_rgba(0.2, 0.4, 0.9, 1.0) + ctx.stroke() + ctx.restore() + + +def draw_nodes( + ctx: cairo.Context, + to_screen: ToScreen, + data: Sequence, + nodes: Iterable[int], + selected: int | None, +) -> None: + """Square node markers in screen space; the selected one is filled.""" + half = NODE_SIZE_PX / 2.0 + ctx.save() + ctx.set_line_width(1.0) + for index in nodes: + x, y = to_screen(_end(data[index])) + ctx.rectangle(x - half, y - half, NODE_SIZE_PX, NODE_SIZE_PX) + if index == selected: + ctx.set_source_rgba(1.0, 0.3, 0.3, 1.0) + else: + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.95) + ctx.fill_preserve() + ctx.set_source_rgba(0.2, 0.2, 0.2, 1.0) + ctx.stroke() + ctx.restore() diff --git a/requirements.txt b/requirements.txt index 08744b842..2ee395fef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ raydriver==0.5.1 -raygeo==1.66.0 +raygeo==1.67.0 aiohttp==3.14.5 asyncudp==0.11.0 blinker==1.9.0 diff --git a/tests/core/undo/test_property_cmd.py b/tests/core/undo/test_property_cmd.py index 56724a348..f46dde3e0 100644 --- a/tests/core/undo/test_property_cmd.py +++ b/tests/core/undo/test_property_cmd.py @@ -98,6 +98,14 @@ def test_property_cannot_coalesce_different_property(): assert cmd1.can_coalesce_with(cmd2) is False +def test_property_cannot_coalesce_when_either_opts_out(): + target = MockTarget() + plain = ChangePropertyCommand(target, "value", 10) + single = ChangePropertyCommand(target, "value", 20, coalesce=False) + assert plain.can_coalesce_with(single) is False + assert single.can_coalesce_with(plain) is False + + def test_property_coalesce_success(): target = MockTarget() cmd1 = ChangePropertyCommand(target, "value", 10, name="cmd1") diff --git a/tests/doceditor/test_node_edit_cmd.py b/tests/doceditor/test_node_edit_cmd.py new file mode 100644 index 000000000..befa0564c --- /dev/null +++ b/tests/doceditor/test_node_edit_cmd.py @@ -0,0 +1,132 @@ +import pytest +from raygeo.geo import Geometry, Matrix + +from rayforge.core.source_asset_segment import SourceAssetSegment +from rayforge.core.vectorization_spec import PassthroughSpec +from rayforge.core.workpiece import WorkPiece +from rayforge.doceditor.node_edit_cmd import NodeEditCmd + +SQUARE = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + + +@pytest.fixture +def node_edit(doc_editor): + return NodeEditCmd(doc_editor) + + +def _square() -> Geometry: + geo = Geometry() + geo.move_to(*SQUARE[0]) + for point in SQUARE[1:]: + geo.line_to(*point) + geo.line_to(*SQUARE[0]) + return geo + + +def _add(doc_editor, geo: Geometry) -> WorkPiece: + segment = SourceAssetSegment( + source_asset_uid="dummy", + pristine_geometry=geo, + normalization_matrix=Matrix.identity(), + vectorization_spec=PassthroughSpec(), + ) + wp = WorkPiece(name="Logo", source_segment=segment) + wp.natural_width_mm = 40.0 + wp.natural_height_mm = 40.0 + wp.set_size(40.0, 40.0) + doc_editor.doc.active_layer.add_child(wp) + return wp + + +def _ends(wp: WorkPiece) -> list[tuple[float, float]]: + boundaries = wp.boundaries + assert boundaries is not None + return [(c.end[0], c.end[1]) for c in boundaries.data] + + +@pytest.mark.asyncio +async def test_editor_exposes_node_edit(doc_editor): + assert isinstance(doc_editor.node_edit, NodeEditCmd) + + +@pytest.mark.asyncio +async def test_move_node_is_one_undo_step(doc_editor, node_edit): + wp = _add(doc_editor, _square()) + index = node_edit.move_node(wp, 2, 1.2, 1.1) + assert index == 2 + assert _ends(wp)[2] == pytest.approx((1.2, 1.1)) + assert wp.size == pytest.approx((40.0, 40.0)) + doc_editor.history_manager.undo() + assert wp._edited_boundaries is None + assert _ends(wp)[2] == (1.0, 1.0) + + +@pytest.mark.asyncio +async def test_delete_insert_and_toggle(doc_editor, node_edit): + wp = _add(doc_editor, _square()) + node_edit.delete_node(wp, 1) + assert len(_ends(wp)) == 4 + index = node_edit.insert_node(wp, 1, 0.5) + assert len(_ends(wp)) == 5 + node_edit.set_segments_curve(wp, {index}, True) + boundaries = wp.boundaries + assert boundaries is not None + assert type(boundaries.data[index]).__name__ == "Bezier" + doc_editor.history_manager.undo() + assert len(_ends(wp)) == 5 + doc_editor.history_manager.undo() + assert len(_ends(wp)) == 4 + doc_editor.history_manager.undo() + assert wp._edited_boundaries is None + + +@pytest.mark.asyncio +async def test_move_handle(doc_editor, node_edit): + wp = _add(doc_editor, _square()) + node_edit.set_segments_curve(wp, {1}, True) + node_edit.move_handle(wp, 1, 1, 0.3, 0.4) + boundaries = wp.boundaries + assert boundaries is not None + assert boundaries.data[1].control1[:2] == pytest.approx((0.3, 0.4)) + + +@pytest.mark.asyncio +async def test_removing_everything_is_allowed(doc_editor, node_edit): + geo = Geometry() + geo.move_to(0, 0) + geo.line_to(1, 1) + wp = _add(doc_editor, geo) + node_edit.delete_node(wp, 1) + boundaries = wp.boundaries + assert boundaries is not None and boundaries.is_empty() + + +@pytest.mark.asyncio +async def test_sketch_workpieces_are_left_alone(doc_editor, node_edit): + wp = _add(doc_editor, _square()) + wp.geometry_provider_uid = "some-sketch" + assert node_edit.move_node(wp, 2, 0.5, 0.5) is None + assert wp._edited_boundaries is None + + +@pytest.mark.asyncio +async def test_does_not_merge_into_a_segment_deletion(doc_editor, node_edit): + wp = _add(doc_editor, _square()) + doc_editor.edit.delete_segments(wp, {4}) + after_delete = _ends(wp) + node_edit.delete_node(wp, 1) + doc_editor.history_manager.undo() + assert _ends(wp) == after_delete + + +@pytest.mark.asyncio +async def test_two_segment_deletions_stay_two_undo_steps(doc_editor): + wp = _add(doc_editor, _square()) + doc_editor.edit.delete_segments(wp, {4}) + after_first = _ends(wp) + doc_editor.edit.delete_segments(wp, {3}) + assert len(_ends(wp)) == 3 + doc_editor.history_manager.undo() + assert _ends(wp) == after_first + doc_editor.history_manager.undo() + assert wp._edited_boundaries is None diff --git a/tests/ui_gtk/canvas/test_canvas_edit_keys.py b/tests/ui_gtk/canvas/test_canvas_edit_keys.py new file mode 100644 index 000000000..e0ffc3104 --- /dev/null +++ b/tests/ui_gtk/canvas/test_canvas_edit_keys.py @@ -0,0 +1,34 @@ +from unittest.mock import MagicMock + +import pytest +from gi.repository import Gdk + +from rayforge.ui_gtk.canvas.canvas import Canvas +from rayforge.ui_gtk.shared.keyboard import PRIMARY_MODIFIER_MASK + + +@pytest.fixture +def canvas(): + c = Canvas.__new__(Canvas) + c.edit_context = MagicMock() + c.edit_context.handle_edit_key.return_value = True + return c + + +@pytest.mark.ui +def test_plain_keys_go_to_the_edit_context(canvas): + assert canvas.on_key_pressed(None, Gdk.KEY_c, 0, 0) is True + canvas.edit_context.handle_edit_key.assert_called_once_with(Gdk.KEY_c) + + +@pytest.mark.ui +def test_shortcuts_with_the_primary_modifier_pass_through(canvas): + handled = canvas.on_key_pressed(None, Gdk.KEY_c, 0, PRIMARY_MODIFIER_MASK) + assert handled is False + canvas.edit_context.handle_edit_key.assert_not_called() + + +@pytest.mark.ui +def test_keys_the_edit_context_ignores_propagate(canvas): + canvas.edit_context.handle_edit_key.return_value = False + assert canvas.on_key_pressed(None, Gdk.KEY_z, 0, 0) is False diff --git a/tests/ui_gtk/canvas2d/test_node_editing.py b/tests/ui_gtk/canvas2d/test_node_editing.py new file mode 100644 index 000000000..ac714929d --- /dev/null +++ b/tests/ui_gtk/canvas2d/test_node_editing.py @@ -0,0 +1,87 @@ +from raygeo.geo import Geometry + +from rayforge.ui_gtk.canvas2d.node_editing import ( + Handle, + handle_positions, + nearest, + node_positions, + visible_handles, + visible_nodes, +) + + +def _two_contours() -> Geometry: + geo = Geometry() + geo.move_to(0, 0) + geo.line_to(1, 0) + geo.bezier_to(1, 1, 1.2, 0.2, 1.2, 0.8) + geo.move_to(5, 5) + geo.line_to(6, 5) + return geo + + +def _square() -> Geometry: + geo = Geometry() + geo.move_to(0, 0) + for x, y in ((1, 0), (1, 1), (0, 1), (0, 0)): + geo.line_to(x, y) + return geo + + +def test_nodes_of_the_selected_contour_only(): + geo = _two_contours() + assert visible_nodes(geo, {1}, None) == [0, 1, 2] + assert visible_nodes(geo, set(), 4) == [3, 4] + assert visible_nodes(geo, set(), None) == [] + + +def test_the_seam_of_a_closed_contour_is_one_node(): + geo = _square() + assert visible_nodes(geo, {1}, None) == [0, 1, 2, 3] + assert visible_nodes(geo, {3}, None) == [0, 1, 2, 3] + + +def test_an_open_contour_shows_its_end_node(): + geo = _two_contours() + assert visible_nodes(geo, {2}, None) == [0, 1, 2] + + +def test_handles_of_selected_beziers_and_the_selected_node(): + geo = _two_contours() + handles = visible_handles(geo, {2}, None) + assert [(h.index, h.which) for h in handles] == [(2, 1), (2, 2)] + assert handles[0].anchor == (1.0, 0.0) + assert handles[1].anchor == (1.0, 1.0) + assert visible_handles(geo, {1}, None) == [] + at_node = visible_handles(geo, set(), 1) + assert [(h.index, h.which) for h in at_node] == [(2, 1), (2, 2)] + + +def test_the_seam_node_shows_the_handles_of_both_sides(): + geo = _square() + geo = geo.set_segment_curve(1, True)[0] + geo = geo.set_segment_curve(3, True)[0] + geo = geo.set_segment_curve(4, True)[0] + handles = visible_handles(geo, set(), 0) + assert [(h.index, h.which) for h in handles] == [ + (1, 1), + (1, 2), + (4, 1), + (4, 2), + ] + + +def test_nearest_uses_a_per_axis_tolerance(): + data = _two_contours().data + nodes = node_positions(data, [0, 1, 2]) + assert nearest(nodes, (1.04, 0.0), (0.05, 0.01)) == 1 + assert nearest(nodes, (1.0, 0.04), (0.05, 0.01)) is None + assert nearest(nodes, (3.0, 3.0), (0.05, 0.05)) is None + + +def test_handles_win_from_nodes_when_asked_first(): + geo = _two_contours() + handles = visible_handles(geo, {2}, None) + hit = nearest(handle_positions(handles), (1.2, 0.2), (0.05, 0.05)) + assert isinstance(hit, Handle) + assert (hit.index, hit.which) == (2, 1) diff --git a/tests/ui_gtk/canvas2d/test_surface.py b/tests/ui_gtk/canvas2d/test_surface.py index 27bb490c9..91f59248e 100644 --- a/tests/ui_gtk/canvas2d/test_surface.py +++ b/tests/ui_gtk/canvas2d/test_surface.py @@ -34,6 +34,7 @@ def surface(mock_work_origin): s._connected_doc = None s._connected_layer = None s._active_layer_wcs_conn = None + s.edit_context = None active_layer = MagicMock() active_layer.rotary_enabled = False diff --git a/tests/ui_gtk/canvas2d/test_workpiece_node_keys.py b/tests/ui_gtk/canvas2d/test_workpiece_node_keys.py new file mode 100644 index 000000000..172d3061d --- /dev/null +++ b/tests/ui_gtk/canvas2d/test_workpiece_node_keys.py @@ -0,0 +1,83 @@ +from unittest.mock import MagicMock + +import pytest +from gi.repository import Gdk +from raygeo.geo import Geometry, Matrix + +from rayforge.core.source_asset_segment import SourceAssetSegment +from rayforge.core.vectorization_spec import PassthroughSpec +from rayforge.core.workpiece import WorkPiece +from rayforge.ui_gtk.canvas2d.elements.workpiece import ( + VectorEditState, + WorkPieceElement, +) + + +def _square() -> Geometry: + geo = Geometry() + geo.move_to(0.0, 0.0) + for x, y in ((1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)): + geo.line_to(x, y) + return geo + + +@pytest.fixture +def element(doc_editor): + segment = SourceAssetSegment( + source_asset_uid="dummy", + pristine_geometry=_square(), + normalization_matrix=Matrix.identity(), + vectorization_spec=PassthroughSpec(), + ) + wp = WorkPiece(name="Logo", source_segment=segment) + wp.natural_width_mm = 40.0 + wp.natural_height_mm = 40.0 + wp.set_size(40.0, 40.0) + doc_editor.doc.active_layer.add_child(wp) + elem = WorkPieceElement.__new__(WorkPieceElement) + elem.data = wp + elem.canvas = MagicMock() + elem.canvas.editor = doc_editor + boundaries = wp.boundaries + assert boundaries is not None + elem._edit_state = VectorEditState(boundaries) + return elem + + +@pytest.mark.ui +@pytest.mark.asyncio +async def test_insert_clears_the_shifted_segment_selection(element): + state = element._edit_state + state.selected_segments = {3} + state.hovered_segment = 1 + state.hovered_t = 0.5 + assert element.handle_edit_key(Gdk.KEY_i) is True + state = element._edit_state + assert len(state.geometry.data) == 6 + assert state.selected_segments == set() + assert state.selected_node is not None + + +@pytest.mark.ui +@pytest.mark.asyncio +async def test_delete_removes_the_selected_node_first(element): + element._edit_state.selected_segments = {3} + element._edit_state.selected_node = 2 + assert element.handle_edit_key(Gdk.KEY_Delete) is True + assert len(element._edit_state.geometry.data) == 4 + assert element._edit_state.selected_segments == set() + + +@pytest.mark.ui +@pytest.mark.asyncio +async def test_delete_segments_without_a_selected_node(element): + element._edit_state.selected_segments = {2} + assert element.handle_edit_key(Gdk.KEY_Delete) is True + assert len(element._edit_state.geometry.data) < 5 + assert element._edit_state.selected_segments == set() + + +@pytest.mark.ui +@pytest.mark.asyncio +async def test_other_keys_are_not_handled(element): + assert element.handle_edit_key(Gdk.KEY_z) is False diff --git a/website/docs/ui/canvas-tools.md b/website/docs/ui/canvas-tools.md index 5507b3afa..22d3b13da 100644 --- a/website/docs/ui/canvas-tools.md +++ b/website/docs/ui/canvas-tools.md @@ -102,8 +102,22 @@ Transform objects numerically for precision. ## Vector Editing -Double-click a workpiece on the canvas to enter vector editing mode. In this mode, you can select -and delete individual vector segments of the workpiece. +Double-click an imported workpiece on the canvas to enter vector editing mode. In this mode, you can +select and delete individual vector segments of the workpiece, and edit its nodes. + +Click a segment to select it, or drag a frame around several. The nodes of the selected contour +appear as small squares, and the handles of selected curves as round dots: + +- **Drag a node** to move it; the curve handles next to it move along. In a closed path the start + and end node are one node. +- **Drag a handle** to change the shape of a curve. +- Click a node and press Delete to remove it; the segments on both sides join into one. + With no node selected, Delete removes the selected segments. +- Point at a segment and press I to insert a node there. +- Press C to turn the selected segments into curves, or back into straight lines when + they are all curves already. + +Every change can be undone. Sketches are edited in the Sketcher instead. To exit vector editing mode, click outside the workpiece or press Escape. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/de/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index 467296171..46728160f 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -160,8 +160,25 @@ Formen kombinieren oder subtrahieren: ## Vektorbearbeitung -Doppelklicke auf ein Werkstück auf der Arbeitsfläche, um in den Vektorbearbeitungsmodus zu wechseln. -In diesem Modus kannst du einzelne Vektorsegmente des Werkstücks auswählen und löschen. +Doppelklicke auf ein importiertes Werkstück auf der Arbeitsfläche, um in den Vektorbearbeitungsmodus +zu wechseln. In diesem Modus kannst du einzelne Vektorsegmente des Werkstücks auswählen und löschen +sowie seine Knoten bearbeiten. + +Klicke auf ein Segment, um es auszuwählen, oder ziehe einen Rahmen um mehrere Segmente. Die Knoten +der ausgewählten Kontur erscheinen als kleine Quadrate, die Anfasser ausgewählter Kurven als runde +Punkte: + +- **Ziehe einen Knoten**, um ihn zu verschieben; die angrenzenden Kurvenanfasser bewegen sich mit. + In einem geschlossenen Pfad sind Start- und Endknoten ein einziger Knoten. +- **Ziehe einen Anfasser**, um die Form einer Kurve zu ändern. +- Klicke auf einen Knoten und drücke Entf, um ihn zu entfernen; die Segmente auf beiden + Seiten werden zu einem verbunden. Ist kein Knoten ausgewählt, entfernt Entf die + ausgewählten Segmente. +- Zeige auf ein Segment und drücke I, um dort einen Knoten einzufügen. +- Drücke C, um die ausgewählten Segmente in Kurven umzuwandeln, oder zurück in gerade + Linien, wenn sie bereits alle Kurven sind. + +Jede Änderung lässt sich rückgängig machen. Skizzen werden stattdessen im Sketcher bearbeitet. Um den Vektorbearbeitungsmodus zu verlassen, klicke außerhalb des Werkstücks oder drücke Escape. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/es/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index 5e5b97d34..56f2d7e56 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -160,8 +160,24 @@ Combina o sustrae formas: ## Edición de Vectores -Haz doble clic en una pieza de trabajo en el lienzo para entrar al modo de edición de vectores. En -este modo, puedes seleccionar y eliminar segmentos de vector individuales de la pieza de trabajo. +Haz doble clic en una pieza de trabajo importada en el lienzo para entrar al modo de edición de +vectores. En este modo, puedes seleccionar y eliminar segmentos de vector individuales de la pieza +de trabajo, y editar sus nodos. + +Haz clic en un segmento para seleccionarlo, o arrastra un marco alrededor de varios. Los nodos del +contorno seleccionado aparecen como pequeños cuadrados, y los manejadores de las curvas +seleccionadas como puntos redondos: + +- **Arrastra un nodo** para moverlo; los manejadores de curva contiguos se mueven con él. En una + ruta cerrada, el nodo inicial y el final son un solo nodo. +- **Arrastra un manejador** para cambiar la forma de una curva. +- Haz clic en un nodo y presiona Supr para eliminarlo; los segmentos de ambos lados se + unen en uno. Sin ningún nodo seleccionado, Supr elimina los segmentos seleccionados. +- Apunta a un segmento y presiona I para insertar un nodo allí. +- Presiona C para convertir los segmentos seleccionados en curvas, o de nuevo en líneas + rectas cuando ya son todos curvas. + +Cada cambio se puede deshacer. Los bocetos se editan en el diseñador paramétrico. Para salir del modo de edición de vectores, haz clic fuera de la pieza de trabajo o presiona Escape. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index bca5b7210..42854decd 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -162,8 +162,25 @@ Combinez ou soustrayez des formes : ## Édition vectorielle -Double-cliquez sur une pièce sur le canevas pour entrer en mode d'édition vectorielle. Dans ce mode, -vous pouvez sélectionner et supprimer des segments vectoriels individuels de la pièce. +Double-cliquez sur une pièce importée sur le canevas pour entrer en mode d'édition vectorielle. Dans +ce mode, vous pouvez sélectionner et supprimer des segments vectoriels individuels de la pièce, et +modifier ses nœuds. + +Cliquez sur un segment pour le sélectionner, ou tracez un cadre autour de plusieurs. Les nœuds du +contour sélectionné apparaissent sous forme de petits carrés, et les poignées des courbes +sélectionnées sous forme de points ronds : + +- **Faites glisser un nœud** pour le déplacer ; les poignées de courbe voisines le suivent. Dans un + chemin fermé, le nœud de début et le nœud de fin ne forment qu'un seul nœud. +- **Faites glisser une poignée** pour modifier la forme d'une courbe. +- Cliquez sur un nœud et appuyez sur Suppr pour le supprimer ; les segments de part et + d'autre sont réunis en un seul. Sans nœud sélectionné, Suppr supprime les segments + sélectionnés. +- Pointez un segment et appuyez sur I pour y insérer un nœud. +- Appuyez sur C pour transformer les segments sélectionnés en courbes, ou de nouveau en + lignes droites lorsqu'ils sont déjà tous des courbes. + +Chaque modification peut être annulée. Les esquisses se modifient plutôt dans l'esquisseur. Pour quitter le mode d'édition vectorielle, cliquez en dehors de la pièce ou appuyez sur Échap. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index 718774312..05a38bf14 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -103,8 +103,22 @@ description: ## वेक्टर संपादन -वेक्टर संपादन मोड में जाने के लिए कैनवस पर किसी वर्कपीस पर डबल-क्लिक करें. इस मोड में, आप वर्कपीस के -व्यक्तिगत वेक्टर खंड चुन और हटा सकते हैं. +वेक्टर संपादन मोड में जाने के लिए कैनवस पर किसी आयात किए गए वर्कपीस पर डबल-क्लिक करें. इस मोड में, +आप वर्कपीस के व्यक्तिगत वेक्टर खंड चुन और हटा सकते हैं, और उसके नोड संपादित कर सकते हैं. + +किसी खंड को चुनने के लिए उस पर क्लिक करें, या कई खंडों के चारों ओर एक फ़्रेम खींचें. चुने गए कंटूर +के नोड छोटे वर्गों के रूप में दिखते हैं, और चुने गए वक्रों के हैंडल गोल बिंदुओं के रूप में: + +- किसी नोड को खिसकाने के लिए **नोड को खींचें**; उसके बगल के वक्र हैंडल भी साथ चलते हैं. बंद पथ में + आरंभ और अंत नोड एक ही नोड होते हैं. +- किसी वक्र का आकार बदलने के लिए **हैंडल को खींचें**. +- किसी नोड को हटाने के लिए उस पर क्लिक करें और Delete दबाएँ; दोनों ओर के खंड जुड़कर एक हो + जाते हैं. कोई नोड चुना न हो तो Delete चुने गए खंड हटा देता है. +- किसी खंड पर पॉइंटर रखें और वहाँ नोड जोड़ने के लिए I दबाएँ. +- चुने गए खंडों को वक्रों में बदलने के लिए C दबाएँ, या यदि वे सभी पहले से वक्र हैं तो + वापस सीधी रेखाओं में. + +हर बदलाव पूर्ववत किया जा सकता है. स्केच इसके बजाय स्केचर में संपादित किए जाते हैं. वेक्टर संपादन मोड से बाहर निकलने के लिए, वर्कपीस के बाहर क्लिक करें या Escape दबाएँ. diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index 10245e011..f6a01eed9 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -160,8 +160,24 @@ Combine ou subtraia formas: ## Edição de Vetores -Clique duas vezes em uma peça de trabalho na tela para entrar no modo de edição de vetores. Neste -modo, você pode selecionar e excluir segmentos de vetor individuais da peça de trabalho. +Clique duas vezes em uma peça de trabalho importada na tela para entrar no modo de edição de +vetores. Neste modo, você pode selecionar e excluir segmentos de vetor individuais da peça de +trabalho, e editar seus nós. + +Clique em um segmento para selecioná-lo, ou arraste um quadro ao redor de vários. Os nós do contorno +selecionado aparecem como pequenos quadrados, e as alças das curvas selecionadas como pontos +redondos: + +- **Arraste um nó** para movê-lo; as alças de curva vizinhas acompanham. Em um caminho fechado, o nó + inicial e o final são um só nó. +- **Arraste uma alça** para mudar a forma de uma curva. +- Clique em um nó e pressione Delete para removê-lo; os segmentos dos dois lados se unem + em um. Sem nenhum nó selecionado, Delete remove os segmentos selecionados. +- Aponte para um segmento e pressione I para inserir um nó ali. +- Pressione C para transformar os segmentos selecionados em curvas, ou de volta em linhas + retas quando todos já forem curvas. + +Toda alteração pode ser desfeita. Esboços são editados no esboçador. Para sair do modo de edição de vetores, clique fora da peça de trabalho ou pressione Escape. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index fd5f6050a..3f3c28e44 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -97,8 +97,23 @@ ## Редагування векторів -Двічі клацніть на робочу деталь на полотні, щоб увійти в режим редагування векторів. У цьому режимі -ви можете вибирати та видаляти окремі векторні сегменти робочої деталі. +Двічі клацніть на імпортовану робочу деталь на полотні, щоб увійти в режим редагування векторів. У +цьому режимі ви можете вибирати та видаляти окремі векторні сегменти робочої деталі, а також +редагувати її вузли. + +Клацніть на сегмент, щоб вибрати його, або обведіть рамкою кілька сегментів. Вузли вибраного контуру +показано маленькими квадратами, а маркери вибраних кривих — круглими точками: + +- **Перетягніть вузол**, щоб перемістити його; сусідні маркери кривих рухаються разом із ним. У + замкненому шляху початковий і кінцевий вузли — це один вузол. +- **Перетягніть маркер**, щоб змінити форму кривої. +- Клацніть на вузол і натисніть Delete, щоб вилучити його; сегменти з обох боків + об'єднуються в один. Якщо жоден вузол не вибрано, Delete видаляє вибрані сегменти. +- Наведіть вказівник на сегмент і натисніть I, щоб вставити там вузол. +- Натисніть C, щоб перетворити вибрані сегменти на криві, або назад на прямі лінії, якщо + всі вони вже криві. + +Кожну зміну можна скасувати. Ескізи натомість редагуються в ескізері. Щоб вийти з режиму редагування векторів, клацніть поза робочою деталлю або натисніть Escape. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/canvas-tools.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/canvas-tools.md index 16a451793..0e1163285 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/canvas-tools.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/ui/canvas-tools.md @@ -155,7 +155,18 @@ ## 矢量编辑 -在画布上双击工件进入矢量编辑模式。在此模式下,您可以选择和删除工件的各个矢量段。 +在画布上双击导入的工件进入矢量编辑模式。在此模式下,您可以选择和删除工件的各个矢量段,并编辑其节点。 + +点击一个段将其选中,或拖出一个框选中多个段。所选轮廓的节点显示为小方块,所选曲线的控制柄显示为圆点: + +- **拖动节点**可移动它;相邻的曲线控制柄随之移动。在闭合路径中,起点和终点是同一个节点。 +- **拖动控制柄**可改变曲线的形状。 +- 点击一个节点并按 Delete 将其删除;两侧的段合并为一段。未选中节点时,Delete + 删除所选的段。 +- 将指针指向一个段并按 I,在该处插入一个节点。 +- 按 C 将所选段转换为曲线;如果它们已全部是曲线,则转换回直线。 + +每次更改都可以撤销。草图则在草图绘制器中编辑。 要退出矢量编辑模式,请点击工件外部或按 Escape。