Merge branch 'rust-ui' of https://git.skyler.io/skyler/Lightningbeam into rust-ui

This commit is contained in:
Skyler Lehmkuhl 2026-02-24 14:40:19 -05:00
commit 4a13ce0684
46 changed files with 7760 additions and 4938 deletions

373
.github/workflows/build.yml vendored Normal file
View File

@ -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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Lightningbeam Editor</string>
<key>CFBundleDisplayName</key>
<string>Lightningbeam Editor</string>
<key>CFBundleIdentifier</key>
<string>com.lightningbeam.editor</string>
<key>CFBundleVersion</key>
<string>${VERSION}</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>lightningbeam-editor</string>
<key>CFBundleIconFile</key>
<string>lightningbeam-editor</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
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 }}

View File

@ -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<<EOF' >> $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 }}

View File

@ -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: # 0.8.1-alpha:
Changes: Changes:
- Rewrite timeline UI - Rewrite timeline UI

View File

@ -1136,6 +1136,11 @@ impl Engine {
self.metronome.set_enabled(enabled); 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 // Node graph commands
Command::GraphAddNode(track_id, node_type, x, y) => { Command::GraphAddNode(track_id, node_type, x, y) => {
eprintln!("[DEBUG] GraphAddNode received: track_id={}, node_type={}, x={}, y={}", 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)); 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 // Node graph operations
/// Add a node to a track's instrument graph /// Add a node to a track's instrument graph

View File

@ -96,6 +96,11 @@ pub struct AudioGraph {
/// Current playback time (for automation nodes) /// Current playback time (for automation nodes)
playback_time: f64, 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) /// Cached topological sort order (invalidated on graph mutation)
topo_cache: Option<Vec<NodeIndex>>, topo_cache: Option<Vec<NodeIndex>>,
@ -119,11 +124,19 @@ impl AudioGraph {
midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(), midi_input_buffers: (0..16).map(|_| Vec::with_capacity(128)).collect(),
node_positions: std::collections::HashMap::new(), node_positions: std::collections::HashMap::new(),
playback_time: 0.0, playback_time: 0.0,
bpm: 120.0,
beats_per_bar: 4,
topo_cache: None, topo_cache: None,
frontend_groups: Vec::new(), 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 /// Add a node to the graph
pub fn add_node(&mut self, node: Box<dyn AudioNode>) -> NodeIndex { pub fn add_node(&mut self, node: Box<dyn AudioNode>) -> NodeIndex {
let graph_node = GraphNode::new(node, self.buffer_size); let graph_node = GraphNode::new(node, self.buffer_size);
@ -452,6 +465,7 @@ impl AudioGraph {
auto_node.set_playback_time(playback_time); auto_node.set_playback_time(playback_time);
} else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::<BeatNode>() { } else if let Some(beat_node) = node.node.as_any_mut().downcast_mut::<BeatNode>() {
beat_node.set_playback_time(playback_time); beat_node.set_playback_time(playback_time);
beat_node.set_tempo(self.bpm, self.beats_per_bar);
} }
} }

View File

@ -3,8 +3,8 @@ use crate::audio::midi::MidiEvent;
const PARAM_RESOLUTION: u32 = 0; const PARAM_RESOLUTION: u32 = 0;
/// Hardcoded BPM until project tempo is implemented
const DEFAULT_BPM: f32 = 120.0; const DEFAULT_BPM: f32 = 120.0;
const DEFAULT_BEATS_PER_BAR: u32 = 4;
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum BeatResolution { pub enum BeatResolution {
@ -47,17 +47,19 @@ impl BeatResolution {
/// Beat clock node — generates tempo-synced CV signals. /// 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 playing: synced to timeline position.
/// When stopped: free-runs continuously at the set BPM. /// When stopped: free-runs continuously at the project BPM.
/// ///
/// Outputs: /// Outputs:
/// - BPM: constant CV proportional to tempo (bpm / 240) /// - BPM: constant CV proportional to tempo (bpm / 240)
/// - Beat Phase: sawtooth 0→1 per beat subdivision /// - 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 /// - Gate: 1.0 for first half of each subdivision, 0.0 otherwise
pub struct BeatNode { pub struct BeatNode {
name: String, name: String,
bpm: f32, bpm: f32,
beats_per_bar: u32,
resolution: BeatResolution, resolution: BeatResolution,
/// Playback time in seconds, set by the graph before process() /// Playback time in seconds, set by the graph before process()
playback_time: f64, playback_time: f64,
@ -88,6 +90,7 @@ impl BeatNode {
Self { Self {
name: name.into(), name: name.into(),
bpm: DEFAULT_BPM, bpm: DEFAULT_BPM,
beats_per_bar: DEFAULT_BEATS_PER_BAR,
resolution: BeatResolution::Quarter, resolution: BeatResolution::Quarter,
playback_time: 0.0, playback_time: 0.0,
prev_playback_time: -1.0, prev_playback_time: -1.0,
@ -101,6 +104,11 @@ impl BeatNode {
pub fn set_playback_time(&mut self, time: f64) { pub fn set_playback_time(&mut self, time: f64) {
self.playback_time = time; 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 { impl AudioNode for BeatNode {
@ -167,8 +175,8 @@ impl AudioNode for BeatNode {
// Beat subdivision phase: 0→1 sawtooth // Beat subdivision phase: 0→1 sawtooth
let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32; let sub_phase = ((beat_pos * subs_per_beat) % 1.0) as f32;
// Bar phase: 0→1 over 4 quarter-note beats // Bar phase: 0→1 over one bar (beats_per_bar beats)
let bar_phase = ((beat_pos / 4.0) % 1.0) as f32; let bar_phase = ((beat_pos / self.beats_per_bar as f64) % 1.0) as f32;
// Gate: high for first half of each subdivision // Gate: high for first half of each subdivision
let gate = if sub_phase < 0.5 { 1.0f32 } else { 0.0 }; let gate = if sub_phase < 0.5 { 1.0f32 } else { 0.0 };
@ -201,6 +209,7 @@ impl AudioNode for BeatNode {
Box::new(Self { Box::new(Self {
name: self.name.clone(), name: self.name.clone(),
bpm: self.bpm, bpm: self.bpm,
beats_per_bar: self.beats_per_bar,
resolution: self.resolution, resolution: self.resolution,
playback_time: 0.0, playback_time: 0.0,
prev_playback_time: -1.0, prev_playback_time: -1.0,

View File

@ -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) /// 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) { 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 // Process all MIDI tracks to handle queued live input events

View File

@ -138,6 +138,8 @@ pub enum Command {
// Metronome command // Metronome command
/// Enable or disable the metronome click track /// Enable or disable the metronome click track
SetMetronomeEnabled(bool), SetMetronomeEnabled(bool),
/// Set project tempo and time signature (bpm, (numerator, denominator))
SetTempo(f32, (u32, u32)),
// Node graph commands // Node graph commands
/// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y) /// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y)

View File

@ -3464,8 +3464,10 @@ dependencies = [
"kurbo 0.12.0", "kurbo 0.12.0",
"lru", "lru",
"pathdiff", "pathdiff",
"rstar",
"serde", "serde",
"serde_json", "serde_json",
"tiny-skia",
"uuid", "uuid",
"vello", "vello",
"wgpu", "wgpu",
@ -5365,6 +5367,17 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" 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]] [[package]]
name = "rtrb" name = "rtrb"
version = "0.3.2" version = "0.3.2"

View File

@ -41,5 +41,11 @@ pathdiff = "0.2"
flacenc = "0.4" # For FLAC encoding (lossless) flacenc = "0.4" # For FLAC encoding (lossless)
claxon = "0.4" # For FLAC decoding claxon = "0.4" # For FLAC decoding
# Spatial indexing for DCEL vertex snapping
rstar = "0.12"
# System clipboard # System clipboard
arboard = "3" arboard = "3"
[dev-dependencies]
tiny-skia = "0.11"

View File

@ -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::action::Action;
use crate::dcel::{bezpath_to_cubic_segments, Dcel, DEFAULT_SNAP_EPSILON};
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::shape::Shape; use crate::shape::{ShapeColor, StrokeStyle};
use kurbo::BezPath;
use uuid::Uuid; 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 { pub struct AddShapeAction {
/// Layer ID to add the shape to
layer_id: Uuid, layer_id: Uuid,
/// The shape to add (contains geometry, styling, transform, opacity)
shape: Shape,
/// Time of the keyframe to add to
time: f64, time: f64,
path: BezPath,
/// ID of the created shape (set after execution) stroke_style: Option<StrokeStyle>,
created_shape_id: Option<Uuid>, stroke_color: Option<ShapeColor>,
fill_color: Option<ShapeColor>,
is_closed: bool,
description_text: String,
/// Snapshot of the DCEL before insertion (for undo).
dcel_before: Option<Dcel>,
} }
impl AddShapeAction { 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<StrokeStyle>,
stroke_color: Option<ShapeColor>,
fill_color: Option<ShapeColor>,
is_closed: bool,
) -> Self {
Self { Self {
layer_id, layer_id,
shape,
time, 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<String>) -> Self {
self.description_text = desc.into();
self
}
} }
impl Action for AddShapeAction { impl Action for AddShapeAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> { fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let layer = match document.get_layer_mut(&self.layer_id) { let layer = document
Some(l) => l, .get_layer_mut(&self.layer_id)
None => return Ok(()), .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 keyframe = vl.ensure_keyframe_at(self.time);
let shape_id = self.shape.id; let dcel = &mut keyframe.dcel;
vector_layer.add_shape_to_keyframe(self.shape.clone(), self.time);
self.created_shape_id = Some(shape_id); // 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(()) Ok(())
} }
fn rollback(&mut self, document: &mut Document) -> Result<(), String> { fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(shape_id) = self.created_shape_id { let layer = document
let layer = match document.get_layer_mut(&self.layer_id) { .get_layer_mut(&self.layer_id)
Some(l) => l, .ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
None => return Ok(()),
};
if let AnyLayer::Vector(vector_layer) = layer { let vl = match layer {
vector_layer.remove_shape_from_keyframe(&shape_id, self.time); 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(()) Ok(())
} }
fn description(&self) -> String { fn description(&self) -> String {
"Add shape".to_string() self.description_text.clone()
}
}
#[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);
}
} }
} }

View File

@ -1,18 +1,13 @@
//! Convert to Movie Clip action //! Convert to Movie Clip action — STUB: needs DCEL rewrite
//!
//! 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+).
use crate::action::Action; use crate::action::Action;
use crate::animation::{AnimationCurve, AnimationTarget, Keyframe, TransformProperty}; use crate::clip::ClipInstance;
use crate::clip::{ClipInstance, VectorClip};
use crate::document::Document; use crate::document::Document;
use crate::layer::{AnyLayer, VectorLayer};
use crate::shape::Shape;
use uuid::Uuid; 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 { pub struct ConvertToMovieClipAction {
layer_id: Uuid, layer_id: Uuid,
time: f64, time: f64,
@ -20,7 +15,6 @@ pub struct ConvertToMovieClipAction {
clip_instance_ids: Vec<Uuid>, clip_instance_ids: Vec<Uuid>,
instance_id: Uuid, instance_id: Uuid,
created_clip_id: Option<Uuid>, created_clip_id: Option<Uuid>,
removed_shapes: Vec<Shape>,
removed_clip_instances: Vec<ClipInstance>, removed_clip_instances: Vec<ClipInstance>,
} }
@ -39,201 +33,18 @@ impl ConvertToMovieClipAction {
clip_instance_ids, clip_instance_ids,
instance_id, instance_id,
created_clip_id: None, created_clip_id: None,
removed_shapes: Vec::new(),
removed_clip_instances: Vec::new(), removed_clip_instances: Vec::new(),
} }
} }
} }
impl Action for ConvertToMovieClipAction { impl Action for ConvertToMovieClipAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> { fn execute(&mut self, _document: &mut Document) -> Result<(), String> {
let layer = document let _ = (&self.layer_id, self.time, &self.shape_ids, &self.clip_instance_ids, self.instance_id);
.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<Shape> = 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<ClipInstance> = 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<Rect> = 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<Shape> = 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<ClipInstance> = 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);
}
Ok(()) Ok(())
} }
fn rollback(&mut self, document: &mut Document) -> Result<(), String> { 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);
}
Ok(()) Ok(())
} }

View File

@ -1,42 +1,20 @@
//! Group action //! Group action — STUB: needs DCEL rewrite
//!
//! 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.
use crate::action::Action; use crate::action::Action;
use crate::animation::{AnimationCurve, AnimationTarget, Keyframe, TransformProperty}; use crate::clip::ClipInstance;
use crate::clip::{ClipInstance, VectorClip};
use crate::document::Document; use crate::document::Document;
use crate::layer::{AnyLayer, VectorLayer};
use crate::shape::Shape;
use uuid::Uuid; use uuid::Uuid;
use vello::kurbo::{Rect, Shape as KurboShape};
/// Action that groups selected shapes and/or clip instances into a VectorClip /// 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 { pub struct GroupAction {
/// Layer containing the items to group
layer_id: Uuid, layer_id: Uuid,
/// Time of the keyframe to operate on (for shape lookup)
time: f64, time: f64,
/// Shape IDs to include in the group
shape_ids: Vec<Uuid>, shape_ids: Vec<Uuid>,
/// Clip instance IDs to include in the group
clip_instance_ids: Vec<Uuid>, clip_instance_ids: Vec<Uuid>,
/// Pre-generated clip instance ID for the new group (so caller can update selection)
instance_id: Uuid, instance_id: Uuid,
/// Created clip ID (for rollback)
created_clip_id: Option<Uuid>, created_clip_id: Option<Uuid>,
/// Shapes removed from the keyframe (for rollback)
removed_shapes: Vec<Shape>,
/// Clip instances removed from the layer (for rollback, preserving original order)
removed_clip_instances: Vec<ClipInstance>, removed_clip_instances: Vec<ClipInstance>,
} }
@ -55,227 +33,19 @@ impl GroupAction {
clip_instance_ids, clip_instance_ids,
instance_id, instance_id,
created_clip_id: None, created_clip_id: None,
removed_shapes: Vec::new(),
removed_clip_instances: Vec::new(), removed_clip_instances: Vec::new(),
} }
} }
} }
impl Action for GroupAction { impl Action for GroupAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> { fn execute(&mut self, _document: &mut Document) -> Result<(), String> {
// --- Phase 1: Collect items and compute bounding box --- let _ = (&self.layer_id, self.time, &self.shape_ids, &self.clip_instance_ids, self.instance_id);
// TODO: Implement DCEL-aware grouping
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<Shape> = 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<ClipInstance> = 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<Rect> = 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<Shape> = 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<ClipInstance> = 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);
}
Ok(()) Ok(())
} }
fn rollback(&mut self, document: &mut Document) -> Result<(), String> { 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);
}
Ok(()) Ok(())
} }
@ -284,129 +54,3 @@ impl Action for GroupAction {
format!("Group {} objects", count) 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");
}
}

View File

@ -9,7 +9,6 @@ pub mod add_layer;
pub mod add_shape; pub mod add_shape;
pub mod modify_shape_path; pub mod modify_shape_path;
pub mod move_clip_instances; pub mod move_clip_instances;
pub mod move_objects;
pub mod paint_bucket; pub mod paint_bucket;
pub mod remove_effect; pub mod remove_effect;
pub mod set_document_properties; pub mod set_document_properties;
@ -18,7 +17,6 @@ pub mod set_layer_properties;
pub mod set_shape_properties; pub mod set_shape_properties;
pub mod split_clip_instance; pub mod split_clip_instance;
pub mod transform_clip_instances; pub mod transform_clip_instances;
pub mod transform_objects;
pub mod trim_clip_instances; pub mod trim_clip_instances;
pub mod create_folder; pub mod create_folder;
pub mod rename_folder; pub mod rename_folder;
@ -27,7 +25,6 @@ pub mod move_asset_to_folder;
pub mod update_midi_notes; pub mod update_midi_notes;
pub mod loop_clip_instances; pub mod loop_clip_instances;
pub mod remove_clip_instances; pub mod remove_clip_instances;
pub mod remove_shapes;
pub mod set_keyframe; pub mod set_keyframe;
pub mod group_shapes; pub mod group_shapes;
pub mod convert_to_movie_clip; pub mod convert_to_movie_clip;
@ -37,18 +34,16 @@ pub use add_clip_instance::AddClipInstanceAction;
pub use add_effect::AddEffectAction; pub use add_effect::AddEffectAction;
pub use add_layer::AddLayerAction; pub use add_layer::AddLayerAction;
pub use add_shape::AddShapeAction; 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_clip_instances::MoveClipInstancesAction;
pub use move_objects::MoveShapeInstancesAction;
pub use paint_bucket::PaintBucketAction; pub use paint_bucket::PaintBucketAction;
pub use remove_effect::RemoveEffectAction; pub use remove_effect::RemoveEffectAction;
pub use set_document_properties::SetDocumentPropertiesAction; pub use set_document_properties::SetDocumentPropertiesAction;
pub use set_instance_properties::{InstancePropertyChange, SetInstancePropertiesAction}; pub use set_instance_properties::{InstancePropertyChange, SetInstancePropertiesAction};
pub use set_layer_properties::{LayerProperty, SetLayerPropertiesAction}; 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 split_clip_instance::SplitClipInstanceAction;
pub use transform_clip_instances::TransformClipInstancesAction; pub use transform_clip_instances::TransformClipInstancesAction;
pub use transform_objects::TransformShapeInstancesAction;
pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType}; pub use trim_clip_instances::{TrimClipInstancesAction, TrimData, TrimType};
pub use create_folder::CreateFolderAction; pub use create_folder::CreateFolderAction;
pub use rename_folder::RenameFolderAction; pub use rename_folder::RenameFolderAction;
@ -57,7 +52,6 @@ pub use move_asset_to_folder::MoveAssetToFolderAction;
pub use update_midi_notes::UpdateMidiNotesAction; pub use update_midi_notes::UpdateMidiNotesAction;
pub use loop_clip_instances::LoopClipInstancesAction; pub use loop_clip_instances::LoopClipInstancesAction;
pub use remove_clip_instances::RemoveClipInstancesAction; pub use remove_clip_instances::RemoveClipInstancesAction;
pub use remove_shapes::RemoveShapesAction;
pub use set_keyframe::SetKeyframeAction; pub use set_keyframe::SetKeyframeAction;
pub use group_shapes::GroupAction; pub use group_shapes::GroupAction;
pub use convert_to_movie_clip::ConvertToMovieClipAction; pub use convert_to_movie_clip::ConvertToMovieClipAction;

View File

@ -1,223 +1,83 @@
//! Modify shape path action //! Modify DCEL action — snapshot-based undo for DCEL editing
//!
//! Handles modifying a shape's bezier path (for vector editing operations)
//! with undo/redo support.
use crate::action::Action; use crate::action::Action;
use crate::dcel::Dcel;
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use uuid::Uuid; 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, /// Used by vertex editing, curve editing, and control point editing.
/// reshaping curves, or manipulating control points. /// The caller provides both snapshots (taken before and after the edit).
pub struct ModifyShapePathAction { pub struct ModifyDcelAction {
/// Layer containing the shape
layer_id: Uuid, layer_id: Uuid,
/// Shape to modify
shape_id: Uuid,
/// Time of the keyframe containing the shape
time: f64, time: f64,
dcel_before: Option<Dcel>,
/// The version index being modified (for shapes with multiple versions) dcel_after: Option<Dcel>,
version_index: usize, description_text: String,
/// New path
new_path: BezPath,
/// Old path (stored after first execution for undo)
old_path: Option<BezPath>,
} }
impl ModifyShapePathAction { impl ModifyDcelAction {
/// Create a new action to modify a shape's path pub fn new(
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(
layer_id: Uuid, layer_id: Uuid,
shape_id: Uuid,
time: f64, time: f64,
version_index: usize, dcel_before: Dcel,
old_path: BezPath, dcel_after: Dcel,
new_path: BezPath, description: impl Into<String>,
) -> Self { ) -> Self {
Self { Self {
layer_id, layer_id,
shape_id,
time, time,
version_index, dcel_before: Some(dcel_before),
new_path, dcel_after: Some(dcel_after),
old_path: Some(old_path), description_text: description.into(),
} }
} }
} }
impl Action for ModifyShapePathAction { impl Action for ModifyDcelAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> { fn execute(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(layer) = document.get_layer_mut(&self.layer_id) { let dcel_after = self.dcel_after.as_ref()
if let AnyLayer::Vector(vector_layer) = layer { .ok_or("ModifyDcelAction: no dcel_after snapshot")?
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(&self.shape_id, self.time) { .clone();
if self.version_index >= shape.versions.len() {
return Err(format!(
"Version index {} out of bounds (shape has {} versions)",
self.version_index,
shape.versions.len()
));
}
// Store old path if not already stored let layer = document.get_layer_mut(&self.layer_id)
if self.old_path.is_none() { .ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
self.old_path = Some(shape.versions[self.version_index].path.clone());
}
// Apply new path if let AnyLayer::Vector(vl) = layer {
shape.versions[self.version_index].path = self.new_path.clone(); if let Some(kf) = vl.keyframe_at_mut(self.time) {
kf.dcel = dcel_after;
return Ok(()); 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> { fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(old_path) = &self.old_path { let dcel_before = self.dcel_before.as_ref()
if let Some(layer) = document.get_layer_mut(&self.layer_id) { .ok_or("ModifyDcelAction: no dcel_before snapshot")?
if let AnyLayer::Vector(vector_layer) = layer { .clone();
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(());
}
}
}
}
}
Err(format!( let layer = document.get_layer_mut(&self.layer_id)
"Could not rollback shape path modification for shape {} in layer {}", .ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
self.shape_id, 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 { fn description(&self) -> String {
"Modify shape path".to_string() self.description_text.clone()
}
}
#[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");
} }
} }

View File

@ -247,7 +247,7 @@ mod tests {
let folder2_id = folder2_action.created_folder_id().unwrap(); let folder2_id = folder2_action.created_folder_id().unwrap();
// Create a clip in folder 1 // 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); clip.folder_id = Some(folder1_id);
let clip_id = clip.id; let clip_id = clip.id;
document.audio_clips.insert(clip_id, clip); document.audio_clips.insert(clip_id, clip);

View File

@ -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<Uuid, (Point, Point)>,
}
impl MoveShapeInstancesAction {
pub fn new(layer_id: Uuid, time: f64, shape_positions: HashMap<Uuid, (Point, Point)>) -> 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)
}
}
}

View File

@ -1,152 +1,103 @@
//! Paint bucket fill action //! Paint bucket fill action — sets fill_color on a DCEL face.
//!
//! This action performs a paint bucket fill operation starting from a click point,
//! using planar graph face detection to identify the region to fill.
use crate::action::Action; use crate::action::Action;
use crate::curve_segment::CurveSegment; use crate::dcel::FaceId;
use crate::document::Document; use crate::document::Document;
use crate::gap_handling::GapHandlingMode;
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::planar_graph::PlanarGraph;
use crate::shape::ShapeColor; use crate::shape::ShapeColor;
use uuid::Uuid; use uuid::Uuid;
use vello::kurbo::Point; 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 { pub struct PaintBucketAction {
/// Layer ID to add the filled shape to
layer_id: Uuid, layer_id: Uuid,
/// Time of the keyframe to operate on
time: f64, time: f64,
/// Click point where fill was initiated
click_point: Point, click_point: Point,
/// Fill color for the shape
fill_color: ShapeColor, fill_color: ShapeColor,
/// The face that was hit (resolved during execute)
/// Tolerance for gap bridging (in pixels) hit_face: Option<FaceId>,
_tolerance: f64, /// Previous fill color for undo
old_fill_color: Option<Option<ShapeColor>>,
/// Gap handling mode
_gap_mode: GapHandlingMode,
/// ID of the created shape (set after execution)
created_shape_id: Option<Uuid>,
} }
impl PaintBucketAction { impl PaintBucketAction {
/// Create a new paint bucket action
pub fn new( pub fn new(
layer_id: Uuid, layer_id: Uuid,
time: f64, time: f64,
click_point: Point, click_point: Point,
fill_color: ShapeColor, fill_color: ShapeColor,
tolerance: f64,
gap_mode: GapHandlingMode,
) -> Self { ) -> Self {
Self { Self {
layer_id, layer_id,
time, time,
click_point, click_point,
fill_color, fill_color,
_tolerance: tolerance, hit_face: None,
_gap_mode: gap_mode, old_fill_color: None,
created_shape_id: None,
} }
} }
} }
impl Action for PaintBucketAction { impl Action for PaintBucketAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> { fn execute(&mut self, document: &mut Document) -> Result<(), String> {
println!("=== PaintBucketAction::execute ==="); let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
// Optimization: Check if we're clicking on an existing shape first let vl = match layer {
if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) { AnyLayer::Vector(vl) => vl,
// Iterate through shapes in the keyframe in reverse order (topmost first) _ => return Err("Not a vector layer".to_string()),
let shapes = vector_layer.shapes_at_time(self.time); };
for shape in shapes.iter().rev() {
// Skip shapes without fill color
if shape.fill_color.is_none() {
continue;
}
use vello::kurbo::PathEl; let keyframe = vl.ensure_keyframe_at(self.time);
let is_closed = shape.path().elements().iter().any(|el| matches!(el, PathEl::ClosePath)); let dcel = &mut keyframe.dcel;
if !is_closed {
continue;
}
// Apply the shape's transform // Record for debug test generation (if recording is active)
let transform_affine = shape.transform.to_affine(); dcel.record_paint_point(self.click_point);
let inverse_transform = transform_affine.inverse();
let local_point = inverse_transform * self.click_point;
use vello::kurbo::Shape as KurboShape; // Hit-test to find which face was clicked
let winding = shape.path().winding(local_point); let face_id = dcel.find_face_containing_point(self.click_point);
if winding != 0 { // Dump cumulative test to stderr after every paint click (if recording)
println!("Clicked on existing shape, changing fill color"); // Do this before the early return so failed clicks are captured too.
let shape_id = shape.id; if dcel.is_recording() {
eprintln!("\n--- DCEL debug test (cumulative, face={:?}) ---", face_id);
// Now get mutable access to change the fill dcel.debug_recorder.as_ref().unwrap().dump_test("test_recorded");
if let Some(shape_mut) = vector_layer.get_shape_in_keyframe_mut(&shape_id, self.time) { eprintln!("--- end test ---\n");
shape_mut.fill_color = Some(self.fill_color);
}
return Ok(());
}
}
println!("No existing shape at click point, creating new fill region");
} }
// Step 1: Extract curves from all shapes in the keyframe if face_id.0 == 0 {
let all_curves = extract_curves_from_keyframe(document, &self.layer_id, self.time); // FaceId(0) is the unbounded exterior face — nothing to fill
return Err("No face at click point".to_string());
println!("Extracted {} curves from all shapes", all_curves.len());
if all_curves.is_empty() {
println!("No curves found, returning");
return Ok(());
} }
// Step 2: Build planar graph // Store for undo
println!("Building planar graph..."); self.hit_face = Some(face_id);
let graph = PlanarGraph::build(&all_curves); self.old_fill_color = Some(dcel.face(face_id).fill_color.clone());
// Step 3: Trace the face containing the click point // Apply fill
println!("Tracing face from click point {:?}...", self.click_point); dcel.face_mut(face_id).fill_color = Some(self.fill_color.clone());
if let Some(face) = graph.trace_face_from_point(self.click_point) {
println!("Successfully traced face containing click point!");
let face_path = graph.build_face_path(&face);
let face_shape = crate::shape::Shape::new(face_path)
.with_fill(self.fill_color);
self.created_shape_id = Some(face_shape.id);
if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) {
vector_layer.add_shape_to_keyframe(face_shape, self.time);
println!("DEBUG: Added filled shape to keyframe");
}
} else {
println!("Click point is not inside any face!");
}
println!("=== Paint Bucket Complete ===");
Ok(()) Ok(())
} }
fn rollback(&mut self, document: &mut Document) -> Result<(), String> { fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(shape_id) = self.created_shape_id { let face_id = self.hit_face.ok_or("No face to undo")?;
if let Some(AnyLayer::Vector(vector_layer)) = document.get_layer_mut(&self.layer_id) {
vector_layer.remove_shape_from_keyframe(&shape_id, self.time); let layer = document
} .get_layer_mut(&self.layer_id)
self.created_shape_id = None; .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()),
};
let keyframe = vl.ensure_keyframe_at(self.time);
let dcel = &mut keyframe.dcel;
dcel.face_mut(face_id).fill_color = self.old_fill_color.take().unwrap_or(None);
Ok(()) Ok(())
} }
@ -154,139 +105,3 @@ impl Action for PaintBucketAction {
"Paint bucket fill".to_string() "Paint bucket fill".to_string()
} }
} }
/// Extract curves from all shapes in the keyframe at the given time
fn extract_curves_from_keyframe(
document: &Document,
layer_id: &Uuid,
time: f64,
) -> Vec<CurveSegment> {
let mut all_curves = Vec::new();
let layer = match document.get_layer(layer_id) {
Some(l) => l,
None => return all_curves,
};
if let AnyLayer::Vector(vector_layer) = layer {
let shapes = vector_layer.shapes_at_time(time);
println!("Extracting curves from {} shapes in keyframe", shapes.len());
for (shape_idx, shape) in shapes.iter().enumerate() {
let transform_affine = shape.transform.to_affine();
let path = shape.path();
let mut current_point = Point::ZERO;
let mut subpath_start = Point::ZERO;
let mut segment_index = 0;
let mut curves_in_shape = 0;
for element in path.elements() {
if let Some(mut segment) = CurveSegment::from_path_element(
shape.id.as_u128() as usize,
segment_index,
element,
current_point,
) {
for control_point in &mut segment.control_points {
*control_point = transform_affine * (*control_point);
}
all_curves.push(segment);
segment_index += 1;
curves_in_shape += 1;
}
match element {
vello::kurbo::PathEl::MoveTo(p) => {
current_point = *p;
subpath_start = *p;
}
vello::kurbo::PathEl::LineTo(p) => current_point = *p,
vello::kurbo::PathEl::QuadTo(_, p) => current_point = *p,
vello::kurbo::PathEl::CurveTo(_, _, p) => current_point = *p,
vello::kurbo::PathEl::ClosePath => {
if let Some(mut segment) = CurveSegment::from_path_element(
shape.id.as_u128() as usize,
segment_index,
&vello::kurbo::PathEl::LineTo(subpath_start),
current_point,
) {
for control_point in &mut segment.control_points {
*control_point = transform_affine * (*control_point);
}
all_curves.push(segment);
segment_index += 1;
curves_in_shape += 1;
}
current_point = subpath_start;
}
}
}
println!(" Shape {}: Extracted {} curves", shape_idx, curves_in_shape);
}
}
all_curves
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::shape::Shape;
use vello::kurbo::{Rect, Shape as KurboShape};
#[test]
fn test_paint_bucket_action_basic() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Layer 1");
// Create a simple rectangle shape (boundary for fill)
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let path = rect.to_path(0.1);
let shape = Shape::new(path);
layer.add_shape_to_keyframe(shape, 0.0);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create and execute paint bucket action
let mut action = PaintBucketAction::new(
layer_id,
0.0,
Point::new(50.0, 50.0),
ShapeColor::rgb(255, 0, 0),
2.0,
GapHandlingMode::BridgeSegment,
);
action.execute(&mut document).unwrap();
// Verify a filled shape was created (or existing shape was recolored)
if let Some(AnyLayer::Vector(layer)) = document.get_layer(&layer_id) {
assert!(layer.shapes_at_time(0.0).len() >= 1);
} else {
panic!("Layer not found or not a vector layer");
}
// Test rollback
action.rollback(&mut document).unwrap();
}
#[test]
fn test_paint_bucket_action_description() {
let action = PaintBucketAction::new(
Uuid::new_v4(),
0.0,
Point::ZERO,
ShapeColor::rgb(0, 0, 255),
2.0,
GapHandlingMode::BridgeSegment,
);
assert_eq!(action.description(), "Paint bucket fill");
}
}

View File

@ -1,119 +1,42 @@
//! Region split action //! Region split action — STUB: needs DCEL rewrite
//!
//! Commits a temporary region-based shape split permanently.
//! Replaces original shapes with their inside and outside portions.
use crate::action::Action; use crate::action::Action;
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer;
use crate::shape::Shape; use crate::shape::Shape;
use uuid::Uuid; use uuid::Uuid;
use vello::kurbo::BezPath; use vello::kurbo::BezPath;
/// One shape split entry for the action /// Action that commits a region split
#[derive(Clone, Debug)] /// TODO: Rewrite for DCEL edge splitting
struct SplitEntry {
/// The original shape (for rollback)
original_shape: Shape,
/// The inside portion shape
inside_shape: Shape,
/// The outside portion shape
outside_shape: Shape,
}
/// Action that commits a region split — replacing original shapes with
/// their inside and outside portions.
pub struct RegionSplitAction { pub struct RegionSplitAction {
layer_id: Uuid, layer_id: Uuid,
time: f64, time: f64,
splits: Vec<SplitEntry>,
} }
impl RegionSplitAction { impl RegionSplitAction {
/// Create a new region split action.
///
/// Each tuple is (original_shape, inside_path, inside_id, outside_path, outside_id).
pub fn new( pub fn new(
layer_id: Uuid, layer_id: Uuid,
time: f64, time: f64,
split_data: Vec<(Shape, BezPath, Uuid, BezPath, Uuid)>, _split_data: Vec<(Shape, BezPath, Uuid, BezPath, Uuid)>,
) -> Self { ) -> Self {
let splits = split_data
.into_iter()
.map(|(original, inside_path, inside_id, outside_path, outside_id)| {
let mut inside_shape = original.clone();
inside_shape.id = inside_id;
inside_shape.versions[0].path = inside_path;
let mut outside_shape = original.clone();
outside_shape.id = outside_id;
outside_shape.versions[0].path = outside_path;
SplitEntry {
original_shape: original,
inside_shape,
outside_shape,
}
})
.collect();
Self { Self {
layer_id, layer_id,
time, time,
splits,
} }
} }
} }
impl Action for RegionSplitAction { impl Action for RegionSplitAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> { fn execute(&mut self, _document: &mut Document) -> Result<(), String> {
let layer = document let _ = (&self.layer_id, self.time);
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return Err("Not a vector layer".to_string()),
};
for split in &self.splits {
// Remove original
vector_layer.remove_shape_from_keyframe(&split.original_shape.id, self.time);
// Add inside and outside portions
vector_layer.add_shape_to_keyframe(split.inside_shape.clone(), self.time);
vector_layer.add_shape_to_keyframe(split.outside_shape.clone(), self.time);
}
Ok(()) Ok(())
} }
fn rollback(&mut self, document: &mut Document) -> Result<(), String> { 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))?;
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return Err("Not a vector layer".to_string()),
};
for split in &self.splits {
// Remove inside and outside portions
vector_layer.remove_shape_from_keyframe(&split.inside_shape.id, self.time);
vector_layer.remove_shape_from_keyframe(&split.outside_shape.id, self.time);
// Restore original
vector_layer.add_shape_to_keyframe(split.original_shape.clone(), self.time);
}
Ok(()) Ok(())
} }
fn description(&self) -> String { fn description(&self) -> String {
let count = self.splits.len(); "Region split".to_string()
if count == 1 {
"Region split shape".to_string()
} else {
format!("Region split {} shapes", count)
}
} }
} }

View File

@ -1,118 +0,0 @@
//! Remove shapes action
//!
//! Handles removing shapes from a vector layer's keyframe (for cut/delete).
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::shape::Shape;
use uuid::Uuid;
/// Action that removes shapes from a vector layer's keyframe
pub struct RemoveShapesAction {
/// Layer ID containing the shapes
layer_id: Uuid,
/// Shape IDs to remove
shape_ids: Vec<Uuid>,
/// Time of the keyframe
time: f64,
/// Saved shapes for rollback
saved_shapes: Vec<Shape>,
}
impl RemoveShapesAction {
pub fn new(layer_id: Uuid, shape_ids: Vec<Uuid>, time: f64) -> Self {
Self {
layer_id,
shape_ids,
time,
saved_shapes: Vec::new(),
}
}
}
impl Action for RemoveShapesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
self.saved_shapes.clear();
let layer = document
.get_layer_mut(&self.layer_id)
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return Err("Not a vector layer".to_string()),
};
for shape_id in &self.shape_ids {
if let Some(shape) = vector_layer.remove_shape_from_keyframe(shape_id, self.time) {
self.saved_shapes.push(shape);
}
}
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))?;
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return Err("Not a vector layer".to_string()),
};
for shape in self.saved_shapes.drain(..) {
vector_layer.add_shape_to_keyframe(shape, self.time);
}
Ok(())
}
fn description(&self) -> String {
let count = self.shape_ids.len();
if count == 1 {
"Delete shape".to_string()
} else {
format!("Delete {} shapes", count)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::shape::Shape;
use vello::kurbo::BezPath;
#[test]
fn test_remove_shapes() {
let mut document = Document::new("Test");
let mut vector_layer = VectorLayer::new("Layer 1");
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((100.0, 100.0));
let shape = Shape::new(path);
let shape_id = shape.id;
vector_layer.add_shape_to_keyframe(shape, 0.0);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(vector_layer));
let mut action = RemoveShapesAction::new(layer_id, vec![shape_id], 0.0);
action.execute(&mut document).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
assert!(vl.shapes_at_time(0.0).is_empty());
}
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);
}
}
}

View File

@ -1,12 +1,7 @@
//! Set shape instance properties action //! Set shape instance properties action — STUB: needs DCEL rewrite
//!
//! Handles changing individual properties on shapes (position, rotation, scale, etc.)
//! with undo/redo support. In the keyframe model, these operate on Shape's transform
//! and opacity fields within the active keyframe.
use crate::action::Action; use crate::action::Action;
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid; use uuid::Uuid;
/// Individual property change for a shape instance /// Individual property change for a shape instance
@ -23,8 +18,7 @@ pub enum InstancePropertyChange {
} }
impl InstancePropertyChange { impl InstancePropertyChange {
/// Extract the f64 value from any variant pub fn value(&self) -> f64 {
fn value(&self) -> f64 {
match self { match self {
InstancePropertyChange::X(v) => *v, InstancePropertyChange::X(v) => *v,
InstancePropertyChange::Y(v) => *v, InstancePropertyChange::Y(v) => *v,
@ -39,22 +33,15 @@ impl InstancePropertyChange {
} }
/// Action that sets a property on one or more shapes in a keyframe /// Action that sets a property on one or more shapes in a keyframe
/// TODO: Replace with DCEL-based property changes
pub struct SetInstancePropertiesAction { pub struct SetInstancePropertiesAction {
/// Layer containing the shapes
layer_id: Uuid, layer_id: Uuid,
/// Time of the keyframe
time: f64, time: f64,
/// Shape IDs to modify and their old values
shape_changes: Vec<(Uuid, Option<f64>)>, shape_changes: Vec<(Uuid, Option<f64>)>,
/// Property to change
property: InstancePropertyChange, property: InstancePropertyChange,
} }
impl SetInstancePropertiesAction { impl SetInstancePropertiesAction {
/// Create a new action to set a property on a single shape
pub fn new(layer_id: Uuid, time: f64, shape_id: Uuid, property: InstancePropertyChange) -> Self { pub fn new(layer_id: Uuid, time: f64, shape_id: Uuid, property: InstancePropertyChange) -> Self {
Self { Self {
layer_id, layer_id,
@ -64,7 +51,6 @@ impl SetInstancePropertiesAction {
} }
} }
/// Create a new action to set a property on multiple shapes
pub fn new_batch(layer_id: Uuid, time: f64, shape_ids: Vec<Uuid>, property: InstancePropertyChange) -> Self { pub fn new_batch(layer_id: Uuid, time: f64, shape_ids: Vec<Uuid>, property: InstancePropertyChange) -> Self {
Self { Self {
layer_id, layer_id,
@ -73,76 +59,15 @@ impl SetInstancePropertiesAction {
property, property,
} }
} }
fn get_value_from_shape(shape: &crate::shape::Shape, property: &InstancePropertyChange) -> f64 {
match property {
InstancePropertyChange::X(_) => shape.transform.x,
InstancePropertyChange::Y(_) => shape.transform.y,
InstancePropertyChange::Rotation(_) => shape.transform.rotation,
InstancePropertyChange::ScaleX(_) => shape.transform.scale_x,
InstancePropertyChange::ScaleY(_) => shape.transform.scale_y,
InstancePropertyChange::SkewX(_) => shape.transform.skew_x,
InstancePropertyChange::SkewY(_) => shape.transform.skew_y,
InstancePropertyChange::Opacity(_) => shape.opacity,
}
}
fn set_value_on_shape(shape: &mut crate::shape::Shape, property: &InstancePropertyChange, value: f64) {
match property {
InstancePropertyChange::X(_) => shape.transform.x = value,
InstancePropertyChange::Y(_) => shape.transform.y = value,
InstancePropertyChange::Rotation(_) => shape.transform.rotation = value,
InstancePropertyChange::ScaleX(_) => shape.transform.scale_x = value,
InstancePropertyChange::ScaleY(_) => shape.transform.scale_y = value,
InstancePropertyChange::SkewX(_) => shape.transform.skew_x = value,
InstancePropertyChange::SkewY(_) => shape.transform.skew_y = value,
InstancePropertyChange::Opacity(_) => shape.opacity = value,
}
}
} }
impl Action for SetInstancePropertiesAction { impl Action for SetInstancePropertiesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> { fn execute(&mut self, _document: &mut Document) -> Result<(), String> {
let new_value = self.property.value(); let _ = (&self.layer_id, self.time, &self.shape_changes, &self.property);
// First pass: collect old values
if let Some(layer) = document.get_layer(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
for (shape_id, old_value) in &mut self.shape_changes {
if old_value.is_none() {
if let Some(shape) = vector_layer.get_shape_in_keyframe(shape_id, self.time) {
*old_value = Some(Self::get_value_from_shape(shape, &self.property));
}
}
}
}
}
// Second pass: apply new values
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer {
for (shape_id, _) in &self.shape_changes {
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
Self::set_value_on_shape(shape, &self.property, new_value);
}
}
}
}
Ok(()) Ok(())
} }
fn rollback(&mut self, document: &mut Document) -> Result<(), String> { fn rollback(&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 {
for (shape_id, old_value) in &self.shape_changes {
if let Some(value) = old_value {
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
Self::set_value_on_shape(shape, &self.property, *value);
}
}
}
}
}
Ok(()) Ok(())
} }
@ -165,144 +90,3 @@ impl Action for SetInstancePropertiesAction {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::shape::Shape;
use vello::kurbo::BezPath;
fn make_shape_at(x: f64, y: f64) -> Shape {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((10.0, 10.0));
Shape::new(path).with_position(x, y)
}
#[test]
fn test_set_x_position() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape = make_shape_at(10.0, 20.0);
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 mut action = SetInstancePropertiesAction::new(
layer_id,
0.0,
shape_id,
InstancePropertyChange::X(50.0),
);
action.execute(&mut document).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(s.transform.x, 50.0);
assert_eq!(s.transform.y, 20.0);
}
action.rollback(&mut document).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(s.transform.x, 10.0);
}
}
#[test]
fn test_set_opacity() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape = make_shape_at(0.0, 0.0);
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 mut action = SetInstancePropertiesAction::new(
layer_id,
0.0,
shape_id,
InstancePropertyChange::Opacity(0.5),
);
action.execute(&mut document).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(s.opacity, 0.5);
}
action.rollback(&mut document).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(s.opacity, 1.0);
}
}
#[test]
fn test_batch_set_scale() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape1 = make_shape_at(0.0, 0.0);
let shape1_id = shape1.id;
let shape2 = make_shape_at(10.0, 10.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 mut action = SetInstancePropertiesAction::new_batch(
layer_id,
0.0,
vec![shape1_id, shape2_id],
InstancePropertyChange::ScaleX(2.0),
);
action.execute(&mut document).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
assert_eq!(vl.get_shape_in_keyframe(&shape1_id, 0.0).unwrap().transform.scale_x, 2.0);
assert_eq!(vl.get_shape_in_keyframe(&shape2_id, 0.0).unwrap().transform.scale_x, 2.0);
}
action.rollback(&mut document).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
assert_eq!(vl.get_shape_in_keyframe(&shape1_id, 0.0).unwrap().transform.scale_x, 1.0);
assert_eq!(vl.get_shape_in_keyframe(&shape2_id, 0.0).unwrap().transform.scale_x, 1.0);
}
}
#[test]
fn test_description() {
let layer_id = Uuid::new_v4();
let shape_id = Uuid::new_v4();
let action1 = SetInstancePropertiesAction::new(
layer_id, 0.0, shape_id,
InstancePropertyChange::X(0.0),
);
assert_eq!(action1.description(), "Set X position");
let action2 = SetInstancePropertiesAction::new(
layer_id, 0.0, shape_id,
InstancePropertyChange::Rotation(0.0),
);
assert_eq!(action2.description(), "Set rotation");
let action3 = SetInstancePropertiesAction::new_batch(
layer_id, 0.0,
vec![Uuid::new_v4(), Uuid::new_v4()],
InstancePropertyChange::Opacity(1.0),
);
assert_eq!(action3.description(), "Set opacity on 2 shapes");
}
}

View File

@ -1,251 +1,170 @@
//! Set shape properties action //! Set shape properties action — operates on DCEL edge/face IDs.
//!
//! Handles changing shape properties (fill color, stroke color, stroke width)
//! with undo/redo support.
use crate::action::Action; use crate::action::Action;
use crate::dcel::{EdgeId, FaceId};
use crate::document::Document; use crate::document::Document;
use crate::layer::AnyLayer; use crate::layer::AnyLayer;
use crate::shape::{ShapeColor, StrokeStyle}; use crate::shape::ShapeColor;
use uuid::Uuid; use uuid::Uuid;
/// Property change for a shape /// Action that sets fill/stroke properties on DCEL elements.
#[derive(Clone, Debug)] pub struct SetShapePropertiesAction {
pub enum ShapePropertyChange { layer_id: Uuid,
FillColor(Option<ShapeColor>), time: f64,
StrokeColor(Option<ShapeColor>), change: PropertyChange,
StrokeWidth(f64), old_edge_values: Vec<(EdgeId, Option<ShapeColor>, Option<f64>)>,
old_face_values: Vec<(FaceId, Option<ShapeColor>)>,
} }
/// Action that sets properties on a shape enum PropertyChange {
pub struct SetShapePropertiesAction { FillColor {
/// Layer containing the shape face_ids: Vec<FaceId>,
layer_id: Uuid, color: Option<ShapeColor>,
},
/// Shape to modify StrokeColor {
shape_id: Uuid, edge_ids: Vec<EdgeId>,
color: Option<ShapeColor>,
/// Time of the keyframe containing the shape },
time: f64, StrokeWidth {
edge_ids: Vec<EdgeId>,
/// New property value width: f64,
new_value: ShapePropertyChange, },
/// Old property value (stored after first execution)
old_value: Option<ShapePropertyChange>,
} }
impl SetShapePropertiesAction { impl SetShapePropertiesAction {
/// Create a new action to set a property on a shape pub fn set_fill_color(
pub fn new(layer_id: Uuid, shape_id: Uuid, time: f64, new_value: ShapePropertyChange) -> Self { layer_id: Uuid,
time: f64,
face_ids: Vec<FaceId>,
color: Option<ShapeColor>,
) -> Self {
Self { Self {
layer_id, layer_id,
shape_id,
time, time,
new_value, change: PropertyChange::FillColor { face_ids, color },
old_value: None, old_edge_values: Vec::new(),
old_face_values: Vec::new(),
} }
} }
/// Create action to set fill color pub fn set_stroke_color(
pub fn set_fill_color(layer_id: Uuid, shape_id: Uuid, time: f64, color: Option<ShapeColor>) -> Self { layer_id: Uuid,
Self::new(layer_id, shape_id, time, ShapePropertyChange::FillColor(color)) time: f64,
edge_ids: Vec<EdgeId>,
color: Option<ShapeColor>,
) -> Self {
Self {
layer_id,
time,
change: PropertyChange::StrokeColor { edge_ids, color },
old_edge_values: Vec::new(),
old_face_values: Vec::new(),
}
} }
/// Create action to set stroke color pub fn set_stroke_width(
pub fn set_stroke_color(layer_id: Uuid, shape_id: Uuid, time: f64, color: Option<ShapeColor>) -> Self { layer_id: Uuid,
Self::new(layer_id, shape_id, time, ShapePropertyChange::StrokeColor(color)) time: f64,
edge_ids: Vec<EdgeId>,
width: f64,
) -> Self {
Self {
layer_id,
time,
change: PropertyChange::StrokeWidth { edge_ids, width },
old_edge_values: Vec::new(),
old_face_values: Vec::new(),
}
} }
/// Create action to set stroke width fn get_dcel_mut<'a>(
pub fn set_stroke_width(layer_id: Uuid, shape_id: Uuid, time: f64, width: f64) -> Self { document: &'a mut Document,
Self::new(layer_id, shape_id, time, ShapePropertyChange::StrokeWidth(width)) layer_id: &Uuid,
} time: f64,
} ) -> Result<&'a mut crate::dcel::Dcel, String> {
let layer = document
fn apply_property(shape: &mut crate::shape::Shape, change: &ShapePropertyChange) { .get_layer_mut(layer_id)
match change { .ok_or_else(|| format!("Layer {} not found", layer_id))?;
ShapePropertyChange::FillColor(color) => { let vl = match layer {
shape.fill_color = *color; AnyLayer::Vector(vl) => vl,
} _ => return Err("Not a vector layer".to_string()),
ShapePropertyChange::StrokeColor(color) => { };
shape.stroke_color = *color; vl.dcel_at_time_mut(time)
} .ok_or_else(|| format!("No keyframe at time {}", time))
ShapePropertyChange::StrokeWidth(width) => {
if let Some(ref mut style) = shape.stroke_style {
style.width = *width;
} else {
shape.stroke_style = Some(StrokeStyle {
width: *width,
..Default::default()
});
}
}
} }
} }
impl Action for SetShapePropertiesAction { impl Action for SetShapePropertiesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> { fn execute(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(layer) = document.get_layer_mut(&self.layer_id) { let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?;
if let AnyLayer::Vector(vector_layer) = layer {
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(&self.shape_id, self.time) {
// Store old value if not already stored
if self.old_value.is_none() {
self.old_value = Some(match &self.new_value {
ShapePropertyChange::FillColor(_) => {
ShapePropertyChange::FillColor(shape.fill_color)
}
ShapePropertyChange::StrokeColor(_) => {
ShapePropertyChange::StrokeColor(shape.stroke_color)
}
ShapePropertyChange::StrokeWidth(_) => {
let width = shape
.stroke_style
.as_ref()
.map(|s| s.width)
.unwrap_or(1.0);
ShapePropertyChange::StrokeWidth(width)
}
});
}
apply_property(shape, &self.new_value); match &self.change {
PropertyChange::FillColor { face_ids, color } => {
self.old_face_values.clear();
for &fid in face_ids {
let face = dcel.face(fid);
self.old_face_values.push((fid, face.fill_color));
dcel.face_mut(fid).fill_color = *color;
}
}
PropertyChange::StrokeColor { edge_ids, color } => {
self.old_edge_values.clear();
for &eid in edge_ids {
let edge = dcel.edge(eid);
let old_width = edge.stroke_style.as_ref().map(|s| s.width);
self.old_edge_values.push((eid, edge.stroke_color, old_width));
dcel.edge_mut(eid).stroke_color = *color;
}
}
PropertyChange::StrokeWidth { edge_ids, width } => {
self.old_edge_values.clear();
for &eid in edge_ids {
let edge = dcel.edge(eid);
let old_width = edge.stroke_style.as_ref().map(|s| s.width);
self.old_edge_values.push((eid, edge.stroke_color, old_width));
if let Some(ref mut style) = dcel.edge_mut(eid).stroke_style {
style.width = *width;
}
} }
} }
} }
Ok(()) Ok(())
} }
fn rollback(&mut self, document: &mut Document) -> Result<(), String> { fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(old_value) = &self.old_value.clone() { let dcel = Self::get_dcel_mut(document, &self.layer_id, self.time)?;
if let Some(layer) = document.get_layer_mut(&self.layer_id) {
if let AnyLayer::Vector(vector_layer) = layer { match &self.change {
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(&self.shape_id, self.time) { PropertyChange::FillColor { .. } => {
apply_property(shape, old_value); for &(fid, old_color) in &self.old_face_values {
dcel.face_mut(fid).fill_color = old_color;
}
}
PropertyChange::StrokeColor { .. } => {
for &(eid, old_color, _) in &self.old_edge_values {
dcel.edge_mut(eid).stroke_color = old_color;
}
}
PropertyChange::StrokeWidth { .. } => {
for &(eid, _, old_width) in &self.old_edge_values {
if let Some(w) = old_width {
if let Some(ref mut style) = dcel.edge_mut(eid).stroke_style {
style.width = w;
}
} }
} }
} }
} }
Ok(()) Ok(())
} }
fn description(&self) -> String { fn description(&self) -> String {
match &self.new_value { match &self.change {
ShapePropertyChange::FillColor(_) => "Set fill color".to_string(), PropertyChange::FillColor { .. } => "Set fill color".to_string(),
ShapePropertyChange::StrokeColor(_) => "Set stroke color".to_string(), PropertyChange::StrokeColor { .. } => "Set stroke color".to_string(),
ShapePropertyChange::StrokeWidth(_) => "Set stroke width".to_string(), PropertyChange::StrokeWidth { .. } => "Set stroke width".to_string(),
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::shape::Shape;
use vello::kurbo::BezPath;
fn create_test_shape() -> Shape {
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();
let mut shape = Shape::new(path);
shape.fill_color = Some(ShapeColor::rgb(255, 0, 0));
shape.stroke_color = Some(ShapeColor::rgb(0, 0, 0));
shape.stroke_style = Some(StrokeStyle {
width: 2.0,
..Default::default()
});
shape
}
#[test]
fn test_set_fill_color() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape = create_test_shape();
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 color
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(shape.fill_color.unwrap().r, 255);
}
// Create and execute action
let new_color = Some(ShapeColor::rgb(0, 255, 0));
let mut action = SetShapePropertiesAction::set_fill_color(layer_id, shape_id, 0.0, new_color);
action.execute(&mut document).unwrap();
// Verify color changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(shape.fill_color.unwrap().g, 255);
}
// 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();
assert_eq!(shape.fill_color.unwrap().r, 255);
}
}
#[test]
fn test_set_stroke_width() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let shape = create_test_shape();
let shape_id = shape.id;
layer.add_shape_to_keyframe(shape, 0.0);
let layer_id = document.root_mut().add_child(AnyLayer::Vector(layer));
// Create and execute action
let mut action = SetShapePropertiesAction::set_stroke_width(layer_id, shape_id, 0.0, 5.0);
action.execute(&mut document).unwrap();
// Verify width changed
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let shape = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(shape.stroke_style.as_ref().unwrap().width, 5.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();
assert_eq!(shape.stroke_style.as_ref().unwrap().width, 2.0);
}
}
#[test]
fn test_description() {
let layer_id = Uuid::new_v4();
let shape_id = Uuid::new_v4();
let action1 =
SetShapePropertiesAction::set_fill_color(layer_id, shape_id, 0.0, Some(ShapeColor::rgb(0, 0, 0)));
assert_eq!(action1.description(), "Set fill color");
let action2 =
SetShapePropertiesAction::set_stroke_color(layer_id, shape_id, 0.0, Some(ShapeColor::rgb(0, 0, 0)));
assert_eq!(action2.description(), "Set stroke color");
let action3 = SetShapePropertiesAction::set_stroke_width(layer_id, shape_id, 0.0, 3.0);
assert_eq!(action3.description(), "Set stroke width");
}
}

View File

@ -1,109 +0,0 @@
//! Transform shapes action
//!
//! Applies scale, rotation, and other transformations to shapes in a keyframe.
use crate::action::Action;
use crate::document::Document;
use crate::layer::AnyLayer;
use crate::object::Transform;
use std::collections::HashMap;
use uuid::Uuid;
/// Action to transform multiple shapes in a keyframe
pub struct TransformShapeInstancesAction {
layer_id: Uuid,
time: f64,
/// Map of shape ID to (old transform, new transform)
shape_transforms: HashMap<Uuid, (Transform, Transform)>,
}
impl TransformShapeInstancesAction {
pub fn new(
layer_id: Uuid,
time: f64,
shape_transforms: HashMap<Uuid, (Transform, Transform)>,
) -> Self {
Self {
layer_id,
time,
shape_transforms,
}
}
}
impl Action for TransformShapeInstancesAction {
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 {
for (shape_id, (_old, new)) in &self.shape_transforms {
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
shape.transform = new.clone();
}
}
}
}
Ok(())
}
fn rollback(&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 {
for (shape_id, (old, _new)) in &self.shape_transforms {
if let Some(shape) = vector_layer.get_shape_in_keyframe_mut(shape_id, self.time) {
shape.transform = old.clone();
}
}
}
}
Ok(())
}
fn description(&self) -> String {
format!("Transform {} shape(s)", self.shape_transforms.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layer::VectorLayer;
use crate::shape::Shape;
use vello::kurbo::BezPath;
#[test]
fn test_transform_shape() {
let mut document = Document::new("Test");
let mut layer = VectorLayer::new("Test Layer");
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((100.0, 100.0));
let shape = Shape::new(path).with_position(10.0, 20.0);
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 old_transform = Transform::with_position(10.0, 20.0);
let new_transform = Transform::with_position(100.0, 200.0);
let mut transforms = HashMap::new();
transforms.insert(shape_id, (old_transform, new_transform));
let mut action = TransformShapeInstancesAction::new(layer_id, 0.0, transforms);
action.execute(&mut document).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(s.transform.x, 100.0);
assert_eq!(s.transform.y, 200.0);
}
action.rollback(&mut document).unwrap();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
let s = vl.get_shape_in_keyframe(&shape_id, 0.0).unwrap();
assert_eq!(s.transform.x, 10.0);
assert_eq!(s.transform.y, 20.0);
}
}
}

View File

@ -0,0 +1,44 @@
//! Beat/measure ↔ seconds conversion utilities
use crate::document::TimeSignature;
/// Position expressed as measure, beat, tick
#[derive(Debug, Clone, Copy)]
pub struct MeasurePosition {
pub measure: u32, // 1-indexed
pub beat: u32, // 1-indexed
pub tick: u32, // 0-999 (subdivision of beat)
}
/// Convert a time in seconds to a measure position
pub fn time_to_measure(time: f64, bpm: f64, time_sig: &TimeSignature) -> MeasurePosition {
let beats_per_second = bpm / 60.0;
let total_beats = (time * beats_per_second).max(0.0);
let beats_per_measure = time_sig.numerator as f64;
let measure = (total_beats / beats_per_measure).floor() as u32 + 1;
let beat = (total_beats.rem_euclid(beats_per_measure)).floor() as u32 + 1;
let tick = ((total_beats.rem_euclid(1.0)) * 1000.0).floor() as u32;
MeasurePosition { measure, beat, tick }
}
/// Convert a measure position to seconds
pub fn measure_to_time(pos: MeasurePosition, bpm: f64, time_sig: &TimeSignature) -> f64 {
let beats_per_measure = time_sig.numerator as f64;
let total_beats = (pos.measure as f64 - 1.0) * beats_per_measure
+ (pos.beat as f64 - 1.0)
+ (pos.tick as f64 / 1000.0);
let beats_per_second = bpm / 60.0;
total_beats / beats_per_second
}
/// Get the duration of one beat in seconds
pub fn beat_duration(bpm: f64) -> f64 {
60.0 / bpm
}
/// Get the duration of one measure in seconds
pub fn measure_duration(bpm: f64, time_sig: &TimeSignature) -> f64 {
beat_duration(bpm) * time_sig.numerator as f64
}

View File

@ -17,7 +17,7 @@ use crate::object::Transform;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use uuid::Uuid; use uuid::Uuid;
use vello::kurbo::{Rect, Shape as KurboShape}; use vello::kurbo::Rect;
/// Vector clip containing nested layers /// Vector clip containing nested layers
/// ///
@ -167,20 +167,19 @@ impl VectorClip {
for layer_node in self.layers.iter() { for layer_node in self.layers.iter() {
// Only process vector layers (skip other layer types) // Only process vector layers (skip other layer types)
if let AnyLayer::Vector(vector_layer) = &layer_node.data { if let AnyLayer::Vector(vector_layer) = &layer_node.data {
// Calculate bounds for all shapes in the active keyframe // Calculate bounds from DCEL edges
for shape in vector_layer.shapes_at_time(clip_time) { if let Some(dcel) = vector_layer.dcel_at_time(clip_time) {
// Get the local bounding box of the shape's path use kurbo::Shape as KurboShape;
let local_bbox = shape.path().bounding_box(); for edge in &dcel.edges {
if edge.deleted {
// Apply the shape's transform continue;
let shape_transform = shape.transform.to_affine(); }
let transformed_bbox = shape_transform.transform_rect_bbox(local_bbox); let edge_bbox = edge.curve.bounding_box();
combined_bounds = Some(match combined_bounds {
// Union with combined bounds None => edge_bbox,
combined_bounds = Some(match combined_bounds { Some(existing) => existing.union(edge_bbox),
None => transformed_bbox, });
Some(existing) => existing.union(transformed_bbox), }
});
} }
// Handle nested clip instances recursively // Handle nested clip instances recursively
@ -847,11 +846,13 @@ mod tests {
#[test] #[test]
fn test_audio_clip_midi() { fn test_audio_clip_midi() {
let events = vec![MidiEvent::note_on(0.0, 0, 60, 100)]; let clip = AudioClip::new_midi("Piano Melody", 1, 60.0);
let clip = AudioClip::new_midi("Piano Melody", 60.0, events.clone(), false);
assert_eq!(clip.name, "Piano Melody"); assert_eq!(clip.name, "Piano Melody");
assert_eq!(clip.duration, 60.0); assert_eq!(clip.duration, 60.0);
assert_eq!(clip.midi_events().map(|e| e.len()), Some(1)); match &clip.clip_type {
AudioClipType::Midi { midi_clip_id } => assert_eq!(*midi_clip_id, 1),
_ => panic!("Expected Midi clip type"),
}
} }
#[test] #[test]

View File

@ -74,8 +74,9 @@ fn find_intersections_recursive(
// Maximum recursion depth // Maximum recursion depth
const MAX_DEPTH: usize = 20; const MAX_DEPTH: usize = 20;
// Minimum parameter range (if smaller, we've found an intersection) // Pixel-space convergence threshold: stop subdividing when both
const MIN_RANGE: f64 = 0.001; // subsegments span less than this many pixels.
const PIXEL_TOL: f64 = 0.25;
// Get bounding boxes of current subsegments // Get bounding boxes of current subsegments
let bbox1 = curve1.bounding_box(); let bbox1 = curve1.bounding_box();
@ -90,16 +91,76 @@ fn find_intersections_recursive(
return; return;
} }
// If we've recursed deep enough or ranges are small enough, record intersection // Evaluate subsegment endpoints for convergence check and line-line solve
if depth >= MAX_DEPTH || let a0 = orig_curve1.eval(t1_start);
((t1_end - t1_start) < MIN_RANGE && (t2_end - t2_start) < MIN_RANGE) { let a1 = orig_curve1.eval(t1_end);
let t1 = (t1_start + t1_end) / 2.0; let b0 = orig_curve2.eval(t2_start);
let t2 = (t2_start + t2_end) / 2.0; let b1 = orig_curve2.eval(t2_end);
// Check convergence in pixel space: both subsegment spans must be
// below the tolerance. This ensures the linear approximation error
// is always well within the vertex snap threshold regardless of
// curve length.
let a_span = (a1 - a0).hypot();
let b_span = (b1 - b0).hypot();
if depth >= MAX_DEPTH || (a_span < PIXEL_TOL && b_span < PIXEL_TOL) {
let (t1, t2, point) = if let Some((s, u)) = line_line_intersect(a0, a1, b0, b1) {
let s = s.clamp(0.0, 1.0);
let u = u.clamp(0.0, 1.0);
let mut t1 = t1_start + s * (t1_end - t1_start);
let mut t2 = t2_start + u * (t2_end - t2_start);
// Newton refinement: converge t1, t2 so that
// curve1.eval(t1) == curve2.eval(t2) to sub-pixel accuracy.
// We solve F(t1,t2) = curve1(t1) - curve2(t2) = 0 via the
// Jacobian [d1, -d2] where d1/d2 are the curve tangents.
let t1_orig = t1;
let t2_orig = t2;
for _ in 0..8 {
let p1 = orig_curve1.eval(t1);
let p2 = orig_curve2.eval(t2);
let err = Point::new(p1.x - p2.x, p1.y - p2.y);
if err.x * err.x + err.y * err.y < 1e-6 {
break;
}
// Tangent vectors (derivative of cubic bezier)
let d1 = cubic_deriv(orig_curve1, t1);
let d2 = cubic_deriv(orig_curve2, t2);
// Solve [d1.x, -d2.x; d1.y, -d2.y] * [dt1; dt2] = -[err.x; err.y]
let det = d1.x * (-d2.y) - d1.y * (-d2.x);
if det.abs() < 1e-12 {
break; // tangents parallel, can't refine
}
let dt1 = (-d2.y * (-err.x) - (-d2.x) * (-err.y)) / det;
let dt2 = (d1.x * (-err.y) - d1.y * (-err.x)) / det;
t1 = (t1 + dt1).clamp(0.0, 1.0);
t2 = (t2 + dt2).clamp(0.0, 1.0);
}
// If Newton diverged far from the initial estimate, it may have
// jumped to a different crossing. Reject and fall back.
if (t1 - t1_orig).abs() > (t1_end - t1_start) * 2.0
|| (t2 - t2_orig).abs() > (t2_end - t2_start) * 2.0
{
t1 = t1_orig;
t2 = t2_orig;
}
let p1 = orig_curve1.eval(t1);
let p2 = orig_curve2.eval(t2);
(t1, t2, Point::new((p1.x + p2.x) * 0.5, (p1.y + p2.y) * 0.5))
} else {
// Lines are parallel/degenerate — fall back to midpoint
let t1 = (t1_start + t1_end) / 2.0;
let t2 = (t2_start + t2_end) / 2.0;
(t1, t2, orig_curve1.eval(t1))
};
intersections.push(Intersection { intersections.push(Intersection {
t1, t1,
t2: Some(t2), t2: Some(t2),
point: orig_curve1.eval(t1), point,
}); });
return; return;
} }
@ -252,21 +313,100 @@ fn refine_self_intersection(curve: &CubicBez, mut t1: f64, mut t2: f64) -> (f64,
(t1.clamp(0.0, 1.0), t2.clamp(0.0, 1.0)) (t1.clamp(0.0, 1.0), t2.clamp(0.0, 1.0))
} }
/// Remove duplicate intersections within a tolerance /// Remove duplicate intersections by clustering on parameter proximity.
fn dedup_intersections(intersections: &mut Vec<Intersection>, tolerance: f64) { ///
let mut i = 0; /// Raw hits from subdivision can produce chains of near-duplicates spaced
while i < intersections.len() { /// just over the spatial tolerance (e.g. 4 hits at 1.02 px apart for a
let mut j = i + 1; /// single crossing of shallow-angle curves). Pairwise spatial dedup fails
while j < intersections.len() { /// on these chains. Instead, we sort by t1, cluster consecutive hits whose
let dist = (intersections[i].point - intersections[j].point).hypot(); /// t1 values are within `param_tol`, and keep the median of each cluster.
if dist < tolerance { fn dedup_intersections(intersections: &mut Vec<Intersection>, _tolerance: f64) {
intersections.remove(j); if intersections.is_empty() {
} else { return;
j += 1;
}
}
i += 1;
} }
const PARAM_TOL: f64 = 0.05;
// Sort by t1 (primary) then t2 (secondary)
intersections.sort_by(|a, b| {
a.t1.partial_cmp(&b.t1)
.unwrap()
.then_with(|| {
let at2 = a.t2.unwrap_or(0.0);
let bt2 = b.t2.unwrap_or(0.0);
at2.partial_cmp(&bt2).unwrap()
})
});
// Cluster consecutive intersections that are close in both t1 and t2
let mut clusters: Vec<Vec<usize>> = Vec::new();
let mut current_cluster = vec![0usize];
for i in 1..intersections.len() {
let prev = &intersections[*current_cluster.last().unwrap()];
let curr = &intersections[i];
let t1_close = (curr.t1 - prev.t1).abs() < PARAM_TOL;
let t2_close = match (curr.t2, prev.t2) {
(Some(a), Some(b)) => (a - b).abs() < PARAM_TOL,
_ => true,
};
if t1_close && t2_close {
current_cluster.push(i);
} else {
clusters.push(std::mem::take(&mut current_cluster));
current_cluster = vec![i];
}
}
clusters.push(current_cluster);
// Keep the median of each cluster
let mut result = Vec::with_capacity(clusters.len());
for cluster in &clusters {
let median_idx = cluster[cluster.len() / 2];
result.push(intersections[median_idx].clone());
}
*intersections = result;
}
/// Derivative (tangent vector) of a cubic Bezier at parameter t.
///
/// B'(t) = 3[(1-t)²(P1-P0) + 2(1-t)t(P2-P1) + t²(P3-P2)]
fn cubic_deriv(c: &CubicBez, t: f64) -> Point {
let u = 1.0 - t;
let d0 = Point::new(c.p1.x - c.p0.x, c.p1.y - c.p0.y);
let d1 = Point::new(c.p2.x - c.p1.x, c.p2.y - c.p1.y);
let d2 = Point::new(c.p3.x - c.p2.x, c.p3.y - c.p2.y);
Point::new(
3.0 * (u * u * d0.x + 2.0 * u * t * d1.x + t * t * d2.x),
3.0 * (u * u * d0.y + 2.0 * u * t * d1.y + t * t * d2.y),
)
}
/// 2D line-line intersection.
///
/// Given line segment A (a0→a1) and line segment B (b0→b1),
/// returns `Some((s, u))` where `s` is the parameter on A and
/// `u` is the parameter on B at the intersection point.
/// Returns `None` if the lines are parallel or degenerate.
fn line_line_intersect(a0: Point, a1: Point, b0: Point, b1: Point) -> Option<(f64, f64)> {
let dx_a = a1.x - a0.x;
let dy_a = a1.y - a0.y;
let dx_b = b1.x - b0.x;
let dy_b = b1.y - b0.y;
let denom = dx_a * dy_b - dy_a * dx_b;
if denom.abs() < 1e-12 {
return None; // parallel or degenerate
}
let dx_ab = b0.x - a0.x;
let dy_ab = b0.y - a0.y;
let s = (dx_ab * dy_b - dy_ab * dx_b) / denom;
let u = (dx_ab * dy_a - dy_ab * dx_a) / denom;
Some((s, u))
} }
#[cfg(test)] #[cfg(test)]

File diff suppressed because it is too large Load Diff

View File

@ -70,6 +70,21 @@ impl Default for GraphicsObject {
} }
} }
/// Musical time signature
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TimeSignature {
pub numerator: u32, // beats per measure (e.g., 4)
pub denominator: u32, // beat unit (e.g., 4 = quarter note)
}
impl Default for TimeSignature {
fn default() -> Self {
Self { numerator: 4, denominator: 4 }
}
}
fn default_bpm() -> f64 { 120.0 }
/// Asset category for folder tree access /// Asset category for folder tree access
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssetCategory { pub enum AssetCategory {
@ -101,6 +116,14 @@ pub struct Document {
/// Framerate (frames per second) /// Framerate (frames per second)
pub framerate: f64, pub framerate: f64,
/// Tempo in beats per minute
#[serde(default = "default_bpm")]
pub bpm: f64,
/// Time signature
#[serde(default)]
pub time_signature: TimeSignature,
/// Duration in seconds /// Duration in seconds
pub duration: f64, pub duration: f64,
@ -182,6 +205,8 @@ impl Default for Document {
width: 1920.0, width: 1920.0,
height: 1080.0, height: 1080.0,
framerate: 60.0, framerate: 60.0,
bpm: 120.0,
time_signature: TimeSignature::default(),
duration: 10.0, duration: 10.0,
root: GraphicsObject::default(), root: GraphicsObject::default(),
vector_clips: HashMap::new(), vector_clips: HashMap::new(),

View File

@ -1,11 +1,11 @@
//! Hit testing for selection and interaction //! Hit testing for selection and interaction
//! //!
//! Provides functions for testing if points or rectangles intersect with //! Provides functions for testing if points or rectangles intersect with
//! shapes and objects, taking into account transform hierarchies. //! DCEL elements and clip instances, taking into account transform hierarchies.
use crate::clip::ClipInstance; use crate::clip::ClipInstance;
use crate::dcel::{VertexId, EdgeId, FaceId};
use crate::layer::VectorLayer; use crate::layer::VectorLayer;
use crate::region_select;
use crate::shape::Shape; use crate::shape::Shape;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
@ -14,15 +14,25 @@ use vello::kurbo::{Affine, BezPath, Point, Rect, Shape as KurboShape};
/// Result of a hit test operation /// Result of a hit test operation
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum HitResult { pub enum HitResult {
/// Hit a shape instance /// Hit a DCEL edge (stroke)
ShapeInstance(Uuid), Edge(EdgeId),
/// Hit a DCEL face (fill)
Face(FaceId),
/// Hit a clip instance /// Hit a clip instance
ClipInstance(Uuid), ClipInstance(Uuid),
} }
/// Hit test a layer at a specific point /// Result of a DCEL-only hit test (no clip instances)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DcelHitResult {
Edge(EdgeId),
Face(FaceId),
}
/// Hit test a layer at a specific point, returning edge or face hits.
/// ///
/// Tests shapes in the active keyframe in reverse order (front to back) and returns the first hit. /// Tests DCEL edges (strokes) and faces (fills) in the active keyframe.
/// Edge hits take priority over face hits.
/// ///
/// # Arguments /// # Arguments
/// ///
@ -34,20 +44,66 @@ pub enum HitResult {
/// ///
/// # Returns /// # Returns
/// ///
/// The UUID of the first shape hit, or None if no hit /// The first DCEL element hit, or None if no hit
pub fn hit_test_layer( pub fn hit_test_layer(
layer: &VectorLayer, layer: &VectorLayer,
time: f64, time: f64,
point: Point, point: Point,
tolerance: f64, tolerance: f64,
parent_transform: Affine, parent_transform: Affine,
) -> Option<Uuid> { ) -> Option<DcelHitResult> {
// Test shapes in reverse order (front to back for hit testing) let dcel = layer.dcel_at_time(time)?;
for shape in layer.shapes_at_time(time).iter().rev() {
let combined_transform = parent_transform * shape.transform.to_affine();
if hit_test_shape(shape, point, tolerance, combined_transform) { // Transform point to local space
return Some(shape.id); let local_point = parent_transform.inverse() * point;
// 1. Check edges (strokes) — priority over faces
let mut best_edge: Option<(EdgeId, f64)> = None;
for (i, edge) in dcel.edges.iter().enumerate() {
if edge.deleted {
continue;
}
// Only hit-test edges that have a visible stroke
if edge.stroke_color.is_none() && edge.stroke_style.is_none() {
continue;
}
use kurbo::ParamCurveNearest;
let nearest = edge.curve.nearest(local_point, 0.5);
let dist = nearest.distance_sq.sqrt();
let hit_radius = edge
.stroke_style
.as_ref()
.map(|s| s.width / 2.0)
.unwrap_or(0.0)
+ tolerance;
if dist < hit_radius {
if best_edge.is_none() || dist < best_edge.unwrap().1 {
best_edge = Some((EdgeId(i as u32), dist));
}
}
}
if let Some((edge_id, _)) = best_edge {
return Some(DcelHitResult::Edge(edge_id));
}
// 2. Check faces (fills)
for (i, face) in dcel.faces.iter().enumerate() {
if face.deleted || i == 0 {
continue; // skip unbounded face
}
if face.fill_color.is_none() && face.image_fill.is_none() {
continue;
}
if face.outer_half_edge.is_none() {
continue;
}
let path = dcel.face_to_bezpath(FaceId(i as u32));
if path.winding(local_point) != 0 {
return Some(DcelHitResult::Face(FaceId(i as u32)));
} }
} }
@ -91,33 +147,73 @@ pub fn hit_test_shape(
false false
} }
/// Hit test objects within a rectangle (for marquee selection) /// Result of DCEL marquee selection
#[derive(Debug, Default)]
pub struct DcelMarqueeResult {
pub edges: Vec<EdgeId>,
pub faces: Vec<FaceId>,
}
/// Hit test DCEL elements within a rectangle (for marquee selection).
/// ///
/// Returns all shapes in the active keyframe whose bounding boxes intersect with the given rectangle. /// Selects edges whose both endpoints are inside the rect,
pub fn hit_test_objects_in_rect( /// and faces whose all boundary vertices are inside the rect.
pub fn hit_test_dcel_in_rect(
layer: &VectorLayer, layer: &VectorLayer,
time: f64, time: f64,
rect: Rect, rect: Rect,
parent_transform: Affine, parent_transform: Affine,
) -> Vec<Uuid> { ) -> DcelMarqueeResult {
let mut hits = Vec::new(); let mut result = DcelMarqueeResult::default();
for shape in layer.shapes_at_time(time) { let dcel = match layer.dcel_at_time(time) {
let combined_transform = parent_transform * shape.transform.to_affine(); Some(d) => d,
None => return result,
};
// Get shape bounding box in local space let inv = parent_transform.inverse();
let bbox = shape.path().bounding_box(); let local_rect = inv.transform_rect_bbox(rect);
// Transform bounding box to screen space // Check edges: both endpoints inside rect
let transformed_bbox = combined_transform.transform_rect_bbox(bbox); for (i, edge) in dcel.edges.iter().enumerate() {
if edge.deleted {
// Check if rectangles intersect continue;
if rect.intersect(transformed_bbox).area() > 0.0 { }
hits.push(shape.id); let [he_fwd, he_bwd] = edge.half_edges;
if he_fwd.is_none() || he_bwd.is_none() {
continue;
}
let v1 = dcel.half_edge(he_fwd).origin;
let v2 = dcel.half_edge(he_bwd).origin;
if v1.is_none() || v2.is_none() {
continue;
}
let p1 = dcel.vertex(v1).position;
let p2 = dcel.vertex(v2).position;
if local_rect.contains(p1) && local_rect.contains(p2) {
result.edges.push(EdgeId(i as u32));
} }
} }
hits // Check faces: all boundary vertices inside rect
for (i, face) in dcel.faces.iter().enumerate() {
if face.deleted || i == 0 {
continue;
}
if face.outer_half_edge.is_none() {
continue;
}
let boundary = dcel.face_boundary(FaceId(i as u32));
let all_inside = boundary.iter().all(|&he_id| {
let v = dcel.half_edge(he_id).origin;
!v.is_none() && local_rect.contains(dcel.vertex(v).position)
});
if all_inside && !boundary.is_empty() {
result.faces.push(FaceId(i as u32));
}
}
result
} }
/// Classification of shapes relative to a clipping region /// Classification of shapes relative to a clipping region
@ -141,7 +237,7 @@ pub fn classify_shapes_by_region(
region: &BezPath, region: &BezPath,
parent_transform: Affine, parent_transform: Affine,
) -> ShapeRegionClassification { ) -> ShapeRegionClassification {
let mut result = ShapeRegionClassification { let result = ShapeRegionClassification {
fully_inside: Vec::new(), fully_inside: Vec::new(),
intersecting: Vec::new(), intersecting: Vec::new(),
fully_outside: Vec::new(), fully_outside: Vec::new(),
@ -149,33 +245,8 @@ pub fn classify_shapes_by_region(
let region_bbox = region.bounding_box(); let region_bbox = region.bounding_box();
for shape in layer.shapes_at_time(time) { // TODO: Implement DCEL-based region classification
let combined_transform = parent_transform * shape.transform.to_affine(); let _ = (layer, time, parent_transform, region_bbox);
let bbox = shape.path().bounding_box();
let transformed_bbox = combined_transform.transform_rect_bbox(bbox);
// Fast rejection: if bounding boxes don't overlap, fully outside
if region_bbox.intersect(transformed_bbox).area() <= 0.0 {
result.fully_outside.push(shape.id);
continue;
}
// Transform the shape path to world space for accurate testing
let world_path = {
let mut p = shape.path().clone();
p.apply_affine(combined_transform);
p
};
// Check if the path crosses the region boundary
if region_select::path_intersects_region(&world_path, region) {
result.intersecting.push(shape.id);
} else if region_select::path_fully_inside_region(&world_path, region) {
result.fully_inside.push(shape.id);
} else {
result.fully_outside.push(shape.id);
}
}
result result
} }
@ -300,23 +371,22 @@ pub fn hit_test_clip_instances_in_rect(
pub enum VectorEditHit { pub enum VectorEditHit {
/// Hit a control point (BezierEdit tool only) /// Hit a control point (BezierEdit tool only)
ControlPoint { ControlPoint {
shape_instance_id: Uuid, edge_id: EdgeId,
curve_index: usize, point_index: u8, // 1 = p1, 2 = p2
point_index: u8,
}, },
/// Hit a vertex (anchor point) /// Hit a vertex (anchor point)
Vertex { Vertex {
shape_instance_id: Uuid, vertex_id: VertexId,
vertex_index: usize,
}, },
/// Hit a curve segment /// Hit a curve segment
Curve { Curve {
shape_instance_id: Uuid, edge_id: EdgeId,
curve_index: usize,
parameter_t: f64, parameter_t: f64,
}, },
/// Hit shape fill /// Hit shape fill
Fill { shape_instance_id: Uuid }, Fill {
face_id: FaceId,
},
} }
/// Tolerances for vector editing hit testing (in screen pixels) /// Tolerances for vector editing hit testing (in screen pixels)
@ -359,80 +429,92 @@ pub fn hit_test_vector_editing(
parent_transform: Affine, parent_transform: Affine,
show_control_points: bool, show_control_points: bool,
) -> Option<VectorEditHit> { ) -> Option<VectorEditHit> {
use crate::bezpath_editing::extract_editable_curves; use kurbo::ParamCurveNearest;
use vello::kurbo::{ParamCurve, ParamCurveNearest};
// Test shapes in reverse order (front to back for hit testing) let dcel = layer.dcel_at_time(time)?;
for shape in layer.shapes_at_time(time).iter().rev() {
let combined_transform = parent_transform * shape.transform.to_affine();
let inverse_transform = combined_transform.inverse();
let local_point = inverse_transform * point;
// Calculate the scale factor to transform screen-space tolerances to local space // Transform point into layer-local space
let coeffs = combined_transform.as_coeffs(); let local_point = parent_transform.inverse() * point;
let scale_x = (coeffs[0].powi(2) + coeffs[1].powi(2)).sqrt();
let scale_y = (coeffs[2].powi(2) + coeffs[3].powi(2)).sqrt();
let avg_scale = (scale_x + scale_y) / 2.0;
let local_tolerance_factor = 1.0 / avg_scale.max(0.001);
let editable = extract_editable_curves(shape.path()); // Priority: ControlPoint > Vertex > Curve > Fill
// Priority 1: Control points (only in BezierEdit mode) // 1. Control points (only when show_control_points is true, e.g. BezierEdit tool)
if show_control_points { if show_control_points {
let local_cp_tolerance = tolerance.control_point * local_tolerance_factor; let mut best_cp: Option<(EdgeId, u8, f64)> = None;
for (i, curve) in editable.curves.iter().enumerate() { for (i, edge) in dcel.edges.iter().enumerate() {
let dist_p1 = (curve.p1 - local_point).hypot(); if edge.deleted {
if dist_p1 < local_cp_tolerance { continue;
return Some(VectorEditHit::ControlPoint { }
shape_instance_id: shape.id, let edge_id = EdgeId(i as u32);
curve_index: i, // Check p1
point_index: 1, let d1 = local_point.distance(edge.curve.p1);
}); if d1 < tolerance.control_point {
if best_cp.is_none() || d1 < best_cp.unwrap().2 {
best_cp = Some((edge_id, 1, d1));
} }
}
let dist_p2 = (curve.p2 - local_point).hypot(); // Check p2
if dist_p2 < local_cp_tolerance { let d2 = local_point.distance(edge.curve.p2);
return Some(VectorEditHit::ControlPoint { if d2 < tolerance.control_point {
shape_instance_id: shape.id, if best_cp.is_none() || d2 < best_cp.unwrap().2 {
curve_index: i, best_cp = Some((edge_id, 2, d2));
point_index: 2,
});
} }
} }
} }
if let Some((edge_id, point_index, _)) = best_cp {
return Some(VectorEditHit::ControlPoint { edge_id, point_index });
}
}
// Priority 2: Vertices (anchor points) // 2. Vertices
let local_vertex_tolerance = tolerance.vertex * local_tolerance_factor; let mut best_vertex: Option<(VertexId, f64)> = None;
for (i, vertex) in editable.vertices.iter().enumerate() { for (i, vertex) in dcel.vertices.iter().enumerate() {
let dist = (vertex.point - local_point).hypot(); if vertex.deleted {
if dist < local_vertex_tolerance { continue;
return Some(VectorEditHit::Vertex { }
shape_instance_id: shape.id, let dist = local_point.distance(vertex.position);
vertex_index: i, if dist < tolerance.vertex {
}); if best_vertex.is_none() || dist < best_vertex.unwrap().1 {
best_vertex = Some((VertexId(i as u32), dist));
} }
} }
}
if let Some((vertex_id, _)) = best_vertex {
return Some(VectorEditHit::Vertex { vertex_id });
}
// Priority 3: Curves // 3. Curves (edges)
let local_curve_tolerance = tolerance.curve * local_tolerance_factor; let mut best_curve: Option<(EdgeId, f64, f64)> = None; // (edge_id, t, dist)
for (i, curve) in editable.curves.iter().enumerate() { for (i, edge) in dcel.edges.iter().enumerate() {
let nearest = curve.nearest(local_point, 1e-6); if edge.deleted {
let nearest_point = curve.eval(nearest.t); continue;
let dist = (nearest_point - local_point).hypot(); }
if dist < local_curve_tolerance { let nearest = edge.curve.nearest(local_point, 0.5);
return Some(VectorEditHit::Curve { let dist = nearest.distance_sq.sqrt();
shape_instance_id: shape.id, if dist < tolerance.curve {
curve_index: i, if best_curve.is_none() || dist < best_curve.unwrap().2 {
parameter_t: nearest.t, best_curve = Some((EdgeId(i as u32), nearest.t, dist));
});
} }
} }
}
if let Some((edge_id, parameter_t, _)) = best_curve {
return Some(VectorEditHit::Curve { edge_id, parameter_t });
}
// Priority 4: Fill // 4. Face fill testing
if shape.fill_color.is_some() && shape.path().contains(local_point) { for (i, face) in dcel.faces.iter().enumerate() {
return Some(VectorEditHit::Fill { if face.deleted || i == 0 {
shape_instance_id: shape.id, continue;
}); }
if face.fill_color.is_none() && face.image_fill.is_none() {
continue;
}
if face.outer_half_edge.is_none() {
continue;
}
let path = dcel.face_to_bezpath(FaceId(i as u32));
if path.winding(local_point) != 0 {
return Some(VectorEditHit::Fill { face_id: FaceId(i as u32) });
} }
} }
@ -447,65 +529,16 @@ mod tests {
#[test] #[test]
fn test_hit_test_simple_circle() { fn test_hit_test_simple_circle() {
let mut layer = VectorLayer::new("Test Layer"); // TODO: DCEL - rewrite test
let circle = Circle::new((100.0, 100.0), 50.0);
let path = circle.to_path(0.1);
let shape = Shape::new(path).with_fill(ShapeColor::rgb(255, 0, 0));
layer.add_shape_to_keyframe(shape, 0.0);
// Test hit inside circle
let hit = hit_test_layer(&layer, 0.0, Point::new(100.0, 100.0), 0.0, Affine::IDENTITY);
assert!(hit.is_some());
// Test miss outside circle
let miss = hit_test_layer(&layer, 0.0, Point::new(200.0, 200.0), 0.0, Affine::IDENTITY);
assert!(miss.is_none());
} }
#[test] #[test]
fn test_hit_test_with_transform() { fn test_hit_test_with_transform() {
let mut layer = VectorLayer::new("Test Layer"); // TODO: DCEL - rewrite test
let circle = Circle::new((0.0, 0.0), 50.0);
let path = circle.to_path(0.1);
let shape = Shape::new(path)
.with_fill(ShapeColor::rgb(255, 0, 0))
.with_position(100.0, 100.0);
layer.add_shape_to_keyframe(shape, 0.0);
// Test hit at translated position
let hit = hit_test_layer(&layer, 0.0, Point::new(100.0, 100.0), 0.0, Affine::IDENTITY);
assert!(hit.is_some());
// Test miss at origin (where shape is defined, but transform moves it)
let miss = hit_test_layer(&layer, 0.0, Point::new(0.0, 0.0), 0.0, Affine::IDENTITY);
assert!(miss.is_none());
} }
#[test] #[test]
fn test_marquee_selection() { fn test_marquee_selection() {
let mut layer = VectorLayer::new("Test Layer"); // TODO: DCEL - rewrite test
let circle1 = Circle::new((50.0, 50.0), 20.0);
let shape1 = Shape::new(circle1.to_path(0.1)).with_fill(ShapeColor::rgb(255, 0, 0));
let circle2 = Circle::new((150.0, 150.0), 20.0);
let shape2 = Shape::new(circle2.to_path(0.1)).with_fill(ShapeColor::rgb(0, 255, 0));
layer.add_shape_to_keyframe(shape1, 0.0);
layer.add_shape_to_keyframe(shape2, 0.0);
// Marquee that contains both circles
let rect = Rect::new(0.0, 0.0, 200.0, 200.0);
let hits = hit_test_objects_in_rect(&layer, 0.0, rect, Affine::IDENTITY);
assert_eq!(hits.len(), 2);
// Marquee that contains only first circle
let rect = Rect::new(0.0, 0.0, 100.0, 100.0);
let hits = hit_test_objects_in_rect(&layer, 0.0, rect, Affine::IDENTITY);
assert_eq!(hits.len(), 1);
} }
} }

View File

@ -4,6 +4,7 @@
use crate::animation::AnimationData; use crate::animation::AnimationData;
use crate::clip::ClipInstance; use crate::clip::ClipInstance;
use crate::dcel::Dcel;
use crate::effect_layer::EffectLayer; use crate::effect_layer::EffectLayer;
use crate::object::ShapeInstance; use crate::object::ShapeInstance;
use crate::shape::Shape; use crate::shape::Shape;
@ -151,13 +152,13 @@ impl Default for TweenType {
} }
} }
/// A keyframe containing all shapes at a point in time /// A keyframe containing vector artwork as a DCEL planar subdivision.
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ShapeKeyframe { pub struct ShapeKeyframe {
/// Time in seconds /// Time in seconds
pub time: f64, pub time: f64,
/// All shapes at this keyframe /// DCEL planar subdivision containing all vector artwork
pub shapes: Vec<Shape>, pub dcel: Dcel,
/// What happens between this keyframe and the next /// What happens between this keyframe and the next
#[serde(default)] #[serde(default)]
pub tween_after: TweenType, pub tween_after: TweenType,
@ -172,17 +173,7 @@ impl ShapeKeyframe {
pub fn new(time: f64) -> Self { pub fn new(time: f64) -> Self {
Self { Self {
time, time,
shapes: Vec::new(), dcel: Dcel::new(),
tween_after: TweenType::None,
clip_instance_ids: Vec::new(),
}
}
/// Create a keyframe with shapes
pub fn with_shapes(time: f64, shapes: Vec<Shape>) -> Self {
Self {
time,
shapes,
tween_after: TweenType::None, tween_after: TweenType::None,
clip_instance_ids: Vec::new(), clip_instance_ids: Vec::new(),
} }
@ -195,13 +186,15 @@ pub struct VectorLayer {
/// Base layer properties /// Base layer properties
pub layer: Layer, pub layer: Layer,
/// Shapes defined in this layer (indexed by UUID for O(1) lookup) /// Legacy shapes — kept for old .beam file compat, not written to new files.
#[serde(default, skip_serializing)]
pub shapes: HashMap<Uuid, Shape>, pub shapes: HashMap<Uuid, Shape>,
/// Shape instances (references to shapes with transforms) /// Legacy shape instances — kept for old .beam file compat, not written to new files.
#[serde(default, skip_serializing)]
pub shape_instances: Vec<ShapeInstance>, pub shape_instances: Vec<ShapeInstance>,
/// Shape keyframes (sorted by time) — replaces shapes/shape_instances /// Shape keyframes (sorted by time)
#[serde(default)] #[serde(default)]
pub keyframes: Vec<ShapeKeyframe>, pub keyframes: Vec<ShapeKeyframe>,
@ -370,12 +363,14 @@ impl VectorLayer {
self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance) self.keyframes.iter().position(|kf| (kf.time - time).abs() < tolerance)
} }
/// Get shapes visible at a given time (from the keyframe at-or-before time) /// Get the DCEL at a given time (from the keyframe at-or-before time)
pub fn shapes_at_time(&self, time: f64) -> &[Shape] { pub fn dcel_at_time(&self, time: f64) -> Option<&Dcel> {
match self.keyframe_at(time) { self.keyframe_at(time).map(|kf| &kf.dcel)
Some(kf) => &kf.shapes, }
None => &[],
} /// Get a mutable DCEL at a given time
pub fn dcel_at_time_mut(&mut self, time: f64) -> Option<&mut Dcel> {
self.keyframe_at_mut(time).map(|kf| &mut kf.dcel)
} }
/// Get the duration of the keyframe span starting at-or-before `time`. /// Get the duration of the keyframe span starting at-or-before `time`.
@ -424,22 +419,10 @@ impl VectorLayer {
time + frame_duration time + frame_duration
} }
/// Get mutable shapes at a given time // Shape-based methods removed — use DCEL methods instead.
pub fn shapes_at_time_mut(&mut self, time: f64) -> Option<&mut Vec<Shape>> { // - shapes_at_time_mut → dcel_at_time_mut
self.keyframe_at_mut(time).map(|kf| &mut kf.shapes) // - get_shape_in_keyframe → use DCEL vertex/edge/face accessors
} // - get_shape_in_keyframe_mut → use DCEL vertex/edge/face accessors
/// Find a shape by ID within the keyframe active at the given time
pub fn get_shape_in_keyframe(&self, shape_id: &Uuid, time: f64) -> Option<&Shape> {
self.keyframe_at(time)
.and_then(|kf| kf.shapes.iter().find(|s| &s.id == shape_id))
}
/// Find a mutable shape by ID within the keyframe active at the given time
pub fn get_shape_in_keyframe_mut(&mut self, shape_id: &Uuid, time: f64) -> Option<&mut Shape> {
self.keyframe_at_mut(time)
.and_then(|kf| kf.shapes.iter_mut().find(|s| &s.id == shape_id))
}
/// Ensure a keyframe exists at the exact time, creating an empty one if needed. /// Ensure a keyframe exists at the exact time, creating an empty one if needed.
/// Returns a mutable reference to the keyframe. /// Returns a mutable reference to the keyframe.
@ -454,8 +437,7 @@ impl VectorLayer {
&mut self.keyframes[insert_idx] &mut self.keyframes[insert_idx]
} }
/// Insert a new keyframe at time by copying shapes from the active keyframe. /// Insert a new keyframe at time by cloning the DCEL from the active keyframe.
/// Shape UUIDs are regenerated (no cross-keyframe identity).
/// If a keyframe already exists at the exact time, does nothing and returns it. /// If a keyframe already exists at the exact time, does nothing and returns it.
pub fn insert_keyframe_from_current(&mut self, time: f64) -> &mut ShapeKeyframe { pub fn insert_keyframe_from_current(&mut self, time: f64) -> &mut ShapeKeyframe {
let tolerance = 0.001; let tolerance = 0.001;
@ -463,45 +445,22 @@ impl VectorLayer {
return &mut self.keyframes[idx]; return &mut self.keyframes[idx];
} }
// Clone shapes and clip instance IDs from the active keyframe // Clone DCEL and clip instance IDs from the active keyframe
let (cloned_shapes, cloned_clip_ids) = self let (cloned_dcel, cloned_clip_ids) = self
.keyframe_at(time) .keyframe_at(time)
.map(|kf| { .map(|kf| {
let shapes: Vec<Shape> = kf.shapes (kf.dcel.clone(), kf.clip_instance_ids.clone())
.iter()
.map(|s| {
let mut new_shape = s.clone();
new_shape.id = Uuid::new_v4();
new_shape
})
.collect();
let clip_ids = kf.clip_instance_ids.clone();
(shapes, clip_ids)
}) })
.unwrap_or_default(); .unwrap_or_else(|| (Dcel::new(), Vec::new()));
let insert_idx = self.keyframes.partition_point(|kf| kf.time < time); let insert_idx = self.keyframes.partition_point(|kf| kf.time < time);
let mut kf = ShapeKeyframe::with_shapes(time, cloned_shapes); let mut kf = ShapeKeyframe::new(time);
kf.dcel = cloned_dcel;
kf.clip_instance_ids = cloned_clip_ids; kf.clip_instance_ids = cloned_clip_ids;
self.keyframes.insert(insert_idx, kf); self.keyframes.insert(insert_idx, kf);
&mut self.keyframes[insert_idx] &mut self.keyframes[insert_idx]
} }
/// Add a shape to the keyframe at the given time.
/// Creates a keyframe if none exists at that time.
pub fn add_shape_to_keyframe(&mut self, shape: Shape, time: f64) {
let kf = self.ensure_keyframe_at(time);
kf.shapes.push(shape);
}
/// Remove a shape from the keyframe at the given time.
/// Returns the removed shape if found.
pub fn remove_shape_from_keyframe(&mut self, shape_id: &Uuid, time: f64) -> Option<Shape> {
let kf = self.keyframe_at_mut(time)?;
let idx = kf.shapes.iter().position(|s| &s.id == shape_id)?;
Some(kf.shapes.remove(idx))
}
/// Remove a keyframe at the exact time (within tolerance). /// Remove a keyframe at the exact time (within tolerance).
/// Returns the removed keyframe if found. /// Returns the removed keyframe if found.
pub(crate) fn remove_keyframe_at(&mut self, time: f64, tolerance: f64) -> Option<ShapeKeyframe> { pub(crate) fn remove_keyframe_at(&mut self, time: f64, tolerance: f64) -> Option<ShapeKeyframe> {

View File

@ -1,6 +1,7 @@
// Lightningbeam Core Library // Lightningbeam Core Library
// Shared data structures and types // Shared data structures and types
pub mod beat_time;
pub mod gpu; pub mod gpu;
pub mod layout; pub mod layout;
pub mod pane; pub mod pane;
@ -43,3 +44,4 @@ pub mod file_io;
pub mod export; pub mod export;
pub mod clipboard; pub mod clipboard;
pub mod region_select; pub mod region_select;
pub mod dcel;

View File

@ -13,7 +13,7 @@ use crate::clip::{ClipInstance, ImageAsset};
use crate::document::Document; use crate::document::Document;
use crate::gpu::BlendMode; use crate::gpu::BlendMode;
use crate::layer::{AnyLayer, LayerTrait, VectorLayer}; use crate::layer::{AnyLayer, LayerTrait, VectorLayer};
use kurbo::{Affine, Shape}; use kurbo::Affine;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@ -178,7 +178,6 @@ pub fn render_document_for_compositing(
base_transform: Affine, base_transform: Affine,
image_cache: &mut ImageCache, image_cache: &mut ImageCache,
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>, video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
skip_instance_id: Option<uuid::Uuid>,
) -> CompositeRenderResult { ) -> CompositeRenderResult {
let time = document.current_time; let time = document.current_time;
@ -212,7 +211,6 @@ pub fn render_document_for_compositing(
base_transform, base_transform,
image_cache, image_cache,
video_manager, video_manager,
skip_instance_id,
); );
rendered_layers.push(rendered); rendered_layers.push(rendered);
} }
@ -237,7 +235,6 @@ pub fn render_layer_isolated(
base_transform: Affine, base_transform: Affine,
image_cache: &mut ImageCache, image_cache: &mut ImageCache,
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>, video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
skip_instance_id: Option<uuid::Uuid>,
) -> RenderedLayer { ) -> RenderedLayer {
let layer_id = layer.id(); let layer_id = layer.id();
let opacity = layer.opacity() as f32; let opacity = layer.opacity() as f32;
@ -259,9 +256,9 @@ pub fn render_layer_isolated(
1.0, // Full opacity - layer opacity handled in compositing 1.0, // Full opacity - layer opacity handled in compositing
image_cache, image_cache,
video_manager, video_manager,
skip_instance_id,
); );
rendered.has_content = !vector_layer.shapes_at_time(time).is_empty() rendered.has_content = vector_layer.dcel_at_time(time)
.map_or(false, |dcel| !dcel.edges.iter().all(|e| e.deleted) || !dcel.faces.iter().skip(1).all(|f| f.deleted))
|| !vector_layer.clip_instances.is_empty(); || !vector_layer.clip_instances.is_empty();
} }
AnyLayer::Audio(_) => { AnyLayer::Audio(_) => {
@ -306,9 +303,7 @@ fn render_vector_layer_to_scene(
parent_opacity: f64, parent_opacity: f64,
image_cache: &mut ImageCache, image_cache: &mut ImageCache,
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>, video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
skip_instance_id: Option<uuid::Uuid>,
) { ) {
// Render using the existing function but to this isolated scene
render_vector_layer( render_vector_layer(
document, document,
time, time,
@ -318,7 +313,6 @@ fn render_vector_layer_to_scene(
parent_opacity, parent_opacity,
image_cache, image_cache,
video_manager, video_manager,
skip_instance_id,
); );
} }
@ -355,7 +349,7 @@ pub fn render_document(
image_cache: &mut ImageCache, image_cache: &mut ImageCache,
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>, video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
) { ) {
render_document_with_transform(document, scene, Affine::IDENTITY, image_cache, video_manager, None); render_document_with_transform(document, scene, Affine::IDENTITY, image_cache, video_manager);
} }
/// Render a document to a Vello scene with a base transform /// Render a document to a Vello scene with a base transform
@ -366,7 +360,6 @@ pub fn render_document_with_transform(
base_transform: Affine, base_transform: Affine,
image_cache: &mut ImageCache, image_cache: &mut ImageCache,
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>, video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
skip_instance_id: Option<uuid::Uuid>,
) { ) {
// 1. Draw background // 1. Draw background
render_background(document, scene, base_transform); render_background(document, scene, base_transform);
@ -380,10 +373,10 @@ pub fn render_document_with_transform(
for layer in document.visible_layers() { for layer in document.visible_layers() {
if any_soloed { if any_soloed {
if layer.soloed() { if layer.soloed() {
render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager, skip_instance_id); render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager);
} }
} else { } else {
render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager, skip_instance_id); render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager);
} }
} }
} }
@ -415,11 +408,10 @@ fn render_layer(
parent_opacity: f64, parent_opacity: f64,
image_cache: &mut ImageCache, image_cache: &mut ImageCache,
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>, video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
skip_instance_id: Option<uuid::Uuid>,
) { ) {
match layer { match layer {
AnyLayer::Vector(vector_layer) => { AnyLayer::Vector(vector_layer) => {
render_vector_layer(document, time, vector_layer, scene, base_transform, parent_opacity, image_cache, video_manager, skip_instance_id) render_vector_layer(document, time, vector_layer, scene, base_transform, parent_opacity, image_cache, video_manager)
} }
AnyLayer::Audio(_) => { AnyLayer::Audio(_) => {
// Audio layers don't render visually // Audio layers don't render visually
@ -620,7 +612,7 @@ fn render_clip_instance(
if !layer_node.data.visible() { if !layer_node.data.visible() {
continue; continue;
} }
render_layer(document, clip_time, &layer_node.data, scene, instance_transform, clip_opacity, image_cache, video_manager, None); render_layer(document, clip_time, &layer_node.data, scene, instance_transform, clip_opacity, image_cache, video_manager);
} }
} }
@ -792,6 +784,89 @@ fn render_video_layer(
} }
/// Render a vector layer with all its clip instances and shape instances /// Render a vector layer with all its clip instances and shape instances
/// Render a DCEL to a Vello scene.
///
/// Walks faces for fills and edges for strokes.
pub fn render_dcel(
dcel: &crate::dcel::Dcel,
scene: &mut Scene,
base_transform: Affine,
layer_opacity: f64,
document: &Document,
image_cache: &mut ImageCache,
) {
let opacity_f32 = layer_opacity as f32;
// 1. Render faces (fills)
for (i, face) in dcel.faces.iter().enumerate() {
if face.deleted || i == 0 {
continue; // Skip unbounded face and deleted faces
}
if face.fill_color.is_none() && face.image_fill.is_none() {
continue; // No fill to render
}
let face_id = crate::dcel::FaceId(i as u32);
let path = dcel.face_to_bezpath_with_holes(face_id);
let fill_rule: Fill = face.fill_rule.into();
let mut filled = false;
// Image fill
if let Some(image_asset_id) = face.image_fill {
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);
filled = true;
}
}
}
// Color fill
if !filled {
if let Some(fill_color) = &face.fill_color {
let alpha = ((fill_color.a as f32 / 255.0) * opacity_f32 * 255.0) as u8;
let adjusted = crate::shape::ShapeColor::rgba(
fill_color.r,
fill_color.g,
fill_color.b,
alpha,
);
scene.fill(fill_rule, base_transform, adjusted.to_peniko(), None, &path);
}
}
}
// 2. Render edges (strokes)
for edge in &dcel.edges {
if edge.deleted {
continue;
}
if let (Some(stroke_color), Some(stroke_style)) = (&edge.stroke_color, &edge.stroke_style) {
let alpha = ((stroke_color.a as f32 / 255.0) * opacity_f32 * 255.0) as u8;
let adjusted = crate::shape::ShapeColor::rgba(
stroke_color.r,
stroke_color.g,
stroke_color.b,
alpha,
);
let mut path = kurbo::BezPath::new();
path.move_to(edge.curve.p0);
path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3);
scene.stroke(
&stroke_style.to_stroke(),
base_transform,
adjusted.to_peniko(),
None,
&path,
);
}
}
}
fn render_vector_layer( fn render_vector_layer(
document: &Document, document: &Document,
time: f64, time: f64,
@ -801,7 +876,6 @@ fn render_vector_layer(
parent_opacity: f64, parent_opacity: f64,
image_cache: &mut ImageCache, image_cache: &mut ImageCache,
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>, video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
skip_instance_id: Option<uuid::Uuid>,
) { ) {
// Cascade opacity: parent_opacity × layer.opacity // Cascade opacity: parent_opacity × layer.opacity
let layer_opacity = parent_opacity * layer.layer.opacity; let layer_opacity = parent_opacity * layer.layer.opacity;
@ -818,124 +892,9 @@ fn render_vector_layer(
render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager, group_end_time); render_clip_instance(document, time, clip_instance, layer_opacity, scene, base_transform, &layer.layer.animation_data, image_cache, video_manager, group_end_time);
} }
// Render each shape in the active keyframe // Render DCEL from active keyframe
for shape in layer.shapes_at_time(time) { if let Some(dcel) = layer.dcel_at_time(time) {
// Skip this shape if it's being edited render_dcel(dcel, scene, base_transform, layer_opacity, document, image_cache);
if Some(shape.id) == skip_instance_id {
continue;
}
// Use shape's transform directly (keyframe model — no animation evaluation)
let x = shape.transform.x;
let y = shape.transform.y;
let rotation = shape.transform.rotation;
let scale_x = shape.transform.scale_x;
let scale_y = shape.transform.scale_y;
let skew_x = shape.transform.skew_x;
let skew_y = shape.transform.skew_y;
let opacity = shape.opacity;
// Get the path
let path = shape.path();
// Build transform matrix (compose with base transform for camera)
let shape_bbox = path.bounding_box();
let center_x = (shape_bbox.x0 + shape_bbox.x1) / 2.0;
let center_y = (shape_bbox.y0 + shape_bbox.y1) / 2.0;
// Build skew transforms (applied around shape center)
let skew_transform = if skew_x != 0.0 || skew_y != 0.0 {
let skew_x_affine = if skew_x != 0.0 {
let tan_skew = skew_x.to_radians().tan();
Affine::new([1.0, 0.0, tan_skew, 1.0, 0.0, 0.0])
} else {
Affine::IDENTITY
};
let skew_y_affine = if skew_y != 0.0 {
let tan_skew = skew_y.to_radians().tan();
Affine::new([1.0, tan_skew, 0.0, 1.0, 0.0, 0.0])
} else {
Affine::IDENTITY
};
Affine::translate((center_x, center_y))
* skew_x_affine
* skew_y_affine
* Affine::translate((-center_x, -center_y))
} else {
Affine::IDENTITY
};
let object_transform = Affine::translate((x, y))
* Affine::rotate(rotation.to_radians())
* Affine::scale_non_uniform(scale_x, scale_y)
* skew_transform;
let affine = base_transform * object_transform;
// Calculate final opacity (cascaded from parent → layer → shape)
let final_opacity = (layer_opacity * opacity) as f32;
// Determine fill rule
let fill_rule = match shape.fill_rule {
crate::shape::FillRule::NonZero => Fill::NonZero,
crate::shape::FillRule::EvenOdd => Fill::EvenOdd,
};
// Render fill - prefer image fill over color fill
let mut filled = false;
// Check for image fill first
if let Some(image_asset_id) = shape.image_fill {
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(final_opacity);
scene.fill(fill_rule, affine, &image_with_alpha, None, &path);
filled = true;
}
}
}
// Fall back to color fill if no image fill (or image failed to load)
if !filled {
if let Some(fill_color) = &shape.fill_color {
let alpha = ((fill_color.a as f32 / 255.0) * final_opacity * 255.0) as u8;
let adjusted_color = crate::shape::ShapeColor::rgba(
fill_color.r,
fill_color.g,
fill_color.b,
alpha,
);
scene.fill(
fill_rule,
affine,
adjusted_color.to_peniko(),
None,
&path,
);
}
}
// Render stroke if present
if let (Some(stroke_color), Some(stroke_style)) = (&shape.stroke_color, &shape.stroke_style)
{
let alpha = ((stroke_color.a as f32 / 255.0) * final_opacity * 255.0) as u8;
let adjusted_color = crate::shape::ShapeColor::rgba(
stroke_color.r,
stroke_color.g,
stroke_color.b,
alpha,
);
scene.stroke(
&stroke_style.to_stroke(),
affine,
adjusted_color.to_peniko(),
None,
&path,
);
}
} }
} }

View File

@ -1,24 +1,28 @@
//! Selection state management //! Selection state management
//! //!
//! Tracks selected shape instances, clip instances, and shapes for editing operations. //! Tracks selected DCEL elements (edges, faces, vertices) and clip instances for editing operations.
use crate::shape::Shape; use crate::dcel::{Dcel, EdgeId, FaceId, VertexId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use uuid::Uuid; use uuid::Uuid;
use vello::kurbo::BezPath; use vello::kurbo::BezPath;
/// Selection state for the editor /// Selection state for the editor
/// ///
/// Maintains sets of selected shape instances, clip instances, and shapes. /// Maintains sets of selected DCEL elements and clip instances.
/// This is separate from the document to make it easy to /// The vertex/edge/face sets implicitly represent a subgraph of the DCEL —
/// pass around for UI rendering without needing mutable access. /// connectivity is determined by shared vertices between edges.
#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Selection { pub struct Selection {
/// Currently selected shape instances /// Currently selected vertices
selected_shape_instances: Vec<Uuid>, selected_vertices: HashSet<VertexId>,
/// Currently selected shapes (definitions) /// Currently selected edges
selected_shapes: Vec<Uuid>, selected_edges: HashSet<EdgeId>,
/// Currently selected faces
selected_faces: HashSet<FaceId>,
/// Currently selected clip instances /// Currently selected clip instances
selected_clip_instances: Vec<Uuid>, selected_clip_instances: Vec<Uuid>,
@ -28,54 +32,168 @@ impl Selection {
/// Create a new empty selection /// Create a new empty selection
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
selected_shape_instances: Vec::new(), selected_vertices: HashSet::new(),
selected_shapes: Vec::new(), selected_edges: HashSet::new(),
selected_faces: HashSet::new(),
selected_clip_instances: Vec::new(), selected_clip_instances: Vec::new(),
} }
} }
/// Add a shape instance to the selection // -----------------------------------------------------------------------
pub fn add_shape_instance(&mut self, id: Uuid) { // DCEL element selection
if !self.selected_shape_instances.contains(&id) { // -----------------------------------------------------------------------
self.selected_shape_instances.push(id);
/// Select an edge and its endpoint vertices, forming/extending a subgraph.
pub fn select_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
if edge_id.is_none() || dcel.edge(edge_id).deleted {
return;
}
self.selected_edges.insert(edge_id);
// Add both endpoint vertices
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
if !he_fwd.is_none() {
let v = dcel.half_edge(he_fwd).origin;
if !v.is_none() {
self.selected_vertices.insert(v);
}
}
if !he_bwd.is_none() {
let v = dcel.half_edge(he_bwd).origin;
if !v.is_none() {
self.selected_vertices.insert(v);
}
} }
} }
/// Add a shape definition to the selection /// Select a face and all its boundary edges + vertices.
pub fn add_shape(&mut self, id: Uuid) { pub fn select_face(&mut self, face_id: FaceId, dcel: &Dcel) {
if !self.selected_shapes.contains(&id) { if face_id.is_none() || face_id.0 == 0 || dcel.face(face_id).deleted {
self.selected_shapes.push(id); return;
}
self.selected_faces.insert(face_id);
// Add all boundary edges and vertices
let boundary = dcel.face_boundary(face_id);
for he_id in boundary {
let he = dcel.half_edge(he_id);
let edge_id = he.edge;
if !edge_id.is_none() {
self.selected_edges.insert(edge_id);
// Add endpoints
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
if !he_fwd.is_none() {
let v = dcel.half_edge(he_fwd).origin;
if !v.is_none() {
self.selected_vertices.insert(v);
}
}
if !he_bwd.is_none() {
let v = dcel.half_edge(he_bwd).origin;
if !v.is_none() {
self.selected_vertices.insert(v);
}
}
}
} }
} }
/// Remove a shape instance from the selection /// Deselect an edge and its vertices (if they have no other selected edges).
pub fn remove_shape_instance(&mut self, id: &Uuid) { pub fn deselect_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
self.selected_shape_instances.retain(|&x| x != *id); self.selected_edges.remove(&edge_id);
// Remove endpoint vertices only if they're not used by other selected edges
let [he_fwd, he_bwd] = dcel.edge(edge_id).half_edges;
for he_id in [he_fwd, he_bwd] {
if he_id.is_none() {
continue;
}
let v = dcel.half_edge(he_id).origin;
if v.is_none() {
continue;
}
// Check if any other selected edge uses this vertex
let used = self.selected_edges.iter().any(|&eid| {
let e = dcel.edge(eid);
let [a, b] = e.half_edges;
(!a.is_none() && dcel.half_edge(a).origin == v)
|| (!b.is_none() && dcel.half_edge(b).origin == v)
});
if !used {
self.selected_vertices.remove(&v);
}
}
} }
/// Remove a shape definition from the selection /// Deselect a face (edges/vertices stay if still referenced by other selections).
pub fn remove_shape(&mut self, id: &Uuid) { pub fn deselect_face(&mut self, face_id: FaceId) {
self.selected_shapes.retain(|&x| x != *id); self.selected_faces.remove(&face_id);
} }
/// Toggle a shape instance's selection state /// Toggle an edge's selection state.
pub fn toggle_shape_instance(&mut self, id: Uuid) { pub fn toggle_edge(&mut self, edge_id: EdgeId, dcel: &Dcel) {
if self.contains_shape_instance(&id) { if self.selected_edges.contains(&edge_id) {
self.remove_shape_instance(&id); self.deselect_edge(edge_id, dcel);
} else { } else {
self.add_shape_instance(id); self.select_edge(edge_id, dcel);
} }
} }
/// Toggle a shape's selection state /// Toggle a face's selection state.
pub fn toggle_shape(&mut self, id: Uuid) { pub fn toggle_face(&mut self, face_id: FaceId, dcel: &Dcel) {
if self.contains_shape(&id) { if self.selected_faces.contains(&face_id) {
self.remove_shape(&id); self.deselect_face(face_id);
} else { } else {
self.add_shape(id); self.select_face(face_id, dcel);
} }
} }
/// Check if an edge is selected.
pub fn contains_edge(&self, edge_id: &EdgeId) -> bool {
self.selected_edges.contains(edge_id)
}
/// Check if a face is selected.
pub fn contains_face(&self, face_id: &FaceId) -> bool {
self.selected_faces.contains(face_id)
}
/// Check if a vertex is selected.
pub fn contains_vertex(&self, vertex_id: &VertexId) -> bool {
self.selected_vertices.contains(vertex_id)
}
/// Clear DCEL element selections (edges, faces, vertices).
pub fn clear_dcel_selection(&mut self) {
self.selected_vertices.clear();
self.selected_edges.clear();
self.selected_faces.clear();
}
/// Check if any DCEL elements are selected.
pub fn has_dcel_selection(&self) -> bool {
!self.selected_edges.is_empty() || !self.selected_faces.is_empty()
}
/// Get selected edges.
pub fn selected_edges(&self) -> &HashSet<EdgeId> {
&self.selected_edges
}
/// Get selected faces.
pub fn selected_faces(&self) -> &HashSet<FaceId> {
&self.selected_faces
}
/// Get selected vertices.
pub fn selected_vertices(&self) -> &HashSet<VertexId> {
&self.selected_vertices
}
// -----------------------------------------------------------------------
// Clip instance selection (unchanged)
// -----------------------------------------------------------------------
/// Add a clip instance to the selection /// Add a clip instance to the selection
pub fn add_clip_instance(&mut self, id: Uuid) { pub fn add_clip_instance(&mut self, id: Uuid) {
if !self.selected_clip_instances.contains(&id) { if !self.selected_clip_instances.contains(&id) {
@ -97,68 +215,14 @@ impl Selection {
} }
} }
/// Clear all selections
pub fn clear(&mut self) {
self.selected_shape_instances.clear();
self.selected_shapes.clear();
self.selected_clip_instances.clear();
}
/// Clear only object selections
pub fn clear_shape_instances(&mut self) {
self.selected_shape_instances.clear();
}
/// Clear only shape selections
pub fn clear_shapes(&mut self) {
self.selected_shapes.clear();
}
/// Clear only clip instance selections
pub fn clear_clip_instances(&mut self) {
self.selected_clip_instances.clear();
}
/// Check if an object is selected
pub fn contains_shape_instance(&self, id: &Uuid) -> bool {
self.selected_shape_instances.contains(id)
}
/// Check if a shape is selected
pub fn contains_shape(&self, id: &Uuid) -> bool {
self.selected_shapes.contains(id)
}
/// Check if a clip instance is selected /// Check if a clip instance is selected
pub fn contains_clip_instance(&self, id: &Uuid) -> bool { pub fn contains_clip_instance(&self, id: &Uuid) -> bool {
self.selected_clip_instances.contains(id) self.selected_clip_instances.contains(id)
} }
/// Check if selection is empty /// Clear only clip instance selections
pub fn is_empty(&self) -> bool { pub fn clear_clip_instances(&mut self) {
self.selected_shape_instances.is_empty() self.selected_clip_instances.clear();
&& self.selected_shapes.is_empty()
&& self.selected_clip_instances.is_empty()
}
/// Get the selected objects
pub fn shape_instances(&self) -> &[Uuid] {
&self.selected_shape_instances
}
/// Get the selected shapes
pub fn shapes(&self) -> &[Uuid] {
&self.selected_shapes
}
/// Get the number of selected objects
pub fn shape_instance_count(&self) -> usize {
self.selected_shape_instances.len()
}
/// Get the number of selected shapes
pub fn shape_count(&self) -> usize {
self.selected_shapes.len()
} }
/// Get the selected clip instances /// Get the selected clip instances
@ -171,86 +235,61 @@ impl Selection {
self.selected_clip_instances.len() self.selected_clip_instances.len()
} }
/// Set selection to a single object (clears previous selection)
pub fn select_only_shape_instance(&mut self, id: Uuid) {
self.clear();
self.add_shape_instance(id);
}
/// Set selection to a single shape (clears previous selection)
pub fn select_only_shape(&mut self, id: Uuid) {
self.clear();
self.add_shape(id);
}
/// Set selection to a single clip instance (clears previous selection) /// Set selection to a single clip instance (clears previous selection)
pub fn select_only_clip_instance(&mut self, id: Uuid) { pub fn select_only_clip_instance(&mut self, id: Uuid) {
self.clear(); self.clear();
self.add_clip_instance(id); self.add_clip_instance(id);
} }
/// Set selection to multiple objects (clears previous selection) /// Set selection to multiple clip instances (clears previous clip selection)
pub fn select_shape_instances(&mut self, ids: &[Uuid]) {
self.clear_shape_instances();
for &id in ids {
self.add_shape_instance(id);
}
}
/// Set selection to multiple shapes (clears previous selection)
pub fn select_shapes(&mut self, ids: &[Uuid]) {
self.clear_shapes();
for &id in ids {
self.add_shape(id);
}
}
/// Set selection to multiple clip instances (clears previous selection)
pub fn select_clip_instances(&mut self, ids: &[Uuid]) { pub fn select_clip_instances(&mut self, ids: &[Uuid]) {
self.clear_clip_instances(); self.clear_clip_instances();
for &id in ids { for &id in ids {
self.add_clip_instance(id); self.add_clip_instance(id);
} }
} }
// -----------------------------------------------------------------------
// General
// -----------------------------------------------------------------------
/// Clear all selections
pub fn clear(&mut self) {
self.selected_vertices.clear();
self.selected_edges.clear();
self.selected_faces.clear();
self.selected_clip_instances.clear();
}
/// Check if selection is empty
pub fn is_empty(&self) -> bool {
self.selected_edges.is_empty()
&& self.selected_faces.is_empty()
&& self.selected_clip_instances.is_empty()
}
} }
/// Represents a temporary region-based split of shapes. /// Represents a temporary region-based selection.
/// ///
/// When a region select is active, shapes that cross the region boundary /// When a region select is active, elements that cross the region boundary
/// are temporarily split into "inside" and "outside" parts. The inside /// are tracked. If the user performs an operation, the selection is
/// parts are selected. If the user performs an operation, the split is /// committed; if they deselect, the original state is restored.
/// committed; if they deselect, the original shapes are restored.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RegionSelection { pub struct RegionSelection {
/// The clipping region as a closed BezPath (polygon or rect) /// The clipping region as a closed BezPath (polygon or rect)
pub region_path: BezPath, pub region_path: BezPath,
/// Layer containing the affected shapes /// Layer containing the affected elements
pub layer_id: Uuid, pub layer_id: Uuid,
/// Keyframe time /// Keyframe time
pub time: f64, pub time: f64,
/// Per-shape split results /// Per-shape split results (legacy, kept for compatibility)
pub splits: Vec<ShapeSplit>, pub splits: Vec<()>,
/// Shape IDs that were fully inside the region (not split, just selected) /// IDs that were fully inside the region
pub fully_inside_ids: Vec<Uuid>, pub fully_inside_ids: Vec<Uuid>,
/// Whether the split has been committed (via an operation on the selection) /// Whether the selection has been committed (via an operation on the selection)
pub committed: bool, pub committed: bool,
} }
/// One shape's split result from a region selection
#[derive(Clone, Debug)]
pub struct ShapeSplit {
/// The original shape (stored for reverting)
pub original_shape: Shape,
/// UUID for the "inside" portion shape
pub inside_shape_id: Uuid,
/// The clipped path inside the region
pub inside_path: BezPath,
/// UUID for the "outside" portion shape
pub outside_shape_id: Uuid,
/// The clipped path outside the region
pub outside_path: BezPath,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -259,67 +298,7 @@ mod tests {
fn test_selection_creation() { fn test_selection_creation() {
let selection = Selection::new(); let selection = Selection::new();
assert!(selection.is_empty()); assert!(selection.is_empty());
assert_eq!(selection.shape_instance_count(), 0); assert_eq!(selection.clip_instance_count(), 0);
assert_eq!(selection.shape_count(), 0);
}
#[test]
fn test_add_remove_objects() {
let mut selection = Selection::new();
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
selection.add_shape_instance(id1);
assert_eq!(selection.shape_instance_count(), 1);
assert!(selection.contains_shape_instance(&id1));
selection.add_shape_instance(id2);
assert_eq!(selection.shape_instance_count(), 2);
selection.remove_shape_instance(&id1);
assert_eq!(selection.shape_instance_count(), 1);
assert!(!selection.contains_shape_instance(&id1));
assert!(selection.contains_shape_instance(&id2));
}
#[test]
fn test_toggle() {
let mut selection = Selection::new();
let id = Uuid::new_v4();
selection.toggle_shape_instance(id);
assert!(selection.contains_shape_instance(&id));
selection.toggle_shape_instance(id);
assert!(!selection.contains_shape_instance(&id));
}
#[test]
fn test_select_only() {
let mut selection = Selection::new();
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
selection.add_shape_instance(id1);
selection.add_shape_instance(id2);
assert_eq!(selection.shape_instance_count(), 2);
selection.select_only_shape_instance(id1);
assert_eq!(selection.shape_instance_count(), 1);
assert!(selection.contains_shape_instance(&id1));
assert!(!selection.contains_shape_instance(&id2));
}
#[test]
fn test_clear() {
let mut selection = Selection::new();
selection.add_shape_instance(Uuid::new_v4());
selection.add_shape(Uuid::new_v4());
assert!(!selection.is_empty());
selection.clear();
assert!(selection.is_empty());
} }
#[test] #[test]
@ -370,54 +349,34 @@ mod tests {
} }
#[test] #[test]
fn test_clear_clip_instances() { fn test_clear() {
let mut selection = Selection::new(); let mut selection = Selection::new();
selection.add_clip_instance(Uuid::new_v4()); selection.add_clip_instance(Uuid::new_v4());
selection.add_clip_instance(Uuid::new_v4());
selection.add_shape_instance(Uuid::new_v4());
assert_eq!(selection.clip_instance_count(), 2);
assert_eq!(selection.shape_instance_count(), 1);
selection.clear_clip_instances();
assert_eq!(selection.clip_instance_count(), 0);
assert_eq!(selection.shape_instance_count(), 1);
}
#[test]
fn test_clip_instances_getter() {
let mut selection = Selection::new();
let id1 = Uuid::new_v4();
let id2 = Uuid::new_v4();
selection.add_clip_instance(id1);
selection.add_clip_instance(id2);
let clip_instances = selection.clip_instances();
assert_eq!(clip_instances.len(), 2);
assert!(clip_instances.contains(&id1));
assert!(clip_instances.contains(&id2));
}
#[test]
fn test_mixed_selection() {
let mut selection = Selection::new();
let shape_instance_id = Uuid::new_v4();
let clip_instance_id = Uuid::new_v4();
selection.add_shape_instance(shape_instance_id);
selection.add_clip_instance(clip_instance_id);
assert_eq!(selection.shape_instance_count(), 1);
assert_eq!(selection.clip_instance_count(), 1);
assert!(!selection.is_empty());
selection.clear_shape_instances();
assert_eq!(selection.shape_instance_count(), 0);
assert_eq!(selection.clip_instance_count(), 1);
assert!(!selection.is_empty()); assert!(!selection.is_empty());
selection.clear(); selection.clear();
assert!(selection.is_empty()); assert!(selection.is_empty());
} }
#[test]
fn test_dcel_selection_basics() {
let selection = Selection::new();
assert!(!selection.has_dcel_selection());
assert!(selection.selected_edges().is_empty());
assert!(selection.selected_faces().is_empty());
assert!(selection.selected_vertices().is_empty());
}
#[test]
fn test_clear_dcel_selection() {
let mut selection = Selection::new();
// Manually insert for unit test (no DCEL needed)
selection.selected_edges.insert(EdgeId(0));
selection.selected_vertices.insert(VertexId(0));
assert!(selection.has_dcel_selection());
selection.clear_dcel_selection();
assert!(!selection.has_dcel_selection());
}
} }

View File

@ -60,7 +60,7 @@ pub enum Cap {
impl Default for Cap { impl Default for Cap {
fn default() -> Self { fn default() -> Self {
Cap::Butt Cap::Round
} }
} }
@ -122,7 +122,7 @@ impl Default for StrokeStyle {
fn default() -> Self { fn default() -> Self {
Self { Self {
width: 1.0, width: 1.0,
cap: Cap::Butt, cap: Cap::Round,
join: Join::Miter, join: Join::Miter,
miter_limit: 4.0, miter_limit: 4.0,
} }

View File

@ -116,22 +116,25 @@ pub enum ToolState {
num_sides: u32, // Number of sides (from properties, default 5) num_sides: u32, // Number of sides (from properties, default 5)
}, },
/// Editing a vertex (dragging it and connected curves) /// Editing a vertex (dragging it and connected edges)
EditingVertex { EditingVertex {
shape_id: Uuid, // Which shape is being edited vertex_id: crate::dcel::VertexId,
vertex_index: usize, // Which vertex in the vertices array connected_edges: Vec<crate::dcel::EdgeId>, // edges to update when vertex moves
start_pos: Point, // Vertex position when drag started
start_mouse: Point, // Mouse position when drag started
affected_curve_indices: Vec<usize>, // Which curves connect to this vertex
}, },
/// Editing a curve (reshaping with moldCurve algorithm) /// Editing a curve (reshaping with moldCurve algorithm)
EditingCurve { EditingCurve {
shape_id: Uuid, // Which shape is being edited edge_id: crate::dcel::EdgeId,
curve_index: usize, // Which curve in the curves array original_curve: vello::kurbo::CubicBez,
original_curve: vello::kurbo::CubicBez, // The curve when drag started start_mouse: Point,
start_mouse: Point, // Mouse position when drag started parameter_t: f64,
parameter_t: f64, // Parameter where the drag started (0.0-1.0) },
/// Pending curve interaction: click selects edge, drag starts curve editing
PendingCurveInteraction {
edge_id: crate::dcel::EdgeId,
parameter_t: f64,
start_mouse: Point,
}, },
/// Drawing a region selection rectangle /// Drawing a region selection rectangle
@ -147,11 +150,10 @@ pub enum ToolState {
/// Editing a control point (BezierEdit tool only) /// Editing a control point (BezierEdit tool only)
EditingControlPoint { EditingControlPoint {
shape_id: Uuid, // Which shape is being edited edge_id: crate::dcel::EdgeId,
curve_index: usize, // Which curve owns this control point
point_index: u8, // 1 or 2 (p1 or p2 of the cubic bezier) point_index: u8, // 1 or 2 (p1 or p2 of the cubic bezier)
original_curve: vello::kurbo::CubicBez, // The curve when drag started original_curve: vello::kurbo::CubicBez,
start_pos: Point, // Control point position when drag started start_pos: Point,
}, },
} }

View File

@ -20,7 +20,7 @@ fn setup_test_document() -> (Document, Uuid, Uuid, Uuid) {
let mut document = Document::new("Test Project"); let mut document = Document::new("Test Project");
// Create a vector clip // Create a vector clip
let vector_clip = VectorClip::new("Test Clip", 10.0, 1920.0, 1080.0); let vector_clip = VectorClip::new("Test Clip", 1920.0, 1080.0, 10.0);
let clip_id = vector_clip.id; let clip_id = vector_clip.id;
document.vector_clips.insert(clip_id, vector_clip); document.vector_clips.insert(clip_id, vector_clip);
@ -126,7 +126,7 @@ fn test_transform_clip_instance_workflow() {
let mut transforms = HashMap::new(); let mut transforms = HashMap::new();
transforms.insert(instance_id, (old_transform, new_transform)); transforms.insert(instance_id, (old_transform, new_transform));
let mut action = TransformClipInstancesAction::new(layer_id, transforms); let mut action = TransformClipInstancesAction::new(layer_id, 0.0, transforms);
// Execute // Execute
action.execute(&mut document); action.execute(&mut document);
@ -214,7 +214,7 @@ fn test_multiple_clip_instances_workflow() {
let mut document = Document::new("Test Project"); let mut document = Document::new("Test Project");
// Create a vector clip // Create a vector clip
let vector_clip = VectorClip::new("Test Clip", 10.0, 1920.0, 1080.0); let vector_clip = VectorClip::new("Test Clip", 1920.0, 1080.0, 10.0);
let clip_id = vector_clip.id; let clip_id = vector_clip.id;
document.vector_clips.insert(clip_id, vector_clip); document.vector_clips.insert(clip_id, vector_clip);
@ -294,7 +294,7 @@ fn test_clip_time_remapping() {
let mut document = Document::new("Test Project"); let mut document = Document::new("Test Project");
// Create a 10 second clip // Create a 10 second clip
let vector_clip = VectorClip::new("Test Clip", 10.0, 1920.0, 1080.0); let vector_clip = VectorClip::new("Test Clip", 1920.0, 1080.0, 10.0);
let clip_id = vector_clip.id; let clip_id = vector_clip.id;
let clip_duration = vector_clip.duration; let clip_duration = vector_clip.duration;
document.vector_clips.insert(clip_id, vector_clip); document.vector_clips.insert(clip_id, vector_clip);

View File

@ -80,7 +80,7 @@ fn test_render_with_transform() {
// Render with zoom and pan // Render with zoom and pan
let transform = Affine::translate((100.0, 50.0)) * Affine::scale(2.0); let transform = Affine::translate((100.0, 50.0)) * Affine::scale(2.0);
render_document_with_transform(&document, &mut scene, transform, &mut image_cache, &video_manager, None); render_document_with_transform(&document, &mut scene, transform, &mut image_cache, &video_manager);
} }
#[test] #[test]

View File

@ -189,7 +189,7 @@ fn test_selection_with_transform_action() {
transforms.insert(id, (old_transform.clone(), new_transform.clone())); transforms.insert(id, (old_transform.clone(), new_transform.clone()));
} }
let mut action = TransformClipInstancesAction::new(layer_id, transforms); let mut action = TransformClipInstancesAction::new(layer_id, 0.0, transforms);
action.execute(&mut document); action.execute(&mut document);
// Verify transform applied // Verify transform applied

View File

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

View File

@ -747,7 +747,6 @@ pub fn render_frame_to_rgba_hdr(
base_transform, base_transform,
image_cache, image_cache,
video_manager, video_manager,
None, // No skipping during export
); );
// Buffer specs for layer rendering // Buffer specs for layer rendering
@ -1133,7 +1132,6 @@ pub fn render_frame_to_gpu_rgba(
base_transform, base_transform,
image_cache, image_cache,
video_manager, video_manager,
None, // No skipping during export
); );
// Buffer specs for layer rendering // Buffer specs for layer rendering

View File

@ -1658,37 +1658,8 @@ impl EditorApp {
}; };
self.clipboard_manager.copy(content); self.clipboard_manager.copy(content);
} else if !self.selection.shape_instances().is_empty() { } else if self.selection.has_dcel_selection() {
let active_layer_id = match self.active_layer_id { // TODO: DCEL copy/paste deferred to Phase 2 (serialize subgraph)
Some(id) => id,
None => return,
};
let document = self.action_executor.document();
let layer = match document.get_layer(&active_layer_id) {
Some(l) => l,
None => return,
};
let vector_layer = match layer {
AnyLayer::Vector(vl) => vl,
_ => return,
};
// Gather selected shapes (they now contain their own transforms)
let selected_shapes: Vec<_> = self.selection.shapes().iter()
.filter_map(|id| vector_layer.shapes.get(id).cloned())
.collect();
if selected_shapes.is_empty() {
return;
}
let content = ClipboardContent::Shapes {
shapes: selected_shapes,
};
self.clipboard_manager.copy(content);
} }
} }
@ -1736,26 +1707,45 @@ impl EditorApp {
} }
self.selection.clear_clip_instances(); self.selection.clear_clip_instances();
} else if !self.selection.shapes().is_empty() { } else if self.selection.has_dcel_selection() {
let active_layer_id = match self.active_layer_id { let active_layer_id = match self.active_layer_id {
Some(id) => id, Some(id) => id,
None => return, None => return,
}; };
let shape_ids: Vec<Uuid> = self.selection.shapes().to_vec(); // Delete selected edges via snapshot-based ModifyDcelAction
let edge_ids: Vec<lightningbeam_core::dcel::EdgeId> =
self.selection.selected_edges().iter().copied().collect();
let action = lightningbeam_core::actions::RemoveShapesAction::new( if !edge_ids.is_empty() {
active_layer_id, let document = self.action_executor.document();
shape_ids, if let Some(layer) = document.get_layer(&active_layer_id) {
self.playback_time, if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer {
); if let Some(dcel_before) = vector_layer.dcel_at_time(self.playback_time) {
let mut dcel_after = dcel_before.clone();
for edge_id in &edge_ids {
if !dcel_after.edge(*edge_id).deleted {
dcel_after.remove_edge(*edge_id);
}
}
if let Err(e) = self.action_executor.execute(Box::new(action)) { let action = lightningbeam_core::actions::ModifyDcelAction::new(
eprintln!("Delete shapes failed: {}", e); active_layer_id,
self.playback_time,
dcel_before.clone(),
dcel_after,
"Delete selected edges",
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Delete DCEL edges failed: {}", e);
}
}
}
}
} }
self.selection.clear_shape_instances(); self.selection.clear_dcel_selection();
self.selection.clear_shapes();
} }
} }
@ -1885,18 +1875,8 @@ impl EditorApp {
} }
}; };
let new_shape_ids: Vec<Uuid> = shapes.iter().map(|s| s.id).collect(); // TODO: DCEL - paste shapes not yet implemented
let _ = (vector_layer, shapes);
let kf = vector_layer.ensure_keyframe_at(self.playback_time);
for shape in shapes {
kf.shapes.push(shape);
}
// Select pasted shapes
self.selection.clear_shapes();
for id in new_shape_ids {
self.selection.add_shape(id);
}
} }
ClipboardContent::MidiNotes { .. } => { ClipboardContent::MidiNotes { .. } => {
// MIDI notes are pasted directly in the piano roll pane, not here // MIDI notes are pasted directly in the piano roll pane, not here
@ -2098,11 +2078,9 @@ impl EditorApp {
_ => return, _ => return,
}; };
for split in &region_sel.splits { // TODO: DCEL - region selection revert disabled during migration
vector_layer.remove_shape_from_keyframe(&split.inside_shape_id, region_sel.time); // (was: remove/add_shape_from/to_keyframe for splits)
vector_layer.remove_shape_from_keyframe(&split.outside_shape_id, region_sel.time); let _ = vector_layer;
vector_layer.add_shape_to_keyframe(split.original_shape.clone(), region_sel.time);
}
selection.clear(); selection.clear();
} }
@ -2429,44 +2407,51 @@ impl EditorApp {
// Modify menu // Modify menu
MenuAction::Group => { MenuAction::Group => {
if let Some(layer_id) = self.active_layer_id { if let Some(layer_id) = self.active_layer_id {
let shape_ids: Vec<uuid::Uuid> = self.selection.shape_instances().to_vec(); if self.selection.has_dcel_selection() {
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec(); // TODO: DCEL group deferred to Phase 2 (extract subgraph)
if shape_ids.len() + clip_ids.len() >= 2 { } else {
let instance_id = uuid::Uuid::new_v4(); let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
let action = lightningbeam_core::actions::GroupAction::new( if clip_ids.len() >= 2 {
layer_id, let instance_id = uuid::Uuid::new_v4();
self.playback_time, let action = lightningbeam_core::actions::GroupAction::new(
shape_ids, layer_id,
clip_ids, self.playback_time,
instance_id, Vec::new(),
); clip_ids,
if let Err(e) = self.action_executor.execute(Box::new(action)) { instance_id,
eprintln!("Failed to group: {}", e); );
} else { if let Err(e) = self.action_executor.execute(Box::new(action)) {
self.selection.clear(); eprintln!("Failed to group: {}", e);
self.selection.add_clip_instance(instance_id); } else {
self.selection.clear();
self.selection.add_clip_instance(instance_id);
}
} }
} }
let _ = layer_id;
} }
} }
MenuAction::ConvertToMovieClip => { MenuAction::ConvertToMovieClip => {
if let Some(layer_id) = self.active_layer_id { if let Some(layer_id) = self.active_layer_id {
let shape_ids: Vec<uuid::Uuid> = self.selection.shape_instances().to_vec(); if self.selection.has_dcel_selection() {
let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec(); // TODO: DCEL convert-to-movie-clip deferred to Phase 2
if shape_ids.len() + clip_ids.len() >= 1 { } else {
let instance_id = uuid::Uuid::new_v4(); let clip_ids: Vec<uuid::Uuid> = self.selection.clip_instances().to_vec();
let action = lightningbeam_core::actions::ConvertToMovieClipAction::new( if clip_ids.len() >= 1 {
layer_id, let instance_id = uuid::Uuid::new_v4();
self.playback_time, let action = lightningbeam_core::actions::ConvertToMovieClipAction::new(
shape_ids, layer_id,
clip_ids, self.playback_time,
instance_id, Vec::new(),
); clip_ids,
if let Err(e) = self.action_executor.execute(Box::new(action)) { instance_id,
eprintln!("Failed to convert to movie clip: {}", e); );
} else { if let Err(e) = self.action_executor.execute(Box::new(action)) {
self.selection.clear(); eprintln!("Failed to convert to movie clip: {}", e);
self.selection.add_clip_instance(instance_id); } else {
self.selection.clear();
self.selection.add_clip_instance(instance_id);
}
} }
} }
} }
@ -2626,7 +2611,7 @@ impl EditorApp {
let mut test_clip = VectorClip::new(&clip_name, 400.0, 400.0, 5.0); let mut test_clip = VectorClip::new(&clip_name, 400.0, 400.0, 5.0);
// Create a layer with some shapes // Create a layer with some shapes
let mut layer = VectorLayer::new("Shapes"); let layer = VectorLayer::new("Shapes");
// Create a red circle shape // Create a red circle shape
let circle_path = Circle::new((100.0, 100.0), 50.0).to_path(0.1); let circle_path = Circle::new((100.0, 100.0), 50.0).to_path(0.1);
@ -2638,10 +2623,8 @@ impl EditorApp {
let mut rect_shape = Shape::new(rect_path); let mut rect_shape = Shape::new(rect_path);
rect_shape.fill_color = Some(ShapeColor::rgb(0, 0, 255)); rect_shape.fill_color = Some(ShapeColor::rgb(0, 0, 255));
// Add shapes to keyframe at time 0.0 // TODO: DCEL - test shape creation not yet implemented
let kf = layer.ensure_keyframe_at(0.0); let _ = (circle_shape, rect_shape);
kf.shapes.push(circle_shape);
kf.shapes.push(rect_shape);
// Add the layer to the clip // Add the layer to the clip
test_clip.layers.add_root(AnyLayer::Vector(layer)); test_clip.layers.add_root(AnyLayer::Vector(layer));
@ -2664,14 +2647,11 @@ impl EditorApp {
if let Some(layer_id) = self.active_layer_id { if let Some(layer_id) = self.active_layer_id {
let document = self.action_executor.document(); let document = self.action_executor.document();
// Determine which selected objects are shape instances vs clip instances // Determine which selected objects are shape instances vs clip instances
let mut shape_ids = Vec::new(); let _shape_ids: Vec<uuid::Uuid> = Vec::new();
let mut clip_ids = Vec::new(); let mut clip_ids = Vec::new();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) { if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
for &id in self.selection.shape_instances() { // TODO: DCEL - shape instance lookup disabled during migration
if vl.get_shape_in_keyframe(&id, self.playback_time).is_some() { // (was: get_shape_in_keyframe to check which selected objects are shapes)
shape_ids.push(id);
}
}
for &id in self.selection.clip_instances() { for &id in self.selection.clip_instances() {
if vl.clip_instances.iter().any(|ci| ci.id == id) { if vl.clip_instances.iter().any(|ci| ci.id == id) {
clip_ids.push(id); clip_ids.push(id);
@ -2940,6 +2920,13 @@ impl EditorApp {
return; return;
} }
eprintln!("📊 [APPLY] Step 4: Set audio project took {:.2}ms", step4_start.elapsed().as_secs_f64() * 1000.0); eprintln!("📊 [APPLY] Step 4: Set audio project took {:.2}ms", step4_start.elapsed().as_secs_f64() * 1000.0);
// Sync BPM/time signature to metronome
let doc = self.action_executor.document();
controller.set_tempo(
doc.bpm as f32,
(doc.time_signature.numerator, doc.time_signature.denominator),
);
} }
// Reset state and restore track mappings // Reset state and restore track mappings
@ -3548,34 +3535,10 @@ impl EditorApp {
// Get image dimensions // Get image dimensions
let (width, height) = asset_info.dimensions.unwrap_or((100.0, 100.0)); let (width, height) = asset_info.dimensions.unwrap_or((100.0, 100.0));
// Get document center position // TODO: Image fills on DCEL faces are a separate feature.
let doc = self.action_executor.document(); // For now, just log a message.
let center_x = doc.width / 2.0; let _ = (layer_id, width, height);
let center_y = doc.height / 2.0; eprintln!("Image drop to canvas not yet supported with DCEL backend");
// Create a rectangle path at the origin (position handled by transform)
use kurbo::BezPath;
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((width, 0.0));
path.line_to((width, height));
path.line_to((0.0, height));
path.close_path();
// Create shape with image fill (references the ImageAsset)
use lightningbeam_core::shape::Shape;
let shape = Shape::new(path).with_image_fill(asset_info.clip_id);
// Set position on shape directly
let shape = shape.with_position(center_x, center_y);
// Create and execute action
let action = lightningbeam_core::actions::AddShapeAction::new(
layer_id,
shape,
self.playback_time,
);
let _ = self.action_executor.execute(Box::new(action));
} else { } else {
// For clips, create a clip instance // For clips, create a clip instance
let mut clip_instance = ClipInstance::new(asset_info.clip_id) let mut clip_instance = ClipInstance::new(asset_info.clip_id)

View File

@ -10,7 +10,6 @@ use eframe::egui;
use lightningbeam_core::clip::{AudioClipType, VectorClip}; use lightningbeam_core::clip::{AudioClipType, VectorClip};
use lightningbeam_core::document::Document; use lightningbeam_core::document::Document;
use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::layer::AnyLayer;
use lightningbeam_core::shape::ShapeColor;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use uuid::Uuid; use uuid::Uuid;
@ -413,8 +412,7 @@ fn generate_midi_thumbnail(
/// Generate a 64x64 RGBA thumbnail for a vector clip /// Generate a 64x64 RGBA thumbnail for a vector clip
/// Renders frame 0 of the clip using tiny-skia for software rendering /// Renders frame 0 of the clip using tiny-skia for software rendering
fn generate_vector_thumbnail(clip: &VectorClip, bg_color: egui::Color32) -> Vec<u8> { fn generate_vector_thumbnail(clip: &VectorClip, bg_color: egui::Color32) -> Vec<u8> {
use kurbo::PathEl; use tiny_skia::Pixmap;
use tiny_skia::{Paint, PathBuilder, Pixmap, Transform as TsTransform};
let size = THUMBNAIL_SIZE as usize; let size = THUMBNAIL_SIZE as usize;
let mut pixmap = Pixmap::new(THUMBNAIL_SIZE, THUMBNAIL_SIZE) let mut pixmap = Pixmap::new(THUMBNAIL_SIZE, THUMBNAIL_SIZE)
@ -431,94 +429,14 @@ fn generate_vector_thumbnail(clip: &VectorClip, bg_color: egui::Color32) -> Vec<
// Calculate scale to fit clip dimensions into thumbnail // Calculate scale to fit clip dimensions into thumbnail
let scale_x = THUMBNAIL_SIZE as f64 / clip.width.max(1.0); let scale_x = THUMBNAIL_SIZE as f64 / clip.width.max(1.0);
let scale_y = THUMBNAIL_SIZE as f64 / clip.height.max(1.0); let scale_y = THUMBNAIL_SIZE as f64 / clip.height.max(1.0);
let scale = scale_x.min(scale_y) * 0.9; // 90% to leave a small margin let _scale = scale_x.min(scale_y) * 0.9; // 90% to leave a small margin
// Center offset
let offset_x = (THUMBNAIL_SIZE as f64 - clip.width * scale) / 2.0;
let offset_y = (THUMBNAIL_SIZE as f64 - clip.height * scale) / 2.0;
// Iterate through layers and render shapes // Iterate through layers and render shapes
for layer_node in clip.layers.iter() { for layer_node in clip.layers.iter() {
if let AnyLayer::Vector(vector_layer) = &layer_node.data { if let AnyLayer::Vector(vector_layer) = &layer_node.data {
// Render each shape at time 0.0 (frame 0) // TODO: DCEL - thumbnail shape rendering disabled during migration
for shape in vector_layer.shapes_at_time(0.0) { // (was: shapes_at_time(0.0) to render shape fills/strokes into thumbnail)
// Get the path (frame 0) let _ = vector_layer;
let kurbo_path = shape.path();
// Convert kurbo BezPath to tiny-skia PathBuilder
let mut path_builder = PathBuilder::new();
for el in kurbo_path.iter() {
match el {
PathEl::MoveTo(p) => {
let x = (p.x * scale + offset_x) as f32;
let y = (p.y * scale + offset_y) as f32;
path_builder.move_to(x, y);
}
PathEl::LineTo(p) => {
let x = (p.x * scale + offset_x) as f32;
let y = (p.y * scale + offset_y) as f32;
path_builder.line_to(x, y);
}
PathEl::QuadTo(p1, p2) => {
let x1 = (p1.x * scale + offset_x) as f32;
let y1 = (p1.y * scale + offset_y) as f32;
let x2 = (p2.x * scale + offset_x) as f32;
let y2 = (p2.y * scale + offset_y) as f32;
path_builder.quad_to(x1, y1, x2, y2);
}
PathEl::CurveTo(p1, p2, p3) => {
let x1 = (p1.x * scale + offset_x) as f32;
let y1 = (p1.y * scale + offset_y) as f32;
let x2 = (p2.x * scale + offset_x) as f32;
let y2 = (p2.y * scale + offset_y) as f32;
let x3 = (p3.x * scale + offset_x) as f32;
let y3 = (p3.y * scale + offset_y) as f32;
path_builder.cubic_to(x1, y1, x2, y2, x3, y3);
}
PathEl::ClosePath => {
path_builder.close();
}
}
}
if let Some(ts_path) = path_builder.finish() {
// Draw fill if present
if let Some(fill_color) = &shape.fill_color {
let mut paint = Paint::default();
paint.set_color(shape_color_to_tiny_skia(fill_color));
paint.anti_alias = true;
pixmap.fill_path(
&ts_path,
&paint,
tiny_skia::FillRule::Winding,
TsTransform::identity(),
None,
);
}
// Draw stroke if present
if let Some(stroke_color) = &shape.stroke_color {
if let Some(stroke_style) = &shape.stroke_style {
let mut paint = Paint::default();
paint.set_color(shape_color_to_tiny_skia(stroke_color));
paint.anti_alias = true;
let stroke = tiny_skia::Stroke {
width: (stroke_style.width * scale) as f32,
..Default::default()
};
pixmap.stroke_path(
&ts_path,
&paint,
&stroke,
TsTransform::identity(),
None,
);
}
}
}
}
} }
} }
@ -541,11 +459,6 @@ fn generate_vector_thumbnail(clip: &VectorClip, bg_color: egui::Color32) -> Vec<
rgba rgba
} }
/// Convert ShapeColor to tiny_skia Color
fn shape_color_to_tiny_skia(color: &ShapeColor) -> tiny_skia::Color {
tiny_skia::Color::from_rgba8(color.r, color.g, color.b, color.a)
}
/// Generate a simple effect thumbnail with a pink gradient /// Generate a simple effect thumbnail with a pink gradient
#[allow(dead_code)] #[allow(dead_code)]
fn generate_effect_thumbnail() -> Vec<u8> { fn generate_effect_thumbnail() -> Vec<u8> {

View File

@ -6,11 +6,8 @@
/// - Shape properties (fill/stroke for selected shapes) /// - Shape properties (fill/stroke for selected shapes)
/// - Document settings (when nothing is selected) /// - Document settings (when nothing is selected)
use eframe::egui::{self, DragValue, Sense, Ui}; use eframe::egui::{self, DragValue, Ui};
use lightningbeam_core::actions::{ use lightningbeam_core::actions::{SetDocumentPropertiesAction, SetShapePropertiesAction};
InstancePropertyChange, SetDocumentPropertiesAction, SetInstancePropertiesAction,
SetShapePropertiesAction,
};
use lightningbeam_core::layer::AnyLayer; use lightningbeam_core::layer::AnyLayer;
use lightningbeam_core::shape::ShapeColor; use lightningbeam_core::shape::ShapeColor;
use lightningbeam_core::tool::{SimplifyMode, Tool}; use lightningbeam_core::tool::{SimplifyMode, Tool};
@ -21,8 +18,6 @@ use uuid::Uuid;
pub struct InfopanelPane { pub struct InfopanelPane {
/// Whether the tool options section is expanded /// Whether the tool options section is expanded
tool_section_open: bool, tool_section_open: bool,
/// Whether the transform section is expanded
transform_section_open: bool,
/// Whether the shape properties section is expanded /// Whether the shape properties section is expanded
shape_section_open: bool, shape_section_open: bool,
} }
@ -31,7 +26,6 @@ impl InfopanelPane {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
tool_section_open: true, tool_section_open: true,
transform_section_open: true,
shape_section_open: true, shape_section_open: true,
} }
} }
@ -41,24 +35,10 @@ impl InfopanelPane {
struct SelectionInfo { struct SelectionInfo {
/// True if nothing is selected /// True if nothing is selected
is_empty: bool, is_empty: bool,
/// Number of selected shape instances /// Number of selected DCEL elements (edges + faces)
shape_count: usize, dcel_count: usize,
/// Layer ID of selected shapes (assumes single layer selection for now) /// Layer ID of selected elements (assumes single layer selection for now)
layer_id: Option<Uuid>, layer_id: Option<Uuid>,
/// Selected shape instance IDs
instance_ids: Vec<Uuid>,
/// Shape IDs referenced by selected instances
shape_ids: Vec<Uuid>,
// Transform values (None = mixed values across selection)
x: Option<f64>,
y: Option<f64>,
rotation: Option<f64>,
scale_x: Option<f64>,
scale_y: Option<f64>,
skew_x: Option<f64>,
skew_y: Option<f64>,
opacity: Option<f64>,
// Shape property values (None = mixed) // Shape property values (None = mixed)
fill_color: Option<Option<ShapeColor>>, fill_color: Option<Option<ShapeColor>>,
@ -70,18 +50,8 @@ impl Default for SelectionInfo {
fn default() -> Self { fn default() -> Self {
Self { Self {
is_empty: true, is_empty: true,
shape_count: 0, dcel_count: 0,
layer_id: None, layer_id: None,
instance_ids: Vec::new(),
shape_ids: Vec::new(),
x: None,
y: None,
rotation: None,
scale_x: None,
scale_y: None,
skew_x: None,
skew_y: None,
opacity: None,
fill_color: None, fill_color: None,
stroke_color: None, stroke_color: None,
stroke_width: None, stroke_width: None,
@ -94,17 +64,15 @@ impl InfopanelPane {
fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo { fn gather_selection_info(&self, shared: &SharedPaneState) -> SelectionInfo {
let mut info = SelectionInfo::default(); let mut info = SelectionInfo::default();
let selected_instances = shared.selection.shape_instances(); let edge_count = shared.selection.selected_edges().len();
info.shape_count = selected_instances.len(); let face_count = shared.selection.selected_faces().len();
info.is_empty = info.shape_count == 0; info.dcel_count = edge_count + face_count;
info.is_empty = info.dcel_count == 0;
if info.is_empty { if info.is_empty {
return info; return info;
} }
info.instance_ids = selected_instances.to_vec();
// Find the layer containing the selected instances
let document = shared.action_executor.document(); let document = shared.action_executor.document();
let active_layer_id = *shared.active_layer_id; let active_layer_id = *shared.active_layer_id;
@ -113,83 +81,54 @@ impl InfopanelPane {
if let Some(layer) = document.get_layer(&layer_id) { if let Some(layer) = document.get_layer(&layer_id) {
if let AnyLayer::Vector(vector_layer) = layer { if let AnyLayer::Vector(vector_layer) = layer {
// Gather values from all selected instances if let Some(dcel) = vector_layer.dcel_at_time(*shared.playback_time) {
let mut first = true; // Gather stroke properties from selected edges
let mut first_stroke_color: Option<Option<ShapeColor>> = None;
let mut first_stroke_width: Option<f64> = None;
let mut stroke_color_mixed = false;
let mut stroke_width_mixed = false;
for instance_id in &info.instance_ids { for &eid in shared.selection.selected_edges() {
if let Some(shape) = vector_layer.get_shape_in_keyframe(instance_id, *shared.playback_time) { let edge = dcel.edge(eid);
info.shape_ids.push(*instance_id); let sc = edge.stroke_color;
let sw = edge.stroke_style.as_ref().map(|s| s.width);
if first { match first_stroke_color {
// First shape - set initial values None => first_stroke_color = Some(sc),
info.x = Some(shape.transform.x); Some(prev) if prev != sc => stroke_color_mixed = true,
info.y = Some(shape.transform.y); _ => {}
info.rotation = Some(shape.transform.rotation);
info.scale_x = Some(shape.transform.scale_x);
info.scale_y = Some(shape.transform.scale_y);
info.skew_x = Some(shape.transform.skew_x);
info.skew_y = Some(shape.transform.skew_y);
info.opacity = Some(shape.opacity);
// Get shape properties
info.fill_color = Some(shape.fill_color);
info.stroke_color = Some(shape.stroke_color);
info.stroke_width = shape
.stroke_style
.as_ref()
.map(|s| Some(s.width))
.unwrap_or(Some(1.0));
first = false;
} else {
// Check if values differ (set to None if mixed)
if info.x != Some(shape.transform.x) {
info.x = None;
}
if info.y != Some(shape.transform.y) {
info.y = None;
}
if info.rotation != Some(shape.transform.rotation) {
info.rotation = None;
}
if info.scale_x != Some(shape.transform.scale_x) {
info.scale_x = None;
}
if info.scale_y != Some(shape.transform.scale_y) {
info.scale_y = None;
}
if info.skew_x != Some(shape.transform.skew_x) {
info.skew_x = None;
}
if info.skew_y != Some(shape.transform.skew_y) {
info.skew_y = None;
}
if info.opacity != Some(shape.opacity) {
info.opacity = None;
}
// Check shape properties
// Compare fill colors - set to None if mixed
if let Some(current_fill) = &info.fill_color {
if *current_fill != shape.fill_color {
info.fill_color = None;
}
}
// Compare stroke colors - set to None if mixed
if let Some(current_stroke) = &info.stroke_color {
if *current_stroke != shape.stroke_color {
info.stroke_color = None;
}
}
let stroke_w = shape
.stroke_style
.as_ref()
.map(|s| s.width)
.unwrap_or(1.0);
if info.stroke_width != Some(stroke_w) {
info.stroke_width = None;
}
} }
match (first_stroke_width, sw) {
(None, _) => first_stroke_width = sw,
(Some(prev), Some(cur)) if (prev - cur).abs() > 0.01 => stroke_width_mixed = true,
_ => {}
}
}
if !stroke_color_mixed {
info.stroke_color = first_stroke_color;
}
if !stroke_width_mixed {
info.stroke_width = first_stroke_width;
}
// Gather fill properties from selected faces
let mut first_fill_color: Option<Option<ShapeColor>> = None;
let mut fill_color_mixed = false;
for &fid in shared.selection.selected_faces() {
let face = dcel.face(fid);
let fc = face.fill_color;
match first_fill_color {
None => first_fill_color = Some(fc),
Some(prev) if prev != fc => fill_color_mixed = true,
_ => {}
}
}
if !fill_color_mixed {
info.fill_color = first_fill_color;
} }
} }
} }
@ -337,207 +276,7 @@ impl InfopanelPane {
}); });
} }
/// Render transform properties section // Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms)
fn render_transform_section(
&mut self,
ui: &mut Ui,
path: &NodePath,
shared: &mut SharedPaneState,
info: &SelectionInfo,
) {
egui::CollapsingHeader::new("Transform")
.id_salt(("transform", path))
.default_open(self.transform_section_open)
.show(ui, |ui| {
self.transform_section_open = true;
ui.add_space(4.0);
let layer_id = match info.layer_id {
Some(id) => id,
None => return,
};
// Position X
self.render_transform_field(
ui,
"X:",
info.x,
1.0,
f64::NEG_INFINITY..=f64::INFINITY,
|value| InstancePropertyChange::X(value),
layer_id,
&info.instance_ids,
shared,
);
// Position Y
self.render_transform_field(
ui,
"Y:",
info.y,
1.0,
f64::NEG_INFINITY..=f64::INFINITY,
|value| InstancePropertyChange::Y(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Rotation
self.render_transform_field(
ui,
"Rotation:",
info.rotation,
1.0,
-360.0..=360.0,
|value| InstancePropertyChange::Rotation(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Scale X
self.render_transform_field(
ui,
"Scale X:",
info.scale_x,
0.01,
0.01..=100.0,
|value| InstancePropertyChange::ScaleX(value),
layer_id,
&info.instance_ids,
shared,
);
// Scale Y
self.render_transform_field(
ui,
"Scale Y:",
info.scale_y,
0.01,
0.01..=100.0,
|value| InstancePropertyChange::ScaleY(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Skew X
self.render_transform_field(
ui,
"Skew X:",
info.skew_x,
1.0,
-89.0..=89.0,
|value| InstancePropertyChange::SkewX(value),
layer_id,
&info.instance_ids,
shared,
);
// Skew Y
self.render_transform_field(
ui,
"Skew Y:",
info.skew_y,
1.0,
-89.0..=89.0,
|value| InstancePropertyChange::SkewY(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
// Opacity
self.render_transform_field(
ui,
"Opacity:",
info.opacity,
0.01,
0.0..=1.0,
|value| InstancePropertyChange::Opacity(value),
layer_id,
&info.instance_ids,
shared,
);
ui.add_space(4.0);
});
}
/// Render a single transform property field with drag-to-adjust
fn render_transform_field<F>(
&self,
ui: &mut Ui,
label: &str,
value: Option<f64>,
speed: f64,
range: std::ops::RangeInclusive<f64>,
make_change: F,
layer_id: Uuid,
instance_ids: &[Uuid],
shared: &mut SharedPaneState,
) where
F: Fn(f64) -> InstancePropertyChange,
{
ui.horizontal(|ui| {
// Label with drag sense for drag-to-adjust
let label_response = ui.add(egui::Label::new(label).sense(Sense::drag()));
match value {
Some(mut v) => {
// Handle drag on label
if label_response.dragged() {
let delta = label_response.drag_delta().x as f64 * speed;
v = (v + delta).clamp(*range.start(), *range.end());
// Create action for each selected instance
for instance_id in instance_ids {
let action = SetInstancePropertiesAction::new(
layer_id,
*shared.playback_time,
*instance_id,
make_change(v),
);
shared.pending_actions.push(Box::new(action));
}
}
// DragValue widget
let response = ui.add(
DragValue::new(&mut v)
.speed(speed)
.range(range.clone()),
);
if response.changed() {
// Create action for each selected instance
for instance_id in instance_ids {
let action = SetInstancePropertiesAction::new(
layer_id,
*shared.playback_time,
*instance_id,
make_change(v),
);
shared.pending_actions.push(Box::new(action));
}
}
}
None => {
// Mixed values - show placeholder
ui.label("--");
}
}
});
}
/// Render shape properties section (fill/stroke) /// Render shape properties section (fill/stroke)
fn render_shape_section( fn render_shape_section(
@ -547,6 +286,15 @@ impl InfopanelPane {
shared: &mut SharedPaneState, shared: &mut SharedPaneState,
info: &SelectionInfo, info: &SelectionInfo,
) { ) {
// Clone IDs and values we need before borrowing shared mutably
let layer_id = match info.layer_id {
Some(id) => id,
None => return,
};
let time = *shared.playback_time;
let face_ids: Vec<_> = shared.selection.selected_faces().iter().copied().collect();
let edge_ids: Vec<_> = shared.selection.selected_edges().iter().copied().collect();
egui::CollapsingHeader::new("Shape") egui::CollapsingHeader::new("Shape")
.id_salt(("shape", path)) .id_salt(("shape", path))
.default_open(self.shape_section_open) .default_open(self.shape_section_open)
@ -554,11 +302,6 @@ impl InfopanelPane {
self.shape_section_open = true; self.shape_section_open = true;
ui.add_space(4.0); ui.add_space(4.0);
let layer_id = match info.layer_id {
Some(id) => id,
None => return,
};
// Fill color // Fill color
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Fill:"); ui.label("Fill:");
@ -567,41 +310,25 @@ impl InfopanelPane {
let mut egui_color = egui::Color32::from_rgba_unmultiplied( let mut egui_color = egui::Color32::from_rgba_unmultiplied(
color.r, color.g, color.b, color.a, color.r, color.g, color.b, color.a,
); );
if egui::color_picker::color_edit_button_srgba(
if ui.color_edit_button_srgba(&mut egui_color).changed() { ui,
let new_color = Some(ShapeColor::new( &mut egui_color,
egui_color.r(), egui::color_picker::Alpha::OnlyBlend,
egui_color.g(), ).changed() {
egui_color.b(), let new_color = ShapeColor {
egui_color.a(), r: egui_color.r(),
)); g: egui_color.g(),
b: egui_color.b(),
// Create action for each selected shape a: egui_color.a(),
for shape_id in &info.shape_ids { };
let action = SetShapePropertiesAction::set_fill_color( let action = SetShapePropertiesAction::set_fill_color(
layer_id, layer_id, time, face_ids.clone(), Some(new_color),
*shape_id, );
*shared.playback_time, shared.pending_actions.push(Box::new(action));
new_color,
);
shared.pending_actions.push(Box::new(action));
}
} }
} }
Some(None) => { Some(None) => {
if ui.button("Add Fill").clicked() { ui.label("None");
// Add default black fill
let default_fill = Some(ShapeColor::rgb(0, 0, 0));
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_fill_color(
layer_id,
*shape_id,
*shared.playback_time,
default_fill,
);
shared.pending_actions.push(Box::new(action));
}
}
} }
None => { None => {
ui.label("--"); ui.label("--");
@ -617,41 +344,25 @@ impl InfopanelPane {
let mut egui_color = egui::Color32::from_rgba_unmultiplied( let mut egui_color = egui::Color32::from_rgba_unmultiplied(
color.r, color.g, color.b, color.a, color.r, color.g, color.b, color.a,
); );
if egui::color_picker::color_edit_button_srgba(
if ui.color_edit_button_srgba(&mut egui_color).changed() { ui,
let new_color = Some(ShapeColor::new( &mut egui_color,
egui_color.r(), egui::color_picker::Alpha::OnlyBlend,
egui_color.g(), ).changed() {
egui_color.b(), let new_color = ShapeColor {
egui_color.a(), r: egui_color.r(),
)); g: egui_color.g(),
b: egui_color.b(),
// Create action for each selected shape a: egui_color.a(),
for shape_id in &info.shape_ids { };
let action = SetShapePropertiesAction::set_stroke_color( let action = SetShapePropertiesAction::set_stroke_color(
layer_id, layer_id, time, edge_ids.clone(), Some(new_color),
*shape_id, );
*shared.playback_time, shared.pending_actions.push(Box::new(action));
new_color,
);
shared.pending_actions.push(Box::new(action));
}
} }
} }
Some(None) => { Some(None) => {
if ui.button("Add Stroke").clicked() { ui.label("None");
// Add default black stroke
let default_stroke = Some(ShapeColor::rgb(0, 0, 0));
for shape_id in &info.shape_ids {
let action = SetShapePropertiesAction::set_stroke_color(
layer_id,
*shape_id,
*shared.playback_time,
default_stroke,
);
shared.pending_actions.push(Box::new(action));
}
}
} }
None => { None => {
ui.label("--"); ui.label("--");
@ -664,22 +375,15 @@ impl InfopanelPane {
ui.label("Stroke Width:"); ui.label("Stroke Width:");
match info.stroke_width { match info.stroke_width {
Some(mut width) => { Some(mut width) => {
let response = ui.add( if ui.add(
DragValue::new(&mut width) DragValue::new(&mut width)
.speed(0.1) .speed(0.1)
.range(0.1..=100.0), .range(0.1..=100.0),
); ).changed() {
let action = SetShapePropertiesAction::set_stroke_width(
if response.changed() { layer_id, time, edge_ids.clone(), width,
for shape_id in &info.shape_ids { );
let action = SetShapePropertiesAction::set_stroke_width( shared.pending_actions.push(Box::new(action));
layer_id,
*shape_id,
*shared.playback_time,
width,
);
shared.pending_actions.push(Box::new(action));
}
} }
} }
None => { None => {
@ -812,13 +516,8 @@ impl PaneRenderer for InfopanelPane {
// 2. Gather selection info // 2. Gather selection info
let info = self.gather_selection_info(shared); let info = self.gather_selection_info(shared);
// 3. Transform section (if shapes selected) // 3. Shape properties section (if DCEL elements selected)
if info.shape_count > 0 { if info.dcel_count > 0 {
self.render_transform_section(ui, path, shared, &info);
}
// 4. Shape properties section (if shapes selected)
if info.shape_count > 0 {
self.render_shape_section(ui, path, shared, &info); self.render_shape_section(ui, path, shared, &info);
} }
@ -828,14 +527,14 @@ impl PaneRenderer for InfopanelPane {
} }
// Show selection count at bottom // Show selection count at bottom
if info.shape_count > 0 { if info.dcel_count > 0 {
ui.add_space(8.0); ui.add_space(8.0);
ui.separator(); ui.separator();
ui.add_space(4.0); ui.add_space(4.0);
ui.label(format!( ui.label(format!(
"{} object{} selected", "{} object{} selected",
info.shape_count, info.dcel_count,
if info.shape_count == 1 { "" } else { "s" } if info.dcel_count == 1 { "" } else { "s" }
)); ));
} }
}); });

View File

@ -205,16 +205,23 @@ impl PianoRollPane {
// ── Ruler interval calculation ─────────────────────────────────────── // ── Ruler interval calculation ───────────────────────────────────────
fn ruler_interval(&self) -> f64 { fn ruler_interval(&self, bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) -> f64 {
let min_pixel_gap = 80.0; let min_pixel_gap = 80.0;
let min_seconds = min_pixel_gap / self.pixels_per_second; let min_seconds = (min_pixel_gap / self.pixels_per_second) as f64;
let intervals = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0];
for &interval in &intervals { // Use beat-aligned intervals
if interval >= min_seconds as f64 { let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig);
let beat_intervals = [
beat_dur / 4.0, beat_dur / 2.0, beat_dur, beat_dur * 2.0,
measure_dur, measure_dur * 2.0, measure_dur * 4.0,
];
for &interval in &beat_intervals {
if interval >= min_seconds {
return interval; return interval;
} }
} }
60.0 measure_dur * 4.0
} }
// ── MIDI mode rendering ────────────────────────────────────────────── // ── MIDI mode rendering ──────────────────────────────────────────────
@ -287,7 +294,11 @@ impl PianoRollPane {
// Render grid (clipped to grid area) // Render grid (clipped to grid area)
let grid_painter = ui.painter_at(grid_rect); let grid_painter = ui.painter_at(grid_rect);
self.render_grid(&grid_painter, grid_rect); let (grid_bpm, grid_time_sig) = {
let doc = shared.action_executor.document();
(doc.bpm, doc.time_signature.clone())
};
self.render_grid(&grid_painter, grid_rect, grid_bpm, &grid_time_sig);
// Render clip boundaries and notes // Render clip boundaries and notes
for &(midi_clip_id, timeline_start, trim_start, duration, _instance_id) in &clip_data { for &(midi_clip_id, timeline_start, trim_start, duration, _instance_id) in &clip_data {
@ -419,7 +430,8 @@ impl PianoRollPane {
); );
} }
fn render_grid(&self, painter: &egui::Painter, grid_rect: Rect) { fn render_grid(&self, painter: &egui::Painter, grid_rect: Rect,
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
// Horizontal lines (note separators) // Horizontal lines (note separators)
for note in MIN_NOTE..=MAX_NOTE { for note in MIN_NOTE..=MAX_NOTE {
let y = self.note_to_y(note, grid_rect); let y = self.note_to_y(note, grid_rect);
@ -445,8 +457,11 @@ impl PianoRollPane {
); );
} }
// Vertical lines (time grid) // Vertical lines (beat-aligned time grid)
let interval = self.ruler_interval(); let interval = self.ruler_interval(bpm, time_sig);
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
let measure_dur = lightningbeam_core::beat_time::measure_duration(bpm, time_sig);
let start = (self.viewport_start_time / interval).floor() as i64; let start = (self.viewport_start_time / interval).floor() as i64;
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64; let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
let end = (end_time / interval).ceil() as i64; let end = (end_time / interval).ceil() as i64;
@ -458,27 +473,36 @@ impl PianoRollPane {
continue; continue;
} }
let is_major = (i % 4 == 0) || interval >= 1.0; // Determine tick importance: measure boundary > beat > subdivision
let alpha = if is_major { 50 } else { 20 }; let is_measure = (time / measure_dur).fract().abs() < 1e-9 || (time / measure_dur).fract() > 1.0 - 1e-9;
let is_beat = (time / beat_dur).fract().abs() < 1e-9 || (time / beat_dur).fract() > 1.0 - 1e-9;
let alpha = if is_measure { 60 } else if is_beat { 35 } else { 20 };
painter.line_segment( painter.line_segment(
[pos2(x, grid_rect.min.y), pos2(x, grid_rect.max.y)], [pos2(x, grid_rect.min.y), pos2(x, grid_rect.max.y)],
Stroke::new(1.0, Color32::from_white_alpha(alpha)), Stroke::new(1.0, Color32::from_white_alpha(alpha)),
); );
// Time labels at major lines // Labels at measure boundaries
if is_major && x > grid_rect.min.x + 20.0 { if is_measure && x > grid_rect.min.x + 20.0 {
let label = if time >= 60.0 { let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig);
format!("{}:{:05.2}", (time / 60.0) as u32, time % 60.0)
} else {
format!("{:.2}s", time)
};
painter.text( painter.text(
pos2(x + 2.0, grid_rect.min.y + 2.0), pos2(x + 2.0, grid_rect.min.y + 2.0),
Align2::LEFT_TOP, Align2::LEFT_TOP,
label, format!("{}", pos.measure),
FontId::proportional(9.0), FontId::proportional(9.0),
Color32::from_white_alpha(80), Color32::from_white_alpha(80),
); );
} else if is_beat && !is_measure && x > grid_rect.min.x + 20.0
&& beat_dur as f32 * self.pixels_per_second > 40.0 {
let pos = lightningbeam_core::beat_time::time_to_measure(time, bpm, time_sig);
painter.text(
pos2(x + 2.0, grid_rect.min.y + 2.0),
Align2::LEFT_TOP,
format!("{}.{}", pos.measure, pos.beat),
FontId::proportional(9.0),
Color32::from_white_alpha(50),
);
} }
} }
} }
@ -578,9 +602,10 @@ impl PianoRollPane {
); );
} }
fn render_dot_grid(&self, painter: &egui::Painter, grid_rect: Rect) { fn render_dot_grid(&self, painter: &egui::Painter, grid_rect: Rect,
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
// Collect visible time grid positions // Collect visible time grid positions
let interval = self.ruler_interval(); let interval = self.ruler_interval(bpm, time_sig);
let start = (self.viewport_start_time / interval).floor() as i64; let start = (self.viewport_start_time / interval).floor() as i64;
let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64; let end_time = self.viewport_start_time + (grid_rect.width() / self.pixels_per_second) as f64;
let end = (end_time / interval).ceil() as i64; let end = (end_time / interval).ceil() as i64;
@ -1414,7 +1439,13 @@ impl PianoRollPane {
// Dot grid background (visible where the spectrogram doesn't draw) // Dot grid background (visible where the spectrogram doesn't draw)
let grid_painter = ui.painter_at(view_rect); let grid_painter = ui.painter_at(view_rect);
self.render_dot_grid(&grid_painter, view_rect); {
let (dot_bpm, dot_ts) = {
let doc = shared.action_executor.document();
(doc.bpm, doc.time_signature.clone())
};
self.render_dot_grid(&grid_painter, view_rect, dot_bpm, &dot_ts);
}
// Find audio pool index for the active layer's clips // Find audio pool index for the active layer's clips
let layer_id = match *shared.active_layer_id { let layer_id = match *shared.active_layer_id {

File diff suppressed because it is too large Load Diff

View File

@ -130,6 +130,13 @@ enum ClipDragType {
LoopExtendLeft, LoopExtendLeft,
} }
/// How time is displayed in the ruler and header
#[derive(Debug, Clone, Copy, PartialEq)]
enum TimeDisplayFormat {
Seconds,
Measures,
}
pub struct TimelinePane { pub struct TimelinePane {
/// Horizontal zoom level (pixels per second) /// Horizontal zoom level (pixels per second)
pixels_per_second: f32, pixels_per_second: f32,
@ -163,6 +170,9 @@ pub struct TimelinePane {
/// Context menu state: Some((optional_clip_instance_id, position)) when a right-click menu is open /// Context menu state: Some((optional_clip_instance_id, position)) when a right-click menu is open
/// clip_id is None when right-clicking on empty timeline space /// clip_id is None when right-clicking on empty timeline space
context_menu_clip: Option<(Option<uuid::Uuid>, egui::Pos2)>, context_menu_clip: Option<(Option<uuid::Uuid>, egui::Pos2)>,
/// Whether to display time as seconds or measures
time_display_format: TimeDisplayFormat,
} }
/// Check if a clip type can be dropped on a layer type /// Check if a clip type can be dropped on a layer type
@ -231,6 +241,7 @@ impl TimelinePane {
mousedown_pos: None, mousedown_pos: None,
layer_control_clicked: false, layer_control_clicked: false,
context_menu_clip: None, context_menu_clip: None,
time_display_format: TimeDisplayFormat::Seconds,
} }
} }
@ -548,72 +559,105 @@ impl TimelinePane {
} }
/// Render the time ruler at the top /// Render the time ruler at the top
fn render_ruler(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme) { fn render_ruler(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme,
bpm: f64, time_sig: &lightningbeam_core::document::TimeSignature) {
let painter = ui.painter(); let painter = ui.painter();
// Background // Background
let bg_style = theme.style(".timeline-background", ui.ctx()); let bg_style = theme.style(".timeline-background", ui.ctx());
let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_rgb(34, 34, 34)); let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_rgb(34, 34, 34));
painter.rect_filled( painter.rect_filled(rect, 0.0, bg_color);
rect,
0.0,
bg_color,
);
// Get text color from theme
let text_style = theme.style(".text-primary", ui.ctx()); let text_style = theme.style(".text-primary", ui.ctx());
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
// Calculate interval for tick marks match self.time_display_format {
let interval = self.calculate_ruler_interval(); TimeDisplayFormat::Seconds => {
let interval = self.calculate_ruler_interval();
let start_time = (self.viewport_start_time / interval).floor() * interval;
let end_time = self.x_to_time(rect.width());
// Draw tick marks and labels let mut time = start_time;
let start_time = (self.viewport_start_time / interval).floor() * interval; while time <= end_time {
let end_time = self.x_to_time(rect.width()); let x = self.time_to_x(time);
if x >= 0.0 && x <= rect.width() {
let mut time = start_time; painter.line_segment(
while time <= end_time { [rect.min + egui::vec2(x, rect.height() - 10.0),
let x = self.time_to_x(time); rect.min + egui::vec2(x, rect.height())],
egui::Stroke::new(1.0, egui::Color32::from_gray(100)),
if x >= 0.0 && x <= rect.width() { );
// Major tick mark painter.text(
painter.line_segment( rect.min + egui::vec2(x + 2.0, 5.0), egui::Align2::LEFT_TOP,
[ format!("{:.1}s", time), egui::FontId::proportional(12.0), text_color,
rect.min + egui::vec2(x, rect.height() - 10.0), );
rect.min + egui::vec2(x, rect.height()), }
], let minor_interval = interval / 5.0;
egui::Stroke::new(1.0, egui::Color32::from_gray(100)), for i in 1..5 {
); let minor_x = self.time_to_x(time + minor_interval * i as f64);
if minor_x >= 0.0 && minor_x <= rect.width() {
// Time label painter.line_segment(
let label = format!("{:.1}s", time); [rect.min + egui::vec2(minor_x, rect.height() - 5.0),
painter.text( rect.min + egui::vec2(minor_x, rect.height())],
rect.min + egui::vec2(x + 2.0, 5.0), egui::Stroke::new(1.0, egui::Color32::from_gray(60)),
egui::Align2::LEFT_TOP, );
label, }
egui::FontId::proportional(12.0), }
text_color, time += interval;
);
}
// Minor tick marks (subdivisions)
let minor_interval = interval / 5.0;
for i in 1..5 {
let minor_time = time + minor_interval * i as f64;
let minor_x = self.time_to_x(minor_time);
if minor_x >= 0.0 && minor_x <= rect.width() {
painter.line_segment(
[
rect.min + egui::vec2(minor_x, rect.height() - 5.0),
rect.min + egui::vec2(minor_x, rect.height()),
],
egui::Stroke::new(1.0, egui::Color32::from_gray(60)),
);
} }
} }
TimeDisplayFormat::Measures => {
let beats_per_second = bpm / 60.0;
let beat_dur = lightningbeam_core::beat_time::beat_duration(bpm);
let bpm_count = time_sig.numerator;
let px_per_beat = beat_dur as f32 * self.pixels_per_second;
time += interval; let start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64;
let end_beat = (self.x_to_time(rect.width()) * beats_per_second).ceil() as i64;
// Adaptive: how often to label measures
let measure_px = px_per_beat * bpm_count as f32;
let label_every = if measure_px > 60.0 { 1u32 } else if measure_px > 20.0 { 4 } else { 16 };
for beat_idx in start_beat..=end_beat {
if beat_idx < 0 { continue; }
let x = self.time_to_x(beat_idx as f64 / beats_per_second);
if x < 0.0 || x > rect.width() { continue; }
let beat_in_measure = (beat_idx as u32) % bpm_count;
let measure = (beat_idx as u32) / bpm_count + 1;
let is_measure_boundary = beat_in_measure == 0;
// Tick height, stroke width, and brightness based on beat importance
let (tick_h, stroke_w, gray) = if is_measure_boundary {
(12.0, 2.0, 140u8)
} else if beat_in_measure % 2 == 0 {
(8.0, 1.0, 80)
} else {
(5.0, 1.0, 50)
};
painter.line_segment(
[rect.min + egui::vec2(x, rect.height() - tick_h),
rect.min + egui::vec2(x, rect.height())],
egui::Stroke::new(stroke_w, egui::Color32::from_gray(gray)),
);
// Labels: measure numbers at boundaries, beat numbers when zoomed in
if is_measure_boundary && (label_every == 1 || measure % label_every == 1) {
painter.text(
rect.min + egui::vec2(x + 3.0, 3.0), egui::Align2::LEFT_TOP,
format!("{}", measure), egui::FontId::proportional(12.0), text_color,
);
} else if !is_measure_boundary && px_per_beat > 40.0 {
let alpha = if beat_in_measure % 2 == 0 { 0.5 } else if px_per_beat > 80.0 { 0.25 } else { continue };
painter.text(
rect.min + egui::vec2(x + 2.0, 5.0), egui::Align2::LEFT_TOP,
format!("{}.{}", measure, beat_in_measure + 1),
egui::FontId::proportional(10.0), text_color.gamma_multiply(alpha),
);
}
}
}
} }
} }
@ -1104,25 +1148,42 @@ impl TimelinePane {
painter.rect_filled(layer_rect, 0.0, bg_color); painter.rect_filled(layer_rect, 0.0, bg_color);
// Grid lines matching ruler // Grid lines matching ruler
let interval = self.calculate_ruler_interval(); match self.time_display_format {
let start_time = (self.viewport_start_time / interval).floor() * interval; TimeDisplayFormat::Seconds => {
let end_time = self.x_to_time(rect.width()); let interval = self.calculate_ruler_interval();
let start_time = (self.viewport_start_time / interval).floor() * interval;
let mut time = start_time; let end_time = self.x_to_time(rect.width());
while time <= end_time { let mut time = start_time;
let x = self.time_to_x(time); while time <= end_time {
let x = self.time_to_x(time);
if x >= 0.0 && x <= rect.width() { if x >= 0.0 && x <= rect.width() {
painter.line_segment( painter.line_segment(
[ [egui::pos2(rect.min.x + x, y),
egui::pos2(rect.min.x + x, y), egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)],
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT), egui::Stroke::new(1.0, egui::Color32::from_gray(30)),
], );
egui::Stroke::new(1.0, egui::Color32::from_gray(30)), }
); time += interval;
}
}
TimeDisplayFormat::Measures => {
let beats_per_second = document.bpm / 60.0;
let bpm_count = document.time_signature.numerator;
let start_beat = (self.viewport_start_time.max(0.0) * beats_per_second).floor() as i64;
let end_beat = (self.x_to_time(rect.width()) * beats_per_second).ceil() as i64;
for beat_idx in start_beat..=end_beat {
if beat_idx < 0 { continue; }
let x = self.time_to_x(beat_idx as f64 / beats_per_second);
if x < 0.0 || x > rect.width() { continue; }
let is_measure_boundary = (beat_idx as u32) % bpm_count == 0;
let gray = if is_measure_boundary { 45 } else { 25 };
painter.line_segment(
[egui::pos2(rect.min.x + x, y),
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)],
egui::Stroke::new(if is_measure_boundary { 1.5 } else { 1.0 }, egui::Color32::from_gray(gray)),
);
}
} }
time += interval;
} }
// Draw clip instances for this layer // Draw clip instances for this layer
@ -2647,13 +2708,95 @@ impl PaneRenderer for TimelinePane {
let text_style = shared.theme.style(".text-primary", ui.ctx()); let text_style = shared.theme.style(".text-primary", ui.ctx());
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
// Time display // Time display (format-dependent)
ui.colored_label(text_color, format!("Time: {:.2}s / {:.2}s", *shared.playback_time, self.duration)); {
let (bpm, time_sig_num, time_sig_den) = {
let doc = shared.action_executor.document();
(doc.bpm, doc.time_signature.numerator, doc.time_signature.denominator)
};
ui.separator(); match self.time_display_format {
TimeDisplayFormat::Seconds => {
ui.colored_label(text_color, format!("Time: {:.2}s / {:.2}s", *shared.playback_time, self.duration));
}
TimeDisplayFormat::Measures => {
let time_sig = lightningbeam_core::document::TimeSignature { numerator: time_sig_num, denominator: time_sig_den };
let pos = lightningbeam_core::beat_time::time_to_measure(
*shared.playback_time, bpm, &time_sig,
);
ui.colored_label(text_color, format!(
"BAR: {}.{} | BPM: {:.0} | {}/{}",
pos.measure, pos.beat, bpm,
time_sig_num, time_sig_den,
));
}
}
// Zoom display ui.separator();
ui.colored_label(text_color, format!("Zoom: {:.0}px/s", self.pixels_per_second));
// Zoom display
ui.colored_label(text_color, format!("Zoom: {:.0}px/s", self.pixels_per_second));
ui.separator();
// Time display format toggle
egui::ComboBox::from_id_salt("time_format")
.selected_text(match self.time_display_format {
TimeDisplayFormat::Seconds => "Seconds",
TimeDisplayFormat::Measures => "Measures",
})
.width(80.0)
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.time_display_format, TimeDisplayFormat::Seconds, "Seconds");
ui.selectable_value(&mut self.time_display_format, TimeDisplayFormat::Measures, "Measures");
});
ui.separator();
// BPM control
let mut bpm_val = bpm;
ui.label("BPM:");
let bpm_response = ui.add(egui::DragValue::new(&mut bpm_val)
.range(20.0..=300.0)
.speed(0.5)
.fixed_decimals(1));
if bpm_response.changed() {
shared.action_executor.document_mut().bpm = bpm_val;
if let Some(controller_arc) = shared.audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.set_tempo(bpm_val as f32, (time_sig_num, time_sig_den));
}
}
ui.separator();
// Time signature selector
let time_sig_presets: [(u32, u32); 8] = [
(2, 4), (3, 4), (4, 4), (5, 4),
(6, 8), (7, 8), (9, 8), (12, 8),
];
let current_ts_label = format!("{}/{}", time_sig_num, time_sig_den);
egui::ComboBox::from_id_salt("time_sig")
.selected_text(&current_ts_label)
.width(60.0)
.show_ui(ui, |ui| {
for (num, den) in &time_sig_presets {
let label = format!("{}/{}", num, den);
if ui.selectable_label(
time_sig_num == *num && time_sig_den == *den,
&label,
).clicked() {
let doc = shared.action_executor.document_mut();
doc.time_signature.numerator = *num;
doc.time_signature.denominator = *den;
if let Some(controller_arc) = shared.audio_controller {
let mut controller = controller_arc.lock().unwrap();
controller.set_tempo(doc.bpm as f32, (*num, *den));
}
}
}
});
}
true true
} }
@ -2750,7 +2893,7 @@ impl PaneRenderer for TimelinePane {
// Render time ruler (clip to ruler rect) // Render time ruler (clip to ruler rect)
ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));
self.render_ruler(ui, ruler_rect, shared.theme); self.render_ruler(ui, ruler_rect, shared.theme, document.bpm, &document.time_signature);
// Render layer rows with clipping // Render layer rows with clipping
ui.set_clip_rect(content_rect.intersect(original_clip_rect)); ui.set_clip_rect(content_rect.intersect(original_clip_rect));