diff --git a/CHANGELOG.md b/CHANGELOG.md index d50890289..d16ae179e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,9 +51,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 back to the network - Linux: official AppImage builds are attached to GitHub releases - Windows: Publish Rayforge through WinGet ("winget install rayforge") +- Image Editing addon: non-destructive image edits stored per + workpiece and applied wherever its image is used (canvas, 3D preview, + operations). The first edit is Tools > Invert Image (#515) +- Addons: the `register_image_edits` hook and image edit registry + (plugin API 23) let addons provide their own image edits ### Changed +- Engrave: Invert moved from the operation to the workpiece, as the + Invert Image edit. Older projects are converted when they are opened, + so their output stays the same (#515) + - Machines: the GRBL Serial driver now runs its entire protocol stack (handshake, character-counting flow control, streaming, stall detection and deadlock recovery) in Rust through the raydriver diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/__init__.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/__init__.py new file mode 100644 index 000000000..4d02767d3 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/__init__.py @@ -0,0 +1 @@ +"""Non-destructive image editing tools for workpieces.""" diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py new file mode 100644 index 000000000..cc44fdc7c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/commands.py @@ -0,0 +1,44 @@ +from collections.abc import Sequence + +from rayforge.core.image_edit import ImageEditStep +from rayforge.core.undo import ChangePropertyCommand, HistoryManager +from rayforge.core.workpiece import WorkPiece + + +def _toggled( + edits: tuple[ImageEditStep, ...], edit: ImageEditStep, add: bool +) -> tuple[ImageEditStep, ...]: + if add: + return edits if edit in edits else (*edits, edit) + return tuple(e for e in edits if e != edit) + + +def toggle_image_edit( + history: HistoryManager, + workpieces: Sequence[WorkPiece], + edit: ImageEditStep, + name: str, +) -> bool: + """ + Removes ``edit`` when every workpiece already has it and appends it + to the others otherwise, as one undo step. Returns True when the + edit was added. + """ + if not workpieces: + return False + add = any(edit not in wp.image_edits for wp in workpieces) + with history.transaction(name) as t: + for wp in workpieces: + new_edits = _toggled(wp.image_edits, edit, add) + if new_edits == wp.image_edits: + continue + t.execute( + ChangePropertyCommand( + target=wp, + property_name="image_edits", + new_value=new_edits, + old_value=wp.image_edits, + name=name, + ) + ) + return add diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py new file mode 100644 index 000000000..a1bb9a23c --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/frontend.py @@ -0,0 +1,52 @@ +""" +Frontend entry point for the image_editing addon. + +Adds the image editing tools to the Tools menu. They act on the selected +workpieces and are undoable. +""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING + +from gi.repository import Gio + +from rayforge.core.hooks import hookimpl +from rayforge.ui_gtk.action_registry import MenuPlacement + +from .commands import toggle_image_edit +from .ops import INVERT + +if TYPE_CHECKING: + from rayforge.ui_gtk.actions import ActionManager + +ADDON_NAME = "image_editing" +INVERT_ACTION = "invert-image" + + +def _has_selection(action_manager: "ActionManager") -> bool: + return bool(action_manager.win.surface.get_selected_workpieces()) + + +@hookimpl +def register_actions(action_registry): + """Register actions with menu placement.""" + action = Gio.SimpleAction.new(INVERT_ACTION, None) + + def on_activate(action, param): + window = action_registry.window + toggle_image_edit( + window.doc_editor.history_manager, + window.surface.get_selected_workpieces(), + INVERT, + _("Invert Image"), + ) + + action.connect("activate", on_activate) + action_registry.register( + action_name=INVERT_ACTION, + action=action, + addon_name=ADDON_NAME, + label=_("Invert Image"), + menu=MenuPlacement(menu_id="tools", priority=60), + enabled_when=_has_selection, + ) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py new file mode 100644 index 000000000..92dbaaace --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/ops.py @@ -0,0 +1,29 @@ +from collections.abc import Mapping +from gettext import gettext as _ +from typing import Any + +import pyvips + +from rayforge.core.image_edit import ImageEditOp, ImageEditStep + + +class InvertOp(ImageEditOp): + """Inverts the colour of the image and leaves its alpha alone.""" + + NAME = "invert" + LABEL = _("Invert") + + @classmethod + def apply( + cls, image: pyvips.Image, params: Mapping[str, Any] + ) -> pyvips.Image: + if not image.hasalpha(): + return image.invert() + color = image.extract_band(0, n=image.bands - 1) + alpha = image.extract_band(image.bands - 1) + return color.invert().bandjoin(alpha) + + +INVERT = ImageEditStep(InvertOp.NAME) + +ALL_OPS = (InvertOp,) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/worker.py b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/worker.py new file mode 100644 index 000000000..19d360f7b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/image_editing/worker.py @@ -0,0 +1,19 @@ +""" +Backend entry point for the image_editing addon. + +Registers the image edit ops, which the pipeline needs in every process +that renders workpieces. +""" + +from rayforge.core.hooks import hookimpl + +from .ops import ALL_OPS + +ADDON_NAME = "image_editing" + + +@hookimpl +def register_image_edits(image_edit_registry): + """Register the image edit ops with the image edit registry.""" + for op_class in ALL_OPS: + image_edit_registry.register(op_class, addon_name=ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/rayforge-addon.yaml b/rayforge/builtin_addons/rayforge-addon-image-editing/rayforge-addon.yaml new file mode 100644 index 000000000..59ab13cc2 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/rayforge-addon.yaml @@ -0,0 +1,12 @@ +name: image_editing +display_name: "Image Editing" +description: "Non-destructive image editing tools for workpieces" +api_version: 23 +author: + name: "Rayforge Team" + email: "noreply@rayforge.org" +provides: + worker: "image_editing.worker" + frontend: "image_editing.frontend" +license: + name: "MIT" diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py new file mode 100644 index 000000000..610cc9fd8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/conftest.py @@ -0,0 +1,18 @@ +""" +Pytest configuration for image_editing builtin addon tests. +""" + +import pytest +from image_editing.ops import InvertOp + +from rayforge.core.image_edit import image_edit_registry + +ADDON_NAME = "image_editing" + + +@pytest.fixture(scope="session", autouse=True) +def register_image_edits(): + """Registers the addon's image edit ops for all tests.""" + image_edit_registry.register(InvertOp, addon_name=ADDON_NAME) + yield + image_edit_registry.unregister_all_from_addon(ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py new file mode 100644 index 000000000..2a975e189 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_commands.py @@ -0,0 +1,51 @@ +from image_editing.commands import toggle_image_edit +from image_editing.ops import INVERT + +from rayforge.core.image_edit import ImageEditStep +from rayforge.core.undo import HistoryManager +from rayforge.core.workpiece import WorkPiece + +OTHER = ImageEditStep("other") + + +def test_adds_the_edit_to_every_workpiece(): + a, b = WorkPiece("a"), WorkPiece("b") + b.image_edits = [OTHER] + assert toggle_image_edit(HistoryManager(), [a, b], INVERT, "Invert") + assert a.image_edits == (INVERT,) + assert b.image_edits == (OTHER, INVERT) + + +def test_removes_the_edit_when_every_workpiece_has_it(): + a, b = WorkPiece("a"), WorkPiece("b") + a.image_edits = [INVERT] + b.image_edits = [OTHER, INVERT] + assert not toggle_image_edit(HistoryManager(), [a, b], INVERT, "Invert") + assert a.image_edits == () + assert b.image_edits == (OTHER,) + + +def test_mixed_selection_adds_where_missing(): + a, b = WorkPiece("a"), WorkPiece("b") + a.image_edits = [INVERT] + assert toggle_image_edit(HistoryManager(), [a, b], INVERT, "Invert") + assert a.image_edits == (INVERT,) + assert b.image_edits == (INVERT,) + + +def test_one_undo_step_restores_everything(): + history = HistoryManager() + a, b = WorkPiece("a"), WorkPiece("b") + toggle_image_edit(history, [a, b], INVERT, "Invert") + history.undo() + assert a.image_edits == () + assert b.image_edits == () + history.redo() + assert a.image_edits == (INVERT,) + assert b.image_edits == (INVERT,) + + +def test_empty_selection_does_nothing(): + history = HistoryManager() + assert not toggle_image_edit(history, [], INVERT, "Invert") + assert not history.can_undo() diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py new file mode 100644 index 000000000..7851b6296 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_ops.py @@ -0,0 +1,33 @@ +import numpy as np +import pyvips +from image_editing.ops import INVERT, InvertOp + +from rayforge.core.image_edit import image_edit_registry + + +def _image(*bands: int) -> pyvips.Image: + return pyvips.Image.new_from_array(np.array([[bands]], dtype=np.uint8)) + + +def _pixel(image: pyvips.Image) -> list[float]: + return list(image(0, 0)) + + +def test_invert_is_registered_under_its_stable_name(): + assert INVERT.op == "invert" + assert image_edit_registry.get("invert") is InvertOp + + +def test_invert_keeps_alpha(): + rgba = _image(10, 20, 30, 128).copy(interpretation="srgb") + assert _pixel(InvertOp.apply(rgba, {})) == [245, 235, 225, 128] + + +def test_invert_without_alpha(): + rgb = _image(0, 100, 255).copy(interpretation="srgb") + assert _pixel(InvertOp.apply(rgb, {})) == [255, 155, 0] + + +def test_invert_grayscale_with_alpha(): + gray_alpha = _image(40, 0).copy(interpretation="b-w") + assert _pixel(InvertOp.apply(gray_alpha, {})) == [215, 0] diff --git a/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py new file mode 100644 index 000000000..ce1914470 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-image-editing/tests/test_raster_equivalence.py @@ -0,0 +1,52 @@ +""" +The Invert edit replaces the Engrave step's old Invert setting; these +tests pin the behaviour that setting had. +""" + +import numpy as np +import pyvips +from image_editing.ops import InvertOp + +from rayforge.image.dither import DitherAlgorithm +from rayforge.image.util import normalize_to_rgba, vips_rgba_to_cairo_surface +from rayforge.pipeline.stage.assembler_helpers import ( + DepthMode, + preprocess_raster_image, +) + + +def _white_left_half_on_transparent() -> pyvips.Image: + pixels = np.zeros((10, 10, 4), dtype=np.uint8) + pixels[:, :5] = (255, 255, 255, 255) + return pyvips.Image.new_from_array(pixels).copy(interpretation="srgb") + + +def _surface(image: pyvips.Image): + return vips_rgba_to_cairo_surface(normalize_to_rgba(image)) + + +def test_inverted_dither_engraves_light_areas_only(): + inverted = InvertOp.apply(_white_left_half_on_transparent(), {}) + image, _ = preprocess_raster_image( + _surface(inverted), + mode=DepthMode.DITHER, + auto_levels=False, + dither_algorithm=DitherAlgorithm.BAYER2, + laser_spot_x_mm=0.1, + pixels_per_mm_x=10.0, + ) + assert image is not None + assert np.all(image[:, :5] == 1) + assert np.all(image[:, 5:] == 0) + + +def test_inverted_power_modulation_flips_visible_pixels_only(): + original = _white_left_half_on_transparent() + gray, alpha = preprocess_raster_image( + _surface(InvertOp.apply(original, {})), + mode=DepthMode.POWER_MODULATION, + auto_levels=False, + ) + assert gray is not None and alpha is not None + assert np.all(gray[:, :5] == 0) + assert np.all(alpha[:, 5:] == 0) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py index 1e1fced13..ada626486 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/raster_step.py @@ -16,6 +16,7 @@ from raygeo.ops.part.image_source import WholeImageSource from rayforge.core.capability import MachineCapability +from rayforge.core.image_edit import ImageEditStep from rayforge.core.step import legacy_producer_params from rayforge.core.varset import ( AngleVar, @@ -229,14 +230,6 @@ def uses_levels(v): max_val=5.0, digits=3, ), - BoolVar( - key="invert", - label=_("Invert"), - description=_( - "Engrave white areas instead of black areas" - ), - default=False, - ), IntVar( key="num_depth_levels", label=_("Number of Depth Levels"), @@ -335,7 +328,7 @@ def __init__(self, name: str | None = None, typelabel: str | None = None): self.power = 0.2 self.scan_angle = 0.0 self.depth_mode = "POWER_MODULATION" - self.invert = False + self._legacy_invert = False self.auto_levels = True self.black_point = 0 self.white_point = 255 @@ -530,7 +523,6 @@ def get_cache_params(self) -> dict: params = super().get_cache_params() params.update( { - "invert": self.invert, "auto_levels": self.auto_levels, "black_point": self.black_point, "white_point": self.white_point, @@ -547,11 +539,21 @@ def get_cache_params(self) -> dict: ) return params + def pop_legacy_image_edits(self) -> list[ImageEditStep]: + """ + Older files stored Invert on the step. It is now the "invert" + image edit on the workpieces, which reproduces it for the + visible pixels. + """ + if not self._legacy_invert: + return [] + self._legacy_invert = False + return [_LEGACY_INVERT] + def to_dict(self) -> dict: result = super().to_dict() result["scan_angle"] = self.scan_angle result["depth_mode"] = self.depth_mode - result["invert"] = self.invert result["auto_levels"] = self.auto_levels result["black_point"] = self.black_point result["white_point"] = self.white_point @@ -594,7 +596,9 @@ def from_dict(cls, data: dict) -> EngraveStep: step.depth_mode = data.get( "depth_mode", legacy.get("depth_mode", "POWER_MODULATION") ) - step.invert = data.get("invert", legacy.get("invert", False)) + step._legacy_invert = bool( + data.get("invert", legacy.get("invert", False)) + ) step.auto_levels = data.get( "auto_levels", legacy.get("auto_levels", True) ) @@ -831,13 +835,11 @@ def _build_raster_part( computed_auto_levels = compute_raster_auto_levels( workpiece, (px_per_mm_x, px_per_mm_y), - invert=step.invert, ) image, alpha = preprocess_raster_image( surface, mode=depth_mode, - invert=step.invert, auto_levels=step.auto_levels, computed_auto_levels=computed_auto_levels, black_point=step.black_point, @@ -864,3 +866,5 @@ def _build_raster_part( MAX_RASTER_RENDER_PIXELS = 16 * 1024 * 1024 + +_LEGACY_INVERT = ImageEditStep("invert") diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/levels_adapter.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/levels_adapter.py index fbc97648c..4d6ed82a5 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/levels_adapter.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/levels_adapter.py @@ -33,9 +33,9 @@ class LevelsAdapter(RowAdapter): mode. The adapter also owns the histogram data: the dialog points it at - the step via :meth:`set_histogram_source` and it (re)computes the - histogram from the step's layer whenever ``invert`` changes (the - value arrives through :meth:`update_from_values`). + the step via :meth:`set_histogram_source` and it computes the + histogram from the step's layer, as the workpieces render with their + image edits applied. It edits two step attributes at once: @@ -58,8 +58,6 @@ def __init__( self._row = row self.preview = preview self._step: Any = None - self._last_invert: bool | None = None - self._histogram_ready = False preview.black_point_changed.connect( lambda s, **kw: self.changed.send(self), weak=False, @@ -99,8 +97,6 @@ def compute_histogram(self) -> None: step = self._step if step is None: return - invert = bool(self._last_invert or False) - layer = step.layer if not layer: self.preview.update_histogram(None) @@ -137,7 +133,7 @@ def compute_histogram(self) -> None: if not surface: continue - gray_values = get_visible_grayscale_values(surface, invert) + gray_values = get_visible_grayscale_values(surface) if gray_values.size > 0: all_gray_values.append(gray_values) @@ -153,7 +149,6 @@ def compute_histogram(self) -> None: auto_black, auto_white = compute_auto_levels(combined_gray) self.preview.set_auto_points(auto_black, auto_white) - self._histogram_ready = True def get_value(self) -> Any | None: return self.preview.black_point @@ -180,19 +175,12 @@ def update_from_var(self, var: Var): self._row.set_subtitle(escape_title(var.description)) def update_from_values(self, values: dict[str, Any]) -> None: - """Sync the preview's auto mode from the sibling auto_levels, - and recompute the histogram when invert changes.""" + """Sync the preview's auto mode from the sibling auto_levels.""" auto_mode = bool(values.get("auto_levels", True)) if auto_mode != self.preview.auto_mode: self.preview.auto_mode = auto_mode self._update_subtitle() - invert = values.get("invert") - if invert is not None and invert != self._last_invert: - self._last_invert = bool(invert) - if self._histogram_ready: - self.compute_histogram() - def _update_subtitle(self): if self.preview.auto_mode: self._row.set_subtitle( diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py index 124671f8a..62b289e6e 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_page.py @@ -29,7 +29,6 @@ "sample_interval_mm", "dot_width_correction_mm", "bidir_x_offset_mm", - "invert", "num_depth_levels", "z_step_down", "angle_increment", @@ -79,18 +78,16 @@ def __init__( widget_cls=RasterPowerWidget, ) - # The Power section's visible_when predicates and histogram - # depend on depth_mode and invert, whose rows live in the - # Engrave section. Feed them in as context; the adapters fire - # changed synchronously on user interaction and on resync. + # The Power section's visible_when predicates depend on + # depth_mode, whose row lives in the Engrave section. Feed it in + # as context; the adapter fires changed synchronously on user + # interaction and on resync. self._sync_power_context() - for key in ("depth_mode", "invert"): - adapter = self.engrave_widget.adapter_for(key) - if adapter is not None: - adapter.changed.connect( - lambda sender, k=key: self._sync_power_context(), - weak=False, - ) + adapter = self.engrave_widget.adapter_for("depth_mode") + if adapter is not None: + adapter.changed.connect( + lambda sender: self._sync_power_context(), weak=False + ) levels = self.power_widget.adapter_for("black_point") if isinstance(levels, LevelsAdapter): @@ -120,13 +117,10 @@ def _push_head_defaults(self): self.engrave_widget.set_values(values) def _sync_power_context(self): - """Feed the current depth_mode/invert into the Power section.""" + """Feed the current depth_mode into the Power section.""" values = self.engrave_widget.get_values() self.power_widget.set_context_values( - { - "depth_mode": values.get("depth_mode"), - "invert": values.get("invert"), - } + {"depth_mode": values.get("depth_mode")} ) def _on_varset_data_changed(self, widget, key): diff --git a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_power_widget.py b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_power_widget.py index 47740906d..8717e853e 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_power_widget.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/widgets/raster_power_widget.py @@ -1,10 +1,7 @@ """Custom varset widget for the raster "Power" section. -Owns the invert-dependent min/max power labels: the min/max rows -describe the lightest/darkest areas, and which is which flips with the -``invert`` setting. The invert row lives in the Engrave section, so -its value arrives here through :meth:`set_context_values`; this widget -re-titles its rows whenever visibility is re-evaluated. +Titles the min/max power rows after the lightest and darkest areas of +the image, and hides the section when no row is visible. """ from gettext import gettext as _ @@ -19,53 +16,41 @@ class RasterPowerWidget(VarSetWidget): """The raster dialog's "Power" section. - Re-titles the min/max power rows based on the ``invert`` context - value. Visibility itself is handled by the base machinery, which - calls :meth:`_update_visibility` after populate, value changes and - context pushes. + Visibility itself is handled by the base machinery, which calls + :meth:`_update_visibility` after populate, value changes and context + pushes. """ def _update_visibility(self): super()._update_visibility() - self._update_power_labels( - bool(self._context_values.get("invert", False)) - ) + self._update_power_labels() # Modes without power modulation or levels (constant power) # leave every row hidden; hide the empty section entirely. self.set_visible( any(row.get_visible() for row, _var in self.widget_map.values()) ) - def _update_power_labels(self, invert: bool): - """Update min/max power labels based on invert setting.""" - lightest_subtitle = _( - "Power for lightest areas, as a percentage of the step's " - "main power" + def _update_power_labels(self): + """Title the min/max rows after the areas they drive.""" + self._set_row_text( + "min_power_level", + _("Min Power (White)"), + _( + "Power for lightest areas, as a percentage of the step's " + "main power" + ), ) - darkest_subtitle = _( - "Power for darkest areas, as a percentage of the step's main power" + self._set_row_text( + "max_power_level", + _("Max Power (Black)"), + _( + "Power for darkest areas, as a percentage of the step's " + "main power" + ), ) - if invert: - min_title = _("Min Power (Black)") - min_subtitle = darkest_subtitle - max_title = _("Max Power (White)") - max_subtitle = lightest_subtitle - else: - min_title = _("Min Power (White)") - min_subtitle = lightest_subtitle - max_title = _("Max Power (Black)") - max_subtitle = darkest_subtitle - - min_row = self.row_for("min_power_level") - if min_row is not None: - min_row.set_title(escape_title(min_title)) - cast(Adw.ActionRow, min_row).set_subtitle( - escape_title(min_subtitle) - ) - max_row = self.row_for("max_power_level") - if max_row is not None: - max_row.set_title(escape_title(max_title)) - cast(Adw.ActionRow, max_row).set_subtitle( - escape_title(max_subtitle) - ) + def _set_row_text(self, key: str, title: str, subtitle: str): + row = self.row_for(key) + if row is not None: + row.set_title(escape_title(title)) + cast(Adw.ActionRow, row).set_subtitle(escape_title(subtitle)) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py index 8c7591621..8c3d195d9 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_raster_step.py @@ -10,6 +10,8 @@ from raygeo.ops.part import Part from raygeo.ops.types import CommandCategory, CommandType +from rayforge.core.doc import Doc +from rayforge.core.image_edit import ImageEditStep from rayforge.core.step_registry import step_registry from rayforge.core.varset import LabeledChoiceVar from rayforge.core.workpiece import WorkPiece @@ -17,6 +19,8 @@ from rayforge.pipeline.transformer import OpsTransformer from rayforge.pipeline.transformer.registry import transformer_registry +LEGACY_INVERT = ImageEditStep("invert") + @pytest.fixture def mock_context(): @@ -204,7 +208,7 @@ def test_from_dict_migrates_legacy_opsproducer_params(self): assert restored.num_depth_levels == 3 assert restored.num_power_levels == 10 assert restored.z_step_down == 0.5 - assert restored.invert is True + assert restored.pop_legacy_image_edits() == [LEGACY_INVERT] assert restored.auto_levels is False assert restored.black_point == 20 assert restored.white_point == 200 @@ -680,3 +684,62 @@ def test_build_raster_part_forwards_dither_options(self, machine): assert kwargs["halftone_angle"] == 12.0 assert kwargs["pixels_per_mm_x"] == pytest.approx(20.0) assert kwargs["pixels_per_mm_y"] == pytest.approx(10.0) + + +def _engrave_step_dicts(data) -> list[dict]: + found = [] + if isinstance(data, dict): + if data.get("step_type") == "EngraveStep": + found.append(data) + for value in data.values(): + found += _engrave_step_dicts(value) + elif isinstance(data, list): + for value in data: + found += _engrave_step_dicts(value) + return found + + +class TestLegacyInvert: + """Invert moved from the step to the workpieces' image edits.""" + + def _saved(self, invert: bool) -> dict: + data = EngraveStep(name="Engrave").to_dict() + data["invert"] = invert + return data + + def test_invert_is_no_longer_a_step_setting(self): + step = EngraveStep() + assert "invert" not in step.to_dict() + assert "invert" not in step.get_cache_params() + assert not hasattr(step, "invert") + + def test_saved_invert_is_handed_over_once(self): + step = EngraveStep.from_dict(self._saved(True)) + assert "invert" not in step.extra + assert step.pop_legacy_image_edits() == [LEGACY_INVERT] + assert step.pop_legacy_image_edits() == [] + + def test_saved_invert_false_hands_over_nothing(self): + step = EngraveStep.from_dict(self._saved(False)) + assert step.pop_legacy_image_edits() == [] + + def test_saved_invert_is_not_written_back(self): + step = EngraveStep.from_dict(self._saved(True)) + assert "invert" not in step.to_dict() + + def test_loading_a_document_moves_invert_to_the_workpieces(self): + doc = Doc() + wp = WorkPiece("photo") + doc.active_layer.add_child(wp) + workflow = doc.active_layer.workflow + assert workflow is not None + workflow.add_step(EngraveStep(name="Engrave")) + data = doc.to_dict() + _engrave_step_dicts(data)[0]["invert"] = True + + restored = Doc.from_dict(data) + + (restored_wp,) = restored.all_workpieces + assert restored_wp.image_edits == (LEGACY_INVERT,) + again = Doc.from_dict(restored.to_dict()) + assert again.all_workpieces[0].image_edits == (LEGACY_INVERT,) diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_recipe_keys.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_recipe_keys.py index fa4ce197b..6f09d5d92 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_recipe_keys.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/steps/test_recipe_keys.py @@ -48,7 +48,6 @@ def test_engrave_step_extends_laser(self): for key in ( "scan_angle", "depth_mode", - "invert", "min_power_level", "max_power_level", ): diff --git a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py index d07e0c16e..be9b541a8 100644 --- a/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py +++ b/rayforge/builtin_addons/rayforge-addon-laser/tests/ui_gtk/test_laser_pages.py @@ -684,29 +684,20 @@ def test_raster_page_power_section_levels_visible_in_dither( @pytest.mark.ui -def test_raster_power_widget_flips_labels_on_invert( +def test_raster_power_widget_titles_rows_by_area( editor, laser_machine, ui_context ): - """The Power widget re-titles min/max rows when invert changes.""" + """The min/max rows are titled after the areas they drive, and + the Engrave section no longer offers Invert.""" step_cls = step_registry.get("EngraveStep") assert step_cls is not None step = cast(EngraveStep, step_cls.create(ui_context)) page = RasterSettingsPage(editor, step) assert isinstance(page.power_widget, RasterPowerWidget) - - min_row = _row(page, "min_power_level") - max_row = _row(page, "max_power_level") - assert min_row.get_title() == "Min Power (White)" - assert max_row.get_title() == "Max Power (Black)" - - invert_adapter = page.engrave_widget.adapter_for("invert") - assert invert_adapter is not None - invert_adapter.set_value(True) - page._sync_power_context() - - assert min_row.get_title() == "Min Power (Black)" - assert max_row.get_title() == "Max Power (White)" + assert _row(page, "min_power_level").get_title() == "Min Power (White)" + assert _row(page, "max_power_level").get_title() == "Max Power (Black)" + assert page.engrave_widget.adapter_for("invert") is None @pytest.mark.ui diff --git a/rayforge/core/doc.py b/rayforge/core/doc.py index 8a1da3d1d..c6a45d17e 100644 --- a/rayforge/core/doc.py +++ b/rayforge/core/doc.py @@ -1,5 +1,5 @@ import logging -from collections.abc import Iterable +from collections.abc import Iterable, Sequence from gettext import gettext as _ from typing import ( TYPE_CHECKING, @@ -13,6 +13,7 @@ from ..core.undo import HistoryManager from .asset import IAsset, UnknownAsset from .color import COLOR_PALETTE +from .image_edit import ImageEditStep, missing_image_edit_ops from .item import DocItem from .layer import Layer from .source_asset import SourceAsset @@ -145,6 +146,7 @@ def _deserialize_item(item_data: dict) -> DocItem: doc.set_children(children) doc._active_layer_index = data.get("active_layer_index", 0) + doc.adopt_legacy_image_edits() return doc @@ -511,6 +513,28 @@ def get_layer_uid_for_step(self, step_uid: str) -> str | None: return layer.uid return None + @property + def missing_image_edit_ops(self) -> set[str]: + """Image edit op names used by workpieces but not registered.""" + missing: set[str] = set() + for wp in self.all_workpieces: + missing |= missing_image_edit_ops(wp.image_edits) + return missing + + def adopt_legacy_image_edits(self) -> None: + """ + Moves image edits that older files kept as step settings onto + the workpieces of the step's layer. Steps hand them over only + once, so this is safe to call after every load. + """ + for layer in self.layers: + if not layer.workflow: + continue + for step in layer.workflow.steps: + edits = step.pop_legacy_image_edits() + if edits: + _append_image_edits(layer.all_workpieces, edits) + @property def missing_step_types(self) -> set[str]: """Step type names referenced but not registered in step_registry.""" @@ -524,3 +548,12 @@ def missing_step_types(self) -> set[str]: if step_registry.get(name) is None: missing.add(step.original_step_type or step.typelabel) return missing + + +def _append_image_edits( + workpieces: Iterable[WorkPiece], edits: Sequence[ImageEditStep] +) -> None: + for wp in workpieces: + new = [e for e in edits if e not in wp.image_edits] + if new: + wp.image_edits = (*wp.image_edits, *new) diff --git a/rayforge/core/hooks.py b/rayforge/core/hooks.py index 880fffbb7..5d20b6758 100644 --- a/rayforge/core/hooks.py +++ b/rayforge/core/hooks.py @@ -4,13 +4,23 @@ hookimpl = pluggy.HookimplMarker("rayforge") MINIMUM_API_VERSION = 16 -PLUGIN_API_VERSION = 22 +PLUGIN_API_VERSION = 23 """ API Changelog ============= +Version 23 +---------- +Added the ``register_image_edits`` hook and the global +``image_edit_registry`` (``rayforge.core.image_edit``). Addons register +``ImageEditOp`` subclasses there; a workpiece applies its +``image_edits`` stack in ``WorkPiece.get_processed_image()``. +``WorkPiece.get_vips_image()`` is now the private +``_get_vips_image()`` and returns the unedited render; callers that +want what the user sees use ``get_processed_image()``. + Version 22 ---------- ``action_registry.register()`` accepts an ``enabled_when`` predicate that @@ -243,6 +253,17 @@ def register_steps(self, step_registry): step_registry: The global StepRegistry instance. """ + @hookspec + def register_image_edits(self, image_edit_registry): + """ + Called to allow addons to register image editing operations. + + .. versionadded:: 22 + + Args: + image_edit_registry: The global ImageEditRegistry instance. + """ + @hookspec def register_transformers(self, transformer_registry): """ diff --git a/rayforge/core/image_edit/__init__.py b/rayforge/core/image_edit/__init__.py new file mode 100644 index 000000000..0bb45dc80 --- /dev/null +++ b/rayforge/core/image_edit/__init__.py @@ -0,0 +1,12 @@ +from .base import ImageEditOp, ImageEditStep +from .registry import ImageEditRegistry, image_edit_registry +from .stack import apply_image_edits, missing_image_edit_ops + +__all__ = [ + "ImageEditOp", + "ImageEditRegistry", + "ImageEditStep", + "apply_image_edits", + "image_edit_registry", + "missing_image_edit_ops", +] diff --git a/rayforge/core/image_edit/base.py b/rayforge/core/image_edit/base.py new file mode 100644 index 000000000..00bf9d4fe --- /dev/null +++ b/rayforge/core/image_edit/base.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, ClassVar + +import pyvips + +_KNOWN_KEYS = frozenset({"op", "params", "enabled"}) + + +@dataclass(frozen=True) +class ImageEditStep: + """ + One non-destructive image editing operation in a workpiece's edit + stack. ``op`` is the registry name of an ImageEditOp; ``params`` + must be JSON-serializable. Keys this version does not know are kept + in ``extra`` and written back, so a newer file survives a round trip. + """ + + op: str + params: Mapping[str, Any] = field(default_factory=dict) + enabled: bool = True + extra: Mapping[str, Any] = field(default_factory=dict, compare=False) + + def to_dict(self) -> dict[str, Any]: + return { + **self.extra, + "op": self.op, + "params": dict(self.params), + "enabled": self.enabled, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> ImageEditStep: + return cls( + op=data["op"], + params=dict(data.get("params", {})), + enabled=data.get("enabled", True), + extra={k: v for k, v in data.items() if k not in _KNOWN_KEYS}, + ) + + +class ImageEditOp(ABC): + """ + An image editing operation. Subclasses are registered with the + ImageEditRegistry under ``NAME``, which is stored in documents and + must never change. + """ + + NAME: ClassVar[str] + LABEL: ClassVar[str] + + @classmethod + @abstractmethod + def apply( + cls, image: pyvips.Image, params: Mapping[str, Any] + ) -> pyvips.Image: + """Returns the edited image; ``image`` is left untouched.""" diff --git a/rayforge/core/image_edit/registry.py b/rayforge/core/image_edit/registry.py new file mode 100644 index 000000000..f2e044ad2 --- /dev/null +++ b/rayforge/core/image_edit/registry.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from .base import ImageEditOp + + +class ImageEditRegistry: + """Registry for image editing operations, keyed by their NAME.""" + + def __init__(self): + self._ops: dict[str, type[ImageEditOp]] = {} + self._addon_items: dict[str, set[str]] = {} + + def register( + self, op_class: type[ImageEditOp], addon_name: str | None = None + ) -> None: + name = op_class.NAME + self._ops[name] = op_class + if addon_name: + self._addon_items.setdefault(addon_name, set()).add(name) + + def unregister(self, name: str) -> bool: + if name not in self._ops: + return False + del self._ops[name] + for items in self._addon_items.values(): + items.discard(name) + return True + + def unregister_all_from_addon(self, addon_name: str) -> int: + items = self._addon_items.pop(addon_name, set()) + count = 0 + for name in items: + if self._ops.pop(name, None) is not None: + count += 1 + return count + + def get(self, name: str) -> type[ImageEditOp] | None: + return self._ops.get(name) + + def all_ops(self) -> dict[str, type[ImageEditOp]]: + return self._ops.copy() + + +image_edit_registry = ImageEditRegistry() diff --git a/rayforge/core/image_edit/stack.py b/rayforge/core/image_edit/stack.py new file mode 100644 index 000000000..de5db8b9e --- /dev/null +++ b/rayforge/core/image_edit/stack.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import logging +from collections.abc import Iterable + +import pyvips + +from .base import ImageEditStep +from .registry import ImageEditRegistry, image_edit_registry + +logger = logging.getLogger(__name__) + + +def apply_image_edits( + image: pyvips.Image, + edits: Iterable[ImageEditStep], + registry: ImageEditRegistry = image_edit_registry, +) -> pyvips.Image: + """ + Applies the enabled edits in order. An op that is not registered + (its addon is missing or disabled) is skipped, so the document stays + usable and keeps the edit for when the op is available again. + """ + for edit in edits: + if not edit.enabled: + continue + op_class = registry.get(edit.op) + if op_class is None: + logger.debug(f"Skipping unregistered image edit '{edit.op}'") + continue + image = op_class.apply(image, edit.params) + return image + + +def missing_image_edit_ops( + edits: Iterable[ImageEditStep], + registry: ImageEditRegistry = image_edit_registry, +) -> set[str]: + """Names of the ops in ``edits`` that are not registered.""" + return {edit.op for edit in edits if registry.get(edit.op) is None} diff --git a/rayforge/core/registration.py b/rayforge/core/registration.py index 3197df3d8..460238614 100644 --- a/rayforge/core/registration.py +++ b/rayforge/core/registration.py @@ -81,6 +81,14 @@ class RegistryEntry: worker_ok=False, needs_window=False, ), + RegistryEntry( + "register_image_edits", + "image_edit_registry", + "rayforge.core.image_edit", + "image_edit_registry", + worker_ok=True, + needs_window=False, + ), RegistryEntry( "register_importers", "importer_registry", diff --git a/rayforge/core/step.py b/rayforge/core/step.py index d75e5d07c..ab5075bd1 100644 --- a/rayforge/core/step.py +++ b/rayforge/core/step.py @@ -24,6 +24,7 @@ from ..pipeline.transformer.registry import transformer_registry from ..shared.units.formatter import format_value from .capability import MachineCapability +from .image_edit import ImageEditStep from .item import DocItem from .step_registry import step_registry from .varset import SpeedVar, VarSet @@ -547,6 +548,14 @@ def apply_import_settings(self, settings: dict[str, Any]) -> None: if cut_speed is not None: self.set_cut_speed(cut_speed) + def pop_legacy_image_edits(self) -> list[ImageEditStep]: + """ + Image edits that an older file stored as a setting of this step. + They are handed over once, after the document is loaded, to the + workpieces of the step's layer; later calls return nothing. + """ + return [] + def to_dict(self) -> dict: """Serializes the step and its configuration to a dictionary.""" step_type = ( diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index 8f25caff2..e91e162fc 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -3,7 +3,7 @@ import logging import math import warnings -from collections.abc import Generator +from collections.abc import Generator, Iterable from copy import deepcopy from dataclasses import asdict from pathlib import Path @@ -28,6 +28,7 @@ from ..context import get_context from .asset_registry import asset_type_registry from .geometry_provider import IGeometryProvider +from .image_edit import ImageEditStep, apply_image_edits from .item import DocItem from .source_asset_segment import SourceAssetSegment from .tab import Tab @@ -89,6 +90,11 @@ def __init__( # The cache for rendered vips images. Key is (width, height). self._render_cache: dict[tuple[int, int], pyvips.Image] = {} + # The non-destructive image edit stack and the cache of rendered + # images with that stack applied. Key is (width, height). + self._image_edits: tuple[ImageEditStep, ...] = () + self._processed_cache: dict[tuple[int, int], pyvips.Image] = {} + # Transient attributes for deserialized instances in subprocesses self._data: bytes | None = None self._original_data: bytes | None = None @@ -219,6 +225,20 @@ def source_segment(self, new_segment: SourceAssetSegment | None): # for triggering the pipeline to re-process the geometry. self.updated.send(self) + @property + def image_edits(self) -> tuple[ImageEditStep, ...]: + """The image edits applied, in order, on top of the render.""" + return self._image_edits + + @image_edits.setter + def image_edits(self, edits: Iterable[ImageEditStep]): + new_edits = tuple(edits) + if new_edits == self._image_edits: + return + self._image_edits = new_edits + self._processed_cache.clear() + self.updated.send(self) + @property def has_edited_boundaries(self) -> bool: """ @@ -251,6 +271,7 @@ def clear_render_cache(self): f"WP {self.uid[:8]}: Clearing all caches (render and boundaries)." ) self._render_cache.clear() + self._processed_cache.clear() self._boundaries_cache = None self._fills_cache = None @@ -583,6 +604,8 @@ def in_world(self) -> WorkPiece: world_wp.source_asset_uid = self.source_asset_uid world_wp._resolved_text_cache = dict(self._resolved_text_cache) + world_wp._image_edits = self._image_edits + # Ensure any edited boundaries are carried over. if self._edited_boundaries is not None: world_wp._edited_boundaries = self._edited_boundaries.copy() @@ -812,9 +835,27 @@ def _process_rendered_image_from_spec( return processed_image - def get_vips_image(self, width: int, height: int) -> pyvips.Image | None: + def get_processed_image( + self, width: int, height: int + ) -> pyvips.Image | None: + """ + The image as the user sees it: the render with the image edit + stack applied. Everything that consumes the workpiece's pixels + (canvas, pipeline, previews) goes through here. + """ + image = self._get_vips_image(width, height) + if image is None or not self._image_edits: + return image + key = (width, height) + processed = self._processed_cache.get(key) + if processed is None: + processed = apply_image_edits(image, self._image_edits) + self._processed_cache[key] = processed + return processed + + def _get_vips_image(self, width: int, height: int) -> pyvips.Image | None: """ - The central hub for rendering a vips image for this workpiece. + Renders the unedited vips image for this workpiece. Orchestrates data retrieval, rendering, cropping, and masking. """ key = (width, height) @@ -895,6 +936,7 @@ def to_dict(self) -> dict[str, Any]: if self._edited_boundaries is not None else None ), + "image_edits": [edit.to_dict() for edit in self._image_edits], "geometry_provider_uid": self.geometry_provider_uid, "geometry_provider_params": self._geometry_provider_params, "source_asset_uid": self.source_asset_uid, @@ -939,6 +981,7 @@ def from_dict(cls, data: dict[str, Any]) -> WorkPiece: "tabs_enabled", "source_segment", "edited_boundaries", + "image_edits", "geometry_provider_uid", "geometry_provider_params", "source_asset_uid", @@ -979,6 +1022,9 @@ def from_dict(cls, data: dict[str, Any]) -> WorkPiece: wp._edited_boundaries = Geometry.from_dict( data["edited_boundaries"] ) + wp._image_edits = tuple( + ImageEditStep.from_dict(d) for d in data.get("image_edits", []) + ) wp.geometry_provider_uid = data.get( "geometry_provider_uid" @@ -1085,7 +1131,7 @@ def render_to_pixels( from ..image import util # This now uses the central hub for rendering. - final_image = self.get_vips_image(width, height) + final_image = self.get_processed_image(width, height) if not final_image: return None normalized_image = util.normalize_to_rgba(final_image) @@ -1176,7 +1222,9 @@ def render_chunk( "or max_memory_size must be provided." ) - vips_image = self.get_vips_image(round(width_px), round(height_px)) + vips_image = self.get_processed_image( + round(width_px), round(height_px) + ) if not vips_image or not isinstance(vips_image, pyvips.Image): logger.warning("Failed to load image for chunking.") return diff --git a/rayforge/pipeline/stage/assembler_helpers.py b/rayforge/pipeline/stage/assembler_helpers.py index 73629a4d6..ffdcbe630 100644 --- a/rayforge/pipeline/stage/assembler_helpers.py +++ b/rayforge/pipeline/stage/assembler_helpers.py @@ -292,7 +292,6 @@ def preprocess_raster_image( surface: cairo.ImageSurface, *, mode: DepthMode, - invert: bool = False, auto_levels: bool = True, computed_auto_levels: tuple[int, int] | None = None, black_point: int = 0, @@ -316,7 +315,6 @@ def preprocess_raster_image( Args: surface: The rendered Cairo ARGB32 surface. mode: The rasterisation depth mode. - invert: If True, invert grayscale before further processing. auto_levels: If True, auto-compute black/white points from the image histogram. computed_auto_levels: Pre-computed ``(black, white)`` points @@ -350,9 +348,6 @@ def preprocess_raster_image( """ if mode in (DepthMode.MULTI_PASS, DepthMode.POWER_MODULATION): gray_image, alpha = surface_to_grayscale(surface) - if invert: - alpha_mask = alpha > 0 - gray_image[alpha_mask] = 255 - gray_image[alpha_mask] gray_image = _apply_raster_levels( gray_image, alpha, @@ -370,9 +365,6 @@ def preprocess_raster_image( ) algo = dither_algorithm or DitherAlgorithm.FLOYD_STEINBERG gray_image, alpha = surface_to_grayscale(surface) - if invert: - alpha_mask = alpha > 0 - gray_image[alpha_mask] = 255 - gray_image[alpha_mask] gray_image = _apply_raster_levels( gray_image, alpha, @@ -396,11 +388,7 @@ def preprocess_raster_image( return image, None if mode == DepthMode.CONSTANT_POWER: - image = surface_to_binary( - surface, - threshold=threshold, - invert=invert, - ) + image = surface_to_binary(surface, threshold=threshold) return image, None return None, None @@ -436,7 +424,6 @@ def compute_raster_auto_levels( workpiece: WorkPiece, pixels_per_mm: tuple[float, float], *, - invert: bool = False, max_preview_pixels: int = 512, ) -> tuple[int, int] | None: """Compute auto-levels from a low-resolution preview render. @@ -452,8 +439,6 @@ def compute_raster_auto_levels( Args: workpiece: The WorkPiece to render a preview of. pixels_per_mm: The ``(x, y)`` resolution of the full render. - invert: If True, invert the grayscale before computing - levels. max_preview_pixels: Maximum preview dimension in pixels. Returns: @@ -478,10 +463,6 @@ def compute_raster_auto_levels( gray_image, alpha = surface_to_grayscale(surface) - if invert: - alpha_mask = alpha > 0 - gray_image[alpha_mask] = 255 - gray_image[alpha_mask] - surface.flush() return compute_auto_levels(gray_image[alpha > 0]) diff --git a/rayforge/ui_gtk/mainwindow.py b/rayforge/ui_gtk/mainwindow.py index db1cadddc..af9215482 100644 --- a/rayforge/ui_gtk/mainwindow.py +++ b/rayforge/ui_gtk/mainwindow.py @@ -1629,8 +1629,10 @@ def _on_document_changed(self, sender): GLib.idle_add(self._check_changed_sources) - # Check for missing step types and show dialog if needed - missing_types = new_doc.missing_step_types + # Check for missing step types and image edits and show a dialog + missing_types = ( + new_doc.missing_step_types | new_doc.missing_image_edit_ops + ) if missing_types: dialog = MissingFeaturesDialog(self, missing_types) dialog.present() diff --git a/rayforge/ui_gtk/sim3d/scene_presenter.py b/rayforge/ui_gtk/sim3d/scene_presenter.py index 3b95b9c31..f699262e0 100644 --- a/rayforge/ui_gtk/sim3d/scene_presenter.py +++ b/rayforge/ui_gtk/sim3d/scene_presenter.py @@ -79,7 +79,7 @@ def _workpiece_image_pixels(wp: WorkPiece) -> np.ndarray | None: width_px = max(1, round(width_px * scale)) height_px = max(1, round(height_px * scale)) try: - vips_image = wp.get_vips_image(width_px, height_px) + vips_image = wp.get_processed_image(width_px, height_px) if vips_image is None: return None from ...image.util.vips import normalize_to_rgba diff --git a/scripts/media/gen_invert_example.py b/scripts/media/gen_invert_example.py new file mode 100755 index 000000000..a728e0731 --- /dev/null +++ b/scripts/media/gen_invert_example.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Generate the Invert Image before/after example for the website docs. + +Renders a grayscale, photo-like landscape and its inverted counterpart +-- the same image as produced by the Image Editing addon's Invert edit, +which flips every colour band and leaves alpha alone -- into two images +under ``website/static/images/``. The images carry no translatable +words; titles live in the markdown that embeds them. +""" + +from pathlib import Path + +import numpy as np +from PIL import Image, ImageFilter + +PROJECT_ROOT = Path(__file__).parent.parent.parent +OUTPUT_DIR = PROJECT_ROOT / "website" / "static" / "images" + +WIDTH, HEIGHT = 640, 400 +HORIZON = 235 +WATERLINE = 330 +SUN_X, SUN_Y = 448, 118 +SUN_RADIUS = 26 +GLOW_RADIUS = 110 +SEED = 20261013 + + +def sky(x: np.ndarray, y: np.ndarray) -> np.ndarray: + """Return the sky brightness with the sun and its glow.""" + top, bottom = 225.0, 135.0 + img = top + (bottom - top) * (y / HORIZON) + distance = np.sqrt((x - SUN_X) ** 2 + (y - SUN_Y) ** 2) + glow = np.clip(1.0 - distance / GLOW_RADIUS, 0.0, 1.0) ** 2 + img += 55.0 * glow + disc = distance <= SUN_RADIUS + img[disc] = 250.0 + return img + + +def ridge( + x: np.ndarray, base: float, amplitude: float, roughness: float +) -> np.ndarray: + """Return the ridge line height for each x coordinate.""" + shape = ( + np.sin(x * 0.011 + base) * 0.55 + + np.sin(x * 0.027 + base * 2.1) * 0.30 + + np.sin(x * 0.053 + base * 3.7) * 0.15 + ) + return base + shape * amplitude * roughness + + +def mountains(img: np.ndarray, x: np.ndarray, y: np.ndarray) -> None: + """Draw the far and near mountain ranges onto the image.""" + far = ridge(x, 205.0, 38.0, 1.0) + near = ridge(x, 262.0, 46.0, 1.15) + far_mask = (y > far) & (y <= HORIZON) + near_mask = y > near + img[far_mask] = 108.0 + img[near_mask] = 62.0 + img[far_mask & near_mask] = 62.0 + + +def water(img: np.ndarray, x: np.ndarray, y: np.ndarray) -> np.ndarray: + """Return the lake brightness with a reflection of the sun.""" + depth = (y - WATERLINE) / (HEIGHT - WATERLINE) + img = 96.0 - 26.0 * depth + distance = np.abs(x - SUN_X) + shimmer = (np.sin(y * 1.7) * 0.5 + 0.5) * 6.0 + np.sin( + x * 0.9 + y * 0.6 + ) * 3.0 + beam = np.exp(-((distance / 46.0) ** 2)) * (1.0 - depth) ** 0.7 + return img + beam * (26.0 + shimmer) + + +def grain(rng: np.random.Generator, img: np.ndarray) -> np.ndarray: + """Add mild photographic grain.""" + return img + rng.normal(0.0, 2.2, img.shape) + + +def render() -> Image.Image: + """Render the example photo as an RGB image.""" + rng = np.random.default_rng(SEED) + x, y = np.meshgrid( + np.arange(WIDTH, dtype=float), np.arange(HEIGHT, dtype=float) + ) + img = np.full((HEIGHT, WIDTH), 135.0) + sky_mask = y < HORIZON + img[sky_mask] = sky(x, y)[sky_mask] + mountains(img, x, y) + water_mask = y >= WATERLINE + img[water_mask] = water(img, x, y)[water_mask] + img = grain(rng, img) + pil = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), mode="L") + pil = pil.filter(ImageFilter.GaussianBlur(0.4)) + return pil.convert("RGB") + + +def invert(image: Image.Image) -> Image.Image: + """Invert the colour bands the way the Invert edit does.""" + return image.point(lambda value: 255 - value) + + +def main() -> None: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + before = render() + for image, name in ( + (before, "image-edit-invert-before.webp"), + (invert(before), "image-edit-invert-after.webp"), + ): + path = OUTPUT_DIR / name + image.save(path, format="WEBP", lossless=True) + print(f"Saved {path}") + + +if __name__ == "__main__": + main() diff --git a/tests/core/image_edit/test_image_edit.py b/tests/core/image_edit/test_image_edit.py new file mode 100644 index 000000000..e758abb61 --- /dev/null +++ b/tests/core/image_edit/test_image_edit.py @@ -0,0 +1,115 @@ +from collections.abc import Mapping +from typing import Any + +import pytest +import pyvips + +from rayforge.core.image_edit import ( + ImageEditOp, + ImageEditRegistry, + ImageEditStep, + apply_image_edits, + missing_image_edit_ops, +) + + +class _AddOp(ImageEditOp): + NAME = "test_add" + LABEL = "Add" + + @classmethod + def apply( + cls, image: pyvips.Image, params: Mapping[str, Any] + ) -> pyvips.Image: + return (image + params.get("amount", 1)).cast("uchar") + + +def _gray(value: int) -> pyvips.Image: + return (pyvips.Image.black(2, 2) + value).cast("uchar") + + +@pytest.fixture +def registry() -> ImageEditRegistry: + reg = ImageEditRegistry() + reg.register(_AddOp, addon_name="test_addon") + return reg + + +class TestImageEditStep: + def test_round_trip(self): + step = ImageEditStep("test_add", {"amount": 3}, enabled=False) + assert ImageEditStep.from_dict(step.to_dict()) == step + + def test_defaults(self): + step = ImageEditStep.from_dict({"op": "test_add"}) + assert step.params == {} + assert step.enabled + + def test_unknown_keys_survive_a_round_trip(self): + data = {"op": "future", "params": {"x": 1}, "version": 2} + assert ImageEditStep.from_dict(data).to_dict() == { + "op": "future", + "params": {"x": 1}, + "enabled": True, + "version": 2, + } + + def test_extra_keys_do_not_affect_equality(self): + a = ImageEditStep.from_dict({"op": "test_add", "note": "a"}) + assert a == ImageEditStep("test_add") + + +class TestImageEditRegistry: + def test_get(self, registry): + assert registry.get("test_add") is _AddOp + assert registry.get("nope") is None + + def test_all_ops(self, registry): + assert registry.all_ops() == {"test_add": _AddOp} + + def test_unregister_all_from_addon(self, registry): + assert registry.unregister_all_from_addon("test_addon") == 1 + assert registry.get("test_add") is None + assert registry.unregister_all_from_addon("test_addon") == 0 + + def test_unregister(self, registry): + assert registry.unregister("test_add") + assert not registry.unregister("test_add") + assert registry.unregister_all_from_addon("test_addon") == 0 + + +class TestApplyImageEdits: + def test_applies_ops_in_order(self, registry): + edits = [ + ImageEditStep("test_add", {"amount": 2}), + ImageEditStep("test_add", {"amount": 5}), + ] + result = apply_image_edits(_gray(10), edits, registry) + assert result.avg() == 17 + + def test_skips_disabled_steps(self, registry): + edits = [ImageEditStep("test_add", {"amount": 2}, enabled=False)] + assert apply_image_edits(_gray(10), edits, registry).avg() == 10 + + def test_skips_unregistered_ops(self, registry): + edits = [ + ImageEditStep("future_op"), + ImageEditStep("test_add", {"amount": 1}), + ] + assert apply_image_edits(_gray(10), edits, registry).avg() == 11 + + def test_no_edits_returns_the_same_image(self, registry): + image = _gray(10) + assert apply_image_edits(image, [], registry) is image + + +def test_missing_image_edit_ops(registry): + edits = [ + ImageEditStep("test_add"), + ImageEditStep("future_op"), + ImageEditStep("other_op", enabled=False), + ] + assert missing_image_edit_ops(edits, registry) == { + "future_op", + "other_op", + } diff --git a/tests/core/test_doc_image_edits.py b/tests/core/test_doc_image_edits.py new file mode 100644 index 000000000..714e4be85 --- /dev/null +++ b/tests/core/test_doc_image_edits.py @@ -0,0 +1,72 @@ +from rayforge.core.doc import Doc +from rayforge.core.image_edit import ImageEditStep +from rayforge.core.layer import Layer +from rayforge.core.step import Step +from rayforge.core.workpiece import WorkPiece + +INVERT = ImageEditStep("test_invert") + + +class _LegacyStep(Step): + def __init__(self, edits): + super().__init__(typelabel="Legacy") + self._legacy = list(edits) + + def pop_legacy_image_edits(self): + edits, self._legacy = self._legacy, [] + return edits + + +def _doc_with(step: Step, *workpieces: WorkPiece) -> Doc: + doc = Doc() + for wp in workpieces: + doc.active_layer.add_child(wp) + workflow = doc.active_layer.workflow + assert workflow is not None + workflow.add_step(step) + return doc + + +def test_base_step_has_no_legacy_image_edits(): + assert Step(typelabel="Plain").pop_legacy_image_edits() == [] + + +def test_legacy_edits_move_to_the_layers_workpieces(): + a, b = WorkPiece("a"), WorkPiece("b") + doc = _doc_with(_LegacyStep([INVERT]), a, b) + doc.adopt_legacy_image_edits() + assert a.image_edits == (INVERT,) + assert b.image_edits == (INVERT,) + + +def test_other_layers_are_untouched(): + inside, outside = WorkPiece("in"), WorkPiece("out") + doc = _doc_with(_LegacyStep([INVERT]), inside) + other = Layer("Other") + doc.add_layer(other) + other.add_child(outside) + doc.adopt_legacy_image_edits() + assert outside.image_edits == () + + +def test_adoption_is_idempotent(): + wp = WorkPiece("a") + doc = _doc_with(_LegacyStep([INVERT]), wp) + doc.adopt_legacy_image_edits() + doc.adopt_legacy_image_edits() + assert wp.image_edits == (INVERT,) + + +def test_an_edit_already_present_is_not_duplicated(): + wp = WorkPiece("a") + wp.image_edits = [INVERT] + doc = _doc_with(_LegacyStep([INVERT]), wp) + doc.adopt_legacy_image_edits() + assert wp.image_edits == (INVERT,) + + +def test_missing_image_edit_ops(): + wp = WorkPiece("a") + wp.image_edits = [ImageEditStep("not_installed")] + doc = _doc_with(Step(typelabel="Plain"), wp) + assert doc.missing_image_edit_ops == {"not_installed"} diff --git a/tests/core/test_workpiece_image_edits.py b/tests/core/test_workpiece_image_edits.py new file mode 100644 index 000000000..58aeaa472 --- /dev/null +++ b/tests/core/test_workpiece_image_edits.py @@ -0,0 +1,138 @@ +from collections.abc import Mapping +from pathlib import Path +from typing import Any, cast + +import pytest +import pyvips + +from rayforge.core.doc import Doc +from rayforge.core.image_edit import ( + ImageEditOp, + ImageEditStep, + image_edit_registry, +) +from rayforge.core.vectorization_spec import LayerImportMode, PassthroughSpec +from rayforge.core.workpiece import WorkPiece +from rayforge.image import import_file + +SVG = b""" + +""" + + +class _BlankOp(ImageEditOp): + NAME = "test_blank" + LABEL = "Blank" + calls = 0 + + @classmethod + def apply( + cls, image: pyvips.Image, params: Mapping[str, Any] + ) -> pyvips.Image: + cls.calls += 1 + return (image * 0 + 255).cast("uchar") + + +@pytest.fixture +def blank_op(): + image_edit_registry.register(_BlankOp, addon_name="test_wp_edits") + _BlankOp.calls = 0 + yield _BlankOp + image_edit_registry.unregister_all_from_addon("test_wp_edits") + + +@pytest.fixture +def workpiece(tmp_path: Path) -> WorkPiece: + path = tmp_path / "square.svg" + path.write_bytes(SVG) + payload = import_file( + path, + vectorization_spec=PassthroughSpec( + layer_import_mode=LayerImportMode.FLATTEN + ), + ) + assert payload is not None + wp = cast(WorkPiece, payload.items[0]) + doc = Doc() + doc.add_asset(payload.source) + doc.active_layer.add_child(wp) + return wp + + +def _mean_rgb(image: pyvips.Image) -> float: + return image.extract_band(0, n=3).avg() + + +def test_new_workpiece_has_no_edits(workpiece): + assert workpiece.image_edits == () + + +def test_processed_image_without_edits_is_the_base_render(workpiece): + base = workpiece._get_vips_image(20, 20) + assert workpiece.get_processed_image(20, 20) is base + + +def test_processed_image_applies_the_stack(workpiece, blank_op): + base_mean = _mean_rgb(workpiece._get_vips_image(20, 20)) + workpiece.image_edits = [ImageEditStep(blank_op.NAME)] + assert _mean_rgb(workpiece.get_processed_image(20, 20)) == 255 + assert _mean_rgb(workpiece._get_vips_image(20, 20)) == base_mean + + +def test_render_to_pixels_uses_the_processed_image(workpiece, blank_op): + workpiece.image_edits = [ImageEditStep(blank_op.NAME)] + surface = workpiece.render_to_pixels(20, 20) + assert surface is not None + assert set(bytes(surface.get_data())) == {255} + + +def test_processed_image_is_cached(workpiece, blank_op): + workpiece.image_edits = [ImageEditStep(blank_op.NAME)] + first = workpiece.get_processed_image(20, 20) + assert workpiece.get_processed_image(20, 20) is first + assert blank_op.calls == 1 + + +def test_setting_edits_invalidates_and_notifies(workpiece, blank_op): + workpiece.get_processed_image(20, 20) + seen = [] + workpiece.updated.connect(seen.append, weak=False) + workpiece.image_edits = [ImageEditStep(blank_op.NAME)] + assert seen == [workpiece] + assert _mean_rgb(workpiece.get_processed_image(20, 20)) == 255 + + +def test_setting_equal_edits_does_not_notify(workpiece, blank_op): + workpiece.image_edits = [ImageEditStep(blank_op.NAME)] + seen = [] + workpiece.updated.connect(seen.append, weak=False) + workpiece.image_edits = (ImageEditStep(blank_op.NAME),) + assert seen == [] + + +def test_unregistered_op_is_skipped(workpiece): + workpiece.image_edits = [ImageEditStep("not_installed")] + base = workpiece._get_vips_image(20, 20) + assert _mean_rgb(workpiece.get_processed_image(20, 20)) == _mean_rgb(base) + + +def test_edits_survive_serialization(workpiece): + edits = ( + ImageEditStep("not_installed", {"x": 1}), + ImageEditStep("test_blank", enabled=False), + ) + workpiece.image_edits = edits + restored = WorkPiece.from_dict(workpiece.to_dict()) + assert restored.image_edits == edits + + +def test_old_documents_load_without_edits(workpiece): + data = workpiece.to_dict() + data.pop("image_edits", None) + assert WorkPiece.from_dict(data).image_edits == () + + +def test_in_world_copies_the_edits(workpiece): + workpiece.image_edits = [ImageEditStep("test_blank")] + assert workpiece.in_world().image_edits == workpiece.image_edits diff --git a/tests/pipeline/stage/test_assembler_helpers.py b/tests/pipeline/stage/test_assembler_helpers.py index d40e85252..cae47115b 100644 --- a/tests/pipeline/stage/test_assembler_helpers.py +++ b/tests/pipeline/stage/test_assembler_helpers.py @@ -286,28 +286,6 @@ def test_dither_auto_levels_stretch_contrast(self): assert np.sum(image_auto_levels) == 5000 assert np.sum(image_auto_levels) != np.sum(image_no_levels) - def test_dither_invert_engraves_light_areas_only(self): - """Inverted dithering engraves light content but keeps the - transparent background clear.""" - surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 10, 10) - ctx = cairo.Context(surface) - ctx.set_source_rgba(1, 1, 1, 1) - ctx.rectangle(0, 0, 5, 10) - ctx.fill() - image, _ = preprocess_raster_image( - surface, - mode=DepthMode.DITHER, - invert=True, - auto_levels=False, - dither_algorithm=DitherAlgorithm.BAYER2, - laser_spot_x_mm=0.1, - pixels_per_mm_x=10.0, - ) - - assert image is not None - assert np.all(image[:, :5] == 1) - assert np.all(image[:, 5:] == 0) - def _gradient_surface(self, width=120, height=60): surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) ctx = cairo.Context(surface) @@ -391,25 +369,6 @@ def test_mask_scan_returns_binary_no_alpha(self): assert alpha is None assert set(np.unique(image)).issubset({0, 1}) - def test_invert_flips_grayscale(self): - surface = self._black_surface() - image_normal, _ = preprocess_raster_image( - surface, - mode=DepthMode.POWER_MODULATION, - invert=False, - auto_levels=False, - ) - image_inverted, _ = preprocess_raster_image( - surface, - mode=DepthMode.POWER_MODULATION, - invert=True, - auto_levels=False, - ) - - assert image_normal is not None - assert image_inverted is not None - assert np.mean(image_inverted) > np.mean(image_normal) - def test_auto_levels_normalizes_grayscale(self): surface = self._gray_surface() image_no_levels, _ = preprocess_raster_image( @@ -494,27 +453,6 @@ def test_returns_none_when_render_fails(self): result = compute_raster_auto_levels(mock_wp, (10.0, 10.0)) assert result is None - def test_invert_affects_levels(self): - surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 10, 10) - ctx = cairo.Context(surface) - ctx.set_source_rgb(0.2, 0.2, 0.2) - ctx.paint() - - mock_wp = MagicMock() - mock_wp.size = (10.0, 10.0) - mock_wp.render_to_pixels.return_value = surface - - result_normal = compute_raster_auto_levels( - mock_wp, (10.0, 10.0), invert=False - ) - result_inverted = compute_raster_auto_levels( - mock_wp, (10.0, 10.0), invert=True - ) - - assert result_normal is not None - assert result_inverted is not None - assert result_normal != result_inverted - def test_preview_size_limited_by_max_preview_pixels(self): surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 100, 100) ctx = cairo.Context(surface) diff --git a/tests/pipeline/test_intent_builder.py b/tests/pipeline/test_intent_builder.py index f0f5d44b8..7fd3b4c18 100644 --- a/tests/pipeline/test_intent_builder.py +++ b/tests/pipeline/test_intent_builder.py @@ -37,6 +37,7 @@ import rayforge.machine.driver as driver_module from rayforge.core.doc import Doc +from rayforge.core.image_edit import ImageEditStep from rayforge.core.layer import Layer from rayforge.core.step import Step from rayforge.core.stock import StockItem @@ -1455,7 +1456,6 @@ def test_raster_step_is_position_sensitive(engrave_step_class): @pytest.mark.parametrize( "attr,value", [ - ("invert", True), ("auto_levels", False), ("black_point", 80), ("white_point", 200), @@ -1472,7 +1472,7 @@ def test_raster_compute_token_changes_on_image_preprocess_params( """Changing any raster image-preprocessing setting must invalidate the workpiece compute cache. - The levels (black/white points), inversion, threshold and dither + The levels (black/white points), threshold and dither are baked into the preprocessed image the assembler consumes, so a token that omits them would replay the exact same ops regardless of the brightness settings. @@ -1496,6 +1496,28 @@ def test_raster_compute_token_changes_on_image_preprocess_params( assert before_t != after_t +def test_compute_token_changes_on_workpiece_image_edits( + engrave_step_class, test_machine_and_config +): + """Image edits live on the workpiece and are baked into the image + every raster step consumes, so changing them must invalidate the + workpiece compute cache.""" + machine, context = test_machine_and_config + step = engrave_step_class.create(context, name="engrave") + wp = WorkPiece(name="wp") + wp.set_size(20.0, 20.0) + doc = _make_doc(step, wp) + + before = IntentBuilder(machine=machine).build(doc) + wpk = workpiece_key(wp.uid, step.uid) + before_t = next(n.version_token for n in before if n.key == wpk) + + wp.image_edits = [ImageEditStep("invert")] + after = IntentBuilder(machine=machine).build(doc) + after_t = next(n.version_token for n in after if n.key == wpk) + assert before_t != after_t + + # ---------------------------------------------------------------------- # Stock resolution # ---------------------------------------------------------------------- diff --git a/website/docs/addons/image-editing.md b/website/docs/addons/image-editing.md new file mode 100644 index 000000000..586fd267c --- /dev/null +++ b/website/docs/addons/image-editing.md @@ -0,0 +1,41 @@ +--- +description: + "Edit the image of a workpiece without changing the imported file, for example to invert it before + engraving or tracing." +--- + +# Image Editing + +The Image Editing addon changes how a workpiece's image looks without touching the imported file. +Each edit is stored with the workpiece, so you can undo it, remove it later, or place the same file +several times with different edits. + +The canvas, the 3D preview and every operation use the edited image. What you see on the canvas is +what the laser engraves. + +## Invert Image + +Select one or more workpieces and choose **Tools - Invert Image**. Light areas become dark and dark +areas become light; transparent areas stay transparent, so the background is still not engraved. + +![A grayscale photo of mountains and a lake, as imported](/images/image-edit-invert-before.webp) + +![The same photo after Invert Image](/images/image-edit-invert-after.webp) + +Choose it again to remove the inversion. With a mixed selection, the workpieces that are not +inverted yet get inverted, so the whole selection ends up the same. + +Use it for lithophanes and embossing in an Engrave operation, where light areas should be engraved +deep, or to engrave light artwork on a dark background. + +## Older Projects + +Before this addon, Invert was a setting of the Engrave operation. When you open an older project +that used it, Rayforge moves the setting to the workpieces of that layer, so the output stays the +same. + +## When the Addon Is Disabled + +A project keeps its image edits when the addon is disabled or not installed. Rayforge tells you that +the project uses features that are not available and shows the images without those edits until the +addon is enabled again. diff --git a/website/docs/features/operations/engrave.md b/website/docs/features/operations/engrave.md index f026b9238..eb8c7c198 100644 --- a/website/docs/features/operations/engrave.md +++ b/website/docs/features/operations/engrave.md @@ -182,12 +182,11 @@ described below. | 10% | 60% | Medium depth, safe | | 20% | 80% | Deep, dramatic relief | -**Invert:** +**Inverting the image:** -- **Off** (default): White = shallow, Black = deep -- **On**: White = deep, Black = shallow - -Use invert for lithophanes (light areas should be thin) or embossing (raised areas). +Black engraves deep and white stays shallow. To swap them, for lithophanes (light areas should be +thin) or embossing (raised areas), select the workpiece and use **Tools - Invert Image**. See +[Image Editing](../../addons/image-editing.md). **Brightness Range:** @@ -280,12 +279,11 @@ at least a few laser spot widths. - Creates crosshatch-like 3D effect - Typical: 0-45 degrees -**Invert:** - -- **Enabled:** White = deep, Black = shallow -- **Disabled:** Black = deep, White = shallow +**Inverting the image:** -Use invert for lithophanes (light areas should be thin) or embossing (raised areas). +Black engraves deep and white stays shallow. To swap them, for lithophanes (light areas should be +thin) or embossing (raised areas), select the workpiece and use **Tools - Invert Image**. See +[Image Editing](../../addons/image-editing.md). ### Scan Pattern diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md new file mode 100644 index 000000000..019933c98 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -0,0 +1,38 @@ +# Bildbearbeitung + +Das Bildbearbeitungs-Addon verändert, wie das Bild eines Werkstücks aussieht, ohne die importierte +Datei zu ändern. Jede Bearbeitung wird mit dem Werkstück gespeichert, sodass du sie rückgängig +machen, später entfernen oder dieselbe Datei mehrmals mit unterschiedlichen Bearbeitungen verwenden +kannst. + +Die Leinwand, die 3D-Vorschau und jede Operation verwenden das bearbeitete Bild. Was du auf der +Leinwand siehst, ist das, was der Laser graviert. + +## Bild invertieren + +Wähle ein oder mehrere Werkstücke aus und wähle **Tools - Invert Image**. Helle Bereiche werden +dunkel und dunkle Bereiche werden hell; transparente Bereiche bleiben transparent, sodass der +Hintergrund weiterhin nicht graviert wird. + +![Ein Graustufenfoto von Bergen und einem See, wie importiert](/images/image-edit-invert-before.webp) + +![Dasselbe Foto nach Invert Image](/images/image-edit-invert-after.webp) + +Wähle es erneut, um die Invertierung zu entfernen. Bei einer gemischten Auswahl werden die +Werkstücke, die noch nicht invertiert sind, invertiert, sodass die gesamte Auswahl am Ende gleich +ist. + +Verwende es für Lithophanen und Prägen in einer Gravur-Operation, bei der helle Bereiche tief +graviert werden sollen, oder um helle Motive auf dunklem Hintergrund zu gravieren. + +## Ältere Projekte + +Vor diesem Addon war Invertieren eine Einstellung der Gravur-Operation. Wenn du ein älteres Projekt +öffnest, das sie verwendet hat, verschiebt Rayforge die Einstellung auf die Werkstücke dieser Ebene, +sodass das Ergebnis gleich bleibt. + +## Wenn das Addon deaktiviert ist + +Ein Projekt behält seine Bildbearbeitungen, wenn das Addon deaktiviert oder nicht installiert ist. +Rayforge teilt dir mit, dass das Projekt Funktionen verwendet, die nicht verfügbar sind, und zeigt +die Bilder ohne diese Bearbeitungen an, bis das Addon wieder aktiviert ist. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md index ad63b2810..b4bafe42d 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -178,13 +178,11 @@ Einstellungen, die unten beschrieben werden. | 10% | 60% | Mittlere Tiefe, sicher | | 20% | 80% | Tiefes, dramatisches Relief | -**Invertieren:** +**Bild invertieren:** -- **Aus** (Standard): Weiß = flach, Schwarz = tief -- **Ein**: Weiß = tief, Schwarz = flach - -Invertieren für Lithophanen verwenden (helle Bereiche sollten dünn sein) oder Prägen (erhabene -Bereiche). +Schwarz wird tief graviert und Weiß bleibt flach. Um sie zu tauschen, etwa für Lithophanen (helle +Bereiche sollten dünn sein) oder Prägen (erhabene Bereiche), wähle das Werkstück aus und verwende +**Tools - Invert Image**. Siehe [Bildbearbeitung](../../addons/image-editing.md). **Helligkeitsbereich:** @@ -280,13 +278,11 @@ ein Raster, das du mit **Halbton-Zellgröße** (Abstand zwischen den Punktmittel - Erzeugt kreuzschraffur-ähnlichen 3D-Effekt - Typisch: 0-45 Grad -**Invertieren:** - -- **Aktiviert:** Weiß = tief, Schwarz = flach -- **Deaktiviert:** Schwarz = tief, Weiß = flach +**Bild invertieren:** -Invertieren für Lithophanen verwenden (helle Bereiche sollten dünn sein) oder Prägen (erhabene -Bereiche). +Schwarz wird tief graviert und Weiß bleibt flach. Um sie zu tauschen, etwa für Lithophanen (helle +Bereiche sollten dünn sein) oder Prägen (erhabene Bereiche), wähle das Werkstück aus und verwende +**Tools - Invert Image**. Siehe [Bildbearbeitung](../../addons/image-editing.md). ### Scan-Muster diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md new file mode 100644 index 000000000..ad8ac619e --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -0,0 +1,36 @@ +# Edición de imágenes + +El complemento de edición de imágenes cambia cómo se ve la imagen de una pieza sin tocar el archivo +importado. Cada edición se guarda con la pieza, de modo que puedes deshacerla, quitarla más tarde o +colocar el mismo archivo varias veces con ediciones diferentes. + +El lienzo, la vista previa 3D y cada operación usan la imagen editada. Lo que ves en el lienzo es lo +que el láser graba. + +## Invertir imagen + +Selecciona una o más piezas y elige **Tools - Invert Image**. Las áreas claras se vuelven oscuras y +las áreas oscuras se vuelven claras; las áreas transparentes permanecen transparentes, por lo que el +fondo sigue sin grabarse. + +![Una foto en escala de grises de montañas y un lago, tal como se importó](/images/image-edit-invert-before.webp) + +![La misma foto después de Invert Image](/images/image-edit-invert-after.webp) + +Elígelo de nuevo para quitar la inversión. Con una selección mixta, las piezas que aún no están +invertidas se invierten, de modo que toda la selección acaba igual. + +Úsalo para litofanías y repujado en una operación de grabado, donde las áreas claras deben grabarse +en profundidad, o para grabar dibujos claros sobre un fondo oscuro. + +## Proyectos antiguos + +Antes de este complemento, Invertir era un ajuste de la operación de grabado. Cuando abres un +proyecto antiguo que lo usaba, Rayforge mueve el ajuste a las piezas de esa capa, para que el +resultado sea el mismo. + +## Cuando el complemento está desactivado + +Un proyecto conserva sus ediciones de imagen cuando el complemento está desactivado o no está +instalado. Rayforge te avisa de que el proyecto usa funciones no disponibles y muestra las imágenes +sin esas ediciones hasta que el complemento vuelva a estar activado. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md index b49ac8d41..ee3ed1cf3 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -178,12 +178,11 @@ descritos a continuación. | 10% | 60% | Profundidad media, seguro | | 20% | 80% | Profundo, relieve dramático | -**Invertir:** +**Invertir la imagen:** -- **Apagado** (predeterminado): Blanco = superficial, Negro = profundo -- **Encendido**: Blanco = profundo, Negro = superficial - -Usa invertir para litofanías (las áreas claras deben ser delgadas) o repujado (áreas elevadas). +El negro se graba en profundidad y el blanco queda superficial. Para invertirlos, para litofanías +(las áreas claras deben ser delgadas) o repujado (áreas elevadas), selecciona la pieza y usa +**Tools - Invert Image**. Consulta [Edición de imágenes](../../addons/image-editing.md). **Rango de Brillo:** @@ -280,12 +279,11 @@ punto láser. - Crea un efecto 3D tipo entramado cruzado - Típico: 0-45 grados -**Invertir:** - -- **Habilitado:** Blanco = profundo, Negro = superficial -- **Deshabilitado:** Negro = profundo, Blanco = superficial +**Invertir la imagen:** -Usa invertir para litofanías (las áreas claras deben ser delgadas) o repujado (áreas elevadas). +El negro se graba en profundidad y el blanco queda superficial. Para invertirlos, para litofanías +(las áreas claras deben ser delgadas) o repujado (áreas elevadas), selecciona la pieza y usa +**Tools - Invert Image**. Consulta [Edición de imágenes](../../addons/image-editing.md). ### Patrón de Escaneo diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md new file mode 100644 index 000000000..ebeac0132 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -0,0 +1,36 @@ +# Édition d'images + +L'extension Édition d'images change l'apparence de l'image d'une pièce sans toucher au fichier +importé. Chaque modification est enregistrée avec la pièce, ce qui permet de l'annuler, de la +supprimer plus tard ou de placer plusieurs fois le même fichier avec des modifications différentes. + +Le canevas, l'aperçu 3D et chaque opération utilisent l'image modifiée. Ce que vous voyez sur le +canevas est ce que le laser grave. + +## Inverser l'image + +Sélectionnez une ou plusieurs pièces et choisissez **Tools - Invert Image**. Les zones claires +deviennent sombres et les zones sombres deviennent claires ; les zones transparentes restent +transparentes, l'arrière-plan n'est donc toujours pas gravé. + +![Une photo en niveaux de gris de montagnes et d'un lac, telle qu'importée](/images/image-edit-invert-before.webp) + +![La même photo après Invert Image](/images/image-edit-invert-after.webp) + +Choisissez-le à nouveau pour supprimer l'inversion. Avec une sélection mixte, les pièces qui ne sont +pas encore inversées le sont, pour que toute la sélection finisse identique. + +Utilisez-le pour les lithophanes et le gaufrage dans une opération de gravure, lorsque les zones +claires doivent être gravées en profondeur, ou pour graver un motif clair sur un fond sombre. + +## Projets plus anciens + +Avant cette extension, Inverser était un réglage de l'opération de gravure. Lorsque vous ouvrez un +ancien projet qui l'utilisait, Rayforge déplace le réglage vers les pièces de cette couche, afin que +le résultat reste le même. + +## Lorsque l'extension est désactivée + +Un projet conserve ses modifications d'image lorsque l'extension est désactivée ou non installée. +Rayforge vous signale que le projet utilise des fonctionnalités non disponibles et affiche les +images sans ces modifications jusqu'à ce que l'extension soit réactivée. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md index ededc1e5a..e8652f36d 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -178,13 +178,11 @@ paramètres, décrits ci-dessous. | 10% | 60% | Profondeur moyenne, sûr | | 20% | 80% | Profond, relief dramatique | -**Inverser :** +**Inversion de l'image :** -- **Off** (défaut) : Blanc = peu profond, Noir = profond -- **On** : Blanc = profond, Noir = peu profond - -Utilisez inverser pour les lithophanes (les zones claires doivent être fines) ou le gaufrage (zones -en relief). +Le noir est gravé en profondeur et le blanc reste peu profond. Pour les inverser, pour les +lithophanes (les zones claires doivent être fines) ou le gaufrage (zones en relief), sélectionnez la +pièce et utilisez **Tools - Invert Image**. Voir [Édition d'images](../../addons/image-editing.md). **Plage de Luminosité :** @@ -281,13 +279,11 @@ de point laser. - Crée un effet 3D type croisé - Typique : 0-45 degrés -**Inverser :** - -- **Activé :** Blanc = profond, Noir = peu profond -- **Désactivé :** Noir = profond, Blanc = peu profond +**Inversion de l'image :** -Utilisez inverser pour les lithophanes (les zones claires doivent être fines) ou le gaufrage (zones -en relief). +Le noir est gravé en profondeur et le blanc reste peu profond. Pour les inverser, pour les +lithophanes (les zones claires doivent être fines) ou le gaufrage (zones en relief), sélectionnez la +pièce et utilisez **Tools - Invert Image**. Voir [Édition d'images](../../addons/image-editing.md). ### Motif de Balayage diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md new file mode 100644 index 000000000..2371a635f --- /dev/null +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -0,0 +1,35 @@ +# छवि संपादन + +छवि संपादन ऐड-ऑन आयातित फ़ाइल को छुए बिना वर्कपीस की छवि का स्वरूप बदलता है. प्रत्येक संपादन वर्कपीस +के साथ सहेजा जाता है, ताकि आप इसे पूर्ववत कर सकें, बाद में हटा सकें, या उसी फ़ाइल को अलग-अलग +संपादनों के साथ कई बार रख सकें. + +कार्यक्षेत्र, 3D पूर्वावलोकन और हर ऑपरेशन संपादित छवि का उपयोग करते हैं. जो आप कार्यक्षेत्र पर देखते +हैं, वही लेज़र उकेरता है. + +## छवि उलटें + +एक या अधिक वर्कपीस चुनें और **Tools - Invert Image** चुनें. उजले क्षेत्र गहरे हो जाते हैं और गहरे +क्षेत्र उजले हो जाते हैं; पारदर्शी क्षेत्र पारदर्शी रहते हैं, इसलिए पृष्ठभूमि अभी भी नहीं उकेरी +जाती. + +![आयात की गई, पहाड़ों और झील वाली ग्रेस्केल तस्वीर](/images/image-edit-invert-before.webp) + +![Invert Image के बाद वही तस्वीर](/images/image-edit-invert-after.webp) + +उलटना हटाने के लिए इसे फिर से चुनें. मिश्रित चयन के साथ, जो वर्कपीस अभी तक नहीं उलटे गए हैं वे उलट +दिए जाते हैं, ताकि पूरा चयन अंततः समान हो जाए. + +इसे उत्कीर्णन ऑपरेशन में लिथोफ़ेन और उभार के लिए उपयोग करें, जहाँ उजले क्षेत्र गहरे उकेरे जाने +चाहिए, या गहरी पृष्ठभूमि पर उजली आकृति उकेरने के लिए. + +## पुराने प्रोजेक्ट + +इस ऐड-ऑन से पहले, उलटें उत्कीर्णन ऑपरेशन की एक सेटिंग थी. जब आप कोई पुराना प्रोजेक्ट खोलते हैं जिसने +इसका उपयोग किया था, तो Rayforge उस सेटिंग को उस लेयर के वर्कपीस पर ले जाता है, ताकि परिणाम वही रहे. + +## जब ऐड-ऑन अक्षम हो + +ऐड-ऑन के अक्षम या इंस्टॉल न होने पर प्रोजेक्ट अपने छवि संपादन बनाए रखता है. Rayforge आपको बताता है +कि प्रोजेक्ट अनुपलब्ध फ़ीचर का उपयोग करता है और ऐड-ऑन के फिर से सक्षम होने तक उन संपादनों के बिना +छवियाँ दिखाता है. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 630eb0469..ea7c17bc1 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -183,12 +183,11 @@ _Step Settings_ टैब के **उत्कीर्णन** और **शक | 10% | 60% | मध्यम गहराई, सुरक्षित | | 20% | 80% | गहरा, नाटकीय रिलीफ़ | -**उलटें:** +**छवि उलटें:** -- **बंद** (डिफ़ॉल्ट): सफ़ेद = उथला, काला = गहरा -- **चालू**: सफ़ेद = गहरा, काला = उथला - -लिथोफ़ेन (उजले क्षेत्र पतले होने चाहिए) या उभार (उठे हुए क्षेत्र) के लिए उलटें उपयोग करें. +काला गहरा उकेरा जाता है और सफ़ेद उथला रहता है. उन्हें बदलने के लिए, लिथोफ़ेन (उजले क्षेत्र पतले होने +चाहिए) या उभार (उठे हुए क्षेत्र) के लिए, वर्कपीस चुनें और **Tools - Invert Image** का उपयोग करें. +[छवि संपादन](../../addons/image-editing.md) देखें. **चमक सीमा:** @@ -281,12 +280,11 @@ _Step Settings_ टैब के **उत्कीर्णन** और **शक - क्रॉसहैच जैसा 3D प्रभाव बनाता है - सामान्य: 0-45 डिग्री -**उलटें:** - -- **सक्षम:** सफ़ेद = गहरा, काला = उथला -- **अक्षम:** काला = गहरा, सफ़ेद = उथला +**छवि उलटें:** -लिथोफ़ेन (उजले क्षेत्र पतले होने चाहिए) या उभार (उठे हुए क्षेत्र) के लिए उलटें उपयोग करें. +काला गहरा उकेरा जाता है और सफ़ेद उथला रहता है. उन्हें बदलने के लिए, लिथोफ़ेन (उजले क्षेत्र पतले होने +चाहिए) या उभार (उठे हुए क्षेत्र) के लिए, वर्कपीस चुनें और **Tools - Invert Image** का उपयोग करें. +[छवि संपादन](../../addons/image-editing.md) देखें. ### स्कैन पैटर्न diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md new file mode 100644 index 000000000..98382cf0c --- /dev/null +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -0,0 +1,36 @@ +# Edição de imagens + +O addon de edição de imagens altera como a imagem de uma peça fica sem tocar no arquivo importado. +Cada edição é armazenada junto com a peça, de modo que você pode desfazê-la, removê-la depois ou +colocar o mesmo arquivo várias vezes com edições diferentes. + +A tela, a visualização 3D e cada operação usam a imagem editada. O que você vê na tela é o que o +laser grava. + +## Inverter imagem + +Selecione uma ou mais peças e escolha **Tools - Invert Image**. Áreas claras ficam escuras e áreas +escuras ficam claras; áreas transparentes permanecem transparentes, então o fundo continua sem ser +gravado. + +![Uma foto em tons de cinza de montanhas e um lago, como importada](/images/image-edit-invert-before.webp) + +![A mesma foto depois de Invert Image](/images/image-edit-invert-after.webp) + +Escolha novamente para remover a inversão. Com uma seleção mista, as peças que ainda não estão +invertidas são invertidas, de modo que toda a seleção termine igual. + +Use para litofanias e embossing em uma operação de gravação, onde áreas claras devem ser gravadas em +profundidade, ou para gravar arte clara em um fundo escuro. + +## Projetos mais antigos + +Antes deste addon, Inverter era uma configuração da operação de gravação. Quando você abre um +projeto mais antigo que a usava, o Rayforge move a configuração para as peças dessa camada, para que +o resultado continue o mesmo. + +## Quando o addon está desativado + +Um projeto mantém suas edições de imagem quando o addon está desativado ou não está instalado. O +Rayforge informa que o projeto usa recursos indisponíveis e mostra as imagens sem essas edições até +que o addon seja ativado novamente. diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md index ba999f39c..2f380dced 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -178,12 +178,11 @@ diferentes, descritas abaixo. | 10% | 60% | Profundidade média, seguro | | 20% | 80% | Relevo profundo, dramático | -**Inverter:** +**Invertendo a imagem:** -- **Desligado** (padrão): Branco = raso, Preto = profundo -- **Ligado**: Branco = profundo, Preto = raso - -Use inverter para litofanias (áreas claras devem ser finas) ou embossing (áreas elevadas). +O preto é gravado em profundidade e o branco fica raso. Para inverter, para litofanias (áreas claras +devem ser finas) ou embossing (áreas elevadas), selecione a peça e use **Tools - Invert Image**. +Consulte [Edição de imagens](../../addons/image-editing.md). **Faixa de Brilho:** @@ -279,12 +278,11 @@ e **Ângulo do meio-tom**; use um tamanho de célula de pelo menos algumas largu - Cria efeito 3D estilo malha cruzada - Típico: 0-45 graus -**Inverter:** - -- **Habilitado:** Branco = profundo, Preto = raso -- **Desabilitado:** Preto = profundo, Branco = raso +**Invertendo a imagem:** -Use inverter para litofanias (áreas claras devem ser finas) ou embossing (áreas elevadas). +O preto é gravado em profundidade e o branco fica raso. Para inverter, para litofanias (áreas claras +devem ser finas) ou embossing (áreas elevadas), selecione a peça e use **Tools - Invert Image**. +Consulte [Edição de imagens](../../addons/image-editing.md). ### Padrão de Varredura diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md new file mode 100644 index 000000000..5edefeebc --- /dev/null +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -0,0 +1,36 @@ +# Редагування зображень + +Розширення редагування зображень змінює вигляд зображення робочого елемента, не торкаючись +імпортованого файлу. Кожне редагування зберігається разом із робочим елементом, тож його можна +скасувати, прибрати пізніше або розмістити той самий файл кілька разів з різними редагуваннями. + +Полотно, 3D-перегляд і кожна операція використовують відредаговане зображення. Те, що ви бачите на +полотні, те й гравіює лазер. + +## Інверсія зображення + +Виділіть один або кілька робочих елементів і виберіть **Tools - Invert Image**. Світлі області +стають темними, а темні — світлими; прозорі області залишаються прозорими, тому тло, як і раніше, не +гравіюється. + +![Чорно-біле фото гір та озера, як імпортовано](/images/image-edit-invert-before.webp) + +![Те саме фото після Invert Image](/images/image-edit-invert-after.webp) + +Виберіть цей пункт ще раз, щоб прибрати інверсію. Для змішаного виділення інвертуються ті робочі +елементи, які ще не інвертовані, тож усе виділення зрештою стає однаковим. + +Використовуйте це для літофаній і тиснення в операції гравіювання, де світлі області мають +гравіюватися глибоко, або для гравіювання світлого зображення на темному тлі. + +## Старіші проєкти + +До цього розширення інверсія була налаштуванням операції гравіювання. Коли ви відкриваєте старіший +проєкт, який його використовував, Rayforge переносить налаштування на робочі елементи цього шару, +тож результат залишається тим самим. + +## Коли розширення вимкнено + +Проєкт зберігає свої редагування зображень, коли розширення вимкнено або не встановлено. Rayforge +повідомляє, що проєкт використовує недоступні функції, і показує зображення без цих редагувань, доки +розширення не буде знову увімкнено. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md index 43dde47da..e3e1a1620 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -178,13 +178,11 @@ | 10% | 60% | Середня глибина, безпечно | | 20% | 80% | Глибокий, драматичний рельєф | -**Інверсія:** +**Інверсія зображення:** -- **Вимкнено** (за замовчуванням): Білий = мілко, Чорний = глибоко -- **Увімкнено:** Білий = глибоко, Чорний = мілко - -Використовуйте інверсію для літофаній (світлі області мають бути тонкими) або тиснення (підняті -області). +Чорне гравіюється глибоко, а біле залишається мілким. Щоб поміняти їх місцями, для літофаній (світлі +області мають бути тонкими) або тиснення (підняті області), виділіть робочий елемент і використайте +**Tools - Invert Image**. Див. [Редагування зображень](../../addons/image-editing.md). **Діапазон яскравості:** @@ -278,13 +276,11 @@ - Створює 3D-ефект, схожий на перехресну штриховку - Типово: 0-45 градусів -**Інверсія:** - -- **Увімкнено:** Білий = глибоко, Чорний = мілко -- **Вимкнено:** Чорний = глибоко, Білий = мілко +**Інверсія зображення:** -Використовуйте інверсію для літофаній (світлі області мають бути тонкими) або тиснення (підняті -області). +Чорне гравіюється глибоко, а біле залишається мілким. Щоб поміняти їх місцями, для літофаній (світлі +області мають бути тонкими) або тиснення (підняті області), виділіть робочий елемент і використайте +**Tools - Invert Image**. Див. [Редагування зображень](../../addons/image-editing.md). ### Патерн сканування diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md new file mode 100644 index 000000000..0b9423fac --- /dev/null +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/image-editing.md @@ -0,0 +1,26 @@ +# 图像编辑 + +图像编辑插件可以在不改动导入文件的情况下更改工件图像的外观。每次编辑都与工件一起保存,因此您可以撤销它、稍后移除它,或者将同一文件以不同的编辑放置多次。 + +画布、3D 预览和每个操作都使用编辑后的图像。您在画布上看到的就是激光雕刻的内容。 + +## 反转图像 + +选择一个或多个工件,然后选择 **Tools - Invert +Image**。浅色区域变深,深色区域变浅;透明区域保持透明,因此背景仍然不会被雕刻。 + +![导入的灰度山湖照片](/images/image-edit-invert-before.webp) + +![Invert Image 之后的同一张照片](/images/image-edit-invert-after.webp) + +再次选择它即可取消反转。对于混合选择,尚未反转的工件会被反转,使整个选择最终保持一致。 + +在雕刻操作中将其用于石版画和浮雕(浅色区域应雕刻得较深),或者在深色背景上雕刻浅色图案。 + +## 旧项目 + +在此插件出现之前,反转是雕刻操作的一个设置。当您打开使用过该设置的旧项目时,Rayforge 会将该设置移动到该图层的工件上,因此输出保持不变。 + +## 插件被禁用时 + +当插件被禁用或未安装时,项目会保留其图像编辑。Rayforge 会提示项目使用了不可用的功能,并在插件重新启用之前显示未应用这些编辑的图像。 diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md index c39e48630..10bb05c26 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/operations/engrave.md @@ -168,12 +168,10 @@ | 10% | 60% | 中等深度、安全 | | 20% | 80% | 深、戏剧性浮雕 | -**反转:** +**反转图像:** -- **关闭**(默认):白色 = 浅,黑色 = 深 -- **开启**:白色 = 深,黑色 = 浅 - -反转用于石版画(浅色区域应较薄)或浮雕(凸起区域)。 +黑色雕刻得深,白色保持浅。要交换两者(例如石版画的浅色区域应较薄,或浮雕的凸起区域),请选择工件并使用 +**Tools - Invert Image**。参见[图像编辑](../../addons/image-editing.md)。 **亮度范围:** @@ -257,12 +255,10 @@ - 创建类似交叉阴影的 3D 效果 - 典型:0-45 度 -**反转:** - -- **启用:** 白色 = 深,黑色 = 浅 -- **禁用:** 黑色 = 深,白色 = 浅 +**反转图像:** -反转用于石版画(浅色区域应较薄)或浮雕(凸起区域)。 +黑色雕刻得深,白色保持浅。要交换两者(例如石版画的浅色区域应较薄,或浮雕的凸起区域),请选择工件并使用 +**Tools - Invert Image**。参见[图像编辑](../../addons/image-editing.md)。 ### 扫描图案 diff --git a/website/sidebars.js b/website/sidebars.js index a293e104e..e3083eb15 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -117,6 +117,7 @@ module.exports = { items: [ 'addons/ai-workpiece-generator', 'addons/deepnest', + 'addons/image-editing', 'addons/print-and-cut', 'addons/projector-mode', 'addons/smart-stock', diff --git a/website/static/images/image-edit-invert-after.webp b/website/static/images/image-edit-invert-after.webp new file mode 100644 index 000000000..da29caed9 Binary files /dev/null and b/website/static/images/image-edit-invert-after.webp differ diff --git a/website/static/images/image-edit-invert-before.webp b/website/static/images/image-edit-invert-before.webp new file mode 100644 index 000000000..62c97248d Binary files /dev/null and b/website/static/images/image-edit-invert-before.webp differ diff --git a/website/static/screenshots/step-settings-engrave-general-constant_power.webp b/website/static/screenshots/step-settings-engrave-general-constant_power.webp index 6f8b0a6f2..39a09a7f7 100644 Binary files a/website/static/screenshots/step-settings-engrave-general-constant_power.webp and b/website/static/screenshots/step-settings-engrave-general-constant_power.webp differ diff --git a/website/static/screenshots/step-settings-engrave-general-dither.webp b/website/static/screenshots/step-settings-engrave-general-dither.webp index 5352aacf6..657a1b187 100644 Binary files a/website/static/screenshots/step-settings-engrave-general-dither.webp and b/website/static/screenshots/step-settings-engrave-general-dither.webp differ diff --git a/website/static/screenshots/step-settings-engrave-general-multi_pass.webp b/website/static/screenshots/step-settings-engrave-general-multi_pass.webp index f759102ae..c4889cb2a 100644 Binary files a/website/static/screenshots/step-settings-engrave-general-multi_pass.webp and b/website/static/screenshots/step-settings-engrave-general-multi_pass.webp differ diff --git a/website/static/screenshots/step-settings-engrave-general-variable.webp b/website/static/screenshots/step-settings-engrave-general-variable.webp index 2b14f4536..39e2b6ff4 100644 Binary files a/website/static/screenshots/step-settings-engrave-general-variable.webp and b/website/static/screenshots/step-settings-engrave-general-variable.webp differ diff --git a/website/static/screenshots/step-settings-engrave-post.webp b/website/static/screenshots/step-settings-engrave-post.webp index a5a1254a5..5df9cbea5 100644 Binary files a/website/static/screenshots/step-settings-engrave-post.webp and b/website/static/screenshots/step-settings-engrave-post.webp differ