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.
+
+
+
+## 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.
+
+
+
+## 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.
+
+
+
+## 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.
+
+
+
+## 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 ऐसे पथों को ठीक वैसे ही काटता है जैसे वे हैं: जो आकृति केवल बंद दिखती है
+उसे खुला माना जाता है, और दोहरा पथ दो बार काटा जाता है.
+
+पथ सफ़ाई टूल चुने गए वर्कपीस पर इसे ठीक करते हैं. ये **ऑब्जेक्ट** मेनू में और कैनवस के संदर्भ मेनू
+में **पथ साफ़ करें** के अंतर्गत मिलते हैं. हर टूल एक ही पूर्ववत चरण है.
+
+
+
+## पथ बंद करें
+
+**पथ बंद करें…** हर उस खुले पथ को बंद करता है जिसके आरंभ और अंत बिंदु आपकी दी गई सहनशीलता से अधिक
+निकट हैं. अंतराल को दोनों मौजूदा बिंदुओं के बीच एक सीधी रेखा से जोड़ा जाता है, इसलिए आकृति न हिलती
+है और न उसका आकार बदलता है.
+
+## खुले पथ जोड़ें
+
+**खुले पथ जोड़ें…** उन खुले पथों को लंबे पथों में जोड़ता है जिनके अंत बिंदु सहनशीलता से अधिक निकट
+हैं. ज़रूरत होने पर टुकड़ों की दिशा उलट दी जाती है, इसलिए यह मायने नहीं रखता कि उन्हें किस क्रम और
+दिशा में बनाया गया था. अगर जुड़े हुए पथ के सिरे फिर मिल जाते हैं, तो पथ बंद भी कर दिया जाता है.
+
+दोनों टूल सत्र के दौरान आपकी पिछली सहनशीलता याद रखते हैं. ज़्यादातर आयात की गई फ़ाइलों के लिए 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.
+
+
+
+## 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 ріже такі шляхи саме такими, якими вони є: фігура, яка лише
+здається замкненою, вважається відкритою, а подвоєний шлях ріжеться двічі.
+
+Інструменти очищення шляхів виправляють це для вибраних робочих деталей. Вони є в меню **Об'єкт** і
+в контекстному меню полотна в розділі **Очистити шляхи**. Кожен інструмент — це один крок
+скасування.
+
+
+
+## Замкнути шляхи
+
+**Замкнути шляхи…** замикає кожен відкритий шлях, у якого початкова й кінцева точки ближчі за
+введений допуск. Проміжок з'єднується прямою лінією між двома наявними точками, тож фігура не
+зсувається і не змінює розміру.
+
+## З'єднати відкриті шляхи
+
+**З'єднати відкриті шляхи…** об'єднує в довші шляхи відкриті шляхи, кінцеві точки яких ближчі за
+допуск. За потреби частини розвертаються, тож порядок і напрямок, у якому їх намалювали, не мають
+значення. Якщо після цього кінці з'єднаного шляху збігаються, шлях також замикається.
+
+Обидва інструменти пам'ятають останній використаний допуск до кінця сеансу. Для більшості
+імпортованих файлів підходить допуск від 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 会按原样切割这些路径:看起来闭合但实际未闭合的形状会被当作开放路径处理,重复的路径会被切割两次。
+
+路径清理工具可以修复所选工件中的这些问题。您可以在**对象**菜单中,以及画布右键菜单的**清理路径**下找到它们。每个工具都是一个撤销步骤。
+
+
+
+## 闭合路径
+
+**闭合路径…**会闭合所有起点与终点距离小于您输入的公差的开放路径。缺口由两个现有点之间的一条直线连接,因此形状不会移动,也不会改变大小。
+
+## 连接开放路径
+
+**连接开放路径…**会把端点距离小于公差的开放路径连接成更长的路径。必要时会反转片段方向,因此片段的绘制顺序和方向都无关紧要。如果连接后的路径首尾相接,该路径也会被闭合。
+
+两个工具都会在本次会话中记住您上次使用的公差。对大多数导入的文件来说,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