Compare commits
34 Commits
f585c370e8
...
8223593649
| Author | SHA1 | Date |
|---|---|---|
|
|
8223593649 | |
|
|
83057b754a | |
|
|
3c3a482e2e | |
|
|
5fd04b5dd5 | |
|
|
a1acecf396 | |
|
|
1dd5de4617 | |
|
|
0d1f62ccce | |
|
|
87bcffd427 | |
|
|
39978e59b3 | |
|
|
0d253d9629 | |
|
|
0914818808 | |
|
|
b292e0e6e6 | |
|
|
4e9cacb789 | |
|
|
0a0d7cd0a9 | |
|
|
5f3cb41c18 | |
|
|
6a788a432e | |
|
|
0883c77e2b | |
|
|
3d0a334014 | |
|
|
d05cbfcde5 | |
|
|
fcc8102a9d | |
|
|
7a150fb5c5 | |
|
|
2f3b0d7790 | |
|
|
aad2d5c515 | |
|
|
6fc3a131a6 | |
|
|
659bc5fb02 | |
|
|
6c9fcb1921 | |
|
|
aea26f6192 | |
|
|
c936078850 | |
|
|
7445ee919f | |
|
|
10b4aa481e | |
|
|
c2c7aefad1 | |
|
|
78109cda93 | |
|
|
4dc937f9c0 | |
|
|
53cae1bfe5 |
|
|
@ -90,6 +90,15 @@ jobs:
|
|||
shell: pwsh
|
||||
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 ──
|
||||
- name: Extract version
|
||||
id: version
|
||||
|
|
|
|||
26
Changelog.md
26
Changelog.md
|
|
@ -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:
|
||||
Changes:
|
||||
- Beats are now the canonical time representation (replacing seconds)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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)
|
||||
Both stem from the **timeline having no model for raster keyframes** — it renders *clip
|
||||
instances* (`layer_clips`/`collect_clip_instances` return `&[ClipInstance]`), but raster layers use
|
||||
`keyframes: Vec<RasterKeyframe>`; every raster arm in timeline.rs is a stub (`Raster => &[]`/`{}`).
|
||||
- **(a) `Timeline > New Keyframe` doesn't refresh the canvas** until you draw. The menu action
|
||||
creates a blank raster keyframe but doesn't trigger a canvas repaint / GPU-cache invalidation for
|
||||
the new (different) `kf.id`, so the stale previous-frame texture stays until a stroke dirties it.
|
||||
- **(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.
|
||||
## Raster-keyframe-UI bugs — **[DONE]** (built the raster keyframe timeline UI, 2026-06-20)
|
||||
Both resolved by the raster-keyframe-timeline-UI work: timeline now draws a diamond per
|
||||
`RasterKeyframe` (mirrors vector), `K`/New Keyframe inserts a blank cel via `AddRasterKeyframeAction`
|
||||
(canvas refreshes), paint tools edit the active keyframe instead of lazily creating, diamonds are
|
||||
click-to-seek (pointing-hand cursor), playback prefetches frames, and onion skinning (raster+vector,
|
||||
tinted, Info-Panel settings) is in. (a) canvas-refresh-on-new-keyframe and (b) keyframes-on-timeline
|
||||
are both fixed.
|
||||
|
||||
## Noted enhancements (later, after the phases)
|
||||
- [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.
|
||||
*(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.
|
||||
- **Export speed:** a 1:14 1080p MP4 took ~9:06 to export (~7.4x slower than realtime). The video
|
||||
export pipeline re-seeks + decodes per output frame (see `[Video Seek]`/`[Video Timing]` logs) and
|
||||
does CPU YUV conversion; likely wins from sequential decode (avoid per-frame seeks), reusing the
|
||||
decode cache, and/or GPU-side color conversion. Profile before optimizing.
|
||||
- **Export speed (audited 2026-06-21):** a 1:14 1080p MP4 took ~9:06 (~7.4x realtime, ~135 ms/frame).
|
||||
Audit **refuted** the per-frame-seek theory — export decodes the source *sequentially*
|
||||
(`video.rs` `need_seek` is false once advancing forward), and readback is already async +
|
||||
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
|
||||
(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/
|
||||
|
|
@ -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)*
|
||||
|
||||
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 —
|
||||
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)
|
||||
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
|
||||
|
|
@ -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
|
||||
behavior; output is byte-identical. Editor compiles clean. *(Not yet runtime-verified — needs an
|
||||
in-app export to confirm A/V sync.)*
|
||||
- [ ] Phase 3a — lazy raster fault-in from blob store
|
||||
- [ ] Phase 3b — raster residency window + eviction
|
||||
- [ ] Phase 3c — bound raster GPU/CPU caches
|
||||
- [ ] Phase 3d — spill undo snapshots
|
||||
- [ ] Phase 4a — frame→asset enumeration (recursive)
|
||||
- [ ] Phase 4b — usage bookkeeping + LRU residency
|
||||
- [ ] Phase 4c — bound decoded image tier
|
||||
- [x] Phase 3a — lazy + async raster fault-in (`RasterStore` + background thread + image proxy)
|
||||
- [x] Phase 3b — raster residency LRU + eviction (dirty-flag data-loss safety)
|
||||
- [x] Phase 3c — bound raster GPU texture cache (recency LRU + F3 VRAM readout)
|
||||
- [x] Phase 3d — raster undo dirty-rect diffs (+ fault-in-before-undo)
|
||||
- [x] Phase 3.5 — image textures in vector scenes (fixed DCEL-broken image import; image-fill tab + picker; container-persisted)
|
||||
- [x] Phase 4 — image asset paging: Tier 2 decoded-cache byte-LRU, Tier 1 lazy container bytes, playback prefetch
|
||||
- [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
|
||||
in `Beats(...)` / passed `&TempoMap` to changed signatures (automation.rs, clip.rs,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -116,7 +116,6 @@ pub struct Engine {
|
|||
|
||||
// Current tempo map — kept in sync with SetTempo/SetTempoMap commands.
|
||||
tempo_map: crate::TempoMap,
|
||||
current_fps: f64,
|
||||
// Current time signature — updated by SetTempo, used when SetTempoMap fires.
|
||||
time_sig: (u32, u32),
|
||||
}
|
||||
|
|
@ -197,7 +196,6 @@ impl Engine {
|
|||
timing_sum_total_us: 0,
|
||||
timing_overrun_count: 0,
|
||||
tempo_map: crate::TempoMap::constant(120.0),
|
||||
current_fps: 30.0,
|
||||
time_sig: (4, 4),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ pub struct AddShapeAction {
|
|||
stroke_color: Option<ShapeColor>,
|
||||
fill_color: Option<ShapeColor>,
|
||||
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,
|
||||
/// Snapshot of the graph before insertion (for undo).
|
||||
graph_before: Option<VectorGraph>,
|
||||
|
|
@ -46,15 +49,53 @@ impl AddShapeAction {
|
|||
stroke_color,
|
||||
fill_color,
|
||||
is_closed,
|
||||
image_fill: None,
|
||||
description_text: "Add shape".to_string(),
|
||||
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 {
|
||||
self.description_text = desc.into();
|
||||
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 {
|
||||
|
|
@ -87,16 +128,25 @@ impl Action for AddShapeAction {
|
|||
DEFAULT_SNAP_EPSILON,
|
||||
);
|
||||
|
||||
// Apply fill if this is a closed shape with fill
|
||||
if self.is_closed {
|
||||
if let Some(ref fill) = self.fill_color {
|
||||
// Compute centroid of the path's bounding box and paint-bucket fill
|
||||
let bbox = self.path.bounding_box();
|
||||
let centroid = kurbo::Point::new(
|
||||
(bbox.x0 + bbox.x1) / 2.0,
|
||||
(bbox.y0 + bbox.y1) / 2.0,
|
||||
);
|
||||
graph.paint_bucket(centroid, fill.clone(), FillRule::NonZero, 0.0);
|
||||
// Apply fill if this is a closed shape with a colour and/or image fill.
|
||||
if self.is_closed && (self.fill_color.is_some() || self.image_fill.is_some()) {
|
||||
// Compute centroid of the path's bounding box and paint-bucket fill.
|
||||
let bbox = self.path.bounding_box();
|
||||
let centroid = kurbo::Point::new(
|
||||
(bbox.x0 + bbox.x1) / 2.0,
|
||||
(bbox.y0 + bbox.y1) / 2.0,
|
||||
);
|
||||
// paint_bucket needs a colour; an image-only fill uses a placeholder
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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::clip::ClipInstance;
|
||||
use crate::actions::clip_from_geometry::{extract_geometry_to_clip, undo_extract_geometry};
|
||||
use crate::document::Document;
|
||||
use crate::vector_graph::{EdgeId, FillId, VectorGraph};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that converts selected items to a Movie Clip
|
||||
/// TODO: Rewrite for DCEL
|
||||
#[allow(dead_code)]
|
||||
pub struct ConvertToMovieClipAction {
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
shape_ids: Vec<Uuid>,
|
||||
clip_instance_ids: Vec<Uuid>,
|
||||
fills: Vec<FillId>,
|
||||
edges: Vec<EdgeId>,
|
||||
clip_id: Uuid,
|
||||
instance_id: Uuid,
|
||||
created_clip_id: Option<Uuid>,
|
||||
removed_clip_instances: Vec<ClipInstance>,
|
||||
graph_before: Option<VectorGraph>,
|
||||
}
|
||||
|
||||
impl ConvertToMovieClipAction {
|
||||
pub fn new(
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
shape_ids: Vec<Uuid>,
|
||||
clip_instance_ids: Vec<Uuid>,
|
||||
fills: Vec<FillId>,
|
||||
edges: Vec<EdgeId>,
|
||||
clip_id: Uuid,
|
||||
instance_id: Uuid,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
time,
|
||||
shape_ids,
|
||||
clip_instance_ids,
|
||||
instance_id,
|
||||
created_clip_id: None,
|
||||
removed_clip_instances: Vec::new(),
|
||||
}
|
||||
Self { layer_id, time, fills, edges, clip_id, instance_id, graph_before: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for ConvertToMovieClipAction {
|
||||
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);
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
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(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
let count = self.shape_ids.len() + self.clip_instance_ids.len();
|
||||
format!("Convert {} object(s) to Movie Clip", count)
|
||||
"Convert to Movie Clip".to_string()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::clip::ClipInstance;
|
||||
use crate::actions::clip_from_geometry::{extract_geometry_to_clip, undo_extract_geometry};
|
||||
use crate::document::Document;
|
||||
use crate::vector_graph::{EdgeId, FillId, VectorGraph};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that groups selected shapes and/or clip instances into a VectorClip
|
||||
/// TODO: Rewrite for DCEL (group DCEL faces/edges into a sub-clip)
|
||||
#[allow(dead_code)]
|
||||
/// Groups the selected DCEL geometry (fills/edges) of a vector layer's active keyframe
|
||||
/// into a new group clip, placing a clip instance in its place (which can be tweened).
|
||||
pub struct GroupAction {
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
shape_ids: Vec<Uuid>,
|
||||
clip_instance_ids: Vec<Uuid>,
|
||||
fills: Vec<FillId>,
|
||||
edges: Vec<EdgeId>,
|
||||
clip_id: Uuid,
|
||||
instance_id: Uuid,
|
||||
created_clip_id: Option<Uuid>,
|
||||
removed_clip_instances: Vec<ClipInstance>,
|
||||
graph_before: Option<VectorGraph>,
|
||||
}
|
||||
|
||||
impl GroupAction {
|
||||
pub fn new(
|
||||
layer_id: Uuid,
|
||||
time: f64,
|
||||
shape_ids: Vec<Uuid>,
|
||||
clip_instance_ids: Vec<Uuid>,
|
||||
fills: Vec<FillId>,
|
||||
edges: Vec<EdgeId>,
|
||||
clip_id: Uuid,
|
||||
instance_id: Uuid,
|
||||
) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
time,
|
||||
shape_ids,
|
||||
clip_instance_ids,
|
||||
instance_id,
|
||||
created_clip_id: None,
|
||||
removed_clip_instances: Vec::new(),
|
||||
}
|
||||
Self { layer_id, time, fills, edges, clip_id, instance_id, graph_before: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for GroupAction {
|
||||
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);
|
||||
// TODO: Implement DCEL-aware grouping
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
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, true, "Group",
|
||||
)?;
|
||||
self.graph_before = Some(before);
|
||||
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(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
let count = self.shape_ids.len() + self.clip_instance_ids.len();
|
||||
format!("Group {} objects", count)
|
||||
"Group".to_string()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,17 +29,20 @@ pub mod update_midi_events;
|
|||
pub mod loop_clip_instances;
|
||||
pub mod remove_clip_instances;
|
||||
pub mod set_keyframe;
|
||||
pub mod set_tween;
|
||||
pub mod group_shapes;
|
||||
pub mod convert_to_movie_clip;
|
||||
pub mod region_split;
|
||||
pub mod toggle_group_expansion;
|
||||
pub mod group_layers;
|
||||
pub mod clip_from_geometry;
|
||||
pub mod raster_diff;
|
||||
pub mod raster_stroke;
|
||||
pub mod raster_fill;
|
||||
pub mod add_raster_keyframe;
|
||||
pub mod move_layer;
|
||||
pub mod set_fill_paint;
|
||||
pub mod set_image_fill;
|
||||
|
||||
pub use add_clip_instance::AddClipInstanceAction;
|
||||
pub use add_effect::AddEffectAction;
|
||||
|
|
@ -65,6 +68,7 @@ pub use update_midi_events::UpdateMidiEventsAction;
|
|||
pub use loop_clip_instances::LoopClipInstancesAction;
|
||||
pub use remove_clip_instances::RemoveClipInstancesAction;
|
||||
pub use set_keyframe::SetKeyframeAction;
|
||||
pub use set_tween::SetTweenAction;
|
||||
pub use group_shapes::GroupAction;
|
||||
pub use convert_to_movie_clip::ConvertToMovieClipAction;
|
||||
pub use region_split::RegionSplitAction;
|
||||
|
|
@ -75,5 +79,6 @@ pub use raster_fill::RasterFillAction;
|
|||
pub use add_raster_keyframe::AddRasterKeyframeAction;
|
||||
pub use move_layer::MoveLayerAction;
|
||||
pub use set_fill_paint::SetFillPaintAction;
|
||||
pub use set_image_fill::SetImageFillAction;
|
||||
pub use change_bpm::ChangeBpmAction;
|
||||
pub use change_fps::ChangeFpsAction;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
//! 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.
|
||||
fn normalize(buf: &[u8], n: usize) -> std::borrow::Cow<[u8]> {
|
||||
fn normalize(buf: &[u8], n: usize) -> std::borrow::Cow<'_, [u8]> {
|
||||
if buf.len() == n {
|
||||
std::borrow::Cow::Borrowed(buf)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ use crate::action::Action;
|
|||
use crate::animation::{AnimationCurve, AnimationTarget, Keyframe, TransformProperty};
|
||||
use crate::document::Document;
|
||||
use crate::layer::{AnyLayer, ShapeKeyframe};
|
||||
use crate::object::Transform;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Undo info for a clip animation curve
|
||||
|
|
@ -54,11 +55,17 @@ const TRANSFORM_PROPERTIES: &[TransformProperty] = &[
|
|||
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 {
|
||||
TransformProperty::ScaleX | TransformProperty::ScaleY => 1.0,
|
||||
TransformProperty::Opacity => 1.0,
|
||||
_ => 0.0,
|
||||
TransformProperty::X => t.x,
|
||||
TransformProperty::Y => t.y,
|
||||
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.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
|
||||
.get_layer_mut(&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
|
||||
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 {
|
||||
let target = AnimationTarget::Object {
|
||||
id: *clip_id,
|
||||
property: *prop,
|
||||
};
|
||||
let default = transform_default(prop);
|
||||
let value = vl.layer.animation_data.eval(&target, self.time, default);
|
||||
// Fall back to the clip's OWN value (not a generic default) so a brand-new
|
||||
// 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();
|
||||
if curve_created {
|
||||
vl.layer
|
||||
.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 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));
|
||||
|
||||
self.clip_undo_entries.push(ClipUndoEntry {
|
||||
|
|
@ -145,3 +181,86 @@ impl Action for SetKeyframeAction {
|
|||
"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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
fn extrapolate_pre(&self, time: f64, first_kf: &Keyframe) -> f64 {
|
||||
match self.pre_extrapolation {
|
||||
|
|
@ -516,6 +530,17 @@ impl AnimationData {
|
|||
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
|
||||
pub fn eval(&self, target: &AnimationTarget, time: f64, default: f64) -> f64 {
|
||||
self.curves
|
||||
|
|
@ -545,3 +570,51 @@ impl AnimationData {
|
|||
(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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,9 +263,11 @@ pub struct ImageAsset {
|
|||
/// Image height in pixels
|
||||
pub height: u32,
|
||||
|
||||
/// Embedded image data (for project portability)
|
||||
/// If None, the image will be loaded from path when needed
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
/// Raw image file bytes. NOT serialized to project JSON — persisted as a
|
||||
/// `MediaKind::ImageAsset` row in the `.beam` container (chunked, pageable) and
|
||||
/// 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>>,
|
||||
|
||||
/// Folder this asset belongs to (None = root of category)
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
let removed = txn.retain_media(&live_media)?;
|
||||
|
||||
|
|
@ -625,6 +651,11 @@ fn load_beam_sqlite(path: &Path) -> Result<LoadedProject, String> {
|
|||
}
|
||||
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).
|
||||
let project_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let missing_files: Vec<MissingFileInfo> = restored_entries
|
||||
|
|
|
|||
|
|
@ -105,6 +105,9 @@ pub struct CompositorLayer {
|
|||
pub opacity: f32,
|
||||
/// Blend mode for this layer
|
||||
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 {
|
||||
|
|
@ -113,12 +116,19 @@ impl CompositorLayer {
|
|||
buffer,
|
||||
opacity: opacity.clamp(0.0, 1.0),
|
||||
blend_mode,
|
||||
tint: [0.0; 4],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normal(buffer: BufferHandle, opacity: f32) -> Self {
|
||||
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
|
||||
|
|
@ -129,8 +139,10 @@ pub struct CompositeUniforms {
|
|||
pub opacity: f32,
|
||||
/// Blend mode index
|
||||
pub blend_mode: u32,
|
||||
/// Padding for alignment
|
||||
/// Padding to 16 bytes before the vec4 tint
|
||||
pub _padding: [u32; 2],
|
||||
/// Screen-blend tint ((0,0,0) = none); `.w` unused.
|
||||
pub tint: [f32; 4],
|
||||
}
|
||||
|
||||
/// Compositor for blending layers
|
||||
|
|
@ -323,6 +335,7 @@ impl Compositor {
|
|||
opacity: layer.opacity,
|
||||
blend_mode: layer.blend_mode.to_index(),
|
||||
_padding: [0, 0],
|
||||
tint: layer.tint,
|
||||
};
|
||||
queue.write_buffer(&uniforms_buffer, 0, bytemuck::bytes_of(&uniforms));
|
||||
|
||||
|
|
@ -382,6 +395,8 @@ struct Uniforms {
|
|||
opacity: f32,
|
||||
blend_mode: 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>;
|
||||
|
|
@ -526,7 +541,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
|||
// Apply 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
|
||||
return vec4<f32>(src.rgb * src_alpha, src_alpha);
|
||||
return vec4<f32>(tinted * src_alpha, src_alpha);
|
||||
}
|
||||
"#;
|
||||
|
|
|
|||
|
|
@ -382,6 +382,29 @@ impl VectorLayer {
|
|||
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
|
||||
pub fn graph_at_time_mut(&mut self, time: f64) -> Option<&mut VectorGraph> {
|
||||
self.keyframe_at_mut(time).map(|kf| &mut kf.graph)
|
||||
|
|
@ -433,6 +456,25 @@ impl VectorLayer {
|
|||
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.
|
||||
// - shapes_at_time_mut → graph_at_time_mut
|
||||
// - get_shape_in_keyframe → use DCEL vertex/edge/face accessors
|
||||
|
|
@ -451,30 +493,53 @@ impl VectorLayer {
|
|||
&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.
|
||||
///
|
||||
/// 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 {
|
||||
let tolerance = 0.001;
|
||||
if let Some(idx) = self.keyframe_index_at_exact(time, tolerance) {
|
||||
return &mut self.keyframes[idx];
|
||||
}
|
||||
|
||||
// Clone graph and clip instance IDs from the active keyframe
|
||||
let (cloned_graph, cloned_clip_ids) = self
|
||||
// Geometry shown at `time` (interpolated if mid-tween, else the held keyframe).
|
||||
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)
|
||||
.map(|kf| {
|
||||
(kf.graph.clone(), kf.clip_instance_ids.clone())
|
||||
})
|
||||
.unwrap_or_else(|| (VectorGraph::new(), Vec::new()));
|
||||
.map(|kf| (kf.tween_after, kf.clip_instance_ids.clone()))
|
||||
.unwrap_or((TweenType::None, Vec::new()));
|
||||
|
||||
let insert_idx = self.keyframes.partition_point(|kf| kf.time < time);
|
||||
let mut kf = ShapeKeyframe::new(time);
|
||||
kf.graph = cloned_graph;
|
||||
kf.tween_after = tween_after;
|
||||
kf.clip_instance_ids = cloned_clip_ids;
|
||||
self.keyframes.insert(insert_idx, kf);
|
||||
&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).
|
||||
/// Returns the removed keyframe if found.
|
||||
pub(crate) fn remove_keyframe_at(&mut self, time: f64, tolerance: f64) -> Option<ShapeKeyframe> {
|
||||
|
|
@ -1050,6 +1115,97 @@ impl AnyLayer {
|
|||
mod tests {
|
||||
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]
|
||||
fn test_layer_creation() {
|
||||
let layer = Layer::new(LayerType::Vector, "Test Layer");
|
||||
|
|
|
|||
|
|
@ -22,43 +22,111 @@ use vello::peniko::{Blob, Fill, ImageAlphaType, ImageBrush, ImageData, ImageForm
|
|||
use vello::Scene;
|
||||
|
||||
/// 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 {
|
||||
cache: HashMap<Uuid, Arc<ImageBrush>>,
|
||||
/// CPU path: tiny-skia pixmaps decoded from the same assets (premultiplied RGBA8)
|
||||
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 {
|
||||
/// Max decoded-image bytes kept resident before LRU eviction.
|
||||
const BUDGET: usize = 256 * 1024 * 1024;
|
||||
|
||||
/// Create a new empty image cache
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
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
|
||||
pub fn get_or_decode(&mut self, asset: &ImageAsset) -> Option<Arc<ImageBrush>> {
|
||||
if let Some(cached) = self.cache.get(&asset.id) {
|
||||
return Some(Arc::clone(cached));
|
||||
let size = (asset.width as usize) * (asset.height as usize) * 4;
|
||||
if let Some(cached) = self.cache.get(&asset.id).map(Arc::clone) {
|
||||
self.touch(asset.id, size);
|
||||
return Some(cached);
|
||||
}
|
||||
|
||||
// Decode and cache
|
||||
let image = decode_image_asset(asset)?;
|
||||
// Decode and cache (bytes from asset.data or paged from the container).
|
||||
let bytes = self.resolve_bytes(asset)?;
|
||||
let image = decode_image_brush(&bytes)?;
|
||||
let arc_image = Arc::new(image);
|
||||
self.cache.insert(asset.id, Arc::clone(&arc_image));
|
||||
self.touch(asset.id, size);
|
||||
Some(arc_image)
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
if let Some(cached) = self.cpu_cache.get(&asset.id) {
|
||||
return Some(Arc::clone(cached));
|
||||
let size = (asset.width as usize) * (asset.height as usize) * 4;
|
||||
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);
|
||||
self.cpu_cache.insert(asset.id, Arc::clone(&arc));
|
||||
self.touch(asset.id, size);
|
||||
Some(arc)
|
||||
}
|
||||
|
||||
|
|
@ -66,12 +134,21 @@ impl ImageCache {
|
|||
pub fn invalidate(&mut self, id: &Uuid) {
|
||||
self.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
|
||||
pub fn clear(&mut self) {
|
||||
self.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).
|
||||
fn decode_image_to_pixmap(asset: &ImageAsset) -> Option<tiny_skia::Pixmap> {
|
||||
let data = asset.data.as_ref()?;
|
||||
/// Image asset ids referenced by the visible vector layers' active keyframes at `time`
|
||||
/// (top-level + group children). Used to prefetch/decode images ahead during playback.
|
||||
/// (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 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()) {
|
||||
let [r, g, b, a] = src.0;
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// Decode an image asset to peniko ImageBrush
|
||||
fn decode_image_asset(asset: &ImageAsset) -> Option<ImageBrush> {
|
||||
// Get the raw file data
|
||||
let data = asset.data.as_ref()?;
|
||||
|
||||
// Decode using the image crate
|
||||
/// Decode image bytes to a peniko ImageBrush (GPU render path).
|
||||
fn decode_image_brush(data: &[u8]) -> Option<ImageBrush> {
|
||||
let img = image::load_from_memory(data).ok()?;
|
||||
let rgba = img.to_rgba8();
|
||||
let (iw, ih) = rgba.dimensions();
|
||||
|
||||
// Create peniko ImageData then ImageBrush
|
||||
let image_data = ImageData {
|
||||
data: Blob::from(rgba.into_raw()),
|
||||
format: ImageFormat::Rgba8,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
width: iw,
|
||||
height: ih,
|
||||
alpha_type: ImageAlphaType::Alpha,
|
||||
};
|
||||
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) = image_cache.get_or_decode(image_asset) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1253,7 +1356,12 @@ fn render_vector_layer(
|
|||
// Cascade opacity: parent_opacity × 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 groups, compute the visibility end from keyframe data
|
||||
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 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(asset) = document.get_image_asset(&image_asset_id) {
|
||||
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(
|
||||
tiny_skia::Pixmap::as_ref(&img_pixmap),
|
||||
tiny_skia::SpreadMode::Pad,
|
||||
tiny_skia::FilterQuality::Bilinear,
|
||||
opacity,
|
||||
tiny_skia::Transform::identity(),
|
||||
pat_tf,
|
||||
);
|
||||
let mut paint = tiny_skia::Paint::default();
|
||||
paint.shader = pattern;
|
||||
|
|
@ -1555,6 +1667,11 @@ fn render_vector_layer_cpu(
|
|||
) {
|
||||
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 {
|
||||
let group_end_time = document.vector_clips.get(&clip_instance.clip_id)
|
||||
.filter(|vc| vc.is_group)
|
||||
|
|
@ -1567,10 +1684,6 @@ fn render_vector_layer_cpu(
|
|||
&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.
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@
|
|||
|
||||
use crate::vector_graph::{VectorGraph, EdgeId, FillId, VertexId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::{Affine, BezPath};
|
||||
|
||||
/// Shape of a raster pixel selection, in canvas pixel coordinates.
|
||||
#[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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -417,6 +417,152 @@ impl VectorGraph {
|
|||
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
|
||||
// -------------------------------------------------------------------
|
||||
|
|
@ -653,6 +799,10 @@ impl VectorGraph {
|
|||
}
|
||||
|
||||
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;
|
||||
|
||||
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 = 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
|
||||
self.vertices[mid_v.idx()].position = point;
|
||||
// Merge with nearby existing vertex if within snap distance
|
||||
|
|
@ -791,127 +948,511 @@ impl VectorGraph {
|
|||
prev_end_vertex = Some(end_v);
|
||||
}
|
||||
|
||||
// Fill splitting pass: for each new edge, check if both endpoints
|
||||
// lie on any fill's boundary — if so, split that fill.
|
||||
let edges_to_check = all_new_edges.clone();
|
||||
for &eid in &edges_to_check {
|
||||
let v0 = self.edges[eid.idx()].vertices[0];
|
||||
let v1 = self.edges[eid.idx()].vertices[1];
|
||||
// Weld dangling stroke endpoints onto a near-coincident existing vertex. A
|
||||
// self-intersecting freehand stroke can create an intersection vertex a fraction of
|
||||
// a pixel away from a segment endpoint that should be the same point; if they don't
|
||||
// merge, the stroke's loop is broken by a degree-1 stub and the cut is lost.
|
||||
self.weld_dangling_endpoints(&all_new_edges, snap_epsilon.max(1.0));
|
||||
|
||||
// Find fills where both v0 and v1 appear as boundary vertices
|
||||
let fill_ids: Vec<FillId> = self.fills
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, f)| !f.deleted)
|
||||
.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();
|
||||
// Coincident-edge cleanup: a new edge that lands exactly on an existing edge
|
||||
// between the same two vertices (e.g. drawing a shape whose edge snaps onto an
|
||||
// existing one) must not be duplicated — duplicates produce zero-area "sliver"
|
||||
// fills. Merge such duplicates before splitting/filling.
|
||||
self.dedupe_coincident_new_edges(&all_new_edges);
|
||||
|
||||
for fid in fill_ids {
|
||||
self.split_fill_by_edge(fid, eid);
|
||||
}
|
||||
}
|
||||
// Re-derive the fills touched by the new edges. Rather than incrementally splitting
|
||||
// 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
|
||||
}
|
||||
|
||||
/// When a new edge splits a fill (both endpoints on the fill's boundary),
|
||||
/// split the fill into two fills.
|
||||
pub fn split_fill_by_edge(
|
||||
&mut self,
|
||||
fill_id: FillId,
|
||||
splitting_edge: EdgeId,
|
||||
) -> Option<(FillId, FillId)> {
|
||||
let fill = &self.fills[fill_id.idx()];
|
||||
if fill.deleted {
|
||||
return None;
|
||||
/// Merge edges from the latest stroke that are geometrically coincident with an
|
||||
/// existing edge between the same two vertices (drawing a shape whose edge lands exactly
|
||||
/// on an existing edge). Keeps the existing edge, redirects fill references, and frees
|
||||
/// the duplicate — preventing zero-area "sliver" fills between the two copies.
|
||||
/// Weld degree-1 endpoints of freshly inserted edges onto a near-coincident existing
|
||||
/// vertex. A self-intersecting freehand stroke can create an intersection vertex a
|
||||
/// fraction of a pixel from a segment endpoint that should be the same point; if they
|
||||
/// don't merge, the stroke's loop is broken by a degree-1 stub and the cut is lost.
|
||||
fn weld_dangling_endpoints(&mut self, new_edges: &[EdgeId], eps: f64) {
|
||||
// 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];
|
||||
let split_v1 = self.edges[splitting_edge.idx()].vertices[1];
|
||||
// Snapshot each affected fill's path + attributes before we delete them.
|
||||
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
|
||||
// endpoint vertices appear as the "arrival" vertex of a directed edge.
|
||||
let boundary = fill.boundary.clone();
|
||||
// Edge set for the local arrangement: every affected fill's boundary edges plus the
|
||||
// ENTIRE inserted stroke. We include the whole stroke (not just the segments whose
|
||||
// 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
|
||||
let end_vertex = |eid: EdgeId, dir: Direction| -> VertexId {
|
||||
match dir {
|
||||
Direction::Forward => self.edges[eid.idx()].vertices[1],
|
||||
Direction::Backward => self.edges[eid.idx()].vertices[0],
|
||||
// Expand to the induced subgraph on the covered vertices. A self-intersecting
|
||||
// freehand stroke splits its own edges via `split_edge`, whose sub-edges aren't in
|
||||
// `new_edges`; without them the local arrangement has gaps and the stroke's loop
|
||||
// looks like dangling fragments. Adding every edge whose endpoints are both already
|
||||
// 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,
|
||||
}
|
||||
};
|
||||
|
||||
// Find positions where boundary edges arrive at split_v0 and split_v1
|
||||
let mut pos_v0: Option<usize> = None;
|
||||
let mut pos_v1: Option<usize> = None;
|
||||
|
||||
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);
|
||||
let dend = |entry: &(EdgeId, Direction)| -> Point {
|
||||
let c = self.edges[entry.0.idx()].curve;
|
||||
match entry.1 {
|
||||
Direction::Forward => c.p3,
|
||||
Direction::Backward => c.p0,
|
||||
}
|
||||
if ev == split_v1 && pos_v1.is_none() {
|
||||
pos_v1 = Some(i);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
const EPS: f64 = 0.5;
|
||||
loop {
|
||||
half_a.push(boundary[idx]);
|
||||
if idx == pos_v1 {
|
||||
let n = face.len();
|
||||
if n < 2 {
|
||||
break;
|
||||
}
|
||||
idx = (idx + 1) % n;
|
||||
}
|
||||
half_a.push((splitting_edge, Direction::Forward));
|
||||
|
||||
let mut half_b = Vec::new();
|
||||
idx = (pos_v1 + 1) % n;
|
||||
loop {
|
||||
half_b.push(boundary[idx]);
|
||||
if idx == pos_v0 {
|
||||
let mut collapsed = false;
|
||||
for i in 0..n {
|
||||
let j = (i + 1) % n;
|
||||
// entries i and j cancel when j ends back at i's start.
|
||||
let si = dstart(&face[i]);
|
||||
let ej = dend(&face[j]);
|
||||
if (si.x - ej.x).hypot(si.y - ej.y) < EPS {
|
||||
let (hi, lo) = if i > j { (i, j) } else { (j, i) };
|
||||
face.remove(hi);
|
||||
face.remove(lo);
|
||||
collapsed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !collapsed {
|
||||
break;
|
||||
}
|
||||
idx = (idx + 1) % n;
|
||||
}
|
||||
half_b.push((splitting_edge, Direction::Backward));
|
||||
}
|
||||
|
||||
// Delete the original fill
|
||||
self.fills[fill_id.idx()].deleted = true;
|
||||
self.free_fills.push(fill_id.0);
|
||||
/// Trace all faces of the planar arrangement formed by `edge_set`, using the standard
|
||||
/// angular next-edge rule (turn to the clockwise-adjacent dart of the twin at each
|
||||
/// 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 fill_a = self.alloc_fill(half_a, color, fill_rule);
|
||||
let fill_b = self.alloc_fill(half_b, color, fill_rule);
|
||||
let mut visited: HashSet<(EdgeId, Direction)> = HashSet::new();
|
||||
let mut faces: Vec<Vec<(EdgeId, Direction)>> = Vec::new();
|
||||
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).
|
||||
|
|
@ -1101,21 +1642,6 @@ impl VectorGraph {
|
|||
// 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).
|
||||
/// Handles `EdgeId::NONE` separators to start new contours (holes).
|
||||
|
|
@ -1458,11 +1984,14 @@ impl VectorGraph {
|
|||
|
||||
// ── 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
|
||||
/// both the returned graph and `self`, so both sides have closed fill
|
||||
/// boundaries when the selection is moved.
|
||||
/// **Boundary edges** are *duplicated* (copied into the returned graph but kept in
|
||||
/// `self`, so remaining shapes keep closed boundaries). They are `explicit_boundary`
|
||||
/// (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
|
||||
/// old (self) IDs to new (returned graph) IDs.
|
||||
|
|
@ -1470,17 +1999,58 @@ impl VectorGraph {
|
|||
&mut self,
|
||||
inside_edges: &HashSet<EdgeId>,
|
||||
inside_fills: &HashSet<FillId>,
|
||||
boundary_edge_ids: &HashSet<EdgeId>,
|
||||
explicit_boundary: &HashSet<EdgeId>,
|
||||
) -> (VectorGraph, HashMap<VertexId, VertexId>, HashMap<EdgeId, EdgeId>) {
|
||||
let mut new_graph = VectorGraph::new();
|
||||
let mut vtx_map: HashMap<VertexId, VertexId> = HashMap::new();
|
||||
let mut edge_map: HashMap<EdgeId, EdgeId> = HashMap::new();
|
||||
|
||||
// Collect all edge IDs we need to copy into the new graph
|
||||
let edges_to_copy: HashSet<EdgeId> = inside_edges
|
||||
.union(boundary_edge_ids)
|
||||
.copied()
|
||||
.collect();
|
||||
// Augment the inside set with every boundary edge of an extracted fill. A
|
||||
// selection might not enumerate them all (e.g. lasso/region selection populates
|
||||
// edges differently than `select_fill`); without this an extracted fill would be
|
||||
// copied with `EdgeId::NONE` standing in for a missing edge, and that NONE later
|
||||
// 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
|
||||
let mut referenced_vids: HashSet<VertexId> = HashSet::new();
|
||||
|
|
@ -1493,16 +2063,21 @@ impl VectorGraph {
|
|||
}
|
||||
}
|
||||
|
||||
// Determine which vertices are interior (exclusively owned by the
|
||||
// extracted subgraph) vs boundary (shared with remaining geometry).
|
||||
// A vertex is interior if ALL of its incident edges are either in
|
||||
// inside_edges or boundary_edge_ids.
|
||||
// Determine which vertices are safe to free from `self`. A vertex can only be
|
||||
// freed if EVERY one of its incident edges is actually being removed from `self`,
|
||||
// i.e. is an inside edge that is NOT a boundary edge. Boundary edges are kept
|
||||
// (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 boundary_vertices: HashSet<VertexId> = HashSet::new();
|
||||
for &vid in &referenced_vids {
|
||||
let incident = self.edges_at_vertex(vid);
|
||||
let all_inside = incident.iter().all(|&eid| edges_to_copy.contains(&eid));
|
||||
if all_inside {
|
||||
let all_removed = !incident.is_empty()
|
||||
&& incident
|
||||
.iter()
|
||||
.all(|&eid| inside_edges.contains(&eid) && !boundary_edge_ids.contains(&eid));
|
||||
if all_removed {
|
||||
interior_vertices.insert(vid);
|
||||
} else {
|
||||
boundary_vertices.insert(vid);
|
||||
|
|
@ -1566,9 +2141,10 @@ impl VectorGraph {
|
|||
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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,3 +10,7 @@ mod editing;
|
|||
mod gap_close;
|
||||
#[cfg(test)]
|
||||
mod region;
|
||||
#[cfg(test)]
|
||||
mod region_cut_select;
|
||||
#[cfg(test)]
|
||||
mod tween;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "lightningbeam-editor"
|
||||
version = "1.0.4-alpha"
|
||||
version = "1.0.5-alpha"
|
||||
edition = "2021"
|
||||
description = "Multimedia editor for audio, video and 2D animation"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
|
|
|||
|
|
@ -43,8 +43,6 @@ pub enum CurveEditAction {
|
|||
#[derive(Clone, Debug)]
|
||||
pub struct CurveDragState {
|
||||
pub keyframe_index: usize,
|
||||
pub original_time: f64,
|
||||
pub original_value: f32,
|
||||
pub current_time: f64,
|
||||
pub current_value: f32,
|
||||
}
|
||||
|
|
@ -263,8 +261,6 @@ pub fn render_curve_lane(
|
|||
let kf = &keyframes[idx];
|
||||
*drag_state = Some(CurveDragState {
|
||||
keyframe_index: idx,
|
||||
original_time: kf.time,
|
||||
original_value: kf.value,
|
||||
current_time: kf.time,
|
||||
current_value: kf.value,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ use ffmpeg_next as ffmpeg;
|
|||
pub struct CpuYuvConverter {
|
||||
width: 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 {
|
||||
|
|
@ -22,7 +27,20 @@ impl CpuYuvConverter {
|
|||
/// * `width` - Frame width in pixels
|
||||
/// * `height` - Frame height in pixels
|
||||
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
|
||||
|
|
@ -40,7 +58,7 @@ impl CpuYuvConverter {
|
|||
///
|
||||
/// # Panics
|
||||
/// 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;
|
||||
assert_eq!(
|
||||
rgba_data.len(),
|
||||
|
|
@ -50,51 +68,17 @@ impl CpuYuvConverter {
|
|||
rgba_data.len()
|
||||
);
|
||||
|
||||
// Create source RGBA frame
|
||||
let mut rgba_frame = ffmpeg::frame::Video::new(
|
||||
ffmpeg::format::Pixel::RGBA,
|
||||
self.width,
|
||||
self.height,
|
||||
);
|
||||
|
||||
// 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)
|
||||
// Copy RGBA into the reused source frame, run the reused scaler into the reused
|
||||
// dest frame (SIMD-optimized), then extract planes.
|
||||
self.rgba_frame.data_mut(0).copy_from_slice(rgba_data);
|
||||
self.scaler
|
||||
.run(&self.rgba_frame, &mut self.yuv_frame)
|
||||
.map_err(|e| format!("swscale conversion failed: {}", e))?;
|
||||
|
||||
// Extract planar YUV data
|
||||
// YUV420p has 3 planes:
|
||||
// - Y: full resolution (width × height)
|
||||
// - U: quarter resolution (width/2 × height/2)
|
||||
// - 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();
|
||||
// YUV420p planes: Y full-res, U/V quarter-res (2×2 subsampled).
|
||||
let y_plane = self.yuv_frame.data(0).to_vec();
|
||||
let u_plane = self.yuv_frame.data(1).to_vec();
|
||||
let v_plane = self.yuv_frame.data(2).to_vec();
|
||||
|
||||
Ok((y_plane, u_plane, v_plane))
|
||||
}
|
||||
|
|
@ -112,7 +96,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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)
|
||||
let rgba_data = vec![0u8; 1920 * 1080 * 4];
|
||||
|
|
@ -133,7 +117,7 @@ mod tests {
|
|||
#[test]
|
||||
#[should_panic(expected = "RGBA data size mismatch")]
|
||||
fn test_wrong_input_size_panics() {
|
||||
let converter = CpuYuvConverter::new(1920, 1080).unwrap();
|
||||
let mut converter = CpuYuvConverter::new(1920, 1080).unwrap();
|
||||
|
||||
// Wrong size input
|
||||
let rgba_data = vec![0u8; 1000];
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ pub struct DocumentHint {
|
|||
pub has_raster: bool,
|
||||
pub has_vector: bool,
|
||||
pub current_time: f64,
|
||||
pub doc_width: u32,
|
||||
pub doc_height: u32,
|
||||
}
|
||||
|
||||
/// Export type selection
|
||||
|
|
@ -577,7 +575,7 @@ impl ExportDialog {
|
|||
|
||||
if ui.button("Choose location...").clicked() {
|
||||
let ext = self.current_extension();
|
||||
let mut dialog = rfd::FileDialog::new()
|
||||
let dialog = rfd::FileDialog::new()
|
||||
.set_directory(&self.output_dir)
|
||||
.set_file_name(&self.output_filename)
|
||||
.add_filter(ext.to_uppercase(), &[ext]);
|
||||
|
|
|
|||
|
|
@ -567,7 +567,7 @@ impl ExportOrchestrator {
|
|||
let gpu = state.gpu_resources.as_mut().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,
|
||||
state.settings.time,
|
||||
w, h,
|
||||
|
|
|
|||
|
|
@ -1586,11 +1586,6 @@ impl GpuBrushEngine {
|
|||
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
|
||||
/// immutable). Bumps recency and evicts the least-recently-used past the budget.
|
||||
/// `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 });
|
||||
}
|
||||
|
||||
/// 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)).
|
||||
pub fn clear_displacement_buf(&self, queue: &wgpu::Queue, id: &Uuid) {
|
||||
if let Some(db) = self.displacement_bufs.get(id) {
|
||||
|
|
@ -2029,12 +2012,24 @@ impl BlitTransform {
|
|||
// y' = b*x + d*y + f
|
||||
// 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();
|
||||
// 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 {
|
||||
col0: [a as f32, b 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],
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ pub enum AppAction {
|
|||
TogglePlayPause,
|
||||
CancelAction,
|
||||
ToggleDebugOverlay,
|
||||
ToggleOnionSkin,
|
||||
#[cfg(debug_assertions)]
|
||||
ToggleTestMode,
|
||||
|
||||
|
|
@ -150,7 +151,7 @@ impl AppAction {
|
|||
Self::ToolErase | Self::ToolSmudge | Self::ToolSelectLasso | Self::ToolSplit => "Tools",
|
||||
|
||||
Self::TogglePlayPause | Self::CancelAction |
|
||||
Self::ToggleDebugOverlay => "Global",
|
||||
Self::ToggleDebugOverlay | Self::ToggleOnionSkin => "Global",
|
||||
#[cfg(debug_assertions)]
|
||||
Self::ToggleTestMode => "Global",
|
||||
|
||||
|
|
@ -246,6 +247,7 @@ impl AppAction {
|
|||
Self::TogglePlayPause => "Toggle Play/Pause",
|
||||
Self::CancelAction => "Cancel / Escape",
|
||||
Self::ToggleDebugOverlay => "Toggle Debug Overlay",
|
||||
Self::ToggleOnionSkin => "Toggle Onion Skinning",
|
||||
#[cfg(debug_assertions)]
|
||||
Self::ToggleTestMode => "Toggle Test Mode",
|
||||
Self::PianoRollDelete => "Piano Roll: Delete",
|
||||
|
|
@ -281,7 +283,7 @@ impl AppAction {
|
|||
Self::ToolEyedropper, Self::ToolLine, Self::ToolPolygon,
|
||||
Self::ToolBezierEdit, Self::ToolText, Self::ToolRegionSelect,
|
||||
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)]
|
||||
Self::ToggleTestMode,
|
||||
Self::PianoRollDelete, Self::StageDelete,
|
||||
|
|
@ -330,6 +332,7 @@ impl From<MenuAction> for AppAction {
|
|||
MenuAction::AddTestClip => Self::AddTestClip,
|
||||
MenuAction::DeleteLayer => Self::DeleteLayer,
|
||||
MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility,
|
||||
MenuAction::ToggleOnionSkin => Self::ToggleOnionSkin,
|
||||
MenuAction::ShowMasterTrack => Self::ToggleLayerVisibility, // not directly mappable
|
||||
MenuAction::NewKeyframe => Self::NewKeyframe,
|
||||
MenuAction::NewBlankKeyframe => Self::NewBlankKeyframe,
|
||||
|
|
@ -392,6 +395,7 @@ impl TryFrom<AppAction> for MenuAction {
|
|||
AppAction::AddTestClip => MenuAction::AddTestClip,
|
||||
AppAction::DeleteLayer => MenuAction::DeleteLayer,
|
||||
AppAction::ToggleLayerVisibility => MenuAction::ToggleLayerVisibility,
|
||||
AppAction::ToggleOnionSkin => MenuAction::ToggleOnionSkin,
|
||||
AppAction::NewKeyframe => MenuAction::NewKeyframe,
|
||||
AppAction::NewBlankKeyframe => MenuAction::NewBlankKeyframe,
|
||||
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::CancelAction, Some(Shortcut::new(ShortcutKey::Escape, 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)]
|
||||
defaults.insert(AppAction::ToggleTestMode, Some(Shortcut::new(ShortcutKey::F5, nc, ns, na)));
|
||||
|
||||
|
|
|
|||
|
|
@ -984,8 +984,9 @@ struct EditorApp {
|
|||
snap_enabled: bool, // Whether to snap to geometry (default: true)
|
||||
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)
|
||||
// Region select state
|
||||
region_selection: Option<lightningbeam_core::selection::RegionSelection>,
|
||||
/// Undo depth before the current region-select session's first cut (see
|
||||
/// `resolve_pending_region_cut`). `None` when no region selection is pending.
|
||||
pending_region_cut_base: Option<usize>,
|
||||
region_select_mode: lightningbeam_core::tool::RegionSelectMode,
|
||||
lasso_mode: lightningbeam_core::tool::LassoMode,
|
||||
|
||||
|
|
@ -1026,6 +1027,8 @@ struct EditorApp {
|
|||
recording_mirror_rx: Option<rtrb::Consumer<f32>>,
|
||||
/// Current file path (None if not yet saved)
|
||||
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.
|
||||
raster_store: lightningbeam_core::raster_store::RasterStore,
|
||||
/// Miss-sink: raster keyframe ids the canvas wanted but whose pixels weren't
|
||||
|
|
@ -1070,6 +1073,11 @@ struct EditorApp {
|
|||
preferences_dialog: preferences::dialog::PreferencesDialog,
|
||||
/// Export orchestrator for background exports
|
||||
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
|
||||
effect_thumbnail_generator: Option<EffectThumbnailGenerator>,
|
||||
|
||||
|
|
@ -1296,7 +1304,7 @@ impl EditorApp {
|
|||
snap_enabled: true, // Default to snapping
|
||||
paint_bucket_gap_tolerance: 5.0, // Default gap tolerance
|
||||
polygon_sides: 5, // Default to pentagon
|
||||
region_selection: None,
|
||||
pending_region_cut_base: None,
|
||||
region_select_mode: lightningbeam_core::tool::RegionSelectMode::default(),
|
||||
lasso_mode: lightningbeam_core::tool::LassoMode::default(),
|
||||
input_level: 0.0,
|
||||
|
|
@ -1313,6 +1321,7 @@ impl EditorApp {
|
|||
waveform_result_tx,
|
||||
recording_mirror_rx,
|
||||
current_file_path: None, // No file loaded initially
|
||||
onion_skin: crate::panes::OnionSkinSettings::default(),
|
||||
raster_store: lightningbeam_core::raster_store::RasterStore::new(None),
|
||||
raster_fault_requests: Default::default(),
|
||||
raster_load_result_tx,
|
||||
|
|
@ -1330,6 +1339,8 @@ impl EditorApp {
|
|||
large_media_prompt: None,
|
||||
preferences_dialog: preferences::dialog::PreferencesDialog::default(),
|
||||
export_orchestrator: None,
|
||||
export_renderer: None,
|
||||
export_image_cache: None,
|
||||
effect_thumbnail_generator: None, // Initialized when GPU available
|
||||
|
||||
// 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
|
||||
/// `raw_pixels` and record a `RasterStrokeAction` for undo.
|
||||
/// Clears `selection.raster_floating` and `selection.raster_selection`.
|
||||
|
|
@ -2619,27 +2596,7 @@ impl EditorApp {
|
|||
None => return,
|
||||
};
|
||||
|
||||
// Region selection case: faces are selected but no edges.
|
||||
// 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.
|
||||
// Delete the selected edges (region/marquee/click all populate the same sets).
|
||||
let edge_ids: Vec<lightningbeam_core::vector_graph::EdgeId> =
|
||||
self.selection.selected_edges().iter().copied().collect();
|
||||
|
||||
|
|
@ -2833,7 +2790,7 @@ impl EditorApp {
|
|||
let canvas_before = kf.raw_pixels.clone();
|
||||
let canvas_w = kf.width;
|
||||
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};
|
||||
self.selection.raster_floating = Some(RasterFloatingSelection {
|
||||
|
|
@ -3026,38 +2983,31 @@ impl EditorApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Revert an uncommitted region selection, restoring original shapes
|
||||
fn revert_region_selection(
|
||||
region_selection: &mut Option<lightningbeam_core::selection::RegionSelection>,
|
||||
action_executor: &mut lightningbeam_core::action::ActionExecutor,
|
||||
selection: &mut lightningbeam_core::selection::Selection,
|
||||
) {
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
|
||||
let region_sel = match region_selection.take() {
|
||||
Some(rs) => rs,
|
||||
None => return,
|
||||
};
|
||||
|
||||
if region_sel.committed {
|
||||
/// Resolve a pending region-select cut once its selection has been cleared.
|
||||
///
|
||||
/// A region/lasso selection cuts the geometry (an undoable `"Region select"` action)
|
||||
/// and selects the resulting sub-pieces. When that selection is deselected:
|
||||
/// - if nothing was edited in between, the cut(s) are healed (undone) so the lasso
|
||||
/// was non-destructive — the shape returns to whole;
|
||||
/// - 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.
|
||||
/// We tell the two apart by walking the undo stack down to the recorded base depth and
|
||||
/// only undoing while the top action is itself a region-select cut; the first non-cut
|
||||
/// 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 };
|
||||
// Only resolve once the region selection has actually been deselected.
|
||||
if self.selection.has_geometry_selection() {
|
||||
return;
|
||||
}
|
||||
|
||||
let doc = action_executor.document_mut();
|
||||
let layer = match doc.get_layer_mut(®ion_sel.layer_id) {
|
||||
Some(l) => l,
|
||||
None => return,
|
||||
};
|
||||
let vector_layer = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// TODO: DCEL - region selection revert disabled during migration
|
||||
// (was: remove/add_shape_from/to_keyframe for splits)
|
||||
let _ = vector_layer;
|
||||
|
||||
selection.clear();
|
||||
while self.action_executor.undo_depth() > base {
|
||||
if self.action_executor.undo_description().as_deref() == Some("Region select") {
|
||||
let _ = self.action_executor.undo();
|
||||
} else {
|
||||
break; // an edit sits on top → the user changed something; keep it all.
|
||||
}
|
||||
}
|
||||
self.pending_region_cut_base = None;
|
||||
}
|
||||
|
||||
fn handle_menu_action(&mut self, action: MenuAction) {
|
||||
|
|
@ -3292,8 +3242,6 @@ impl EditorApp {
|
|||
has_raster: false,
|
||||
has_vector: false,
|
||||
current_time: doc.current_time,
|
||||
doc_width: doc.width as u32,
|
||||
doc_height: doc.height as u32,
|
||||
};
|
||||
scan(&doc.root.children, &mut h);
|
||||
h
|
||||
|
|
@ -3461,26 +3409,24 @@ impl EditorApp {
|
|||
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 self.selection.has_geometry_selection() {
|
||||
// TODO: DCEL group deferred to Phase 2
|
||||
} else {
|
||||
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
|
||||
if clip_ids.len() >= 2 {
|
||||
let instance_id = uuid::Uuid::new_v4();
|
||||
let action = lightningbeam_core::actions::GroupAction::new(
|
||||
layer_id, self.playback_time, Vec::new(), clip_ids, instance_id,
|
||||
);
|
||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||
eprintln!("Failed to group: {}", e);
|
||||
} else {
|
||||
self.selection.clear();
|
||||
self.selection.add_clip_instance(instance_id);
|
||||
}
|
||||
let fills: Vec<_> = self.selection.selected_fills().iter().copied().collect();
|
||||
let edges: Vec<_> = self.selection.selected_edges().iter().copied().collect();
|
||||
let clip_id = uuid::Uuid::new_v4();
|
||||
let instance_id = uuid::Uuid::new_v4();
|
||||
let action = lightningbeam_core::actions::GroupAction::new(
|
||||
layer_id, self.playback_time, fills, edges, clip_id, instance_id,
|
||||
);
|
||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||
eprintln!("Failed to group: {}", e);
|
||||
} else {
|
||||
self.selection.clear();
|
||||
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 => {
|
||||
if let Some(layer_id) = self.active_layer_id {
|
||||
if self.selection.has_geometry_selection() {
|
||||
// TODO: DCEL convert-to-movie-clip deferred to Phase 2
|
||||
} else {
|
||||
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
|
||||
if clip_ids.len() >= 1 {
|
||||
let instance_id = uuid::Uuid::new_v4();
|
||||
let action = lightningbeam_core::actions::ConvertToMovieClipAction::new(
|
||||
layer_id,
|
||||
self.playback_time,
|
||||
Vec::new(),
|
||||
clip_ids,
|
||||
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);
|
||||
}
|
||||
let fills: Vec<_> = self.selection.selected_fills().iter().copied().collect();
|
||||
let edges: Vec<_> = self.selection.selected_edges().iter().copied().collect();
|
||||
let clip_id = uuid::Uuid::new_v4();
|
||||
let instance_id = uuid::Uuid::new_v4();
|
||||
let action = lightningbeam_core::actions::ConvertToMovieClipAction::new(
|
||||
layer_id, self.playback_time, fills, edges, clip_id, 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_w, doc_h) = (doc.width as u32, doc.height as u32);
|
||||
drop(doc);
|
||||
let _ = doc;
|
||||
let mut layer = RasterLayer::new(layer_name);
|
||||
layer.ensure_keyframe_at(self.playback_time, doc_w, doc_h);
|
||||
let action = lightningbeam_core::actions::AddLayerAction::new(AnyLayer::Raster(layer))
|
||||
|
|
@ -3785,8 +3725,16 @@ impl EditorApp {
|
|||
// TODO: Implement add motion tween
|
||||
}
|
||||
MenuAction::AddShapeTween => {
|
||||
println!("Menu: Add Shape Tween");
|
||||
// TODO: Implement add shape tween
|
||||
if let Some(layer_id) = self.active_layer_id {
|
||||
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 => {
|
||||
println!("Menu: Return to Start");
|
||||
|
|
@ -3816,6 +3764,9 @@ impl EditorApp {
|
|||
MenuAction::RecenterView => {
|
||||
self.pending_view_action = Some(MenuAction::RecenterView);
|
||||
}
|
||||
MenuAction::ToggleOnionSkin => {
|
||||
self.onion_skin.enabled = !self.onion_skin.enabled;
|
||||
}
|
||||
MenuAction::NextLayout => {
|
||||
println!("Menu: Next Layout");
|
||||
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
|
||||
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 {
|
||||
// Get image dimensions
|
||||
let (width, height) = asset_info.dimensions.unwrap_or((100.0, 100.0));
|
||||
|
||||
// TODO: Image fills on DCEL faces are a separate feature.
|
||||
// For now, just log a message.
|
||||
let _ = (layer_id, width, height);
|
||||
eprintln!("Image drop to canvas not yet supported with DCEL backend");
|
||||
let (img_w, img_h) = asset_info.dimensions.unwrap_or((100.0, 100.0));
|
||||
let (doc_w, doc_h) = {
|
||||
let d = self.action_executor.document();
|
||||
(d.width as f64, d.height as f64)
|
||||
};
|
||||
let x = (doc_w - img_w) / 2.0;
|
||||
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 {
|
||||
// For clips, create a clip instance
|
||||
let mut clip_instance = ClipInstance::new(asset_info.clip_id)
|
||||
|
|
@ -5781,7 +5739,7 @@ impl eframe::App for EditorApp {
|
|||
.map(|(&lid, _)| lid);
|
||||
if let Some(layer_id) = recording_layer {
|
||||
// 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();
|
||||
document.get_layer(&layer_id)
|
||||
.and_then(|layer| {
|
||||
|
|
@ -6195,17 +6153,17 @@ impl eframe::App for EditorApp {
|
|||
self.render_large_media_prompt(ctx);
|
||||
|
||||
// Render video frames incrementally (if video export in progress)
|
||||
if let Some(orchestrator) = &mut self.export_orchestrator {
|
||||
if orchestrator.is_exporting() {
|
||||
// Get GPU resources from eframe's wgpu render state
|
||||
if let Some(render_state) = frame.wgpu_render_state() {
|
||||
let device = &render_state.device;
|
||||
let queue = &render_state.queue;
|
||||
let exporting = self.export_orchestrator.as_ref().map_or(false, |o| o.is_exporting());
|
||||
if exporting {
|
||||
if let Some(render_state) = frame.wgpu_render_state() {
|
||||
let device = &render_state.device;
|
||||
let queue = &render_state.queue;
|
||||
|
||||
// Create temporary renderer and image cache for export
|
||||
// Note: Creating a new renderer per frame is inefficient but simple
|
||||
// TODO: Reuse renderer across frames by storing it in EditorApp
|
||||
let mut temp_renderer = vello::Renderer::new(
|
||||
// Build the renderer + image cache ONCE per export (reused every frame).
|
||||
// The image cache is given the container path so lazily-paged image assets
|
||||
// (Phase 4 Tier 1) decode during export.
|
||||
if self.export_renderer.is_none() {
|
||||
self.export_renderer = vello::Renderer::new(
|
||||
device,
|
||||
vello::RendererOptions {
|
||||
use_cpu: false,
|
||||
|
|
@ -6214,43 +6172,52 @@ impl eframe::App for EditorApp {
|
|||
pipeline_cache: None,
|
||||
},
|
||||
).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) = &mut temp_renderer {
|
||||
// Drive incremental video export.
|
||||
if let Ok(has_more) = orchestrator.render_next_video_frame(
|
||||
self.action_executor.document_mut(),
|
||||
device,
|
||||
queue,
|
||||
renderer,
|
||||
&mut temp_image_cache,
|
||||
&self.video_manager,
|
||||
Some(&self.raster_store),
|
||||
) {
|
||||
if has_more {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if let (Some(renderer), Some(image_cache), Some(orchestrator)) = (
|
||||
self.export_renderer.as_mut(),
|
||||
self.export_image_cache.as_mut(),
|
||||
self.export_orchestrator.as_mut(),
|
||||
) {
|
||||
// Drive incremental video export.
|
||||
if let Ok(has_more) = orchestrator.render_next_video_frame(
|
||||
self.action_executor.document_mut(),
|
||||
device,
|
||||
queue,
|
||||
renderer,
|
||||
image_cache,
|
||||
&self.video_manager,
|
||||
Some(&self.raster_store),
|
||||
) {
|
||||
if has_more {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
// Drive single-frame image export (two-frame async: render then readback).
|
||||
match orchestrator.render_image_frame(
|
||||
self.action_executor.document_mut(),
|
||||
device,
|
||||
queue,
|
||||
renderer,
|
||||
&mut temp_image_cache,
|
||||
&self.video_manager,
|
||||
self.selection.raster_floating.as_ref(),
|
||||
Some(&self.raster_store),
|
||||
) {
|
||||
Ok(false) => { ctx.request_repaint(); } // readback pending
|
||||
Ok(true) => {} // done or cancelled
|
||||
Err(e) => { eprintln!("Image export failed: {e}"); }
|
||||
}
|
||||
// Drive single-frame image export (two-frame async: render then readback).
|
||||
match orchestrator.render_image_frame(
|
||||
self.action_executor.document_mut(),
|
||||
device,
|
||||
queue,
|
||||
renderer,
|
||||
image_cache,
|
||||
&self.video_manager,
|
||||
self.selection.raster_floating.as_ref(),
|
||||
Some(&self.raster_store),
|
||||
) {
|
||||
Ok(false) => { ctx.request_repaint(); } // readback pending
|
||||
Ok(true) => {} // done or cancelled
|
||||
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
|
||||
|
|
@ -6459,6 +6426,14 @@ impl eframe::App for EditorApp {
|
|||
// Create render context
|
||||
let mut ctx = RenderContext {
|
||||
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,
|
||||
icon_cache: &mut self.icon_cache,
|
||||
selected_tool: &mut self.selected_tool,
|
||||
|
|
@ -6534,13 +6509,12 @@ impl eframe::App for EditorApp {
|
|||
graph_topology_generation: &mut self.graph_topology_generation,
|
||||
script_to_edit: &mut self.script_to_edit,
|
||||
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,
|
||||
lasso_mode: &mut self.lasso_mode,
|
||||
pending_graph_loads: &self.pending_graph_loads,
|
||||
clipboard_consumed: &mut clipboard_consumed,
|
||||
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_ungroup: &mut self.pending_node_ungroup,
|
||||
#[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 self.selection.raster_floating.is_some() {
|
||||
self.cancel_raster_floating();
|
||||
} else if self.selection.raster_selection.is_some() {
|
||||
self.selection.raster_selection = None;
|
||||
} else if self.region_selection.is_some() {
|
||||
Self::revert_region_selection(
|
||||
&mut self.region_selection,
|
||||
&mut self.action_executor,
|
||||
&mut self.selection,
|
||||
);
|
||||
} else if self.selection.has_geometry_selection() {
|
||||
self.selection.clear_geometry_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)
|
||||
if ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::ToggleDebugOverlay, i)) {
|
||||
self.debug_overlay_visible = !self.debug_overlay_visible;
|
||||
|
|
@ -7501,7 +7475,7 @@ fn render_pane(
|
|||
// Active tab highlight with per-corner rounding
|
||||
if is_active {
|
||||
let cr = corner_r as u8;
|
||||
let rounding = egui::Rounding {
|
||||
let rounding = egui::CornerRadius {
|
||||
nw: if i == 0 { cr } else { 0 },
|
||||
sw: if i == 0 { cr } else { 0 },
|
||||
ne: if i == n - 1 { cr } else { 0 },
|
||||
|
|
@ -7546,7 +7520,7 @@ fn render_pane(
|
|||
if tab_response.hovered() && !is_active {
|
||||
ui.painter().rect_filled(
|
||||
tab_rect,
|
||||
egui::Rounding {
|
||||
egui::CornerRadius {
|
||||
nw: 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 },
|
||||
|
|
|
|||
|
|
@ -332,6 +332,7 @@ pub enum MenuAction {
|
|||
ZoomOut,
|
||||
ActualSize,
|
||||
RecenterView,
|
||||
ToggleOnionSkin,
|
||||
NextLayout,
|
||||
PreviousLayout,
|
||||
#[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 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 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 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::GROUP, &Self::ADD_LAYER, &Self::NEW_KEYFRAME,
|
||||
&Self::ZOOM_IN, &Self::ZOOM_OUT, &Self::ACTUAL_SIZE,
|
||||
&Self::TOGGLE_ONION_SKIN,
|
||||
&Self::NEXT_LAYOUT, &Self::PREVIOUS_LAYOUT,
|
||||
&Self::SETTINGS, &Self::CLOSE_WINDOW,
|
||||
]
|
||||
|
|
@ -566,6 +569,7 @@ impl MenuItemDef {
|
|||
MenuDef::Item(&Self::ZOOM_OUT),
|
||||
MenuDef::Item(&Self::ACTUAL_SIZE),
|
||||
MenuDef::Item(&Self::RECENTER_VIEW),
|
||||
MenuDef::Item(&Self::TOGGLE_ONION_SKIN),
|
||||
MenuDef::Separator,
|
||||
MenuDef::Submenu {
|
||||
label: "Layout",
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ pub fn gradient_stop_editor(
|
|||
// ── Paint handles ─────────────────────────────────────────────────────
|
||||
// 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()) {
|
||||
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 stroke = Stroke::new(
|
||||
if is_selected { 2.0 } else { 1.0 },
|
||||
|
|
@ -308,7 +308,7 @@ pub fn gradient_stop_editor(
|
|||
|
||||
// ── 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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
/// - Document settings (when nothing is focused)
|
||||
|
||||
use eframe::egui::{self, DragValue, Ui};
|
||||
use lightningbeam_core::brush_settings::{bundled_brushes, BrushSettings};
|
||||
use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction, SetFillPaintAction};
|
||||
use lightningbeam_core::brush_settings::bundled_brushes;
|
||||
use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction, SetFillPaintAction, SetImageFillAction};
|
||||
use lightningbeam_core::gradient::ShapeGradient;
|
||||
use lightningbeam_core::layer::{AnyLayer, LayerTrait};
|
||||
use lightningbeam_core::selection::FocusSelection;
|
||||
|
|
@ -785,6 +785,26 @@ impl InfopanelPane {
|
|||
let edge_ids: Vec<lightningbeam_core::vector_graph::EdgeId> = shared.selection.selected_edges()
|
||||
.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")
|
||||
.id_salt(("shape", path))
|
||||
.default_open(self.shape_section_open)
|
||||
|
|
@ -792,47 +812,67 @@ impl InfopanelPane {
|
|||
self.shape_section_open = true;
|
||||
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_solid = matches!(&info.fill_color, Some(Some(_)));
|
||||
let fill_is_none = matches!(&info.fill_color, Some(None))
|
||||
&& matches!(&info.fill_gradient, Some(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.label("Fill:");
|
||||
if fill_mixed {
|
||||
ui.label("--");
|
||||
} else {
|
||||
if ui.selectable_label(fill_is_none, "None").clicked() && !fill_is_none {
|
||||
let action = SetFillPaintAction::solid(
|
||||
layer_id, time, face_ids.clone(), None,
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
if ui.selectable_label(fill_is_none && !has_image, "None").clicked() {
|
||||
if has_image {
|
||||
shared.pending_actions.push(Box::new(
|
||||
SetImageFillAction::new(layer_id, time, face_ids.clone(), None)));
|
||||
}
|
||||
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 {
|
||||
// Switch to solid: use existing color or default to black
|
||||
let color = info.fill_color.flatten()
|
||||
.unwrap_or(ShapeColor::rgba(0, 0, 0, 255));
|
||||
let action = SetFillPaintAction::solid(
|
||||
layer_id, time, face_ids.clone(), Some(color),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
if ui.selectable_label(has_solid && !has_image, "Solid").clicked() {
|
||||
if has_image {
|
||||
shared.pending_actions.push(Box::new(
|
||||
SetImageFillAction::new(layer_id, time, face_ids.clone(), None)));
|
||||
}
|
||||
if !has_solid {
|
||||
let color = info.fill_color.flatten()
|
||||
.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 {
|
||||
let grad = info.fill_gradient.clone().flatten()
|
||||
.unwrap_or_default();
|
||||
let action = SetFillPaintAction::gradient(
|
||||
layer_id, time, face_ids.clone(), Some(grad),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
if ui.selectable_label(has_gradient && !has_image, "Gradient").clicked() {
|
||||
if has_image {
|
||||
shared.pending_actions.push(Box::new(
|
||||
SetImageFillAction::new(layer_id, time, face_ids.clone(), None)));
|
||||
}
|
||||
if !has_gradient {
|
||||
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
|
||||
if !fill_mixed && has_solid {
|
||||
if !fill_mixed && has_solid && !has_image {
|
||||
if let Some(Some(color)) = info.fill_color {
|
||||
ui.horizontal(|ui| {
|
||||
let mut rgba = [color.r, color.g, color.b, color.a];
|
||||
|
|
@ -848,7 +888,7 @@ impl InfopanelPane {
|
|||
}
|
||||
|
||||
// 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 gradient_stop_editor(ui, &mut grad, &mut self.selected_shape_gradient_stop) {
|
||||
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
|
||||
ui.horizontal(|ui| {
|
||||
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
|
||||
fn render_layer_section(&self, ui: &mut Ui, path: &NodePath, shared: &SharedPaneState, layer_ids: &[Uuid]) {
|
||||
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")
|
||||
fn midi_note_name(note: u8) -> String {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -143,7 +143,45 @@ pub fn find_sampled_audio_track(document: &lightningbeam_core::document::Documen
|
|||
}
|
||||
|
||||
/// 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> {
|
||||
/// 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,
|
||||
#[allow(dead_code)] // Used by pane chrome rendering in main.rs
|
||||
pub icon_cache: &'a mut crate::IconCache,
|
||||
|
|
@ -287,8 +325,10 @@ pub struct SharedPaneState<'a> {
|
|||
pub script_to_edit: &'a mut Option<Uuid>,
|
||||
/// Script ID that was just saved (triggers auto-recompile of nodes using it)
|
||||
pub script_saved: &'a mut Option<Uuid>,
|
||||
/// Active region selection (temporary split state)
|
||||
pub region_selection: &'a mut Option<lightningbeam_core::selection::RegionSelection>,
|
||||
/// Undo-stack depth recorded before the first region-select cut of the current
|
||||
/// 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)
|
||||
pub region_select_mode: &'a mut lightningbeam_core::tool::RegionSelectMode,
|
||||
/// Lasso select sub-mode (Freehand / Polygonal / Magnetic)
|
||||
|
|
@ -302,9 +342,6 @@ pub struct SharedPaneState<'a> {
|
|||
pub clipboard_consumed: &'a mut bool,
|
||||
/// Remappable keyboard shortcut manager
|
||||
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
|
||||
pub pending_node_group: &'a mut bool,
|
||||
/// Set by MenuAction::Group (ungroup variant) when focus is Nodes — consumed by node graph pane
|
||||
|
|
|
|||
|
|
@ -240,12 +240,6 @@ impl PianoRollPane {
|
|||
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 ──────────────────────────────────────────────────
|
||||
|
||||
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 (1–15) not already used by any note
|
||||
/// 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).
|
||||
|
|
@ -2408,7 +2363,7 @@ impl PaneRenderer for PianoRollPane {
|
|||
let doc = shared.action_executor.document();
|
||||
let is_measures = doc.timeline_mode == lightningbeam_core::document::TimelineMode::Measures;
|
||||
let tempo_map = doc.tempo_map();
|
||||
drop(doc);
|
||||
let _ = doc;
|
||||
|
||||
if is_measures {
|
||||
// Auto-detect grid when selection changes
|
||||
|
|
|
|||
|
|
@ -67,5 +67,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
|||
let mask = textureSample(mask_tex, mask_sampler, canvas_uv).r;
|
||||
let masked_a = c.a * mask;
|
||||
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
|
|
@ -483,7 +483,6 @@ struct AutomationLaneRender {
|
|||
value_max: f32,
|
||||
accent_color: egui::Color32,
|
||||
playback_time: f64,
|
||||
kind: AutomationLaneKind,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
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);
|
||||
|
||||
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,
|
||||
accent_color: lane_accent,
|
||||
playback_time,
|
||||
kind: lane.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -4054,7 +4052,6 @@ impl TimelinePane {
|
|||
value_max: lane.value_max,
|
||||
accent_color: lane_accent,
|
||||
playback_time,
|
||||
kind: lane.kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -5409,12 +5406,10 @@ impl PaneRenderer for TimelinePane {
|
|||
let editing_clip_id = shared.editing_clip_id;
|
||||
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)
|
||||
let master_any_layer;
|
||||
let _master_any_layer;
|
||||
if self.show_master_track && editing_clip_id.is_none() {
|
||||
master_any_layer = Some(AnyLayer::Group(document.master_layer.clone()));
|
||||
context_layers.insert(0, master_any_layer.as_ref().unwrap());
|
||||
} else {
|
||||
master_any_layer = None;
|
||||
_master_any_layer = Some(AnyLayer::Group(document.master_layer.clone()));
|
||||
context_layers.insert(0, _master_any_layer.as_ref().unwrap());
|
||||
}
|
||||
// Use virtual row count (includes expanded group children) for height calculations
|
||||
let layer_count = build_timeline_rows(&context_layers).len();
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
//! allocates and validates them in [`begin_raster_workspace`]; tools only
|
||||
//! dispatch shaders.
|
||||
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
use eframe::egui;
|
||||
|
||||
|
|
@ -25,11 +24,6 @@ pub enum WorkspaceSource {
|
|||
Layer {
|
||||
layer_id: Uuid,
|
||||
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.
|
||||
Float,
|
||||
|
|
@ -51,9 +45,6 @@ pub struct RasterWorkspace {
|
|||
pub b_canvas_id: Uuid,
|
||||
/// C canvas (Rgba16Float) — scratch; tools accumulate dabs here across the stroke.
|
||||
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.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
|
|
@ -69,40 +60,6 @@ pub struct 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).
|
||||
pub fn canvas_ids(&self) -> [Uuid; 3] {
|
||||
[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).
|
||||
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
|
||||
/// extract pending GPU work accumulated by `begin()` / `update()`.
|
||||
///
|
||||
|
|
@ -377,384 +329,7 @@ impl RasterTool for BrushRasterTool {
|
|||
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>> {
|
||||
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, 0–255).
|
||||
/// 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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
pub fn clear_pending_geometry(&self) {
|
||||
if let Ok(mut guard) = self.pending_geometry.try_lock() {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ impl ThemeMode {
|
|||
|
||||
/// Background type for CSS backgrounds
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub enum Background {
|
||||
Solid(egui::Color32),
|
||||
LinearGradient {
|
||||
|
|
@ -456,6 +457,7 @@ impl Theme {
|
|||
}
|
||||
|
||||
/// Invalidate the cache (call on stylesheet reload or mode change)
|
||||
#[allow(dead_code)]
|
||||
pub fn invalidate_cache(&self) {
|
||||
self.cache.borrow_mut().clear();
|
||||
}
|
||||
|
|
@ -518,6 +520,7 @@ impl Theme {
|
|||
}
|
||||
|
||||
/// 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 {
|
||||
let style = self.resolve(context, ctx);
|
||||
match property {
|
||||
|
|
@ -534,6 +537,7 @@ impl Theme {
|
|||
}
|
||||
|
||||
/// Paint background for a region (handles solid/gradient/image)
|
||||
#[allow(dead_code)]
|
||||
pub fn paint_bg(
|
||||
&self,
|
||||
context: &[&str],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use eframe::egui;
|
|||
use crate::theme::Background;
|
||||
|
||||
/// Paint a background into the given rect
|
||||
#[allow(dead_code)]
|
||||
pub fn paint_background(
|
||||
painter: &egui::Painter,
|
||||
rect: egui::Rect,
|
||||
|
|
@ -35,6 +36,7 @@ pub fn paint_background(
|
|||
/// - 90deg = left to right
|
||||
/// - 180deg = top to bottom (default)
|
||||
/// - 270deg = right to left
|
||||
#[allow(dead_code)]
|
||||
pub fn paint_linear_gradient(
|
||||
painter: &egui::Painter,
|
||||
rect: egui::Rect,
|
||||
|
|
|
|||
Loading…
Reference in New Issue