Skip to content

refactor: image edit registry, with Invert as its first edit - #552

Merged
knipknap merged 8 commits into
barebaric:mainfrom
switch87:feat/image-edit-registry
Oct 13, 2026
Merged

knipknap merged 8 commits into
barebaric:mainfrom
switch87:feat/image-edit-registry

Conversation

@switch87

@switch87 switch87 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

This is the architecture-only refactor you asked for on #535: an image edit registry, with Invert turned into its first edit and removed from the Engrave step. No new editing tools; #535 gets reworked on top of this once it lands.

Shape

Core (rayforge/core/image_edit/). The core only knows the registry, never what an edit does.

  • ImageEditStep(op, params, enabled): one entry in a workpiece's edit stack. It is frozen and JSON-serializable. Keys it does not know are kept and written back.
  • ImageEditOp: the base class an addon implements (NAME, LABEL, apply(image, params) -> pyvips.Image).
  • ImageEditRegistry / image_edit_registry: built like StepRegistry, including unregister_all_from_addon.
  • apply_image_edits(): applies the enabled edits in order and skips ops that are not registered. missing_image_edit_ops() lists those ops.
  • New hook register_image_edits(image_edit_registry), registered worker_ok=True because the pipeline renders. Plugin API goes to 22, with a changelog entry in hooks.py.

WorkPiece.

  • image_edits: a tuple of ImageEditStep. The setter clears the processed-image cache and sends updated, so the pipeline and the canvas pick up a change the same way they do for source_segment.
  • It is serialized in to_dict/from_dict (older files load with no edits) and copied in in_world().
  • As discussed, the two functions are split:
    • _get_vips_image() (private) is the unedited render. It is the old get_vips_image() unchanged, still on top of the shared SourceAsset base cache.
    • get_processed_image() (public) applies the stack and caches the result per size.
  • All three callers now use get_processed_image(): render_to_pixels, render_chunk and the 3D preview. Everything that consumes pixels goes through render_to_pixels, so the canvas, Engrave, auto-levels, the levels histogram, tracing fallbacks and Print & Cut all see the edited image.

Image Editing addon (rayforge-addon-image-editing).

  • The addon has a worker and a frontend. The worker registers InvertOp (NAME = "invert"). The frontend adds Tools > Invert Image.
  • Invert Image toggles the edit on the selected workpieces as one undo step. On a mixed selection it inverts the workpieces that are not inverted yet.
  • InvertOp inverts the colour bands and leaves alpha alone, so transparent areas stay transparent and are still not engraved.

Missing addon. Doc.missing_image_edit_ops joins missing_step_types, so a project that uses an edit whose addon is unavailable gets the existing missing features dialog. As you suggested, the edit is then skipped (and kept in the file).

Removing Invert from the Engrave step

The step loses the setting, its row, its cache key and its to_dict entry. The levels histogram and the Power section's min/max titles no longer depend on it.

Older projects.

  • The pipeline should not know about Engrave, so the migration is a small generic hook. Step.pop_legacy_image_edits() returns [] on the base class. EngraveStep returns [invert] if the file had invert: true, and only once.
  • At the end of Doc.from_dict, Doc.adopt_legacy_image_edits() appends those edits to the workpieces of the step's layer and skips any edit a workpiece already has. That makes it idempotent, which the addon-toggle round trip in DocEditor needs.
  • invert stays a known key on EngraveStep, so an old value does not end up in extra and get written back forever.

How faithful this is:

  • Power modulation, multi-pass and dither: identical for every opaque pixel. Both versions leave fully transparent pixels alone.
  • Semi-transparent edge pixels differ slightly. The old code inverted after compositing over white; the edit inverts the colour before.
  • Constant power: the threshold now applies to the inverted image. The old code flipped the comparison (gray > threshold), so at the default threshold of 254 an inverted step burned only pure white. Now it burns everything that is not near-black, the mirror image of the non-inverted output. I think that is what Invert was meant to do, but it is a change.
  • Mixed layers: if one layer has an inverted and a non-inverted Engrave step, both now see the inverted image after migration, because the edit lives on the workpiece. If that layer also traces bitmaps in a Contour step, the trace now follows the inverted image too.
  • Saved recipes lose their invert value, as for any key that leaves recipe_keys().

Not in this PR

  • TraceSpec.invert (the import dialog's Invert for tracing) is untouched. It could become the same edit later, but that changes what the import dialog stores, so I left it out of an architecture PR.
  • No .po/.pot changes and only English docs: addons/image-editing.md (new) plus the two Invert paragraphs in engrave.md.

Verification

  • Targeted suites: core image_edit (13), WorkPiece edits (11), Doc adoption (6), image-editing addon (11, including two tests that pin the old Invert behaviour for dither and power modulation), laser addon (189), and the laser UI pages (54, run with -m ui).
  • Live app, run headless on a virtual display:
    • Invert Image is in the Tools menu and disabled without a selection.
    • On a dark square, the mean RGB goes 30.6 → 214.5 and the workpiece's image_edits becomes ["invert"].
    • Undo, redo and a second toggle all restore correctly.
    • A project saved with invert: true on its Engrave step loads with the workpiece inverted, with no missing-feature dialog and no invert left on the step.

Workpieces carry an ordered stack of image edits. The ops come from a
new image edit registry that addons fill through the
register_image_edits hook (plugin API 22); the core only applies them
and skips ops that are not registered.

get_vips_image() becomes the private _get_vips_image() for the
unedited render, and get_processed_image() applies the stack. Every
consumer of the workpiece's pixels goes through it, so the canvas, the
3D preview and the pipeline see the same image. Unregistered ops are
reported through the existing missing-features dialog, and steps can
hand legacy settings over to the stack after a document loads.

Refs barebaric#515
The addon registers an Invert edit that inverts the colour and keeps
alpha, and a Tools > Invert Image action that toggles it on the
selected workpieces as one undo step.

Refs barebaric#515
The Engrave step no longer has an Invert setting, and the raster
preprocessing no longer knows about inversion. Projects saved with
Invert on an Engrave step get the Invert edit on the workpieces of that
layer when they are opened.

Refs barebaric#515
The addon now registers its action directly in the register_actions
hook with an enabled_when predicate, instead of deferring through
action_extension_registry. Plugin API is 23 after merging main's
API 22.
The Engrave step lost its Invert row to the new image edit registry,
and the dither and post processing pages picked up the Serpentine and
Crop to Stock rows from main.
Embed a before/after example pair for Invert Image, generated by
scripts/media/gen_invert_example.py, and translate the image editing
addon page plus the Invert paragraphs of the engrave page into all
shipped website languages.
@knipknap
knipknap marked this pull request as ready for review October 13, 2026 04:12
@knipknap
knipknap merged commit 32ec79d into barebaric:main Oct 13, 2026
32 checks passed
@knipknap

Copy link
Copy Markdown
Contributor

Thanks a lot for the refactoring PR! I made some refinements and think this makes main ready for rebasing #535 on top of it!

This branch was previously deployed

1 inactive deployment
pypi — ef3849fc Deployed Oct 12, 2026 by knipknap via Build and Publish Wheel #2283
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants