diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..fe9d654
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,373 @@
+name: Build & Package
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - release
+
+jobs:
+ build:
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: ubuntu-22.04
+ target: ''
+ artifact-name: linux-x86_64
+ - platform: macos-latest
+ target: aarch64-apple-darwin
+ artifact-name: macos-arm64
+ - platform: macos-latest
+ target: x86_64-apple-darwin
+ artifact-name: macos-x86_64
+ - platform: windows-latest
+ target: ''
+ artifact-name: windows-x86_64
+
+ runs-on: ${{ matrix.platform }}
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: true
+
+ - name: Clone egui fork
+ run: git clone --depth 1 -b ibus-wayland-fix https://git.skyler.io/skyler/egui.git ../egui-fork
+
+ - name: Install Rust stable
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
+
+ - name: Rust cache
+ uses: swatinem/rust-cache@v2
+ with:
+ workspaces: './lightningbeam-ui -> target'
+ key: ${{ matrix.target || 'default' }}
+
+ # ── Linux dependencies ──
+ - name: Install dependencies (Linux)
+ if: matrix.platform == 'ubuntu-22.04'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ build-essential pkg-config clang nasm cmake \
+ libasound2-dev libwayland-dev libwayland-cursor0 \
+ libx11-dev libxkbcommon-dev libxcb-shape0-dev libxcb-xfixes0-dev \
+ libxdo-dev libglib2.0-dev libgtk-3-dev libvulkan-dev \
+ yasm libx264-dev libx265-dev libvpx-dev libmp3lame-dev libopus-dev \
+ libpulse-dev squashfs-tools dpkg rpm
+
+ - name: Install cargo packaging tools (Linux)
+ if: matrix.platform == 'ubuntu-22.04'
+ uses: taiki-e/install-action@v2
+ with:
+ tool: cargo-deb,cargo-generate-rpm
+
+ # ── macOS dependencies ──
+ - name: Install dependencies (macOS)
+ if: matrix.platform == 'macos-latest'
+ run: brew install nasm cmake create-dmg
+
+ # ── Windows dependencies ──
+ - name: Install dependencies (Windows)
+ if: matrix.platform == 'windows-latest'
+ run: choco install nasm cmake --installargs 'ADD_CMAKE_TO_PATH=System' -y
+ shell: pwsh
+
+ # ── Common build steps ──
+ - name: Extract version
+ id: version
+ shell: bash
+ run: |
+ VERSION=$(grep '^version' lightningbeam-ui/lightningbeam-editor/Cargo.toml | sed 's/.*"\(.*\)"/\1/')
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+
+ - name: Enable FFmpeg build from source
+ shell: bash
+ run: |
+ sed -i.bak 's/ffmpeg-next = { version = "8.0", features = \["static"\] }/ffmpeg-next = { version = "8.0", features = ["build", "static"] }/' lightningbeam-ui/lightningbeam-editor/Cargo.toml
+
+ - name: Setup icons
+ shell: bash
+ run: |
+ mkdir -p lightningbeam-ui/lightningbeam-editor/assets/icons
+ cp -f src-tauri/icons/32x32.png lightningbeam-ui/lightningbeam-editor/assets/icons/
+ cp -f src-tauri/icons/128x128.png lightningbeam-ui/lightningbeam-editor/assets/icons/
+ cp -f src-tauri/icons/icon.png lightningbeam-ui/lightningbeam-editor/assets/icons/256x256.png
+
+ - name: Stage factory presets
+ shell: bash
+ run: |
+ mkdir -p lightningbeam-ui/lightningbeam-editor/assets/presets
+ cp -r src/assets/instruments/* lightningbeam-ui/lightningbeam-editor/assets/presets/
+ # Remove empty category dirs and README
+ find lightningbeam-ui/lightningbeam-editor/assets/presets -maxdepth 1 -type d -empty -delete
+ rm -f lightningbeam-ui/lightningbeam-editor/assets/presets/README.md
+
+ - name: Inject preset entries into RPM metadata (Linux)
+ if: matrix.platform == 'ubuntu-22.04'
+ shell: bash
+ run: |
+ cd lightningbeam-ui
+ find lightningbeam-editor/assets/presets -type f | sort | while read -r f; do
+ rel="${f#lightningbeam-editor/}"
+ dest="/usr/share/lightningbeam-editor/presets/${f#lightningbeam-editor/assets/presets/}"
+ printf '\n[[package.metadata.generate-rpm.assets]]\nsource = "%s"\ndest = "%s"\nmode = "644"\n' "$rel" "$dest" >> lightningbeam-editor/Cargo.toml
+ done
+
+ - name: Build release binary
+ shell: bash
+ env:
+ FFMPEG_STATIC: "1"
+ run: |
+ cd lightningbeam-ui
+ if [ -n "${{ matrix.target }}" ]; then
+ cargo build --release --bin lightningbeam-editor --target ${{ matrix.target }}
+ else
+ cargo build --release --bin lightningbeam-editor
+ fi
+
+ - name: Copy cross-compiled binary to release dir (macOS cross)
+ if: matrix.target != ''
+ shell: bash
+ run: |
+ mkdir -p lightningbeam-ui/target/release
+ cp lightningbeam-ui/target/${{ matrix.target }}/release/lightningbeam-editor lightningbeam-ui/target/release/
+
+ # ── Stage presets next to binary for packaging ──
+ - name: Stage presets in target dir
+ shell: bash
+ run: |
+ mkdir -p lightningbeam-ui/target/release/presets
+ cp -r lightningbeam-ui/lightningbeam-editor/assets/presets/* lightningbeam-ui/target/release/presets/
+
+ # ══════════════════════════════════════════════
+ # Linux Packaging
+ # ══════════════════════════════════════════════
+ - name: Build .deb package
+ if: matrix.platform == 'ubuntu-22.04'
+ shell: bash
+ run: |
+ cd lightningbeam-ui
+ cargo deb -p lightningbeam-editor --no-build --no-strip
+
+ # Inject factory presets into .deb (cargo-deb doesn't handle recursive dirs well)
+ DEB=$(ls target/debian/*.deb | head -1)
+ WORK=$(mktemp -d)
+ dpkg-deb -R "$DEB" "$WORK"
+ mkdir -p "$WORK/usr/share/lightningbeam-editor/presets"
+ cp -r lightningbeam-editor/assets/presets/* "$WORK/usr/share/lightningbeam-editor/presets/"
+ dpkg-deb -b "$WORK" "$DEB"
+ rm -rf "$WORK"
+
+ - name: Build .rpm package
+ if: matrix.platform == 'ubuntu-22.04'
+ shell: bash
+ run: |
+ cd lightningbeam-ui
+ cargo generate-rpm -p lightningbeam-editor
+
+ - name: Build AppImage
+ if: matrix.platform == 'ubuntu-22.04'
+ shell: bash
+ run: |
+ cd lightningbeam-ui
+ VERSION="${{ steps.version.outputs.version }}"
+ APPDIR=/tmp/AppDir
+ ASSETS=lightningbeam-editor/assets
+
+ rm -rf "$APPDIR"
+ mkdir -p "$APPDIR/usr/bin"
+ mkdir -p "$APPDIR/usr/bin/presets"
+ mkdir -p "$APPDIR/usr/share/applications"
+ mkdir -p "$APPDIR/usr/share/metainfo"
+ mkdir -p "$APPDIR/usr/share/icons/hicolor/32x32/apps"
+ mkdir -p "$APPDIR/usr/share/icons/hicolor/128x128/apps"
+ mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps"
+
+ cp target/release/lightningbeam-editor "$APPDIR/usr/bin/"
+ cp -r lightningbeam-editor/assets/presets/* "$APPDIR/usr/bin/presets/"
+
+ cp "$ASSETS/com.lightningbeam.editor.desktop" "$APPDIR/usr/share/applications/"
+ cp "$ASSETS/com.lightningbeam.editor.appdata.xml" "$APPDIR/usr/share/metainfo/"
+ cp "$ASSETS/icons/32x32.png" "$APPDIR/usr/share/icons/hicolor/32x32/apps/lightningbeam-editor.png"
+ cp "$ASSETS/icons/128x128.png" "$APPDIR/usr/share/icons/hicolor/128x128/apps/lightningbeam-editor.png"
+ cp "$ASSETS/icons/256x256.png" "$APPDIR/usr/share/icons/hicolor/256x256/apps/lightningbeam-editor.png"
+
+ ln -sf usr/share/icons/hicolor/256x256/apps/lightningbeam-editor.png "$APPDIR/lightningbeam-editor.png"
+ ln -sf usr/share/applications/com.lightningbeam.editor.desktop "$APPDIR/lightningbeam-editor.desktop"
+
+ printf '#!/bin/bash\nSELF=$(readlink -f "$0")\nHERE=${SELF%%/*}\nexport XDG_DATA_DIRS="${HERE}/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"\nexec "${HERE}/usr/bin/lightningbeam-editor" "$@"\n' > "$APPDIR/AppRun"
+ chmod +x "$APPDIR/AppRun"
+
+ # Download AppImage runtime
+ wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/runtime-x86_64" \
+ -O /tmp/appimage-runtime
+ chmod +x /tmp/appimage-runtime
+
+ # Build squashfs and concatenate
+ mksquashfs "$APPDIR" /tmp/appimage.squashfs \
+ -root-owned -noappend -no-exports -no-xattrs \
+ -comp gzip -b 131072
+ cat /tmp/appimage-runtime /tmp/appimage.squashfs \
+ > "Lightningbeam_Editor-${VERSION}-x86_64.AppImage"
+ chmod +x "Lightningbeam_Editor-${VERSION}-x86_64.AppImage"
+
+ - name: Collect Linux artifacts
+ if: matrix.platform == 'ubuntu-22.04'
+ shell: bash
+ run: |
+ mkdir -p artifacts
+ cp lightningbeam-ui/target/debian/*.deb artifacts/
+ cp lightningbeam-ui/target/generate-rpm/*.rpm artifacts/
+ cp lightningbeam-ui/Lightningbeam_Editor-*.AppImage artifacts/
+
+ # ══════════════════════════════════════════════
+ # macOS Packaging
+ # ══════════════════════════════════════════════
+ - name: Create macOS .app bundle
+ if: matrix.platform == 'macos-latest'
+ shell: bash
+ run: |
+ VERSION="${{ steps.version.outputs.version }}"
+ APP="Lightningbeam Editor.app"
+ mkdir -p "$APP/Contents/MacOS"
+ mkdir -p "$APP/Contents/Resources/presets"
+
+ cp lightningbeam-ui/target/release/lightningbeam-editor "$APP/Contents/MacOS/"
+ cp src-tauri/icons/icon.icns "$APP/Contents/Resources/lightningbeam-editor.icns"
+ cp -r lightningbeam-ui/lightningbeam-editor/assets/presets/* "$APP/Contents/Resources/presets/"
+
+ cat > "$APP/Contents/Info.plist" << EOF
+
+
+
+
+ CFBundleName
+ Lightningbeam Editor
+ CFBundleDisplayName
+ Lightningbeam Editor
+ CFBundleIdentifier
+ com.lightningbeam.editor
+ CFBundleVersion
+ ${VERSION}
+ CFBundleShortVersionString
+ ${VERSION}
+ CFBundlePackageType
+ APPL
+ CFBundleExecutable
+ lightningbeam-editor
+ CFBundleIconFile
+ lightningbeam-editor
+ LSMinimumSystemVersion
+ 11.0
+ NSHighResolutionCapable
+
+
+
+ EOF
+
+ - name: Create macOS .dmg
+ if: matrix.platform == 'macos-latest'
+ shell: bash
+ run: |
+ VERSION="${{ steps.version.outputs.version }}"
+ ARCH="${{ matrix.target == 'aarch64-apple-darwin' && 'arm64' || 'x86_64' }}"
+ DMG_NAME="Lightningbeam_Editor-${VERSION}-macOS-${ARCH}.dmg"
+
+ create-dmg \
+ --volname "Lightningbeam Editor" \
+ --window-pos 200 120 \
+ --window-size 600 400 \
+ --icon-size 100 \
+ --icon "Lightningbeam Editor.app" 175 190 \
+ --app-drop-link 425 190 \
+ "$DMG_NAME" \
+ "Lightningbeam Editor.app" || true
+ # create-dmg returns non-zero if codesigning is skipped, but the .dmg is still valid
+
+ - name: Collect macOS artifacts
+ if: matrix.platform == 'macos-latest'
+ shell: bash
+ run: |
+ mkdir -p artifacts
+ cp Lightningbeam_Editor-*.dmg artifacts/
+
+ # ══════════════════════════════════════════════
+ # Windows Packaging
+ # ══════════════════════════════════════════════
+ - name: Create Windows .zip
+ if: matrix.platform == 'windows-latest'
+ shell: pwsh
+ run: |
+ $VERSION = "${{ steps.version.outputs.version }}"
+ $DIST = "Lightningbeam_Editor-${VERSION}-Windows-x86_64"
+ New-Item -ItemType Directory -Force -Path $DIST
+ Copy-Item "lightningbeam-ui/target/release/lightningbeam-editor.exe" "$DIST/"
+ Copy-Item -Recurse "lightningbeam-ui/target/release/presets" "$DIST/presets"
+ Compress-Archive -Path $DIST -DestinationPath "${DIST}.zip"
+
+ - name: Collect Windows artifacts
+ if: matrix.platform == 'windows-latest'
+ shell: pwsh
+ run: |
+ New-Item -ItemType Directory -Force -Path artifacts
+ Copy-Item "Lightningbeam_Editor-*.zip" "artifacts/"
+
+ # ── Upload ──
+ - name: Upload artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.artifact-name }}
+ path: artifacts/*
+ if-no-files-found: error
+
+ release:
+ needs: build
+ runs-on: ubuntu-22.04
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ sparse-checkout: |
+ lightningbeam-ui/lightningbeam-editor/Cargo.toml
+ Changelog.md
+
+ - name: Extract version
+ id: version
+ run: |
+ VERSION=$(grep '^version' lightningbeam-ui/lightningbeam-editor/Cargo.toml | sed 's/.*"\(.*\)"/\1/')
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+
+ - name: Extract release notes
+ id: notes
+ uses: sean0x42/markdown-extract@v2.1.0
+ with:
+ pattern: "${{ steps.version.outputs.version }}:"
+ file: Changelog.md
+
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: dist
+ merge-multiple: true
+
+ - name: List artifacts
+ run: ls -lhR dist/
+
+ - name: Create draft release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: "v${{ steps.version.outputs.version }}"
+ name: "Lightningbeam v${{ steps.version.outputs.version }}"
+ body: ${{ steps.notes.outputs.markdown }}
+ draft: true
+ prerelease: true
+ files: dist/*
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
deleted file mode 100644
index 657ee38..0000000
--- a/.github/workflows/main.yml
+++ /dev/null
@@ -1,150 +0,0 @@
-name: 'publish'
-
-on:
- workflow_dispatch:
- push:
- branches:
- - release
-
-jobs:
- extract-changelog:
- runs-on: ubuntu-22.04
- steps:
- - uses: actions/checkout@v4
-
- - name: Set version for changelog extraction
- shell: bash
- run: |
- # Read the version from src-tauri/tauri.conf.json
- VERSION=$(jq -r '.version' src-tauri/tauri.conf.json)
- # Set the version in the environment variable
- echo "VERSION=$VERSION" >> $GITHUB_ENV
-
- - name: Extract release notes from Changelog.md
- id: changelog
- uses: sean0x42/markdown-extract@v2.1.0
- with:
- pattern: "${{ env.VERSION }}:" # Look for the version header (e.g., # 0.6.15-alpha:)
- file: Changelog.md
-
- - name: Set markdown output
- id: set-markdown-output
- run: |
- echo 'RELEASE_NOTES<> $GITHUB_OUTPUT
- echo "${{ steps.changelog.outputs.markdown }}" >> $GITHUB_OUTPUT
- echo 'EOF' >> $GITHUB_OUTPUT
-
- publish-tauri:
- needs: extract-changelog
- permissions:
- contents: write
- strategy:
- fail-fast: false
- matrix:
- include:
- - platform: 'macos-latest' # for Arm based macs (M1 and above).
- args: '--target aarch64-apple-darwin'
- - platform: 'macos-latest' # for Intel based macs.
- args: '--target x86_64-apple-darwin'
- - platform: 'ubuntu-22.04'
- args: ''
- - platform: 'windows-latest'
- args: ''
-
- runs-on: ${{ matrix.platform }}
- steps:
- - uses: actions/checkout@v4
-
- - name: Debug the extracted release notes
- run: |
- echo "Extracted Release Notes: ${{ needs.extract-changelog.outputs.RELEASE_NOTES }}"
-
- - name: install dependencies (ubuntu only)
- if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above.
- run: |
- sudo apt-get update
- sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
-
- - name: Install jq on Windows
- if: matrix.platform == 'windows-latest'
- run: |
- choco install jq
-
- - name: Set version for all platforms
- shell: bash
- run: |
- # Read the version from src-tauri/tauri.conf.json
- VERSION=$(jq -r '.version' src-tauri/tauri.conf.json)
- # Set the version in the environment variable
- echo "VERSION=$VERSION" >> $GITHUB_ENV
- if: matrix.platform != 'windows-latest'
-
- - name: Set version for Windows build
- if: matrix.platform == 'windows-latest' # Only run on Windows
- shell: pwsh # Use PowerShell on Windows runners
- run: |
- # Read the version from src-tauri/tauri.conf.json
- $tauriConf = Get-Content src-tauri/tauri.conf.json | ConvertFrom-Json
- $VERSION = $tauriConf.version
-
- # Replace '-alpha' with '-0' and '-beta' with '-1' for Windows version
- if ($VERSION -match "-alpha") {
- $WINDOWS_VERSION = $VERSION -replace "-alpha", "-1"
- } elseif ($VERSION -match "-beta") {
- $WINDOWS_VERSION = $VERSION -replace "-beta", "-2"
- } else {
- $WINDOWS_VERSION = $VERSION
- }
- Copy-Item src-tauri/tauri.conf.json -Destination src-tauri/tauri.windows.conf.json
-
- # Modify the version in tauri.windows.conf.json
- (Get-Content src-tauri/tauri.windows.conf.json) | ForEach-Object {
- $_ -replace '"version": ".*"', ('"version": "' + $WINDOWS_VERSION + '"')
- } | Set-Content src-tauri/tauri.windows.conf.json
-
- echo "VERSION=$VERSION" >> $env:GITHUB_ENV
-
- - name: Print contents of tauri.windows.conf.json (Windows)
- if: matrix.platform == 'windows-latest' # Only run on Windows
- shell: pwsh
- run: |
- Write-Host "Contents of src-tauri/tauri.windows.conf.json:"
- Get-Content src-tauri/tauri.windows.conf.json
-
- - name: setup pnpm
- uses: pnpm/action-setup@v2
- with:
- version: 9.1.2
- - name: setup node
- uses: actions/setup-node@v4
- with:
- node-version: lts/*
- cache: 'pnpm' # Set this to npm, yarn or pnpm.
-
- - name: install Rust stable
- uses: dtolnay/rust-toolchain@stable # Set this to dtolnay/rust-toolchain@nightly
- with:
- # Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
- targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
-
- - name: Rust cache
- uses: swatinem/rust-cache@v2
- with:
- workspaces: './src-tauri -> target'
-
- - name: install frontend dependencies
- # If you don't have `beforeBuildCommand` configured you may want to build your frontend here too.
- run: pnpm install # change this to npm or pnpm depending on which one you use.
-
- - name: Create Release with Tauri Action
- uses: tauri-apps/tauri-action@v0
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- RELEASE_NOTES: ${{ needs.extract-changelog.outputs.RELEASE_NOTES }}
- with:
- tagName: "app-v${{ env.VERSION }}" # Use the original version tag for the release
- releaseName: "Lightningbeam v${{ env.VERSION }}"
- releaseBody: "${{ needs.extract-changelog.outputs.RELEASE_NOTES }}"
- releaseDraft: true # Set to true if you want the release to be a draft
- prerelease: true
- args: ${{ matrix.args }}
diff --git a/Changelog.md b/Changelog.md
index 1415c48..b335e30 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -1,3 +1,11 @@
+# 1.0.0-alpha:
+Changes:
+- New native GUI built with egui + wgpu (replaces Tauri/web frontend)
+- GPU-accelerated canvas with vello rendering
+- MIDI input and node-based audio graph improvements
+- Factory instrument presets
+- Video import and high performance playback
+
# 0.8.1-alpha:
Changes:
- Rewrite timeline UI
diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs
index 12e8dcc..b73adcb 100644
--- a/daw-backend/src/audio/engine.rs
+++ b/daw-backend/src/audio/engine.rs
@@ -1136,6 +1136,11 @@ impl Engine {
self.metronome.set_enabled(enabled);
}
+ Command::SetTempo(bpm, time_sig) => {
+ self.metronome.update_timing(bpm, time_sig);
+ self.project.set_tempo(bpm, time_sig.0);
+ }
+
// Node graph commands
Command::GraphAddNode(track_id, node_type, x, y) => {
eprintln!("[DEBUG] GraphAddNode received: track_id={}, node_type={}, x={}, y={}", track_id, node_type, x, y);
@@ -3197,6 +3202,11 @@ impl EngineController {
let _ = self.command_tx.push(Command::SetMetronomeEnabled(enabled));
}
+ /// Set project tempo (BPM) and time signature
+ pub fn set_tempo(&mut self, bpm: f32, time_signature: (u32, u32)) {
+ let _ = self.command_tx.push(Command::SetTempo(bpm, time_signature));
+ }
+
// Node graph operations
/// Add a node to a track's instrument graph
diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs
index 0a77303..51df5d8 100644
--- a/daw-backend/src/audio/node_graph/graph.rs
+++ b/daw-backend/src/audio/node_graph/graph.rs
@@ -96,6 +96,11 @@ pub struct AudioGraph {
/// Current playback time (for automation nodes)
playback_time: f64,
+ /// Project tempo (synced from Engine via SetTempo)
+ bpm: f32,
+ /// Beats per bar (time signature numerator)
+ beats_per_bar: u32,
+
/// Cached topological sort order (invalidated on graph mutation)
topo_cache: Option>,
@@ -119,11 +124,19 @@ impl AudioGraph {
midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(),
node_positions: std::collections::HashMap::new(),
playback_time: 0.0,
+ bpm: 120.0,
+ beats_per_bar: 4,
topo_cache: None,
frontend_groups: Vec::new(),
}
}
+ /// Set the project tempo and time signature for BeatNodes
+ pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) {
+ self.bpm = bpm;
+ self.beats_per_bar = beats_per_bar;
+ }
+
/// Add a node to the graph
pub fn add_node(&mut self, node: Box) -> NodeIndex {
let graph_node = GraphNode::new(node, self.buffer_size);
@@ -452,6 +465,7 @@ impl AudioGraph {
auto_node.set_playback_time(playback_time);
} else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::() {
beat_node.set_playback_time(playback_time);
+ beat_node.set_tempo(self.bpm, self.beats_per_bar);
}
}
diff --git a/daw-backend/src/audio/node_graph/nodes/beat.rs b/daw-backend/src/audio/node_graph/nodes/beat.rs
index 0bd368b..6b0113d 100644
--- a/daw-backend/src/audio/node_graph/nodes/beat.rs
+++ b/daw-backend/src/audio/node_graph/nodes/beat.rs
@@ -3,8 +3,8 @@ use crate::audio::midi::MidiEvent;
const PARAM_RESOLUTION: u32 = 0;
-/// Hardcoded BPM until project tempo is implemented
const DEFAULT_BPM: f32 = 120.0;
+const DEFAULT_BEATS_PER_BAR: u32 = 4;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum BeatResolution {
@@ -47,17 +47,19 @@ impl BeatResolution {
/// Beat clock node — generates tempo-synced CV signals.
///
+/// BPM and time signature are synced from the project document via SetTempo.
/// When playing: synced to timeline position.
-/// When stopped: free-runs continuously at the set BPM.
+/// When stopped: free-runs continuously at the project BPM.
///
/// Outputs:
/// - BPM: constant CV proportional to tempo (bpm / 240)
/// - Beat Phase: sawtooth 0→1 per beat subdivision
-/// - Bar Phase: sawtooth 0→1 per bar (4 beats)
+/// - Bar Phase: sawtooth 0→1 per bar (uses project time signature)
/// - Gate: 1.0 for first half of each subdivision, 0.0 otherwise
pub struct BeatNode {
name: String,
bpm: f32,
+ beats_per_bar: u32,
resolution: BeatResolution,
/// Playback time in seconds, set by the graph before process()
playback_time: f64,
@@ -88,6 +90,7 @@ impl BeatNode {
Self {
name: name.into(),
bpm: DEFAULT_BPM,
+ beats_per_bar: DEFAULT_BEATS_PER_BAR,
resolution: BeatResolution::Quarter,
playback_time: 0.0,
prev_playback_time: -1.0,
@@ -101,6 +104,11 @@ impl BeatNode {
pub fn set_playback_time(&mut self, time: f64) {
self.playback_time = time;
}
+
+ pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) {
+ self.bpm = bpm;
+ self.beats_per_bar = beats_per_bar;
+ }
}
impl AudioNode for BeatNode {
@@ -167,8 +175,8 @@ impl AudioNode for BeatNode {
// Beat subdivision phase: 0→1 sawtooth
let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32;
- // Bar phase: 0→1 over 4 quarter-note beats
- let bar_phase = ((beat_pos / 4.0) % 1.0) as f32;
+ // Bar phase: 0→1 over one bar (beats_per_bar beats)
+ let bar_phase = ((beat_pos / self.beats_per_bar as f64) % 1.0) as f32;
// Gate: high for first half of each subdivision
let gate = if sub_phase < 0.5 { 1.0f32 } else { 0.0 };
@@ -201,6 +209,7 @@ impl AudioNode for BeatNode {
Box::new(Self {
name: self.name.clone(),
bpm: self.bpm,
+ beats_per_bar: self.beats_per_bar,
resolution: self.resolution,
playback_time: 0.0,
prev_playback_time: -1.0,
diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs
index ce497e1..f838c64 100644
--- a/daw-backend/src/audio/project.rs
+++ b/daw-backend/src/audio/project.rs
@@ -569,6 +569,17 @@ impl Project {
}
}
+ /// Propagate tempo to all audio graphs (for BeatNode sync)
+ pub fn set_tempo(&mut self, bpm: f32, beats_per_bar: u32) {
+ for track in self.tracks.values_mut() {
+ match track {
+ TrackNode::Audio(t) => t.effects_graph.set_tempo(bpm, beats_per_bar),
+ TrackNode::Midi(t) => t.instrument_graph.set_tempo(bpm, beats_per_bar),
+ TrackNode::Group(g) => g.audio_graph.set_tempo(bpm, beats_per_bar),
+ }
+ }
+ }
+
/// Process live MIDI input from all MIDI tracks (called even when not playing)
pub fn process_live_midi(&mut self, output: &mut [f32], sample_rate: u32, channels: u32) {
// Process all MIDI tracks to handle queued live input events
diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs
index 698bd65..855f0bb 100644
--- a/daw-backend/src/command/types.rs
+++ b/daw-backend/src/command/types.rs
@@ -138,6 +138,8 @@ pub enum Command {
// Metronome command
/// Enable or disable the metronome click track
SetMetronomeEnabled(bool),
+ /// Set project tempo and time signature (bpm, (numerator, denominator))
+ SetTempo(f32, (u32, u32)),
// Node graph commands
/// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y)
diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock
index 677a974..5d13075 100644
--- a/lightningbeam-ui/Cargo.lock
+++ b/lightningbeam-ui/Cargo.lock
@@ -3464,8 +3464,10 @@ dependencies = [
"kurbo 0.12.0",
"lru",
"pathdiff",
+ "rstar",
"serde",
"serde_json",
+ "tiny-skia",
"uuid",
"vello",
"wgpu",
@@ -5365,6 +5367,17 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
+[[package]]
+name = "rstar"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb"
+dependencies = [
+ "heapless",
+ "num-traits",
+ "smallvec",
+]
+
[[package]]
name = "rtrb"
version = "0.3.2"
diff --git a/lightningbeam-ui/lightningbeam-core/Cargo.toml b/lightningbeam-ui/lightningbeam-core/Cargo.toml
index c75f76f..015614f 100644
--- a/lightningbeam-ui/lightningbeam-core/Cargo.toml
+++ b/lightningbeam-ui/lightningbeam-core/Cargo.toml
@@ -41,5 +41,11 @@ pathdiff = "0.2"
flacenc = "0.4" # For FLAC encoding (lossless)
claxon = "0.4" # For FLAC decoding
+# Spatial indexing for DCEL vertex snapping
+rstar = "0.12"
+
# System clipboard
arboard = "3"
+
+[dev-dependencies]
+tiny-skia = "0.11"
diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs b/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs
index b5e7dff..cc4a48a 100644
--- a/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs
+++ b/lightningbeam-ui/lightningbeam-core/src/actions/add_shape.rs
@@ -1,111 +1,124 @@
-//! Add shape action
+//! Add shape action — inserts strokes into the DCEL.
//!
-//! Handles adding a new shape to a vector layer's keyframe.
+//! Converts a BezPath into cubic segments and inserts them via
+//! `Dcel::insert_stroke()`. Undo is handled by snapshotting the DCEL.
use crate::action::Action;
+use crate::dcel::{bezpath_to_cubic_segments, Dcel, DEFAULT_SNAP_EPSILON};
use crate::document::Document;
use crate::layer::AnyLayer;
-use crate::shape::Shape;
+use crate::shape::{ShapeColor, StrokeStyle};
+use kurbo::BezPath;
use uuid::Uuid;
-/// Action that adds a shape to a vector layer's keyframe
+/// Action that inserts a drawn path into a vector layer's DCEL keyframe.
pub struct AddShapeAction {
- /// Layer ID to add the shape to
layer_id: Uuid,
-
- /// The shape to add (contains geometry, styling, transform, opacity)
- shape: Shape,
-
- /// Time of the keyframe to add to
time: f64,
-
- /// ID of the created shape (set after execution)
- created_shape_id: Option,
+ path: BezPath,
+ stroke_style: Option,
+ stroke_color: Option,
+ fill_color: Option,
+ is_closed: bool,
+ description_text: String,
+ /// Snapshot of the DCEL before insertion (for undo).
+ dcel_before: Option,
}
impl AddShapeAction {
- pub fn new(layer_id: Uuid, shape: Shape, time: f64) -> Self {
+ pub fn new(
+ layer_id: Uuid,
+ time: f64,
+ path: BezPath,
+ stroke_style: Option,
+ stroke_color: Option,
+ fill_color: Option,
+ is_closed: bool,
+ ) -> Self {
Self {
layer_id,
- shape,
time,
- created_shape_id: None,
+ path,
+ stroke_style,
+ stroke_color,
+ fill_color,
+ is_closed,
+ description_text: "Add shape".to_string(),
+ dcel_before: None,
}
}
+
+ pub fn with_description(mut self, desc: impl Into) -> Self {
+ self.description_text = desc.into();
+ self
+ }
}
impl Action for AddShapeAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
- let layer = match document.get_layer_mut(&self.layer_id) {
- Some(l) => l,
- None => return Ok(()),
+ let layer = document
+ .get_layer_mut(&self.layer_id)
+ .ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
+
+ let vl = match layer {
+ AnyLayer::Vector(vl) => vl,
+ _ => return Err("Not a vector layer".to_string()),
};
- if let AnyLayer::Vector(vector_layer) = layer {
- let shape_id = self.shape.id;
- vector_layer.add_shape_to_keyframe(self.shape.clone(), self.time);
- self.created_shape_id = Some(shape_id);
+ let keyframe = vl.ensure_keyframe_at(self.time);
+ let dcel = &mut keyframe.dcel;
+
+ // Snapshot for undo
+ self.dcel_before = Some(dcel.clone());
+
+ let subpaths = bezpath_to_cubic_segments(&self.path);
+
+ for segments in &subpaths {
+ if segments.is_empty() {
+ continue;
+ }
+ let result = dcel.insert_stroke(
+ segments,
+ self.stroke_style.clone(),
+ self.stroke_color.clone(),
+ DEFAULT_SNAP_EPSILON,
+ );
+
+ // Apply fill to new faces if this is a closed shape with fill
+ if self.is_closed {
+ if let Some(ref fill) = self.fill_color {
+ for face_id in &result.new_faces {
+ dcel.face_mut(*face_id).fill_color = Some(fill.clone());
+ }
+ }
+ }
}
+
+ dcel.rebuild_spatial_index();
+
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
- if let Some(shape_id) = self.created_shape_id {
- let layer = match document.get_layer_mut(&self.layer_id) {
- Some(l) => l,
- None => return Ok(()),
- };
+ let layer = document
+ .get_layer_mut(&self.layer_id)
+ .ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
- if let AnyLayer::Vector(vector_layer) = layer {
- vector_layer.remove_shape_from_keyframe(&shape_id, self.time);
- }
+ let vl = match layer {
+ AnyLayer::Vector(vl) => vl,
+ _ => return Err("Not a vector layer".to_string()),
+ };
+
+ let keyframe = vl.ensure_keyframe_at(self.time);
+ keyframe.dcel = self
+ .dcel_before
+ .take()
+ .ok_or_else(|| "No DCEL snapshot for undo".to_string())?;
- self.created_shape_id = None;
- }
Ok(())
}
fn description(&self) -> String {
- "Add shape".to_string()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::layer::VectorLayer;
- use crate::shape::ShapeColor;
- use vello::kurbo::{Rect, Shape as KurboShape};
-
- #[test]
- fn test_add_shape_action_rectangle() {
- let mut document = Document::new("Test");
- let vector_layer = VectorLayer::new("Layer 1");
- let layer_id = document.root.add_child(AnyLayer::Vector(vector_layer));
-
- let rect = Rect::new(0.0, 0.0, 100.0, 50.0);
- let path = rect.to_path(0.1);
- let shape = Shape::new(path)
- .with_fill(ShapeColor::rgb(255, 0, 0))
- .with_position(50.0, 50.0);
-
- let mut action = AddShapeAction::new(layer_id, shape, 0.0);
- action.execute(&mut document).unwrap();
-
- if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
- let shapes = layer.shapes_at_time(0.0);
- assert_eq!(shapes.len(), 1);
- assert_eq!(shapes[0].transform.x, 50.0);
- assert_eq!(shapes[0].transform.y, 50.0);
- } else {
- panic!("Layer not found or not a vector layer");
- }
-
- // Rollback
- action.rollback(&mut document).unwrap();
-
- if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
- assert_eq!(layer.shapes_at_time(0.0).len(), 0);
- }
+ self.description_text.clone()
}
}
diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs b/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs
index b47ac7e..c9ea444 100644
--- a/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs
+++ b/lightningbeam-ui/lightningbeam-core/src/actions/convert_to_movie_clip.rs
@@ -1,18 +1,13 @@
-//! Convert to Movie Clip action
-//!
-//! Wraps selected shapes and/or clip instances into a new VectorClip
-//! with is_group = false, giving it a real internal timeline.
-//! Works with 1+ selected items (unlike Group which requires 2+).
+//! Convert to Movie Clip action — STUB: needs DCEL rewrite
use crate::action::Action;
-use crate::animation::{AnimationCurve, AnimationTarget, Keyframe, TransformProperty};
-use crate::clip::{ClipInstance, VectorClip};
+use crate::clip::ClipInstance;
use crate::document::Document;
-use crate::layer::{AnyLayer, VectorLayer};
-use crate::shape::Shape;
use uuid::Uuid;
-use vello::kurbo::{Rect, Shape as KurboShape};
+/// Action that converts selected items to a Movie Clip
+/// TODO: Rewrite for DCEL
+#[allow(dead_code)]
pub struct ConvertToMovieClipAction {
layer_id: Uuid,
time: f64,
@@ -20,7 +15,6 @@ pub struct ConvertToMovieClipAction {
clip_instance_ids: Vec,
instance_id: Uuid,
created_clip_id: Option,
- removed_shapes: Vec,
removed_clip_instances: Vec,
}
@@ -39,201 +33,18 @@ impl ConvertToMovieClipAction {
clip_instance_ids,
instance_id,
created_clip_id: None,
- removed_shapes: Vec::new(),
removed_clip_instances: Vec::new(),
}
}
}
impl Action for ConvertToMovieClipAction {
- fn execute(&mut self, document: &mut Document) -> Result<(), String> {
- let layer = document
- .get_layer(&self.layer_id)
- .ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
-
- let vl = match layer {
- AnyLayer::Vector(vl) => vl,
- _ => return Err("Convert to Movie Clip is only supported on vector layers".to_string()),
- };
-
- // Collect shapes
- let shapes_at_time = vl.shapes_at_time(self.time);
- let mut collected_shapes: Vec = Vec::new();
- for id in &self.shape_ids {
- if let Some(shape) = shapes_at_time.iter().find(|s| &s.id == id) {
- collected_shapes.push(shape.clone());
- }
- }
-
- // Collect clip instances
- let mut collected_clip_instances: Vec = Vec::new();
- for id in &self.clip_instance_ids {
- if let Some(ci) = vl.clip_instances.iter().find(|ci| &ci.id == id) {
- collected_clip_instances.push(ci.clone());
- }
- }
-
- let total_items = collected_shapes.len() + collected_clip_instances.len();
- if total_items < 1 {
- return Err("Need at least 1 item to convert to movie clip".to_string());
- }
-
- // Compute combined bounding box
- let mut combined_bbox: Option = None;
-
- for shape in &collected_shapes {
- let local_bbox = shape.path().bounding_box();
- let transform = shape.transform.to_affine();
- let transformed_bbox = transform.transform_rect_bbox(local_bbox);
- combined_bbox = Some(match combined_bbox {
- Some(existing) => existing.union(transformed_bbox),
- None => transformed_bbox,
- });
- }
-
- for ci in &collected_clip_instances {
- let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&ci.clip_id) {
- let clip_time = ((self.time - ci.timeline_start) * ci.playback_speed) + ci.trim_start;
- vector_clip.calculate_content_bounds(document, clip_time)
- } else if let Some(video_clip) = document.get_video_clip(&ci.clip_id) {
- Rect::new(0.0, 0.0, video_clip.width, video_clip.height)
- } else {
- continue;
- };
- let ci_transform = ci.transform.to_affine();
- let transformed_bbox = ci_transform.transform_rect_bbox(content_bounds);
- combined_bbox = Some(match combined_bbox {
- Some(existing) => existing.union(transformed_bbox),
- None => transformed_bbox,
- });
- }
-
- let bbox = combined_bbox.ok_or("Could not compute bounding box")?;
- let center_x = (bbox.x0 + bbox.x1) / 2.0;
- let center_y = (bbox.y0 + bbox.y1) / 2.0;
-
- // Offset shapes relative to center
- let mut clip_shapes: Vec = collected_shapes.clone();
- for shape in &mut clip_shapes {
- shape.transform.x -= center_x;
- shape.transform.y -= center_y;
- }
-
- let mut clip_instances_inside: Vec = collected_clip_instances.clone();
- for ci in &mut clip_instances_inside {
- ci.transform.x -= center_x;
- ci.transform.y -= center_y;
- }
-
- // Create VectorClip with real timeline duration
- let mut clip = VectorClip::new("Movie Clip", bbox.width(), bbox.height(), document.duration);
- // is_group defaults to false — movie clips have real timelines
- let clip_id = clip.id;
-
- let mut inner_layer = VectorLayer::new("Layer 1");
- for shape in clip_shapes {
- inner_layer.add_shape_to_keyframe(shape, 0.0);
- }
- for ci in clip_instances_inside {
- inner_layer.clip_instances.push(ci);
- }
- clip.layers.add_root(AnyLayer::Vector(inner_layer));
-
- document.add_vector_clip(clip);
- self.created_clip_id = Some(clip_id);
-
- // Remove originals from the layer
- let layer = document.get_layer_mut(&self.layer_id).unwrap();
- let vl = match layer {
- AnyLayer::Vector(vl) => vl,
- _ => unreachable!(),
- };
-
- self.removed_shapes.clear();
- for id in &self.shape_ids {
- if let Some(shape) = vl.remove_shape_from_keyframe(id, self.time) {
- self.removed_shapes.push(shape);
- }
- }
-
- self.removed_clip_instances.clear();
- for id in &self.clip_instance_ids {
- if let Some(pos) = vl.clip_instances.iter().position(|ci| &ci.id == id) {
- self.removed_clip_instances.push(vl.clip_instances.remove(pos));
- }
- }
-
- // Place the new ClipInstance
- let instance = ClipInstance::with_id(self.instance_id, clip_id)
- .with_position(center_x, center_y)
- .with_name("Movie Clip");
- vl.clip_instances.push(instance);
-
- // Create default animation curves
- let props_and_values = [
- (TransformProperty::X, center_x),
- (TransformProperty::Y, center_y),
- (TransformProperty::Rotation, 0.0),
- (TransformProperty::ScaleX, 1.0),
- (TransformProperty::ScaleY, 1.0),
- (TransformProperty::SkewX, 0.0),
- (TransformProperty::SkewY, 0.0),
- (TransformProperty::Opacity, 1.0),
- ];
-
- for (prop, value) in props_and_values {
- let target = AnimationTarget::Object {
- id: self.instance_id,
- property: prop,
- };
- let mut curve = AnimationCurve::new(target.clone(), value);
- curve.set_keyframe(Keyframe::linear(0.0, value));
- vl.layer.animation_data.set_curve(curve);
- }
-
+ 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);
Ok(())
}
- fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
- let layer = document
- .get_layer_mut(&self.layer_id)
- .ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
-
- if let AnyLayer::Vector(vl) = layer {
- // Remove animation curves
- for prop in &[
- TransformProperty::X, TransformProperty::Y,
- TransformProperty::Rotation,
- TransformProperty::ScaleX, TransformProperty::ScaleY,
- TransformProperty::SkewX, TransformProperty::SkewY,
- TransformProperty::Opacity,
- ] {
- let target = AnimationTarget::Object {
- id: self.instance_id,
- property: *prop,
- };
- vl.layer.animation_data.remove_curve(&target);
- }
-
- // Remove the clip instance
- vl.clip_instances.retain(|ci| ci.id != self.instance_id);
-
- // Re-insert removed shapes
- for shape in self.removed_shapes.drain(..) {
- vl.add_shape_to_keyframe(shape, self.time);
- }
-
- // Re-insert removed clip instances
- for ci in self.removed_clip_instances.drain(..) {
- vl.clip_instances.push(ci);
- }
- }
-
- // Remove the VectorClip from the document
- if let Some(clip_id) = self.created_clip_id.take() {
- document.remove_vector_clip(&clip_id);
- }
-
+ fn rollback(&mut self, _document: &mut Document) -> Result<(), String> {
Ok(())
}
diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/group_shapes.rs b/lightningbeam-ui/lightningbeam-core/src/actions/group_shapes.rs
index 8c5806f..ab32889 100644
--- a/lightningbeam-ui/lightningbeam-core/src/actions/group_shapes.rs
+++ b/lightningbeam-ui/lightningbeam-core/src/actions/group_shapes.rs
@@ -1,42 +1,20 @@
-//! Group action
-//!
-//! Groups selected shapes and/or clip instances into a new VectorClip
-//! with a ClipInstance placed on the layer. Supports grouping shapes,
-//! existing clip instances (groups), or a mix of both.
+//! Group action — STUB: needs DCEL rewrite
use crate::action::Action;
-use crate::animation::{AnimationCurve, AnimationTarget, Keyframe, TransformProperty};
-use crate::clip::{ClipInstance, VectorClip};
+use crate::clip::ClipInstance;
use crate::document::Document;
-use crate::layer::{AnyLayer, VectorLayer};
-use crate::shape::Shape;
use uuid::Uuid;
-use vello::kurbo::{Rect, Shape as KurboShape};
/// 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)]
pub struct GroupAction {
- /// Layer containing the items to group
layer_id: Uuid,
-
- /// Time of the keyframe to operate on (for shape lookup)
time: f64,
-
- /// Shape IDs to include in the group
shape_ids: Vec,
-
- /// Clip instance IDs to include in the group
clip_instance_ids: Vec,
-
- /// Pre-generated clip instance ID for the new group (so caller can update selection)
instance_id: Uuid,
-
- /// Created clip ID (for rollback)
created_clip_id: Option,
-
- /// Shapes removed from the keyframe (for rollback)
- removed_shapes: Vec,
-
- /// Clip instances removed from the layer (for rollback, preserving original order)
removed_clip_instances: Vec,
}
@@ -55,227 +33,19 @@ impl GroupAction {
clip_instance_ids,
instance_id,
created_clip_id: None,
- removed_shapes: Vec::new(),
removed_clip_instances: Vec::new(),
}
}
}
impl Action for GroupAction {
- fn execute(&mut self, document: &mut Document) -> Result<(), String> {
- // --- Phase 1: Collect items and compute bounding box ---
-
- let layer = document
- .get_layer(&self.layer_id)
- .ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
-
- let vl = match layer {
- AnyLayer::Vector(vl) => vl,
- _ => return Err("Group is only supported on vector layers".to_string()),
- };
-
- // Collect shapes
- let shapes_at_time = vl.shapes_at_time(self.time);
- let mut group_shapes: Vec = Vec::new();
- for id in &self.shape_ids {
- if let Some(shape) = shapes_at_time.iter().find(|s| &s.id == id) {
- group_shapes.push(shape.clone());
- }
- }
-
- // Collect clip instances
- let mut group_clip_instances: Vec = Vec::new();
- for id in &self.clip_instance_ids {
- if let Some(ci) = vl.clip_instances.iter().find(|ci| &ci.id == id) {
- group_clip_instances.push(ci.clone());
- }
- }
-
- let total_items = group_shapes.len() + group_clip_instances.len();
- if total_items < 2 {
- return Err("Need at least 2 items to group".to_string());
- }
-
- // Compute combined bounding box in parent (layer) space
- let mut combined_bbox: Option = None;
-
- // Shape bounding boxes
- for shape in &group_shapes {
- let local_bbox = shape.path().bounding_box();
- let transform = shape.transform.to_affine();
- let transformed_bbox = transform.transform_rect_bbox(local_bbox);
- combined_bbox = Some(match combined_bbox {
- Some(existing) => existing.union(transformed_bbox),
- None => transformed_bbox,
- });
- }
-
- // Clip instance bounding boxes
- for ci in &group_clip_instances {
- let content_bounds = if let Some(vector_clip) = document.get_vector_clip(&ci.clip_id) {
- let clip_time = ((self.time - ci.timeline_start) * ci.playback_speed) + ci.trim_start;
- vector_clip.calculate_content_bounds(document, clip_time)
- } else if let Some(video_clip) = document.get_video_clip(&ci.clip_id) {
- Rect::new(0.0, 0.0, video_clip.width, video_clip.height)
- } else {
- continue;
- };
- let ci_transform = ci.transform.to_affine();
- let transformed_bbox = ci_transform.transform_rect_bbox(content_bounds);
- combined_bbox = Some(match combined_bbox {
- Some(existing) => existing.union(transformed_bbox),
- None => transformed_bbox,
- });
- }
-
- let bbox = combined_bbox.ok_or("Could not compute bounding box")?;
- let center_x = (bbox.x0 + bbox.x1) / 2.0;
- let center_y = (bbox.y0 + bbox.y1) / 2.0;
-
- // --- Phase 2: Build the VectorClip ---
-
- // Offset shapes so positions are relative to the group center
- let mut clip_shapes: Vec = group_shapes.clone();
- for shape in &mut clip_shapes {
- shape.transform.x -= center_x;
- shape.transform.y -= center_y;
- }
-
- // Offset clip instances similarly
- let mut clip_instances_inside: Vec = group_clip_instances.clone();
- for ci in &mut clip_instances_inside {
- ci.transform.x -= center_x;
- ci.transform.y -= center_y;
- }
-
- // Create VectorClip — groups are static (one frame), not time-based clips
- let frame_duration = 1.0 / document.framerate;
- let mut clip = VectorClip::new("Group", bbox.width(), bbox.height(), frame_duration);
- clip.is_group = true;
- let clip_id = clip.id;
-
- let mut inner_layer = VectorLayer::new("Layer 1");
- for shape in clip_shapes {
- inner_layer.add_shape_to_keyframe(shape, 0.0);
- }
- for ci in clip_instances_inside {
- inner_layer.clip_instances.push(ci);
- }
- clip.layers.add_root(AnyLayer::Vector(inner_layer));
-
- // Add clip to document library
- document.add_vector_clip(clip);
- self.created_clip_id = Some(clip_id);
-
- // --- Phase 3: Remove originals from the layer ---
-
- let layer = document.get_layer_mut(&self.layer_id).unwrap();
- let vl = match layer {
- AnyLayer::Vector(vl) => vl,
- _ => unreachable!(),
- };
-
- // Remove shapes
- self.removed_shapes.clear();
- for id in &self.shape_ids {
- if let Some(shape) = vl.remove_shape_from_keyframe(id, self.time) {
- self.removed_shapes.push(shape);
- }
- }
-
- // Remove clip instances (preserve order for rollback)
- self.removed_clip_instances.clear();
- for id in &self.clip_instance_ids {
- if let Some(pos) = vl.clip_instances.iter().position(|ci| &ci.id == id) {
- self.removed_clip_instances.push(vl.clip_instances.remove(pos));
- }
- }
-
- // --- Phase 4: Place the new group ClipInstance ---
-
- let instance = ClipInstance::with_id(self.instance_id, clip_id)
- .with_position(center_x, center_y)
- .with_name("Group");
- vl.clip_instances.push(instance);
-
- // Register the group in the current keyframe's clip_instance_ids
- if let Some(kf) = vl.keyframe_at_mut(self.time) {
- if !kf.clip_instance_ids.contains(&self.instance_id) {
- kf.clip_instance_ids.push(self.instance_id);
- }
- }
-
- // --- Phase 5: Create default animation curves with initial keyframe ---
-
- let props_and_values = [
- (TransformProperty::X, center_x),
- (TransformProperty::Y, center_y),
- (TransformProperty::Rotation, 0.0),
- (TransformProperty::ScaleX, 1.0),
- (TransformProperty::ScaleY, 1.0),
- (TransformProperty::SkewX, 0.0),
- (TransformProperty::SkewY, 0.0),
- (TransformProperty::Opacity, 1.0),
- ];
-
- for (prop, value) in props_and_values {
- let target = AnimationTarget::Object {
- id: self.instance_id,
- property: prop,
- };
- let mut curve = AnimationCurve::new(target.clone(), value);
- curve.set_keyframe(Keyframe::linear(0.0, value));
- vl.layer.animation_data.set_curve(curve);
- }
-
+ 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
Ok(())
}
- fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
- let layer = document
- .get_layer_mut(&self.layer_id)
- .ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
-
- if let AnyLayer::Vector(vl) = layer {
- // Remove animation curves for the group's clip instance
- for prop in &[
- TransformProperty::X, TransformProperty::Y,
- TransformProperty::Rotation,
- TransformProperty::ScaleX, TransformProperty::ScaleY,
- TransformProperty::SkewX, TransformProperty::SkewY,
- TransformProperty::Opacity,
- ] {
- let target = AnimationTarget::Object {
- id: self.instance_id,
- property: *prop,
- };
- vl.layer.animation_data.remove_curve(&target);
- }
-
- // Remove the group's clip instance
- vl.clip_instances.retain(|ci| ci.id != self.instance_id);
-
- // Remove the group ID from the keyframe
- if let Some(kf) = vl.keyframe_at_mut(self.time) {
- kf.clip_instance_ids.retain(|id| id != &self.instance_id);
- }
-
- // Re-insert removed shapes
- for shape in self.removed_shapes.drain(..) {
- vl.add_shape_to_keyframe(shape, self.time);
- }
-
- // Re-insert removed clip instances
- for ci in self.removed_clip_instances.drain(..) {
- vl.clip_instances.push(ci);
- }
- }
-
- // Remove the VectorClip from the document
- if let Some(clip_id) = self.created_clip_id.take() {
- document.remove_vector_clip(&clip_id);
- }
-
+ fn rollback(&mut self, _document: &mut Document) -> Result<(), String> {
Ok(())
}
@@ -284,129 +54,3 @@ impl Action for GroupAction {
format!("Group {} objects", count)
}
}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::shape::ShapeColor;
- use vello::kurbo::{Circle, Shape as KurboShape};
-
- #[test]
- fn test_group_shapes() {
- let mut document = Document::new("Test");
- let mut layer = VectorLayer::new("Test Layer");
-
- let circle1 = Circle::new((0.0, 0.0), 20.0);
- let shape1 = Shape::new(circle1.to_path(0.1))
- .with_fill(ShapeColor::rgb(255, 0, 0))
- .with_position(50.0, 50.0);
- let shape1_id = shape1.id;
-
- let circle2 = Circle::new((0.0, 0.0), 20.0);
- let shape2 = Shape::new(circle2.to_path(0.1))
- .with_fill(ShapeColor::rgb(0, 255, 0))
- .with_position(150.0, 50.0);
- let shape2_id = shape2.id;
-
- layer.add_shape_to_keyframe(shape1, 0.0);
- layer.add_shape_to_keyframe(shape2, 0.0);
-
- let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
-
- let instance_id = Uuid::new_v4();
- let mut action = GroupAction::new(
- layer_id, 0.0,
- vec![shape1_id, shape2_id],
- vec![],
- instance_id,
- );
- action.execute(&mut document).unwrap();
-
- // Shapes removed, clip instance added
- if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
- assert_eq!(vl.shapes_at_time(0.0).len(), 0);
- assert_eq!(vl.clip_instances.len(), 1);
- assert_eq!(vl.clip_instances[0].id, instance_id);
- }
- assert_eq!(document.vector_clips.len(), 1);
-
- // Rollback
- action.rollback(&mut document).unwrap();
-
- if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
- assert_eq!(vl.shapes_at_time(0.0).len(), 2);
- assert_eq!(vl.clip_instances.len(), 0);
- }
- assert!(document.vector_clips.is_empty());
- }
-
- #[test]
- fn test_group_mixed_shapes_and_clips() {
- let mut document = Document::new("Test");
- let mut layer = VectorLayer::new("Test Layer");
-
- // Add a shape
- let circle = Circle::new((0.0, 0.0), 20.0);
- let shape = Shape::new(circle.to_path(0.1))
- .with_fill(ShapeColor::rgb(255, 0, 0))
- .with_position(50.0, 50.0);
- let shape_id = shape.id;
- layer.add_shape_to_keyframe(shape, 0.0);
-
- // Add a clip instance (create a clip for it first)
- let mut inner_clip = VectorClip::new("Inner", 40.0, 40.0, 1.0);
- let inner_clip_id = inner_clip.id;
- let mut inner_layer = VectorLayer::new("Inner Layer");
- let inner_shape = Shape::new(Circle::new((20.0, 20.0), 15.0).to_path(0.1))
- .with_fill(ShapeColor::rgb(0, 0, 255));
- inner_layer.add_shape_to_keyframe(inner_shape, 0.0);
- inner_clip.layers.add_root(AnyLayer::Vector(inner_layer));
- document.add_vector_clip(inner_clip);
-
- let ci = ClipInstance::new(inner_clip_id).with_position(150.0, 50.0);
- let ci_id = ci.id;
- layer.clip_instances.push(ci);
-
- let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
-
- let instance_id = Uuid::new_v4();
- let mut action = GroupAction::new(
- layer_id, 0.0,
- vec![shape_id],
- vec![ci_id],
- instance_id,
- );
- action.execute(&mut document).unwrap();
-
- if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
- assert_eq!(vl.shapes_at_time(0.0).len(), 0);
- // Only the new group instance remains (the inner clip instance was grouped)
- assert_eq!(vl.clip_instances.len(), 1);
- assert_eq!(vl.clip_instances[0].id, instance_id);
- }
- // Two vector clips: the inner one + the new group
- assert_eq!(document.vector_clips.len(), 2);
-
- // Rollback
- action.rollback(&mut document).unwrap();
-
- if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
- assert_eq!(vl.shapes_at_time(0.0).len(), 1);
- assert_eq!(vl.clip_instances.len(), 1);
- assert_eq!(vl.clip_instances[0].id, ci_id);
- }
- // Only the inner clip remains
- assert_eq!(document.vector_clips.len(), 1);
- }
-
- #[test]
- fn test_group_description() {
- let action = GroupAction::new(
- Uuid::new_v4(), 0.0,
- vec![Uuid::new_v4(), Uuid::new_v4()],
- vec![Uuid::new_v4()],
- Uuid::new_v4(),
- );
- assert_eq!(action.description(), "Group 3 objects");
- }
-}
diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs
index c728b27..bfab90e 100644
--- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs
+++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs
@@ -9,7 +9,6 @@ pub mod add_layer;
pub mod add_shape;
pub mod modify_shape_path;
pub mod move_clip_instances;
-pub mod move_objects;
pub mod paint_bucket;
pub mod remove_effect;
pub mod set_document_properties;
@@ -18,7 +17,6 @@ pub mod set_layer_properties;
pub mod set_shape_properties;
pub mod split_clip_instance;
pub mod transform_clip_instances;
-pub mod transform_objects;
pub mod trim_clip_instances;
pub mod create_folder;
pub mod rename_folder;
@@ -27,7 +25,6 @@ pub mod move_asset_to_folder;
pub mod update_midi_notes;
pub mod loop_clip_instances;
pub mod remove_clip_instances;
-pub mod remove_shapes;
pub mod set_keyframe;
pub mod group_shapes;
pub mod convert_to_movie_clip;
@@ -37,18 +34,16 @@ pub use add_clip_instance::AddClipInstanceAction;
pub use add_effect::AddEffectAction;
pub use add_layer::AddLayerAction;
pub use add_shape::AddShapeAction;
-pub use modify_shape_path::ModifyShapePathAction;
+pub use modify_shape_path::ModifyDcelAction;
pub use move_clip_instances::MoveClipInstancesAction;
-pub use move_objects::MoveShapeInstancesAction;
pub use paint_bucket::PaintBucketAction;
pub use remove_effect::RemoveEffectAction;
pub use set_document_properties::SetDocumentPropertiesAction;
pub use set_instance_properties::{InstancePropertyChange, SetInstancePropertiesAction};
pub use set_layer_properties::{LayerProperty, SetLayerPropertiesAction};
-pub use set_shape_properties::{SetShapePropertiesAction, ShapePropertyChange};
+pub use set_shape_properties::SetShapePropertiesAction;
pub use split_clip_instance::SplitClipInstanceAction;
pub use transform_clip_instances::TransformClipInstancesAction;
-pub use transform_objects::TransformShapeInstancesAction;
pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType};
pub use create_folder::CreateFolderAction;
pub use rename_folder::RenameFolderAction;
@@ -57,7 +52,6 @@ pub use move_asset_to_folder::MoveAssetToFolderAction;
pub use update_midi_notes::UpdateMidiNotesAction;
pub use loop_clip_instances::LoopClipInstancesAction;
pub use remove_clip_instances::RemoveClipInstancesAction;
-pub use remove_shapes::RemoveShapesAction;
pub use set_keyframe::SetKeyframeAction;
pub use group_shapes::GroupAction;
pub use convert_to_movie_clip::ConvertToMovieClipAction;
diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/modify_shape_path.rs b/lightningbeam-ui/lightningbeam-core/src/actions/modify_shape_path.rs
index ec1628b..fa64efe 100644
--- a/lightningbeam-ui/lightningbeam-core/src/actions/modify_shape_path.rs
+++ b/lightningbeam-ui/lightningbeam-core/src/actions/modify_shape_path.rs
@@ -1,223 +1,83 @@
-//! Modify shape path action
-//!
-//! Handles modifying a shape's bezier path (for vector editing operations)
-//! with undo/redo support.
+//! Modify DCEL action — snapshot-based undo for DCEL editing
use crate::action::Action;
+use crate::dcel::Dcel;
use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid;
-use vello::kurbo::BezPath;
-/// Action that modifies a shape's path
+/// Action that captures a before/after DCEL snapshot for undo/redo.
///
-/// This action is used for vector editing operations like dragging vertices,
-/// reshaping curves, or manipulating control points.
-pub struct ModifyShapePathAction {
- /// Layer containing the shape
+/// Used by vertex editing, curve editing, and control point editing.
+/// The caller provides both snapshots (taken before and after the edit).
+pub struct ModifyDcelAction {
layer_id: Uuid,
-
- /// Shape to modify
- shape_id: Uuid,
-
- /// Time of the keyframe containing the shape
time: f64,
-
- /// The version index being modified (for shapes with multiple versions)
- version_index: usize,
-
- /// New path
- new_path: BezPath,
-
- /// Old path (stored after first execution for undo)
- old_path: Option,
+ dcel_before: Option,
+ dcel_after: Option,
+ description_text: String,
}
-impl ModifyShapePathAction {
- /// Create a new action to modify a shape's path
- pub fn new(layer_id: Uuid, shape_id: Uuid, time: f64, version_index: usize, new_path: BezPath) -> Self {
- Self {
- layer_id,
- shape_id,
- time,
- version_index,
- new_path,
- old_path: None,
- }
- }
-
- /// Create action with old path already known (for optimization)
- pub fn with_old_path(
+impl ModifyDcelAction {
+ pub fn new(
layer_id: Uuid,
- shape_id: Uuid,
time: f64,
- version_index: usize,
- old_path: BezPath,
- new_path: BezPath,
+ dcel_before: Dcel,
+ dcel_after: Dcel,
+ description: impl Into,
) -> Self {
Self {
layer_id,
- shape_id,
time,
- version_index,
- new_path,
- old_path: Some(old_path),
+ dcel_before: Some(dcel_before),
+ dcel_after: Some(dcel_after),
+ description_text: description.into(),
}
}
}
-impl Action for ModifyShapePathAction {
+impl Action for ModifyDcelAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
- if let Some(layer) = document.get_layer_mut(&self.layer_id) {
- if let AnyLayer::Vector(vector_layer) = layer {
- if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(&self.shape_id, self.time) {
- if self.version_index >= shape.versions.len() {
- return Err(format!(
- "Version index {} out of bounds (shape has {} versions)",
- self.version_index,
- shape.versions.len()
- ));
- }
+ let dcel_after = self.dcel_after.as_ref()
+ .ok_or("ModifyDcelAction: no dcel_after snapshot")?
+ .clone();
- // Store old path if not already stored
- if self.old_path.is_none() {
- self.old_path = Some(shape.versions[self.version_index].path.clone());
- }
+ let layer = document.get_layer_mut(&self.layer_id)
+ .ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
- // Apply new path
- shape.versions[self.version_index].path = self.new_path.clone();
-
- return Ok(());
- }
+ if let AnyLayer::Vector(vl) = layer {
+ if let Some(kf) = vl.keyframe_at_mut(self.time) {
+ kf.dcel = dcel_after;
+ Ok(())
+ } else {
+ Err(format!("No keyframe at time {}", self.time))
}
+ } else {
+ Err("Not a vector layer".to_string())
}
-
- Err(format!(
- "Could not find shape {} in layer {}",
- self.shape_id, self.layer_id
- ))
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
- if let Some(old_path) = &self.old_path {
- if let Some(layer) = document.get_layer_mut(&self.layer_id) {
- if let AnyLayer::Vector(vector_layer) = layer {
- if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(&self.shape_id, self.time) {
- if self.version_index < shape.versions.len() {
- shape.versions[self.version_index].path = old_path.clone();
- return Ok(());
- }
- }
- }
- }
- }
+ let dcel_before = self.dcel_before.as_ref()
+ .ok_or("ModifyDcelAction: no dcel_before snapshot")?
+ .clone();
- Err(format!(
- "Could not rollback shape path modification for shape {} in layer {}",
- self.shape_id, self.layer_id
- ))
+ let layer = document.get_layer_mut(&self.layer_id)
+ .ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
+
+ if let AnyLayer::Vector(vl) = layer {
+ if let Some(kf) = vl.keyframe_at_mut(self.time) {
+ kf.dcel = dcel_before;
+ Ok(())
+ } else {
+ Err(format!("No keyframe at time {}", self.time))
+ }
+ } else {
+ Err("Not a vector layer".to_string())
+ }
}
fn description(&self) -> String {
- "Modify shape path".to_string()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::layer::VectorLayer;
- use crate::shape::Shape;
- use vello::kurbo::Shape as KurboShape;
-
- fn create_test_path() -> BezPath {
- let mut path = BezPath::new();
- path.move_to((0.0, 0.0));
- path.line_to((100.0, 0.0));
- path.line_to((100.0, 100.0));
- path.line_to((0.0, 100.0));
- path.close_path();
- path
- }
-
- fn create_modified_path() -> BezPath {
- let mut path = BezPath::new();
- path.move_to((0.0, 0.0));
- path.line_to((150.0, 0.0));
- path.line_to((150.0, 150.0));
- path.line_to((0.0, 150.0));
- path.close_path();
- path
- }
-
- #[test]
- fn test_modify_shape_path() {
- let mut document = Document::new("Test");
- let mut layer = VectorLayer::new("Test Layer");
-
- let shape = Shape::new(create_test_path());
- let shape_id = shape.id;
- layer.add_shape_to_keyframe(shape, 0.0);
-
- let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
-
- // Verify initial path
- if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
- let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
- let bbox = shape.versions[0].path.bounding_box();
- assert_eq!(bbox.width(), 100.0);
- assert_eq!(bbox.height(), 100.0);
- }
-
- // Create and execute action
- let new_path = create_modified_path();
- let mut action = ModifyShapePathAction::new(layer_id, shape_id, 0.0, 0, new_path);
- action.execute(&mut document).unwrap();
-
- // Verify path changed
- if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
- let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
- let bbox = shape.versions[0].path.bounding_box();
- assert_eq!(bbox.width(), 150.0);
- assert_eq!(bbox.height(), 150.0);
- }
-
- // Rollback
- action.rollback(&mut document).unwrap();
-
- // Verify restored
- if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
- let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
- let bbox = shape.versions[0].path.bounding_box();
- assert_eq!(bbox.width(), 100.0);
- assert_eq!(bbox.height(), 100.0);
- }
- }
-
- #[test]
- fn test_invalid_version_index() {
- let mut document = Document::new("Test");
- let mut layer = VectorLayer::new("Test Layer");
-
- let shape = Shape::new(create_test_path());
- let shape_id = shape.id;
- layer.add_shape_to_keyframe(shape, 0.0);
-
- let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
-
- let new_path = create_modified_path();
- let mut action = ModifyShapePathAction::new(layer_id, shape_id, 0.0, 5, new_path);
- let result = action.execute(&mut document);
-
- assert!(result.is_err());
- assert!(result.unwrap_err().contains("out of bounds"));
- }
-
- #[test]
- fn test_description() {
- let layer_id = Uuid::new_v4();
- let shape_id = Uuid::new_v4();
- let action = ModifyShapePathAction::new(layer_id, shape_id, 0.0, 0, create_test_path());
- assert_eq!(action.description(), "Modify shape path");
+ self.description_text.clone()
}
}
diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_asset_to_folder.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_asset_to_folder.rs
index 4c55dcd..b596a22 100644
--- a/lightningbeam-ui/lightningbeam-core/src/actions/move_asset_to_folder.rs
+++ b/lightningbeam-ui/lightningbeam-core/src/actions/move_asset_to_folder.rs
@@ -247,7 +247,7 @@ mod tests {
let folder2_id = folder2_action.created_folder_id().unwrap();
// Create a clip in folder 1
- let mut clip = crate::clip::AudioClip::new_sampled("Test Audio", 5.0, 0);
+ let mut clip = crate::clip::AudioClip::new_sampled("Test Audio", 0, 5.0);
clip.folder_id = Some(folder1_id);
let clip_id = clip.id;
document.audio_clips.insert(clip_id, clip);
diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/move_objects.rs b/lightningbeam-ui/lightningbeam-core/src/actions/move_objects.rs
deleted file mode 100644
index 5fbc43f..0000000
--- a/lightningbeam-ui/lightningbeam-core/src/actions/move_objects.rs
+++ /dev/null
@@ -1,73 +0,0 @@
-//! Move shapes action
-//!
-//! Handles moving one or more shapes to new positions within a keyframe.
-
-use crate::action::Action;
-use crate::document::Document;
-use crate::layer::AnyLayer;
-use std::collections::HashMap;
-use uuid::Uuid;
-use vello::kurbo::Point;
-
-/// Action that moves shapes to new positions within a keyframe
-pub struct MoveShapeInstancesAction {
- layer_id: Uuid,
- time: f64,
- /// Map of shape IDs to their old and new positions
- shape_positions: HashMap,
-}
-
-impl MoveShapeInstancesAction {
- pub fn new(layer_id: Uuid, time: f64, shape_positions: HashMap) -> Self {
- Self {
- layer_id,
- time,
- shape_positions,
- }
- }
-}
-
-impl Action for MoveShapeInstancesAction {
- fn execute(&mut self, document: &mut Document) -> Result<(), String> {
- let layer = match document.get_layer_mut(&self.layer_id) {
- Some(l) => l,
- None => return Ok(()),
- };
-
- if let AnyLayer::Vector(vector_layer) = layer {
- for (shape_id, (_old, new)) in &self.shape_positions {
- if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
- shape.transform.x = new.x;
- shape.transform.y = new.y;
- }
- }
- }
- Ok(())
- }
-
- fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
- let layer = match document.get_layer_mut(&self.layer_id) {
- Some(l) => l,
- None => return Ok(()),
- };
-
- if let AnyLayer::Vector(vector_layer) = layer {
- for (shape_id, (old, _new)) in &self.shape_positions {
- if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
- shape.transform.x = old.x;
- shape.transform.y = old.y;
- }
- }
- }
- Ok(())
- }
-
- fn description(&self) -> String {
- let count = self.shape_positions.len();
- if count == 1 {
- "Move shape".to_string()
- } else {
- format!("Move {} shapes", count)
- }
- }
-}
diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs b/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs
index a5259c3..f0ccb2f 100644
--- a/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs
+++ b/lightningbeam-ui/lightningbeam-core/src/actions/paint_bucket.rs
@@ -1,152 +1,103 @@
-//! Paint bucket fill action
-//!
-//! This action performs a paint bucket fill operation starting from a click point,
-//! using planar graph face detection to identify the region to fill.
+//! Paint bucket fill action — sets fill_color on a DCEL face.
use crate::action::Action;
-use crate::curve_segment::CurveSegment;
+use crate::dcel::FaceId;
use crate::document::Document;
-use crate::gap_handling::GapHandlingMode;
use crate::layer::AnyLayer;
-use crate::planar_graph::PlanarGraph;
use crate::shape::ShapeColor;
use uuid::Uuid;
use vello::kurbo::Point;
-/// Action that performs a paint bucket fill operation
+/// Action that performs a paint bucket fill on a DCEL face.
pub struct PaintBucketAction {
- /// Layer ID to add the filled shape to
layer_id: Uuid,
-
- /// Time of the keyframe to operate on
time: f64,
-
- /// Click point where fill was initiated
click_point: Point,
-
- /// Fill color for the shape
fill_color: ShapeColor,
-
- /// Tolerance for gap bridging (in pixels)
- _tolerance: f64,
-
- /// Gap handling mode
- _gap_mode: GapHandlingMode,
-
- /// ID of the created shape (set after execution)
- created_shape_id: Option,
+ /// The face that was hit (resolved during execute)
+ hit_face: Option,
+ /// Previous fill color for undo
+ old_fill_color: Option