Skip to content

feat: combine paint layers + flatten filters#847

Merged
hm21 merged 4 commits into
stablefrom
feat/merge-layers
Jul 7, 2026
Merged

feat: combine paint layers + flatten filters#847
hm21 merged 4 commits into
stablefrom
feat/merge-layers

Conversation

@hm21

@hm21 hm21 commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Summary

Adds appearance-preserving combine/flatten operations, so a host app can reduce clutter without changing how the result looks.

Tracking issue in the consuming app: divine-mobile#5834.

1. Combine paint layers (the original ask)

Reduce several selected draw (paint) layers to a single PaintLayer. A true merge to one layer — distinct from grouping (groupId).

bool get canMergeSelectedLayers;    // true when ≥2 non-censor PaintLayers selected
PaintLayer? mergeSelectedLayers();  // merge → one layer, one undo entry, selects it
  • PaintLayer now holds List<PaintedModel> items; item stays a back-compat getter/setter to items.first. Single-stroke layers serialize legacy item; merged layers add an items array (minified it); fromMap still reads legacy payloads.
  • Pure, unit-tested PaintLayerMergeManager.merge(...) bakes each source layer's offset/scale/rotation/flip/opacity (+ stroke width, erase radius) into a shared frame → identity-transform layer that renders pixel-identical.
  • Inserted at the top-most source z-index; originals removed; single undo entry.

2. Flatten filters

bool get canMergeFilters;   // true when ≥2 active filters and none use video-timeline metadata
FilterState? mergeFilters();// flatten the active filter stack into one FilterState, one undo entry
  • Exact by construction: ColorFilterGenerator already composes the whole filter+tune stack into one ColorFilter.matrix (mergeColorMatrices), so flattening N filter states reuses that math and is appearance-identical. The filter editor re-reads prior filters as an opaque allMatrices base, so re-editing still works. Gated out when any filter carries video-timeline scheduling.
  • Backed by pure mergeFilterStates() / canMergeFilterStates().

Why not text layers / tune adjustments

  • Text layers — not possible. A TextLayer is one string + one style + one transform; N independently positioned/rotated/styled texts can't be baked into one without losing appearance or editability. Grouping already covers "handle together".
  • Tune adjustments — deliberately skipped. Same color-matrix math would compose exactly, but the tune editor restores each slider by semantic id (el.id == item.id), so flattening to one opaque matrix would reset every slider to 0 on re-open. The re-editability regression isn't worth it. (Happy to add it opt-in if you want the trade-off.)

Tests

flutter test507 pass, dart analyze (lib, test, example) clean. New coverage:

  • Paint geometry (rotated + flipped + scaled sources map to identical world positions; stroke-width/radius/opacity baking; flattening already-merged sources).
  • Paint serialization round-trip (multi-item plain + minified keep N items; legacy single-item still deserializes).
  • Paint history (one undo entry; undo restores exact originals) and gating (<2 / censor / mixed non-paint).
  • Filter flatten exactness (merged+tune == original stack+tune), gating (<2 / video-timeline), and history/undo.
  • Back-compat: existing single-item PaintLayer tests pass; item getter returns items.first.

Housekeeping

  • "Combine Paint Layers" button in the layer grouping example.
  • Version bumped to 13.2.0 + CHANGELOG entries.

hm21 added 2 commits July 7, 2026 13:49
Add a real "merge/combine paint layers" feature so a host app can reduce
several selected draw layers to a single PaintLayer, distinct from grouping.

- PaintLayer now holds List<PaintedModel> items; `item` is a back-compat
  getter/setter mapping to items.first. Single-stroke layers serialize the
  legacy `item`; merged layers additionally serialize an `items` array
  (minified key `it`), and fromMap still reads legacy single-item payloads.
- PaintLayerMergeManager.merge bakes each source layer's offset/scale/rotation/
  flip/opacity (and stroke width, erase radius) into a shared frame so the
  merged layer has an identity transform and looks pixel-identical.
- Public API on ProImageEditorState: `PaintLayer? mergeSelectedLayers()` and
  `bool get canMergeSelectedLayers`; wrapper `handleMergeLayers()` in the
  layers service. Merge is inserted at the top-most source z-index, removes the
  originals, selects the new layer, and records a single undo entry.
- Renderer iterates all items (per-stroke opacity for merged layers); hit
  checks and copy managers updated for multiple items.
- Example: "Combine Paint Layers" button in the layer grouping example.

Bumps version to 13.2.0.
Filters are pure color matrices that the editor already renders as a single
combined ColorFilter.matrix, so flattening the accumulated filter stack into
one FilterState is appearance-identical. Add ProImageEditorState.mergeFilters()
/ canMergeFilters (gated to >=2 filters with no video-timeline metadata),
backed by the pure, tested mergeFilterStates()/canMergeFilterStates() helpers.

Not applied to text layers (a single TextLayer cannot represent multiple
independently transformed/styled texts) or tune adjustments (the tune editor
restores each slider by id, which flattening would break).
@hm21 hm21 changed the title feat(paint): combine selected paint layers into one feat: combine paint layers + flatten filters Jul 7, 2026
@hm21 hm21 merged commit 7d82264 into stable Jul 7, 2026
1 check passed
@hm21 hm21 deleted the feat/merge-layers branch July 7, 2026 12:36
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.

1 participant