Compare commits

..

34 Commits

Author SHA1 Message Date
Skyler Lehmkuhl 8223593649 CI: activate MSVC dev environment for the Windows build
The cmake crate building nam-ffi/NeuralAudio panicked with "couldn't determine
visual studio generator" because the Windows job never ran vcvars, so
cmake-rs had no VisualStudioVersion to detect the VS generator from.

Add an ilammy/msvc-dev-cmd step (x64) after the Windows dependency install; it
runs vcvars and exports the env to later steps so the C++ build can configure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:49:08 -04:00
Skyler Lehmkuhl 83057b754a Clean up build warnings
Resolve all compiler warnings across daw-backend, lightningbeam-core, and
lightningbeam-editor:

- Delete dead code: the superseded CPU raster tools in raster_tool.rs
  (EffectBrush/Smudge/Gradient/Transform/Warp/Liquify/Selection — replaced by
  the GPU path), plus orphaned helpers and never-read struct fields.
- Mechanical fixes: drop unused imports/variables/mut, underscore unused params,
  `drop(&x)` -> `let _ = x`, deprecated egui::Rounding -> CornerRadius, snake_case
  rename, elided-lifetime Cow<'_, [u8]>.
- Keep the WIP CSS theming system (theme.rs/theme_render.rs) under
  #[allow(dead_code)] rather than deleting it.

Editor checks warning-free; 293 core tests pass.
2026-06-21 16:48:59 -04:00
Skyler Lehmkuhl 3c3a482e2e Bump version to 1.0.5-alpha 2026-06-21 16:14:44 -04:00
Skyler Lehmkuhl 5fd04b5dd5 Lock editing on tween in-between frames
Block geometry/clip edits on frames that fall strictly inside a tween span,
where an edit would silently mutate the bracketing keyframe instead:

- Shape tweens: gate vector editing (vertices, curves, DCEL hits) and all
  geometry tools in stage.rs behind VectorLayer::is_tween_inbetween.
- Motion tweens: block selecting/dragging/transforming clip instances whose
  transform is mid-tween, via AnimationData::is_object_tweened_at.

Also: inserting a keyframe mid-tween now captures the interpolated geometry
shown at that frame (not the left keyframe's) and inherits the shape tween,
so the new keyframe continues morphing toward the right keyframe.
2026-06-21 16:01:47 -04:00
Skyler Lehmkuhl a1acecf396 Implement shape tweens (same-topology lerp)
`tween_after == Shape` was stored on keyframes but never read. Now the
render path morphs geometry across a shape-tween span:

- VectorGraph::interpolated(other, t): same-topology lerp of vertex
  positions, edge curves, stroke widths and stroke/fill colours. Returns
  None when topology differs (counts, deleted flags, edge endpoints, fill
  boundaries), so the caller holds the source keyframe.
- VectorLayer::tweened_graph_at(time): returns an owned morphed graph for
  a shape-tween span whose two keyframes share topology, else borrows the
  held keyframe. Editing still uses graph_at_time (the held keyframe).
- Renderer (Vello + CPU paths) renders via tweened_graph_at.
- SetTweenAction + wired the previously-stubbed "Add Shape Tween" menu.

The typical workflow — keyframe, duplicate it (same topology), move
vertices, Add Shape Tween — now morphs between the two. Non-matching
topology falls back to a hold.
2026-06-21 15:46:53 -04:00
Skyler Lehmkuhl 1dd5de4617 Render clip instances on top of a layer's loose shapes
Within a vector layer, groups and movie clips (clip instances) were drawn
before the layer's own VectorGraph, so they appeared underneath the loose
shapes. Draw the loose shapes first and the clip instances on top, in both
the Vello and CPU render paths.
2026-06-21 15:23:54 -04:00
Skyler Lehmkuhl 0d1f62ccce Fix motion-tween first-keyframe: anchor the clip's start
Creating the first transform keyframe for a clip instance at frame N left
the curve with a single keyframe, which Hold-extrapolates backward — so
moving it at frame N also moved it on every earlier frame (frame 1).

When SetKeyframeAction creates a brand-new curve for a clip instance and
the clip already existed before `time`, also anchor a keyframe at the
clip's start (its group visibility start, or timeline_start for movie
clips) with the original value. Earlier frames now hold the original
position and the move produces a proper tween from start to N.

Also capture the clip instance's actual on-stage value when keying (its
base transform), instead of a generic 0/identity default, so a new
keyframe doesn't snap the clip to the origin.

Adds VectorLayer::group_visibility_start and tests covering the anchor and
the no-double-anchor case (keying at the clip's own start).
2026-06-21 15:18:15 -04:00
Skyler Lehmkuhl 87bcffd427 Collapse boundary spikes in region cut; embed dump regression fixtures
A dense self-intersecting freehand lasso leaves clusters of near-coincident
duplicate sub-pixel edges (split products the coincident-edge dedupe can't
reach). The planar face trace bounces back and forth across them, producing
a degenerate "spike" boundary (an edge used twice). Add
`collapse_boundary_spikes`, run on each traced face before it becomes a
fill: it removes consecutive out-and-back entries (where the boundary
returns to where it started) until the loop is simple.

Embed the captured region-select dumps as committed fixtures under
tests/region_dumps/ (replayed by `dumped_region_selects_are_valid`) so the
regression survives /tmp being cleared. dump3 is the boundary-spike repro;
it fails this test without the collapse and passes with it. Loosen the
boundary-connectivity test tolerance to 1e-2 (above sub-micropixel float
drift, far below any real gap).
2026-06-21 15:02:30 -04:00
Skyler Lehmkuhl 39978e59b3 Unify selection systems and make region/lasso cut robust
Collapse the two parallel selection systems into one. The RegionSelect
tool (rect + lasso) now cuts the geometry along the region outline and
selects the resulting sub-pieces into the standard `Selection` ID-sets,
exactly like every other tool. The vestigial floating `RegionSelection`
(drag never wired; commit/delete/copy were stubbed) and all its plumbing
are removed, so Group, Convert-to-Movie-Clip, Delete, and Properties all
operate uniformly from lasso, rect, marquee, and click selections.

Region cutting is reworked onto a robust planar arrangement:

- Replace fragile incremental "split a fill by one cut edge" logic with
  planar face re-tracing (`retrace_fills_after_cut` + `trace_faces`),
  which correctly handles arbitrary holed/concave fills.
- `extract_subgraph` no longer frees vertices still referenced by kept
  boundary edges (fixed Group leaving freed-but-referenced vertices that
  a later alloc reused and corrupted).
- `split_fill_by_*` direction fix (was producing disconnected boundaries
  rendered as stray diagonals).
- `fill_interior_point` (area-centroid + inward-step fallback) for
  reliable inside/outside classification of non-convex pieces.
- Coincident-edge dedupe + degenerate-fill removal (edge-adjacent shapes
  no longer make zero-area sliver fills).
- Dangling-edge pruning, near-coincident endpoint welding, induced-
  subgraph expansion, and tracking of `split_edge` sub-edges, so
  self-intersecting freehand lassos cut correctly.

Region-select capture is available behind LIGHTNINGBEAM_DUMP_REGION=1 for
turning a misbehaving cut into a deterministic test. Extensive regression
tests added in vector_graph/tests/region_cut_select.rs.
2026-06-21 14:47:11 -04:00
Skyler Lehmkuhl 0d253d9629 Motion tweens: implement Group + Convert to Movie Clip for DCEL geometry
Both actions were DCEL-stubs (no-ops). They now extract the selected geometry from a
vector layer's active keyframe into a new VectorClip (group vs movie clip) and place a
ClipInstance in its place (identity transform → renders where the geometry was), which
the existing transform-animation system can motion-tween.

- Shared `clip_from_geometry::extract_geometry_to_clip` (+ undo) does the work; the
  actions are thin wrappers. Undo snapshots the source graph + removes the clip/instance.
- `extract_subgraph` now DERIVES shared-fill boundary edges internally (an inside edge
  still used by a non-extracted fill must be duplicated, not moved) and unions them with
  the caller's `explicit_boundary` — so a plain geometry selection needs no boundary
  analysis (the selection already includes fill boundary edges via `select_fill`). The
  region-select caller keeps passing its lasso boundary.
- Handlers: Group / Convert to Movie Clip on a geometry selection now build these actions
  from `selected_fills`/`selected_edges`.

Next: shape tweens (same-topology lerp).
2026-06-21 09:12:46 -04:00
Skyler Lehmkuhl 0914818808 docs: export now ~1.74x realtime (4x win); GPU-bound on composite, CPU wins won't help 2026-06-21 09:12:30 -04:00
Skyler Lehmkuhl b292e0e6e6 docs: export-speed audit findings + #1/#2a done, remaining wins noted 2026-06-21 01:35:41 -04:00
Skyler Lehmkuhl 4e9cacb789 Export speed #2: cache the RGBA→YUV swscale context across frames
CpuYuvConverter::convert rebuilt the swscale context + both ffmpeg frames on every
call (per output frame), despite the converter persisting for the whole export. Now
the scaler + reusable source/dest frames are built once in new() and reused each
convert(). (convert is &mut self; the caller already held it mutably.)
2026-06-21 01:34:28 -04:00
Skyler Lehmkuhl 0a0d7cd0a9 Export speed #1: reuse the Vello renderer + ImageCache across frames
The export pump built a fresh `vello::Renderer` (full wgpu pipeline init, ~tens of ms)
AND an empty `ImageCache` on every egui repaint — i.e. per output frame. That was the
dominant per-frame cost (and the code comment already flagged it). Now the renderer +
image cache are built once on the first export pump, reused for every frame, and
dropped when the export finishes.

Also fixes a latent bug: the throwaway export ImageCache had no container path, so with
Phase 4 Tier 1 (lazy image bytes) images stored only in the container wouldn't render
in exports. The persistent cache now gets the container path.

Audit found the original "per-frame seek" theory was wrong — export decodes the source
sequentially; readback is already async/triple-buffered. Remaining wins: cache swscale
contexts (rebuilt per frame), GPU YUV conversion, decouple from the repaint cadence.
2026-06-21 01:29:54 -04:00
Skyler Lehmkuhl 5f3cb41c18 docs: mark Phase 3/3.5/4 done in plan checklist; flag stale JS-era TODO entries 2026-06-21 01:22:17 -04:00
Skyler Lehmkuhl 6a788a432e docs: Phase 4 done (image asset paging: Tier 1/2 + prefetch) 2026-06-21 01:12:57 -04:00
Skyler Lehmkuhl 0883c77e2b Phase 4: prefetch upcoming images during playback
`assets_needed_at(document, time)` (core) enumerates the image asset ids referenced by
the visible vector layers' active keyframes at a time (top-level + group children).
During playback the stage decodes the images needed ~0.5s ahead into the bounded
ImageCache, so a keyframe that swaps image fills doesn't hitch when the playhead
reaches it. Gated on is_playing; nested clip-instance recursion + background decode
are refinements.

Completes Phase 4 (image asset paging: Tier 2 decoded-cache LRU, Tier 1 lazy bytes,
playback prefetch).
2026-06-21 01:12:37 -04:00
Skyler Lehmkuhl 3d0a334014 Phase 4 Tier 1: lazy image-asset bytes paged from the container
Project load no longer eager-reads all image bytes — `ImageAsset.data` stays empty
and the renderer's ImageCache pages compressed bytes from the `.beam` on a decode
miss (read_packed_media_readonly by asset id), decoding into the byte-bounded Tier-2
cache. Result: instant load, and compressed bytes don't accumulate on the heap.

- ImageCache: `container_path` + `resolve_bytes` (asset.data if resident — fresh
  import or old base64 project — else page from the container); decoders take `&[u8]`
  and use the decoded dimensions.
- Container path threaded App.current_file_path → SharedPaneState → VelloRenderContext,
  set on the cache each prepare.
- load_beam_sqlite drops the 3.5b eager read.

(Refinement: a persistent read connection instead of open-per-miss.)
2026-06-21 01:12:05 -04:00
Skyler Lehmkuhl d05cbfcde5 docs: Phase 4 Tier 2 (decoded ImageCache LRU) done; note Tier 1 + prefetch 2026-06-21 00:55:26 -04:00
Skyler Lehmkuhl fcc8102a9d Phase 4a: bound the decoded ImageCache (usage-LRU byte budget)
The decoded-image cache (peniko ImageBrush + tiny-skia Pixmap, ~w·h·4 each) was
unbounded — the main asset-memory cost. Now capped at 256 MB with usage-LRU eviction:
every get_or_decode bumps the asset's recency, and inserts past the budget evict the
least-recently-used (a miss re-decodes from the resident asset.data). Images actually
rendered each frame stay resident; unused ones age out under pressure. invalidate/clear
keep the lru + byte accounting in sync.

Next (4b): lazy-load asset.data from the container instead of eager on project open.
2026-06-21 00:52:56 -04:00
Skyler Lehmkuhl 7a150fb5c5 docs: mark Phase 3.5 (image textures in vector scenes) done 2026-06-21 00:46:22 -04:00
Skyler Lehmkuhl 2f3b0d7790 Phase 3.5b: persist image assets in the .beam container
Image asset bytes are now stored as MediaKind::ImageAsset rows in the SQLite
container (chunked, kept-in-place on re-save) instead of base64-embedded in the
project JSON — the pageable storage Phase 4 needs.

- ImageAsset.data is `#[serde(default, skip_serializing)]`: never written to JSON,
  but still deserialized for old projects (base64) which then migrate to the
  container on the next save.
- save_beam writes each asset's bytes (keyed by asset id; ext from the source path),
  keeping an existing row when bytes aren't resident; live_media covers them so orphan
  cleanup doesn't drop them.
- load_beam_sqlite eager-reads the bytes back into `data` (Phase 4 makes this lazy +
  LRU). Old base64 projects keep their JSON-deserialized data (no container row).
2026-06-21 00:44:18 -04:00
Skyler Lehmkuhl aad2d5c515 Onion/image: make Image a fill-type tab (None | Solid | Gradient | Image)
Image fill is now a tab in the Fill type row rather than a separate dropdown. When
Image is active, an asset-picker combo selects which image; switching to None/Solid/
Gradient clears the image fill (it otherwise overrides them). The Image tab only
appears when there are imported image assets.
2026-06-21 00:37:33 -04:00
Skyler Lehmkuhl 6fc3a131a6 Phase 3.5a: SetImageFillAction + Info-Panel image-fill picker
- SetImageFillAction (core): set/clear `image_fill` on the selected VectorGraph fills,
  with per-fill undo (mirrors SetFillPaintAction). Image takes render priority; clearing
  reveals the colour/gradient underneath.
- Info Panel Shape section: an "Image:" combo listing the document's image assets (+ None)
  for the selected fill(s), showing the current assignment. Assign/clear pushes the action.

This lets an existing shape be given (or cleared of) an image fill, complementing the
import/drop placement. Next: 3.5b — persist image assets in the .beam container.
2026-06-21 00:30:01 -04:00
Skyler Lehmkuhl 659bc5fb02 Fix image-fill mapping: anchor to the fill's bounding box
The renderer painted the image brush at its native origin (0,0) with no brush
transform, so an image-filled rect drawn anywhere but the world origin only showed
the overlapping corner of the image. Both render paths now map the image's native
pixel space onto the fill's bounding box (Vello brush_transform; tiny-skia Pattern
transform) — 1:1 for an image-sized rectangle, stretch-to-bbox for arbitrary shapes.
2026-06-21 00:23:07 -04:00
Skyler Lehmkuhl 6c9fcb1921 Phase 3.5a: place imported images on the canvas (image-filled rect)
Replaces the DCEL "not yet supported" stubs so importing/dropping an image actually
puts it in the vector scene.

- AddShapeAction gains an `image_fill` + `AddShapeAction::image_rect(...)` constructor:
  a borderless rectangle (invisible edges) whose enclosed region is paint-bucketed and
  tagged with an image asset id. The renderer already prioritises `image_fill`.
- Direct import (auto_place_asset): an imported image is placed centered on the canvas
  at native size on a vector layer.
- Drag from the asset library onto the stage: image-filled rect at the drop point
  (centered), native size, using the asset's dimensions.

Next: SetImageFillAction + an Info-Panel image-fill picker for existing shapes; then
3.5b container persistence.
2026-06-21 00:22:17 -04:00
Skyler Lehmkuhl aea26f6192 docs: add Phase 3.5 (image textures in vector scenes; fixes DCEL-broken image import) 2026-06-21 00:02:47 -04:00
Skyler Lehmkuhl c936078850 docs: add 'fix animation tweens' (low pri); mark raster-keyframe-UI bugs done 2026-06-20 23:54:46 -04:00
Skyler Lehmkuhl 7445ee919f Onion skin settings: move from floating window to the Info Panel
The standalone egui window didn't fit the UI. Replaced it with a collapsible
"Onion Skin" section at the bottom of the Info Panel (Enabled checkbox + frames
before/after + opacity), available regardless of selection. SharedPaneState carries
a mutable `onion_skin` ref for the controls (distinct from the gated `onion` copy
used by rendering).
2026-06-20 23:49:38 -04:00
Skyler Lehmkuhl 10b4aa481e Onion skinning: vector-layer ghosts (tinted)
- Core compositor gains an optional screen-blend tint per CompositorLayer
  ([0,0,0,0] default = no-op, so existing compositing is unaffected);
  CompositorLayer::with_tint sets it — giving the Vello/vector path a tint hook.
- For the active VECTOR layer with onion on, build ghost scenes at each neighbouring
  keyframe's time via render_layer_isolated (reusing the prepare's image cache), then
  render each scene → sRGB → linear → composite with warm/cool tint + opacity falloff,
  behind the current frame. Off during playback; active layer only.

Completes onion skinning for all layer types (raster + vector).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 23:49:33 -04:00
Skyler Lehmkuhl c2c7aefad1 Onion skin: settings window (frames before/after + opacity)
A small 'Onion Skin' egui window (shown while onion skinning is enabled) with
DragValues for frames_before/after (0..=5) and an opacity slider, rendered next to
the F3 debug overlay. Lets the user tune the configurable settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 23:25:43 -04:00
Skyler Lehmkuhl 78109cda93 Fix onion ghosts: show on blank cels + fault in on seek
- Removed the early `continue` that skipped a layer with no resident content — it
  also skipped the active raster layer's onion ghosts when the current cel was blank
  (the exact case you trace a new cel from neighbours). Each render arm already
  guards its own empty case, so the skip was redundant.
- Ghost texture resolution now falls through cache → resident raw_pixels (upload) →
  in-memory proxy → request fault-in, so neighbours that were paged out (e.g. after a
  seek, or saved-this-session with no decoded proxy) page in and ghost in, instead of
  silently rendering nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 23:18:24 -04:00
Skyler Lehmkuhl 4dc937f9c0 Onion tint: screen-blend instead of multiply (so outlines tint too)
Multiplicative tint left blacks black, so outlines (the main thing to ghost in line
art) never picked up the warm/cool color. Switched the canvas-blit tint to a screen
blend (out = base + tint - base*tint): black → tint color, white unchanged, and a
clean no-op at tint=(0,0,0) for all normal blits. Reverted the default .w slots to 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 23:06:05 -04:00
Skyler Lehmkuhl 53cae1bfe5 Onion skinning (raster): toggle + tinted ghosts of the active layer
- OnionSkinSettings (editor view state): enabled, frames_before/after (default 2/2),
  opacity (0.35), warm past / cool future tints, linear falloff. Threaded App →
  SharedPaneState (gated off during playback) → VelloRenderContext.
- Toggle: View ▸ Onion Skinning + the `O` shortcut (AppAction::ToggleOnionSkin).
- Tint: packed an RGB multiply into the canvas-blit matrix uniform's unused .w slots
  (1,1,1 = no tint for all normal blits); BlitTransform::with_tint. Shader multiplies.
- Render: for the ACTIVE raster layer only, blit the N neighbouring keyframes (full
  texture if resident, else the low-res proxy — uploaded on demand) tinted + faint,
  composited behind the current frame. Off during playback.

Next: a settings panel (frame count/opacity) and vector-layer ghosts.
2026-06-20 23:05:57 -04:00
50 changed files with 3640 additions and 1484 deletions

View File

@ -90,6 +90,15 @@ jobs:
shell: pwsh shell: pwsh
run: choco install cmake llvm --installargs 'ADD_CMAKE_TO_PATH=System' -y run: choco install cmake llvm --installargs 'ADD_CMAKE_TO_PATH=System' -y
# Activate the MSVC developer environment (runs vcvars) so the cmake crate
# building nam-ffi/NeuralAudio can determine the Visual Studio generator.
# Without this, cmake-rs panics with "couldn't determine visual studio generator".
- name: Set up MSVC developer environment (Windows)
if: matrix.platform == 'windows-latest'
uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
# ── Common build steps ── # ── Common build steps ──
- name: Extract version - name: Extract version
id: version id: version

View File

@ -1,3 +1,29 @@
# 1.0.5-alpha:
Changes:
- Add shape tweens (morph vector geometry between keyframes)
- Add motion tweens for groups and movie clips
- Group geometry and Convert to Movie Clip now work on DCEL vector shapes
- Region/lasso select now cuts the shape and feeds the normal selection, so Group, Convert, Delete and Properties all work from a lasso (hold shift to add to the selection)
- Clip instances now draw on top of a layer's loose shapes
- Add onion skinning for raster and vector layers, with tinted ghosts and settings in the Info Panel
- Images can now fill vector shapes (None / Solid / Gradient / Image fill types)
- Imported images can be placed on the canvas
- Add a raster keyframe timeline UI with explicit keyframe creation; click a keyframe diamond to snap the playhead to it
- Stream audio, video and images to and from the project file instead of holding them in memory, supporting arbitrarily long media
- Persist (and resume) waveforms and video thumbnails in the project file
- Use low-res proxies for fast cold scrubbing of raster frames
- Bound memory use for raster pixels, GPU textures, video frames and decoded images on large projects
- Video export is roughly 4x faster
- Downmix surround video audio to stereo
Bugfixes:
- Fix video export resolution scaling and a post-export UI hang
- Fix gamma handling and improve brush canvas performance
- Fix a save crash on projects with zero or sparse audio
- Fix raster strokes vanishing when committed
- Fix image fill mapping (anchor to the fill's bounding box)
- Fix video thumbnail strip bugs
# 1.0.4-alpha: # 1.0.4-alpha:
Changes: Changes:
- Beats are now the canonical time representation (replacing seconds) - Beats are now the canonical time representation (replacing seconds)

View File

@ -22,16 +22,13 @@ is now part of this plan.
timestamp, and the cache keys on that — so a tile picks up a new texture when a closer thumbnail timestamp, and the cache keys on that — so a tile picks up a new texture when a closer thumbnail
finishes generating. Existing `retain`-by-visible-clip cleanup keeps it bounded. *(Needs in-app check.)* finishes generating. Existing `retain`-by-visible-clip cleanup keeps it bounded. *(Needs in-app check.)*
## Deferred raster-keyframe-UI bugs (pre-existing; found during Phase 3 testing) ## Raster-keyframe-UI bugs — **[DONE]** (built the raster keyframe timeline UI, 2026-06-20)
Both stem from the **timeline having no model for raster keyframes** — it renders *clip Both resolved by the raster-keyframe-timeline-UI work: timeline now draws a diamond per
instances* (`layer_clips`/`collect_clip_instances` return `&[ClipInstance]`), but raster layers use `RasterKeyframe` (mirrors vector), `K`/New Keyframe inserts a blank cel via `AddRasterKeyframeAction`
`keyframes: Vec<RasterKeyframe>`; every raster arm in timeline.rs is a stub (`Raster => &[]`/`{}`). (canvas refreshes), paint tools edit the active keyframe instead of lazily creating, diamonds are
- **(a) `Timeline > New Keyframe` doesn't refresh the canvas** until you draw. The menu action click-to-seek (pointing-hand cursor), playback prefetches frames, and onion skinning (raster+vector,
creates a blank raster keyframe but doesn't trigger a canvas repaint / GPU-cache invalidation for tinted, Info-Panel settings) is in. (a) canvas-refresh-on-new-keyframe and (b) keyframes-on-timeline
the new (different) `kf.id`, so the stale previous-frame texture stays until a stroke dirties it. are both fixed.
- **(b) Raster keyframes never render on the timeline** — no code walks `RasterLayer::keyframes` to
draw markers. Needs a raster-keyframe strip (display + click-to-navigate + insert).
Confirmed not paging regressions (same before 3a-1). A focused "raster keyframe timeline UI" task.
## Noted enhancements (later, after the phases) ## Noted enhancements (later, after the phases)
- [x] **Surround → stereo downmix (DONE).** Done uniformly in `render_from_file` (`pool.rs`) so it - [x] **Surround → stereo downmix (DONE).** Done uniformly in `render_from_file` (`pool.rs`) so it
@ -44,10 +41,32 @@ Confirmed not paging regressions (same before 3a-1). A focused "raster keyframe
paths (only when `dst==2 && src>2`; unknown layouts fall back to front L/R). Compiles clean. paths (only when `dst==2 && src>2`; unknown layouts fall back to front L/R). Compiles clean.
*(Needs in-app check: a 5.1 file now has centre/dialog present and isn't thin; not distorted/clipping.)* *(Needs in-app check: a 5.1 file now has centre/dialog present and isn't thin; not distorted/clipping.)*
Native multichannel support remains a separate, larger project. Native multichannel support remains a separate, larger project.
- **Export speed:** a 1:14 1080p MP4 took ~9:06 to export (~7.4x slower than realtime). The video - **Export speed (audited 2026-06-21):** a 1:14 1080p MP4 took ~9:06 (~7.4x realtime, ~135 ms/frame).
export pipeline re-seeks + decodes per output frame (see `[Video Seek]`/`[Video Timing]` logs) and Audit **refuted** the per-frame-seek theory — export decodes the source *sequentially*
does CPU YUV conversion; likely wins from sequential decode (avoid per-frame seeks), reusing the (`video.rs` `need_seek` is false once advancing forward), and readback is already async +
decode cache, and/or GPU-side color conversion. Profile before optimizing. triple-buffered. Real hotspots:
- **[DONE] #1 — per-frame renderer rebuild.** The export pump built a fresh `vello::Renderer`
(full wgpu pipeline init) + empty `ImageCache` *every egui repaint* (`main.rs` ~6218). Now built
once per export and reused; also fixed lazy-image export (the throwaway cache had no container
path). **Expected the dominant win.**
- **[DONE] #2a — encode swscale rebuilt per frame.** `CpuYuvConverter::convert` now caches the
RGBA→YUV420p `scaling::Context` + frames in `new()` instead of per call.
- **[TODO] #2b — decode swscale + stride-repack** per frame in `video.rs:294-320` (shared with
scrubbing; cache the YUV→RGBA scaler on the decoder). Small win, modest risk.
- **Result of #1+#2a (measured):** ~7.4x → **~1.74x realtime** (130.7 s for 4488 frames @ 60 fps;
34 fps). Per-stage avg: Render(CPU build) 15 ms, **Readback(GPU latency) 42 ms**, Extract 1.3 ms,
Convert 5.7 ms.
- **Now GPU-bound.** Per ~87 ms poll cycle the CPU does ~66 ms (3× build 45 + convert 17 + extract 4)
but the GPU does ~87 ms (3 × ~29 ms composite) → GPU saturated at ~29 ms/frame; "Readback 42 ms" is
queue latency, not transfer (8 MB is sub-ms).
- **[SKIP] #3 GPU YUV / #5 pacing** — both only trim the CPU side, which is already *under* the GPU.
Won't move a GPU-bound throughput.
- **[TODO, big] Reduce the GPU composite (~29 ms/frame).** The per-layer HDR pipeline (Vello render →
linear → composite, ×layers) is the wall, shared with live rendering. Options: batch composite
passes; a fast-path skipping HDR compositing for simple single-layer/no-blend docs; cache unchanged
layers' scenes (CPU-side, only helps if it later becomes CPU-bound). Render-architecture project.
- Non-issues: per-frame seek, blocking readback, audio. (`video.rs:237` container-reopen-on-seek is
a latent cost but doesn't fire on forward export.)
- **AAC export NaN guard (done):** `convert_chunk_to_planar_f32` now sanitizes non-finite samples - **AAC export NaN guard (done):** `convert_chunk_to_planar_f32` now sanitizes non-finite samples
(NaN/Inf → 0, finite clamped to [-1,1]) like the integer paths, with a one-time warning — a stray (NaN/Inf → 0, finite clamped to [-1,1]) like the integer paths, with a one-time warning — a stray
non-finite render sample no longer fails the whole export. Upstream NaN source (effect/automation/ non-finite render sample no longer fails the whole export. Upstream NaN source (effect/automation/
@ -395,6 +414,56 @@ dirty never evicted. 4. 3c GPU bound. 5. 3d undo diffs reproduce pre-stroke buff
--- ---
## Phase 3.5 — Image textures in vector scenes **[DONE 2026-06-21]** *(prereq for Phase 4; fixed DCEL-broken image import)*
**Done:** 3.5a — import/drop places an image as a borderless image-filled rectangle
(`AddShapeAction::image_rect`), centered (direct import) or at the drop point (library drag);
renderer now maps the image brush onto the fill's bounding box (was anchored at world origin →
only a corner showed); `SetImageFillAction` + an **Image** fill-type tab (None|Solid|Gradient|Image)
with an asset picker in the Info Panel. 3.5b — image bytes persist as `MediaKind::ImageAsset` rows in
the `.beam` (kept-in-place; `ImageAsset.data` is `skip_serializing` + container-backed; old base64
projects migrate on re-save); eager-read on load. *(ImageCache still unbounded — Phase 4 adds the
usage-based LRU/lazy paging.)*
### (original plan below)
## Phase 3.5 — Image textures in vector scenes *(prereq for testing Phase 4; fixes DCEL-broken image import)*
**Why:** Phase 4 pages *image assets*, but there's currently no way to get an image asset into a
vector scene — so nothing to page. This also repairs image import, half-broken since the DCEL switch.
**Current state (audited 2026-06-21):**
- *Works:* `import_image` (`main.rs`) decodes dims + creates an `ImageAsset` (raw bytes embedded in
`Document::image_assets`, serialized as **base64 in project JSON**). The renderer's image-fill paths
are **complete** — GPU/Vello (`renderer.rs:~1160`, `ImageBrush` via `ImageCache.get_or_decode`) and
CPU/tiny-skia (`renderer.rs:~1486`). `Fill::image_fill` (`vector_graph/mod.rs:110`) and
`Face::image_fill` (`dcel2/mod.rs:117`) fields exist and render when set.
- *Broken/missing (the workflow):*
1. **Drop image → canvas is stubbed:** `stage.rs:~11782` and `main.rs:~4924` both just print
"Image drag to stage not yet supported with DCEL backend". Nothing is added to the scene.
2. **No way to assign an image fill:** no `SetImageFillAction` (only `SetFillPaintAction` for
color/gradient); no Info-Panel picker. `Fill`/`Face.image_fill` are never populated.
3. **DCEL faces never get `image_fill`** (`dcel2/import.rs:275` always `None`; topology copies from
parent which is also `None`).
4. **Not in the container:** `MediaKind::ImageAsset` exists but is **dead** — image bytes live only
as base64 in project JSON. Not chunked, not pageable (so Phase 4 can't page them).
**Tasks:**
- **3.5a — Place + assign.** Replace the two drop stubs: dropping an image onto a vector layer creates
a rectangle face sized to the image at the drop point with `image_fill = asset_id`. Add
`SetImageFillAction` (set/clear an image fill on the selected face/shape; mirrors `SetFillPaintAction`)
+ an Info-Panel image-asset picker for the selected shape's fill. Populate `Face.image_fill` in DCEL
(and keep it through topology ops — already copied from parent).
- **3.5b — Persist in the container.** Write image assets as `MediaKind::ImageAsset` rows in the `.beam`
SQLite (like raster/audio: write on save kept-in-place on re-save; read on load), keyed by asset id;
drop the base64-in-JSON embedding (or keep a tiny ref). This is the storage Phase 4 pages from.
- **3.5c — Lazy decode hook.** Image bytes load from the container into `ImageCache` on first render
(decode → `ImageBrush`/`Pixmap`). Leave `ImageCache` **unbounded for now**; Phase 4 adds the
usage-based LRU/eviction (this phase just makes there *be* real, container-backed image assets to page).
- **Tests:** import→drop→render round-trip; save/reload preserves the image fill + reads bytes from the
container (not JSON); CPU and GPU render paths both show the image.
---
## Phase 4 — Asset paging by usage + LRU *(vector's real cost is assets, not geometry)* ## Phase 4 — Asset paging by usage + LRU *(vector's real cost is assets, not geometry)*
Vector geometry is compact flat POD (tens of KB/frame, no cached tessellation/DCEL) — leave Vector geometry is compact flat POD (tens of KB/frame, no cached tessellation/DCEL) — leave
@ -414,6 +483,22 @@ it resident. The heavy, evictable thing is the **image assets** referenced by fi
from blob row or external `path`); Tier 2 = decoded pixels (`ImageCache` + GPU textures — from blob row or external `path`); Tier 2 = decoded pixels (`ImageCache` + GPU textures —
the heavy one). the heavy one).
**Progress (2026-06-21):**
- **[DONE] Tier 2 — bound the decoded `ImageCache`.** 256 MB **usage-LRU**: every
`get_or_decode`/`_cpu` bumps the asset's recency; inserts past budget evict the least-recently-used
(a miss re-decodes from `asset.data`). Achieves usage-based eviction via render-access recency
(simpler than the frame→asset enumeration below; that enumeration is only needed for *prefetch*).
- **[DONE] Tier 1 — lazy compressed bytes.** `ImageCache` holds the container path (threaded
App.current_file_path → SharedPaneState → VelloRenderContext) and pages bytes on a decode miss via
`read_packed_media_readonly`; `load_beam_sqlite` no longer eager-reads → instant load, compressed
bytes don't accumulate. `asset.data` is still used when resident (fresh import / old base64 project).
*(Refinement: persistent read connection vs open-per-miss.)*
- **[DONE] Prefetch.** `assets_needed_at(document, time)` enumerates image ids in the visible vector
layers' active keyframes; during playback the stage decodes the ~0.5s-ahead set into the cache.
*(Refinements: nested clip-instance recursion; background-thread decode.)*
**Phase 4 = DONE** (image asset paging by usage + LRU).
### 4a. Frame→asset enumeration (incl. nested clips — see note below) ### 4a. Frame→asset enumeration (incl. nested clips — see note below)
A function `assets_needed_at(time) -> HashSet<Uuid>`: walk each visible vector layer's active A function `assets_needed_at(time) -> HashSet<Uuid>`: walk each visible vector layer's active
`ShapeKeyframe`, collect `fill.image_fill` across its `VectorGraph.fills`, **recursing into `ShapeKeyframe`, collect `fill.image_fill` across its `VectorGraph.fills`, **recursing into
@ -753,13 +838,12 @@ viewports of fine tiles; persist pyramid in `.beam`.
packet per stream (O(1) memory vs O(duration)). Same tie-break (`v_us <= a_us`) and drain-on-EOF packet per stream (O(1) memory vs O(duration)). Same tie-break (`v_us <= a_us`) and drain-on-EOF
behavior; output is byte-identical. Editor compiles clean. *(Not yet runtime-verified — needs an behavior; output is byte-identical. Editor compiles clean. *(Not yet runtime-verified — needs an
in-app export to confirm A/V sync.)* in-app export to confirm A/V sync.)*
- [ ] Phase 3a — lazy raster fault-in from blob store - [x] Phase 3a — lazy + async raster fault-in (`RasterStore` + background thread + image proxy)
- [ ] Phase 3b — raster residency window + eviction - [x] Phase 3b — raster residency LRU + eviction (dirty-flag data-loss safety)
- [ ] Phase 3c — bound raster GPU/CPU caches - [x] Phase 3c — bound raster GPU texture cache (recency LRU + F3 VRAM readout)
- [ ] Phase 3d — spill undo snapshots - [x] Phase 3d — raster undo dirty-rect diffs (+ fault-in-before-undo)
- [ ] Phase 4a — frame→asset enumeration (recursive) - [x] Phase 3.5 — image textures in vector scenes (fixed DCEL-broken image import; image-fill tab + picker; container-persisted)
- [ ] Phase 4b — usage bookkeeping + LRU residency - [x] Phase 4 — image asset paging: Tier 2 decoded-cache byte-LRU, Tier 1 lazy container bytes, playback prefetch
- [ ] Phase 4c — bound decoded image tier
- [x] Phase 5 — fixed the broken `#[cfg(test)]` unit tests; **`cargo test --lib` green again** - [x] Phase 5 — fixed the broken `#[cfg(test)]` unit tests; **`cargo test --lib` green again**
(daw-backend 17 passed, lightningbeam-core 264 passed). Wrapped stale raw-`f64` time literals (daw-backend 17 passed, lightningbeam-core 264 passed). Wrapped stale raw-`f64` time literals
in `Beats(...)` / passed `&TempoMap` to changed signatures (automation.rs, clip.rs, in `Beats(...)` / passed `&TempoMap` to changed signatures (automation.rs, clip.rs,

305
TODO.md Normal file
View File

@ -0,0 +1,305 @@
# Lightningbeam TODO
> ⚠️ **Stale entries:** Lightningbeam was rewritten from JavaScript to Rust. Any entry below
> that cites `src/*.js` / `main.js` / `animation.js` predates that migration — the *issue* may
> or may not still exist in the Rust codebase, but the file/line references are obsolete.
> **Re-verify against the current Rust code before acting** (this covers the "Animation System
> Refactoring" section and the JS-referencing "Known Issues" entries — node editor, default
> interpolation, etc.). Items with no `.js` references are current.
## Animation System Refactoring *(STALE — JS-era migration notes; superseded by the Rust DCEL/keyframe system)*
### Completed
- ✅ Implement AnimationData curve-based system (Keyframe, AnimationCurve, AnimationData classes)
- ✅ Add GraphicsObject.currentTime property
- ✅ Migrate shape rendering to use AnimationData curves (exists, zOrder)
- ✅ Binary search optimization for keyframe lookups
### In Progress
- Migrating from Frame-based to AnimationData curve-based system throughout codebase
### Pending Features
#### Animation Curve Enhancements
- [ ] Implement extrapolation modes (separate for start vs end):
- "hold" (default) - hold value at first/last keyframe
- "extend" - linearly extend the curve beyond keyframes
- "repeat" - repeat the animation
- "decay" - exponential decay to a target value
- [ ] Add position, scale, rotation animation curves for shapes
- [ ] Add shape morphing/tweening between keyframes
#### Keyframing Behavior
- [ ] Add user preference for keyframing behavior when editing objects:
- Auto-keyframe (current default): create/update keyframe at current time
- Edit previous (Flash-style): update most recent keyframe before current time
- Ephemeral (Blender-style): changes don't persist without manual keyframe
- Optional: Add modifier key (e.g. Shift) to toggle between modes
#### Shape Ordering
- [ ] Add "Bring Forward" menu option (swap zOrder with shape in front)
- [ ] Add "Send Backward" menu option (swap zOrder with shape behind)
- [ ] Add "Bring to Front" menu option (set zOrder to max + 1)
- [ ] Add "Send to Back" menu option (set zOrder to min - 1)
#### Code Cleanup
- [ ] Remove all remaining references to Frame-based system
- [ ] Remove legacy Frame class once migration is complete
- [ ] Clean up GraphicsObject.shapes[] array (shapes should only live in Layers)
## Known Issues / Platform Limitations
### Animation: Tweens are broken (Rust codebase) — LOW PRIORITY
- **Issue**: Animation tweening between keyframes (shape/vector interpolation, and the
`tween_after` behavior on keyframes) does not work correctly in the current Rust app.
Needs investigation + fix. Not urgent — revisit later.
- (Older JS-codebase animation entries below reference `src/*.js` and are stale.)
### Audio: Oscillator Timbre Drift (Phase Accumulation Error)
- **Issue**: Oscillators exhibit timbre changes over time due to floating-point phase accumulation errors
- **Affected Files**:
- `daw-backend/src/effects/synth.rs:117-120` (SimpleSynth)
- `daw-backend/src/audio/node_graph/nodes/oscillator.rs:167-170` (OscillatorNode)
- **Root Cause**: Current phase wrapping uses conditional subtraction (`if phase >= 1.0 { phase -= 1.0 }`), which accumulates f32 rounding errors over time, especially for long-playing notes
- **Current Code**:
```rust
self.phase += frequency / sample_rate;
if self.phase >= 1.0 {
self.phase -= 1.0;
}
```
- **Recommended Fix**: Replace with `.fract()` for numerically stable wraparound:
```rust
self.phase += frequency / sample_rate;
self.phase = self.phase.fract();
```
- **Impact**: Medium - affects audio quality for sustained notes, becomes noticeable after several seconds
- **Priority**: Medium - should be addressed before production use
### UI: Node Connections Render Behind VoiceAllocator Child Nodes
- **Issue**: Connection lines (SVG paths) inside expanded VoiceAllocator nodes render behind child nodes due to z-index stacking
- **Affected File**: `src/styles.css:1128`
- **Root Cause**: Child nodes have `z-index: 10` while connection SVG paths have default/lower z-index
- **Current Code**:
```css
.drawflow .drawflow-node.child-node {
opacity: 0.9;
border: 1px solid #5a5aaa !important;
box-shadow: 0 2px 8px rgba(90, 90, 170, 0.3);
z-index: 10;
}
```
- **Recommended Fix**: Either:
1. Remove `z-index: 10` from `.child-node` (simplest), or
2. Add higher z-index to connection SVG paths, or
3. Use CSS `isolation: isolate` on the VoiceAllocator contents area to create a new stacking context
- **Impact**: Low - visual issue only, connections still function but appear to go "behind" nodes
- **Priority**: Low - cosmetic issue that doesn't affect functionality
### UI: VoiceAllocator Child Nodes Don't Move with Parent
- **Issue**: When a VoiceAllocator node is moved, its child nodes remain in their original positions instead of moving with the parent
- **Affected File**: `src/main.js:6202-6207`
- **Root Cause**: The `nodeMoved` event handler only handles the case where a child node is moved (resizes parent), but doesn't handle when the VoiceAllocator itself is moved
- **Current Code**:
```javascript
editor.on("nodeMoved", (nodeId) => {
const node = editor.getNodeFromId(nodeId);
if (node && node.data.parentNodeId) {
resizeVoiceAllocatorToFit(node.data.parentNodeId);
}
});
```
- **Recommended Fix**: Add logic to detect when a VoiceAllocator is moved and update all child node positions:
```javascript
editor.on("nodeMoved", (nodeId) => {
const node = editor.getNodeFromId(nodeId);
// Case 1: A child node was moved - resize parent
if (node && node.data.parentNodeId) {
resizeVoiceAllocatorToFit(node.data.parentNodeId);
}
// Case 2: A VoiceAllocator was moved - move all children
if (node && node.data.nodeType === 'VoiceAllocator') {
// Calculate delta from previous position (need to track)
// Update all child node positions by the delta
// Call editor.updateConnectionNodes() for parent and all children
}
});
```
- **Impact**: High - child nodes become disconnected from parent visually
- **Priority**: High - breaks expected behavior of grouped nodes
### UI: VoiceAllocator Expansion Doesn't Update Connection Positions
- **Issue**: When expanding/collapsing a VoiceAllocator, connection endpoints don't update to match the new port positions
- **Affected File**: `src/main.js:6496-6555` (handleNodeDoubleClick function)
- **Root Cause**: The expand/collapse logic shows/hides child nodes and resizes the container, but never calls `editor.updateConnectionNodes()` to refresh connection positions
- **Current Code**: In `handleNodeDoubleClick()`, after expanding or collapsing:
```javascript
// Expand
expandedNodes.add(nodeId);
nodeElement.classList.add('expanded');
nodeElement.style.width = '600px';
nodeElement.style.height = '400px';
// ... shows child nodes ...
// Missing: editor.updateConnectionNodes(`node-${nodeId}`)
```
- **Recommended Fix**: Call `editor.updateConnectionNodes()` after resizing:
```javascript
// After expanding
expandedNodes.add(nodeId);
nodeElement.classList.add('expanded');
// ... resize and show children ...
// Update connection positions for VoiceAllocator and all children
editor.updateConnectionNodes(`node-${nodeId}`);
for (const [childId, parentId] of nodeParents.entries()) {
if (parentId === nodeId) {
editor.updateConnectionNodes(`node-${childId}`);
}
}
```
- **Impact**: Medium - connections appear in wrong positions until manually moved
- **Priority**: Medium - visual issue that affects usability
### UI: Node Editor Allows Editing Without MIDI Layer Selected
- **Issue**: The node editor pane allows adding/editing instrument nodes even when no MIDI layer is selected, and always uses hardcoded `trackId: 0`
- **Affected File**: `src/main.js:6045-6920` (nodeEditor function)
- **Root Cause**: The node editor never checks if `context.activeObject.activeLayer` exists or is a MIDI track, and all backend commands use hardcoded `trackId: 0`
- **Current Code**: All graph commands hardcode track 0:
```javascript
const commandArgs = parentNodeId
? {
trackId: 0, // HARDCODED!
voiceAllocatorId: editor.getNodeFromId(parentNodeId).data.backendId,
nodeType: nodeType,
x: x,
y: y
}
: {
trackId: 0, // HARDCODED!
nodeType: nodeType,
x: x,
y: y
};
```
- **Recommended Fix**:
1. Check if activeLayer is a MIDI track before allowing edits:
```javascript
function getSelectedMidiTrack() {
const activeLayer = context.activeObject?.activeLayer;
if (!activeLayer || activeLayer.type !== 'midi') {
return null;
}
return activeLayer;
}
```
2. Show placeholder when no MIDI track selected:
```javascript
function nodeEditor() {
const container = document.createElement("div");
const midiTrack = getSelectedMidiTrack();
if (!midiTrack) {
container.innerHTML = '<div class="placeholder">Select a MIDI layer to edit instruments</div>';
return container;
}
// ... rest of node editor code ...
}
```
3. Use actual track ID instead of hardcoded 0:
```javascript
const trackId = midiTrack.audioTrackId || 0;
const commandArgs = { trackId, nodeType, x, y };
```
4. Add listener to refresh node editor when layer selection changes
- **Impact**: High - allows editing wrong track's instrument graph, data corruption risk
- **Priority**: High - can cause confusion and data loss
### Animation: Wrong Default Interpolation for Shape and Object Keyframes
- **Issue**: Shape index and object transform keyframes default to "linear" interpolation but should default to "hold" (step function), and there's no UI to change interpolation after creation
- **Affected Files**:
- `src/models/animation.js:124` (Keyframe constructor defaults to "linear")
- `src/main.js:2161` (shapeIndex keyframes default to "linear")
- `src/main.js:2198` (object position/rotation/scale keyframes default to "linear")
- `src/main.js:5910` (Timeline menu - missing tween options)
- **Root Cause**:
1. The Keyframe constructor defaults interpolation to "linear"
2. Shape index keyframes preserve existing interpolation or default to "linear"
3. Object transform keyframes explicitly use "linear"
4. No menu options exist to change interpolation mode after keyframe creation
- **Current Code**:
- Keyframe constructor (animation.js:124):
```javascript
constructor(time, value, interpolation = "linear", uuid = undefined) {
```
- Shape index keyframes (main.js:2161):
```javascript
const interpolationType = existingShapeIndexKf ? existingShapeIndexKf.interpolation : 'linear';
const shapeIndexKeyframe = new Keyframe(currentTime, newShapeIndex, interpolationType);
```
- Object keyframes (main.js:2198):
```javascript
const newKeyframe = new Keyframe(
currentTime,
currentValue,
'linear' // Default to linear interpolation
);
```
- **Expected Behavior**:
- Shape index keyframes should default to "hold" (shapes shouldn't morph between versions)
- Object transforms should default to "hold" (objects shouldn't move/rotate/scale between keyframes unless explicitly tweened)
- Timeline menu should have options to convert between interpolation modes
- **Recommended Fix**:
1. Change shapeIndex default to "hold" (main.js:2161):
```javascript
const interpolationType = existingShapeIndexKf ? existingShapeIndexKf.interpolation : 'hold';
```
2. Change object keyframe default to "hold" (main.js:2198):
```javascript
const newKeyframe = new Keyframe(currentTime, currentValue, 'hold');
```
3. Add Timeline menu options (main.js:5910, in timelineSubmenu):
```javascript
{
text: "Add Shape Tween",
enabled: /* check if shape is selected and has keyframes */,
action: () => {
// Find shapeIndex curve for selected shape
// Change interpolation between keyframes to "linear"
}
},
{
text: "Add Motion Tween",
enabled: /* check if object is selected and has transform keyframes */,
action: () => {
// Find position/rotation/scale curves for selected object
// Change interpolation between keyframes to "linear" or "bezier"
}
}
```
- **Note**: exists and zOrder keyframes already correctly use "hold" (main.js:2139, 2150)
- **Impact**: High - causes unwanted interpolation, shapes morph unexpectedly, objects move when they shouldn't
- **Priority**: High - fundamental animation behavior is incorrect
### Tauri Pinch-Zoom on Linux
- **Issue**: Two-finger pinch gestures zoom the entire Tauri window instead of individual canvases
- **Status**: Known Tauri limitation on Linux/GTK with no cross-platform solution
- **Tracking**: https://github.com/tauri-apps/tauri/discussions/3843
- **Workaround attempts**: Tried `zoomHotkeysEnabled: false`, `touch-action: none`, viewport meta tags - none worked
- **Resolution**: Monitor Tauri releases for official fix
## Notes
### Architecture
- **GraphicsObject** contains Layers and has `currentTime` (continuous time)
- **Layer** contains `shapes[]` array and `animationData` (AnimationData instance)
- **AnimationData** contains curves dictionary, each curve identified by parameter name
- Shape curves: `shape.{uuid}.exists`, `shape.{uuid}.zOrder`
- Future: `shape.{uuid}.x`, `shape.{uuid}.y`, `shape.{uuid}.rotation`, etc.
- **Shapes render based on curves**: Layer.draw checks exists > 0, sorts by zOrder, draws in order
### Interpolation Types
- `linear` - Linear interpolation between keyframes
- `bezier` - Cubic Bezier with easing control points
- `step`/`hold` - Step function (jumps to next value)

View File

@ -116,7 +116,6 @@ pub struct Engine {
// Current tempo map — kept in sync with SetTempo/SetTempoMap commands. // Current tempo map — kept in sync with SetTempo/SetTempoMap commands.
tempo_map: crate::TempoMap, tempo_map: crate::TempoMap,
current_fps: f64,
// Current time signature — updated by SetTempo, used when SetTempoMap fires. // Current time signature — updated by SetTempo, used when SetTempoMap fires.
time_sig: (u32, u32), time_sig: (u32, u32),
} }
@ -197,7 +196,6 @@ impl Engine {
timing_sum_total_us: 0, timing_sum_total_us: 0,
timing_overrun_count: 0, timing_overrun_count: 0,
tempo_map: crate::TempoMap::constant(120.0), tempo_map: crate::TempoMap::constant(120.0),
current_fps: 30.0,
time_sig: (4, 4), time_sig: (4, 4),
} }
} }

View File

@ -23,6 +23,9 @@ pub struct AddShapeAction {
stroke_color: Option<ShapeColor>, stroke_color: Option<ShapeColor>,
fill_color: Option<ShapeColor>, fill_color: Option<ShapeColor>,
is_closed: bool, is_closed: bool,
/// When set, the enclosed region is filled with this image asset (instead of a
/// solid colour). The renderer prioritises `image_fill` over colour/gradient.
image_fill: Option<Uuid>,
description_text: String, description_text: String,
/// Snapshot of the graph before insertion (for undo). /// Snapshot of the graph before insertion (for undo).
graph_before: Option<VectorGraph>, graph_before: Option<VectorGraph>,
@ -46,15 +49,53 @@ impl AddShapeAction {
stroke_color, stroke_color,
fill_color, fill_color,
is_closed, is_closed,
image_fill: None,
description_text: "Add shape".to_string(), description_text: "Add shape".to_string(),
graph_before: None, graph_before: None,
} }
} }
/// A borderless, axis-aligned rectangle filled with an image asset — the result
/// of importing/dropping an image onto a vector layer.
pub fn image_rect(
layer_id: Uuid,
time: f64,
x: f64,
y: f64,
w: f64,
h: f64,
asset_id: Uuid,
) -> Self {
let mut path = BezPath::new();
path.move_to((x, y));
path.line_to((x + w, y));
path.line_to((x + w, y + h));
path.line_to((x, y + h));
path.close_path();
Self {
layer_id,
time,
path,
stroke_style: None, // invisible edges — just the image
stroke_color: None,
fill_color: None,
is_closed: true,
image_fill: Some(asset_id),
description_text: "Add image".to_string(),
graph_before: None,
}
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self { pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description_text = desc.into(); self.description_text = desc.into();
self self
} }
/// Fill the created region with an image asset (image takes render priority).
pub fn with_image_fill(mut self, asset_id: Uuid) -> Self {
self.image_fill = Some(asset_id);
self
}
} }
impl Action for AddShapeAction { impl Action for AddShapeAction {
@ -87,16 +128,25 @@ impl Action for AddShapeAction {
DEFAULT_SNAP_EPSILON, DEFAULT_SNAP_EPSILON,
); );
// Apply fill if this is a closed shape with fill // Apply fill if this is a closed shape with a colour and/or image fill.
if self.is_closed { if self.is_closed && (self.fill_color.is_some() || self.image_fill.is_some()) {
if let Some(ref fill) = self.fill_color { // Compute centroid of the path's bounding box and paint-bucket fill.
// Compute centroid of the path's bounding box and paint-bucket fill let bbox = self.path.bounding_box();
let bbox = self.path.bounding_box(); let centroid = kurbo::Point::new(
let centroid = kurbo::Point::new( (bbox.x0 + bbox.x1) / 2.0,
(bbox.x0 + bbox.x1) / 2.0, (bbox.y0 + bbox.y1) / 2.0,
(bbox.y0 + bbox.y1) / 2.0, );
); // paint_bucket needs a colour; an image-only fill uses a placeholder
graph.paint_bucket(centroid, fill.clone(), FillRule::NonZero, 0.0); // that the image overrides (cleared below).
let color = self.fill_color.clone().unwrap_or_else(|| ShapeColor::rgba(255, 255, 255, 255));
if let Some(fid) = graph.paint_bucket(centroid, color, FillRule::NonZero, 0.0) {
if let Some(asset_id) = self.image_fill {
let fill = graph.fill_mut(fid);
fill.image_fill = Some(asset_id);
if self.fill_color.is_none() {
fill.color = None; // image-only: don't double-paint a colour
}
}
} }
} }
} }

View File

@ -0,0 +1,126 @@
//! Shared logic for the "Group" and "Convert to Movie Clip" actions: extract the
//! selected DCEL geometry from a vector layer's active keyframe into a new `VectorClip`
//! and drop a `ClipInstance` in its place (so it can then be motion-tweened).
//!
//! A *group* (`is_group = true`) is a static container; a *movie clip* (`is_group =
//! false`) has its own timeline. Both are tweenable via the clip instance's transform.
//!
//! Both the Select tool and the (cut-and-select) RegionSelect tool populate the same
//! `Selection` ID sets, so a single entry point — [`extract_geometry_to_clip`] — handles
//! Group and Convert-to-Movie-Clip from whatever is selected.
use std::collections::HashSet;
use crate::clip::{ClipInstance, VectorClip};
use crate::document::Document;
use crate::layer::{AnyLayer, ShapeKeyframe, VectorLayer};
use crate::object::Transform;
use crate::vector_graph::{EdgeId, FillId, VectorGraph};
use uuid::Uuid;
/// Build a clip holding `sub_graph` and place a `ClipInstance` (with `transform`) on the
/// layer. Shared by both extraction paths. Does **not** touch the source graph — the
/// caller is responsible for having removed the moved geometry first.
fn assemble_clip_from_graph(
document: &mut Document,
layer_id: Uuid,
time: f64,
sub_graph: VectorGraph,
transform: Transform,
clip_id: Uuid,
instance_id: Uuid,
is_group: bool,
clip_name: &str,
) {
let (doc_w, doc_h, doc_dur) = (document.width, document.height, document.duration.max(1.0));
// A vector layer whose single keyframe holds the extracted graph (in the source's
// coordinate space, so an identity/translation placement renders it in place).
let mut inner = VectorLayer::new("Layer 1");
let mut kf = ShapeKeyframe::new(0.0);
kf.graph = sub_graph;
inner.keyframes.push(kf);
let mut clip = VectorClip::with_id(clip_id, clip_name, doc_w, doc_h, doc_dur);
clip.is_group = is_group;
clip.layers.add_root(AnyLayer::Vector(inner));
document.add_vector_clip(clip);
let mut instance = ClipInstance::with_id(instance_id, clip_id);
instance.transform = transform;
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
// Groups gate visibility by the active keyframe's clip_instance_ids; movie
// clips render unconditionally.
if is_group {
if let Some(kf) = vl.keyframe_at_mut(time) {
kf.clip_instance_ids.push(instance_id);
}
}
vl.clip_instances.push(instance);
}
}
/// Extract the selected geometry into a new clip + place a `ClipInstance`. Returns the
/// pre-extraction graph snapshot for undo. `clip_id`/`instance_id` are caller-provided
/// so undo/redo is stable. The selection sets come straight from the editor selection
/// (`select_fill` already includes each fill's boundary edges); `extract_subgraph`
/// derives which of those edges are shared with non-selected shapes.
pub fn extract_geometry_to_clip(
document: &mut Document,
layer_id: Uuid,
time: f64,
fills: &HashSet<FillId>,
edges: &HashSet<EdgeId>,
clip_id: Uuid,
instance_id: Uuid,
is_group: bool,
clip_name: &str,
) -> Result<VectorGraph, String> {
if fills.is_empty() && edges.is_empty() {
return Err("No geometry selected".to_string());
}
// 1. Extract from the source graph (extract_subgraph removes the moved geometry).
let (graph_before, sub_graph) = {
let layer = document.get_layer_mut(&layer_id).ok_or("Layer not found")?;
let vl = match layer {
AnyLayer::Vector(vl) => vl,
_ => return Err("Not a vector layer".to_string()),
};
let graph = vl.graph_at_time_mut(time).ok_or("No keyframe at time")?;
let before = graph.clone();
// No explicit cut boundary — extract_subgraph derives shared-fill boundaries.
let (sub, _, _) = graph.extract_subgraph(edges, fills, &HashSet::new());
(before, sub)
};
// 2 & 3. Build the clip + place an identity-transform instance (geometry stays put).
assemble_clip_from_graph(
document, layer_id, time, sub_graph, Transform::default(),
clip_id, instance_id, is_group, clip_name,
);
Ok(graph_before)
}
/// Reverse `extract_geometry_to_clip`: remove the clip + instance and restore the graph.
pub fn undo_extract_geometry(
document: &mut Document,
layer_id: Uuid,
time: f64,
clip_id: Uuid,
instance_id: Uuid,
graph_before: &VectorGraph,
) {
document.vector_clips.remove(&clip_id);
document.rebuild_layer_to_clip_map();
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&layer_id) {
vl.clip_instances.retain(|ci| ci.id != instance_id);
if let Some(kf) = vl.keyframe_at_mut(time) {
kf.clip_instance_ids.retain(|id| *id != instance_id);
}
if let Some(graph) = vl.graph_at_time_mut(time) {
*graph = graph_before.clone();
}
}
}

View File

@ -1,55 +1,57 @@
//! Convert to Movie Clip action — STUB: needs DCEL rewrite //! Convert to Movie Clip — extract selected geometry into a movie-clip `VectorClip`
//! (its own timeline) + a `ClipInstance` that can be motion-tweened.
use std::collections::HashSet;
use crate::action::Action; use crate::action::Action;
use crate::clip::ClipInstance; use crate::actions::clip_from_geometry::{extract_geometry_to_clip, undo_extract_geometry};
use crate::document::Document; use crate::document::Document;
use crate::vector_graph::{EdgeId, FillId, VectorGraph};
use uuid::Uuid; use uuid::Uuid;
/// Action that converts selected items to a Movie Clip
/// TODO: Rewrite for DCEL
#[allow(dead_code)]
pub struct ConvertToMovieClipAction { pub struct ConvertToMovieClipAction {
layer_id: Uuid, layer_id: Uuid,
time: f64, time: f64,
shape_ids: Vec<Uuid>, fills: Vec<FillId>,
clip_instance_ids: Vec<Uuid>, edges: Vec<EdgeId>,
clip_id: Uuid,
instance_id: Uuid, instance_id: Uuid,
created_clip_id: Option<Uuid>, graph_before: Option<VectorGraph>,
removed_clip_instances: Vec<ClipInstance>,
} }
impl ConvertToMovieClipAction { impl ConvertToMovieClipAction {
pub fn new( pub fn new(
layer_id: Uuid, layer_id: Uuid,
time: f64, time: f64,
shape_ids: Vec<Uuid>, fills: Vec<FillId>,
clip_instance_ids: Vec<Uuid>, edges: Vec<EdgeId>,
clip_id: Uuid,
instance_id: Uuid, instance_id: Uuid,
) -> Self { ) -> Self {
Self { Self { layer_id, time, fills, edges, clip_id, instance_id, graph_before: None }
layer_id,
time,
shape_ids,
clip_instance_ids,
instance_id,
created_clip_id: None,
removed_clip_instances: Vec::new(),
}
} }
} }
impl Action for ConvertToMovieClipAction { impl Action for ConvertToMovieClipAction {
fn execute(&mut self, _document: &mut Document) -> Result<(), String> { fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let _ = (&self.layer_id, self.time, &self.shape_ids, &self.clip_instance_ids, self.instance_id); let fills: HashSet<FillId> = self.fills.iter().copied().collect();
let edges: HashSet<EdgeId> = self.edges.iter().copied().collect();
let before = extract_geometry_to_clip(
document, self.layer_id, self.time, &fills, &edges,
self.clip_id, self.instance_id, false, "Movie Clip",
)?;
self.graph_before = Some(before);
Ok(()) Ok(())
} }
fn rollback(&mut self, _document: &mut Document) -> Result<(), String> { fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(before) = &self.graph_before {
undo_extract_geometry(document, self.layer_id, self.time, self.clip_id, self.instance_id, before);
}
Ok(()) Ok(())
} }
fn description(&self) -> String { fn description(&self) -> String {
let count = self.shape_ids.len() + self.clip_instance_ids.len(); "Convert to Movie Clip".to_string()
format!("Convert {} object(s) to Movie Clip", count)
} }
} }

View File

@ -1,56 +1,58 @@
//! Group action — STUB: needs DCEL rewrite //! Group action — extract selected geometry into a group `VectorClip` + a `ClipInstance`.
use std::collections::HashSet;
use crate::action::Action; use crate::action::Action;
use crate::clip::ClipInstance; use crate::actions::clip_from_geometry::{extract_geometry_to_clip, undo_extract_geometry};
use crate::document::Document; use crate::document::Document;
use crate::vector_graph::{EdgeId, FillId, VectorGraph};
use uuid::Uuid; use uuid::Uuid;
/// Action that groups selected shapes and/or clip instances into a VectorClip /// Groups the selected DCEL geometry (fills/edges) of a vector layer's active keyframe
/// TODO: Rewrite for DCEL (group DCEL faces/edges into a sub-clip) /// into a new group clip, placing a clip instance in its place (which can be tweened).
#[allow(dead_code)]
pub struct GroupAction { pub struct GroupAction {
layer_id: Uuid, layer_id: Uuid,
time: f64, time: f64,
shape_ids: Vec<Uuid>, fills: Vec<FillId>,
clip_instance_ids: Vec<Uuid>, edges: Vec<EdgeId>,
clip_id: Uuid,
instance_id: Uuid, instance_id: Uuid,
created_clip_id: Option<Uuid>, graph_before: Option<VectorGraph>,
removed_clip_instances: Vec<ClipInstance>,
} }
impl GroupAction { impl GroupAction {
pub fn new( pub fn new(
layer_id: Uuid, layer_id: Uuid,
time: f64, time: f64,
shape_ids: Vec<Uuid>, fills: Vec<FillId>,
clip_instance_ids: Vec<Uuid>, edges: Vec<EdgeId>,
clip_id: Uuid,
instance_id: Uuid, instance_id: Uuid,
) -> Self { ) -> Self {
Self { Self { layer_id, time, fills, edges, clip_id, instance_id, graph_before: None }
layer_id,
time,
shape_ids,
clip_instance_ids,
instance_id,
created_clip_id: None,
removed_clip_instances: Vec::new(),
}
} }
} }
impl Action for GroupAction { impl Action for GroupAction {
fn execute(&mut self, _document: &mut Document) -> Result<(), String> { fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let _ = (&self.layer_id, self.time, &self.shape_ids, &self.clip_instance_ids, self.instance_id); let fills: HashSet<FillId> = self.fills.iter().copied().collect();
// TODO: Implement DCEL-aware grouping let edges: HashSet<EdgeId> = self.edges.iter().copied().collect();
let before = extract_geometry_to_clip(
document, self.layer_id, self.time, &fills, &edges,
self.clip_id, self.instance_id, true, "Group",
)?;
self.graph_before = Some(before);
Ok(()) Ok(())
} }
fn rollback(&mut self, _document: &mut Document) -> Result<(), String> { fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(before) = &self.graph_before {
undo_extract_geometry(document, self.layer_id, self.time, self.clip_id, self.instance_id, before);
}
Ok(()) Ok(())
} }
fn description(&self) -> String { fn description(&self) -> String {
let count = self.shape_ids.len() + self.clip_instance_ids.len(); "Group".to_string()
format!("Group {} objects", count)
} }
} }

View File

@ -29,17 +29,20 @@ pub mod update_midi_events;
pub mod loop_clip_instances; pub mod loop_clip_instances;
pub mod remove_clip_instances; pub mod remove_clip_instances;
pub mod set_keyframe; pub mod set_keyframe;
pub mod set_tween;
pub mod group_shapes; pub mod group_shapes;
pub mod convert_to_movie_clip; pub mod convert_to_movie_clip;
pub mod region_split; pub mod region_split;
pub mod toggle_group_expansion; pub mod toggle_group_expansion;
pub mod group_layers; pub mod group_layers;
pub mod clip_from_geometry;
pub mod raster_diff; pub mod raster_diff;
pub mod raster_stroke; pub mod raster_stroke;
pub mod raster_fill; pub mod raster_fill;
pub mod add_raster_keyframe; pub mod add_raster_keyframe;
pub mod move_layer; pub mod move_layer;
pub mod set_fill_paint; pub mod set_fill_paint;
pub mod set_image_fill;
pub use add_clip_instance::AddClipInstanceAction; pub use add_clip_instance::AddClipInstanceAction;
pub use add_effect::AddEffectAction; pub use add_effect::AddEffectAction;
@ -65,6 +68,7 @@ pub use update_midi_events::UpdateMidiEventsAction;
pub use loop_clip_instances::LoopClipInstancesAction; pub use loop_clip_instances::LoopClipInstancesAction;
pub use remove_clip_instances::RemoveClipInstancesAction; pub use remove_clip_instances::RemoveClipInstancesAction;
pub use set_keyframe::SetKeyframeAction; pub use set_keyframe::SetKeyframeAction;
pub use set_tween::SetTweenAction;
pub use group_shapes::GroupAction; pub use group_shapes::GroupAction;
pub use convert_to_movie_clip::ConvertToMovieClipAction; pub use convert_to_movie_clip::ConvertToMovieClipAction;
pub use region_split::RegionSplitAction; pub use region_split::RegionSplitAction;
@ -75,5 +79,6 @@ pub use raster_fill::RasterFillAction;
pub use add_raster_keyframe::AddRasterKeyframeAction; pub use add_raster_keyframe::AddRasterKeyframeAction;
pub use move_layer::MoveLayerAction; pub use move_layer::MoveLayerAction;
pub use set_fill_paint::SetFillPaintAction; pub use set_fill_paint::SetFillPaintAction;
pub use set_image_fill::SetImageFillAction;
pub use change_bpm::ChangeBpmAction; pub use change_bpm::ChangeBpmAction;
pub use change_fps::ChangeFpsAction; pub use change_fps::ChangeFpsAction;

View File

@ -12,7 +12,7 @@
//! correct). If the base is somehow not resident we skip rather than corrupt. //! correct). If the base is somehow not resident we skip rather than corrupt.
/// Normalize a buffer to full length `n`; an empty/short buffer becomes transparent. /// Normalize a buffer to full length `n`; an empty/short buffer becomes transparent.
fn normalize(buf: &[u8], n: usize) -> std::borrow::Cow<[u8]> { fn normalize(buf: &[u8], n: usize) -> std::borrow::Cow<'_, [u8]> {
if buf.len() == n { if buf.len() == n {
std::borrow::Cow::Borrowed(buf) std::borrow::Cow::Borrowed(buf)
} else { } else {

View File

@ -0,0 +1,67 @@
//! Action that sets or clears the image fill on one or more VectorGraph fills.
//!
//! `image_fill` is an asset id the renderer maps onto the fill's bounding box; it
//! takes priority over colour/gradient. Setting `None` clears it (the colour/gradient
//! underneath shows again).
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::vector_graph::FillId;
use uuid::Uuid;
pub struct SetImageFillAction {
layer_id: Uuid,
time: f64,
fill_ids: Vec<FillId>,
/// `Some(asset_id)` to set, `None` to clear.
new_image: Option<Uuid>,
/// Per-fill previous `image_fill`, for undo.
old: Vec<(FillId, Option<Uuid>)>,
}
impl SetImageFillAction {
pub fn new(layer_id: Uuid, time: f64, fill_ids: Vec<FillId>, image: Option<Uuid>) -> Self {
Self { layer_id, time, fill_ids, new_image: image, old: Vec::new() }
}
fn get_graph_mut<'a>(
document: &'a mut Document,
layer_id: &Uuid,
time: f64,
) -> Result<&'a mut crate::vector_graph::VectorGraph, String> {
let layer = document
.get_layer_mut(layer_id)
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
match layer {
AnyLayer::Vector(vl) => vl
.graph_at_time_mut(time)
.ok_or_else(|| format!("No keyframe at time {}", time)),
_ => Err("Not a vector layer".to_string()),
}
}
}
impl Action for SetImageFillAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let graph = Self::get_graph_mut(document, &self.layer_id, self.time)?;
self.old.clear();
for &fid in &self.fill_ids {
self.old.push((fid, graph.fill(fid).image_fill));
graph.fill_mut(fid).image_fill = self.new_image;
}
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let graph = Self::get_graph_mut(document, &self.layer_id, self.time)?;
for &(fid, old) in &self.old {
graph.fill_mut(fid).image_fill = old;
}
Ok(())
}
fn description(&self) -> String {
if self.new_image.is_some() { "Set image fill" } else { "Clear image fill" }.to_string()
}
}

View File

@ -8,6 +8,7 @@ use crate::action::Action;
use crate::animation::{AnimationCurve, AnimationTarget, Keyframe, TransformProperty}; use crate::animation::{AnimationCurve, AnimationTarget, Keyframe, TransformProperty};
use crate::document::Document; use crate::document::Document;
use crate::layer::{AnyLayer, ShapeKeyframe}; use crate::layer::{AnyLayer, ShapeKeyframe};
use crate::object::Transform;
use uuid::Uuid; use uuid::Uuid;
/// Undo info for a clip animation curve /// Undo info for a clip animation curve
@ -54,11 +55,17 @@ const TRANSFORM_PROPERTIES: &[TransformProperty] = &[
TransformProperty::Opacity, TransformProperty::Opacity,
]; ];
fn transform_default(prop: &TransformProperty) -> f64 { /// The clip instance's own value for a property (its base transform / opacity).
fn transform_prop_value(t: &Transform, opacity: f64, prop: &TransformProperty) -> f64 {
match prop { match prop {
TransformProperty::ScaleX | TransformProperty::ScaleY => 1.0, TransformProperty::X => t.x,
TransformProperty::Opacity => 1.0, TransformProperty::Y => t.y,
_ => 0.0, TransformProperty::Rotation => t.rotation,
TransformProperty::ScaleX => t.scale_x,
TransformProperty::ScaleY => t.scale_y,
TransformProperty::SkewX => t.skew_x,
TransformProperty::SkewY => t.skew_y,
TransformProperty::Opacity => opacity,
} }
} }
@ -67,6 +74,21 @@ impl Action for SetKeyframeAction {
self.clip_undo_entries.clear(); self.clip_undo_entries.clear();
self.shape_keyframe_created = false; self.shape_keyframe_created = false;
// Phase 1 (immutable): for each clip instance, gather its base transform and the
// start time of its visibility region, so a brand-new curve can be anchored there.
let mut clip_info: std::collections::HashMap<Uuid, (Transform, f64, f64)> =
std::collections::HashMap::new(); // id -> (base transform, opacity, start time)
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&self.layer_id) {
for clip_id in &self.clip_instance_ids {
if let Some(ci) = vl.clip_instances.iter().find(|c| c.id == *clip_id) {
let start = vl
.group_visibility_start(clip_id, self.time)
.unwrap_or(ci.timeline_start);
clip_info.insert(*clip_id, (ci.transform.clone(), ci.opacity, start));
}
}
}
let layer = document let layer = document
.get_layer_mut(&self.layer_id) .get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?; .ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
@ -82,23 +104,37 @@ impl Action for SetKeyframeAction {
// Add clip animation keyframes // Add clip animation keyframes
for clip_id in &self.clip_instance_ids { for clip_id in &self.clip_instance_ids {
let (base_transform, base_opacity, start) = clip_info
.get(clip_id)
.cloned()
.unwrap_or((Transform::new(), 1.0, 0.0));
for prop in TRANSFORM_PROPERTIES { for prop in TRANSFORM_PROPERTIES {
let target = AnimationTarget::Object { let target = AnimationTarget::Object {
id: *clip_id, id: *clip_id,
property: *prop, property: *prop,
}; };
let default = transform_default(prop); // Fall back to the clip's OWN value (not a generic default) so a brand-new
let value = vl.layer.animation_data.eval(&target, self.time, default); // keyframe captures the actual on-stage position, not (0,0)/identity.
let base = transform_prop_value(&base_transform, base_opacity, prop);
let value = vl.layer.animation_data.eval(&target, self.time, base);
let curve_created = vl.layer.animation_data.get_curve(&target).is_none(); let curve_created = vl.layer.animation_data.get_curve(&target).is_none();
if curve_created { if curve_created {
vl.layer vl.layer
.animation_data .animation_data
.set_curve(AnimationCurve::new(target.clone(), default)); .set_curve(AnimationCurve::new(target.clone(), base));
} }
let curve = vl.layer.animation_data.get_curve_mut(&target).unwrap(); let curve = vl.layer.animation_data.get_curve_mut(&target).unwrap();
let old_keyframe = curve.get_keyframe_at(self.time, 0.001).cloned(); let old_keyframe = curve.get_keyframe_at(self.time, 0.001).cloned();
// When this is the first keyframe of the curve and the clip already existed
// before `time`, anchor a keyframe at its start with the original value.
// Otherwise a single keyframe would Hold-extrapolate backward and move the
// clip on every earlier frame too (the motion-tween first-keyframe bug).
if curve_created && start < self.time - 0.001 {
curve.set_keyframe(Keyframe::linear(start, base));
}
curve.set_keyframe(Keyframe::linear(self.time, value)); curve.set_keyframe(Keyframe::linear(self.time, value));
self.clip_undo_entries.push(ClipUndoEntry { self.clip_undo_entries.push(ClipUndoEntry {
@ -145,3 +181,86 @@ impl Action for SetKeyframeAction {
"New keyframe".to_string() "New keyframe".to_string()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::actions::TransformClipInstancesAction;
use crate::clip::ClipInstance;
use crate::layer::VectorLayer;
use std::collections::HashMap;
fn x_curve_eval(document: &Document, layer_id: Uuid, instance_id: Uuid, time: f64) -> f64 {
let target = AnimationTarget::Object { id: instance_id, property: TransformProperty::X };
match document.get_layer(&layer_id) {
Some(AnyLayer::Vector(vl)) => vl.layer.animation_data.eval(&target, time, f64::NAN),
_ => panic!("no layer"),
}
}
#[test]
fn first_keyframe_then_move_does_not_disturb_earlier_frames() {
// Group created at frame 0 (clip at x=50), keyframe + move at frame 10 → x=200.
// Frame 0 must keep x=50 (the motion-tween first-keyframe bug: it used to become 200).
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Layer");
let clip_id = Uuid::new_v4();
let instance_id = Uuid::new_v4();
let mut instance = ClipInstance::with_id(instance_id, clip_id);
instance.transform = Transform::with_position(50.0, 50.0);
layer.clip_instances.push(instance);
// The group's visibility starts at a keyframe at time 0 containing the instance.
layer.ensure_keyframe_at(0.0).clip_instance_ids.push(instance_id);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create a keyframe at frame 10.
SetKeyframeAction::new(layer_id, 10.0, vec![instance_id])
.execute(&mut document)
.unwrap();
// The new curve must be anchored at the start (two keyframes, both at x=50 so far).
assert!((x_curve_eval(&document, layer_id, instance_id, 0.0) - 50.0).abs() < 1e-6);
assert!((x_curve_eval(&document, layer_id, instance_id, 10.0) - 50.0).abs() < 1e-6);
// Move the clip at frame 10 to x=200.
let mut transforms = HashMap::new();
transforms.insert(
instance_id,
(Transform::with_position(50.0, 50.0), Transform::with_position(200.0, 200.0)),
);
TransformClipInstancesAction::new(layer_id, 10.0, transforms)
.execute(&mut document)
.unwrap();
// Frame 0 unchanged; frame 10 moved; midpoint tweens.
assert!((x_curve_eval(&document, layer_id, instance_id, 0.0) - 50.0).abs() < 1e-6, "frame 0 must stay 50");
assert!((x_curve_eval(&document, layer_id, instance_id, 10.0) - 200.0).abs() < 1e-6, "frame 10 must be 200");
assert!((x_curve_eval(&document, layer_id, instance_id, 5.0) - 125.0).abs() < 1e-6, "midpoint tweens");
}
#[test]
fn first_keyframe_at_clip_start_is_not_double_anchored() {
// When the keyframe is created at the clip's own start, there's nothing earlier to
// anchor — a single keyframe is correct.
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Layer");
let clip_id = Uuid::new_v4();
let instance_id = Uuid::new_v4();
let mut instance = ClipInstance::with_id(instance_id, clip_id);
instance.transform = Transform::with_position(10.0, 0.0);
layer.clip_instances.push(instance);
layer.ensure_keyframe_at(0.0).clip_instance_ids.push(instance_id);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
SetKeyframeAction::new(layer_id, 0.0, vec![instance_id])
.execute(&mut document)
.unwrap();
let target = AnimationTarget::Object { id: instance_id, property: TransformProperty::X };
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let curve = vl.layer.animation_data.get_curve(&target).unwrap();
assert_eq!(curve.keyframes.len(), 1, "keyframe at clip start needs no anchor");
assert!((curve.eval(0.0) - 10.0).abs() < 1e-6);
}
}
}

View File

@ -0,0 +1,57 @@
//! Set the tween type on the keyframe at-or-before a time (e.g. "Add Shape Tween").
//!
//! The keyframe's `tween_after` controls how the span between it and the next keyframe is
//! rendered: `None` holds, `Shape` morphs the geometry (when the two keyframes share
//! topology — otherwise rendering falls back to holding).
use crate::action::Action;
use crate::document::Document;
use crate::layer::{AnyLayer, TweenType};
use uuid::Uuid;
pub struct SetTweenAction {
layer_id: Uuid,
time: f64,
new_tween: TweenType,
old_tween: Option<TweenType>,
}
impl SetTweenAction {
pub fn new(layer_id: Uuid, time: f64, new_tween: TweenType) -> Self {
Self { layer_id, time, new_tween, old_tween: None }
}
}
impl Action for SetTweenAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&self.layer_id) {
if let Some(kf) = vl.keyframe_at_mut(self.time) {
self.old_tween = Some(kf.tween_after);
kf.tween_after = self.new_tween;
} else {
return Err("No keyframe at-or-before this time".to_string());
}
} else {
return Err("Not a vector layer".to_string());
}
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if let (Some(old), Some(AnyLayer::Vector(vl))) =
(self.old_tween, document.get_layer_mut(&self.layer_id))
{
if let Some(kf) = vl.keyframe_at_mut(self.time) {
kf.tween_after = old;
}
}
Ok(())
}
fn description(&self) -> String {
match self.new_tween {
TweenType::Shape => "Add shape tween".to_string(),
TweenType::None => "Remove tween".to_string(),
}
}
}

View File

@ -322,6 +322,20 @@ impl AnimationCurve {
} }
} }
/// True when `time` lies strictly between two keyframes — an in-between frame of a
/// tween (not on a keyframe, not in the pre/post-extrapolation tails).
pub fn is_tween_inbetween(&self, time: f64, tol: f64) -> bool {
if self.keyframes.len() < 2 {
return false;
}
let first = self.keyframes.first().unwrap().time;
let last = self.keyframes.last().unwrap().time;
if time <= first + tol || time >= last - tol {
return false;
}
!self.keyframes.iter().any(|kf| (kf.time - time).abs() <= tol)
}
/// Extrapolate before the first keyframe /// Extrapolate before the first keyframe
fn extrapolate_pre(&self, time: f64, first_kf: &Keyframe) -> f64 { fn extrapolate_pre(&self, time: f64, first_kf: &Keyframe) -> f64 {
match self.pre_extrapolation { match self.pre_extrapolation {
@ -516,6 +530,17 @@ impl AnimationData {
self.curves.remove(target) self.curves.remove(target)
} }
/// True when the object (e.g. a clip instance) is mid motion-tween at `time` — any of
/// its curves has `time` strictly between two keyframes. Used to lock out editing on
/// in-between frames (editing there would silently insert a keyframe and disturb the tween).
pub fn is_object_tweened_at(&self, id: uuid::Uuid, time: f64) -> bool {
const TOL: f64 = 0.001;
self.curves.iter().any(|(target, curve)| {
matches!(target, AnimationTarget::Object { id: oid, .. } if *oid == id)
&& curve.is_tween_inbetween(time, TOL)
})
}
/// Evaluate a property at a given time /// Evaluate a property at a given time
pub fn eval(&self, target: &AnimationTarget, time: f64, default: f64) -> f64 { pub fn eval(&self, target: &AnimationTarget, time: f64, default: f64) -> f64 {
self.curves self.curves
@ -545,3 +570,51 @@ impl AnimationData {
(t, opacity) (t, opacity)
} }
} }
#[cfg(test)]
mod tween_lock_tests {
use super::*;
#[test]
fn curve_in_between_detection() {
let mut c = AnimationCurve::new(
AnimationTarget::Object { id: uuid::Uuid::nil(), property: TransformProperty::X },
0.0,
);
c.set_keyframe(Keyframe::linear(0.0, 0.0));
c.set_keyframe(Keyframe::linear(10.0, 100.0));
assert!(c.is_tween_inbetween(5.0, 0.001), "strictly between keyframes");
assert!(!c.is_tween_inbetween(0.0, 0.001), "on a keyframe");
assert!(!c.is_tween_inbetween(10.0, 0.001), "on a keyframe");
assert!(!c.is_tween_inbetween(15.0, 0.001), "past the last keyframe (extrapolation tail)");
}
#[test]
fn single_keyframe_is_never_in_between() {
let mut c = AnimationCurve::new(
AnimationTarget::Object { id: uuid::Uuid::nil(), property: TransformProperty::X },
0.0,
);
c.set_keyframe(Keyframe::linear(10.0, 100.0));
assert!(!c.is_tween_inbetween(5.0, 0.001));
assert!(!c.is_tween_inbetween(20.0, 0.001));
}
#[test]
fn object_tweened_when_any_curve_is_in_between() {
let id = uuid::Uuid::new_v4();
let mut data = AnimationData::new();
let mut cx = AnimationCurve::new(
AnimationTarget::Object { id, property: TransformProperty::X },
0.0,
);
cx.set_keyframe(Keyframe::linear(0.0, 0.0));
cx.set_keyframe(Keyframe::linear(10.0, 100.0));
data.set_curve(cx);
assert!(data.is_object_tweened_at(id, 5.0));
assert!(!data.is_object_tweened_at(id, 0.0));
assert!(!data.is_object_tweened_at(uuid::Uuid::new_v4(), 5.0), "different object");
}
}

View File

@ -263,9 +263,11 @@ pub struct ImageAsset {
/// Image height in pixels /// Image height in pixels
pub height: u32, pub height: u32,
/// Embedded image data (for project portability) /// Raw image file bytes. NOT serialized to project JSON — persisted as a
/// If None, the image will be loaded from path when needed /// `MediaKind::ImageAsset` row in the `.beam` container (chunked, pageable) and
#[serde(skip_serializing_if = "Option::is_none")] /// read back on load. `default` so new projects (bytes in the container, not JSON)
/// deserialize; old projects with base64-embedded `data` still load via deserialize.
#[serde(default, skip_serializing)]
pub data: Option<Vec<u8>>, pub data: Option<Vec<u8>>,
/// Folder this asset belongs to (None = root of category) /// Folder this asset belongs to (None = root of category)

View File

@ -462,6 +462,32 @@ pub fn save_beam(
} }
} }
// --- image assets -> media rows (original file bytes), keyed by asset id ---
let mut image_count = 0usize;
for (id, asset) in &document.image_assets {
if let Some(ref data) = asset.data {
let ext = asset
.path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("img")
.to_lowercase();
txn.put_media_packed(
*id,
MediaKind::ImageAsset,
&ext,
data,
MediaMeta { width: Some(asset.width), height: Some(asset.height), ..Default::default() },
)?;
live_media.insert(*id);
image_count += 1;
} else if txn.media_exists(*id)? {
// Bytes not resident (paged out) but already stored — keep the row.
live_media.insert(*id);
}
}
let _ = image_count;
// --- orphan cleanup: drop media for removed clips/keyframes --- // --- orphan cleanup: drop media for removed clips/keyframes ---
let removed = txn.retain_media(&live_media)?; let removed = txn.retain_media(&live_media)?;
@ -625,6 +651,11 @@ fn load_beam_sqlite(path: &Path) -> Result<LoadedProject, String> {
} }
let _ = proxy_load_count; let _ = proxy_load_count;
// Image-asset bytes are NOT eagerly read (Phase 4 Tier 1 paging): `ImageAsset.data`
// stays empty and the renderer's ImageCache pages bytes from the container on a
// decode miss (keyed by asset id). Old base64 projects keep their deserialized
// `data` (no container row). Loading is instant; only rendered images touch disk.
// Missing external files (referenced entries whose file no longer exists). // Missing external files (referenced entries whose file no longer exists).
let project_dir = path.parent().unwrap_or_else(|| Path::new(".")); let project_dir = path.parent().unwrap_or_else(|| Path::new("."));
let missing_files: Vec<MissingFileInfo> = restored_entries let missing_files: Vec<MissingFileInfo> = restored_entries

View File

@ -105,6 +105,9 @@ pub struct CompositorLayer {
pub opacity: f32, pub opacity: f32,
/// Blend mode for this layer /// Blend mode for this layer
pub blend_mode: BlendMode, pub blend_mode: BlendMode,
/// Screen-blend RGB tint; `[0,0,0,0]` (the default) is a no-op. Used by
/// onion-skin ghosts (warm = past, cool = future).
pub tint: [f32; 4],
} }
impl CompositorLayer { impl CompositorLayer {
@ -113,12 +116,19 @@ impl CompositorLayer {
buffer, buffer,
opacity: opacity.clamp(0.0, 1.0), opacity: opacity.clamp(0.0, 1.0),
blend_mode, blend_mode,
tint: [0.0; 4],
} }
} }
pub fn normal(buffer: BufferHandle, opacity: f32) -> Self { pub fn normal(buffer: BufferHandle, opacity: f32) -> Self {
Self::new(buffer, opacity, BlendMode::Normal) Self::new(buffer, opacity, BlendMode::Normal)
} }
/// Screen-blend the layer toward an RGB tint (for onion-skin ghosts).
pub fn with_tint(mut self, r: f32, g: f32, b: f32) -> Self {
self.tint = [r, g, b, 0.0];
self
}
} }
/// Uniform data for the composite shader /// Uniform data for the composite shader
@ -129,8 +139,10 @@ pub struct CompositeUniforms {
pub opacity: f32, pub opacity: f32,
/// Blend mode index /// Blend mode index
pub blend_mode: u32, pub blend_mode: u32,
/// Padding for alignment /// Padding to 16 bytes before the vec4 tint
pub _padding: [u32; 2], pub _padding: [u32; 2],
/// Screen-blend tint ((0,0,0) = none); `.w` unused.
pub tint: [f32; 4],
} }
/// Compositor for blending layers /// Compositor for blending layers
@ -323,6 +335,7 @@ impl Compositor {
opacity: layer.opacity, opacity: layer.opacity,
blend_mode: layer.blend_mode.to_index(), blend_mode: layer.blend_mode.to_index(),
_padding: [0, 0], _padding: [0, 0],
tint: layer.tint,
}; };
queue.write_buffer(&uniforms_buffer, 0, bytemuck::bytes_of(&uniforms)); queue.write_buffer(&uniforms_buffer, 0, bytemuck::bytes_of(&uniforms));
@ -382,6 +395,8 @@ struct Uniforms {
opacity: f32, opacity: f32,
blend_mode: u32, blend_mode: u32,
_padding: vec2<u32>, _padding: vec2<u32>,
// Screen-blend tint ((0,0,0) = no tint). Used by onion-skin ghosts.
tint: vec4<f32>,
} }
@group(0) @binding(0) var source_tex: texture_2d<f32>; @group(0) @binding(0) var source_tex: texture_2d<f32>;
@ -526,7 +541,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Apply opacity // Apply opacity
let src_alpha = src.a * uniforms.opacity; let src_alpha = src.a * uniforms.opacity;
// Screen-blend tint (recolors blacks toward the tint; no-op at tint=0).
let t = uniforms.tint.rgb;
let tinted = src.rgb + t - src.rgb * t;
// Output premultiplied alpha in linear color space // Output premultiplied alpha in linear color space
return vec4<f32>(src.rgb * src_alpha, src_alpha); return vec4<f32>(tinted * src_alpha, src_alpha);
} }
"#; "#;

View File

@ -382,6 +382,29 @@ impl VectorLayer {
self.keyframe_at(time).map(|kf| &kf.graph) self.keyframe_at(time).map(|kf| &kf.graph)
} }
/// The VectorGraph to *render* at `time`. When the keyframe at-or-before `time` has
/// `tween_after == Shape` and the next keyframe shares its topology, returns an owned
/// graph morphed between them; otherwise borrows the held keyframe's graph. Editing
/// should keep using `graph_at_time`/`graph_at_time_mut` (the held keyframe).
pub fn tweened_graph_at(&self, time: f64) -> Option<std::borrow::Cow<'_, VectorGraph>> {
use std::borrow::Cow;
let idx = self.keyframes.partition_point(|kf| kf.time <= time);
if idx == 0 {
return None;
}
let a = &self.keyframes[idx - 1];
if a.tween_after == TweenType::Shape && idx < self.keyframes.len() {
let b = &self.keyframes[idx];
if b.time > a.time {
let t = ((time - a.time) / (b.time - a.time)).clamp(0.0, 1.0);
if let Some(g) = a.graph.interpolated(&b.graph, t) {
return Some(Cow::Owned(g));
}
}
}
Some(Cow::Borrowed(&a.graph))
}
/// Get a mutable VectorGraph at a given time /// Get a mutable VectorGraph at a given time
pub fn graph_at_time_mut(&mut self, time: f64) -> Option<&mut VectorGraph> { pub fn graph_at_time_mut(&mut self, time: f64) -> Option<&mut VectorGraph> {
self.keyframe_at_mut(time).map(|kf| &mut kf.graph) self.keyframe_at_mut(time).map(|kf| &mut kf.graph)
@ -433,6 +456,25 @@ impl VectorLayer {
time + frame_duration time + frame_duration
} }
/// Start time of the group clip instance's visibility region that contains `time`:
/// the time of the earliest keyframe reachable by walking back through consecutive
/// keyframes that all contain the clip instance. Returns `None` if the clip instance
/// isn't a keyframe-gated group visible at `time` (e.g. a movie clip).
pub fn group_visibility_start(&self, clip_instance_id: &Uuid, time: f64) -> Option<f64> {
let after = self.keyframes.partition_point(|kf| kf.time <= time);
if after == 0 {
return None;
}
let mut idx = after - 1; // keyframe at-or-before `time`
if !self.keyframes[idx].clip_instance_ids.contains(clip_instance_id) {
return None;
}
while idx > 0 && self.keyframes[idx - 1].clip_instance_ids.contains(clip_instance_id) {
idx -= 1;
}
Some(self.keyframes[idx].time)
}
// Shape-based methods removed — use DCEL methods instead. // Shape-based methods removed — use DCEL methods instead.
// - shapes_at_time_mut → graph_at_time_mut // - shapes_at_time_mut → graph_at_time_mut
// - get_shape_in_keyframe → use DCEL vertex/edge/face accessors // - get_shape_in_keyframe → use DCEL vertex/edge/face accessors
@ -451,30 +493,53 @@ impl VectorLayer {
&mut self.keyframes[insert_idx] &mut self.keyframes[insert_idx]
} }
/// Insert a new keyframe at time by cloning the DCEL from the active keyframe. /// Insert a new keyframe at time, taking the geometry the layer shows there.
/// If a keyframe already exists at the exact time, does nothing and returns it. /// If a keyframe already exists at the exact time, does nothing and returns it.
///
/// Inside a shape-tween span this captures the *interpolated* geometry at `time` (not
/// the left keyframe's), and inherits the span's `tween_after` so the new keyframe keeps
/// tweening toward the next one — i.e. splitting a tween in two leaves the motion intact.
pub fn insert_keyframe_from_current(&mut self, time: f64) -> &mut ShapeKeyframe { pub fn insert_keyframe_from_current(&mut self, time: f64) -> &mut ShapeKeyframe {
let tolerance = 0.001; let tolerance = 0.001;
if let Some(idx) = self.keyframe_index_at_exact(time, tolerance) { if let Some(idx) = self.keyframe_index_at_exact(time, tolerance) {
return &mut self.keyframes[idx]; return &mut self.keyframes[idx];
} }
// Clone graph and clip instance IDs from the active keyframe // Geometry shown at `time` (interpolated if mid-tween, else the held keyframe).
let (cloned_graph, cloned_clip_ids) = self let cloned_graph = self
.tweened_graph_at(time)
.map(|g| g.into_owned())
.unwrap_or_else(VectorGraph::new);
// Inherit tween + clip instances from the active (left) keyframe.
let (tween_after, cloned_clip_ids) = self
.keyframe_at(time) .keyframe_at(time)
.map(|kf| { .map(|kf| (kf.tween_after, kf.clip_instance_ids.clone()))
(kf.graph.clone(), kf.clip_instance_ids.clone()) .unwrap_or((TweenType::None, Vec::new()));
})
.unwrap_or_else(|| (VectorGraph::new(), Vec::new()));
let insert_idx = self.keyframes.partition_point(|kf| kf.time < time); let insert_idx = self.keyframes.partition_point(|kf| kf.time < time);
let mut kf = ShapeKeyframe::new(time); let mut kf = ShapeKeyframe::new(time);
kf.graph = cloned_graph; kf.graph = cloned_graph;
kf.tween_after = tween_after;
kf.clip_instance_ids = cloned_clip_ids; kf.clip_instance_ids = cloned_clip_ids;
self.keyframes.insert(insert_idx, kf); self.keyframes.insert(insert_idx, kf);
&mut self.keyframes[insert_idx] &mut self.keyframes[insert_idx]
} }
/// True when `time` falls strictly inside a shape-tween span — i.e. an in-between frame
/// (the left keyframe has `tween_after == Shape` and `time` is not on either keyframe).
/// Editing such a frame would silently modify the left keyframe, so the editor blocks it.
pub fn is_tween_inbetween(&self, time: f64) -> bool {
let tol = 0.001;
let idx = self.keyframes.partition_point(|kf| kf.time <= time);
if idx == 0 || idx >= self.keyframes.len() {
return false;
}
let (a, b) = (&self.keyframes[idx - 1], &self.keyframes[idx]);
a.tween_after == TweenType::Shape
&& (time - a.time).abs() > tol
&& (b.time - time).abs() > tol
}
/// Remove a keyframe at the exact time (within tolerance). /// Remove a keyframe at the exact time (within tolerance).
/// Returns the removed keyframe if found. /// Returns the removed keyframe if found.
pub(crate) fn remove_keyframe_at(&mut self, time: f64, tolerance: f64) -> Option<ShapeKeyframe> { pub(crate) fn remove_keyframe_at(&mut self, time: f64, tolerance: f64) -> Option<ShapeKeyframe> {
@ -1050,6 +1115,97 @@ impl AnyLayer {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn tweened_graph_at_morphs_between_shape_keyframes() {
use crate::vector_graph::{Direction, FillRule, ShapeColor};
use kurbo::{CubicBez, Point};
// Build a single-vertex-ish graph at a given x via one degenerate fill is overkill;
// use one vertex + one edge (a loop) is also odd. Use two vertices + one edge and
// just check the vertex lerp through the layer's tween path.
let mk = |x: f64| {
let mut g = VectorGraph::new();
let v0 = g.alloc_vertex(Point::new(x, 0.0));
let v1 = g.alloc_vertex(Point::new(x + 10.0, 0.0));
let c = CubicBez::new(
Point::new(x, 0.0),
Point::new(x + 3.0, 0.0),
Point::new(x + 7.0, 0.0),
Point::new(x + 10.0, 0.0),
);
g.alloc_edge(c, v0, v1, None, Some(ShapeColor::rgb(0, 0, 0)));
g.alloc_fill(vec![(crate::vector_graph::EdgeId(0), Direction::Forward)],
ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
g
};
let mut layer = VectorLayer::new("L");
layer.keyframes.clear();
let mut kf0 = ShapeKeyframe::new(0.0);
kf0.graph = mk(0.0);
kf0.tween_after = TweenType::Shape;
let mut kf10 = ShapeKeyframe::new(10.0);
kf10.graph = mk(100.0);
layer.keyframes.push(kf0);
layer.keyframes.push(kf10);
// Midway through the tween, vertex 0 is halfway (x=50).
let g = layer.tweened_graph_at(5.0).unwrap();
assert!((g.vertices[0].position.x - 50.0).abs() < 1e-6);
// Without the tween flag, it holds the left keyframe (x=0).
layer.keyframes[0].tween_after = TweenType::None;
let g = layer.tweened_graph_at(5.0).unwrap();
assert!((g.vertices[0].position.x - 0.0).abs() < 1e-6);
}
#[test]
fn inserting_keyframe_mid_tween_captures_interpolated_geometry_and_inherits_tween() {
use crate::vector_graph::{Direction, FillRule, ShapeColor};
use kurbo::{CubicBez, Point};
let mk = |x: f64| {
let mut g = VectorGraph::new();
let v0 = g.alloc_vertex(Point::new(x, 0.0));
let v1 = g.alloc_vertex(Point::new(x + 10.0, 0.0));
let c = CubicBez::new(
Point::new(x, 0.0),
Point::new(x + 3.0, 0.0),
Point::new(x + 7.0, 0.0),
Point::new(x + 10.0, 0.0),
);
g.alloc_edge(c, v0, v1, None, Some(ShapeColor::rgb(0, 0, 0)));
g.alloc_fill(vec![(crate::vector_graph::EdgeId(0), Direction::Forward)],
ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
g
};
let mut layer = VectorLayer::new("L");
layer.keyframes.clear();
let mut kf_a = ShapeKeyframe::new(0.0);
kf_a.graph = mk(0.0);
kf_a.tween_after = TweenType::Shape;
let mut kf_b = ShapeKeyframe::new(10.0);
kf_b.graph = mk(100.0);
layer.keyframes.push(kf_a);
layer.keyframes.push(kf_b);
// Insert keyframe C at the midpoint of the A→B shape tween.
layer.insert_keyframe_from_current(5.0);
let c = layer.keyframe_at(5.0).expect("keyframe C exists");
// C took the interpolated geometry (x=50), not A's geometry (x=0).
assert!((c.graph.vertices[0].position.x - 50.0).abs() < 1e-6,
"C should hold interpolated geometry, got {}", c.graph.vertices[0].position.x);
// C inherits the shape tween so it still morphs toward B.
assert_eq!(c.tween_after, TweenType::Shape, "C should inherit the shape tween");
// The frame between C and B is still an in-between (tween continues past C).
assert!(layer.is_tween_inbetween(7.0));
// C itself is a keyframe, not an in-between.
assert!(!layer.is_tween_inbetween(5.0));
}
#[test] #[test]
fn test_layer_creation() { fn test_layer_creation() {
let layer = Layer::new(LayerType::Vector, "Test Layer"); let layer = Layer::new(LayerType::Vector, "Test Layer");

View File

@ -22,43 +22,111 @@ use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageForm
use vello::Scene; use vello::Scene;
/// Cache for decoded image data to avoid re-decoding every frame /// Cache for decoded image data to avoid re-decoding every frame
/// Decoded-image cache, bounded by a byte budget with usage-LRU eviction (Phase 4
/// asset paging). The decoded RGBA (~`w·h·4` per image) is the heavy, evictable cost;
/// a miss re-decodes from `asset.data`. Recency is bumped on every access, so images
/// actually rendered each frame stay resident and unused ones age out under pressure.
pub struct ImageCache { pub struct ImageCache {
cache: HashMap<Uuid, Arc<ImageBrush>>, cache: HashMap<Uuid, Arc<ImageBrush>>,
/// CPU path: tiny-skia pixmaps decoded from the same assets (premultiplied RGBA8) /// CPU path: tiny-skia pixmaps decoded from the same assets (premultiplied RGBA8)
cpu_cache: HashMap<Uuid, Arc<tiny_skia::Pixmap>>, cpu_cache: HashMap<Uuid, Arc<tiny_skia::Pixmap>>,
/// Recency order (least-recent first) of resident asset ids.
lru: Vec<Uuid>,
/// Decoded bytes per resident asset (counted once; GPU/CPU are ~equal and a render
/// session uses one path) and the running total.
sizes: HashMap<Uuid, usize>,
bytes: usize,
/// `.beam` container path for lazily loading compressed `ImageAsset` bytes on a
/// decode miss (Tier 1 paging) when `asset.data` isn't resident.
container_path: Option<std::path::PathBuf>,
} }
impl ImageCache { impl ImageCache {
/// Max decoded-image bytes kept resident before LRU eviction.
const BUDGET: usize = 256 * 1024 * 1024;
/// Create a new empty image cache /// Create a new empty image cache
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
cache: HashMap::new(), cache: HashMap::new(),
cpu_cache: HashMap::new(), cpu_cache: HashMap::new(),
lru: Vec::new(),
sizes: HashMap::new(),
bytes: 0,
container_path: None,
}
}
/// Set the `.beam` container path used to lazily load image bytes that aren't
/// resident in `asset.data` (Tier 1 paging). Cheap to call each frame.
pub fn set_container_path(&mut self, path: Option<std::path::PathBuf>) {
self.container_path = path;
}
/// Resolve an asset's compressed bytes: prefer the resident `asset.data` (imported
/// this session, or an old base64 project), else page from the container.
fn resolve_bytes<'a>(&self, asset: &'a ImageAsset) -> Option<std::borrow::Cow<'a, [u8]>> {
if let Some(d) = &asset.data {
return Some(std::borrow::Cow::Borrowed(d.as_slice()));
}
let path = self.container_path.as_ref()?;
crate::beam_archive::read_packed_media_readonly(path, asset.id)
.ok()
.flatten()
.map(std::borrow::Cow::Owned)
}
/// Mark `id` (size `size` bytes) as most-recently-used; evict LRU entries over budget.
fn touch(&mut self, id: Uuid, size: usize) {
if !self.sizes.contains_key(&id) {
self.sizes.insert(id, size);
self.bytes += size;
}
if let Some(pos) = self.lru.iter().position(|x| *x == id) {
self.lru.remove(pos);
}
self.lru.push(id);
// Keep at least the just-touched entry resident.
while self.bytes > Self::BUDGET && self.lru.len() > 1 {
let old = self.lru.remove(0);
self.cache.remove(&old);
self.cpu_cache.remove(&old);
if let Some(sz) = self.sizes.remove(&old) {
self.bytes -= sz;
}
} }
} }
/// Get or decode an image, caching the result /// Get or decode an image, caching the result
pub fn get_or_decode(&mut self, asset: &ImageAsset) -> Option<Arc<ImageBrush>> { pub fn get_or_decode(&mut self, asset: &ImageAsset) -> Option<Arc<ImageBrush>> {
if let Some(cached) = self.cache.get(&asset.id) { let size = (asset.width as usize) * (asset.height as usize) * 4;
return Some(Arc::clone(cached)); if let Some(cached) = self.cache.get(&asset.id).map(Arc::clone) {
self.touch(asset.id, size);
return Some(cached);
} }
// Decode and cache // Decode and cache (bytes from asset.data or paged from the container).
let image = decode_image_asset(asset)?; let bytes = self.resolve_bytes(asset)?;
let image = decode_image_brush(&bytes)?;
let arc_image = Arc::new(image); let arc_image = Arc::new(image);
self.cache.insert(asset.id, Arc::clone(&arc_image)); self.cache.insert(asset.id, Arc::clone(&arc_image));
self.touch(asset.id, size);
Some(arc_image) Some(arc_image)
} }
/// Get or decode an image as a premultiplied tiny-skia Pixmap (CPU render path). /// Get or decode an image as a premultiplied tiny-skia Pixmap (CPU render path).
pub fn get_or_decode_cpu(&mut self, asset: &ImageAsset) -> Option<Arc<tiny_skia::Pixmap>> { pub fn get_or_decode_cpu(&mut self, asset: &ImageAsset) -> Option<Arc<tiny_skia::Pixmap>> {
if let Some(cached) = self.cpu_cache.get(&asset.id) { let size = (asset.width as usize) * (asset.height as usize) * 4;
return Some(Arc::clone(cached)); if let Some(cached) = self.cpu_cache.get(&asset.id).map(Arc::clone) {
self.touch(asset.id, size);
return Some(cached);
} }
let pixmap = decode_image_to_pixmap(asset)?; let bytes = self.resolve_bytes(asset)?;
let pixmap = decode_image_to_pixmap(&bytes)?;
let arc = Arc::new(pixmap); let arc = Arc::new(pixmap);
self.cpu_cache.insert(asset.id, Arc::clone(&arc)); self.cpu_cache.insert(asset.id, Arc::clone(&arc));
self.touch(asset.id, size);
Some(arc) Some(arc)
} }
@ -66,12 +134,21 @@ impl ImageCache {
pub fn invalidate(&mut self, id: &Uuid) { pub fn invalidate(&mut self, id: &Uuid) {
self.cache.remove(id); self.cache.remove(id);
self.cpu_cache.remove(id); self.cpu_cache.remove(id);
if let Some(pos) = self.lru.iter().position(|x| x == id) {
self.lru.remove(pos);
}
if let Some(sz) = self.sizes.remove(id) {
self.bytes -= sz;
}
} }
/// Clear all cached images /// Clear all cached images
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.cache.clear(); self.cache.clear();
self.cpu_cache.clear(); self.cpu_cache.clear();
self.lru.clear();
self.sizes.clear();
self.bytes = 0;
} }
} }
@ -81,12 +158,34 @@ impl Default for ImageCache {
} }
} }
/// Decode an image asset to a premultiplied tiny-skia Pixmap (CPU render path). /// Image asset ids referenced by the visible vector layers' active keyframes at `time`
fn decode_image_to_pixmap(asset: &ImageAsset) -> Option<tiny_skia::Pixmap> { /// (top-level + group children). Used to prefetch/decode images ahead during playback.
let data = asset.data.as_ref()?; /// (Recursing into nested clip instances is a refinement.)
pub fn assets_needed_at(document: &Document, time: f64) -> Vec<Uuid> {
let mut ids = Vec::new();
for layer in document.all_layers() {
if let crate::layer::AnyLayer::Vector(vl) = layer {
if !vl.layer.visible {
continue;
}
if let Some(kf) = vl.keyframe_at(time) {
for fill in &kf.graph.fills {
if let Some(id) = fill.image_fill {
ids.push(id);
}
}
}
}
}
ids
}
/// Decode image bytes to a premultiplied tiny-skia Pixmap (CPU render path).
fn decode_image_to_pixmap(data: &[u8]) -> Option<tiny_skia::Pixmap> {
let img = image::load_from_memory(data).ok()?; let img = image::load_from_memory(data).ok()?;
let rgba = img.to_rgba8(); let rgba = img.to_rgba8();
let mut pixmap = tiny_skia::Pixmap::new(asset.width, asset.height)?; let (iw, ih) = rgba.dimensions();
let mut pixmap = tiny_skia::Pixmap::new(iw, ih)?;
for (dst, src) in pixmap.pixels_mut().iter_mut().zip(rgba.pixels()) { for (dst, src) in pixmap.pixels_mut().iter_mut().zip(rgba.pixels()) {
let [r, g, b, a] = src.0; let [r, g, b, a] = src.0;
// Convert straight alpha (image crate output) to premultiplied (tiny-skia internal format) // Convert straight alpha (image crate output) to premultiplied (tiny-skia internal format)
@ -100,21 +199,17 @@ fn decode_image_to_pixmap(asset: &ImageAsset) -> Option<tiny_skia::Pixmap> {
Some(pixmap) Some(pixmap)
} }
/// Decode an image asset to peniko ImageBrush /// Decode image bytes to a peniko ImageBrush (GPU render path).
fn decode_image_asset(asset: &ImageAsset) -> Option<ImageBrush> { fn decode_image_brush(data: &[u8]) -> Option<ImageBrush> {
// Get the raw file data
let data = asset.data.as_ref()?;
// Decode using the image crate
let img = image::load_from_memory(data).ok()?; let img = image::load_from_memory(data).ok()?;
let rgba = img.to_rgba8(); let rgba = img.to_rgba8();
let (iw, ih) = rgba.dimensions();
// Create peniko ImageData then ImageBrush
let image_data = ImageData { let image_data = ImageData {
data: Blob::from(rgba.into_raw()), data: Blob::from(rgba.into_raw()),
format: ImageFormat::Rgba8, format: ImageFormat::Rgba8,
width: asset.width, width: iw,
height: asset.height, height: ih,
alpha_type: ImageAlphaType::Alpha, alpha_type: ImageAlphaType::Alpha,
}; };
Some(ImageBrush::new(image_data)) Some(ImageBrush::new(image_data))
@ -1162,7 +1257,15 @@ pub fn render_vector_graph(
if let Some(image_asset) = document.get_image_asset(&image_asset_id) { if let Some(image_asset) = document.get_image_asset(&image_asset_id) {
if let Some(image) = image_cache.get_or_decode(image_asset) { if let Some(image) = image_cache.get_or_decode(image_asset) {
let image_with_alpha = (*image).clone().with_alpha(opacity_f32); let image_with_alpha = (*image).clone().with_alpha(opacity_f32);
scene.fill(fill_rule, base_transform, &image_with_alpha, None, &path); // Map the image (native pixel space, origin 0,0) onto the fill's
// bounding box, so it sits where the shape is and scales to fit
// (1:1 for an image-sized rectangle).
let bbox = vello::kurbo::Shape::bounding_box(&path);
let iw = (image_asset.width.max(1)) as f64;
let ih = (image_asset.height.max(1)) as f64;
let brush_transform = Affine::translate((bbox.x0, bbox.y0))
* Affine::scale_non_uniform(bbox.width() / iw, bbox.height() / ih);
scene.fill(fill_rule, base_transform, &image_with_alpha, Some(brush_transform), &path);
filled = true; filled = true;
} }
} }
@ -1253,7 +1356,12 @@ fn render_vector_layer(
// Cascade opacity: parent_opacity × layer.opacity // Cascade opacity: parent_opacity × layer.opacity
let layer_opacity = parent_opacity * layer.layer.opacity; let layer_opacity = parent_opacity * layer.layer.opacity;
// Render clip instances first (they appear under shape instances) // Render the layer's own VectorGraph (loose shapes) first, then clip instances
// (groups / movie clips) on top. Shape tweens are applied here.
if let Some(graph) = layer.tweened_graph_at(time) {
render_vector_graph(&graph, scene, base_transform, layer_opacity, document, image_cache);
}
for clip_instance in &layer.clip_instances { for clip_instance in &layer.clip_instances {
// For groups, compute the visibility end from keyframe data // For groups, compute the visibility end from keyframe data
let group_end_time = document.vector_clips.get(&clip_instance.clip_id) let group_end_time = document.vector_clips.get(&clip_instance.clip_id)
@ -1264,11 +1372,6 @@ fn render_vector_layer(
}); });
render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager, group_end_time); render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager, group_end_time);
} }
// Render VectorGraph from active keyframe
if let Some(graph) = layer.graph_at_time(time) {
render_vector_graph(graph, scene, base_transform, layer_opacity, document, image_cache);
}
} }
// ============================================================================ // ============================================================================
@ -1486,12 +1589,21 @@ fn render_vector_graph_cpu(
if let Some(image_asset_id) = fill.image_fill { if let Some(image_asset_id) = fill.image_fill {
if let Some(asset) = document.get_image_asset(&image_asset_id) { if let Some(asset) = document.get_image_asset(&image_asset_id) {
if let Some(img_pixmap) = image_cache.get_or_decode_cpu(asset) { if let Some(img_pixmap) = image_cache.get_or_decode_cpu(asset) {
// Map the image's native pixel space onto the fill's bounding box.
let bbox: kurbo::Rect = vello::kurbo::Shape::bounding_box(&path);
let iw = (asset.width.max(1)) as f32;
let ih = (asset.height.max(1)) as f32;
let sx = (bbox.width() as f32) / iw;
let sy = (bbox.height() as f32) / ih;
let pat_tf = tiny_skia::Transform::from_row(
sx, 0.0, 0.0, sy, bbox.x0 as f32, bbox.y0 as f32,
);
let pattern = tiny_skia::Pattern::new( let pattern = tiny_skia::Pattern::new(
tiny_skia::Pixmap::as_ref(&img_pixmap), tiny_skia::Pixmap::as_ref(&img_pixmap),
tiny_skia::SpreadMode::Pad, tiny_skia::SpreadMode::Pad,
tiny_skia::FilterQuality::Bilinear, tiny_skia::FilterQuality::Bilinear,
opacity, opacity,
tiny_skia::Transform::identity(), pat_tf,
); );
let mut paint = tiny_skia::Paint::default(); let mut paint = tiny_skia::Paint::default();
paint.shader = pattern; paint.shader = pattern;
@ -1555,6 +1667,11 @@ fn render_vector_layer_cpu(
) { ) {
let layer_opacity = parent_opacity * layer.layer.opacity; let layer_opacity = parent_opacity * layer.layer.opacity;
// Loose shapes first, then clip instances (groups / movie clips) on top.
if let Some(graph) = layer.tweened_graph_at(time) {
render_vector_graph_cpu(&graph, pixmap, affine_to_ts(base_transform), layer_opacity as f32, document, image_cache);
}
for clip_instance in &layer.clip_instances { for clip_instance in &layer.clip_instances {
let group_end_time = document.vector_clips.get(&clip_instance.clip_id) let group_end_time = document.vector_clips.get(&clip_instance.clip_id)
.filter(|vc| vc.is_group) .filter(|vc| vc.is_group)
@ -1567,10 +1684,6 @@ fn render_vector_layer_cpu(
&layer.layer.animation_data, image_cache, group_end_time, &layer.layer.animation_data, image_cache, group_end_time,
); );
} }
if let Some(graph) = layer.graph_at_time(time) {
render_vector_graph_cpu(graph, pixmap, affine_to_ts(base_transform), layer_opacity as f32, document, image_cache);
}
} }
/// Render a clip instance (and its nested layers) to a CPU pixmap. /// Render a clip instance (and its nested layers) to a CPU pixmap.

View File

@ -4,9 +4,8 @@
use crate::vector_graph::{VectorGraph, EdgeId, FillId, VertexId}; use crate::vector_graph::{VectorGraph, EdgeId, FillId, VertexId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet}; use std::collections::HashSet;
use uuid::Uuid; use uuid::Uuid;
use vello::kurbo::{Affine, BezPath};
/// Shape of a raster pixel selection, in canvas pixel coordinates. /// Shape of a raster pixel selection, in canvas pixel coordinates.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -440,42 +439,6 @@ impl Selection {
} }
} }
/// Represents a temporary region-based selection.
///
/// When a region select is active, the region boundary is inserted into the
/// DCEL as invisible edges, splitting existing geometry. Faces inside the
/// region are added to the normal `Selection`. If the user performs an
/// operation, the selection is committed; if they deselect, the DCEL is
/// restored from the snapshot.
#[derive(Clone, Debug)]
pub struct RegionSelection {
/// The clipping region as a closed BezPath (polygon or rect)
pub region_path: BezPath,
/// Layer containing the affected elements
pub layer_id: Uuid,
/// Keyframe time
pub time: f64,
/// Snapshot of the graph before region boundary insertion, for revert
pub graph_snapshot: VectorGraph,
/// The extracted graph containing geometry inside the region
pub selected_graph: VectorGraph,
/// Transform applied to the selected graph (e.g. from dragging)
pub transform: Affine,
/// Whether the selection has been committed (via an operation on the selection)
pub committed: bool,
/// IDs of the invisible edges inserted for the region boundary stroke.
/// These exist in the main graph (remainder side). Deleted during merge-back.
pub region_edge_ids: Vec<EdgeId>,
/// Action epoch recorded when this selection was created.
/// Compared against `ActionExecutor::epoch()` on deselect to decide
/// whether merge-back is needed or a clean snapshot restore suffices.
pub action_epoch_at_selection: u64,
/// selected_graph VID → main graph VID for boundary vertices (shared between both graphs).
pub boundary_vertex_map: HashMap<VertexId, VertexId>,
/// selected_graph boundary EID → main graph boundary EID (duplicated edges to skip on merge).
pub boundary_edge_map: HashMap<EdgeId, EdgeId>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -417,6 +417,152 @@ impl VectorGraph {
self.boundary_to_bezpath(&fill.boundary) self.boundary_to_bezpath(&fill.boundary)
} }
/// Interpolate toward `other` by `t` ∈ [0,1] for a same-topology shape tween.
///
/// Returns `None` if the two graphs don't share identical topology — same vertex,
/// edge and fill structure (counts, deleted flags, edge endpoints, fill boundaries).
/// In that case the caller should hold the source keyframe instead of morphing.
/// Vertex positions, edge curves, stroke widths and stroke/fill colours are lerped.
pub fn interpolated(&self, other: &VectorGraph, t: f64) -> Option<VectorGraph> {
if self.vertices.len() != other.vertices.len()
|| self.edges.len() != other.edges.len()
|| self.fills.len() != other.fills.len()
{
return None;
}
for (a, b) in self.vertices.iter().zip(&other.vertices) {
if a.deleted != b.deleted {
return None;
}
}
for (a, b) in self.edges.iter().zip(&other.edges) {
if a.deleted != b.deleted || a.vertices != b.vertices {
return None;
}
}
for (a, b) in self.fills.iter().zip(&other.fills) {
if a.deleted != b.deleted || a.boundary != b.boundary {
return None;
}
}
let lf = |x: f64, y: f64| x + (y - x) * t;
let lp = |p: Point, q: Point| Point::new(lf(p.x, q.x), lf(p.y, q.y));
let lc = |a: Option<ShapeColor>, b: Option<ShapeColor>| match (a, b) {
(Some(a), Some(b)) => {
let c = |x: u8, y: u8| (lf(x as f64, y as f64)).round().clamp(0.0, 255.0) as u8;
Some(ShapeColor::new(c(a.r, b.r), c(a.g, b.g), c(a.b, b.b), c(a.a, b.a)))
}
(a, _) => a,
};
let mut g = self.clone();
for (i, v) in g.vertices.iter_mut().enumerate() {
v.position = lp(self.vertices[i].position, other.vertices[i].position);
}
for (i, e) in g.edges.iter_mut().enumerate() {
let (a, b) = (self.edges[i].curve, other.edges[i].curve);
e.curve = CubicBez::new(lp(a.p0, b.p0), lp(a.p1, b.p1), lp(a.p2, b.p2), lp(a.p3, b.p3));
if let (Some(s), Some(sa), Some(sb)) = (
e.stroke_style.as_mut(),
self.edges[i].stroke_style.as_ref(),
other.edges[i].stroke_style.as_ref(),
) {
s.width = lf(sa.width, sb.width);
}
e.stroke_color = lc(self.edges[i].stroke_color, other.edges[i].stroke_color);
}
for (i, f) in g.fills.iter_mut().enumerate() {
f.color = lc(self.fills[i].color, other.fills[i].color);
}
Some(g)
}
/// A point guaranteed to lie inside the fill — for point-in-region classification
/// (e.g. deciding whether a fill is inside a lasso). Prefers the polygon area-centroid,
/// but for a non-convex fill (e.g. an L-shape, where the area-centroid can fall in the
/// concavity *outside* the shape) it steps just inward from a boundary edge instead.
/// The naive average of boundary-edge midpoints is NOT reliable here — it can land
/// outside a non-convex fill and misclassify it.
pub fn fill_interior_point(&self, fill_id: FillId) -> Point {
let boundary = self.fills[fill_id.idx()].boundary.clone();
self.boundary_interior_point(&boundary)
}
/// A point guaranteed to lie inside the region enclosed by a `(edge, direction)`
/// boundary loop. See [`fill_interior_point`].
pub fn boundary_interior_point(&self, boundary: &[(EdgeId, Direction)]) -> Point {
use kurbo::{ParamCurve, Shape, Vec2};
let path = self.boundary_to_bezpath(boundary);
// Ordered polygon corners: the directed start point of each boundary edge.
let mut pts: Vec<Point> = Vec::new();
for &(eid, dir) in boundary {
if eid.is_none() {
continue;
}
let c = self.edges[eid.idx()].curve;
pts.push(match dir {
Direction::Forward => c.p0,
Direction::Backward => c.p3,
});
}
if pts.len() < 3 {
if pts.is_empty() {
return Point::ZERO;
}
let (sx, sy) = pts.iter().fold((0.0, 0.0), |(x, y), p| (x + p.x, y + p.y));
return Point::new(sx / pts.len() as f64, sy / pts.len() as f64);
}
// Shoelace area-centroid.
let (mut a2, mut cx, mut cy) = (0.0, 0.0, 0.0);
for i in 0..pts.len() {
let p0 = pts[i];
let p1 = pts[(i + 1) % pts.len()];
let cross = p0.x * p1.y - p1.x * p0.y;
a2 += cross;
cx += (p0.x + p1.x) * cross;
cy += (p0.y + p1.y) * cross;
}
if a2.abs() > 1e-9 {
let c = Point::new(cx / (3.0 * a2), cy / (3.0 * a2));
if path.winding(c) != 0 {
return c;
}
}
// Fallback: step a small distance inward from a boundary edge midpoint.
let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in &pts {
minx = minx.min(p.x);
miny = miny.min(p.y);
maxx = maxx.max(p.x);
maxy = maxy.max(p.y);
}
let eps = ((maxx - minx).min(maxy - miny) * 1e-3).max(1e-4);
for &(eid, _) in boundary {
if eid.is_none() {
continue;
}
let c = self.edges[eid.idx()].curve;
let mid = c.eval(0.5);
let tangent = c.eval(0.5001) - c.eval(0.4999);
let len = tangent.hypot();
if len < 1e-12 {
continue;
}
let n = Vec2::new(-tangent.y / len, tangent.x / len);
for s in [1.0_f64, -1.0] {
let cand = mid + n * (s * eps);
if path.winding(cand) != 0 {
return cand;
}
}
}
pts[0]
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Vertex editing // Vertex editing
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@ -653,6 +799,10 @@ impl VectorGraph {
} }
let mut all_new_edges = Vec::new(); let mut all_new_edges = Vec::new();
// Sub-edges produced when a stroke segment splits an existing edge (incl. an earlier
// segment of this same stroke). Tracked separately so they reach the fill re-tracer
// without polluting the returned edge list.
let mut split_products: Vec<EdgeId> = Vec::new();
let mut prev_end_vertex: Option<VertexId> = None; let mut prev_end_vertex: Option<VertexId> = None;
for (seg_idx, seg) in expanded_segments.iter().enumerate() { for (seg_idx, seg) in expanded_segments.iter().enumerate() {
@ -706,7 +856,14 @@ impl VectorGraph {
let remapped_t = original_edge_t / head_end; let remapped_t = original_edge_t / head_end;
let remapped_t = remapped_t.clamp(ENDPOINT_T_MARGIN, 1.0 - ENDPOINT_T_MARGIN); let remapped_t = remapped_t.clamp(ENDPOINT_T_MARGIN, 1.0 - ENDPOINT_T_MARGIN);
let (mid_v, _sub_a, _sub_b) = self.split_edge(eid, remapped_t); let (mid_v, sub_a, sub_b) = self.split_edge(eid, remapped_t);
// Track the split products so the fill re-tracer sees them: when a later
// stroke segment crosses an earlier one, these sub-edges are part of the
// stroke's arrangement but would otherwise be invisible to the re-trace.
// (Kept out of `all_new_edges` so the returned edge list stays the stroke's
// own edges only.)
split_products.push(sub_a);
split_products.push(sub_b);
// Snap vertex to intersection point // Snap vertex to intersection point
self.vertices[mid_v.idx()].position = point; self.vertices[mid_v.idx()].position = point;
// Merge with nearby existing vertex if within snap distance // Merge with nearby existing vertex if within snap distance
@ -791,127 +948,511 @@ impl VectorGraph {
prev_end_vertex = Some(end_v); prev_end_vertex = Some(end_v);
} }
// Fill splitting pass: for each new edge, check if both endpoints // Weld dangling stroke endpoints onto a near-coincident existing vertex. A
// lie on any fill's boundary — if so, split that fill. // self-intersecting freehand stroke can create an intersection vertex a fraction of
let edges_to_check = all_new_edges.clone(); // a pixel away from a segment endpoint that should be the same point; if they don't
for &eid in &edges_to_check { // merge, the stroke's loop is broken by a degree-1 stub and the cut is lost.
let v0 = self.edges[eid.idx()].vertices[0]; self.weld_dangling_endpoints(&all_new_edges, snap_epsilon.max(1.0));
let v1 = self.edges[eid.idx()].vertices[1];
// Find fills where both v0 and v1 appear as boundary vertices // Coincident-edge cleanup: a new edge that lands exactly on an existing edge
let fill_ids: Vec<FillId> = self.fills // between the same two vertices (e.g. drawing a shape whose edge snaps onto an
.iter() // existing one) must not be duplicated — duplicates produce zero-area "sliver"
.enumerate() // fills. Merge such duplicates before splitting/filling.
.filter(|(_, f)| !f.deleted) self.dedupe_coincident_new_edges(&all_new_edges);
.filter(|(_, f)| {
let has_v0 = f.boundary.iter().any(|&(be, _)| {
let e = &self.edges[be.idx()];
e.vertices[0] == v0 || e.vertices[1] == v0
});
let has_v1 = f.boundary.iter().any(|&(be, _)| {
let e = &self.edges[be.idx()];
e.vertices[0] == v1 || e.vertices[1] == v1
});
has_v0 && has_v1
})
.map(|(i, _)| FillId(i as u32))
.collect();
for fid in fill_ids { // Re-derive the fills touched by the new edges. Rather than incrementally splitting
self.split_fill_by_edge(fid, eid); // along single cut edges (which can't handle a lasso whose path is interrupted by a
} // hole/notch in a non-convex fill), we re-trace the planar faces of the affected
} // sub-arrangement and rebuild the fills from them. This is robust for arbitrary
// holed/concave fills (e.g. cutting across geometry left behind by a prior group).
// Include split products so a self-crossing stroke's full arrangement is seen.
let mut retrace_edges = all_new_edges.clone();
retrace_edges.extend(split_products);
self.retrace_fills_after_cut(&retrace_edges);
// Drop any zero-area fills (e.g. slivers left between coincident edges).
self.remove_degenerate_fills();
all_new_edges all_new_edges
} }
/// When a new edge splits a fill (both endpoints on the fill's boundary), /// Merge edges from the latest stroke that are geometrically coincident with an
/// split the fill into two fills. /// existing edge between the same two vertices (drawing a shape whose edge lands exactly
pub fn split_fill_by_edge( /// on an existing edge). Keeps the existing edge, redirects fill references, and frees
&mut self, /// the duplicate — preventing zero-area "sliver" fills between the two copies.
fill_id: FillId, /// Weld degree-1 endpoints of freshly inserted edges onto a near-coincident existing
splitting_edge: EdgeId, /// vertex. A self-intersecting freehand stroke can create an intersection vertex a
) -> Option<(FillId, FillId)> { /// fraction of a pixel from a segment endpoint that should be the same point; if they
let fill = &self.fills[fill_id.idx()]; /// don't merge, the stroke's loop is broken by a degree-1 stub and the cut is lost.
if fill.deleted { fn weld_dangling_endpoints(&mut self, new_edges: &[EdgeId], eps: f64) {
return None; // Repeatedly weld the closest dangling new endpoint to its nearest neighbour.
loop {
let mut to_merge: Option<(VertexId, VertexId)> = None; // (keep, merge)
'scan: for &e in new_edges {
if self.edges[e.idx()].deleted {
continue;
}
for &v in &self.edges[e.idx()].vertices {
if self.vertices[v.idx()].deleted || self.edges_at_vertex(v).len() != 1 {
continue;
}
let pv = self.vertices[v.idx()].position;
let mut best: Option<(f64, VertexId)> = None;
for ui in 0..self.vertices.len() {
if ui == v.idx() || self.vertices[ui].deleted {
continue;
}
let pu = self.vertices[ui].position;
let d = (pu.x - pv.x).hypot(pu.y - pv.y);
if d < eps && best.map_or(true, |(bd, _)| d < bd) {
best = Some((d, VertexId(ui as u32)));
}
}
if let Some((_, keep)) = best {
to_merge = Some((keep, v));
break 'scan;
}
}
}
match to_merge {
Some((keep, merge)) => self.merge_vertices(keep, merge),
None => break,
}
}
// Drop any edges that collapsed to zero length (both endpoints welded together).
let degenerate: Vec<EdgeId> = self
.edges
.iter()
.enumerate()
.filter(|(_, e)| !e.deleted && e.vertices[0] == e.vertices[1])
.map(|(i, _)| EdgeId(i as u32))
.collect();
for e in degenerate {
for fill in &mut self.fills {
if !fill.deleted {
fill.boundary.retain(|&(fe, _)| fe != e);
}
}
self.free_edge(e);
}
}
fn dedupe_coincident_new_edges(&mut self, new_edges: &[EdgeId]) {
for &ne in new_edges {
if self.edges[ne.idx()].deleted {
continue;
}
let va = self.edges[ne.idx()].vertices[0];
let vb = self.edges[ne.idx()].vertices[1];
let candidates: Vec<EdgeId> = self
.edges_at_vertex(va)
.into_iter()
.filter(|&e| e != ne && !self.edges[e.idx()].deleted)
.filter(|&e| {
let v = self.edges[e.idx()].vertices;
(v[0] == va && v[1] == vb) || (v[0] == vb && v[1] == va)
})
.collect();
for c in candidates {
if self.edges[c.idx()].deleted {
continue;
}
if self.curves_coincident(ne, c) {
self.redirect_edge_in_fills(ne, c);
self.free_edge(ne);
break;
}
}
}
}
/// Whether two edges (already known to share both endpoints) trace the same path.
/// Coincident duplicates in practice are straight collinear segments (a shape edge
/// snapping onto an existing edge), so we treat "both are straight lines between the
/// same endpoints" as coincident. Comparing curve `eval(t)` directly is unreliable —
/// split sub-edges are non-uniformly parameterised, so equal `t` ≠ equal point.
fn curves_coincident(&self, a: EdgeId, b: EdgeId) -> bool {
let is_straight = |c: kurbo::CubicBez| {
let chord = c.p3 - c.p0;
let len = chord.hypot();
if len < 1e-9 {
return true; // zero-length chord — degenerate, treat as coincident
}
// Perpendicular distance of each control point from the p0→p3 chord.
let dist = |p: Point| ((p - c.p0).cross(chord)).abs() / len;
dist(c.p1) < 1e-2 && dist(c.p2) < 1e-2
};
is_straight(self.edges[a.idx()].curve) && is_straight(self.edges[b.idx()].curve)
}
/// Replace every `from` boundary reference with `to`, preserving traversal direction.
fn redirect_edge_in_fills(&mut self, from: EdgeId, to: EdgeId) {
let f = self.edges[from.idx()].vertices;
let t = self.edges[to.idx()].vertices;
let same_dir = t[0] == f[0] && t[1] == f[1];
for fill in &mut self.fills {
if fill.deleted {
continue;
}
for entry in &mut fill.boundary {
if entry.0 == from {
entry.1 = match (entry.1, same_dir) {
(Direction::Forward, true) | (Direction::Backward, false) => {
Direction::Forward
}
(Direction::Backward, true) | (Direction::Forward, false) => {
Direction::Backward
}
};
entry.0 = to;
}
}
}
}
/// Drop fills that enclose ~zero area (degenerate slivers from coincident edges).
fn remove_degenerate_fills(&mut self) {
for i in 0..self.fills.len() {
if self.fills[i].deleted {
continue;
}
let mut pts: Vec<Point> = Vec::new();
for &(eid, dir) in &self.fills[i].boundary {
if eid.is_none() {
continue;
}
let c = self.edges[eid.idx()].curve;
pts.push(match dir {
Direction::Forward => c.p0,
Direction::Backward => c.p3,
});
}
let area = if pts.len() < 3 {
0.0
} else {
let mut a2 = 0.0;
for k in 0..pts.len() {
let p0 = pts[k];
let p1 = pts[(k + 1) % pts.len()];
a2 += p0.x * p1.y - p1.x * p0.y;
}
(a2 * 0.5).abs()
};
if area < 1e-6 {
self.fills[i].deleted = true;
self.free_fills.push(i as u32);
}
}
}
/// Directed end vertex of a `(edge, direction)` boundary entry.
#[inline]
fn entry_end_vertex(&self, eid: EdgeId, dir: Direction) -> VertexId {
match dir {
Direction::Forward => self.edges[eid.idx()].vertices[1],
Direction::Backward => self.edges[eid.idx()].vertices[0],
}
}
/// Re-derive the fills touched by a freshly inserted stroke by re-tracing the planar
/// faces of the affected sub-arrangement. This replaces incremental "split a fill by a
/// cut edge" logic, which can't handle a cut whose path is interrupted by a hole/notch
/// in a non-convex fill. Each affected fill is deleted and rebuilt from the traced
/// faces that lie inside it (inheriting its colour/rule); faces outside it — or in a
/// hole — are dropped.
fn retrace_fills_after_cut(&mut self, new_edges: &[EdgeId]) {
use kurbo::Shape;
let new_set: HashSet<EdgeId> = new_edges
.iter()
.filter(|&&e| !e.is_none() && !self.edges[e.idx()].deleted)
.copied()
.collect();
if new_set.is_empty() {
return;
}
let new_verts: HashSet<VertexId> =
new_set.iter().flat_map(|&e| self.edges[e.idx()].vertices).collect();
// Affected fills: any non-deleted fill that shares a vertex with a new edge.
let affected: Vec<FillId> = (0..self.fills.len())
.filter(|&i| !self.fills[i].deleted)
.filter(|&i| {
self.fills[i].boundary.iter().any(|&(e, _)| {
!e.is_none()
&& self.edges[e.idx()].vertices.iter().any(|v| new_verts.contains(v))
})
})
.map(|i| FillId(i as u32))
.collect();
if affected.is_empty() {
return;
} }
let split_v0 = self.edges[splitting_edge.idx()].vertices[0]; // Snapshot each affected fill's path + attributes before we delete them.
let split_v1 = self.edges[splitting_edge.idx()].vertices[1]; let originals: Vec<(kurbo::BezPath, Option<ShapeColor>, FillRule)> = affected
.iter()
.map(|&f| {
(
self.fill_to_bezpath(f),
self.fills[f.idx()].color,
self.fills[f.idx()].fill_rule,
)
})
.collect();
// Find the positions in the boundary where the splitting edge's // Edge set for the local arrangement: every affected fill's boundary edges plus the
// endpoint vertices appear as the "arrival" vertex of a directed edge. // ENTIRE inserted stroke. We include the whole stroke (not just the segments whose
let boundary = fill.boundary.clone(); // midpoint is inside a fill) because a wiggly freehand lasso has segments that dip
// just outside the fill; excluding them would break the inside-arc chain into
// dangling fragments that then get pruned away, losing the cut entirely. The stroke
// forms closed loops, so it contributes no dangling edges; faces that end up outside
// every affected fill are discarded by the classification below.
let mut edge_set: HashSet<EdgeId> = HashSet::new();
for &f in &affected {
for &(e, _) in &self.fills[f.idx()].boundary {
if !e.is_none() && !self.edges[e.idx()].deleted {
edge_set.insert(e);
}
}
}
edge_set.extend(new_set.iter().copied());
// Helper: get the "end" vertex of a directed boundary edge // Expand to the induced subgraph on the covered vertices. A self-intersecting
let end_vertex = |eid: EdgeId, dir: Direction| -> VertexId { // freehand stroke splits its own edges via `split_edge`, whose sub-edges aren't in
match dir { // `new_edges`; without them the local arrangement has gaps and the stroke's loop
Direction::Forward => self.edges[eid.idx()].vertices[1], // looks like dangling fragments. Adding every edge whose endpoints are both already
Direction::Backward => self.edges[eid.idx()].vertices[0], // covered closes those gaps (the sub-edges connect already-covered stroke vertices).
loop {
let verts: HashSet<VertexId> = edge_set
.iter()
.flat_map(|&e| self.edges[e.idx()].vertices)
.collect();
let added: Vec<EdgeId> = (0..self.edges.len())
.map(|i| EdgeId(i as u32))
.filter(|&e| !self.edges[e.idx()].deleted && !edge_set.contains(&e))
.filter(|&e| {
let [a, b] = self.edges[e.idx()].vertices;
verts.contains(&a) && verts.contains(&b)
})
.collect();
if added.is_empty() {
break;
}
edge_set.extend(added);
}
// Prune dangling edges (a vertex with degree < 2 in the local arrangement). They
// form spikes, never real face boundaries — a freehand lasso that wiggles or nearly
// self-touches leaves such stubs, and tracing them produces a face that runs out and
// back along the same edge (a degenerate self-touching boundary). Iterate, since
// removing one stub can expose another.
loop {
let mut degree: HashMap<VertexId, usize> = HashMap::new();
for &e in &edge_set {
for &v in &self.edges[e.idx()].vertices {
*degree.entry(v).or_default() += 1;
}
}
let dangling: Vec<EdgeId> = edge_set
.iter()
.copied()
.filter(|&e| {
let [a, b] = self.edges[e.idx()].vertices;
a == b || degree[&a] < 2 || degree[&b] < 2
})
.collect();
if dangling.is_empty() {
break;
}
for e in dangling {
edge_set.remove(&e);
}
}
let faces = self.trace_faces(&edge_set);
// Replace the affected fills with the re-traced bounded faces that fall inside them.
for &f in &affected {
self.fills[f.idx()].deleted = true;
self.free_fills.push(f.0);
}
for mut face in faces {
// Collapse degenerate "spikes" — a sequence that runs out to a point and back
// (e.g. across near-coincident duplicate tiny edges from a dense freehand path).
self.collapse_boundary_spikes(&mut face);
if face.len() < 3 {
continue;
}
// Only bounded (counter-clockwise, positive-area) faces are real regions; the
// outer face is clockwise/negative.
if self.face_signed_area(&face) <= 1e-6 {
continue;
}
let sample = self.boundary_interior_point(&face);
if let Some((_, color, rule)) =
originals.iter().find(|(p, _, _)| p.winding(sample) != 0)
{
self.alloc_fill(face, *color, *rule);
}
}
}
/// Remove out-and-back "spikes" from a face boundary: consecutive entries where the
/// second exactly reverses the first (the boundary returns to where it started, e.g.
/// bouncing across near-coincident duplicate edges). These are zero-area and would make
/// `boundary_to_bezpath` render a stray hair; collapsing them yields a simple loop.
fn collapse_boundary_spikes(&self, face: &mut Vec<(EdgeId, Direction)>) {
let dstart = |entry: &(EdgeId, Direction)| -> Point {
let c = self.edges[entry.0.idx()].curve;
match entry.1 {
Direction::Forward => c.p0,
Direction::Backward => c.p3,
} }
}; };
let dend = |entry: &(EdgeId, Direction)| -> Point {
// Find positions where boundary edges arrive at split_v0 and split_v1 let c = self.edges[entry.0.idx()].curve;
let mut pos_v0: Option<usize> = None; match entry.1 {
let mut pos_v1: Option<usize> = None; Direction::Forward => c.p3,
Direction::Backward => c.p0,
for (i, &(eid, dir)) in boundary.iter().enumerate() {
let ev = end_vertex(eid, dir);
if ev == split_v0 && pos_v0.is_none() {
pos_v0 = Some(i);
} }
if ev == split_v1 && pos_v1.is_none() { };
pos_v1 = Some(i); const EPS: f64 = 0.5;
}
}
let pos_v0 = pos_v0?;
let pos_v1 = pos_v1?;
// Ensure we have two distinct positions
if pos_v0 == pos_v1 {
return None;
}
// Walk boundary in two halves:
// Half A: from pos_v0+1 to pos_v1 (inclusive), then splitting_edge Forward
// Half B: from pos_v1+1 to pos_v0 (wrapping), then splitting_edge Backward
let n = boundary.len();
let color = fill.color;
let fill_rule = fill.fill_rule;
let mut half_a = Vec::new();
let mut idx = (pos_v0 + 1) % n;
loop { loop {
half_a.push(boundary[idx]); let n = face.len();
if idx == pos_v1 { if n < 2 {
break; break;
} }
idx = (idx + 1) % n; let mut collapsed = false;
} for i in 0..n {
half_a.push((splitting_edge, Direction::Forward)); let j = (i + 1) % n;
// entries i and j cancel when j ends back at i's start.
let mut half_b = Vec::new(); let si = dstart(&face[i]);
idx = (pos_v1 + 1) % n; let ej = dend(&face[j]);
loop { if (si.x - ej.x).hypot(si.y - ej.y) < EPS {
half_b.push(boundary[idx]); let (hi, lo) = if i > j { (i, j) } else { (j, i) };
if idx == pos_v0 { face.remove(hi);
face.remove(lo);
collapsed = true;
break;
}
}
if !collapsed {
break; break;
} }
idx = (idx + 1) % n;
} }
half_b.push((splitting_edge, Direction::Backward)); }
// Delete the original fill /// Trace all faces of the planar arrangement formed by `edge_set`, using the standard
self.fills[fill_id.idx()].deleted = true; /// angular next-edge rule (turn to the clockwise-adjacent dart of the twin at each
self.free_fills.push(fill_id.0); /// vertex). Returns each face as an ordered `(edge, direction)` loop. Bounded faces
/// come out counter-clockwise (positive signed area); the outer face clockwise.
fn trace_faces(&self, edge_set: &HashSet<EdgeId>) -> Vec<Vec<(EdgeId, Direction)>> {
// Outgoing darts per vertex, sorted by outgoing angle (CCW).
let mut out: HashMap<VertexId, Vec<(f64, (EdgeId, Direction))>> = HashMap::new();
for &e in edge_set {
if self.edges[e.idx()].deleted {
continue;
}
let [a, b] = self.edges[e.idx()].vertices;
out.entry(a)
.or_default()
.push((self.dart_angle(e, Direction::Forward), (e, Direction::Forward)));
out.entry(b)
.or_default()
.push((self.dart_angle(e, Direction::Backward), (e, Direction::Backward)));
}
for darts in out.values_mut() {
darts.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal));
}
// Create two new fills let mut visited: HashSet<(EdgeId, Direction)> = HashSet::new();
let fill_a = self.alloc_fill(half_a, color, fill_rule); let mut faces: Vec<Vec<(EdgeId, Direction)>> = Vec::new();
let fill_b = self.alloc_fill(half_b, color, fill_rule); let cap = edge_set.len() * 2 + 4;
for &e in edge_set {
if self.edges[e.idx()].deleted {
continue;
}
for dir in [Direction::Forward, Direction::Backward] {
let start = (e, dir);
if visited.contains(&start) {
continue;
}
let mut face: Vec<(EdgeId, Direction)> = Vec::new();
let mut d = start;
loop {
visited.insert(d);
face.push(d);
// Next dart: at the end vertex, the dart clockwise-adjacent to the twin.
let end_v = self.entry_end_vertex(d.0, d.1);
let twin = (
d.0,
match d.1 {
Direction::Forward => Direction::Backward,
Direction::Backward => Direction::Forward,
},
);
let darts = match out.get(&end_v) {
Some(d) => d,
None => break,
};
let Some(idx) = darts.iter().position(|&(_, dd)| dd == twin) else {
break;
};
let next = darts[(idx + darts.len() - 1) % darts.len()].1;
if next == start {
break;
}
if visited.contains(&next) || face.len() > cap {
break;
}
d = next;
}
if face.len() >= 3 {
faces.push(face);
}
}
}
faces
}
Some((fill_a, fill_b)) /// Outgoing direction angle of a dart at its start vertex.
fn dart_angle(&self, e: EdgeId, dir: Direction) -> f64 {
let c = self.edges[e.idx()].curve;
let (base, cands) = match dir {
Direction::Forward => (c.p0, [c.p1, c.p2, c.p3]),
Direction::Backward => (c.p3, [c.p2, c.p1, c.p0]),
};
for cand in cands {
let d = cand - base;
if d.hypot() > 1e-9 {
return d.y.atan2(d.x);
}
}
0.0
}
/// Signed area of a face given as an ordered `(edge, direction)` loop (CCW positive).
fn face_signed_area(&self, face: &[(EdgeId, Direction)]) -> f64 {
let pts: Vec<Point> = face
.iter()
.map(|&(e, dir)| {
let c = self.edges[e.idx()].curve;
match dir {
Direction::Forward => c.p0,
Direction::Backward => c.p3,
}
})
.collect();
if pts.len() < 3 {
return 0.0;
}
let mut a2 = 0.0;
for i in 0..pts.len() {
let p0 = pts[i];
let p1 = pts[(i + 1) % pts.len()];
a2 += p0.x * p1.y - p1.x * p0.y;
}
a2 * 0.5
} }
/// Merge two fills that share a boundary edge (e.g., after edge deletion). /// Merge two fills that share a boundary edge (e.g., after edge deletion).
@ -1101,21 +1642,6 @@ impl VectorGraph {
// Boundary tracing internals // Boundary tracing internals
// ------------------------------------------------------------------- // -------------------------------------------------------------------
/// Find the nearest non-deleted edge to a point. Returns (EdgeId, t, distance).
fn nearest_edge_to_point(&self, point: Point) -> Option<(EdgeId, f64, f64)> {
let mut best: Option<(EdgeId, f64, f64)> = None;
for (i, e) in self.edges.iter().enumerate() {
if e.deleted {
continue;
}
let eid = EdgeId(i as u32);
let (t, dist) = nearest_point_on_cubic(&e.curve, point);
if best.is_none() || dist < best.unwrap().2 {
best = Some((eid, t, dist));
}
}
best
}
/// Build a BezPath from a boundary (without storing it as a fill). /// Build a BezPath from a boundary (without storing it as a fill).
/// Handles `EdgeId::NONE` separators to start new contours (holes). /// Handles `EdgeId::NONE` separators to start new contours (holes).
@ -1458,11 +1984,14 @@ impl VectorGraph {
// ── Region selection: extract / merge subgraph ────────────────────── // ── Region selection: extract / merge subgraph ──────────────────────
/// Extract a subgraph containing `inside_edges` and `inside_fills`. /// Extract a subgraph containing `inside_edges` and `inside_fills` (typically a
/// geometry selection — `select_fill` already includes each fill's boundary edges).
/// ///
/// Boundary edges (`boundary_edge_ids`) are **duplicated** — they exist in /// **Boundary edges** are *duplicated* (copied into the returned graph but kept in
/// both the returned graph and `self`, so both sides have closed fill /// `self`, so remaining shapes keep closed boundaries). They are `explicit_boundary`
/// boundaries when the selection is moved. /// (a cut the caller knows about, e.g. a lasso region — pass an empty set if none)
/// UNION any inside edge still shared with a non-extracted fill (derived here, so a
/// plain geometry selection needs no boundary analysis from the caller).
/// ///
/// Returns `(new_graph, vertex_map, edge_map)` where the maps go from /// Returns `(new_graph, vertex_map, edge_map)` where the maps go from
/// old (self) IDs to new (returned graph) IDs. /// old (self) IDs to new (returned graph) IDs.
@ -1470,17 +1999,58 @@ impl VectorGraph {
&mut self, &mut self,
inside_edges: &HashSet<EdgeId>, inside_edges: &HashSet<EdgeId>,
inside_fills: &HashSet<FillId>, inside_fills: &HashSet<FillId>,
boundary_edge_ids: &HashSet<EdgeId>, explicit_boundary: &HashSet<EdgeId>,
) -> (VectorGraph, HashMap<VertexId, VertexId>, HashMap<EdgeId, EdgeId>) { ) -> (VectorGraph, HashMap<VertexId, VertexId>, HashMap<EdgeId, EdgeId>) {
let mut new_graph = VectorGraph::new(); let mut new_graph = VectorGraph::new();
let mut vtx_map: HashMap<VertexId, VertexId> = HashMap::new(); let mut vtx_map: HashMap<VertexId, VertexId> = HashMap::new();
let mut edge_map: HashMap<EdgeId, EdgeId> = HashMap::new(); let mut edge_map: HashMap<EdgeId, EdgeId> = HashMap::new();
// Collect all edge IDs we need to copy into the new graph // Augment the inside set with every boundary edge of an extracted fill. A
let edges_to_copy: HashSet<EdgeId> = inside_edges // selection might not enumerate them all (e.g. lasso/region selection populates
.union(boundary_edge_ids) // edges differently than `select_fill`); without this an extracted fill would be
.copied() // copied with `EdgeId::NONE` standing in for a missing edge, and that NONE later
.collect(); // panics any code that indexes `fill.boundary` (e.g. `insert_stroke`).
let inside_edges: HashSet<EdgeId> = {
let mut s = inside_edges.clone();
for &fid in inside_fills {
if fid.is_none() {
continue;
}
if let Some(fill) = self.fills.get(fid.idx()) {
if fill.deleted {
continue;
}
for &(eid, _) in &fill.boundary {
if !eid.is_none() {
s.insert(eid);
}
}
}
}
s
};
let inside_edges = &inside_edges;
// Boundary = `explicit_boundary` (e.g. a region/lasso cut the caller knows about)
// UNION any inside edge still referenced by a fill we're NOT extracting (a shared
// DCEL edge — must be duplicated, not moved, or that fill dangles). Deriving the
// latter here means a plain geometry selection needs no boundary analysis.
let mut boundary_edge_ids: HashSet<EdgeId> = explicit_boundary.clone();
for (i, fill) in self.fills.iter().enumerate() {
if fill.deleted || inside_fills.contains(&FillId(i as u32)) {
continue;
}
for &(eid, _) in &fill.boundary {
if !eid.is_none() && inside_edges.contains(&eid) {
boundary_edge_ids.insert(eid);
}
}
}
let boundary_edge_ids = &boundary_edge_ids;
// Copy all inside edges + any boundary edges (the explicit ones may not be in
// inside_edges); boundary edges are kept in self below.
let edges_to_copy: HashSet<EdgeId> = inside_edges.union(boundary_edge_ids).copied().collect();
// Collect all vertices referenced by edges we're copying // Collect all vertices referenced by edges we're copying
let mut referenced_vids: HashSet<VertexId> = HashSet::new(); let mut referenced_vids: HashSet<VertexId> = HashSet::new();
@ -1493,16 +2063,21 @@ impl VectorGraph {
} }
} }
// Determine which vertices are interior (exclusively owned by the // Determine which vertices are safe to free from `self`. A vertex can only be
// extracted subgraph) vs boundary (shared with remaining geometry). // freed if EVERY one of its incident edges is actually being removed from `self`,
// A vertex is interior if ALL of its incident edges are either in // i.e. is an inside edge that is NOT a boundary edge. Boundary edges are kept
// inside_edges or boundary_edge_ids. // (duplicated) in `self`, so a vertex touching one is still referenced and must
// remain — otherwise it becomes a freed-but-referenced vertex whose slot a later
// `alloc_vertex` reuses, corrupting the remaining fill.
let mut interior_vertices: HashSet<VertexId> = HashSet::new(); let mut interior_vertices: HashSet<VertexId> = HashSet::new();
let mut boundary_vertices: HashSet<VertexId> = HashSet::new(); let mut boundary_vertices: HashSet<VertexId> = HashSet::new();
for &vid in &referenced_vids { for &vid in &referenced_vids {
let incident = self.edges_at_vertex(vid); let incident = self.edges_at_vertex(vid);
let all_inside = incident.iter().all(|&eid| edges_to_copy.contains(&eid)); let all_removed = !incident.is_empty()
if all_inside { && incident
.iter()
.all(|&eid| inside_edges.contains(&eid) && !boundary_edge_ids.contains(&eid));
if all_removed {
interior_vertices.insert(vid); interior_vertices.insert(vid);
} else { } else {
boundary_vertices.insert(vid); boundary_vertices.insert(vid);
@ -1566,9 +2141,10 @@ impl VectorGraph {
new_graph.fills[new_fid.idx()].image_fill = fill.image_fill; new_graph.fills[new_fid.idx()].image_fill = fill.image_fill;
} }
// Remove inside_edges from self (but NOT boundary edges — those are duplicated) // Remove inside_edges from self, EXCEPT boundary edges (those are duplicated —
// a non-extracted fill still needs them).
for &eid in inside_edges { for &eid in inside_edges {
if !eid.is_none() && !self.edges[eid.idx()].deleted { if !eid.is_none() && !boundary_edge_ids.contains(&eid) && !self.edges[eid.idx()].deleted {
self.free_edge(eid); self.free_edge(eid);
} }
} }

View File

@ -10,3 +10,7 @@ mod editing;
mod gap_close; mod gap_close;
#[cfg(test)] #[cfg(test)]
mod region; mod region;
#[cfg(test)]
mod region_cut_select;
#[cfg(test)]
mod tween;

View File

@ -0,0 +1,660 @@
//! Reproduces the unified region-select behaviour at the graph level: cutting a shape
//! along a lasso outline (`insert_stroke`) and classifying which resulting fills/edges
//! fall inside the lasso — the same logic the editor's `execute_region_select` runs.
//!
//! The shape is built exactly as the editor builds a rectangle: `insert_stroke` of a
//! closed rect loop, then `paint_bucket` to create the fill (NOT a hand-rolled
//! `alloc_fill`), because the bug is in how that traced fill's boundary survives being
//! split by the lasso cut.
use super::super::*;
use kurbo::{BezPath, CubicBez, ParamCurve, Point, Shape};
/// Straight-line cubic from a to b.
fn line(a: Point, b: Point) -> CubicBez {
CubicBez::new(
a,
Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0),
Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0),
b,
)
}
fn closed_rect_path(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> BezPath {
let mut p = BezPath::new();
p.move_to((min_x, min_y));
p.line_to((max_x, min_y));
p.line_to((max_x, max_y));
p.line_to((min_x, max_y));
p.close_path();
p
}
/// Draw a filled rectangle into an existing graph the way the editor's rectangle tool does.
fn draw_filled_rect(g: &mut VectorGraph, min_x: f64, min_y: f64, max_x: f64, max_y: f64) {
let style = StrokeStyle { width: 1.0, ..Default::default() };
let color = ShapeColor::rgb(0, 0, 0);
for segs in bezpath_to_cubic_segments(&closed_rect_path(min_x, min_y, max_x, max_y)) {
g.insert_stroke(&segs, Some(style.clone()), Some(color), 0.5);
}
let centroid = Point::new((min_x + max_x) / 2.0, (min_y + max_y) / 2.0);
g.paint_bucket(centroid, ShapeColor::rgb(255, 0, 0), FillRule::NonZero, 0.0)
.expect("paint_bucket should create a fill");
}
/// Build a filled rectangle the way the editor's rectangle tool does.
fn editor_filled_rect(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> VectorGraph {
let mut g = VectorGraph::new();
draw_filled_rect(&mut g, min_x, min_y, max_x, max_y);
g
}
/// Closed rectangular lasso: cubic segments (for insert_stroke) + BezPath (for winding).
fn rect_lasso(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> (Vec<CubicBez>, BezPath) {
let p = [
Point::new(min_x, min_y),
Point::new(max_x, min_y),
Point::new(max_x, max_y),
Point::new(min_x, max_y),
];
let segs = vec![line(p[0], p[1]), line(p[1], p[2]), line(p[2], p[3]), line(p[3], p[0])];
let mut path = BezPath::new();
path.move_to(p[0]);
for pt in &p[1..] {
path.line_to(*pt);
}
path.close_path();
(segs, path)
}
// Classify-against-lasso uses the same guaranteed-interior probe point the editor uses.
fn fill_centroid(g: &VectorGraph, fid: FillId) -> Point {
g.fill_interior_point(fid)
}
/// Directed start/end points of a boundary entry.
fn dir_start(g: &VectorGraph, eid: EdgeId, dir: Direction) -> Point {
let c = g.edge(eid).curve;
match dir {
Direction::Forward => c.p0,
Direction::Backward => c.p3,
}
}
fn dir_end(g: &VectorGraph, eid: EdgeId, dir: Direction) -> Point {
let c = g.edge(eid).curve;
match dir {
Direction::Forward => c.p3,
Direction::Backward => c.p0,
}
}
/// Describe a fill's boundary as an ordered list of (start → end) segments for diagnostics.
fn describe_boundary(g: &VectorGraph, fid: FillId) -> Vec<(EdgeId, Point, Point)> {
g.fill(fid)
.boundary
.iter()
.filter(|(e, _)| !e.is_none())
.map(|&(e, d)| (e, dir_start(g, e, d), dir_end(g, e, d)))
.collect()
}
/// Assert no fill boundary uses the same edge twice (the signature of a degenerate
/// "spike" where the trace runs out and back along a dangling edge).
fn assert_no_spikes(g: &VectorGraph) {
for (fi, f) in g.fills.iter().enumerate() {
if f.deleted { continue; }
let mut seen = std::collections::HashSet::new();
for &(e, _) in &f.boundary {
if e.is_none() { continue; }
assert!(seen.insert(e.0), "fill {fi} uses edge {} twice (spike)", e.0);
}
}
}
/// Assert every non-deleted fill's boundary is a connected closed loop.
fn assert_all_fills_connected(g: &VectorGraph) {
for (i, f) in g.fills.iter().enumerate() {
if f.deleted {
continue;
}
let fid = FillId(i as u32);
let b = describe_boundary(g, fid);
if b.is_empty() {
continue;
}
let n = b.len();
for k in 0..n {
let (_, _, end) = b[k];
let (_, next_start, _) = b[(k + 1) % n];
// Tolerance well below any real gap (pixels) but above accumulated float drift.
assert!(
(end.x - next_start.x).abs() < 1e-2 && (end.y - next_start.y).abs() < 1e-2,
"fill {i} boundary disconnected between edge {k} (ends {end:?}) and \
edge {} (starts {next_start:?}); boundary = {b:#?}",
(k + 1) % n
);
}
}
}
#[test]
fn single_vertical_cut_produces_two_connected_fills() {
// Rectangle (0,0)-(200,100), one vertical cut at x=100.
let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0);
let cut = vec![line(Point::new(100.0, -10.0), Point::new(100.0, 110.0))];
g.insert_stroke(&cut, None, None, 1.0);
let live = g.fills.iter().filter(|f| !f.deleted).count();
assert_eq!(live, 2, "one cut should split the rect into 2 fills, got {live}");
assert_all_fills_connected(&g);
}
#[test]
fn lasso_over_second_of_two_rects_splits_that_rect() {
// Two separate rectangles; lasso a vertical strip over the SECOND one.
let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0);
draw_filled_rect(&mut g, 0.0, 150.0, 200.0, 250.0);
assert_eq!(g.fills.iter().filter(|f| !f.deleted).count(), 2, "two rects → two fills");
let (segs, lasso_path) = rect_lasso(50.0, 120.0, 150.0, 300.0); // crosses rect B only
g.insert_stroke(&segs, None, None, 1.0);
g.gc_invisible_edges();
// Rect B should now be split into 3 fills (left/middle/right strips); rect A is untouched.
// So 1 (rect A) + 3 (rect B pieces) = 4 fills total.
let live = g.fills.iter().filter(|f| !f.deleted).count();
let inside_fills: Vec<FillId> = g
.fills
.iter()
.enumerate()
.filter(|(_, f)| !f.deleted)
.map(|(i, _)| FillId(i as u32))
.filter(|&fid| lasso_path.winding(fill_centroid(&g, fid)) != 0)
.collect();
let dump = || {
g.fills
.iter()
.enumerate()
.filter(|(_, f)| !f.deleted)
.map(|(i, _)| (i, fill_centroid(&g, FillId(i as u32))))
.collect::<Vec<_>>()
};
assert_eq!(live, 4, "rect B should split into 3 (total 4 fills); got {live}: {:#?}", dump());
assert_eq!(
inside_fills.len(),
1,
"exactly the center strip of rect B should be inside; got {:#?}",
dump()
);
assert_all_fills_connected(&g);
}
#[test]
fn lasso_strip_through_side_by_side_second_rect() {
// Two rectangles side by side; lasso a vertical strip through the SECOND (right) one.
let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0);
draw_filled_rect(&mut g, 150.0, 0.0, 250.0, 100.0);
let (segs, lasso_path) = rect_lasso(180.0, -50.0, 220.0, 150.0);
g.insert_stroke(&segs, None, None, 1.0);
g.gc_invisible_edges();
assert_all_fills_connected(&g);
let inside: Vec<FillId> = g.fills.iter().enumerate()
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32))
.filter(|&fid| lasso_path.winding(fill_centroid(&g, fid)) != 0).collect();
let dump: Vec<_> = g.fills.iter().enumerate().filter(|(_, f)| !f.deleted)
.map(|(i, _)| (i, fill_centroid(&g, FillId(i as u32)), describe_boundary(&g, FillId(i as u32)).len())).collect();
assert_eq!(inside.len(), 1, "one strip of the right rect should be inside; got {dump:#?}");
}
/// No live fill may reference a freed edge or vertex (whose slot a later alloc reuses).
fn assert_no_freed_but_referenced(g: &VectorGraph) {
let freed_v: std::collections::HashSet<u32> = g.free_vertices.iter().copied().collect();
let freed_e: std::collections::HashSet<u32> = g.free_edges.iter().copied().collect();
for (fi, f) in g.fills.iter().enumerate() {
if f.deleted { continue; }
for &(e, _) in &f.boundary {
if e.is_none() { continue; }
assert!(!freed_e.contains(&e.0), "live fill {fi} references freed edge {}", e.0);
for &v in &g.edge(e).vertices {
assert!(!freed_v.contains(&v.0), "live fill {fi} references freed vertex {}", v.0);
}
}
}
}
#[test]
fn extract_after_cut_keeps_remaining_fill_intact() {
// Cut a rectangle's corner with a lasso, then extract (Group) the clipped corner. The
// extraction must not free vertices/edges still referenced by the L-shaped remainder —
// previously it freed the shared cut vertices, and a later `alloc_vertex` reused those
// slots and corrupted the remainder (the "second lasso deletes all faces" bug).
let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0);
let (segs, lasso) = rect_lasso(100.0, -50.0, 300.0, 50.0); // clips the top-right corner
g.insert_stroke(&segs, None, None, 1.0);
g.gc_invisible_edges();
// Pick the fill inside the lasso (the clipped corner) and extract it.
let inside: Vec<FillId> = g.fills.iter().enumerate()
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32))
.filter(|&fid| lasso.winding(g.fill_interior_point(fid)) != 0).collect();
assert_eq!(inside.len(), 1, "one corner fill should be inside the lasso");
let inside_fills: HashSet<FillId> = inside.iter().copied().collect();
let inside_edges: HashSet<EdgeId> = g.fill(inside[0]).boundary.iter()
.filter_map(|&(e, _)| (!e.is_none()).then_some(e)).collect();
let _ = g.extract_subgraph(&inside_edges, &inside_fills, &HashSet::new());
assert_no_freed_but_referenced(&g);
assert_all_fills_connected(&g);
// Simulate the next lasso allocating vertices, then re-validate: a corrupted (freed but
// referenced) vertex would now be at a bogus position.
let (segs2, _) = rect_lasso(20.0, 20.0, 60.0, 80.0);
g.insert_stroke(&segs2, None, None, 1.0);
g.gc_invisible_edges();
assert_all_fills_connected(&g);
}
/// Captured region-select cases (`LIGHTNINGBEAM_DUMP_REGION=1`), embedded so the regression
/// survives `/tmp` being cleared. Each is `{ "graph": <VectorGraph>, "segments": [[[x,y]*4]*] }`.
/// They span: two separate rects, side-by-side, overlapping, a notched post-group fill, and
/// dense self-intersecting freehand lassos (dump 3 is the boundary-spike repro).
const REGION_DUMPS: &[&str] = &[
include_str!("region_dumps/dump0.json"),
include_str!("region_dumps/dump1.json"),
include_str!("region_dumps/dump2.json"),
include_str!("region_dumps/dump3.json"),
include_str!("region_dumps/dump4.json"),
];
#[test]
fn dumped_region_selects_are_valid() {
// Replays each captured region-select cut and asserts it yields only valid, non-corrupt
// geometry (no freed-but-referenced refs, no spikes, every fill a connected loop).
for json in REGION_DUMPS {
let v: serde_json::Value = serde_json::from_str(json).unwrap();
let mut g: VectorGraph = serde_json::from_value(v["graph"].clone()).unwrap();
let segs: Vec<CubicBez> = v["segments"].as_array().unwrap().iter().map(|s| {
let p = |i: usize| { let a = s[i].as_array().unwrap(); Point::new(a[0].as_f64().unwrap(), a[1].as_f64().unwrap()) };
CubicBez::new(p(0), p(1), p(2), p(3))
}).collect();
g.insert_stroke(&segs, None, None, 1.0);
g.gc_invisible_edges();
assert_no_freed_but_referenced(&g);
assert_no_spikes(&g);
assert_all_fills_connected(&g);
}
}
#[test]
fn near_coincident_needle_does_not_spike() {
// Smoke test: a stroke poking into a fill and returning along a near-coincident path
// must still yield valid, non-corrupt geometry. (The dense-freehand accordion that the
// boundary-spike collapse specifically fixes is only reliably reproduced by the captured
// region dumps; this is a lightweight portable guard for the same family of inputs.)
let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0);
g.insert_stroke(
&[
line(Point::new(50.0, -10.0), Point::new(50.0, 50.0)),
line(Point::new(50.0, 50.0), Point::new(50.0003, -10.0)),
],
None, None, 1.0,
);
g.gc_invisible_edges();
assert_no_spikes(&g);
assert_all_fills_connected(&g);
}
#[test]
fn second_stroke_crossing_first_splits_into_quadrants() {
// A later stroke that crosses an earlier stroke's edge inside a fill triggers an
// edge-domain `split_edge`, whose sub-edges aren't tracked in the second stroke's new
// edges. The retrace must still see them (induced-subgraph expansion) or the cut breaks
// and unravels. Two crossing cuts through a rectangle must yield four connected quadrants.
let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0);
g.insert_stroke(&[line(Point::new(-10.0, 50.0), Point::new(110.0, 50.0))], None, None, 1.0); // horizontal
assert_eq!(g.fills.iter().filter(|f| !f.deleted).count(), 2, "horizontal cut → 2");
g.insert_stroke(&[line(Point::new(50.0, -10.0), Point::new(50.0, 110.0))], None, None, 1.0); // vertical, crosses it
g.gc_invisible_edges();
let live: Vec<FillId> = g.fills.iter().enumerate()
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32)).collect();
assert_eq!(live.len(), 4, "two crossing cuts → four quadrants; got {}", live.len());
assert_all_fills_connected(&g);
assert_no_spikes(&g);
for &fid in &live {
assert!((polygon_area(&g, fid) - 2500.0).abs() < 1.0, "each quadrant is 50x50, got {}", polygon_area(&g, fid));
}
}
#[test]
fn stroke_dead_ending_inside_fill_does_not_spike() {
// A stroke (e.g. a freehand lasso that wiggles or nearly self-touches) can leave a
// dangling stub: an edge whose inner endpoint has degree 1. Re-tracing must not run out
// and back along it (a self-touching "spike" boundary); the fill stays a clean loop.
let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0);
// Open stroke entering the top edge and dead-ending inside at (100,50).
let stub = vec![line(Point::new(50.0, -20.0), Point::new(100.0, 50.0))];
g.insert_stroke(&stub, None, None, 1.0);
g.gc_invisible_edges();
let live: Vec<FillId> = g.fills.iter().enumerate()
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32)).collect();
assert_eq!(live.len(), 1, "a dead-end stub must not split the fill; got {}", live.len());
assert_all_fills_connected(&g);
assert_no_spikes(&g);
// The fill is still the full rectangle.
assert!((polygon_area(&g, live[0]) - 20000.0).abs() < 1.0,
"fill should remain the 200x100 rectangle, area {}", polygon_area(&g, live[0]));
}
#[test]
fn groupfail_two_notched_fills_second_lasso() {
// Faithful reconstruction of the captured post-group state (dump_1): two adjacent
// fills sharing the x=337 column, each with an invisible notch (the hole left by the
// first lasso+group). A second lasso (398,249)-(495,327) clips fill 3's corner.
use std::collections::HashMap;
let mut g = VectorGraph::new();
let vp = [
(130.0, 232.0), (337.0, 232.0), (337.0, 362.0), (130.0, 362.0), (337.0, 297.0),
(535.0, 297.0), (535.0, 417.0), (337.0, 417.0), (0.0, 0.0), (337.0, 315.0),
(220.0, 315.0), (456.0, 315.0), (456.0, 345.0), (337.0, 345.0), (220.0, 345.0),
];
let vs: Vec<_> = vp.iter().map(|&(x, y)| g.alloc_vertex(Point::new(x, y))).collect();
let style = StrokeStyle { width: 3.0, ..Default::default() };
let color = ShapeColor::rgb(0, 0, 0);
// (name, v_start, v_end, visible)
let edge_defs: &[(&str, usize, usize, bool)] = &[
("e0", 0, 1, true), ("e1", 4, 5, true), ("e2", 2, 3, true), ("e3", 3, 0, true),
("e4", 1, 4, true), ("e5", 7, 2, true), ("e6", 5, 6, true), ("e7", 6, 7, true),
("e8", 10, 9, false), ("e9", 12, 13, false), ("e11", 4, 9, true), ("e12", 9, 11, false),
("e13", 11, 12, false), ("e15", 13, 2, true), ("e16", 13, 14, false), ("e17", 14, 10, false),
];
let mut em: HashMap<&str, EdgeId> = HashMap::new();
for &(name, a, b, vis) in edge_defs {
let (ss, sc) = if vis { (Some(style.clone()), Some(color)) } else { (None, None) };
let e = g.alloc_edge(line(vp[a].into(), vp[b].into()), vs[a], vs[b], ss, sc);
em.insert(name, e);
}
let mk = |spec: &[(&str, Direction)]| -> Vec<(EdgeId, Direction)> {
spec.iter().map(|&(n, d)| (em[n], d)).collect()
};
use Direction::{Backward as B, Forward as F};
g.alloc_fill(mk(&[("e11", B), ("e4", B), ("e0", B), ("e3", B), ("e2", B), ("e15", B), ("e16", F), ("e17", F), ("e8", F)]),
ShapeColor::rgb(100, 100, 255), FillRule::NonZero);
g.alloc_fill(mk(&[("e15", F), ("e5", B), ("e7", B), ("e6", B), ("e1", B), ("e11", F), ("e12", F), ("e13", F), ("e9", F)]),
ShapeColor::rgb(100, 100, 255), FillRule::NonZero);
assert_eq!(g.fills.iter().filter(|f| !f.deleted).count(), 2, "starts with 2 fills");
let (segs, lasso) = rect_lasso(398.0, 249.0, 495.0, 327.0);
g.insert_stroke(&segs, None, None, 1.0);
g.gc_invisible_edges();
let live = g.fills.iter().filter(|f| !f.deleted).count();
let inside: Vec<FillId> = g.fills.iter().enumerate()
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32))
.filter(|&fid| lasso.winding(g.fill_interior_point(fid)) != 0).collect();
eprintln!("groupfail: {live} live fills, {} inside lasso", inside.len());
for (i, f) in g.fills.iter().enumerate() {
if f.deleted { continue; }
let fid = FillId(i as u32);
eprintln!(" fill {i}: bbox {:?} area {:.0}", fill_bbox(&g, fid), polygon_area(&g, fid));
}
assert_all_fills_connected(&g);
// The two original shapes must survive (no faces wrongly deleted); the lasso adds a cut.
assert!(live >= 2, "must keep both shapes plus the cut; got {live}");
assert_eq!(inside.len(), 1, "exactly the clipped corner of fill 3 should be selected");
}
#[test]
fn lasso_across_notched_fill_never_corrupts_boundaries() {
// A rectangle with a rectangular notch bitten out of its top edge — the shape a fill
// is left as after grouping a lasso selection (the notch is the extracted region's
// hole). A second lasso crossing the notch must never produce disconnected/corrupt
// fill boundaries (it previously incorporated the lasso's out-of-shape edges, drawing
// stray diagonals). It may under-select on such complex geometry, but must stay valid.
let mut g = VectorGraph::new();
let pts = [
Point::new(0.0, 0.0), Point::new(80.0, 0.0), Point::new(80.0, 40.0),
Point::new(120.0, 40.0), Point::new(120.0, 0.0), Point::new(200.0, 0.0),
Point::new(200.0, 100.0), Point::new(0.0, 100.0),
];
let style = StrokeStyle { width: 1.0, ..Default::default() };
let color = ShapeColor::rgb(0, 0, 0);
let v: Vec<_> = pts.iter().map(|&p| g.alloc_vertex(p)).collect();
let mut boundary = Vec::new();
for i in 0..pts.len() {
let e = g.alloc_edge(line(pts[i], pts[(i + 1) % pts.len()]), v[i], v[(i + 1) % pts.len()],
Some(style.clone()), Some(color));
boundary.push((e, Direction::Forward));
}
g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
// Lasso a rectangle that straddles the notch and the shape body. Inside the fill it
// covers (60..140, 0..60) minus the notch (80..120, 0..40) — an arch shape — which the
// cut must isolate as one connected fill, with the remainder outside.
let (segs, lasso) = rect_lasso(60.0, -20.0, 140.0, 60.0);
g.insert_stroke(&segs, None, None, 1.0);
g.gc_invisible_edges();
// Every resulting fill is a valid connected loop (never corrupt).
assert_all_fills_connected(&g);
// Exactly one fill lies inside the lasso, and it is the arch (area = 80*60 - 40*40).
let inside: Vec<FillId> = g.fills.iter().enumerate()
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32))
.filter(|&fid| lasso.winding(g.fill_interior_point(fid)) != 0).collect();
assert_eq!(inside.len(), 1, "the arch (lasso ∩ notched fill) should be one selected fill");
let area = polygon_area(&g, inside[0]);
assert!((area - 3200.0).abs() < 1.0, "arch area should be 3200, got {area}");
}
/// Absolute polygon area of a fill from its boundary corner points.
fn polygon_area(g: &VectorGraph, fid: FillId) -> f64 {
let pts: Vec<Point> = g.fill(fid).boundary.iter()
.filter(|(e, _)| !e.is_none())
.map(|&(e, d)| dir_start(g, e, d))
.collect();
if pts.len() < 3 { return 0.0; }
let mut a2 = 0.0;
for i in 0..pts.len() {
let p0 = pts[i]; let p1 = pts[(i + 1) % pts.len()];
a2 += p0.x * p1.y - p1.x * p0.y;
}
(a2 * 0.5).abs()
}
#[test]
fn two_edge_adjacent_rects_make_two_clean_fills() {
// Two rectangles sharing the x=100 edge. The second's left edge lands exactly on the
// first's right edge; without coincident-edge cleanup this produced duplicate edges and
// zero-area "sliver" fills (4 fills instead of 2) before any lasso.
let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0);
draw_filled_rect(&mut g, 100.0, 0.0, 200.0, 100.0);
let live: Vec<_> = g.fills.iter().enumerate().filter(|(_, f)| !f.deleted)
.map(|(i, _)| (i, fill_bbox(&g, FillId(i as u32)))).collect();
assert_eq!(live.len(), 2, "two adjacent rects should make exactly two fills; got {live:?}");
assert_all_fills_connected(&g);
}
#[test]
fn lasso_strip_through_adjacent_second_rect() {
// Two rectangles sharing an edge (snapped adjacent), lasso a strip through the second.
let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0);
draw_filled_rect(&mut g, 100.0, 0.0, 200.0, 100.0); // shares the x=100 edge with the first
let (segs, lasso_path) = rect_lasso(130.0, -50.0, 170.0, 150.0); // strip through 2nd
g.insert_stroke(&segs, None, None, 1.0);
g.gc_invisible_edges();
assert_all_fills_connected(&g);
let inside: Vec<FillId> = g.fills.iter().enumerate()
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32))
.filter(|&fid| lasso_path.winding(fill_centroid(&g, fid)) != 0).collect();
let dump: Vec<_> = g.fills.iter().enumerate().filter(|(_, f)| !f.deleted)
.map(|(i, _)| (i, fill_centroid(&g, FillId(i as u32)), describe_boundary(&g, FillId(i as u32)).len())).collect();
assert_eq!(inside.len(), 1, "one strip of the 2nd rect should be inside; got {dump:#?}");
}
#[test]
fn lasso_strip_through_overlapping_second_rect() {
// Second rectangle overlaps the first; lasso a strip through the second's free part.
let mut g = editor_filled_rect(0.0, 0.0, 100.0, 100.0);
draw_filled_rect(&mut g, 60.0, 60.0, 200.0, 160.0);
let (segs, _lasso) = rect_lasso(120.0, 40.0, 160.0, 180.0);
g.insert_stroke(&segs, None, None, 1.0);
g.gc_invisible_edges();
assert_all_fills_connected(&g);
}
/// Bounding box of a fill's boundary edge endpoints.
fn fill_bbox(g: &VectorGraph, fid: FillId) -> (f64, f64, f64, f64) {
let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for &(eid, _) in &g.fill(fid).boundary {
if eid.is_none() {
continue;
}
for &vid in &g.edge(eid).vertices {
let p = g.vertex(vid).position;
minx = minx.min(p.x);
miny = miny.min(p.y);
maxx = maxx.max(p.x);
maxy = maxy.max(p.y);
}
}
(minx, miny, maxx, maxy)
}
#[test]
fn lasso_corner_clip_of_second_rect_splits_off_corner() {
// Captured repro (/tmp dump): two separate rects; the lasso clips the top-left CORNER
// of the second rect, so the cut path turns a corner at a vertex INTERIOR to the rect.
let mut g = editor_filled_rect(128.37, 255.97, 290.91, 336.55); // rect 1
draw_filled_rect(&mut g, 417.39, 311.62, 620.25, 442.55); // rect 2
let (segs, lasso) = rect_lasso(360.12, 277.92, 537.32, 397.14); // clips rect-2 corner
g.insert_stroke(&segs, None, None, 1.0);
g.gc_invisible_edges();
assert_all_fills_connected(&g);
let inside: Vec<FillId> = g.fills.iter().enumerate()
.filter(|(_, f)| !f.deleted).map(|(i, _)| FillId(i as u32))
.filter(|&fid| lasso.winding(fill_centroid(&g, fid)) != 0).collect();
let dump: Vec<_> = g.fills.iter().enumerate().filter(|(_, f)| !f.deleted)
.map(|(i, _)| (i, fill_centroid(&g, FillId(i as u32)), fill_bbox(&g, FillId(i as u32)), describe_boundary(&g, FillId(i as u32)).len())).collect();
assert_eq!(inside.len(), 1, "exactly the clipped corner should be inside; fills = {dump:#?}");
// The selected fill must be the clipped CORNER, not the whole rect 2.
let (minx, miny, maxx, maxy) = fill_bbox(&g, inside[0]);
let eps = 1.0;
assert!(
(minx - 417.39).abs() < eps && (miny - 311.62).abs() < eps
&& (maxx - 537.32).abs() < eps && (maxy - 397.14).abs() < eps,
"expected clipped-corner bbox (417.4,311.6)-(537.3,397.1), got ({minx:.1},{miny:.1})-({maxx:.1},{maxy:.1}) \
whole rect2 is (417,311)-(620,442); the fill wasn't split"
);
}
#[test]
fn lasso_over_middle_of_rect_selects_clean_center_strip() {
// Rectangle (0,0)-(200,100); lasso a vertical strip (50,-50)-(150,150).
let mut g = editor_filled_rect(0.0, 0.0, 200.0, 100.0);
let (segs, lasso_path) = rect_lasso(50.0, -50.0, 150.0, 150.0);
g.insert_stroke(&segs, None, None, 1.0);
// The editor runs this right after the cut: the lasso's top/bottom edges and the
// out-of-shape extensions of its sides are invisible and unreferenced — they must be
// collected so they can't be selected/edited later.
g.gc_invisible_edges();
for (i, e) in g.edges.iter().enumerate() {
if e.deleted || e.stroke_style.is_some() || e.stroke_color.is_some() {
continue;
}
let eid = EdgeId(i as u32);
let in_a_fill = g
.fills
.iter()
.any(|f| !f.deleted && f.boundary.iter().any(|&(fe, _)| fe == eid));
assert!(
in_a_fill,
"stray invisible edge {i} ({:?}) survived gc — not part of any fill",
e.curve
);
}
// Classify fills by centroid winding (the editor's logic).
let inside_fills: Vec<FillId> = g
.fills
.iter()
.enumerate()
.filter(|(_, f)| !f.deleted)
.map(|(i, _)| FillId(i as u32))
.filter(|&fid| lasso_path.winding(fill_centroid(&g, fid)) != 0)
.collect();
let dump = || {
g.fills
.iter()
.enumerate()
.filter(|(_, f)| !f.deleted)
.map(|(i, _)| {
let fid = FillId(i as u32);
(i, fill_centroid(&g, fid), describe_boundary(&g, fid))
})
.collect::<Vec<_>>()
};
assert_eq!(
inside_fills.len(),
1,
"expected exactly one inside fill (the center strip); live fills = {:#?}",
dump()
);
let fid = inside_fills[0];
let boundary = describe_boundary(&g, fid);
// The center strip is a rectangle: it must have 4 boundary edges that form a
// *connected closed loop* (each edge's directed end == the next edge's directed start).
// Before the fix, the split produced a 2-edge boundary (left cut + top segment) whose
// bbox is still (50,0)-(150,100) but which `fill_to_bezpath` renders as left+top edges
// plus a diagonal close — the artifact the user reported.
assert_eq!(
boundary.len(),
4,
"center strip should have 4 boundary edges, got {}: {:#?}",
boundary.len(),
boundary
);
let eps = 1e-6;
let n = boundary.len();
for i in 0..n {
let (_, _, end) = boundary[i];
let (_, next_start, _) = boundary[(i + 1) % n];
assert!(
(end.x - next_start.x).abs() < eps && (end.y - next_start.y).abs() < eps,
"boundary is disconnected between edge {i} (ends {end:?}) and edge {} \
(starts {next_start:?}); full boundary = {boundary:#?}",
(i + 1) % n
);
}
// And the loop should visit the four expected corners.
let corners = [
Point::new(50.0, 0.0),
Point::new(150.0, 0.0),
Point::new(150.0, 100.0),
Point::new(50.0, 100.0),
];
for c in corners {
let hit = boundary
.iter()
.any(|&(_, s, _)| (s.x - c.x).abs() < 0.5 && (s.y - c.y).abs() < 0.5);
assert!(hit, "center strip boundary should pass through corner {c:?}: {boundary:#?}");
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,78 @@
//! Tests for same-topology shape-tween interpolation (`VectorGraph::interpolated`).
use super::super::*;
use kurbo::{CubicBez, Point};
fn line(a: Point, b: Point) -> CubicBez {
CubicBez::new(
a,
Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0),
Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0),
b,
)
}
/// Triangle (3 verts, 3 edges, 1 fill) offset by (ox, oy).
fn triangle(ox: f64, oy: f64) -> VectorGraph {
let mut g = VectorGraph::new();
let p = [
Point::new(ox, oy),
Point::new(ox + 100.0, oy),
Point::new(ox + 50.0, oy + 100.0),
];
let v: Vec<_> = p.iter().map(|&pt| g.alloc_vertex(pt)).collect();
let style = StrokeStyle { width: 1.0, ..Default::default() };
let mut boundary = Vec::new();
for i in 0..3 {
let e = g.alloc_edge(
line(p[i], p[(i + 1) % 3]),
v[i],
v[(i + 1) % 3],
Some(style.clone()),
Some(ShapeColor::rgb(0, 0, 0)),
);
boundary.push((e, Direction::Forward));
}
g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
g
}
#[test]
fn interpolate_same_topology_lerps_positions() {
let a = triangle(0.0, 0.0);
let b = triangle(100.0, 50.0);
let mid = a.interpolated(&b, 0.5).expect("same topology should interpolate");
// Vertex 0: (0,0) and (100,50) → (50,25). Curve endpoints follow.
assert!((mid.vertices[0].position.x - 50.0).abs() < 1e-6);
assert!((mid.vertices[0].position.y - 25.0).abs() < 1e-6);
assert!((mid.edges[0].curve.p0.x - 50.0).abs() < 1e-6);
// Endpoints: t=0 is `a`, t=1 is `b`.
assert!((a.interpolated(&b, 0.0).unwrap().vertices[0].position.x - 0.0).abs() < 1e-6);
assert!((a.interpolated(&b, 1.0).unwrap().vertices[0].position.x - 100.0).abs() < 1e-6);
}
#[test]
fn interpolate_lerps_fill_color() {
let mut a = triangle(0.0, 0.0);
let mut b = triangle(0.0, 0.0);
a.fills[0].color = Some(ShapeColor::rgb(0, 0, 0));
b.fills[0].color = Some(ShapeColor::rgb(100, 200, 40));
let mid = a.interpolated(&b, 0.5).unwrap();
let c = mid.fills[0].color.unwrap();
assert_eq!((c.r, c.g, c.b), (50, 100, 20));
}
#[test]
fn interpolate_topology_mismatch_returns_none() {
let a = triangle(0.0, 0.0);
let mut more_verts = triangle(0.0, 0.0);
more_verts.alloc_vertex(Point::new(999.0, 999.0));
assert!(a.interpolated(&more_verts, 0.5).is_none(), "different vertex count");
// Same counts but a moved edge endpoint (different vertices) is still a mismatch.
let mut rewired = triangle(0.0, 0.0);
rewired.edges[0].vertices = [VertexId(2), VertexId(1)];
assert!(a.interpolated(&rewired, 0.5).is_none(), "different edge endpoints");
}

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lightningbeam-editor" name = "lightningbeam-editor"
version = "1.0.4-alpha" version = "1.0.5-alpha"
edition = "2021" edition = "2021"
description = "Multimedia editor for audio, video and 2D animation" description = "Multimedia editor for audio, video and 2D animation"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"

View File

@ -43,8 +43,6 @@ pub enum CurveEditAction {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct CurveDragState { pub struct CurveDragState {
pub keyframe_index: usize, pub keyframe_index: usize,
pub original_time: f64,
pub original_value: f32,
pub current_time: f64, pub current_time: f64,
pub current_value: f32, pub current_value: f32,
} }
@ -263,8 +261,6 @@ pub fn render_curve_lane(
let kf = &keyframes[idx]; let kf = &keyframes[idx];
*drag_state = Some(CurveDragState { *drag_state = Some(CurveDragState {
keyframe_index: idx, keyframe_index: idx,
original_time: kf.time,
original_value: kf.value,
current_time: kf.time, current_time: kf.time,
current_value: kf.value, current_value: kf.value,
}); });

View File

@ -13,6 +13,11 @@ use ffmpeg_next as ffmpeg;
pub struct CpuYuvConverter { pub struct CpuYuvConverter {
width: u32, width: u32,
height: u32, height: u32,
/// swscale context + reusable source/dest frames, built once and reused every frame
/// (creating them per call was a measurable per-output-frame export cost).
scaler: ffmpeg::software::scaling::Context,
rgba_frame: ffmpeg::frame::Video,
yuv_frame: ffmpeg::frame::Video,
} }
impl CpuYuvConverter { impl CpuYuvConverter {
@ -22,7 +27,20 @@ impl CpuYuvConverter {
/// * `width` - Frame width in pixels /// * `width` - Frame width in pixels
/// * `height` - Frame height in pixels /// * `height` - Frame height in pixels
pub fn new(width: u32, height: u32) -> Result<Self, String> { pub fn new(width: u32, height: u32) -> Result<Self, String> {
Ok(Self { width, height }) // BT.709 (HD) RGBA→YUV420p context, created once.
let scaler = ffmpeg::software::scaling::Context::get(
ffmpeg::format::Pixel::RGBA,
width,
height,
ffmpeg::format::Pixel::YUV420P,
width,
height,
ffmpeg::software::scaling::Flags::BILINEAR,
)
.map_err(|e| format!("Failed to create swscale context: {}", e))?;
let rgba_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height);
let yuv_frame = ffmpeg::frame::Video::new(ffmpeg::format::Pixel::YUV420P, width, height);
Ok(Self { width, height, scaler, rgba_frame, yuv_frame })
} }
/// Convert RGBA data to YUV420p planes /// Convert RGBA data to YUV420p planes
@ -40,7 +58,7 @@ impl CpuYuvConverter {
/// ///
/// # Panics /// # Panics
/// Panics if rgba_data length doesn't match width * height * 4 /// Panics if rgba_data length doesn't match width * height * 4
pub fn convert(&self, rgba_data: &[u8]) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), String> { pub fn convert(&mut self, rgba_data: &[u8]) -> Result<(Vec<u8>, Vec<u8>, Vec<u8>), String> {
let expected_size = (self.width * self.height * 4) as usize; let expected_size = (self.width * self.height * 4) as usize;
assert_eq!( assert_eq!(
rgba_data.len(), rgba_data.len(),
@ -50,51 +68,17 @@ impl CpuYuvConverter {
rgba_data.len() rgba_data.len()
); );
// Create source RGBA frame // Copy RGBA into the reused source frame, run the reused scaler into the reused
let mut rgba_frame = ffmpeg::frame::Video::new( // dest frame (SIMD-optimized), then extract planes.
ffmpeg::format::Pixel::RGBA, self.rgba_frame.data_mut(0).copy_from_slice(rgba_data);
self.width, self.scaler
self.height, .run(&self.rgba_frame, &mut self.yuv_frame)
);
// Copy RGBA data into source frame
// ffmpeg-next provides mutable access to the frame data
let frame_data = rgba_frame.data_mut(0);
frame_data.copy_from_slice(rgba_data);
// Create destination YUV420p frame
let mut yuv_frame = ffmpeg::frame::Video::new(
ffmpeg::format::Pixel::YUV420P,
self.width,
self.height,
);
// Create swscale context for RGBA→YUV420p conversion
// Uses BT.709 color matrix (HD standard)
let mut scaler = ffmpeg::software::scaling::Context::get(
ffmpeg::format::Pixel::RGBA,
self.width,
self.height,
ffmpeg::format::Pixel::YUV420P,
self.width,
self.height,
ffmpeg::software::scaling::Flags::BILINEAR,
)
.map_err(|e| format!("Failed to create swscale context: {}", e))?;
// Perform the conversion (SIMD-optimized)
scaler
.run(&rgba_frame, &mut yuv_frame)
.map_err(|e| format!("swscale conversion failed: {}", e))?; .map_err(|e| format!("swscale conversion failed: {}", e))?;
// Extract planar YUV data // YUV420p planes: Y full-res, U/V quarter-res (2×2 subsampled).
// YUV420p has 3 planes: let y_plane = self.yuv_frame.data(0).to_vec();
// - Y: full resolution (width × height) let u_plane = self.yuv_frame.data(1).to_vec();
// - U: quarter resolution (width/2 × height/2) let v_plane = self.yuv_frame.data(2).to_vec();
// - V: quarter resolution (width/2 × height/2)
let y_plane = yuv_frame.data(0).to_vec();
let u_plane = yuv_frame.data(1).to_vec();
let v_plane = yuv_frame.data(2).to_vec();
Ok((y_plane, u_plane, v_plane)) Ok((y_plane, u_plane, v_plane))
} }
@ -112,7 +96,7 @@ mod tests {
#[test] #[test]
fn test_conversion_output_sizes() { fn test_conversion_output_sizes() {
let converter = CpuYuvConverter::new(1920, 1080).unwrap(); let mut converter = CpuYuvConverter::new(1920, 1080).unwrap();
// Create dummy RGBA data (all black) // Create dummy RGBA data (all black)
let rgba_data = vec![0u8; 1920 * 1080 * 4]; let rgba_data = vec![0u8; 1920 * 1080 * 4];
@ -133,7 +117,7 @@ mod tests {
#[test] #[test]
#[should_panic(expected = "RGBA data size mismatch")] #[should_panic(expected = "RGBA data size mismatch")]
fn test_wrong_input_size_panics() { fn test_wrong_input_size_panics() {
let converter = CpuYuvConverter::new(1920, 1080).unwrap(); let mut converter = CpuYuvConverter::new(1920, 1080).unwrap();
// Wrong size input // Wrong size input
let rgba_data = vec![0u8; 1000]; let rgba_data = vec![0u8; 1000];

View File

@ -17,8 +17,6 @@ pub struct DocumentHint {
pub has_raster: bool, pub has_raster: bool,
pub has_vector: bool, pub has_vector: bool,
pub current_time: f64, pub current_time: f64,
pub doc_width: u32,
pub doc_height: u32,
} }
/// Export type selection /// Export type selection
@ -577,7 +575,7 @@ impl ExportDialog {
if ui.button("Choose location...").clicked() { if ui.button("Choose location...").clicked() {
let ext = self.current_extension(); let ext = self.current_extension();
let mut dialog = rfd::FileDialog::new() let dialog = rfd::FileDialog::new()
.set_directory(&self.output_dir) .set_directory(&self.output_dir)
.set_file_name(&self.output_filename) .set_file_name(&self.output_filename)
.add_filter(ext.to_uppercase(), &[ext]); .add_filter(ext.to_uppercase(), &[ext]);

View File

@ -567,7 +567,7 @@ impl ExportOrchestrator {
let gpu = state.gpu_resources.as_mut().unwrap(); let gpu = state.gpu_resources.as_mut().unwrap();
let output_view = state.output_texture_view.as_ref().unwrap(); let output_view = state.output_texture_view.as_ref().unwrap();
let mut encoder = video_exporter::render_frame_to_gpu_rgba( let encoder = video_exporter::render_frame_to_gpu_rgba(
document, document,
state.settings.time, state.settings.time,
w, h, w, h,

View File

@ -1586,11 +1586,6 @@ impl GpuBrushEngine {
crate::debug_overlay::update_gpu_memory(count, total); crate::debug_overlay::update_gpu_memory(count, total);
} }
/// Get the cached display texture for a raster layer keyframe.
pub fn get_layer_texture(&self, kf_id: &Uuid) -> Option<&CanvasPair> {
self.raster_layer_cache.get(kf_id)
}
/// Ensure a low-res proxy texture exists for `kf_id` (uploaded once; proxies are /// Ensure a low-res proxy texture exists for `kf_id` (uploaded once; proxies are
/// immutable). Bumps recency and evicts the least-recently-used past the budget. /// immutable). Bumps recency and evicts the least-recently-used past the budget.
/// `pixels` is sRGB-premultiplied RGBA of length `w * h * 4`. /// `pixels` is sRGB-premultiplied RGBA of length `w * h * 4`.
@ -1778,18 +1773,6 @@ impl GpuBrushEngine {
self.displacement_bufs.insert(id, DisplacementBuffer { buf, width, height }); self.displacement_bufs.insert(id, DisplacementBuffer { buf, width, height });
} }
/// Overwrite the displacement buffer contents with the provided data.
pub fn upload_displacement_buf(
&self,
queue: &wgpu::Queue,
id: &Uuid,
data: &[[f32; 2]],
) {
if let Some(db) = self.displacement_bufs.get(id) {
queue.write_buffer(&db.buf, 0, bytemuck::cast_slice(data));
}
}
/// Zero out a displacement buffer (reset all displacements to (0,0)). /// Zero out a displacement buffer (reset all displacements to (0,0)).
pub fn clear_displacement_buf(&self, queue: &wgpu::Queue, id: &Uuid) { pub fn clear_displacement_buf(&self, queue: &wgpu::Queue, id: &Uuid) {
if let Some(db) = self.displacement_bufs.get(id) { if let Some(db) = self.displacement_bufs.get(id) {
@ -2029,12 +2012,24 @@ impl BlitTransform {
// y' = b*x + d*y + f // y' = b*x + d*y + f
// Column-major 3×3: col0=(a,b,0), col1=(c,d,0), col2=(e,f,1) // Column-major 3×3: col0=(a,b,0), col1=(c,d,0), col2=(e,f,1)
let [a, b, c, d, e, f] = combined.as_coeffs(); let [a, b, c, d, e, f] = combined.as_coeffs();
// The .w slots are unused by the 3×3 matrix; the shader reads them as an RGB
// screen-blend tint ((0,0,0) = no tint — the default for all normal blits).
Self { Self {
col0: [a as f32, b as f32, 0.0, 0.0], col0: [a as f32, b as f32, 0.0, 0.0],
col1: [c as f32, d as f32, 0.0, 0.0], col1: [c as f32, d as f32, 0.0, 0.0],
col2: [e as f32, f as f32, 1.0, 0.0], col2: [e as f32, f as f32, 1.0, 0.0],
} }
} }
/// Screen-blend the sampled color toward an RGB tint (for onion-skin ghosts), so
/// blacks/outlines pick up the tint. Packed into the matrix uniform's `.w` slots,
/// so no extra binding is needed.
pub fn with_tint(mut self, r: f32, g: f32, b: f32) -> Self {
self.col0[3] = r;
self.col1[3] = g;
self.col2[3] = b;
self
}
} }
impl CanvasBlitPipeline { impl CanvasBlitPipeline {

View File

@ -103,6 +103,7 @@ pub enum AppAction {
TogglePlayPause, TogglePlayPause,
CancelAction, CancelAction,
ToggleDebugOverlay, ToggleDebugOverlay,
ToggleOnionSkin,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
ToggleTestMode, ToggleTestMode,
@ -150,7 +151,7 @@ impl AppAction {
Self::ToolErase | Self::ToolSmudge | Self::ToolSelectLasso | Self::ToolSplit => "Tools", Self::ToolErase | Self::ToolSmudge | Self::ToolSelectLasso | Self::ToolSplit => "Tools",
Self::TogglePlayPause | Self::CancelAction | Self::TogglePlayPause | Self::CancelAction |
Self::ToggleDebugOverlay => "Global", Self::ToggleDebugOverlay | Self::ToggleOnionSkin => "Global",
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
Self::ToggleTestMode => "Global", Self::ToggleTestMode => "Global",
@ -246,6 +247,7 @@ impl AppAction {
Self::TogglePlayPause => "Toggle Play/Pause", Self::TogglePlayPause => "Toggle Play/Pause",
Self::CancelAction => "Cancel / Escape", Self::CancelAction => "Cancel / Escape",
Self::ToggleDebugOverlay => "Toggle Debug Overlay", Self::ToggleDebugOverlay => "Toggle Debug Overlay",
Self::ToggleOnionSkin => "Toggle Onion Skinning",
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
Self::ToggleTestMode => "Toggle Test Mode", Self::ToggleTestMode => "Toggle Test Mode",
Self::PianoRollDelete => "Piano Roll: Delete", Self::PianoRollDelete => "Piano Roll: Delete",
@ -281,7 +283,7 @@ impl AppAction {
Self::ToolEyedropper, Self::ToolLine, Self::ToolPolygon, Self::ToolEyedropper, Self::ToolLine, Self::ToolPolygon,
Self::ToolBezierEdit, Self::ToolText, Self::ToolRegionSelect, Self::ToolBezierEdit, Self::ToolText, Self::ToolRegionSelect,
Self::ToolErase, Self::ToolSmudge, Self::ToolSelectLasso, Self::ToolSplit, Self::ToolErase, Self::ToolSmudge, Self::ToolSelectLasso, Self::ToolSplit,
Self::TogglePlayPause, Self::CancelAction, Self::ToggleDebugOverlay, Self::TogglePlayPause, Self::CancelAction, Self::ToggleDebugOverlay, Self::ToggleOnionSkin,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
Self::ToggleTestMode, Self::ToggleTestMode,
Self::PianoRollDelete, Self::StageDelete, Self::PianoRollDelete, Self::StageDelete,
@ -330,6 +332,7 @@ impl From<MenuAction> for AppAction {
MenuAction::AddTestClip => Self::AddTestClip, MenuAction::AddTestClip => Self::AddTestClip,
MenuAction::DeleteLayer => Self::DeleteLayer, MenuAction::DeleteLayer => Self::DeleteLayer,
MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility, MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility,
MenuAction::ToggleOnionSkin => Self::ToggleOnionSkin,
MenuAction::ShowMasterTrack => Self::ToggleLayerVisibility, // not directly mappable MenuAction::ShowMasterTrack => Self::ToggleLayerVisibility, // not directly mappable
MenuAction::NewKeyframe => Self::NewKeyframe, MenuAction::NewKeyframe => Self::NewKeyframe,
MenuAction::NewBlankKeyframe => Self::NewBlankKeyframe, MenuAction::NewBlankKeyframe => Self::NewBlankKeyframe,
@ -392,6 +395,7 @@ impl TryFrom<AppAction> for MenuAction {
AppAction::AddTestClip => MenuAction::AddTestClip, AppAction::AddTestClip => MenuAction::AddTestClip,
AppAction::DeleteLayer => MenuAction::DeleteLayer, AppAction::DeleteLayer => MenuAction::DeleteLayer,
AppAction::ToggleLayerVisibility => MenuAction::ToggleLayerVisibility, AppAction::ToggleLayerVisibility => MenuAction::ToggleLayerVisibility,
AppAction::ToggleOnionSkin => MenuAction::ToggleOnionSkin,
AppAction::NewKeyframe => MenuAction::NewKeyframe, AppAction::NewKeyframe => MenuAction::NewKeyframe,
AppAction::NewBlankKeyframe => MenuAction::NewBlankKeyframe, AppAction::NewBlankKeyframe => MenuAction::NewBlankKeyframe,
AppAction::DeleteFrame => MenuAction::DeleteFrame, AppAction::DeleteFrame => MenuAction::DeleteFrame,
@ -507,6 +511,7 @@ pub fn all_defaults() -> HashMap<AppAction, Option<Shortcut>> {
defaults.insert(AppAction::TogglePlayPause, Some(Shortcut::new(ShortcutKey::Space, nc, ns, na))); defaults.insert(AppAction::TogglePlayPause, Some(Shortcut::new(ShortcutKey::Space, nc, ns, na)));
defaults.insert(AppAction::CancelAction, Some(Shortcut::new(ShortcutKey::Escape, nc, ns, na))); defaults.insert(AppAction::CancelAction, Some(Shortcut::new(ShortcutKey::Escape, nc, ns, na)));
defaults.insert(AppAction::ToggleDebugOverlay, Some(Shortcut::new(ShortcutKey::F3, nc, ns, na))); defaults.insert(AppAction::ToggleDebugOverlay, Some(Shortcut::new(ShortcutKey::F3, nc, ns, na)));
defaults.insert(AppAction::ToggleOnionSkin, Some(Shortcut::new(ShortcutKey::O, nc, ns, na)));
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
defaults.insert(AppAction::ToggleTestMode, Some(Shortcut::new(ShortcutKey::F5, nc, ns, na))); defaults.insert(AppAction::ToggleTestMode, Some(Shortcut::new(ShortcutKey::F5, nc, ns, na)));

View File

@ -984,8 +984,9 @@ struct EditorApp {
snap_enabled: bool, // Whether to snap to geometry (default: true) snap_enabled: bool, // Whether to snap to geometry (default: true)
paint_bucket_gap_tolerance: f64, // Fill gap tolerance for paint bucket (default: 5.0) paint_bucket_gap_tolerance: f64, // Fill gap tolerance for paint bucket (default: 5.0)
polygon_sides: u32, // Number of sides for polygon tool (default: 5) polygon_sides: u32, // Number of sides for polygon tool (default: 5)
// Region select state /// Undo depth before the current region-select session's first cut (see
region_selection: Option<lightningbeam_core::selection::RegionSelection>, /// `resolve_pending_region_cut`). `None` when no region selection is pending.
pending_region_cut_base: Option<usize>,
region_select_mode: lightningbeam_core::tool::RegionSelectMode, region_select_mode: lightningbeam_core::tool::RegionSelectMode,
lasso_mode: lightningbeam_core::tool::LassoMode, lasso_mode: lightningbeam_core::tool::LassoMode,
@ -1026,6 +1027,8 @@ struct EditorApp {
recording_mirror_rx: Option<rtrb::Consumer<f32>>, recording_mirror_rx: Option<rtrb::Consumer<f32>>,
/// Current file path (None if not yet saved) /// Current file path (None if not yet saved)
current_file_path: Option<std::path::PathBuf>, current_file_path: Option<std::path::PathBuf>,
/// Onion-skinning view settings (toggle + frame counts + opacity).
onion_skin: crate::panes::OnionSkinSettings,
/// On-demand loader for raster keyframe pixels from the project `.beam` container. /// On-demand loader for raster keyframe pixels from the project `.beam` container.
raster_store: lightningbeam_core::raster_store::RasterStore, raster_store: lightningbeam_core::raster_store::RasterStore,
/// Miss-sink: raster keyframe ids the canvas wanted but whose pixels weren't /// Miss-sink: raster keyframe ids the canvas wanted but whose pixels weren't
@ -1070,6 +1073,11 @@ struct EditorApp {
preferences_dialog: preferences::dialog::PreferencesDialog, preferences_dialog: preferences::dialog::PreferencesDialog,
/// Export orchestrator for background exports /// Export orchestrator for background exports
export_orchestrator: Option<export::ExportOrchestrator>, export_orchestrator: Option<export::ExportOrchestrator>,
/// Vello renderer + image cache reused across all frames of an in-progress export
/// (built once on the first export pump, dropped when export ends) — building a new
/// renderer per frame was the dominant export cost.
export_renderer: Option<vello::Renderer>,
export_image_cache: Option<lightningbeam_core::renderer::ImageCache>,
/// GPU-rendered effect thumbnail generator /// GPU-rendered effect thumbnail generator
effect_thumbnail_generator: Option<EffectThumbnailGenerator>, effect_thumbnail_generator: Option<EffectThumbnailGenerator>,
@ -1296,7 +1304,7 @@ impl EditorApp {
snap_enabled: true, // Default to snapping snap_enabled: true, // Default to snapping
paint_bucket_gap_tolerance: 5.0, // Default gap tolerance paint_bucket_gap_tolerance: 5.0, // Default gap tolerance
polygon_sides: 5, // Default to pentagon polygon_sides: 5, // Default to pentagon
region_selection: None, pending_region_cut_base: None,
region_select_mode: lightningbeam_core::tool::RegionSelectMode::default(), region_select_mode: lightningbeam_core::tool::RegionSelectMode::default(),
lasso_mode: lightningbeam_core::tool::LassoMode::default(), lasso_mode: lightningbeam_core::tool::LassoMode::default(),
input_level: 0.0, input_level: 0.0,
@ -1313,6 +1321,7 @@ impl EditorApp {
waveform_result_tx, waveform_result_tx,
recording_mirror_rx, recording_mirror_rx,
current_file_path: None, // No file loaded initially current_file_path: None, // No file loaded initially
onion_skin: crate::panes::OnionSkinSettings::default(),
raster_store: lightningbeam_core::raster_store::RasterStore::new(None), raster_store: lightningbeam_core::raster_store::RasterStore::new(None),
raster_fault_requests: Default::default(), raster_fault_requests: Default::default(),
raster_load_result_tx, raster_load_result_tx,
@ -1330,6 +1339,8 @@ impl EditorApp {
large_media_prompt: None, large_media_prompt: None,
preferences_dialog: preferences::dialog::PreferencesDialog::default(), preferences_dialog: preferences::dialog::PreferencesDialog::default(),
export_orchestrator: None, export_orchestrator: None,
export_renderer: None,
export_image_cache: None,
effect_thumbnail_generator: None, // Initialized when GPU available effect_thumbnail_generator: None, // Initialized when GPU available
// Debug test mode (F5) // Debug test mode (F5)
@ -2223,40 +2234,6 @@ impl EditorApp {
} }
} }
/// Porter-Duff "over" composite of `src` onto `dst` at canvas offset `(ox, oy)`.
/// Both buffers are sRGB-encoded premultiplied RGBA.
fn composite_over(
dst: &mut [u8], dst_w: u32, dst_h: u32,
src: &[u8], src_w: u32, src_h: u32,
ox: i32, oy: i32,
) {
for row in 0..src_h {
let dy = oy + row as i32;
if dy < 0 || dy >= dst_h as i32 { continue; }
for col in 0..src_w {
let dx = ox + col as i32;
if dx < 0 || dx >= dst_w as i32 { continue; }
let si = ((row * src_w + col) * 4) as usize;
let di = ((dy as u32 * dst_w + dx as u32) * 4) as usize;
let sa = src[si + 3] as u32;
if sa == 0 { continue; }
let da = dst[di + 3] as u32;
// out_a = src_a + dst_a * (255 - src_a) / 255
let out_a = sa + da * (255 - sa) / 255;
dst[di + 3] = out_a as u8;
if out_a > 0 {
for c in 0..3 {
// premul over: out = src + dst*(1-src_a/255)
// v is in [0, 255²], so one /255 brings it back to [0, 255]
let v = src[si + c] as u32 * 255
+ dst[di + c] as u32 * (255 - sa);
dst[di + c] = (v / 255).min(255) as u8;
}
}
}
}
}
/// Commit a floating raster selection: composite it into the keyframe's /// Commit a floating raster selection: composite it into the keyframe's
/// `raw_pixels` and record a `RasterStrokeAction` for undo. /// `raw_pixels` and record a `RasterStrokeAction` for undo.
/// Clears `selection.raster_floating` and `selection.raster_selection`. /// Clears `selection.raster_floating` and `selection.raster_selection`.
@ -2619,27 +2596,7 @@ impl EditorApp {
None => return, None => return,
}; };
// Region selection case: faces are selected but no edges. // Delete the selected edges (region/marquee/click all populate the same sets).
// The inside geometry was already extracted from the live DCEL;
// commit the current state (outside + boundary) using the
// pre-boundary snapshot as the "before" for undo.
if self.selection.selected_edges().is_empty() {
if let Some(region_sel) = self.region_selection.take() {
// dcel_snapshot = state before boundary was inserted.
// Current document DCEL = outside portion only (boundary edges present).
// We commit the snapshot as "before" and the current state as "after",
// then drop the region selection so it is not merged back.
// TODO: Region selection delete requires converting Dcel snapshot
// to VectorGraph for ModifyGraphAction. Deferred until RegionSelection
// is migrated from Dcel to VectorGraph.
eprintln!("Region selection delete: not yet ported to VectorGraph");
// region_sel is dropped; the stage pane will see region_selection == None.
}
self.selection.clear_geometry_selection();
return;
}
// Select-tool case: delete the selected edges.
let edge_ids: Vec<lightningbeam_core::vector_graph::EdgeId> = let edge_ids: Vec<lightningbeam_core::vector_graph::EdgeId> =
self.selection.selected_edges().iter().copied().collect(); self.selection.selected_edges().iter().copied().collect();
@ -2833,7 +2790,7 @@ impl EditorApp {
let canvas_before = kf.raw_pixels.clone(); let canvas_before = kf.raw_pixels.clone();
let canvas_w = kf.width; let canvas_w = kf.width;
let canvas_h = kf.height; let canvas_h = kf.height;
drop(kf); // release immutable borrow before taking mutable let _ = kf; // release immutable borrow before taking mutable
use lightningbeam_core::selection::{RasterFloatingSelection, RasterSelection}; use lightningbeam_core::selection::{RasterFloatingSelection, RasterSelection};
self.selection.raster_floating = Some(RasterFloatingSelection { self.selection.raster_floating = Some(RasterFloatingSelection {
@ -3026,38 +2983,31 @@ impl EditorApp {
} }
} }
/// Revert an uncommitted region selection, restoring original shapes /// Resolve a pending region-select cut once its selection has been cleared.
fn revert_region_selection( ///
region_selection: &mut Option<lightningbeam_core::selection::RegionSelection>, /// A region/lasso selection cuts the geometry (an undoable `"Region select"` action)
action_executor: &mut lightningbeam_core::action::ActionExecutor, /// and selects the resulting sub-pieces. When that selection is deselected:
selection: &mut lightningbeam_core::selection::Selection, /// - if nothing was edited in between, the cut(s) are healed (undone) so the lasso
) { /// was non-destructive — the shape returns to whole;
use lightningbeam_core::layer::AnyLayer; /// - if anything was edited, everything is kept (the edits — and the cut they rely
/// on — stay on the undo stack), i.e. the change is "merged" in place.
let region_sel = match region_selection.take() { /// We tell the two apart by walking the undo stack down to the recorded base depth and
Some(rs) => rs, /// only undoing while the top action is itself a region-select cut; the first non-cut
None => return, /// action (an edit) stops the heal and leaves everything intact.
}; fn resolve_pending_region_cut(&mut self) {
let Some(base) = self.pending_region_cut_base else { return };
if region_sel.committed { // Only resolve once the region selection has actually been deselected.
if self.selection.has_geometry_selection() {
return; return;
} }
while self.action_executor.undo_depth() > base {
let doc = action_executor.document_mut(); if self.action_executor.undo_description().as_deref() == Some("Region select") {
let layer = match doc.get_layer_mut(&region_sel.layer_id) { let _ = self.action_executor.undo();
Some(l) => l, } else {
None => return, break; // an edit sits on top → the user changed something; keep it all.
}; }
let vector_layer = match layer { }
AnyLayer::Vector(vl) => vl, self.pending_region_cut_base = None;
_ => return,
};
// TODO: DCEL - region selection revert disabled during migration
// (was: remove/add_shape_from/to_keyframe for splits)
let _ = vector_layer;
selection.clear();
} }
fn handle_menu_action(&mut self, action: MenuAction) { fn handle_menu_action(&mut self, action: MenuAction) {
@ -3292,8 +3242,6 @@ impl EditorApp {
has_raster: false, has_raster: false,
has_vector: false, has_vector: false,
current_time: doc.current_time, current_time: doc.current_time,
doc_width: doc.width as u32,
doc_height: doc.height as u32,
}; };
scan(&doc.root.children, &mut h); scan(&doc.root.children, &mut h);
h h
@ -3461,26 +3409,24 @@ impl EditorApp {
self.pending_node_group = true; self.pending_node_group = true;
} }
_ => { _ => {
// Existing clip instance grouping fallback (stub) // Group selected geometry into a group clip + clip instance.
if let Some(layer_id) = self.active_layer_id { if let Some(layer_id) = self.active_layer_id {
if self.selection.has_geometry_selection() { if self.selection.has_geometry_selection() {
// TODO: DCEL group deferred to Phase 2 let fills: Vec<_> = self.selection.selected_fills().iter().copied().collect();
} else { let edges: Vec<_> = self.selection.selected_edges().iter().copied().collect();
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec(); let clip_id = uuid::Uuid::new_v4();
if clip_ids.len() >= 2 { let instance_id = uuid::Uuid::new_v4();
let instance_id = uuid::Uuid::new_v4(); let action = lightningbeam_core::actions::GroupAction::new(
let action = lightningbeam_core::actions::GroupAction::new( layer_id, self.playback_time, fills, edges, clip_id, instance_id,
layer_id, self.playback_time, Vec::new(), clip_ids, instance_id, );
); if let Err(e) = self.action_executor.execute(Box::new(action)) {
if let Err(e) = self.action_executor.execute(Box::new(action)) { eprintln!("Failed to group: {}", e);
eprintln!("Failed to group: {}", e); } else {
} else { self.selection.clear();
self.selection.clear(); self.selection.add_clip_instance(instance_id);
self.selection.add_clip_instance(instance_id);
}
} }
} }
let _ = layer_id; // (Grouping existing clip instances is a separate op, not wired.)
} }
} }
} }
@ -3488,24 +3434,18 @@ impl EditorApp {
MenuAction::ConvertToMovieClip => { MenuAction::ConvertToMovieClip => {
if let Some(layer_id) = self.active_layer_id { if let Some(layer_id) = self.active_layer_id {
if self.selection.has_geometry_selection() { if self.selection.has_geometry_selection() {
// TODO: DCEL convert-to-movie-clip deferred to Phase 2 let fills: Vec<_> = self.selection.selected_fills().iter().copied().collect();
} else { let edges: Vec<_> = self.selection.selected_edges().iter().copied().collect();
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec(); let clip_id = uuid::Uuid::new_v4();
if clip_ids.len() >= 1 { let instance_id = uuid::Uuid::new_v4();
let instance_id = uuid::Uuid::new_v4(); let action = lightningbeam_core::actions::ConvertToMovieClipAction::new(
let action = lightningbeam_core::actions::ConvertToMovieClipAction::new( layer_id, self.playback_time, fills, edges, clip_id, instance_id,
layer_id, );
self.playback_time, if let Err(e) = self.action_executor.execute(Box::new(action)) {
Vec::new(), eprintln!("Failed to convert to movie clip: {}", e);
clip_ids, } else {
instance_id, self.selection.clear();
); self.selection.add_clip_instance(instance_id);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Failed to convert to movie clip: {}", e);
} else {
self.selection.clear();
self.selection.add_clip_instance(instance_id);
}
} }
} }
} }
@ -3660,7 +3600,7 @@ impl EditorApp {
let doc = self.action_executor.document(); let doc = self.action_executor.document();
let (doc_w, doc_h) = (doc.width as u32, doc.height as u32); let (doc_w, doc_h) = (doc.width as u32, doc.height as u32);
drop(doc); let _ = doc;
let mut layer = RasterLayer::new(layer_name); let mut layer = RasterLayer::new(layer_name);
layer.ensure_keyframe_at(self.playback_time, doc_w, doc_h); layer.ensure_keyframe_at(self.playback_time, doc_w, doc_h);
let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Raster(layer)) let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Raster(layer))
@ -3785,8 +3725,16 @@ impl EditorApp {
// TODO: Implement add motion tween // TODO: Implement add motion tween
} }
MenuAction::AddShapeTween => { MenuAction::AddShapeTween => {
println!("Menu: Add Shape Tween"); if let Some(layer_id) = self.active_layer_id {
// TODO: Implement add shape tween let action = lightningbeam_core::actions::SetTweenAction::new(
layer_id,
self.playback_time,
lightningbeam_core::layer::TweenType::Shape,
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Failed to add shape tween: {}", e);
}
}
} }
MenuAction::ReturnToStart => { MenuAction::ReturnToStart => {
println!("Menu: Return to Start"); println!("Menu: Return to Start");
@ -3816,6 +3764,9 @@ impl EditorApp {
MenuAction::RecenterView => { MenuAction::RecenterView => {
self.pending_view_action = Some(MenuAction::RecenterView); self.pending_view_action = Some(MenuAction::RecenterView);
} }
MenuAction::ToggleOnionSkin => {
self.onion_skin.enabled = !self.onion_skin.enabled;
}
MenuAction::NextLayout => { MenuAction::NextLayout => {
println!("Menu: Next Layout"); println!("Menu: Next Layout");
let next_index = (self.current_layout_index + 1) % self.layouts.len(); let next_index = (self.current_layout_index + 1) % self.layouts.len();
@ -4915,15 +4866,22 @@ impl EditorApp {
// Add clip instance or shape to the target layer // Add clip instance or shape to the target layer
if let Some(layer_id) = target_layer_id { if let Some(layer_id) = target_layer_id {
// For images, create a shape with image fill instead of a clip instance // For images, create an image-filled rectangle on the (vector) layer,
// centered on the canvas at native size.
if asset_info.clip_type == panes::DragClipType::Image { if asset_info.clip_type == panes::DragClipType::Image {
// Get image dimensions let (img_w, img_h) = asset_info.dimensions.unwrap_or((100.0, 100.0));
let (width, height) = asset_info.dimensions.unwrap_or((100.0, 100.0)); let (doc_w, doc_h) = {
let d = self.action_executor.document();
// TODO: Image fills on DCEL faces are a separate feature. (d.width as f64, d.height as f64)
// For now, just log a message. };
let _ = (layer_id, width, height); let x = (doc_w - img_w) / 2.0;
eprintln!("Image drop to canvas not yet supported with DCEL backend"); let y = (doc_h - img_h) / 2.0;
let action = lightningbeam_core::actions::AddShapeAction::image_rect(
layer_id, self.playback_time, x, y, img_w, img_h, asset_info.clip_id,
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Failed to place image: {}", e);
}
} else { } else {
// For clips, create a clip instance // For clips, create a clip instance
let mut clip_instance = ClipInstance::new(asset_info.clip_id) let mut clip_instance = ClipInstance::new(asset_info.clip_id)
@ -5781,7 +5739,7 @@ impl eframe::App for EditorApp {
.map(|(&lid, _)| lid); .map(|(&lid, _)| lid);
if let Some(layer_id) = recording_layer { if let Some(layer_id) = recording_layer {
// First, find the clip instance and clip id // First, find the clip instance and clip id
let (clip_id, instance_id, timeline_start, trim_start) = { let (clip_id, instance_id, _timeline_start, _trim_start) = {
let document = self.action_executor.document(); let document = self.action_executor.document();
document.get_layer(&layer_id) document.get_layer(&layer_id)
.and_then(|layer| { .and_then(|layer| {
@ -6195,17 +6153,17 @@ impl eframe::App for EditorApp {
self.render_large_media_prompt(ctx); self.render_large_media_prompt(ctx);
// Render video frames incrementally (if video export in progress) // Render video frames incrementally (if video export in progress)
if let Some(orchestrator) = &mut self.export_orchestrator { let exporting = self.export_orchestrator.as_ref().map_or(false, |o| o.is_exporting());
if orchestrator.is_exporting() { if exporting {
// Get GPU resources from eframe's wgpu render state if let Some(render_state) = frame.wgpu_render_state() {
if let Some(render_state) = frame.wgpu_render_state() { let device = &render_state.device;
let device = &render_state.device; let queue = &render_state.queue;
let queue = &render_state.queue;
// Create temporary renderer and image cache for export // Build the renderer + image cache ONCE per export (reused every frame).
// Note: Creating a new renderer per frame is inefficient but simple // The image cache is given the container path so lazily-paged image assets
// TODO: Reuse renderer across frames by storing it in EditorApp // (Phase 4 Tier 1) decode during export.
let mut temp_renderer = vello::Renderer::new( if self.export_renderer.is_none() {
self.export_renderer = vello::Renderer::new(
device, device,
vello::RendererOptions { vello::RendererOptions {
use_cpu: false, use_cpu: false,
@ -6214,43 +6172,52 @@ impl eframe::App for EditorApp {
pipeline_cache: None, pipeline_cache: None,
}, },
).ok(); ).ok();
let mut ic = lightningbeam_core::renderer::ImageCache::new();
ic.set_container_path(self.current_file_path.clone());
self.export_image_cache = Some(ic);
}
let mut temp_image_cache = lightningbeam_core::renderer::ImageCache::new(); if let (Some(renderer), Some(image_cache), Some(orchestrator)) = (
self.export_renderer.as_mut(),
if let Some(renderer) = &mut temp_renderer { self.export_image_cache.as_mut(),
// Drive incremental video export. self.export_orchestrator.as_mut(),
if let Ok(has_more) = orchestrator.render_next_video_frame( ) {
self.action_executor.document_mut(), // Drive incremental video export.
device, if let Ok(has_more) = orchestrator.render_next_video_frame(
queue, self.action_executor.document_mut(),
renderer, device,
&mut temp_image_cache, queue,
&self.video_manager, renderer,
Some(&self.raster_store), image_cache,
) { &self.video_manager,
if has_more { Some(&self.raster_store),
ctx.request_repaint(); ) {
} if has_more {
ctx.request_repaint();
} }
}
// Drive single-frame image export (two-frame async: render then readback). // Drive single-frame image export (two-frame async: render then readback).
match orchestrator.render_image_frame( match orchestrator.render_image_frame(
self.action_executor.document_mut(), self.action_executor.document_mut(),
device, device,
queue, queue,
renderer, renderer,
&mut temp_image_cache, image_cache,
&self.video_manager, &self.video_manager,
self.selection.raster_floating.as_ref(), self.selection.raster_floating.as_ref(),
Some(&self.raster_store), Some(&self.raster_store),
) { ) {
Ok(false) => { ctx.request_repaint(); } // readback pending Ok(false) => { ctx.request_repaint(); } // readback pending
Ok(true) => {} // done or cancelled Ok(true) => {} // done or cancelled
Err(e) => { eprintln!("Image export failed: {e}"); } Err(e) => { eprintln!("Image export failed: {e}"); }
}
} }
} }
} }
} else if self.export_renderer.is_some() {
// Export finished — free the renderer + cache.
self.export_renderer = None;
self.export_image_cache = None;
} }
// Poll export orchestrator for progress // Poll export orchestrator for progress
@ -6459,6 +6426,14 @@ impl eframe::App for EditorApp {
// Create render context // Create render context
let mut ctx = RenderContext { let mut ctx = RenderContext {
shared: panes::SharedPaneState { shared: panes::SharedPaneState {
container_path: self.current_file_path.clone(),
onion: {
// Onion skinning is disabled during playback.
let mut o = self.onion_skin;
o.enabled = o.enabled && !self.is_playing;
o
},
onion_skin: &mut self.onion_skin,
tool_icon_cache: &mut self.tool_icon_cache, tool_icon_cache: &mut self.tool_icon_cache,
icon_cache: &mut self.icon_cache, icon_cache: &mut self.icon_cache,
selected_tool: &mut self.selected_tool, selected_tool: &mut self.selected_tool,
@ -6534,13 +6509,12 @@ impl eframe::App for EditorApp {
graph_topology_generation: &mut self.graph_topology_generation, graph_topology_generation: &mut self.graph_topology_generation,
script_to_edit: &mut self.script_to_edit, script_to_edit: &mut self.script_to_edit,
script_saved: &mut self.script_saved, script_saved: &mut self.script_saved,
region_selection: &mut self.region_selection, pending_region_cut_base: &mut self.pending_region_cut_base,
region_select_mode: &mut self.region_select_mode, region_select_mode: &mut self.region_select_mode,
lasso_mode: &mut self.lasso_mode, lasso_mode: &mut self.lasso_mode,
pending_graph_loads: &self.pending_graph_loads, pending_graph_loads: &self.pending_graph_loads,
clipboard_consumed: &mut clipboard_consumed, clipboard_consumed: &mut clipboard_consumed,
keymap: &self.keymap, keymap: &self.keymap,
commit_raster_floating_if_any: &mut self.commit_raster_floating_if_any,
pending_node_group: &mut self.pending_node_group, pending_node_group: &mut self.pending_node_group,
pending_node_ungroup: &mut self.pending_node_ungroup, pending_node_ungroup: &mut self.pending_node_ungroup,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@ -6973,21 +6947,21 @@ impl eframe::App for EditorApp {
} }
} }
// Escape key: cancel floating raster selection or revert uncommitted region selection // Escape key: cancel floating raster selection, or clear the geometry selection
if !wants_keyboard && ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::CancelAction, i)) { if !wants_keyboard && ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::CancelAction, i)) {
if self.selection.raster_floating.is_some() { if self.selection.raster_floating.is_some() {
self.cancel_raster_floating(); self.cancel_raster_floating();
} else if self.selection.raster_selection.is_some() { } else if self.selection.raster_selection.is_some() {
self.selection.raster_selection = None; self.selection.raster_selection = None;
} else if self.region_selection.is_some() { } else if self.selection.has_geometry_selection() {
Self::revert_region_selection( self.selection.clear_geometry_selection();
&mut self.region_selection,
&mut self.action_executor,
&mut self.selection,
);
} }
} }
// After all input is processed, if a region selection was deselected without any
// intervening edit, heal (undo) its cut so the lasso is non-destructive.
self.resolve_pending_region_cut();
// F3 debug overlay toggle (works even when text input is active) // F3 debug overlay toggle (works even when text input is active)
if ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::ToggleDebugOverlay, i)) { if ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::ToggleDebugOverlay, i)) {
self.debug_overlay_visible = !self.debug_overlay_visible; self.debug_overlay_visible = !self.debug_overlay_visible;
@ -7501,7 +7475,7 @@ fn render_pane(
// Active tab highlight with per-corner rounding // Active tab highlight with per-corner rounding
if is_active { if is_active {
let cr = corner_r as u8; let cr = corner_r as u8;
let rounding = egui::Rounding { let rounding = egui::CornerRadius {
nw: if i == 0 { cr } else { 0 }, nw: if i == 0 { cr } else { 0 },
sw: if i == 0 { cr } else { 0 }, sw: if i == 0 { cr } else { 0 },
ne: if i == n - 1 { cr } else { 0 }, ne: if i == n - 1 { cr } else { 0 },
@ -7546,7 +7520,7 @@ fn render_pane(
if tab_response.hovered() && !is_active { if tab_response.hovered() && !is_active {
ui.painter().rect_filled( ui.painter().rect_filled(
tab_rect, tab_rect,
egui::Rounding { egui::CornerRadius {
nw: if i == 0 { corner_r as u8 } else { 0 }, nw: if i == 0 { corner_r as u8 } else { 0 },
sw: if i == 0 { corner_r as u8 } else { 0 }, sw: if i == 0 { corner_r as u8 } else { 0 },
ne: if i == n - 1 { corner_r as u8 } else { 0 }, ne: if i == n - 1 { corner_r as u8 } else { 0 },

View File

@ -332,6 +332,7 @@ pub enum MenuAction {
ZoomOut, ZoomOut,
ActualSize, ActualSize,
RecenterView, RecenterView,
ToggleOnionSkin,
NextLayout, NextLayout,
PreviousLayout, PreviousLayout,
#[allow(dead_code)] // Handler exists in main.rs, menu item not yet wired #[allow(dead_code)] // Handler exists in main.rs, menu item not yet wired
@ -432,6 +433,7 @@ impl MenuItemDef {
const ZOOM_OUT: Self = Self { label: "Zoom Out", action: MenuAction::ZoomOut, shortcut: Some(Shortcut::new(ShortcutKey::Minus, CTRL, NO_SHIFT, NO_ALT)) }; const ZOOM_OUT: Self = Self { label: "Zoom Out", action: MenuAction::ZoomOut, shortcut: Some(Shortcut::new(ShortcutKey::Minus, CTRL, NO_SHIFT, NO_ALT)) };
const ACTUAL_SIZE: Self = Self { label: "Actual Size", action: MenuAction::ActualSize, shortcut: Some(Shortcut::new(ShortcutKey::Num0, CTRL, NO_SHIFT, NO_ALT)) }; const ACTUAL_SIZE: Self = Self { label: "Actual Size", action: MenuAction::ActualSize, shortcut: Some(Shortcut::new(ShortcutKey::Num0, CTRL, NO_SHIFT, NO_ALT)) };
const RECENTER_VIEW: Self = Self { label: "Recenter View", action: MenuAction::RecenterView, shortcut: None }; const RECENTER_VIEW: Self = Self { label: "Recenter View", action: MenuAction::RecenterView, shortcut: None };
const TOGGLE_ONION_SKIN: Self = Self { label: "Onion Skinning", action: MenuAction::ToggleOnionSkin, shortcut: Some(Shortcut::new(ShortcutKey::O, NO_CTRL, NO_SHIFT, NO_ALT)) };
const NEXT_LAYOUT: Self = Self { label: "Next Layout", action: MenuAction::NextLayout, shortcut: Some(Shortcut::new(ShortcutKey::BracketRight, CTRL, NO_SHIFT, NO_ALT)) }; const NEXT_LAYOUT: Self = Self { label: "Next Layout", action: MenuAction::NextLayout, shortcut: Some(Shortcut::new(ShortcutKey::BracketRight, CTRL, NO_SHIFT, NO_ALT)) };
const PREVIOUS_LAYOUT: Self = Self { label: "Previous Layout", action: MenuAction::PreviousLayout, shortcut: Some(Shortcut::new(ShortcutKey::BracketLeft, CTRL, NO_SHIFT, NO_ALT)) }; const PREVIOUS_LAYOUT: Self = Self { label: "Previous Layout", action: MenuAction::PreviousLayout, shortcut: Some(Shortcut::new(ShortcutKey::BracketLeft, CTRL, NO_SHIFT, NO_ALT)) };
@ -455,6 +457,7 @@ impl MenuItemDef {
&Self::DELETE, &Self::SELECT_ALL, &Self::SELECT_NONE, &Self::DELETE, &Self::SELECT_ALL, &Self::SELECT_NONE,
&Self::GROUP, &Self::ADD_LAYER, &Self::NEW_KEYFRAME, &Self::GROUP, &Self::ADD_LAYER, &Self::NEW_KEYFRAME,
&Self::ZOOM_IN, &Self::ZOOM_OUT, &Self::ACTUAL_SIZE, &Self::ZOOM_IN, &Self::ZOOM_OUT, &Self::ACTUAL_SIZE,
&Self::TOGGLE_ONION_SKIN,
&Self::NEXT_LAYOUT, &Self::PREVIOUS_LAYOUT, &Self::NEXT_LAYOUT, &Self::PREVIOUS_LAYOUT,
&Self::SETTINGS, &Self::CLOSE_WINDOW, &Self::SETTINGS, &Self::CLOSE_WINDOW,
] ]
@ -566,6 +569,7 @@ impl MenuItemDef {
MenuDef::Item(&Self::ZOOM_OUT), MenuDef::Item(&Self::ZOOM_OUT),
MenuDef::Item(&Self::ACTUAL_SIZE), MenuDef::Item(&Self::ACTUAL_SIZE),
MenuDef::Item(&Self::RECENTER_VIEW), MenuDef::Item(&Self::RECENTER_VIEW),
MenuDef::Item(&Self::TOGGLE_ONION_SKIN),
MenuDef::Separator, MenuDef::Separator,
MenuDef::Submenu { MenuDef::Submenu {
label: "Layout", label: "Layout",

View File

@ -238,7 +238,7 @@ pub fn gradient_stop_editor(
// ── Paint handles ───────────────────────────────────────────────────── // ── Paint handles ─────────────────────────────────────────────────────
// handle_rects was built before any deletions this frame; guard against OOB. // handle_rects was built before any deletions this frame; guard against OOB.
for (i, h_rect) in handle_rects.iter().enumerate().take(gradient.stops.len()) { for (i, h_rect) in handle_rects.iter().enumerate().take(gradient.stops.len()) {
let col = ShapeColor_to_Color32(gradient.stops[i].color); let col = shape_color_to_color32(gradient.stops[i].color);
let is_selected = *selected_stop == Some(i); let is_selected = *selected_stop == Some(i);
let stroke = Stroke::new( let stroke = Stroke::new(
if is_selected { 2.0 } else { 1.0 }, if is_selected { 2.0 } else { 1.0 },
@ -308,7 +308,7 @@ pub fn gradient_stop_editor(
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
fn ShapeColor_to_Color32(c: ShapeColor) -> Color32 { fn shape_color_to_color32(c: ShapeColor) -> Color32 {
Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a) Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
} }

View File

@ -11,8 +11,8 @@
/// - Document settings (when nothing is focused) /// - Document settings (when nothing is focused)
use eframe::egui::{self, DragValue, Ui}; use eframe::egui::{self, DragValue, Ui};
use lightningbeam_core::brush_settings::{bundled_brushes, BrushSettings}; use lightningbeam_core::brush_settings::bundled_brushes;
use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction, SetFillPaintAction}; use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction, SetFillPaintAction, SetImageFillAction};
use lightningbeam_core::gradient::ShapeGradient; use lightningbeam_core::gradient::ShapeGradient;
use lightningbeam_core::layer::{AnyLayer, LayerTrait}; use lightningbeam_core::layer::{AnyLayer, LayerTrait};
use lightningbeam_core::selection::FocusSelection; use lightningbeam_core::selection::FocusSelection;
@ -785,6 +785,26 @@ impl InfopanelPane {
let edge_ids: Vec<lightningbeam_core::vector_graph::EdgeId> = shared.selection.selected_edges() let edge_ids: Vec<lightningbeam_core::vector_graph::EdgeId> = shared.selection.selected_edges()
.iter().map(|eid| lightningbeam_core::vector_graph::EdgeId(eid.0)).collect(); .iter().map(|eid| lightningbeam_core::vector_graph::EdgeId(eid.0)).collect();
// Image-fill state for the selected fills + the document's image assets, for
// the picker below. Gathered now (immutable read) before `shared` is borrowed mut.
let image_assets: Vec<(Uuid, String)> = {
let doc = shared.action_executor.document();
let mut v: Vec<(Uuid, String)> = doc.image_assets.iter()
.map(|(id, a)| (*id, a.name.clone())).collect();
v.sort_by(|a, b| a.1.cmp(&b.1));
v
};
let current_image_fill: Option<Uuid> = {
let doc = shared.action_executor.document();
match doc.get_layer(&layer_id) {
Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => vl
.graph_at_time(time)
.and_then(|g| face_ids.first().map(|&fid| g.fill(fid).image_fill))
.flatten(),
_ => None,
}
};
egui::CollapsingHeader::new("Shape") egui::CollapsingHeader::new("Shape")
.id_salt(("shape", path)) .id_salt(("shape", path))
.default_open(self.shape_section_open) .default_open(self.shape_section_open)
@ -792,47 +812,67 @@ impl InfopanelPane {
self.shape_section_open = true; self.shape_section_open = true;
ui.add_space(4.0); ui.add_space(4.0);
// Fill — determine current fill type // Fill — determine current fill type. `image_fill` takes render priority,
// so when set it's the active type (overriding colour/gradient underneath);
// switching to None/Solid/Gradient clears it.
let has_image = current_image_fill.is_some();
let has_gradient = matches!(&info.fill_gradient, Some(Some(_))); let has_gradient = matches!(&info.fill_gradient, Some(Some(_)));
let has_solid = matches!(&info.fill_color, Some(Some(_))); let has_solid = matches!(&info.fill_color, Some(Some(_)));
let fill_is_none = matches!(&info.fill_color, Some(None)) let fill_is_none = matches!(&info.fill_color, Some(None))
&& matches!(&info.fill_gradient, Some(None)); && matches!(&info.fill_gradient, Some(None));
let fill_mixed = info.fill_color.is_none() && info.fill_gradient.is_none(); let fill_mixed = info.fill_color.is_none() && info.fill_gradient.is_none();
// Fill type toggle row // Fill type toggle row: None | Solid | Gradient | Image
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Fill:"); ui.label("Fill:");
if fill_mixed { if fill_mixed {
ui.label("--"); ui.label("--");
} else { } else {
if ui.selectable_label(fill_is_none, "None").clicked() && !fill_is_none { if ui.selectable_label(fill_is_none && !has_image, "None").clicked() {
let action = SetFillPaintAction::solid( if has_image {
layer_id, time, face_ids.clone(), None, shared.pending_actions.push(Box::new(
); SetImageFillAction::new(layer_id, time, face_ids.clone(), None)));
shared.pending_actions.push(Box::new(action)); }
shared.pending_actions.push(Box::new(
SetFillPaintAction::solid(layer_id, time, face_ids.clone(), None)));
} }
if ui.selectable_label(has_solid || (!has_gradient && !fill_is_none), "Solid").clicked() && !has_solid { if ui.selectable_label(has_solid && !has_image, "Solid").clicked() {
// Switch to solid: use existing color or default to black if has_image {
let color = info.fill_color.flatten() shared.pending_actions.push(Box::new(
.unwrap_or(ShapeColor::rgba(0, 0, 0, 255)); SetImageFillAction::new(layer_id, time, face_ids.clone(), None)));
let action = SetFillPaintAction::solid( }
layer_id, time, face_ids.clone(), Some(color), if !has_solid {
); let color = info.fill_color.flatten()
shared.pending_actions.push(Box::new(action)); .unwrap_or(ShapeColor::rgba(0, 0, 0, 255));
shared.pending_actions.push(Box::new(
SetFillPaintAction::solid(layer_id, time, face_ids.clone(), Some(color))));
}
} }
if ui.selectable_label(has_gradient, "Gradient").clicked() && !has_gradient { if ui.selectable_label(has_gradient && !has_image, "Gradient").clicked() {
let grad = info.fill_gradient.clone().flatten() if has_image {
.unwrap_or_default(); shared.pending_actions.push(Box::new(
let action = SetFillPaintAction::gradient( SetImageFillAction::new(layer_id, time, face_ids.clone(), None)));
layer_id, time, face_ids.clone(), Some(grad), }
); if !has_gradient {
shared.pending_actions.push(Box::new(action)); let grad = info.fill_gradient.clone().flatten().unwrap_or_default();
shared.pending_actions.push(Box::new(
SetFillPaintAction::gradient(layer_id, time, face_ids.clone(), Some(grad))));
}
}
// Image tab — only offered if there are imported image assets.
if !image_assets.is_empty() || has_image {
if ui.selectable_label(has_image, "Image").clicked() && !has_image {
if let Some((aid, _)) = image_assets.first() {
shared.pending_actions.push(Box::new(
SetImageFillAction::new(layer_id, time, face_ids.clone(), Some(*aid))));
}
}
} }
} }
}); });
// Solid fill color editor // Solid fill color editor
if !fill_mixed && has_solid { if !fill_mixed && has_solid && !has_image {
if let Some(Some(color)) = info.fill_color { if let Some(Some(color)) = info.fill_color {
ui.horizontal(|ui| { ui.horizontal(|ui| {
let mut rgba = [color.r, color.g, color.b, color.a]; let mut rgba = [color.r, color.g, color.b, color.a];
@ -848,7 +888,7 @@ impl InfopanelPane {
} }
// Gradient fill editor // Gradient fill editor
if !fill_mixed && has_gradient { if !fill_mixed && has_gradient && !has_image {
if let Some(Some(mut grad)) = info.fill_gradient.clone() { if let Some(Some(mut grad)) = info.fill_gradient.clone() {
if gradient_stop_editor(ui, &mut grad, &mut self.selected_shape_gradient_stop) { if gradient_stop_editor(ui, &mut grad, &mut self.selected_shape_gradient_stop) {
let action = SetFillPaintAction::gradient( let action = SetFillPaintAction::gradient(
@ -859,6 +899,28 @@ impl InfopanelPane {
} }
} }
// Image fill editor — pick which asset (active Image type).
if has_image {
ui.horizontal(|ui| {
ui.label("Image:");
let selected_text = current_image_fill
.and_then(|id| image_assets.iter().find(|(aid, _)| *aid == id))
.map(|(_, n)| n.clone())
.unwrap_or_else(|| "(missing)".to_string());
egui::ComboBox::from_id_salt(("image_fill", path))
.selected_text(selected_text)
.show_ui(ui, |ui| {
for (aid, name) in &image_assets {
if ui.selectable_label(current_image_fill == Some(*aid), name).clicked() {
shared.pending_actions.push(Box::new(
SetImageFillAction::new(layer_id, time, face_ids.clone(), Some(*aid)),
));
}
}
});
});
}
// Stroke color // Stroke color
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Stroke:"); ui.label("Stroke:");
@ -1035,6 +1097,29 @@ impl InfopanelPane {
}); });
} }
/// Render the onion-skinning view settings (global; not tied to selection).
fn render_onion_section(&mut self, ui: &mut Ui, path: &NodePath, shared: &mut SharedPaneState) {
egui::CollapsingHeader::new("Onion Skin")
.id_salt(("onion", path))
.default_open(shared.onion_skin.enabled)
.show(ui, |ui| {
ui.add_space(4.0);
ui.checkbox(&mut shared.onion_skin.enabled, "Enabled");
ui.add_enabled_ui(shared.onion_skin.enabled, |ui| {
ui.horizontal(|ui| {
ui.label("Frames before:");
ui.add(DragValue::new(&mut shared.onion_skin.frames_before).range(0..=5));
});
ui.horizontal(|ui| {
ui.label("Frames after:");
ui.add(DragValue::new(&mut shared.onion_skin.frames_after).range(0..=5));
});
ui.add(egui::Slider::new(&mut shared.onion_skin.opacity, 0.0..=1.0).text("Opacity"));
});
ui.add_space(4.0);
});
}
/// Render layer info section /// Render layer info section
fn render_layer_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, layer_ids: &[Uuid]) { fn render_layer_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, layer_ids: &[Uuid]) {
let document = shared.action_executor.document(); let document = shared.action_executor.document();
@ -1354,40 +1439,6 @@ impl InfopanelPane {
} }
} }
/// Draw a brush dab preview into `rect` approximating the brush falloff shape.
///
/// Renders N concentric filled circles from outermost to innermost. Because each
/// inner circle overwrites the pixels of all outer circles beneath it, the visible
/// alpha at distance `d` from the centre equals the alpha of the innermost circle
/// whose radius ≥ `d`. This step-approximates the actual brush falloff formula:
/// `opa = ((1 r) / (1 hardness))²` for `r > hardness`, 1 inside the hard core.
fn paint_brush_dab(painter: &egui::Painter, rect: egui::Rect, s: &BrushSettings) {
let center = rect.center();
let max_r = (rect.width().min(rect.height()) / 2.0 - 2.0).max(1.0);
let h = s.hardness;
let a = s.opaque;
const N: usize = 12;
for i in 0..N {
// t: normalized radial position of this ring, 1.0 = outermost edge
let t = 1.0 - i as f32 / N as f32;
let r = max_r * t;
let opa_weight = if h >= 1.0 || t <= h {
1.0f32
} else {
let x = (1.0 - t) / (1.0 - h).max(1e-4);
(x * x).min(1.0)
};
let alpha = (opa_weight * a * 220.0).min(220.0) as u8;
painter.circle_filled(
center, r,
egui::Color32::from_rgba_unmultiplied(200, 200, 220, alpha),
);
}
}
/// Convert MIDI note number to note name (e.g. 60 -> "C4") /// Convert MIDI note number to note name (e.g. 60 -> "C4")
fn midi_note_name(note: u8) -> String { fn midi_note_name(note: u8) -> String {
const NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; const NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
@ -1478,6 +1529,12 @@ impl PaneRenderer for InfopanelPane {
} }
} }
} }
// Onion-skinning view settings — always available, regardless of selection.
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
self.render_onion_section(ui, path, shared);
}); });
} }

View File

@ -143,7 +143,45 @@ pub fn find_sampled_audio_track(document: &lightningbeam_core::document::Documen
} }
/// Shared state that all panes can access /// Shared state that all panes can access
/// Onion-skinning view settings (editor-only; not saved with the document). Ghosts the
/// active layer's neighbouring keyframes, warm-tinted for past and cool for future.
#[derive(Clone, Copy, Debug)]
pub struct OnionSkinSettings {
pub enabled: bool,
pub frames_before: usize,
pub frames_after: usize,
/// Opacity of the nearest ghost; further ghosts fall off linearly.
pub opacity: f32,
}
impl Default for OnionSkinSettings {
fn default() -> Self {
Self { enabled: false, frames_before: 2, frames_after: 2, opacity: 0.35 }
}
}
impl OnionSkinSettings {
/// RGB tint multipliers for past (warm) and future (cool) ghosts.
pub const PAST_TINT: [f32; 3] = [1.0, 0.45, 0.45];
pub const FUTURE_TINT: [f32; 3] = [0.45, 0.6, 1.0];
/// Opacity for the `n`-th ghost away from the current frame (n = 1 is nearest),
/// linearly falling off so the furthest ghost is faintest.
pub fn ghost_opacity(&self, n: usize, total: usize) -> f32 {
if total == 0 { return self.opacity; }
let falloff = 1.0 - (n.saturating_sub(1) as f32) / (total as f32);
self.opacity * falloff.clamp(0.15, 1.0)
}
}
pub struct SharedPaneState<'a> { pub struct SharedPaneState<'a> {
/// Current `.beam` container path (for lazily paging image-asset bytes in the
/// renderer's ImageCache). `None` before the project is first saved/loaded.
pub container_path: Option<std::path::PathBuf>,
/// Effective onion-skin settings (already gated to off during playback by main.rs).
pub onion: OnionSkinSettings,
/// The raw onion-skin settings, mutable — edited by the Info Panel's controls.
pub onion_skin: &'a mut OnionSkinSettings,
pub tool_icon_cache: &'a mut crate::ToolIconCache, pub tool_icon_cache: &'a mut crate::ToolIconCache,
#[allow(dead_code)] // Used by pane chrome rendering in main.rs #[allow(dead_code)] // Used by pane chrome rendering in main.rs
pub icon_cache: &'a mut crate::IconCache, pub icon_cache: &'a mut crate::IconCache,
@ -287,8 +325,10 @@ pub struct SharedPaneState<'a> {
pub script_to_edit: &'a mut Option<Uuid>, pub script_to_edit: &'a mut Option<Uuid>,
/// Script ID that was just saved (triggers auto-recompile of nodes using it) /// Script ID that was just saved (triggers auto-recompile of nodes using it)
pub script_saved: &'a mut Option<Uuid>, pub script_saved: &'a mut Option<Uuid>,
/// Active region selection (temporary split state) /// Undo-stack depth recorded before the first region-select cut of the current
pub region_selection: &'a mut Option<lightningbeam_core::selection::RegionSelection>, /// selection session. `Some` while a region selection is live and unchanged; lets a
/// later deselect heal (undo) the cut if nothing was edited. See `resolve_pending_region_cut`.
pub pending_region_cut_base: &'a mut Option<usize>,
/// Region select mode (Rectangle or Lasso) /// Region select mode (Rectangle or Lasso)
pub region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode, pub region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode,
/// Lasso select sub-mode (Freehand / Polygonal / Magnetic) /// Lasso select sub-mode (Freehand / Polygonal / Magnetic)
@ -302,9 +342,6 @@ pub struct SharedPaneState<'a> {
pub clipboard_consumed: &'a mut bool, pub clipboard_consumed: &'a mut bool,
/// Remappable keyboard shortcut manager /// Remappable keyboard shortcut manager
pub keymap: &'a crate::keymap::KeymapManager, pub keymap: &'a crate::keymap::KeymapManager,
/// Set by raster selection tools when they need main to commit the floating
/// selection before starting a new interaction.
pub commit_raster_floating_if_any: &'a mut bool,
/// Set by MenuAction::Group when focus is Nodes — consumed by node graph pane /// Set by MenuAction::Group when focus is Nodes — consumed by node graph pane
pub pending_node_group: &'a mut bool, pub pending_node_group: &'a mut bool,
/// Set by MenuAction::Group (ungroup variant) when focus is Nodes — consumed by node graph pane /// Set by MenuAction::Group (ungroup variant) when focus is Nodes — consumed by node graph pane

View File

@ -240,12 +240,6 @@ impl PianoRollPane {
matches!(note % 12, 1 | 3 | 6 | 8 | 10) matches!(note % 12, 1 | 3 | 6 | 8 | 10)
} }
fn note_name(note: u8) -> String {
let names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
let octave = (note / 12) as i32 - 1;
format!("{}{}", names[note as usize % 12], octave)
}
// ── Note resolution ────────────────────────────────────────────────── // ── Note resolution ──────────────────────────────────────────────────
fn resolve_notes(events: &[daw_backend::audio::midi::MidiEvent]) -> Vec<ResolvedNote> { fn resolve_notes(events: &[daw_backend::audio::midi::MidiEvent]) -> Vec<ResolvedNote> {
@ -736,45 +730,6 @@ impl PianoRollPane {
} }
} }
/// Generate pitch bend MIDI events for a note based on the zone and target semitones.
fn generate_pitch_bend_events(
note_start: f64,
note_duration: f64,
zone: PitchBendZone,
semitones: f32,
channel: u8,
pitch_bend_range: f32,
) -> Vec<daw_backend::audio::midi::MidiEvent> {
use daw_backend::audio::midi::MidiEvent;
let num_steps: usize = 128;
let mut events = Vec::new();
let encode_bend = |normalized: f32| -> (u8, u8) {
let value_14 = (normalized * 8191.0 + 8192.0).clamp(0.0, 16383.0) as i16;
((value_14 & 0x7F) as u8, ((value_14 >> 7) & 0x7F) as u8)
};
// Use t directly (0..=1 across the full note) — same formula as the visual ghost.
// Start: peak → 0 (ramps down over full note)
// Middle: 0 → peak → 0 (sine arch, peaks at center)
// End: 0 → peak (ramps up over full note)
for i in 0..=num_steps {
let t = i as f64 / num_steps as f64;
let t_f32 = t as f32;
// Cosine ease curves: Start+End at equal value = perfectly flat (partition of unity).
// Start: (1+cos(πt))/2 — peaks at t=0, smooth decay to 0 at t=1
// End: (1-cos(πt))/2 — 0 at t=0, smooth rise to peak at t=1
// Middle: sin(πt) — arch peaking at t=0.5
let normalized = match zone {
PitchBendZone::Start => semitones / pitch_bend_range * (1.0 + (std::f32::consts::PI * t_f32).cos()) * 0.5,
PitchBendZone::Middle => semitones / pitch_bend_range * (std::f32::consts::PI * t_f32).sin(),
PitchBendZone::End => semitones / pitch_bend_range * (1.0 - (std::f32::consts::PI * t_f32).cos()) * 0.5,
};
let timestamp = note_start + t * note_duration;
let (lsb, msb) = encode_bend(normalized);
events.push(MidiEvent::new(daw_backend::Beats(timestamp), 0xE0 | channel, lsb, msb));
}
events
}
/// Find the lowest available MIDI channel (115) not already used by any note /// Find the lowest available MIDI channel (115) not already used by any note
/// overlapping [note_start, note_end], excluding the note being assigned itself. /// overlapping [note_start, note_end], excluding the note being assigned itself.
/// Returns the note's current channel unchanged if it is already uniquely assigned (non-zero). /// Returns the note's current channel unchanged if it is already uniquely assigned (non-zero).
@ -2408,7 +2363,7 @@ impl PaneRenderer for PianoRollPane {
let doc = shared.action_executor.document(); let doc = shared.action_executor.document();
let is_measures = doc.timeline_mode == lightningbeam_core::document::TimelineMode::Measures; let is_measures = doc.timeline_mode == lightningbeam_core::document::TimelineMode::Measures;
let tempo_map = doc.tempo_map(); let tempo_map = doc.tempo_map();
drop(doc); let _ = doc;
if is_measures { if is_measures {
// Auto-detect grid when selection changes // Auto-detect grid when selection changes

View File

@ -67,5 +67,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let mask = textureSample(mask_tex, mask_sampler, canvas_uv).r; let mask = textureSample(mask_tex, mask_sampler, canvas_uv).r;
let masked_a = c.a * mask; let masked_a = c.a * mask;
let inv_a = select(0.0, 1.0 / c.a, c.a > 1e-6); let inv_a = select(0.0, 1.0 / c.a, c.a > 1e-6);
return vec4<f32>(c.r * inv_a, c.g * inv_a, c.b * inv_a, masked_a); // RGB tint packed into the matrix uniform's unused .w slots ((0,0,0) = no tint).
// Screen-blended so it recolors blacks too (outlines warm/cool ghosts) and
// leaves white alone: out = base + tint - base*tint. Used by onion-skin ghosts.
let tint = vec3<f32>(transform.col0.w, transform.col1.w, transform.col2.w);
let base = vec3<f32>(c.r * inv_a, c.g * inv_a, c.b * inv_a);
let tinted = base + tint - base * tint;
return vec4<f32>(tinted, masked_a);
} }

File diff suppressed because it is too large Load Diff

View File

@ -483,7 +483,6 @@ struct AutomationLaneRender {
value_max: f32, value_max: f32,
accent_color: egui::Color32, accent_color: egui::Color32,
playback_time: f64, playback_time: f64,
kind: AutomationLaneKind,
} }
/// Pending automation keyframe edit action from curve lane interaction /// Pending automation keyframe edit action from curve lane interaction
@ -1160,7 +1159,7 @@ impl TimelinePane {
*shared.recording_clips.get(&layer_id).unwrap_or(&0), daw_backend::Beats::ZERO); *shared.recording_clips.get(&layer_id).unwrap_or(&0), daw_backend::Beats::ZERO);
let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip); let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip);
let mut clip_instance = ClipInstance::new(doc_clip_id) let clip_instance = ClipInstance::new(doc_clip_id)
.with_timeline_start(start_time); .with_timeline_start(start_time);
if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&layer_id) { if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
@ -3230,7 +3229,6 @@ impl TimelinePane {
value_max: lane.value_max, value_max: lane.value_max,
accent_color: lane_accent, accent_color: lane_accent,
playback_time, playback_time,
kind: lane.kind,
}); });
} }
} }
@ -4054,7 +4052,6 @@ impl TimelinePane {
value_max: lane.value_max, value_max: lane.value_max,
accent_color: lane_accent, accent_color: lane_accent,
playback_time, playback_time,
kind: lane.kind,
}); });
} }
} }
@ -5409,12 +5406,10 @@ impl PaneRenderer for TimelinePane {
let editing_clip_id = shared.editing_clip_id; let editing_clip_id = shared.editing_clip_id;
let mut context_layers = document.context_layers(editing_clip_id.as_ref()); let mut context_layers = document.context_layers(editing_clip_id.as_ref());
// Prepend master track as the first row when enabled (only at root context, not inside clips) // Prepend master track as the first row when enabled (only at root context, not inside clips)
let master_any_layer; let _master_any_layer;
if self.show_master_track && editing_clip_id.is_none() { if self.show_master_track && editing_clip_id.is_none() {
master_any_layer = Some(AnyLayer::Group(document.master_layer.clone())); _master_any_layer = Some(AnyLayer::Group(document.master_layer.clone()));
context_layers.insert(0, master_any_layer.as_ref().unwrap()); context_layers.insert(0, _master_any_layer.as_ref().unwrap());
} else {
master_any_layer = None;
} }
// Use virtual row count (includes expanded group children) for height calculations // Use virtual row count (includes expanded group children) for height calculations
let layer_count = build_timeline_rows(&context_layers).len(); let layer_count = build_timeline_rows(&context_layers).len();

View File

@ -12,7 +12,6 @@
//! allocates and validates them in [`begin_raster_workspace`]; tools only //! allocates and validates them in [`begin_raster_workspace`]; tools only
//! dispatch shaders. //! dispatch shaders.
use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use eframe::egui; use eframe::egui;
@ -25,11 +24,6 @@ pub enum WorkspaceSource {
Layer { Layer {
layer_id: Uuid, layer_id: Uuid,
time: f64, time: f64,
/// The keyframe's own UUID (the A-canvas key in `GpuBrushEngine`).
kf_id: Uuid,
/// Full canvas dimensions (may differ from workspace dims for floating selections).
canvas_w: u32,
canvas_h: u32,
}, },
/// Operating on the floating selection. /// Operating on the floating selection.
Float, Float,
@ -51,9 +45,6 @@ pub struct RasterWorkspace {
pub b_canvas_id: Uuid, pub b_canvas_id: Uuid,
/// C canvas (Rgba16Float) — scratch; tools accumulate dabs here across the stroke. /// C canvas (Rgba16Float) — scratch; tools accumulate dabs here across the stroke.
pub c_canvas_id: Uuid, pub c_canvas_id: Uuid,
/// Optional R8Unorm selection mask (same pixel dimensions as A/B/C).
/// `None` means the entire workspace is selected.
pub mask_texture: Option<Arc<wgpu::Texture>>,
/// Pixel dimensions. A, B, C, and mask are all guaranteed to be this size. /// Pixel dimensions. A, B, C, and mask are all guaranteed to be this size.
pub width: u32, pub width: u32,
pub height: u32, pub height: u32,
@ -69,40 +60,6 @@ pub struct RasterWorkspace {
} }
impl RasterWorkspace { impl RasterWorkspace {
/// Panic-safe bounds check. Asserts that every GPU canvas exists and has
/// the dimensions declared by this workspace. Called by the framework
/// before `begin()` and before each `update()`.
pub fn validate(&self, gpu: &crate::gpu_brush::GpuBrushEngine) {
for (name, id) in [
("A", self.a_canvas_id),
("B", self.b_canvas_id),
("C", self.c_canvas_id),
] {
let canvas = gpu.canvases.get(&id).unwrap_or_else(|| {
panic!(
"RasterWorkspace::validate: buffer '{}' (id={}) not found in GpuBrushEngine",
name, id
)
});
assert_eq!(
canvas.width, self.width,
"RasterWorkspace::validate: buffer '{}' width {} != workspace width {}",
name, canvas.width, self.width
);
assert_eq!(
canvas.height, self.height,
"RasterWorkspace::validate: buffer '{}' height {} != workspace height {}",
name, canvas.height, self.height
);
}
let expected = (self.width * self.height * 4) as usize;
assert_eq!(
self.before_pixels.len(), expected,
"RasterWorkspace::validate: before_pixels.len()={} != expected {}",
self.before_pixels.len(), expected
);
}
/// Returns the three canvas UUIDs as an array (convenient for bulk removal). /// Returns the three canvas UUIDs as an array (convenient for bulk removal).
pub fn canvas_ids(&self) -> [Uuid; 3] { pub fn canvas_ids(&self) -> [Uuid; 3] {
[self.a_canvas_id, self.b_canvas_id, self.c_canvas_id] [self.a_canvas_id, self.b_canvas_id, self.c_canvas_id]
@ -208,11 +165,6 @@ pub trait RasterTool: Send + Sync {
/// the operation was a no-op (e.g. the pointer never moved). /// the operation was a no-op (e.g. the pointer never moved).
fn finish(&mut self, ws: &RasterWorkspace) -> bool; fn finish(&mut self, ws: &RasterWorkspace) -> bool;
/// Called on **Escape** or tool switch mid-stroke. The caller restores the
/// source pixels from `ws.before_pixels` without creating an undo entry; the
/// tool just cleans up internal state.
fn cancel(&mut self, ws: &RasterWorkspace);
/// Called once per frame (in the VelloCallback construction, UI thread) to /// Called once per frame (in the VelloCallback construction, UI thread) to
/// extract pending GPU work accumulated by `begin()` / `update()`. /// extract pending GPU work accumulated by `begin()` / `update()`.
/// ///
@ -377,384 +329,7 @@ impl RasterTool for BrushRasterTool {
self.has_dabs self.has_dabs
} }
fn cancel(&mut self, _ws: &RasterWorkspace) {
self.pending = None;
self.has_dabs = false;
}
fn take_pending_gpu_work(&mut self) -> Option<Box<dyn PendingGpuWork>> { fn take_pending_gpu_work(&mut self) -> Option<Box<dyn PendingGpuWork>> {
self.pending.take().map(|w| w as Box<dyn PendingGpuWork>) self.pending.take().map(|w| w as Box<dyn PendingGpuWork>)
} }
} }
// ── EffectBrushTool ───────────────────────────────────────────────────────────
/// Raster tool for effect brushes (Blur, Sharpen, Dodge, Burn, Sponge, Desaturate).
///
/// C accumulates a per-pixel influence weight (R channel, 0255).
/// The composite pass applies the effect to A, scaled by C.r, writing to B:
/// `B = lerp(A, effect(A), C.r)`
///
/// Using C as an influence map (rather than accumulating modified pixels) prevents
/// overlapping dabs from compounding the effect beyond the C.r cap (255).
///
/// # GPU implementation (TODO)
/// Requires a dedicated `effect_brush_composite.wgsl` shader that reads A and C,
/// applies the blend-mode-specific filter to A, and blends by C.r → B.
pub struct EffectBrushTool {
brush: BrushSettings,
blend_mode: RasterBlendMode,
has_dabs: bool,
}
impl EffectBrushTool {
pub fn new(brush: BrushSettings, blend_mode: RasterBlendMode) -> Self {
Self { brush, blend_mode, has_dabs: false }
}
}
impl RasterTool for EffectBrushTool {
fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {}
fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
self.has_dabs = true; // placeholder
}
fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dabs }
fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_dabs = false; }
// GPU shaders not yet implemented; take_pending_gpu_work returns None (default).
}
// ── SmudgeTool ────────────────────────────────────────────────────────────────
/// Raster tool for the smudge brush.
///
/// `begin()`: copy A → C so C starts with the source pixels for color pickup.
/// `update()`: dispatch smudge dabs using `blend_mode=2` (reads C as source,
/// writes smear to C); then composite C over A → B.
/// Because the smudge shader reads from `canvas_src` (C.src) and writes to
/// `canvas_dst` (C.dst), existing dabs are preserved in the smear history.
///
/// # GPU implementation (TODO)
/// Requires an initial A → C copy in `begin()` (via GPU copy command).
/// The smudge dab dispatch then uses `render_dabs(c_id, smudge_dabs, ...)`.
/// The composite pass is `composite_a_c_to_b` (same as BrushRasterTool).
pub struct SmudgeTool {
brush: BrushSettings,
has_dabs: bool,
}
impl SmudgeTool {
pub fn new(brush: BrushSettings) -> Self {
Self { brush, has_dabs: false }
}
}
impl RasterTool for SmudgeTool {
fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {}
fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
self.has_dabs = true; // placeholder
}
fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dabs }
fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_dabs = false; }
// GPU shaders not yet implemented; take_pending_gpu_work returns None (default).
}
// ── GradientRasterTool ────────────────────────────────────────────────────────
use crate::gpu_brush::GpuGradientStop;
use lightningbeam_core::gradient::{GradientExtend, GradientType, ShapeGradient};
fn gradient_stops_to_gpu(gradient: &ShapeGradient) -> Vec<GpuGradientStop> {
gradient.stops.iter().map(|s| {
GpuGradientStop::from_srgb_u8(s.position, s.color.r, s.color.g, s.color.b, s.color.a)
}).collect()
}
fn gradient_extend_to_u32(extend: GradientExtend) -> u32 {
match extend {
GradientExtend::Pad => 0,
GradientExtend::Reflect => 1,
GradientExtend::Repeat => 2,
}
}
fn gradient_kind_to_u32(kind: GradientType) -> u32 {
match kind {
GradientType::Linear => 0,
GradientType::Radial => 1,
}
}
struct PendingGradientWork {
a_id: Uuid,
b_id: Uuid,
stops: Vec<GpuGradientStop>,
start: (f32, f32),
end: (f32, f32),
opacity: f32,
extend_mode: u32,
kind: u32,
}
impl PendingGpuWork for PendingGradientWork {
fn execute(&self, device: &wgpu::Device, queue: &wgpu::Queue, gpu: &mut crate::gpu_brush::GpuBrushEngine) {
gpu.apply_gradient_fill(
device, queue,
&self.a_id, &self.b_id,
&self.stops,
self.start, self.end,
self.opacity, self.extend_mode, self.kind,
);
}
}
/// Raster tool for gradient fills.
///
/// `begin()` records the canvas-local start position.
/// `update()` recomputes gradient parameters from settings and queues a
/// `PendingGradientWork` that calls `apply_gradient_fill` in `prepare()`.
/// `finish()` returns whether any gradient was dispatched.
pub struct GradientRasterTool {
start_canvas: egui::Vec2,
end_canvas: egui::Vec2,
pending: Option<Box<PendingGradientWork>>,
has_dispatched: bool,
}
impl GradientRasterTool {
pub fn new() -> Self {
Self {
start_canvas: egui::Vec2::ZERO,
end_canvas: egui::Vec2::ZERO,
pending: None,
has_dispatched: false,
}
}
}
impl RasterTool for GradientRasterTool {
fn begin(&mut self, ws: &RasterWorkspace, pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
let canvas_pos = pos - egui::vec2(ws.x as f32, ws.y as f32);
self.start_canvas = canvas_pos;
self.end_canvas = canvas_pos;
}
fn update(&mut self, ws: &RasterWorkspace, pos: egui::Vec2, _dt: f32, settings: &crate::tools::RasterToolSettings) {
self.end_canvas = pos - egui::vec2(ws.x as f32, ws.y as f32);
let gradient = &settings.gradient;
self.pending = Some(Box::new(PendingGradientWork {
a_id: ws.a_canvas_id,
b_id: ws.b_canvas_id,
stops: gradient_stops_to_gpu(gradient),
start: (self.start_canvas.x, self.start_canvas.y),
end: (self.end_canvas.x, self.end_canvas.y),
opacity: settings.gradient_opacity,
extend_mode: gradient_extend_to_u32(gradient.extend),
kind: gradient_kind_to_u32(gradient.kind),
}));
self.has_dispatched = true;
}
fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dispatched }
fn cancel(&mut self, _ws: &RasterWorkspace) {
self.pending = None;
self.has_dispatched = false;
}
fn take_pending_gpu_work(&mut self) -> Option<Box<dyn PendingGpuWork>> {
self.pending.take().map(|w| w as Box<dyn PendingGpuWork>)
}
}
// ── TransformRasterTool ───────────────────────────────────────────────────────
use crate::gpu_brush::RasterTransformGpuParams;
struct PendingTransformWork {
a_id: Uuid,
b_id: Uuid,
params: RasterTransformGpuParams,
}
impl PendingGpuWork for PendingTransformWork {
fn execute(&self, device: &wgpu::Device, queue: &wgpu::Queue, gpu: &mut crate::gpu_brush::GpuBrushEngine) {
gpu.render_transform(device, queue, &self.a_id, &self.b_id, self.params);
}
}
/// Raster tool for affine transforms (move, scale, rotate, shear).
///
/// `begin()` stores the initial canvas dimensions and queues an identity
/// transform so B is initialised on the first frame.
/// `update()` recomputes the inverse affine matrix from the current handle
/// positions and queues a new `PendingTransformWork`.
///
/// The inverse matrix maps output pixel coordinates back to source pixel
/// coordinates: `src = M_inv * dst + b`
/// where `M_inv = [[a00, a01], [a10, a11]]` and `b = [b0, b1]`.
///
/// # GPU implementation
/// Fully wired — uses `GpuBrushEngine::render_transform`. Handle interaction
/// logic (drag, rotate, scale) is handled by the tool's `update()` caller in
/// `stage.rs` which computes and passes in the `RasterTransformGpuParams`.
pub struct TransformRasterTool {
pending: Option<Box<PendingTransformWork>>,
has_dispatched: bool,
canvas_w: u32,
canvas_h: u32,
}
impl TransformRasterTool {
pub fn new() -> Self {
Self {
pending: None,
has_dispatched: false,
canvas_w: 0,
canvas_h: 0,
}
}
/// Queue a transform with the given inverse-affine matrix.
/// Called by the stage handler after computing handle positions.
pub fn set_transform(
&mut self,
ws: &RasterWorkspace,
params: RasterTransformGpuParams,
) {
self.pending = Some(Box::new(PendingTransformWork {
a_id: ws.a_canvas_id,
b_id: ws.b_canvas_id,
params,
}));
self.has_dispatched = true;
}
}
impl RasterTool for TransformRasterTool {
fn begin(&mut self, ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
self.canvas_w = ws.width;
self.canvas_h = ws.height;
// Queue identity transform so B shows the source immediately.
let identity = RasterTransformGpuParams {
a00: 1.0, a01: 0.0,
a10: 0.0, a11: 1.0,
b0: 0.0, b1: 0.0,
src_w: ws.width, src_h: ws.height,
dst_w: ws.width, dst_h: ws.height,
_pad0: 0, _pad1: 0,
};
self.set_transform(ws, identity);
}
fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
// Handle interaction and matrix updates are driven from stage.rs via set_transform().
}
fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dispatched }
fn cancel(&mut self, _ws: &RasterWorkspace) {
self.pending = None;
self.has_dispatched = false;
}
fn take_pending_gpu_work(&mut self) -> Option<Box<dyn PendingGpuWork>> {
self.pending.take().map(|w| w as Box<dyn PendingGpuWork>)
}
}
// ── WarpRasterTool ────────────────────────────────────────────────────────────
/// Raster tool for warp / mesh deformation.
///
/// Uses a displacement buffer (managed by `GpuBrushEngine`) that maps each
/// output pixel to a source offset. The displacement grid is updated by
/// dragging control points; the warp shader reads anchor pixels + displacement
/// → B each frame.
///
/// # GPU implementation (TODO)
/// Requires: `create_displacement_buf`, `apply_warp` already exist in
/// `GpuBrushEngine`. Wire brush-drag interaction to update displacement
/// entries and call `apply_warp`.
pub struct WarpRasterTool {
has_dispatched: bool,
}
impl WarpRasterTool {
pub fn new() -> Self { Self { has_dispatched: false } }
}
impl RasterTool for WarpRasterTool {
fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {}
fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
self.has_dispatched = true; // placeholder
}
fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dispatched }
fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_dispatched = false; }
// take_pending_gpu_work: default (None) — full GPU wiring is TODO.
}
// ── LiquifyRasterTool ─────────────────────────────────────────────────────────
/// Raster tool for liquify (per-pixel displacement painting).
///
/// Similar to `WarpRasterTool` but uses a full per-pixel displacement map
/// (grid_cols = grid_rows = 0 in `apply_warp`) painted by brush strokes.
/// Each dab accumulates displacement in the push/pull/swirl direction.
///
/// # GPU implementation (TODO)
/// Requires: a dab-to-displacement shader that accumulates per-pixel offsets
/// into the displacement buffer, then `apply_warp` reads it → B.
pub struct LiquifyRasterTool {
has_dispatched: bool,
}
impl LiquifyRasterTool {
pub fn new() -> Self { Self { has_dispatched: false } }
}
impl RasterTool for LiquifyRasterTool {
fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {}
fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
self.has_dispatched = true; // placeholder
}
fn finish(&mut self, _ws: &RasterWorkspace) -> bool { self.has_dispatched }
fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_dispatched = false; }
// take_pending_gpu_work: default (None) — full GPU wiring is TODO.
}
// ── SelectionTool ─────────────────────────────────────────────────────────────
/// Raster selection tool (Magic Wand / Quick Select).
///
/// C (RGBA8) acts as the growing selection; C.r = mask value (0 or 255).
/// Each `update()` frame a flood-fill / region-grow shader extends C.r.
/// The composite pass draws A + a tinted overlay from C.r → B so the user
/// sees the growing selection boundary.
///
/// `finish()` returns false (commit does not write pixels back to the layer;
/// instead the caller extracts C.r into the standalone `R8Unorm` selection
/// texture via `shared.raster_selection`).
///
/// # GPU implementation (TODO)
/// Requires: a flood-fill compute shader seeded by the click position that
/// grows the selection in C.r; and a composite shader that tints selected
/// pixels blue/cyan for preview.
pub struct SelectionTool {
has_selection: bool,
}
impl SelectionTool {
pub fn new() -> Self { Self { has_selection: false } }
}
impl RasterTool for SelectionTool {
fn begin(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {}
fn update(&mut self, _ws: &RasterWorkspace, _pos: egui::Vec2, _dt: f32, _settings: &crate::tools::RasterToolSettings) {
self.has_selection = true; // placeholder
}
/// Selection tools never trigger a pixel readback/commit on mouseup.
/// The caller reads C.r directly into the selection mask texture.
fn finish(&mut self, _ws: &RasterWorkspace) -> bool { false }
fn cancel(&mut self, _ws: &RasterWorkspace) { self.has_selection = false; }
// take_pending_gpu_work: default (None) — full GPU wiring is TODO.
}

View File

@ -232,15 +232,6 @@ impl TestModeState {
} }
} }
/// Store geometry context for panic capture.
/// Called before risky operations (e.g. region select) so the panic hook
/// can include it in the crash file for easier reproduction.
pub fn set_pending_geometry(&self, context: serde_json::Value) {
if let Ok(mut guard) = self.pending_geometry.try_lock() {
*guard = Some(context);
}
}
/// Clear the pending geometry context (call after the operation succeeds). /// Clear the pending geometry context (call after the operation succeeds).
pub fn clear_pending_geometry(&self) { pub fn clear_pending_geometry(&self) {
if let Ok(mut guard) = self.pending_geometry.try_lock() { if let Ok(mut guard) = self.pending_geometry.try_lock() {

View File

@ -42,6 +42,7 @@ impl ThemeMode {
/// Background type for CSS backgrounds /// Background type for CSS backgrounds
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum Background { pub enum Background {
Solid(egui::Color32), Solid(egui::Color32),
LinearGradient { LinearGradient {
@ -456,6 +457,7 @@ impl Theme {
} }
/// Invalidate the cache (call on stylesheet reload or mode change) /// Invalidate the cache (call on stylesheet reload or mode change)
#[allow(dead_code)]
pub fn invalidate_cache(&self) { pub fn invalidate_cache(&self) {
self.cache.borrow_mut().clear(); self.cache.borrow_mut().clear();
} }
@ -518,6 +520,7 @@ impl Theme {
} }
/// Convenience: resolve and extract a dimension with fallback /// Convenience: resolve and extract a dimension with fallback
#[allow(dead_code)]
pub fn dimension(&self, context: &[&str], ctx: &egui::Context, property: &str, fallback: f32) -> f32 { pub fn dimension(&self, context: &[&str], ctx: &egui::Context, property: &str, fallback: f32) -> f32 {
let style = self.resolve(context, ctx); let style = self.resolve(context, ctx);
match property { match property {
@ -534,6 +537,7 @@ impl Theme {
} }
/// Paint background for a region (handles solid/gradient/image) /// Paint background for a region (handles solid/gradient/image)
#[allow(dead_code)]
pub fn paint_bg( pub fn paint_bg(
&self, &self,
context: &[&str], context: &[&str],

View File

@ -7,6 +7,7 @@ use eframe::egui;
use crate::theme::Background; use crate::theme::Background;
/// Paint a background into the given rect /// Paint a background into the given rect
#[allow(dead_code)]
pub fn paint_background( pub fn paint_background(
painter: &egui::Painter, painter: &egui::Painter,
rect: egui::Rect, rect: egui::Rect,
@ -35,6 +36,7 @@ pub fn paint_background(
/// - 90deg = left to right /// - 90deg = left to right
/// - 180deg = top to bottom (default) /// - 180deg = top to bottom (default)
/// - 270deg = right to left /// - 270deg = right to left
#[allow(dead_code)]
pub fn paint_linear_gradient( pub fn paint_linear_gradient(
painter: &egui::Painter, painter: &egui::Painter,
rect: egui::Rect, rect: egui::Rect,