Merge branch 'rust-ui' of https://git.skyler.io/skyler/Lightningbeam into rust-ui
This commit is contained in:
commit
4a13ce0684
|
|
@ -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 }}
|
||||||
|
|
@ -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 }}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ®ion_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)
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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(¤t_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));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue