diff --git a/CHANGELOG.md b/CHANGELOG.md index fbb2354f9..82e9a99a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 clustered-dot Newsprint screen and a round-dot Halftone screen with adjustable cell size and angle. LightBurn imports keep the image layer's dither mode (#514) +- Path clean-up tools in the Object menu and canvas context menu: + Close Paths and Join Open Paths with a tolerance in mm, Delete + Duplicates (duplicate contours inside a workpiece and stacked copies + of whole workpieces), and Break Apart (one workpiece per contour, + holes and open paths included). Each is a single undo step (#516) - Sketcher: boolean operations. Union, Difference, Intersection and Exclude bake the selected closed regions into multi-ring polygons (outer contours counter-clockwise, hole contours clockwise), so diff --git a/debian/requirements-bundle.txt b/debian/requirements-bundle.txt index 0d43efc59..3129f9356 100644 --- a/debian/requirements-bundle.txt +++ b/debian/requirements-bundle.txt @@ -1,7 +1,7 @@ asyncudp==0.11.0 pyvips==3.2.0 raydriver==0.3.0 -raygeo==1.62.0 +raygeo==1.63.0 ruida-pa==0.21.2 trimesh==5.1.1 vtracer==0.6.15 \ No newline at end of file diff --git a/pixi.lock b/pixi.lock index 7390f5939..5c83c311b 100644 --- a/pixi.lock +++ b/pixi.lock @@ -247,11 +247,11 @@ environments: - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/22/35/285ce45186a60572472c96df5646989ca97710e060419e5c749226a057cf/raygeo-1.63.0.tar.gz - pypi: https://files.pythonhosted.org/packages/31/c4/e54512d343c4c177e92c45ec16852abe63945b924274c692a5a1867db011/zeroconf-0.151.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_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/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3d/de/02f631997741485881fbe129aef2de75a23b4e9776f5b1c4a7afc313ae7e/raygeo-1.62.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/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -480,7 +480,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/a5/1d1baffeb743cbdfff98c180473a9c81bbabe433dea5a72761d4c1edf1ef/raygeo-1.62.0-cp311-abi3-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/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/3f/99/2e119d541d596daea39643eb9312b47c7847383951300f889166938035b1/rpyc-6.0.2-py3-none-any.whl @@ -530,6 +529,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fa/48/f36d233ce749d1bb880cb5e6f8bb171dc2effabc7435c31da604d831b3ec/gitpython-3.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/4b/7cf8a68f8865e04e036a5b6847276f97075157ca2360048ba7fd60cd3b88/raygeo-1.63.0-cp311-abi3-macosx_11_0_arm64.whl build: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -928,12 +928,12 @@ environments: - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/22/35/285ce45186a60572472c96df5646989ca97710e060419e5c749226a057cf/raygeo-1.63.0.tar.gz - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/31/c4/e54512d343c4c177e92c45ec16852abe63945b924274c692a5a1867db011/zeroconf-0.151.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_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/3a/cf/9c2e4d6c179529d5bf4a64cff76fa581486569e9fbdd35bd98f51cb624bf/msgpack-1.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3d/de/02f631997741485881fbe129aef2de75a23b4e9776f5b1c4a7afc313ae7e/raygeo-1.62.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/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl @@ -1188,7 +1188,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/16/67/1f0b582e73ce46511f2799cbdcdbdf9ca98f344f2aad42ff5ff5dde0d8fe/narwhals-2.27.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/17/fe/77f4a24264e728fd95542e53f026a91249b51d1611087b8c82d3a033ea97/asyncudp-0.11.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/20/a5/1d1baffeb743cbdfff98c180473a9c81bbabe433dea5a72761d4c1edf1ef/raygeo-1.62.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.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/3c/2c/c43c03eaf630435f023f1dc61ec4a4a78951ad5530a62c71cc89bde307b7/pypdf-6.19.0-py3-none-any.whl @@ -1254,6 +1253,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/fc/4b/7cf8a68f8865e04e036a5b6847276f97075157ca2360048ba7fd60cd3b88/raygeo-1.63.0-cp311-abi3-macosx_11_0_arm64.whl video: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -9647,7 +9647,7 @@ packages: name: rayforge requires_dist: - raydriver==0.3.0 - - raygeo==1.62.0 + - raygeo==1.63.0 - aiohttp==3.14.3 - asyncudp==0.11.0 - blinker==1.9.0 @@ -9820,25 +9820,25 @@ packages: name: asyncudp version: 0.11.0 sha256: 96d859d86471e3ed30cf9f05cb38e96920156e3a9fe70ee916096ba758ea316e -- pypi: https://files.pythonhosted.org/packages/20/a5/1d1baffeb743cbdfff98c180473a9c81bbabe433dea5a72761d4c1edf1ef/raygeo-1.62.0-cp311-abi3-macosx_11_0_arm64.whl +- pypi: https://files.pythonhosted.org/packages/22/35/285ce45186a60572472c96df5646989ca97710e060419e5c749226a057cf/raygeo-1.63.0.tar.gz name: raygeo - version: 1.62.0 - sha256: 094187c356b390e729a8fa7287b97f7f657b26a280cf2014c6726e944786a4a3 + version: 1.63.0 + sha256: 6a411c28e7aecea81fa7ccb3d34c652ed1ff4bf1fa677a09323fdd00988a77c8 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' + - 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/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl name: mccabe @@ -9894,26 +9894,6 @@ packages: - arabic-reshaper ; extra == 'rtl-text' - python-bidi ; extra == 'rtl-text' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/3d/de/02f631997741485881fbe129aef2de75a23b4e9776f5b1c4a7afc313ae7e/raygeo-1.62.0.tar.gz - name: raygeo - version: 1.62.0 - sha256: 45b319ba805362aaa288532a0f0b0394b0898f39724c9b6a7ae378c463ed2346 - 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 @@ -10801,6 +10781,26 @@ packages: - tree-sitter-yaml>=0.6.0 ; python_full_version >= '3.10' and extra == 'syntax' - typing-extensions>=4.4.0,<5.0.0 requires_python: '>=3.9,<4.0' +- pypi: https://files.pythonhosted.org/packages/fc/4b/7cf8a68f8865e04e036a5b6847276f97075157ca2360048ba7fd60cd3b88/raygeo-1.63.0-cp311-abi3-macosx_11_0_arm64.whl + name: raygeo + version: 1.63.0 + sha256: 80cc04f6a7c723c4c1a6b2cf5aee1491fc99a41ad499c7100623cbbe2cd96022 + 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/ff/72/b12534f2d335448bc98504610c8d57520e530dab840251871a72cbb6c227/vtracer-0.6.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl name: vtracer version: 0.6.15 diff --git a/pixi.toml b/pixi.toml index b9c8738b2..750d0a29c 100644 --- a/pixi.toml +++ b/pixi.toml @@ -38,7 +38,7 @@ rust = "==1.98.1" patchelf = "==0.19.2" [feature.app.pypi-dependencies] -raygeo = "==1.62.0" +raygeo = "==1.63.0" raydriver = "==0.3.0" rayforge = { editable = true, path = "." } ruida-pa = "==0.22.0" diff --git a/rayforge/doceditor/editor.py b/rayforge/doceditor/editor.py index 9c6e8d54e..41fc8405a 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 .path_cleanup_cmd import PathCleanupCmd from .reload_cmd import ReloadCmd from .split_cmd import SplitCmd from .step_cmd import StepCmd @@ -152,6 +153,7 @@ def __init__( self.group = GroupCmd(self, self.task_manager) self.layer = LayerCmd(self) self.layout = LayoutCmd(self, self.task_manager) + self.path_cleanup = PathCleanupCmd(self) self.reload = ReloadCmd(self) self.split = SplitCmd(self) self.stock = StockCmd(self) diff --git a/rayforge/doceditor/path_cleanup_cmd.py b/rayforge/doceditor/path_cleanup_cmd.py new file mode 100644 index 000000000..1ca509333 --- /dev/null +++ b/rayforge/doceditor/path_cleanup_cmd.py @@ -0,0 +1,232 @@ +import logging +from collections.abc import Callable +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from raygeo.geo import Geometry, Matrix + +from ..core.item import DocItem +from ..core.undo import ChangePropertyCommand, ListItemCommand +from ..core.workpiece import WorkPiece +from .split_cmd import ContourSplitStrategy + +if TYPE_CHECKING: + from .editor import DocEditor + +logger = logging.getLogger(__name__) + +DEFAULT_DUPLICATE_TOLERANCE_MM = 0.01 + +GeometryOperation = Callable[[Geometry, float], tuple[Geometry, int]] + + +def _mm_scale(workpiece: WorkPiece) -> tuple[float, float]: + """ + The factors that turn the workpiece's normalized geometry into + millimetres. A degenerate axis keeps a factor of 1 so the scaling can + be undone. + """ + width, height = workpiece.size + return ( + width if width > 1e-9 else 1.0, + height if height > 1e-9 else 1.0, + ) + + +def _is_editable(workpiece: WorkPiece) -> bool: + """ + Workpieces generated from a geometry provider such as a sketch are + edited at their source, so path clean-up leaves them alone. + """ + if workpiece.geometry_provider_uid: + return False + boundaries = workpiece.boundaries + return boundaries is not None and not boundaries.is_empty() + + +def _world_geometry(workpiece: WorkPiece) -> Geometry | None: + boundaries = workpiece.boundaries + if boundaries is None or boundaries.is_empty(): + return None + geo = boundaries.copy() + geo.transform(workpiece.get_world_transform()) + return geo + + +def _rects_overlap(a: Geometry, b: Geometry, tolerance: float) -> bool: + return all(abs(va - vb) <= tolerance for va, vb in zip(a.rect(), b.rect())) + + +class PathCleanupCmd: + """ + Clean-up operations on the vector paths of workpieces: closing, + joining, removing duplicates and breaking apart. Tolerances are given + in millimetres, independent of the workpiece's scale. + """ + + def __init__(self, editor: "DocEditor"): + self._editor = editor + + def close_paths( + self, workpieces: list[WorkPiece], tolerance_mm: float + ) -> int: + """ + Closes open contours whose start and end are within the tolerance. + + Returns: + The number of closed contours. + """ + return self._apply( + workpieces, + Geometry.close_open_contours, + tolerance_mm, + _("Close paths"), + ) + + def join_paths( + self, workpieces: list[WorkPiece], tolerance_mm: float + ) -> int: + """ + Joins open contours whose end points meet within the tolerance. + + Returns: + The number of joins made. + """ + return self._apply( + workpieces, + Geometry.join_open_contours, + tolerance_mm, + _("Join paths"), + ) + + def delete_duplicates( + self, + workpieces: list[WorkPiece], + tolerance_mm: float = DEFAULT_DUPLICATE_TOLERANCE_MM, + ) -> int: + """ + Removes duplicate contours inside each workpiece, and removes + workpieces that are an exact copy of an earlier one in the list + lying on top of it. + + Returns: + The number of removed contours and workpieces. + """ + duplicates = self._find_duplicate_workpieces(workpieces, tolerance_mm) + remaining = [wp for wp in workpieces if wp not in duplicates] + changes = self._compute_changes( + remaining, Geometry.remove_duplicate_contours, tolerance_mm + ) + if not duplicates and not changes: + return 0 + + name = _("Delete duplicates") + removed = len(duplicates) + with self._editor.history_manager.transaction(name) as t: + for workpiece in duplicates: + t.execute( + ListItemCommand( + owner_obj=workpiece.parent, + item=workpiece, + undo_command="add_child", + redo_command="remove_child", + name=name, + ) + ) + for workpiece, new_geo, count in changes: + t.execute(self._boundaries_command(workpiece, new_geo, name)) + removed += count + return removed + + def break_apart(self, workpieces: list[WorkPiece]) -> list[DocItem]: + """ + Splits each workpiece into one workpiece per contour. + + Returns: + The newly created workpieces. + """ + return self._editor.split.split_items( + workpieces, strategy=ContourSplitStrategy(), name=_("Break apart") + ) + + def _apply( + self, + workpieces: list[WorkPiece], + operation: GeometryOperation, + tolerance_mm: float, + name: str, + ) -> int: + changes = self._compute_changes(workpieces, operation, tolerance_mm) + if not changes: + return 0 + 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)) + total += count + return total + + def _compute_changes( + self, + workpieces: list[WorkPiece], + operation: GeometryOperation, + tolerance_mm: float, + ) -> list[tuple[WorkPiece, Geometry, int]]: + """ + Runs the operation on each workpiece's geometry in millimetres and + returns the normalized results of the workpieces it changed. + """ + changes = [] + for workpiece in workpieces: + if not _is_editable(workpiece): + continue + boundaries = workpiece.boundaries + assert boundaries is not None + sx, sy = _mm_scale(workpiece) + geo_mm = boundaries.copy() + geo_mm.transform(Matrix.scale(sx, sy)) + new_geo, count = operation(geo_mm, tolerance_mm) + if count == 0: + continue + new_geo.transform(Matrix.scale(1.0 / sx, 1.0 / sy)) + 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]: + """ + Finds workpieces whose world-space geometry matches an earlier + workpiece in the list. + """ + kept: list[tuple[WorkPiece, Geometry]] = [] + duplicates: list[WorkPiece] = [] + for workpiece in workpieces: + geo = _world_geometry(workpiece) + if geo is None or workpiece.parent is None: + continue + if any( + _rects_overlap(geo, other, tolerance_mm) + and geo.matches(other, tolerance_mm) + for _wp, other in kept + ): + duplicates.append(workpiece) + else: + kept.append((workpiece, geo)) + return duplicates diff --git a/rayforge/doceditor/split_cmd.py b/rayforge/doceditor/split_cmd.py index 12f08883e..884df1af0 100644 --- a/rayforge/doceditor/split_cmd.py +++ b/rayforge/doceditor/split_cmd.py @@ -49,6 +49,19 @@ def calculate_fragments(self, workpiece: "WorkPiece") -> list[Geometry]: return workpiece.boundaries.split_into_components() +class ContourSplitStrategy(SplitStrategy): + """ + Splits a workpiece into one fragment per contour ("Break Apart"). + Unlike the connectivity split, holes become separate pieces and open + paths are kept. + """ + + def calculate_fragments(self, workpiece: "WorkPiece") -> list[Geometry]: + if not workpiece.boundaries or workpiece.boundaries.is_empty(): + return [] + return workpiece.boundaries.split_drawn_contours() + + class SplitCmd: """Handles splitting of document items.""" @@ -59,6 +72,7 @@ def split_items( self, items: list[WorkPiece], strategy: SplitStrategy | None = None, + name: str | None = None, ) -> list[DocItem]: """ Splits the provided items into multiple fragments based on the given @@ -69,6 +83,7 @@ def split_items( items: The list of items to split. strategy: The strategy to use for calculating fragments. Defaults to splitting disjoint components. + name: The name of the undo step. Defaults to "Split item(s)". Returns: A list of the newly created items. @@ -81,7 +96,7 @@ def split_items( history = self._editor.history_manager newly_created_items = [] - with history.transaction(_("Split item(s)")) as t: + with history.transaction(name or _("Split item(s)")) as t: for item in items: # Capture the parent before any modification/removal occurs. # Executing remove_cmd may set item.parent to None. diff --git a/rayforge/ui_gtk/actions.py b/rayforge/ui_gtk/actions.py index 45e60507b..2b6528625 100644 --- a/rayforge/ui_gtk/actions.py +++ b/rayforge/ui_gtk/actions.py @@ -1,9 +1,10 @@ import logging from collections.abc import Callable from gettext import gettext as _ +from gettext import ngettext from typing import TYPE_CHECKING, cast -from gi.repository import Gio, GLib, Gtk +from gi.repository import Adw, Gio, GLib, Gtk from ..context import get_context from ..core.group import Group @@ -21,6 +22,7 @@ PointRotationArrayDialog, ) from .doceditor.add_tabs_popover import AddTabsPopover +from .doceditor.path_tolerance_dialog import PathToleranceDialog from .doceditor.stock_properties_dialog import StockPropertiesDialog from .shared.keyboard import PRIMARY_ACCEL @@ -430,6 +432,12 @@ def register_actions(self): # Split Action self._add_action("split", self.on_split_action) + # Path Clean-Up Actions + self._add_action("break-apart", self.on_break_apart) + self._add_action("close-paths", self.on_close_paths) + self._add_action("join-paths", self.on_join_paths) + self._add_action("delete-duplicates", self.on_delete_duplicates) + # Convert to Stock Action self._add_action("convert-to-stock", self.on_convert_to_stock) @@ -557,6 +565,13 @@ def update_action_states(self, *args, **kwargs): layout_info.action.set_enabled(has_workpieces) self.actions["split"].set_enabled(bool(selected_wps)) + for name in ( + "break-apart", + "close-paths", + "join-paths", + "delete-duplicates", + ): + self.actions[name].set_enabled(bool(selected_wps)) self.actions["export-object"].set_enabled(len(selected_wps) == 1) sources = self.editor.reload.sources_of(list(selected_wps)) @@ -851,6 +866,90 @@ def on_split_action(self, action, param): if new_items: self.win.surface.select_items(new_items) + def on_break_apart(self, action, param): + """Handler for the 'break-apart' action.""" + selected_workpieces = self.win.surface.get_selected_workpieces() + if not selected_workpieces: + return + + new_items = self.editor.path_cleanup.break_apart(selected_workpieces) + if new_items: + self.win.surface.select_items(new_items) + else: + self._show_toast(_("Nothing to break apart")) + + def on_close_paths(self, action, param): + """Handler for the 'close-paths' action: asks for a tolerance.""" + workpieces = self.win.surface.get_selected_workpieces() + if not workpieces: + return + + def _apply(tolerance: float): + count = self.editor.path_cleanup.close_paths(workpieces, tolerance) + self._show_toast( + ngettext( + "Closed {count} path", + "Closed {count} paths", + count, + ).format(count=count) + ) + + PathToleranceDialog( + heading=_("Close Paths"), + body=_( + "Closes open paths whose start and end point are closer " + "than the tolerance." + ), + apply_label=_("Close Paths"), + key="close-paths", + on_apply=_apply, + ).present(self.win) + + def on_join_paths(self, action, param): + """Handler for the 'join-paths' action: asks for a tolerance.""" + workpieces = self.win.surface.get_selected_workpieces() + if not workpieces: + return + + def _apply(tolerance: float): + count = self.editor.path_cleanup.join_paths(workpieces, tolerance) + self._show_toast( + ngettext( + "Made {count} join", + "Made {count} joins", + count, + ).format(count=count) + ) + + PathToleranceDialog( + heading=_("Join Open Paths"), + body=_( + "Joins open paths whose end points are closer than the " + "tolerance. Paths whose ends then meet are closed." + ), + apply_label=_("Join Paths"), + key="join-paths", + on_apply=_apply, + ).present(self.win) + + def on_delete_duplicates(self, action, param): + """Handler for the 'delete-duplicates' action.""" + workpieces = self.win.surface.get_selected_workpieces() + if not workpieces: + return + + count = self.editor.path_cleanup.delete_duplicates(workpieces) + self._show_toast( + ngettext( + "Deleted {count} duplicate", + "Deleted {count} duplicates", + count, + ).format(count=count) + ) + + def _show_toast(self, message: str): + self.win.toast_overlay.add_toast(Adw.Toast.new(message)) + def on_convert_to_stock(self, action, param): """Handler for the 'convert-to-stock' action.""" selected_wps = self.win.surface.get_selected_workpieces() diff --git a/rayforge/ui_gtk/canvas2d/context_menu.py b/rayforge/ui_gtk/canvas2d/context_menu.py index 427bd96c6..a31796d3e 100644 --- a/rayforge/ui_gtk/canvas2d/context_menu.py +++ b/rayforge/ui_gtk/canvas2d/context_menu.py @@ -114,6 +114,24 @@ def _populate_standard_items(menu: Gio.Menu): # Separator menu.append_section(None, Gio.Menu.new()) + cleanup_menu = Gio.Menu.new() + cleanup_menu.append_item( + Gio.MenuItem.new(_("Break Apart"), "win.break-apart") + ) + cleanup_menu.append_item( + Gio.MenuItem.new(_("Close Paths…"), "win.close-paths") + ) + cleanup_menu.append_item( + Gio.MenuItem.new(_("Join Open Paths…"), "win.join-paths") + ) + cleanup_menu.append_item( + Gio.MenuItem.new(_("Delete Duplicates"), "win.delete-duplicates") + ) + menu.append_submenu(_("Clean Up Paths"), cleanup_menu) + + # Separator + menu.append_section(None, Gio.Menu.new()) + menu.append_item( Gio.MenuItem.new(_("Convert to Stock"), "win.convert-to-stock") ) diff --git a/rayforge/ui_gtk/doceditor/path_tolerance_dialog.py b/rayforge/ui_gtk/doceditor/path_tolerance_dialog.py new file mode 100644 index 000000000..b19491f6d --- /dev/null +++ b/rayforge/ui_gtk/doceditor/path_tolerance_dialog.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import logging +from collections.abc import Callable +from gettext import gettext as _ +from typing import ClassVar + +from gi.repository import Adw + +from ..shared.pref_rows.length_spin_row import LengthSpinRow + +logger = logging.getLogger(__name__) + +DEFAULT_TOLERANCE_MM = 0.1 + + +class PathToleranceDialog(Adw.AlertDialog): + """ + Asks for the tolerance of a path clean-up operation and runs it on + confirmation. The last value used per operation is remembered for + the rest of the session. + """ + + _last_values: ClassVar[dict[str, float]] = {} + + def __init__( + self, + heading: str, + body: str, + apply_label: str, + key: str, + on_apply: Callable[[float], None], + ): + super().__init__() + self._key = key + self._on_apply = on_apply + + self.set_heading(heading) + self.set_body(body) + self.add_response("cancel", _("Cancel")) + self.add_response("apply", apply_label) + self.set_response_appearance("apply", Adw.ResponseAppearance.SUGGESTED) + self.set_default_response("apply") + self.set_close_response("cancel") + + self.tolerance_row = LengthSpinRow( + _("Tolerance"), + lower=0.001, + upper=10.0, + step_increment=0.01, + digits=3, + value_in_base=self._last_values.get(key, DEFAULT_TOLERANCE_MM), + ) + group = Adw.PreferencesGroup() + group.add(self.tolerance_row) + group.set_size_request(430, -1) + self.set_extra_child(group) + + self.connect("response", self._on_response) + + def get_tolerance_mm(self) -> float: + return self.tolerance_row.get_value_in_base_units() + + def _on_response(self, _dialog, response: str): + if response != "apply": + return + tolerance = self.get_tolerance_mm() + self._last_values[self._key] = tolerance + self._on_apply(tolerance) diff --git a/rayforge/ui_gtk/main_menu.py b/rayforge/ui_gtk/main_menu.py index 3abc15f51..bf0425778 100644 --- a/rayforge/ui_gtk/main_menu.py +++ b/rayforge/ui_gtk/main_menu.py @@ -112,9 +112,16 @@ def __init__(self): other_group = Gio.Menu() other_group.append(_("Split"), "win.split") + other_group.append(_("Break Apart"), "win.break-apart") other_group.append(_("Export Object..."), "win.export-object") object_menu.append_section(None, other_group) + cleanup_group = Gio.Menu() + cleanup_group.append(_("Close Paths…"), "win.close-paths") + cleanup_group.append(_("Join Open Paths…"), "win.join-paths") + cleanup_group.append(_("Delete Duplicates"), "win.delete-duplicates") + object_menu.append_section(None, cleanup_group) + # Addon section for Object menu self._addon_sections["object"] = Gio.Menu() object_menu.append_section(None, self._addon_sections["object"]) diff --git a/requirements.txt b/requirements.txt index b46963b07..db6e926a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ raydriver==0.3.0 -raygeo==1.62.0 +raygeo==1.63.0 aiohttp==3.14.3 asyncudp==0.11.0 blinker==1.9.0 diff --git a/scripts/screenshot/cli.py b/scripts/screenshot/cli.py index 1afeaa3ae..729357609 100755 --- a/scripts/screenshot/cli.py +++ b/scripts/screenshot/cli.py @@ -83,6 +83,7 @@ "main:array:grid": "array_grid", "main:array:point-rotation": "array_point_rotation", "main:array:circular": "array_circular", + "main:path-cleanup": "path_cleanup", "addons:sketcher:conflicts": "sketcher_constraints", "addons:sketcher:constraints": "sketcher_editor", "addons:sketcher:constraint:angle": "sketcher_constraints", diff --git a/scripts/screenshot/path_cleanup.py b/scripts/screenshot/path_cleanup.py new file mode 100644 index 000000000..0e226ff0a --- /dev/null +++ b/scripts/screenshot/path_cleanup.py @@ -0,0 +1,96 @@ +""" +Screenshot: Close Paths tolerance dialog with a vector workpiece selected. + +Usage: pixi run screenshot main:path-cleanup +""" + +import logging +import time +from gettext import gettext as _ + +from utils import ( + clear_window_subtitle, + load_project, + restore_config, + run_on_main_thread, + set_window_size, + take_window_screenshot, + wait_for_settled, +) + +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + + +def _select_all_items(): + """Select all content items on the active layer.""" + win.surface.update_from_doc() + layer = win.doc_editor.doc.active_layer + items = layer.get_content_items() + if items: + win.surface.select_items(items) + return items + + +def _open_close_paths_dialog(): + from rayforge.ui_gtk.doceditor.path_tolerance_dialog import ( + PathToleranceDialog, + ) + + def _apply(tolerance: float): + pass + + dialog = PathToleranceDialog( + heading=_("Close Paths"), + body=_( + "Closes open paths whose start and end point are closer " + "than the tolerance." + ), + apply_label=_("Close Paths"), + key="close-paths", + on_apply=_apply, + ) + dialog.present(win) + return dialog + + +@restore_config +def main(): + set_window_size(win, 2000, 1400) + + load_project(win, "contour.ryp") + logger.info("Waiting for document to settle...") + if not wait_for_settled(win, timeout=10): + logger.error("Document did not settle in time") + app.quit_idle() + return + + logger.info("Document settled") + + items = run_on_main_thread(_select_all_items) + if not items: + logger.error("No items found in document") + app.quit_idle() + return + + logger.info(f"Selected {len(items)} items") + + dialog = run_on_main_thread(_open_close_paths_dialog) + time.sleep(0.5) + + clear_window_subtitle(win) + + logger.info("Taking screenshot: main-path-cleanup.webp") + take_window_screenshot(win) + + time.sleep(0.25) + + def close_dialog(): + dialog.close() + + run_on_main_thread(close_dialog) + app.quit_idle() + + +main() diff --git a/tests/doceditor/test_path_cleanup_cmd.py b/tests/doceditor/test_path_cleanup_cmd.py new file mode 100644 index 000000000..8c8ce34c6 --- /dev/null +++ b/tests/doceditor/test_path_cleanup_cmd.py @@ -0,0 +1,195 @@ +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.path_cleanup_cmd import PathCleanupCmd +from rayforge.doceditor.split_cmd import ContourSplitStrategy + +WIDTH_MM = 100.0 +HEIGHT_MM = 50.0 + + +@pytest.fixture +def cleanup_cmd(doc_editor): + return PathCleanupCmd(doc_editor) + + +def _mm_polyline(points_mm, close=False) -> Geometry: + """A polyline given in mm, normalized to the 100x50 mm test box.""" + geo = Geometry() + pts = [(x / WIDTH_MM, y / HEIGHT_MM) for x, y in points_mm] + geo.move_to(*pts[0]) + for p in pts[1:]: + geo.line_to(*p) + if close: + geo.line_to(*pts[0]) + return geo + + +def _concat(*geos: Geometry) -> Geometry: + result = Geometry() + for geo in geos: + result.extend(geo) + return result + + +def _add_workpiece(doc_editor, norm_geo: Geometry, name="Logo", pos=(0, 0)): + segment = SourceAssetSegment( + source_asset_uid="dummy", + pristine_geometry=norm_geo, + vectorization_spec=PassthroughSpec(), + normalization_matrix=Matrix.identity(), + ) + wp = WorkPiece(name=name, source_segment=segment) + wp.natural_width_mm = WIDTH_MM + wp.natural_height_mm = HEIGHT_MM + wp.set_size(WIDTH_MM, HEIGHT_MM) + wp.pos = pos + doc_editor.doc.active_layer.add_child(wp) + return wp + + +def _contours(wp: WorkPiece) -> list[Geometry]: + assert wp.boundaries is not None + return wp.boundaries.split_into_contours() + + +# A 0.3 mm wide shape inset into the 100x50 mm box. The bounding box +# corners at (0, 0) and (100, 50) keep the normalization stable. +FRAME = [(0, 0), (100, 0), (100, 50), (0, 50)] +OPEN_SQUARE_MM = [(20, 10), (40, 10), (40, 30), (20, 30), (20, 10.05)] + + +class TestClosePaths: + def test_tolerance_is_in_millimetres(self, doc_editor, cleanup_cmd): + geo = _concat( + _mm_polyline(FRAME, close=True), _mm_polyline(OPEN_SQUARE_MM) + ) + wp = _add_workpiece(doc_editor, geo) + + assert cleanup_cmd.close_paths([wp], tolerance_mm=0.01) == 0 + assert not _contours(wp)[1].is_closed() + + assert cleanup_cmd.close_paths([wp], tolerance_mm=0.1) == 1 + assert _contours(wp)[1].is_closed() + + def test_is_one_undo_step(self, doc_editor, cleanup_cmd): + geo = _mm_polyline(OPEN_SQUARE_MM) + wp = _add_workpiece(doc_editor, geo) + history = doc_editor.history_manager + depth = len(history.undo_stack) + + cleanup_cmd.close_paths([wp], tolerance_mm=0.1) + assert len(history.undo_stack) == depth + 1 + assert _contours(wp)[0].is_closed() + + history.undo() + assert not _contours(wp)[0].is_closed() + + def test_no_change_adds_no_undo_step(self, doc_editor, cleanup_cmd): + wp = _add_workpiece(doc_editor, _mm_polyline(FRAME, close=True)) + history = doc_editor.history_manager + depth = len(history.undo_stack) + + assert cleanup_cmd.close_paths([wp], tolerance_mm=0.1) == 0 + assert len(history.undo_stack) == depth + + def test_keeps_size_and_position(self, doc_editor, cleanup_cmd): + wp = _add_workpiece(doc_editor, _mm_polyline(OPEN_SQUARE_MM)) + size, pos = wp.size, wp.pos + cleanup_cmd.close_paths([wp], tolerance_mm=0.1) + assert wp.size == pytest.approx(size) + assert wp.pos == pytest.approx(pos) + + def test_skips_sketch_based_workpieces(self, doc_editor, cleanup_cmd): + wp = _add_workpiece(doc_editor, _mm_polyline(OPEN_SQUARE_MM)) + wp.geometry_provider_uid = "some-sketch" + assert cleanup_cmd.close_paths([wp], tolerance_mm=0.1) == 0 + assert wp._edited_boundaries is None + + +class TestJoinPaths: + def test_joins_pieces_within_tolerance(self, doc_editor, cleanup_cmd): + geo = _concat( + _mm_polyline(FRAME, close=True), + _mm_polyline([(20, 10), (40, 10), (40, 30)]), + _mm_polyline([(20, 10), (20, 30), (40, 30.05)]), + ) + wp = _add_workpiece(doc_editor, geo) + + assert cleanup_cmd.join_paths([wp], tolerance_mm=0.1) == 1 + contours = _contours(wp) + assert len(contours) == 2 + assert contours[1].is_closed() + + doc_editor.history_manager.undo() + assert len(_contours(wp)) == 3 + + +class TestDeleteDuplicates: + def test_removes_duplicate_contour(self, doc_editor, cleanup_cmd): + geo = _concat( + _mm_polyline(FRAME, close=True), + _mm_polyline(OPEN_SQUARE_MM), + _mm_polyline(list(reversed(OPEN_SQUARE_MM))), + ) + wp = _add_workpiece(doc_editor, geo) + + assert cleanup_cmd.delete_duplicates([wp]) == 1 + assert len(_contours(wp)) == 2 + + doc_editor.history_manager.undo() + assert len(_contours(wp)) == 3 + + def test_removes_stacked_copy_of_workpiece(self, doc_editor, cleanup_cmd): + layer = doc_editor.doc.active_layer + geo = _mm_polyline(FRAME, close=True) + first = _add_workpiece(doc_editor, geo, name="A", pos=(10, 10)) + copy = _add_workpiece(doc_editor, geo.copy(), name="B", pos=(10, 10)) + moved = _add_workpiece(doc_editor, geo.copy(), name="C", pos=(50, 10)) + + assert cleanup_cmd.delete_duplicates([first, copy, moved]) == 1 + assert first in layer.children + assert copy not in layer.children + assert moved in layer.children + + doc_editor.history_manager.undo() + assert copy in layer.children + + def test_nothing_to_delete(self, doc_editor, cleanup_cmd): + wp = _add_workpiece(doc_editor, _mm_polyline(FRAME, close=True)) + depth = len(doc_editor.history_manager.undo_stack) + assert cleanup_cmd.delete_duplicates([wp]) == 0 + assert len(doc_editor.history_manager.undo_stack) == depth + + +class TestBreakApart: + def test_every_contour_becomes_a_workpiece(self, doc_editor, cleanup_cmd): + layer = doc_editor.doc.active_layer + inner = [(10, 10), (90, 10), (90, 40), (10, 40)] + geo = _concat( + _mm_polyline(FRAME, close=True), + _mm_polyline(inner, close=True), + _mm_polyline([(20, 20), (80, 20)]), + ) + wp = _add_workpiece(doc_editor, geo) + + new_items = cleanup_cmd.break_apart([wp]) + assert len(new_items) == 3 + assert wp not in layer.children + sizes = sorted(round(item.size[0]) for item in new_items) + assert sizes == [60, 80, 100] + + doc_editor.history_manager.undo() + assert wp in layer.children + + def test_contour_strategy_keeps_open_paths(self, doc_editor): + geo = _concat( + _mm_polyline(FRAME, close=True), + _mm_polyline([(20, 20), (80, 20)]), + ) + wp = _add_workpiece(doc_editor, geo) + fragments = ContourSplitStrategy().calculate_fragments(wp) + assert len(fragments) == 2 diff --git a/tests/ui_gtk/doceditor/test_path_tolerance_dialog.py b/tests/ui_gtk/doceditor/test_path_tolerance_dialog.py new file mode 100644 index 000000000..04d145c6a --- /dev/null +++ b/tests/ui_gtk/doceditor/test_path_tolerance_dialog.py @@ -0,0 +1,60 @@ +# flake8: noqa: E402 +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +import pytest + +from rayforge.ui_gtk.doceditor.path_tolerance_dialog import ( + DEFAULT_TOLERANCE_MM, + PathToleranceDialog, +) + + +@pytest.fixture(autouse=True) +def _reset_remembered_values(ui_context_initializer): + PathToleranceDialog._last_values.clear() + yield + PathToleranceDialog._last_values.clear() + + +def _dialog(calls: list[float], key="close-paths") -> PathToleranceDialog: + return PathToleranceDialog( + heading="Close Paths", + body="Body", + apply_label="Close", + key=key, + on_apply=calls.append, + ) + + +@pytest.mark.ui +def test_apply_runs_callback_with_tolerance(): + calls: list[float] = [] + dialog = _dialog(calls) + assert dialog.get_tolerance_mm() == pytest.approx(DEFAULT_TOLERANCE_MM) + + dialog.tolerance_row.set_value_in_base_units(0.25) + dialog.emit("response", "apply") + + assert calls == [pytest.approx(0.25)] + + +@pytest.mark.ui +def test_cancel_does_not_run_callback(): + calls: list[float] = [] + dialog = _dialog(calls) + dialog.emit("response", "cancel") + assert calls == [] + + +@pytest.mark.ui +def test_last_value_is_remembered_per_operation(): + first = _dialog([]) + first.tolerance_row.set_value_in_base_units(0.5) + first.emit("response", "apply") + + assert _dialog([]).get_tolerance_mm() == pytest.approx(0.5) + other = _dialog([], key="join-paths") + assert other.get_tolerance_mm() == pytest.approx(DEFAULT_TOLERANCE_MM) diff --git a/website/docs/features/path-cleanup.md b/website/docs/features/path-cleanup.md new file mode 100644 index 000000000..2468c09d6 --- /dev/null +++ b/website/docs/features/path-cleanup.md @@ -0,0 +1,55 @@ +--- +description: + "Clean up imported vector paths in Rayforge: close nearly closed paths, join open pieces, delete + duplicate shapes and break a workpiece apart into its contours." +--- + +# Path Clean-Up + +Logos and drawings from other programs are not always clean. An outline may stop a fraction of a +millimetre before its start point, a shape may be made of several open pieces, or the same path may +be in the file twice. Rayforge cuts such paths exactly as they are, so a shape that only looks +closed is treated as open, and a doubled path is cut twice. + +The path clean-up tools fix this on the selected workpieces. You find them in the **Object** menu +and under **Clean Up Paths** in the canvas context menu. Each tool is a single undo step. + +![The Close Paths dialog](/screenshots/main-path-cleanup.webp) + +## Close Paths + +**Close Paths…** closes every open path whose start and end point are closer than the tolerance you +enter. The gap is bridged with a straight line between the two existing points, so the shape does +not move or change size. + +## Join Open Paths + +**Join Open Paths…** connects open paths whose end points are closer than the tolerance into longer +paths. Pieces are reversed where needed, so the order and direction in which they were drawn do not +matter. When the ends of a joined path then meet, the path is closed as well. + +Both tools remember the last tolerance you used during the session. A tolerance between 0.05 mm and +0.2 mm works for most imported files. + +## Delete Duplicates + +**Delete Duplicates** removes paths that lie exactly on top of another path in the same workpiece, +regardless of their direction or start point. When you select several workpieces, a workpiece that +is an exact copy of another selected workpiece at the same position is removed too. + +Overlapping paths that only share part of their length are left alone. The +[Merge Lines](./merge-lines.md) post-processor takes care of those when the job is generated. + +## Break Apart + +**Break Apart** turns every path of a workpiece into a workpiece of its own, holes and open paths +included. This differs from **Split**, which keeps an island together with its holes. + +Path clean-up only works on imported and traced workpieces. Workpieces that come from a sketch are +edited in the [Sketcher](./sketcher/index.md) instead. + +## Related Pages + +- [Merge Lines](./merge-lines.md) - Cutting overlapping segments only once +- [Canvas Tools](../ui/canvas-tools.md) - Selecting and deleting individual segments +- [Importing Files](../files/importing.md) - Bringing designs into Rayforge diff --git a/website/docs/ui/canvas-tools.md b/website/docs/ui/canvas-tools.md index 81db70c84..5507b3afa 100644 --- a/website/docs/ui/canvas-tools.md +++ b/website/docs/ui/canvas-tools.md @@ -107,6 +107,9 @@ and delete individual vector segments of the workpiece. To exit vector editing mode, click outside the workpiece or press Escape. +To close gaps, join open pieces, delete duplicate paths or break a workpiece apart, use the +[Path Clean-Up](../features/path-cleanup.md) tools. + --- **Next**: [3D View →](3d-preview) diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/path-cleanup.md new file mode 100644 index 000000000..20585be1c --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -0,0 +1,54 @@ +# Pfade bereinigen + +Logos und Zeichnungen aus anderen Programmen sind nicht immer sauber. Ein Umriss kann einen +Bruchteil eines Millimeters vor seinem Startpunkt enden, eine Form kann aus mehreren offenen Teilen +bestehen, oder derselbe Pfad steht zweimal in der Datei. Rayforge schneidet solche Pfade genau so, +wie sie sind: Eine Form, die nur geschlossen aussieht, wird als offen behandelt, und ein doppelter +Pfad wird zweimal geschnitten. + +Die Werkzeuge zum Bereinigen von Pfaden beheben das bei den ausgewählten Werkstücken. Du findest sie +im Menü **Objekt** und im Kontextmenü der Arbeitsfläche unter **Pfade bereinigen**. Jedes Werkzeug +ist ein einzelner Rückgängig-Schritt. + +![Der Dialog „Pfade schließen“](/screenshots/main-path-cleanup.webp) + +## Pfade schließen + +**Pfade schließen…** schließt jeden offenen Pfad, dessen Anfangs- und Endpunkt näher beieinander +liegen als die eingegebene Toleranz. Die Lücke wird mit einer geraden Linie zwischen den beiden +vorhandenen Punkten überbrückt, sodass sich die Form weder verschiebt noch ihre Größe ändert. + +## Offene Pfade verbinden + +**Offene Pfade verbinden…** verbindet offene Pfade, deren Endpunkte näher beieinander liegen als die +Toleranz, zu längeren Pfaden. Teile werden bei Bedarf umgedreht, die Reihenfolge und Richtung, in +der sie gezeichnet wurden, spielt also keine Rolle. Treffen sich danach die Enden eines verbundenen +Pfads, wird er außerdem geschlossen. + +Beide Werkzeuge merken sich die zuletzt verwendete Toleranz für die laufende Sitzung. Für die +meisten importierten Dateien passt eine Toleranz zwischen 0,05 mm und 0,2 mm. + +## Duplikate löschen + +**Duplikate löschen** entfernt Pfade, die genau auf einem anderen Pfad im selben Werkstück liegen, +unabhängig von ihrer Richtung oder ihrem Startpunkt. Wenn du mehrere Werkstücke auswählst, wird auch +ein Werkstück entfernt, das eine exakte Kopie eines anderen ausgewählten Werkstücks an derselben +Position ist. + +Überlappende Pfade, die nur einen Teil ihrer Länge gemeinsam haben, bleiben erhalten. Um diese +kümmert sich der Postprozessor [Linien zusammenführen](./merge-lines.md), wenn der Auftrag erzeugt +wird. + +## Zerlegen + +**Zerlegen** macht aus jedem Pfad eines Werkstücks ein eigenes Werkstück, einschließlich Löchern und +offener Pfade. Das unterscheidet sich von **Teilen**, das eine Insel mit ihren Löchern zusammenhält. + +Das Bereinigen von Pfaden funktioniert nur bei importierten und nachgezeichneten Werkstücken. +Werkstücke aus einer Skizze bearbeitest du stattdessen im [Sketcher](./sketcher/index.md). + +## Verwandte Seiten + +- [Linien zusammenführen](./merge-lines.md) - Überlappende Segmente nur einmal schneiden +- [Canvas-Werkzeuge](../ui/canvas-tools.md) - Einzelne Segmente auswählen und löschen +- [Dateien importieren](../files/importing.md) - Designs in Rayforge laden 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 b8f7c4fe3..467296171 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 @@ -166,6 +166,9 @@ In diesem Modus kannst du einzelne Vektorsegmente des Werkstücks auswählen und Um den Vektorbearbeitungsmodus zu verlassen, klicke außerhalb des Werkstücks oder drücke Escape. +Um Lücken zu schließen, offene Teile zu verbinden, doppelte Pfade zu löschen oder ein Werkstück zu +zerlegen, verwende die Werkzeuge unter [Pfade bereinigen](../features/path-cleanup.md). + --- **Weiter**: [3D-Vorschau →](3d-preview) diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/path-cleanup.md new file mode 100644 index 000000000..d1fcbad2c --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -0,0 +1,52 @@ +# Limpieza de rutas + +Los logotipos y dibujos de otros programas no siempre están limpios. Un contorno puede terminar una +fracción de milímetro antes de su punto inicial, una forma puede estar hecha de varias piezas +abiertas o la misma ruta puede estar dos veces en el archivo. Rayforge corta esas rutas tal como +son: una forma que solo parece cerrada se trata como abierta y una ruta duplicada se corta dos +veces. + +Las herramientas de limpieza de rutas corrigen esto en las piezas de trabajo seleccionadas. Las +encontrarás en el menú **Objeto** y en **Limpiar rutas** del menú contextual del lienzo. Cada +herramienta es un solo paso de deshacer. + +![El diálogo de cerrar rutas](/screenshots/main-path-cleanup.webp) + +## Cerrar rutas + +**Cerrar rutas…** cierra cada ruta abierta cuyo punto inicial y final están más cerca que la +tolerancia que indiques. El hueco se salva con una línea recta entre los dos puntos existentes, así +que la forma no se mueve ni cambia de tamaño. + +## Unir rutas abiertas + +**Unir rutas abiertas…** une en rutas más largas las rutas abiertas cuyos extremos están más cerca +que la tolerancia. Las piezas se invierten cuando hace falta, así que el orden y la dirección en que +se dibujaron no importan. Si después los extremos de una ruta unida coinciden, la ruta también se +cierra. + +Ambas herramientas recuerdan la última tolerancia que usaste durante la sesión. Una tolerancia entre +0,05 mm y 0,2 mm funciona para la mayoría de los archivos importados. + +## Eliminar duplicados + +**Eliminar duplicados** quita las rutas que están exactamente encima de otra ruta de la misma pieza +de trabajo, sin importar su dirección o punto inicial. Si seleccionas varias piezas de trabajo, +también se quita una pieza que sea una copia exacta de otra pieza seleccionada en la misma posición. + +Las rutas que solo se superponen en parte de su longitud se mantienen. De ellas se encarga el +posprocesador [Fusionar líneas](./merge-lines.md) al generar el trabajo. + +## Separar + +**Separar** convierte cada ruta de una pieza de trabajo en una pieza propia, incluidos los agujeros +y las rutas abiertas. Esto es distinto de **Dividir**, que mantiene una isla junto con sus agujeros. + +La limpieza de rutas solo funciona en piezas de trabajo importadas y trazadas. Las piezas que vienen +de un boceto se editan en el [Sketcher](./sketcher/index.md). + +## Páginas relacionadas + +- [Fusionar líneas](./merge-lines.md) - Cortar los segmentos superpuestos una sola vez +- [Herramientas del lienzo](../ui/canvas-tools.md) - Seleccionar y eliminar segmentos individuales +- [Importar archivos](../files/importing.md) - Llevar diseños a Rayforge 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 5e3bf3edc..5e5b97d34 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 @@ -166,6 +166,9 @@ este modo, puedes seleccionar y eliminar segmentos de vector individuales de la Para salir del modo de edición de vectores, haz clic fuera de la pieza de trabajo o presiona Escape. +Para cerrar huecos, unir piezas abiertas, eliminar rutas duplicadas o separar una pieza de trabajo, +usa las herramientas de [Limpieza de rutas](../features/path-cleanup.md). + --- **Siguiente**: [Vista Previa 3D →](3d-preview) diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/path-cleanup.md new file mode 100644 index 000000000..ba547d4f6 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -0,0 +1,54 @@ +# Nettoyage des chemins + +Les logos et dessins provenant d'autres logiciels ne sont pas toujours propres. Un contour peut +s'arrêter une fraction de millimètre avant son point de départ, une forme peut être composée de +plusieurs morceaux ouverts, ou le même chemin peut figurer deux fois dans le fichier. Rayforge +découpe ces chemins tels quels : une forme qui semble seulement fermée est traitée comme ouverte, et +un chemin en double est découpé deux fois. + +Les outils de nettoyage des chemins corrigent cela sur les pièces sélectionnées. Vous les trouverez +dans le menu **Objet** et sous **Nettoyer les chemins** dans le menu contextuel du canevas. Chaque +outil correspond à une seule étape d'annulation. + +![La boîte de dialogue Fermer les chemins](/screenshots/main-path-cleanup.webp) + +## Fermer les chemins + +**Fermer les chemins…** ferme chaque chemin ouvert dont le point de départ et le point d'arrivée +sont plus proches que la tolérance saisie. L'écart est comblé par une ligne droite entre les deux +points existants : la forme ne se déplace pas et ne change pas de taille. + +## Joindre les chemins ouverts + +**Joindre les chemins ouverts…** relie en chemins plus longs les chemins ouverts dont les extrémités +sont plus proches que la tolérance. Les morceaux sont inversés si nécessaire, l'ordre et le sens +dans lesquels ils ont été dessinés n'ont donc pas d'importance. Si les extrémités d'un chemin joint +se rejoignent ensuite, le chemin est également fermé. + +Les deux outils retiennent la dernière tolérance utilisée pendant la session. Une tolérance comprise +entre 0,05 mm et 0,2 mm convient à la plupart des fichiers importés. + +## Supprimer les doublons + +**Supprimer les doublons** retire les chemins qui se trouvent exactement sur un autre chemin de la +même pièce, quels que soient leur sens ou leur point de départ. Si vous sélectionnez plusieurs +pièces, une pièce qui est la copie exacte d'une autre pièce sélectionnée à la même position est +également retirée. + +Les chemins qui ne se chevauchent que sur une partie de leur longueur sont conservés. Le +post-processeur [Fusionner les lignes](./merge-lines.md) s'en charge lors de la génération de la +tâche. + +## Décomposer + +**Décomposer** transforme chaque chemin d'une pièce en une pièce distincte, trous et chemins ouverts +compris. C'est différent de **Scinder**, qui garde un îlot avec ses trous. + +Le nettoyage des chemins ne fonctionne que sur les pièces importées et vectorisées. Les pièces +issues d'une esquisse se modifient dans le [Sketcher](./sketcher/index.md). + +## Pages connexes + +- [Fusionner les lignes](./merge-lines.md) - Ne découper qu'une fois les segments qui se chevauchent +- [Outils du canevas](../ui/canvas-tools.md) - Sélectionner et supprimer des segments individuels +- [Importer des fichiers](../files/importing.md) - Importer des dessins dans Rayforge 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 1d0a10b36..bca5b7210 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 @@ -168,6 +168,9 @@ vous pouvez sélectionner et supprimer des segments vectoriels individuels de la Pour quitter le mode d'édition vectorielle, cliquez en dehors de la pièce ou appuyez sur Échap. +Pour fermer des écarts, joindre des morceaux ouverts, supprimer des chemins en double ou décomposer +une pièce, utilisez les outils de [Nettoyage des chemins](../features/path-cleanup.md). + --- **Suivant** : [Prévisualisation 3D →](3d-preview) diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/path-cleanup.md new file mode 100644 index 000000000..803b48b45 --- /dev/null +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -0,0 +1,49 @@ +# पथ सफ़ाई + +दूसरे प्रोग्राम से आए लोगो और ड्रॉइंग हमेशा साफ़ नहीं होते. कोई आउटलाइन अपने आरंभ बिंदु से एक +मिलीमीटर के अंश पहले रुक सकती है, कोई आकृति कई खुले टुकड़ों से बनी हो सकती है, या एक ही पथ फ़ाइल में +दो बार हो सकता है. Rayforge ऐसे पथों को ठीक वैसे ही काटता है जैसे वे हैं: जो आकृति केवल बंद दिखती है +उसे खुला माना जाता है, और दोहरा पथ दो बार काटा जाता है. + +पथ सफ़ाई टूल चुने गए वर्कपीस पर इसे ठीक करते हैं. ये **ऑब्जेक्ट** मेनू में और कैनवस के संदर्भ मेनू +में **पथ साफ़ करें** के अंतर्गत मिलते हैं. हर टूल एक ही पूर्ववत चरण है. + +![पथ बंद करें संवाद](/screenshots/main-path-cleanup.webp) + +## पथ बंद करें + +**पथ बंद करें…** हर उस खुले पथ को बंद करता है जिसके आरंभ और अंत बिंदु आपकी दी गई सहनशीलता से अधिक +निकट हैं. अंतराल को दोनों मौजूदा बिंदुओं के बीच एक सीधी रेखा से जोड़ा जाता है, इसलिए आकृति न हिलती +है और न उसका आकार बदलता है. + +## खुले पथ जोड़ें + +**खुले पथ जोड़ें…** उन खुले पथों को लंबे पथों में जोड़ता है जिनके अंत बिंदु सहनशीलता से अधिक निकट +हैं. ज़रूरत होने पर टुकड़ों की दिशा उलट दी जाती है, इसलिए यह मायने नहीं रखता कि उन्हें किस क्रम और +दिशा में बनाया गया था. अगर जुड़े हुए पथ के सिरे फिर मिल जाते हैं, तो पथ बंद भी कर दिया जाता है. + +दोनों टूल सत्र के दौरान आपकी पिछली सहनशीलता याद रखते हैं. ज़्यादातर आयात की गई फ़ाइलों के लिए 0.05 +mm से 0.2 mm के बीच की सहनशीलता ठीक रहती है. + +## डुप्लिकेट हटाएँ + +**डुप्लिकेट हटाएँ** उन पथों को हटाता है जो उसी वर्कपीस के किसी दूसरे पथ के ठीक ऊपर हैं, चाहे उनकी +दिशा या आरंभ बिंदु कुछ भी हो. अगर आप कई वर्कपीस चुनते हैं, तो वह वर्कपीस भी हटा दिया जाता है जो उसी +स्थान पर किसी दूसरे चुने गए वर्कपीस की हूबहू प्रति है. + +जो पथ अपनी लंबाई के केवल एक हिस्से में ओवरलैप करते हैं, वे बने रहते हैं. जॉब बनाते समय +[रेखाएँ मर्ज करें](./merge-lines.md) पोस्ट-प्रोसेसर उन्हें संभालता है. + +## अलग-अलग करें + +**अलग-अलग करें** किसी वर्कपीस के हर पथ को उसका अपना वर्कपीस बना देता है, छेद और खुले पथ भी. यह +**विभाजित करें** से अलग है, जो किसी द्वीप को उसके छेदों के साथ एक रखता है. + +पथ सफ़ाई केवल आयात किए गए और ट्रेस किए गए वर्कपीस पर काम करती है. स्केच से बने वर्कपीस को +[स्केचर](./sketcher/index.md) में संपादित करें. + +## संबंधित पृष्ठ + +- [रेखाएँ मर्ज करें](./merge-lines.md) - ओवरलैप होने वाले खंडों को केवल एक बार काटना +- [कैनवस उपकरण](../ui/canvas-tools.md) - अलग-अलग खंड चुनना और हटाना +- [फ़ाइलें आयात करना](../files/importing.md) - Rayforge में डिज़ाइन लाना 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 f29e4baf2..718774312 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 @@ -108,6 +108,9 @@ description: वेक्टर संपादन मोड से बाहर निकलने के लिए, वर्कपीस के बाहर क्लिक करें या Escape दबाएँ. +अंतराल बंद करने, खुले टुकड़ों को जोड़ने, डुप्लिकेट पथ हटाने या किसी वर्कपीस को अलग-अलग करने के लिए +[पथ सफ़ाई](../features/path-cleanup.md) टूल का उपयोग करें. + --- **अगला:** [3D दृश्य →](3d-preview) diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/path-cleanup.md new file mode 100644 index 000000000..9e0893efc --- /dev/null +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -0,0 +1,53 @@ +# Limpeza de caminhos + +Logotipos e desenhos vindos de outros programas nem sempre estão limpos. Um contorno pode terminar +uma fração de milímetro antes do ponto inicial, uma forma pode ser composta de várias partes +abertas, ou o mesmo caminho pode aparecer duas vezes no arquivo. O Rayforge corta esses caminhos +exatamente como estão: uma forma que só parece fechada é tratada como aberta, e um caminho duplicado +é cortado duas vezes. + +As ferramentas de limpeza de caminhos corrigem isso nas peças de trabalho selecionadas. Você as +encontra no menu **Objeto** e em **Limpar caminhos** no menu de contexto da tela. Cada ferramenta é +um único passo de desfazer. + +![O diálogo de fechar caminhos](/screenshots/main-path-cleanup.webp) + +## Fechar caminhos + +**Fechar caminhos…** fecha cada caminho aberto cujos pontos inicial e final estão mais próximos do +que a tolerância informada. A lacuna é preenchida com uma linha reta entre os dois pontos +existentes, então a forma não se move nem muda de tamanho. + +## Unir caminhos abertos + +**Unir caminhos abertos…** junta em caminhos mais longos os caminhos abertos cujas extremidades +estão mais próximas do que a tolerância. As partes são invertidas quando necessário, então a ordem e +o sentido em que foram desenhadas não importam. Quando as extremidades de um caminho unido passam a +se encontrar, o caminho também é fechado. + +As duas ferramentas lembram a última tolerância usada durante a sessão. Uma tolerância entre 0,05 mm +e 0,2 mm funciona para a maioria dos arquivos importados. + +## Excluir duplicados + +**Excluir duplicados** remove caminhos que estão exatamente sobre outro caminho da mesma peça de +trabalho, independentemente do sentido ou do ponto inicial. Se você selecionar várias peças de +trabalho, uma peça que seja cópia exata de outra peça selecionada na mesma posição também é +removida. + +Caminhos que se sobrepõem apenas em parte do comprimento são mantidos. O pós-processador +[Mesclar linhas](./merge-lines.md) cuida deles quando o trabalho é gerado. + +## Separar + +**Separar** transforma cada caminho de uma peça de trabalho em uma peça própria, incluindo furos e +caminhos abertos. Isso é diferente de **Dividir**, que mantém uma ilha junto com seus furos. + +A limpeza de caminhos só funciona em peças de trabalho importadas e vetorizadas. Peças que vêm de um +esboço são editadas no [Sketcher](./sketcher/index.md). + +## Páginas relacionadas + +- [Mesclar linhas](./merge-lines.md) - Cortar segmentos sobrepostos apenas uma vez +- [Ferramentas de tela](../ui/canvas-tools.md) - Selecionar e excluir segmentos individuais +- [Importando arquivos](../files/importing.md) - Trazer desenhos para o Rayforge 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 ba7d15ed7..10245e011 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 @@ -166,6 +166,9 @@ modo, você pode selecionar e excluir segmentos de vetor individuais da peça de Para sair do modo de edição de vetores, clique fora da peça de trabalho ou pressione Escape. +Para fechar lacunas, unir partes abertas, excluir caminhos duplicados ou separar uma peça de +trabalho, use as ferramentas de [Limpeza de caminhos](../features/path-cleanup.md). + --- **Próximo**: [Visualização 3D →](3d-preview) diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/path-cleanup.md new file mode 100644 index 000000000..a87fc20a9 --- /dev/null +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -0,0 +1,50 @@ +# Очищення шляхів + +Логотипи й креслення з інших програм не завжди чисті. Контур може закінчуватися на частку міліметра +раніше за свою початкову точку, фігура може складатися з кількох відкритих частин, або той самий +шлях може бути у файлі двічі. Rayforge ріже такі шляхи саме такими, якими вони є: фігура, яка лише +здається замкненою, вважається відкритою, а подвоєний шлях ріжеться двічі. + +Інструменти очищення шляхів виправляють це для вибраних робочих деталей. Вони є в меню **Об'єкт** і +в контекстному меню полотна в розділі **Очистити шляхи**. Кожен інструмент — це один крок +скасування. + +![Діалогове вікно «Замкнути шляхи»](/screenshots/main-path-cleanup.webp) + +## Замкнути шляхи + +**Замкнути шляхи…** замикає кожен відкритий шлях, у якого початкова й кінцева точки ближчі за +введений допуск. Проміжок з'єднується прямою лінією між двома наявними точками, тож фігура не +зсувається і не змінює розміру. + +## З'єднати відкриті шляхи + +**З'єднати відкриті шляхи…** об'єднує в довші шляхи відкриті шляхи, кінцеві точки яких ближчі за +допуск. За потреби частини розвертаються, тож порядок і напрямок, у якому їх намалювали, не мають +значення. Якщо після цього кінці з'єднаного шляху збігаються, шлях також замикається. + +Обидва інструменти пам'ятають останній використаний допуск до кінця сеансу. Для більшості +імпортованих файлів підходить допуск від 0,05 мм до 0,2 мм. + +## Видалити дублікати + +**Видалити дублікати** прибирає шляхи, що лежать точно на іншому шляху тієї самої робочої деталі, +незалежно від їхнього напрямку чи початкової точки. Якщо вибрати кілька робочих деталей, то деталь, +яка є точною копією іншої вибраної деталі в тому самому місці, також видаляється. + +Шляхи, що перекриваються лише частиною своєї довжини, залишаються. Ними опікується постпроцесор +[Об'єднання ліній](./merge-lines.md) під час створення завдання. + +## Розібрати на частини + +**Розібрати на частини** робить із кожного шляху робочої деталі окрему деталь, включно з отворами й +відкритими шляхами. Це відрізняється від **Розділити**, яке тримає острів разом з його отворами. + +Очищення шляхів працює лише з імпортованими та трасованими робочими деталями. Деталі, створені зі +скетчу, редагуйте у [Sketcher](./sketcher/index.md). + +## Пов'язані сторінки + +- [Об'єднання ліній](./merge-lines.md) - Різати сегменти, що перекриваються, лише один раз +- [Інструменти полотна](../ui/canvas-tools.md) - Вибір і видалення окремих сегментів +- [Імпортування файлів](../files/importing.md) - Завантаження дизайнів у Rayforge 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 1dcf8c81b..fd5f6050a 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 @@ -103,6 +103,9 @@ Щоб вийти з режиму редагування векторів, клацніть поза робочою деталлю або натисніть Escape. +Щоб закрити проміжки, з'єднати відкриті частини, видалити дублікати шляхів або розібрати робочу +деталь на частини, скористайтеся інструментами [Очищення шляхів](../features/path-cleanup.md). + --- **Далі**: [3D вид →](3d-preview) diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/path-cleanup.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/path-cleanup.md new file mode 100644 index 000000000..0bd793333 --- /dev/null +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/path-cleanup.md @@ -0,0 +1,36 @@ +# 清理路径 + +来自其他软件的标志和图纸并不总是干净的。轮廓可能在距起点不到一毫米的地方就停止了,一个形状可能由多段开放的片段组成,或者同一条路径在文件中出现了两次。Rayforge 会按原样切割这些路径:看起来闭合但实际未闭合的形状会被当作开放路径处理,重复的路径会被切割两次。 + +路径清理工具可以修复所选工件中的这些问题。您可以在**对象**菜单中,以及画布右键菜单的**清理路径**下找到它们。每个工具都是一个撤销步骤。 + +![闭合路径对话框](/screenshots/main-path-cleanup.webp) + +## 闭合路径 + +**闭合路径…**会闭合所有起点与终点距离小于您输入的公差的开放路径。缺口由两个现有点之间的一条直线连接,因此形状不会移动,也不会改变大小。 + +## 连接开放路径 + +**连接开放路径…**会把端点距离小于公差的开放路径连接成更长的路径。必要时会反转片段方向,因此片段的绘制顺序和方向都无关紧要。如果连接后的路径首尾相接,该路径也会被闭合。 + +两个工具都会在本次会话中记住您上次使用的公差。对大多数导入的文件来说,0.05 mm 到 0.2 +mm 之间的公差即可。 + +## 删除重复项 + +**删除重复项**会删除与同一工件中另一条路径完全重合的路径,无论其方向或起点如何。如果您选择了多个工件,与另一个所选工件位置相同且完全相同的工件也会被删除。 + +只有部分长度重叠的路径会被保留。生成作业时,[合并线条](./merge-lines.md)后处理器会处理这些路径。 + +## 打散 + +**打散**会把工件的每条路径都变成一个独立的工件,包括孔和开放路径。这与**分割**不同,分割会把一个岛与它的孔保留在一起。 + +路径清理只适用于导入和描摹得到的工件。来自草图的工件请在[草图编辑器](./sketcher/index.md)中编辑。 + +## 相关页面 + +- [合并线条](./merge-lines.md) - 重叠的线段只切割一次 +- [画布工具](../ui/canvas-tools.md) - 选择和删除单个线段 +- [导入文件](../files/importing.md) - 将设计导入 Rayforge 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 f9fdc7b05..16a451793 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 @@ -159,6 +159,8 @@ 要退出矢量编辑模式,请点击工件外部或按 Escape。 +要闭合缺口、连接开放的片段、删除重复路径或将工件打散,请使用[清理路径](../features/path-cleanup.md)工具。 + --- **下一步:**[3D 预览 →](3d-preview) diff --git a/website/sidebars.js b/website/sidebars.js index c1577ae78..a293e104e 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -102,6 +102,7 @@ module.exports = { ], }, 'features/arrays', + 'features/path-cleanup', 'features/multi-layer', 'features/stock-handling', 'features/framing-your-job', diff --git a/website/static/screenshots/main-path-cleanup.webp b/website/static/screenshots/main-path-cleanup.webp new file mode 100644 index 000000000..85a6a0610 Binary files /dev/null and b/website/static/screenshots/main-path-cleanup.webp differ