Skip to content

fix: canvas tool correctness, modifiers and missing controls - #52

Open
ProdigyRahul wants to merge 12 commits into
Infrawrench:mainfrom
ProdigyRahul:combine/tools
Open

fix: canvas tool correctness, modifiers and missing controls#52
ProdigyRahul wants to merge 12 commits into
Infrawrench:mainfrom
ProdigyRahul:combine/tools

Conversation

@ProdigyRahul

@ProdigyRahul ProdigyRahul commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

groups ten branches: the canvas tools.

tools acting on the wrong thing

  • paint tools silently painted a different layer when the active one was locked or non-raster; red eye and vanishing point ignored the selection and the layer lock.
  • free transform scaled about the box centre rather than the pinned corner, and the handle did not follow the cursor; it ignored the active selection and did not transform the layer's mask.
  • image size did not rescale layer masks; crop and canvas size skipped group and adjustment layers, and left guides, artboards, paths and notes at the old coordinates.
  • shape layers always landed at the top of the root stack; direct selection mirrored the opposite handle's length as well as its direction; path selection dragged the active path even when the click missed it.
  • tool sessions were not scoped to a document, so switching tabs baked live-preview pixels in with no undo entry, and a clone source alt-clicked in one document sampled another at the old coordinates.

selection maths

feather was quantised to floor(radius / √3), so small radii did nothing and 2 px equalled 3 px; border sat entirely outside the edge for odd widths; marquees were not clipped to the canvas.

gestures that did not exist

shift-constrain and alt-duplicate on the move tool, and alt-from-centre for the marquee. the brush had no Flow, no adjustable spacing, and pressure changed only the dab's size.

before after
before after

alt-dragging with the move tool, and what the layers panel holds afterwards:

before after
before after

the gradient ramp was two colours

five styles but no way to put a third colour anywhere, and no per-stop opacity. the ramp is a stop list now; the two swatch-driven fills behave exactly as they did.

before after
before after

spectrum

dodge and burn also gain Range and Exposure, blur and sharpen a Strength, and the smudge brush's mix stops being hard-coded — all defaulting to what those paths already applied.

previews that lied

the elliptical marquee previewed a rectangle:

before after
before after

the rotate handle's standoff was in document units, so at 800% it sat three screen pixels off the box and at 10% it floated 240 away; the brush cursor ignored pressure and was never cleared; the polygonal lasso had no way to drop an anchor; bring forward at the top of the stack recorded a no-op edit; replace_layer_tiles was quadratic in tile count.

one merge decision worth flagging

two branches independently added on_deactivate to the paint tool — one cancelling the in-progress stroke, one committing it. i kept commit: a stroke is pixels the user already painted, switching tools should not throw it away, and finish records it as one undoable edit.


cargo fmt --all --check, cargo clippy --workspace --all-targets -D warnings, cargo test --workspace — 667 passed, 0 failed.

how this relates to my other open prs

these seven are independent of each other — each branches off main and each is green on its own. they do share files with my five open prs (#36, #38, #42, #44, #46), mostly workspace.rs, so whichever lands first will leave the others needing a rebase. happy to rebase in whatever order suits you, or to split any of these further if one is too big to review in a sitting.

@ProdigyRahul
ProdigyRahul marked this pull request as ready for review August 26, 2026 12:25

@IAmJSD IAmJSD left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review was written by Claude (Fable 5), acting on Astrid's behalf.

The majority of this 10-commit PR is correct, well-tested fixes for real bugs — the mask-tile undo fix, bezier direction-only mirroring, guides/artboards riding crop/resize, brush flow/spacing/pressure dynamics with pixel-level tests, multi-stop ramp_at, the replace_layer_tiles FxHashSet fix, and red-eye selection gating all check out. But several of the fixes introduce new, verified bugs, two of which break core interactions in ordinary use. Requesting changes.

Blockers:

  1. Transform pivot jump on the second drag of a session (plugins/tools-transform/src/lib.rs:416): on_pointer_down re-anchors pivot_anchor per drag without compensating offset, so the accumulated transform is re-interpreted about the new pivot. Scale 2× with the right handle, release, then just click inside the box to move — the layer instantly jumps by half its original width. The added test only does single-drag sessions, so it can't see this.

  2. Empty wand-Replace leaves a "select nothing" trap (plugins/tools-select/src/lib.rs:21,63): the new empty-Replace path runs deselect() then unconditionally sel.activate() with an all-zero mask. Since is_empty() is just !active, every brush, fill, gradient and retouch tool then silently does nothing document-wide until the user manually Deselects. The intent (empty replace clears the selection) needs deselect() without activate().

Major:

  1. VanishingPointTool::on_deactivate (perspective.rs:414) calls self.on_commit(ctx) — but the tool never implements on_commit (strokes commit in on_pointer_up), so it dispatches to the ToolPlugin trait's empty default. The "fix" is a no-op with a comment claiming otherwise; self.stroke isn't even cleared, and switching tools mid-stroke still bakes preview pixels with no history entry — the exact bug commit 0250055 claims to fix.
  2. transform_mask (crates/core/src/resample.rs:421,448) samples the raw tile map (0 for missing tiles) instead of LayerMask::value semantics (default_value outside bounds). For a revealing mask this (a) densely allocates all-zero tiles across the whole clip on Image Size / Free Transform (~140 MB of garbage on a 12k×12k doc with a fresh empty mask) and (b) darkens a bilinear fringe along the old bounds. The added mask-resize test asserts on raw tiles.value for an effectively no-op mask, so it verifies the wrong semantics.
  3. The elliptical marquee clips the drag rect to canvas before inscribing the ellipse (tools-select/src/lib.rs:270), so a partly off-canvas drag commits a differently-proportioned ellipse than the preview showed — clip the mask, not the generating rect.
  4. The feather box-blur rewrite (crates/core/src/selection.rs:570-600) replaces the O(1)-per-pixel sliding window with a per-pixel radius loop — O(w·h·r), ~150× more work at radius 250, and it runs on every marquee commit with feather set. The fractional-radius fix only needed two extra taps on the existing window.
  5. on_cancel of an alt-duplicate drag (tools-basic/src/lib.rs:272) removes the copy via LayerTree::remove, which doesn't clear doc.active_layer — Esc mid-alt-drag leaves the active layer pointing at a deleted id and painting/move/transform silently no-op. The adjacent comment describes remove_layer, which is never called.
  6. Exposure/Strength are applied as a * x * 2.0 (tools-paint/src/lib.rs:400): above 50%, Burn writes negative RGB unclamped into F32 tiles and Blur/Sharpen extrapolates past the convolved value (ringing). Clamp the factor to 1 or use a non-extrapolating model.

Minor: duplicated near-identical comment paragraphs in tools-transform (~486, ~545) and a fused doc-comment in liquify.rs:131 (render's docs lost); paintable_layer's "the caller reports it" comment is false — locked/hidden/non-raster layers now silently no-op with no feedback; the Alt scale-from-centre branch keeps the old half-speed handle rate the commit claims to fix; and three PR-body claims aren't implemented (vanishing point still ignores locked/selection, free transform still ignores the selection, map_geometry's doc cites rotate/flip commands that aren't wired to it). MoveTool also gets no on_deactivate, so an in-flight alt-duplicate leaks an unrecorded layer on tab-switch — the same session-scoping class this PR fixes elsewhere.

Findings 1–3 are quick, surgical fixes; 4 and 6 need a bit more care. With those addressed this merges — the underlying work is good.

@ProdigyRahul

Copy link
Copy Markdown
Contributor Author

fixed

@IAmJSD IAmJSD left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This follow-up review was written by Claude (Fable 5), acting on Astrid's behalf.

This was a strong round: all twelve findings were addressed and ten are cleanly fixed with meaningful tests — the empty-Replace trap, transform_mask default-value semantics (with the old test corrected to assert the right thing), the sliding-window feather with a direct-sum comparison at fractional radii, the vanishing-point finish_stroke, the alt-duplicate cancel restoring the active layer, tone clamping, the live-pivot scale rate, and the previously-unimplemented PR-body claims (locked/selection-aware vanishing point, selection-scoped free transform, map_geometry wired to rotate/flip, MoveTool::on_deactivate). Three verified items remain, all small:

  1. Major — repivot inverts the linear part in the wrong order (plugins/tools-transform/src/lib.rs:232-245): the compensation counter-rotates first, then divides by scale (the comment even says "Undo the rotation, then the scale"), but matrix() composes scale-then-rotate, so the inverse must divide by scale first, then counter-rotate. Rotation-free repivots are exact (and the new multi-drag test covers those), but scale one axis with an edge handle, rotate, then press inside to move — the layer jumps (~17 px on a 40 px box at 2×/45°). Surgical fix: swap the two steps; add a rotated repivot test.

  2. Major — 90° canvas rotation destroys guides (crates/core/src/document.rs:843): the guide branch maps the position but never swaps guide.horizontal, which the two 90° ops require. Under Cw90 (x,y)→(h−y,x), a horizontal guide at pos maps (0,pos)→(h−pos,0) and keeps the y component — every horizontal guide collapses to position 0, every vertical guide lands at x = h. This branch was safe for its pre-existing axis-preserving callers (crop/resize); wiring it to Cw90/Ccw90 in this round exposed it. Flips and 180° are fine. The mapping needs an orientation swap for the 90° cases, plus a guide-rotation test.

  3. Minor — corner-sliver marquee recreates the select-nothing trap (plugins/tools-select/src/lib.rs:289 + Selection::clip_to): an elliptical drag whose bounding rect clips the canvas only at a corner the ellipse never reaches (e.g. (190,190)→(400,400) on a 200×200 canvas) passes the rect-intersect guard, apply_shape sets active = true, then clip_to zeroes everything — the exact active-all-zero state finding 2 just eliminated, and with Replace it also wipes the prior selection. Deselect when the mask is empty after clip_to.

One doc nit: lift's "reproduces the layer exactly" holds only for hard edges — feathered selections recombine with a slightly translucent seam (accepted float-composite semantics, but the comment overclaims).

With those three, this merges — the underlying work here is the best of the series.

Quarter turns swap guide orientation via map_geometry's new
swap_guide_axes flag; repivot inverts the composed linear map in the
right order; neural upscales rescale masks and document geometry like
the classical path; an elliptical marquee that only clips the canvas
with its bounding box deselects instead of leaving an all-zero mask.

(re-applied from the review changes in merge ef18ea4, which this rebase
flattens)
@IAmJSD

IAmJSD commented Sep 4, 2026

Copy link
Copy Markdown
Member

Rebased onto current main (ef18ea46e4e336), flattening the merge commit into 12 linear commits. Two notes on what changed beyond the mechanical rebase:

  • The old merge commit had accidentally reverted several things that landed on main after this branch was cut: the ICC-profile undo/PSD-writer work (set_icc_profile, EditOp::IccProfileSet, the colormgmt display-transform rebuilds and soft-proof toggle, the PSD ICC resource substitution and its tests), the compositor fixes for adjustment-layer blend modes and group fill opacity (CPU + WGSL), and the schist-bin PKGBUILD dep/checksum re-pin. The rebase restores main's versions of all of those.
  • The review fixes that only existed inside the merge commit (guide-axis swap on quarter turns + test, the repivot inversion-order fix + test, mask/geometry rescale in the neural upscale path, the empty-ellipse deselect guard + test) were re-applied as the final commit, fix: address final canvas-tools review.

Workspace build and full test suite pass locally (1423 tests).

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