Skip to content

Misc bugfixes - #95

Merged
TheTechromancer merged 15 commits into
devfrom
misc-bugfixes
Aug 11, 2026
Merged

Misc bugfixes#95
TheTechromancer merged 15 commits into
devfrom
misc-bugfixes

Conversation

@TheTechromancer

@TheTechromancer TheTechromancer commented Aug 2, 2026

Copy link
Copy Markdown
Member

Summary

A batch of bug fixes and follow-through work across the layer model, watercolor brush, noise sampling, paste, save/export, and canvas resize. Several of the fixes were symptoms of the same underlying asymmetry — masks were modeled as a second-class entity kind with their own parallel document/undo primitives — so the layer work unifies that rather than patching each call site.

Layer entities: one kind-uniform attach/detach model

Masks (and future modifiers) hung off a host through a separate set of document primitives from tree nodes, and consumers had to know which pair to call. Using the wrong one half-detached a mask (unlinked from the host but left in its filters list) or reattached it into root.children.

  • Document now has one primitive pair — link/unlink, reinsert_entity, remove_entity — that routes by the entity's own kind. LayerNode owns the routing via ChildSlot + attach_child/detach_child (layer.rs); no caller asks what it's holding. move_layer refuses a filter id outright — a mask has no position in the tree.
  • Undo drops FilterAddAction/FilterRemoveAction entirely; LayerAddAction/LayerRemoveAction become EntityAddAction/EntityRemoveAction and cover every kind. Adding a new entity kind needs no new undo action. Restore now lands at the original index, so undoing removal of one of several modifiers no longer reorders the stack.
  • DarklyEngine::detach_for_remove dispatches on kind, so masks are deletable as ordinary layer-panel rows (single and batch), including in one undo step alongside layers. The "can't delete the last layer" floor counts only tree nodes; merge_down rejects a modifier id instead of indexing the host's children with a filter index.
  • add_mask_unseeded extracts the allocate-texture/ensure-snapshot half of add_mask, so duplicate and rich paste reuse it without pushing a spurious undo entry or seeding the mask from the receiving document's active selection.

Layer reselection after delete

Deleting a layer left nothing selected (and left an isolated-node id dangling, blanking the canvas).

  • New state/layerTree.ts: a single indexing walk answering liveness, panel order, visibility and parentage, plus the reselection rule — nearest surviving sibling below → above → parent, escalating when the parent died in the same batch. The rule is taken from GIMP (gimp_item_tree_remove_item) and Krita (LayerBox::slotAboutToRemoveRows), which agree on it exactly; citations are in the module header.
  • pruneSelectionAgainstTree becomes reconcileSelection, running in the one reconciler every tree mutation funnels through — no removal path knows about reselection. It also clears the isolation target when the isolated node dies (matching Krita's KisImage::aboutToRemoveANode) and expands collapsed ancestors of the reselected row (GIMP's tree view does the same).
  • Undo/redo pass adoptAppeared, so undoing a delete selects the restored subtree root rather than leaving the selection where it was.
  • app.nodeById / app.activeNode centralize "resolve a node by id"; PropertiesPanel and the text tool stop re-walking the tree.

Watercolor pigment buildup

The pickup atlas sampled only pre_stroke_texture — the dry layer — so the pigment load a dab picked up was frozen for the whole stroke and a mark could never build past its first pass.

The pickup pass now samples the live canvas: the stroke scratch composited over the dry layer with source_over. This is legal because the pass targets the atlas, not the scratch, so there's no read/write alias — Scratch documents the two read paths and exposes live_canvas_bind_group() for the cheap one (the mirror copy is only needed by passes that also write the scratch). A const assert pins ATLAS_WIDTH * ATLAS_HEIGHT >= MAX_DABS_PER_PHASE (silent cell aliasing otherwise), and a debug_assert pins the scratch/pre-stroke frame equality the shared UV uniforms depend on.

Noise: non-repeating field, pixel-domain scale, 2D per-dab scatter

Three related defects in the noise/image sampling frame:

  • Repeating field. TILE_SPAN = 16.0 was doing double duty as both the field's repeat period and the tile's detail window. It's split into FIELD_SPAN = 128.0 (period — how far the texel budget stretches) and DETAIL_SPAN = 16 (what resolution_for_octaves sizes against). The field no longer visibly repeats within a normal view, at identical memory; fine octaves soften instead.
  • 1D, aliased per-dab variation. The offset was vec2(v*64, v*64) — the same scalar on both axes, so every dab landed on the x == y diagonal, and the 64-unit stride was an exact multiple of the 16-unit period. Replaced by fbm_offset2, a 2D hash bounded to exactly one period.
  • scale_with_brush removed from noise and image. Dab space now always samples in oriented dab-pixels, so scale is a canvas-pixel feature size in both frames. To scale grain with the brush you wire brush_settings.size into it — and that signal is now published in canvas pixels (the brush diameter), seeded bespoke in seed_sensors so the CPU slot and the GPU-packed dab field are numerically identical. size also declares a natural_range, without which the wire-boundary remap was skipped and a raw 0..4 value was dumped into a pixel field.

Bundled brushes follow: hair.yaml rewired to the new model, and twirly_hair becomes sponge (polygon tip, randomized noise rotation/variation).

Paste

  • Paste in place silently did nothing after a normal copy. The floating path read only flat ImageData clips, but a normal copy produces a rich Layer clip. Clipboard::paste_pixels() returns pixels from either variant.
  • Paste into a mask. pasteInPlace is now "Paste into Active Layer" and routes through the same floating→commit_floating path as the transform variant, whose R8 branch already writes masks — so pasting into an active mask works, committed or floating.
  • A region copy no longer carries the source layer's mask (a mask belongs to a whole-layer copy), and rich paste attaches the mask unseeded so it can't turn the receiving document's active selection into mask pixels.

Save / Save As / export

Save and Export Image were separate flows, and Save was disabled outright on Firefox.

  • One SAVE_FORMATS table where each format owns its extension, MIME, picker filter, and produce() — no switch (format) at any consumer. The native picker gets a "Save as type" dropdown (.darkly, PNG, JPEG, WebP) and the chosen file type decides document-save vs canvas-export.
  • Firefox/Safari get an in-app SaveModal driving produce + download; saveDocument resolves only when the whole save is done, so closeGuard can await it. canSave becomes hasFilePicker and no longer gates the UI.
  • ExportImageModal.svelte, exportImage.svelte.ts and the exportImage action are deleted, along with the exportImage hotkey in defaults + all three editor presets. Users bound to Ctrl+Shift+E (or Ctrl+Alt+W under the Photoshop preset) lose that binding — Save As covers it.
  • The OffscreenCanvas encode is extracted to rgbaToBlob and shared by export and the .darkly zip's internal composite.png; timelapse export reuses the same picker (acquired inside the click's activation window, before the long encode).

Canvas resize freeze (Firefox) + flood-fill commit

  • The resize path now drives the frame synchronously in the same task as the canvas.width/height write: Firefox's zero-copy WebGPU present stalls the GPU process when a present straddles a swapchain reconfigure. requestFrame's body is extracted to runFrame, shared with the new renderNow (which cancels any pending rAF so _framePending can't be left set).
  • needs_more now surfaces compositor.needs_present(). A Lost/Outdated acquire reconfigures and returns without presenting; without this the loop stopped and the reconfigured surface never got a real frame.
  • Flood fill committed its undo region against doc.canvas_rect() while the lazy save snapshotted the layer extent. After an upward canvas resize the canvas rect reaches past the layer — a debug_assert failure, or an uninitialized-scratch read in release. It now commits the layer extent.

Use-after-free on tab close

Closing a tab freed the WASM handle but left the reference live, so an already-queued rAF called render on it (Attempt to use a moved value). DarklyInstance.dispose() stops the tool session and stream sources, frees the handle, then nulls engine last so the render loop's existing guard short-circuits.

Text tool

Text objects were created by an $effect in TextProperties.svelte, so creation depended on the properties panel being mounted. The tool now creates the object itself on pointer-up via createTextFromPending (with a reentrancy guard), and the placement handoff is gone — PropertiesPanel loses its pending-placement special case. Selecting the text tool fires warmVectorRenderer, compiling Vello's pipelines (a >1s one-time cost) during the gap before the first click instead of stalling the frame that would show the new box.

Hotkey label rendering

Menus, the command palette and the menu bar formatted the raw hotkeys.<id> config value, which may hold several bindings joined with | — rendering garbage for any multi-binding action. hotkeyLabel(actionId) is now the single entry point for displaying an action's shortcut, and effectiveHotkeys/effectiveHotkey move to the config store next to it.

Misc

  • Brush builder: math nodes (add/subtract/multiply/divide) get an "Extended range" toggle unlocking their sliders to 0–1000 for large gains. Editor-only — the engine never enforced slider bounds; a value already outside the declared range also widens automatically so loaded gains aren't clamped on first touch.
  • README copy.

Tests

New: modifier_removal.rs, paste_mask.rs, flood_fill_resize.rs, noise_field_nonrepeating.rs, dab_variation_scatter.rs, and frontend layerTree.test.ts, reselection.test.ts, delete_layer.test.ts, save_flow.test.ts, resize_atomic.test.ts, close_use_after_free.test.ts, hotkey_label.test.ts, text_tool_create.test.ts.

Extended: canvas_resize.rs (dropped-present reschedule), watercolor.rs (buildup across flushes, buildup on transparent canvas, wet-neighbour bleed), document/mod.rs unit tests (filter detach/reattach/remove/move), wgsl.rs and settable_source.rs for the sampling-frame changes.

@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

@TheTechromancer
TheTechromancer merged commit 0d8ccd7 into dev Aug 11, 2026
7 checks passed
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