Skip to content
Merged
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Non-destructive image editing tools for workpieces."""
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,)
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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]
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading