Compare commits
23 Commits
0b4aee51d7
...
78577babb1
| Author | SHA1 | Date |
|---|---|---|
|
|
78577babb1 | |
|
|
b3e1da3152 | |
|
|
6bd400d353 | |
|
|
b87e4325c2 | |
|
|
520776c6e5 | |
|
|
f8df4d1232 | |
|
|
52b12204d4 | |
|
|
5d39627d03 | |
|
|
0026ad3e02 | |
|
|
9edfc2086a | |
|
|
14a2b0a4c2 | |
|
|
1462df308f | |
|
|
5a19e91788 | |
|
|
dc27cf253d | |
|
|
1621602f41 | |
|
|
7c37e69687 | |
|
|
63c1ba8854 | |
|
|
1cc7029321 | |
|
|
353aec3513 | |
|
|
4c34c8a17d | |
|
|
2b63fdd2c5 | |
|
|
543d99e5d5 | |
|
|
4195005455 |
|
|
@ -258,14 +258,29 @@ jobs:
|
|||
> "Lightningbeam_Editor-${VERSION}-x86_64.AppImage"
|
||||
chmod +x "Lightningbeam_Editor-${VERSION}-x86_64.AppImage"
|
||||
|
||||
- name: Collect Linux artifacts
|
||||
- name: Upload .deb
|
||||
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/
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: deb-package
|
||||
path: lightningbeam-ui/target/debian/*.deb
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload .rpm
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rpm-package
|
||||
path: lightningbeam-ui/target/generate-rpm/*.rpm
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload AppImage
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: appimage
|
||||
path: lightningbeam-ui/Lightningbeam_Editor-*.AppImage
|
||||
if-no-files-found: error
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# macOS Packaging
|
||||
|
|
@ -331,12 +346,13 @@ jobs:
|
|||
"Lightningbeam Editor.app" || true
|
||||
# create-dmg returns non-zero if codesigning is skipped, but the .dmg is still valid
|
||||
|
||||
- name: Collect macOS artifacts
|
||||
- name: Upload .dmg
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
cp Lightningbeam_Editor-*.dmg artifacts/
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dmg-${{ matrix.artifact-name }}
|
||||
path: Lightningbeam_Editor-*.dmg
|
||||
if-no-files-found: error
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# Windows Packaging
|
||||
|
|
@ -354,19 +370,12 @@ jobs:
|
|||
Copy-Item "$env:FFMPEG_DIR\bin\*.dll" "$DIST/"
|
||||
Compress-Archive -Path $DIST -DestinationPath "${DIST}.zip"
|
||||
|
||||
- name: Collect Windows artifacts
|
||||
- name: Upload .zip
|
||||
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/*
|
||||
name: windows-zip
|
||||
path: Lightningbeam_Editor-*.zip
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
|
|
|
|||
14
Changelog.md
14
Changelog.md
|
|
@ -1,3 +1,17 @@
|
|||
# 1.0.1-alpha:
|
||||
Changes:
|
||||
- Added real-time amp simulation via NAM
|
||||
- Added beat mode to the timeline
|
||||
- Changed shape drawing from making separate shapes to making shapes in the layer using a DCEL graph
|
||||
- Licensed under GPLv3
|
||||
- Added snapping for vector editing
|
||||
- Added organ instrument and vibrato node
|
||||
|
||||
Bugfixes:
|
||||
- Fix preset loading not updating node graph editor
|
||||
- Fix stroke intersections not splitting strokes
|
||||
- Fix paint bucket fill not attaching to existing strokes
|
||||
|
||||
# 1.0.0-alpha:
|
||||
Changes:
|
||||
- New native GUI built with egui + wgpu (replaces Tauri/web frontend)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
|
@ -42,6 +42,7 @@ mod slew_limiter;
|
|||
mod splitter;
|
||||
mod svf;
|
||||
mod template_io;
|
||||
mod vibrato;
|
||||
mod vocoder;
|
||||
mod voice_allocator;
|
||||
mod wavetable_oscillator;
|
||||
|
|
@ -90,6 +91,7 @@ pub use slew_limiter::SlewLimiterNode;
|
|||
pub use splitter::SplitterNode;
|
||||
pub use svf::SVFNode;
|
||||
pub use template_io::{TemplateInputNode, TemplateOutputNode};
|
||||
pub use vibrato::VibratoNode;
|
||||
pub use vocoder::VocoderNode;
|
||||
pub use voice_allocator::VoiceAllocatorNode;
|
||||
pub use wavetable_oscillator::WavetableOscillatorNode;
|
||||
|
|
@ -146,6 +148,7 @@ pub fn create_node(node_type: &str, sample_rate: u32, buffer_size: usize) -> Opt
|
|||
"TemplateInput" => Box::new(TemplateInputNode::new("Template Input")),
|
||||
"TemplateOutput" => Box::new(TemplateOutputNode::new("Template Output")),
|
||||
"VoiceAllocator" => Box::new(VoiceAllocatorNode::new("VoiceAllocator", sample_rate, buffer_size)),
|
||||
"Vibrato" => Box::new(VibratoNode::new("Vibrato")),
|
||||
"AmpSim" => Box::new(AmpSimNode::new("Amp Sim")),
|
||||
"AudioOutput" => Box::new(AudioOutputNode::new("Output")),
|
||||
_ => return None,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||
use crate::audio::midi::MidiEvent;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
const PARAM_RATE: u32 = 0;
|
||||
const PARAM_DEPTH: u32 = 1;
|
||||
|
||||
const MAX_DELAY_MS: f32 = 7.0;
|
||||
const BASE_DELAY_MS: f32 = 0.5;
|
||||
|
||||
/// Vibrato effect — periodic pitch modulation via a short modulated delay line.
|
||||
///
|
||||
/// 100% wet signal (no dry mix). Supports an external Mod CV input that, when
|
||||
/// connected, replaces the internal sine LFO with the incoming CV signal.
|
||||
pub struct VibratoNode {
|
||||
name: String,
|
||||
rate: f32,
|
||||
depth: f32,
|
||||
|
||||
delay_buffer_left: Vec<f32>,
|
||||
delay_buffer_right: Vec<f32>,
|
||||
write_position: usize,
|
||||
max_delay_samples: usize,
|
||||
sample_rate: u32,
|
||||
|
||||
lfo_phase: f32,
|
||||
|
||||
inputs: Vec<NodePort>,
|
||||
outputs: Vec<NodePort>,
|
||||
parameters: Vec<Parameter>,
|
||||
}
|
||||
|
||||
impl VibratoNode {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
let name = name.into();
|
||||
|
||||
let inputs = vec![
|
||||
NodePort::new("Audio In", SignalType::Audio, 0),
|
||||
NodePort::new("Mod CV In", SignalType::CV, 1),
|
||||
NodePort::new("Rate CV In", SignalType::CV, 2),
|
||||
NodePort::new("Depth CV In", SignalType::CV, 3),
|
||||
];
|
||||
|
||||
let outputs = vec![
|
||||
NodePort::new("Audio Out", SignalType::Audio, 0),
|
||||
];
|
||||
|
||||
let parameters = vec![
|
||||
Parameter::new(PARAM_RATE, "Rate", 0.1, 14.0, 5.0, ParameterUnit::Frequency),
|
||||
Parameter::new(PARAM_DEPTH, "Depth", 0.0, 1.0, 0.5, ParameterUnit::Generic),
|
||||
];
|
||||
|
||||
let max_delay_samples = ((MAX_DELAY_MS / 1000.0) * 48000.0) as usize;
|
||||
|
||||
Self {
|
||||
name,
|
||||
rate: 5.0,
|
||||
depth: 0.5,
|
||||
delay_buffer_left: vec![0.0; max_delay_samples],
|
||||
delay_buffer_right: vec![0.0; max_delay_samples],
|
||||
write_position: 0,
|
||||
max_delay_samples,
|
||||
sample_rate: 48000,
|
||||
lfo_phase: 0.0,
|
||||
inputs,
|
||||
outputs,
|
||||
parameters,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_interpolated_sample(&self, buffer: &[f32], delay_samples: f32) -> f32 {
|
||||
let delay_samples = delay_samples.clamp(0.0, (self.max_delay_samples - 1) as f32);
|
||||
|
||||
let read_pos_float = self.write_position as f32 - delay_samples;
|
||||
let read_pos_float = if read_pos_float < 0.0 {
|
||||
read_pos_float + self.max_delay_samples as f32
|
||||
} else {
|
||||
read_pos_float
|
||||
};
|
||||
|
||||
let read_pos_int = read_pos_float.floor() as usize;
|
||||
let frac = read_pos_float - read_pos_int as f32;
|
||||
|
||||
let sample1 = buffer[read_pos_int % self.max_delay_samples];
|
||||
let sample2 = buffer[(read_pos_int + 1) % self.max_delay_samples];
|
||||
|
||||
sample1 * (1.0 - frac) + sample2 * frac
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioNode for VibratoNode {
|
||||
fn category(&self) -> NodeCategory {
|
||||
NodeCategory::Effect
|
||||
}
|
||||
|
||||
fn inputs(&self) -> &[NodePort] {
|
||||
&self.inputs
|
||||
}
|
||||
|
||||
fn outputs(&self) -> &[NodePort] {
|
||||
&self.outputs
|
||||
}
|
||||
|
||||
fn parameters(&self) -> &[Parameter] {
|
||||
&self.parameters
|
||||
}
|
||||
|
||||
fn set_parameter(&mut self, id: u32, value: f32) {
|
||||
match id {
|
||||
PARAM_RATE => {
|
||||
self.rate = value.clamp(0.1, 14.0);
|
||||
}
|
||||
PARAM_DEPTH => {
|
||||
self.depth = value.clamp(0.0, 1.0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_parameter(&self, id: u32) -> f32 {
|
||||
match id {
|
||||
PARAM_RATE => self.rate,
|
||||
PARAM_DEPTH => self.depth,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn process(
|
||||
&mut self,
|
||||
inputs: &[&[f32]],
|
||||
outputs: &mut [&mut [f32]],
|
||||
_midi_inputs: &[&[MidiEvent]],
|
||||
_midi_outputs: &mut [&mut Vec<MidiEvent>],
|
||||
sample_rate: u32,
|
||||
) {
|
||||
if inputs.is_empty() || outputs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.sample_rate != sample_rate {
|
||||
self.sample_rate = sample_rate;
|
||||
self.max_delay_samples = ((MAX_DELAY_MS / 1000.0) * sample_rate as f32) as usize;
|
||||
self.delay_buffer_left.resize(self.max_delay_samples, 0.0);
|
||||
self.delay_buffer_right.resize(self.max_delay_samples, 0.0);
|
||||
self.write_position = 0;
|
||||
}
|
||||
|
||||
let input = inputs[0];
|
||||
let output = &mut outputs[0];
|
||||
|
||||
// CV inputs — unconnected ports are filled with NaN
|
||||
let mod_cv = inputs.get(1);
|
||||
let rate_cv = inputs.get(2);
|
||||
let depth_cv = inputs.get(3);
|
||||
|
||||
let frames = input.len() / 2;
|
||||
let output_frames = output.len() / 2;
|
||||
let frames_to_process = frames.min(output_frames);
|
||||
|
||||
let base_delay_samples = (BASE_DELAY_MS / 1000.0) * self.sample_rate as f32;
|
||||
let max_modulation_samples = (MAX_DELAY_MS - BASE_DELAY_MS) / 1000.0 * self.sample_rate as f32;
|
||||
|
||||
for frame in 0..frames_to_process {
|
||||
let left_in = input[frame * 2];
|
||||
let right_in = input[frame * 2 + 1];
|
||||
|
||||
// Resolve depth: CV overrides knob when connected
|
||||
let depth = if let Some(cv) = depth_cv {
|
||||
let cv_val = cv.get(frame).copied().unwrap_or(f32::NAN);
|
||||
if cv_val.is_nan() {
|
||||
self.depth
|
||||
} else {
|
||||
cv_val.clamp(0.0, 1.0)
|
||||
}
|
||||
} else {
|
||||
self.depth
|
||||
};
|
||||
|
||||
// Determine modulation value (0..1 range, pre-depth)
|
||||
let mod_value = if let Some(cv) = mod_cv {
|
||||
let cv_val = cv.get(frame).copied().unwrap_or(f32::NAN);
|
||||
if cv_val.is_nan() {
|
||||
// No external mod — use internal LFO
|
||||
None
|
||||
} else {
|
||||
Some(cv_val.clamp(0.0, 1.0))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let modulation = if let Some(ext) = mod_value {
|
||||
// External modulation: CV value scaled by depth
|
||||
ext * depth
|
||||
} else {
|
||||
// Internal LFO: resolve rate with CV
|
||||
let rate = if let Some(cv) = rate_cv {
|
||||
let cv_val = cv.get(frame).copied().unwrap_or(f32::NAN);
|
||||
if cv_val.is_nan() {
|
||||
self.rate
|
||||
} else {
|
||||
(self.rate + cv_val * 14.0).clamp(0.1, 14.0)
|
||||
}
|
||||
} else {
|
||||
self.rate
|
||||
};
|
||||
|
||||
let lfo_value = (self.lfo_phase * 2.0 * PI).sin() * 0.5 + 0.5;
|
||||
|
||||
self.lfo_phase += rate / self.sample_rate as f32;
|
||||
if self.lfo_phase >= 1.0 {
|
||||
self.lfo_phase -= 1.0;
|
||||
}
|
||||
|
||||
lfo_value * depth
|
||||
};
|
||||
|
||||
let delay_samples = base_delay_samples + modulation * max_modulation_samples;
|
||||
|
||||
// 100% wet — output is only the delayed signal
|
||||
output[frame * 2] = self.read_interpolated_sample(&self.delay_buffer_left, delay_samples);
|
||||
output[frame * 2 + 1] = self.read_interpolated_sample(&self.delay_buffer_right, delay_samples);
|
||||
|
||||
self.delay_buffer_left[self.write_position] = left_in;
|
||||
self.delay_buffer_right[self.write_position] = right_in;
|
||||
|
||||
self.write_position = (self.write_position + 1) % self.max_delay_samples;
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.delay_buffer_left.fill(0.0);
|
||||
self.delay_buffer_right.fill(0.0);
|
||||
self.write_position = 0;
|
||||
self.lfo_phase = 0.0;
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"Vibrato"
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn clone_node(&self) -> Box<dyn AudioNode> {
|
||||
Box::new(Self {
|
||||
name: self.name.clone(),
|
||||
rate: self.rate,
|
||||
depth: self.depth,
|
||||
delay_buffer_left: vec![0.0; self.max_delay_samples],
|
||||
delay_buffer_right: vec![0.0; self.max_delay_samples],
|
||||
write_position: 0,
|
||||
max_delay_samples: self.max_delay_samples,
|
||||
sample_rate: self.sample_rate,
|
||||
lfo_phase: 0.0,
|
||||
inputs: self.inputs.clone(),
|
||||
outputs: self.outputs.clone(),
|
||||
parameters: self.parameters.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -1817,8 +1817,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"emath",
|
||||
|
|
@ -1828,8 +1826,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "eframe"
|
||||
version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"bytemuck",
|
||||
|
|
@ -1865,8 +1861,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "egui"
|
||||
version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"ahash 0.8.12",
|
||||
|
|
@ -1885,8 +1879,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "egui-wgpu"
|
||||
version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"bytemuck",
|
||||
|
|
@ -1905,8 +1897,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "egui-winit"
|
||||
version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29"
|
||||
dependencies = [
|
||||
"accesskit_winit",
|
||||
"arboard",
|
||||
|
|
@ -1936,8 +1926,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d01d34e845f01c62e3fded726961092e70417d66570c499b9817ab24674ca4ed"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"egui",
|
||||
|
|
@ -1953,8 +1941,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"egui",
|
||||
|
|
@ -1987,8 +1973,6 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
|||
[[package]]
|
||||
name = "emath"
|
||||
version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde",
|
||||
|
|
@ -2064,8 +2048,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "epaint"
|
||||
version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"ahash 0.8.12",
|
||||
|
|
@ -2083,8 +2065,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "epaint_default_fonts"
|
||||
version = "0.33.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862"
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
|
|
@ -3476,7 +3456,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightningbeam-editor"
|
||||
version = "1.0.0-alpha"
|
||||
version = "1.0.1-alpha"
|
||||
dependencies = [
|
||||
"beamdsp",
|
||||
"bytemuck",
|
||||
|
|
@ -4050,7 +4030,7 @@ version = "0.7.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
|
||||
dependencies = [
|
||||
"proc-macro-crate 1.3.1",
|
||||
"proc-macro-crate 2.0.2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
|
|
@ -7144,7 +7124,7 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -8142,35 +8122,3 @@ dependencies = [
|
|||
"syn 2.0.110",
|
||||
"winnow 0.7.13",
|
||||
]
|
||||
|
||||
[[patch.unused]]
|
||||
name = "ecolor"
|
||||
version = "0.33.2"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "eframe"
|
||||
version = "0.33.2"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui"
|
||||
version = "0.33.2"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui-wgpu"
|
||||
version = "0.33.2"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui-winit"
|
||||
version = "0.33.2"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.33.2"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "emath"
|
||||
version = "0.33.2"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "epaint"
|
||||
version = "0.33.2"
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ impl Action for AddClipInstanceAction {
|
|||
AnyLayer::Effect(_) => {
|
||||
return Err("Cannot add clip instances to effect layers".to_string());
|
||||
}
|
||||
AnyLayer::Group(_) => {
|
||||
return Err("Cannot add clip instances directly to group layers".to_string());
|
||||
}
|
||||
}
|
||||
self.executed = true;
|
||||
|
||||
|
|
@ -136,6 +139,9 @@ impl Action for AddClipInstanceAction {
|
|||
AnyLayer::Effect(_) => {
|
||||
// Effect layers don't have clip instances, nothing to rollback
|
||||
}
|
||||
AnyLayer::Group(_) => {
|
||||
// Group layers don't have clip instances, nothing to rollback
|
||||
}
|
||||
}
|
||||
self.executed = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ pub struct AddLayerAction {
|
|||
/// If Some, add to this VectorClip's layers instead of root
|
||||
target_clip_id: Option<Uuid>,
|
||||
|
||||
/// If Some, add as a child of this GroupLayer instead of root
|
||||
target_group_id: Option<Uuid>,
|
||||
|
||||
/// ID of the created layer (set after execution)
|
||||
created_layer_id: Option<Uuid>,
|
||||
}
|
||||
|
|
@ -30,6 +33,7 @@ impl AddLayerAction {
|
|||
Self {
|
||||
layer: AnyLayer::Vector(layer),
|
||||
target_clip_id: None,
|
||||
target_group_id: None,
|
||||
created_layer_id: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +47,7 @@ impl AddLayerAction {
|
|||
Self {
|
||||
layer,
|
||||
target_clip_id: None,
|
||||
target_group_id: None,
|
||||
created_layer_id: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -53,6 +58,12 @@ impl AddLayerAction {
|
|||
self
|
||||
}
|
||||
|
||||
/// Set the target group for this action (add layer inside a group layer)
|
||||
pub fn with_target_group(mut self, group_id: Uuid) -> Self {
|
||||
self.target_group_id = Some(group_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Get the ID of the created layer (after execution)
|
||||
pub fn created_layer_id(&self) -> Option<Uuid> {
|
||||
self.created_layer_id
|
||||
|
|
@ -61,7 +72,18 @@ impl AddLayerAction {
|
|||
|
||||
impl Action for AddLayerAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let layer_id = if let Some(clip_id) = self.target_clip_id {
|
||||
let layer_id = if let Some(group_id) = self.target_group_id {
|
||||
// Add layer inside a group layer
|
||||
let id = self.layer.id();
|
||||
if let Some(AnyLayer::Group(g)) = document.root.children.iter_mut()
|
||||
.find(|l| l.id() == group_id)
|
||||
{
|
||||
g.add_child(self.layer.clone());
|
||||
} else {
|
||||
return Err(format!("Target group {} not found", group_id));
|
||||
}
|
||||
id
|
||||
} else if let Some(clip_id) = self.target_clip_id {
|
||||
// Add layer inside a vector clip (movie clip)
|
||||
let clip = document.vector_clips.get_mut(&clip_id)
|
||||
.ok_or_else(|| format!("Target clip {} not found", clip_id))?;
|
||||
|
|
@ -84,7 +106,14 @@ impl Action for AddLayerAction {
|
|||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
// Remove the created layer if it exists
|
||||
if let Some(layer_id) = self.created_layer_id {
|
||||
if let Some(clip_id) = self.target_clip_id {
|
||||
if let Some(group_id) = self.target_group_id {
|
||||
// Remove from group layer
|
||||
if let Some(AnyLayer::Group(g)) = document.root.children.iter_mut()
|
||||
.find(|l| l.id() == group_id)
|
||||
{
|
||||
g.children.retain(|l| l.id() != layer_id);
|
||||
}
|
||||
} else if let Some(clip_id) = self.target_clip_id {
|
||||
// Remove from vector clip
|
||||
if let Some(clip) = document.vector_clips.get_mut(&clip_id) {
|
||||
clip.layers.roots.retain(|node| node.data.id() != layer_id);
|
||||
|
|
@ -107,6 +136,7 @@ impl Action for AddLayerAction {
|
|||
AnyLayer::Audio(_) => "Add audio layer",
|
||||
AnyLayer::Video(_) => "Add video layer",
|
||||
AnyLayer::Effect(_) => "Add effect layer",
|
||||
AnyLayer::Group(_) => "Add group layer",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
//! `Dcel::insert_stroke()`. Undo is handled by snapshotting the DCEL.
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::dcel::{bezpath_to_cubic_segments, Dcel, DEFAULT_SNAP_EPSILON};
|
||||
use crate::dcel::{bezpath_to_cubic_segments, Dcel, FaceId, DEFAULT_SNAP_EPSILON};
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::shape::{ShapeColor, StrokeStyle};
|
||||
|
|
@ -87,8 +87,24 @@ impl Action for AddShapeAction {
|
|||
// 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());
|
||||
if !result.new_faces.is_empty() {
|
||||
for face_id in &result.new_faces {
|
||||
dcel.face_mut(*face_id).fill_color = Some(fill.clone());
|
||||
}
|
||||
} else if let Some(&first_edge) = result.new_edges.first() {
|
||||
// Closed shape in F0 — no face was auto-created.
|
||||
// One half-edge of the first new edge is on the interior cycle.
|
||||
// Pick the side with positive signed area (CCW winding).
|
||||
let [he_a, he_b] = dcel.edge(first_edge).half_edges;
|
||||
let interior_he = if dcel.cycle_signed_area(he_a) > 0.0 {
|
||||
he_a
|
||||
} else {
|
||||
he_b
|
||||
};
|
||||
if dcel.half_edge(interior_he).face == FaceId(0) {
|
||||
let face_id = dcel.create_face_at_cycle(interior_he);
|
||||
dcel.face_mut(face_id).fill_color = Some(fill.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ impl Action for LoopClipInstancesAction {
|
|||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
AnyLayer::Group(_) => continue,
|
||||
};
|
||||
|
||||
for (instance_id, _old_dur, new_dur, _old_lb, new_lb) in loops {
|
||||
|
|
@ -57,6 +58,7 @@ impl Action for LoopClipInstancesAction {
|
|||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
AnyLayer::Group(_) => continue,
|
||||
};
|
||||
|
||||
for (instance_id, old_dur, _new_dur, old_lb, _new_lb) in loops {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ pub mod set_keyframe;
|
|||
pub mod group_shapes;
|
||||
pub mod convert_to_movie_clip;
|
||||
pub mod region_split;
|
||||
pub mod toggle_group_expansion;
|
||||
|
||||
pub use add_clip_instance::AddClipInstanceAction;
|
||||
pub use add_effect::AddEffectAction;
|
||||
|
|
@ -56,3 +57,4 @@ pub use set_keyframe::SetKeyframeAction;
|
|||
pub use group_shapes::GroupAction;
|
||||
pub use convert_to_movie_clip::ConvertToMovieClipAction;
|
||||
pub use region_split::RegionSplitAction;
|
||||
pub use toggle_group_expansion::ToggleGroupExpansionAction;
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ impl Action for MoveClipInstancesAction {
|
|||
AnyLayer::Audio(al) => &al.clip_instances,
|
||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &el.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
};
|
||||
|
||||
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
|
||||
|
|
@ -93,6 +94,7 @@ impl Action for MoveClipInstancesAction {
|
|||
AnyLayer::Video(vl) => &vl.clip_instances,
|
||||
AnyLayer::Vector(vl) => &vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &el.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
};
|
||||
|
||||
let group: Vec<(Uuid, f64, f64)> = moves.iter().filter_map(|(id, old_start, _)| {
|
||||
|
|
@ -126,6 +128,7 @@ impl Action for MoveClipInstancesAction {
|
|||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
AnyLayer::Group(_) => continue,
|
||||
};
|
||||
|
||||
// Update timeline_start for each clip instance
|
||||
|
|
@ -151,6 +154,7 @@ impl Action for MoveClipInstancesAction {
|
|||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
AnyLayer::Group(_) => continue,
|
||||
};
|
||||
|
||||
// Restore original timeline_start for each clip instance
|
||||
|
|
|
|||
|
|
@ -55,22 +55,28 @@ impl Action for PaintBucketAction {
|
|||
// Record for debug test generation (if recording is active)
|
||||
dcel.record_paint_point(self.click_point);
|
||||
|
||||
// Hit-test to find which face was clicked
|
||||
let face_id = dcel.find_face_containing_point(self.click_point);
|
||||
// Find the enclosing cycle for the click point
|
||||
let query = dcel.find_face_at_point(self.click_point);
|
||||
|
||||
// Dump cumulative test to stderr after every paint click (if recording)
|
||||
// Do this before the early return so failed clicks are captured too.
|
||||
if dcel.is_recording() {
|
||||
eprintln!("\n--- DCEL debug test (cumulative, face={:?}) ---", face_id);
|
||||
eprintln!("\n--- DCEL debug test (cumulative, face={:?}) ---", query.face);
|
||||
dcel.debug_recorder.as_ref().unwrap().dump_test("test_recorded");
|
||||
eprintln!("--- end test ---\n");
|
||||
}
|
||||
|
||||
if face_id.0 == 0 {
|
||||
// FaceId(0) is the unbounded exterior face — nothing to fill
|
||||
if query.cycle_he.is_none() {
|
||||
// No edges at all — nothing to fill
|
||||
return Err("No face at click point".to_string());
|
||||
}
|
||||
|
||||
// If the cycle is in F0 (no face created yet), create one now
|
||||
let face_id = if query.face.0 == 0 {
|
||||
dcel.create_face_at_cycle(query.cycle_he)
|
||||
} else {
|
||||
query.face
|
||||
};
|
||||
|
||||
// Store for undo
|
||||
self.hit_face = Some(face_id);
|
||||
self.old_fill_color = Some(dcel.face(face_id).fill_color.clone());
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ impl Action for RemoveClipInstancesAction {
|
|||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
AnyLayer::Group(_) => continue,
|
||||
};
|
||||
|
||||
// Find and remove the instance, saving it for rollback
|
||||
|
|
@ -68,6 +69,7 @@ impl Action for RemoveClipInstancesAction {
|
|||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
AnyLayer::Group(_) => continue,
|
||||
};
|
||||
|
||||
clip_instances.push(instance);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
use crate::shape::ShapeColor;
|
||||
|
||||
/// Individual property change for a document
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -13,18 +14,14 @@ pub enum DocumentPropertyChange {
|
|||
Height(f64),
|
||||
Duration(f64),
|
||||
Framerate(f64),
|
||||
BackgroundColor(ShapeColor),
|
||||
}
|
||||
|
||||
impl DocumentPropertyChange {
|
||||
/// Extract the f64 value from any variant
|
||||
fn value(&self) -> f64 {
|
||||
match self {
|
||||
DocumentPropertyChange::Width(v) => *v,
|
||||
DocumentPropertyChange::Height(v) => *v,
|
||||
DocumentPropertyChange::Duration(v) => *v,
|
||||
DocumentPropertyChange::Framerate(v) => *v,
|
||||
}
|
||||
}
|
||||
/// Stored old value for undo (either f64 or color)
|
||||
#[derive(Clone, Debug)]
|
||||
enum OldValue {
|
||||
F64(f64),
|
||||
Color(ShapeColor),
|
||||
}
|
||||
|
||||
/// Action that sets a property on the document
|
||||
|
|
@ -32,7 +29,7 @@ pub struct SetDocumentPropertiesAction {
|
|||
/// The new property value
|
||||
property: DocumentPropertyChange,
|
||||
/// The old value for undo
|
||||
old_value: Option<f64>,
|
||||
old_value: Option<OldValue>,
|
||||
}
|
||||
|
||||
impl SetDocumentPropertiesAction {
|
||||
|
|
@ -68,41 +65,53 @@ impl SetDocumentPropertiesAction {
|
|||
}
|
||||
}
|
||||
|
||||
fn get_current_value(&self, document: &Document) -> f64 {
|
||||
match &self.property {
|
||||
DocumentPropertyChange::Width(_) => document.width,
|
||||
DocumentPropertyChange::Height(_) => document.height,
|
||||
DocumentPropertyChange::Duration(_) => document.duration,
|
||||
DocumentPropertyChange::Framerate(_) => document.framerate,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_value(&self, document: &mut Document, value: f64) {
|
||||
match &self.property {
|
||||
DocumentPropertyChange::Width(_) => document.width = value,
|
||||
DocumentPropertyChange::Height(_) => document.height = value,
|
||||
DocumentPropertyChange::Duration(_) => document.duration = value,
|
||||
DocumentPropertyChange::Framerate(_) => document.framerate = value,
|
||||
/// Create a new action to set background color
|
||||
pub fn set_background_color(color: ShapeColor) -> Self {
|
||||
Self {
|
||||
property: DocumentPropertyChange::BackgroundColor(color),
|
||||
old_value: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for SetDocumentPropertiesAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
// Store old value if not already stored
|
||||
if self.old_value.is_none() {
|
||||
self.old_value = Some(self.get_current_value(document));
|
||||
self.old_value = Some(match &self.property {
|
||||
DocumentPropertyChange::Width(_) => OldValue::F64(document.width),
|
||||
DocumentPropertyChange::Height(_) => OldValue::F64(document.height),
|
||||
DocumentPropertyChange::Duration(_) => OldValue::F64(document.duration),
|
||||
DocumentPropertyChange::Framerate(_) => OldValue::F64(document.framerate),
|
||||
DocumentPropertyChange::BackgroundColor(_) => OldValue::Color(document.background_color),
|
||||
});
|
||||
}
|
||||
|
||||
// Apply new value
|
||||
let new_value = self.property.value();
|
||||
self.apply_value(document, new_value);
|
||||
match &self.property {
|
||||
DocumentPropertyChange::Width(v) => document.width = *v,
|
||||
DocumentPropertyChange::Height(v) => document.height = *v,
|
||||
DocumentPropertyChange::Duration(v) => document.duration = *v,
|
||||
DocumentPropertyChange::Framerate(v) => document.framerate = *v,
|
||||
DocumentPropertyChange::BackgroundColor(c) => document.background_color = *c,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
if let Some(old_value) = self.old_value {
|
||||
self.apply_value(document, old_value);
|
||||
match &self.old_value {
|
||||
Some(OldValue::F64(v)) => {
|
||||
let v = *v;
|
||||
match &self.property {
|
||||
DocumentPropertyChange::Width(_) => document.width = v,
|
||||
DocumentPropertyChange::Height(_) => document.height = v,
|
||||
DocumentPropertyChange::Duration(_) => document.duration = v,
|
||||
DocumentPropertyChange::Framerate(_) => document.framerate = v,
|
||||
DocumentPropertyChange::BackgroundColor(_) => {}
|
||||
}
|
||||
}
|
||||
Some(OldValue::Color(c)) => {
|
||||
document.background_color = *c;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -113,6 +122,7 @@ impl Action for SetDocumentPropertiesAction {
|
|||
DocumentPropertyChange::Height(_) => "canvas height",
|
||||
DocumentPropertyChange::Duration(_) => "duration",
|
||||
DocumentPropertyChange::Framerate(_) => "framerate",
|
||||
DocumentPropertyChange::BackgroundColor(_) => "background color",
|
||||
};
|
||||
format!("Set {}", property_name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
use crate::layer::LayerTrait;
|
||||
use crate::layer::{AnyLayer, LayerTrait};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Property that can be set on a layer
|
||||
|
|
@ -17,6 +17,8 @@ pub enum LayerProperty {
|
|||
Locked(bool),
|
||||
Opacity(f64),
|
||||
Visible(bool),
|
||||
/// Video layer only: toggle live webcam preview
|
||||
CameraEnabled(bool),
|
||||
}
|
||||
|
||||
/// Stored old value for rollback
|
||||
|
|
@ -28,6 +30,7 @@ enum OldValue {
|
|||
Locked(bool),
|
||||
Opacity(f64),
|
||||
Visible(bool),
|
||||
CameraEnabled(bool),
|
||||
}
|
||||
|
||||
/// Action that sets a property on one or more layers
|
||||
|
|
@ -87,6 +90,14 @@ impl Action for SetLayerPropertiesAction {
|
|||
LayerProperty::Locked(_) => OldValue::Locked(layer.locked()),
|
||||
LayerProperty::Opacity(_) => OldValue::Opacity(layer.opacity()),
|
||||
LayerProperty::Visible(_) => OldValue::Visible(layer.visible()),
|
||||
LayerProperty::CameraEnabled(_) => {
|
||||
let val = if let AnyLayer::Video(v) = layer {
|
||||
v.camera_enabled
|
||||
} else {
|
||||
false
|
||||
};
|
||||
OldValue::CameraEnabled(val)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -98,6 +109,11 @@ impl Action for SetLayerPropertiesAction {
|
|||
LayerProperty::Locked(l) => layer.set_locked(*l),
|
||||
LayerProperty::Opacity(o) => layer.set_opacity(*o),
|
||||
LayerProperty::Visible(v) => layer.set_visible(*v),
|
||||
LayerProperty::CameraEnabled(c) => {
|
||||
if let AnyLayer::Video(v) = layer {
|
||||
v.camera_enabled = *c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -117,6 +133,11 @@ impl Action for SetLayerPropertiesAction {
|
|||
OldValue::Locked(l) => layer.set_locked(*l),
|
||||
OldValue::Opacity(o) => layer.set_opacity(*o),
|
||||
OldValue::Visible(v) => layer.set_visible(*v),
|
||||
OldValue::CameraEnabled(c) => {
|
||||
if let AnyLayer::Video(v) = layer {
|
||||
v.camera_enabled = *c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -140,7 +161,7 @@ impl Action for SetLayerPropertiesAction {
|
|||
LayerProperty::Volume(v) => controller.set_track_volume(track_id, *v as f32),
|
||||
LayerProperty::Muted(m) => controller.set_track_mute(track_id, *m),
|
||||
LayerProperty::Soloed(s) => controller.set_track_solo(track_id, *s),
|
||||
_ => {} // Locked/Opacity/Visible are UI-only
|
||||
_ => {} // Locked/Opacity/Visible/CameraEnabled are UI-only
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -180,6 +201,7 @@ impl Action for SetLayerPropertiesAction {
|
|||
LayerProperty::Locked(_) => "lock",
|
||||
LayerProperty::Opacity(_) => "opacity",
|
||||
LayerProperty::Visible(_) => "visibility",
|
||||
LayerProperty::CameraEnabled(_) => "camera",
|
||||
};
|
||||
|
||||
if self.layer_ids.len() == 1 {
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ impl Action for SplitClipInstanceAction {
|
|||
AnyLayer::Audio(al) => &al.clip_instances,
|
||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &el.clip_instances,
|
||||
AnyLayer::Group(_) => return Err("Cannot split clip instances on group layers".to_string()),
|
||||
};
|
||||
|
||||
let instance = clip_instances
|
||||
|
|
@ -228,6 +229,9 @@ impl Action for SplitClipInstanceAction {
|
|||
}
|
||||
el.clip_instances.push(right_instance);
|
||||
}
|
||||
AnyLayer::Group(_) => {
|
||||
return Err("Cannot split clip instances on group layers".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
self.executed = true;
|
||||
|
|
@ -283,6 +287,9 @@ impl Action for SplitClipInstanceAction {
|
|||
inst.timeline_duration = self.original_timeline_duration;
|
||||
}
|
||||
}
|
||||
AnyLayer::Group(_) => {
|
||||
// Group layers don't have clip instances, nothing to rollback
|
||||
}
|
||||
}
|
||||
|
||||
self.executed = false;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
//! Toggle group layer expansion state (collapsed/expanded in timeline)
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that toggles a group layer's expanded/collapsed state
|
||||
pub struct ToggleGroupExpansionAction {
|
||||
group_id: Uuid,
|
||||
new_expanded: bool,
|
||||
old_expanded: Option<bool>,
|
||||
}
|
||||
|
||||
impl ToggleGroupExpansionAction {
|
||||
pub fn new(group_id: Uuid, expanded: bool) -> Self {
|
||||
Self {
|
||||
group_id,
|
||||
new_expanded: expanded,
|
||||
old_expanded: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for ToggleGroupExpansionAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
if let Some(AnyLayer::Group(g)) = document
|
||||
.root
|
||||
.children
|
||||
.iter_mut()
|
||||
.find(|l| l.id() == self.group_id)
|
||||
{
|
||||
self.old_expanded = Some(g.expanded);
|
||||
g.expanded = self.new_expanded;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Group layer {} not found", self.group_id))
|
||||
}
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
if let Some(old) = self.old_expanded {
|
||||
if let Some(AnyLayer::Group(g)) = document
|
||||
.root
|
||||
.children
|
||||
.iter_mut()
|
||||
.find(|l| l.id() == self.group_id)
|
||||
{
|
||||
g.expanded = old;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
if self.new_expanded {
|
||||
"Expand group".to_string()
|
||||
} else {
|
||||
"Collapse group".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -99,6 +99,7 @@ impl Action for TransformClipInstancesAction {
|
|||
}
|
||||
}
|
||||
AnyLayer::Effect(_) => {}
|
||||
AnyLayer::Group(_) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -136,6 +137,7 @@ impl Action for TransformClipInstancesAction {
|
|||
}
|
||||
}
|
||||
AnyLayer::Effect(_) => {}
|
||||
AnyLayer::Group(_) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ impl Action for TrimClipInstancesAction {
|
|||
AnyLayer::Audio(al) => &al.clip_instances,
|
||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &el.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
};
|
||||
|
||||
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
|
||||
|
|
@ -134,6 +135,7 @@ impl Action for TrimClipInstancesAction {
|
|||
AnyLayer::Audio(al) => &al.clip_instances,
|
||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &el.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
};
|
||||
|
||||
if let Some(instance) = clip_instances.iter().find(|ci| ci.id == *member_instance_id) {
|
||||
|
|
@ -176,6 +178,7 @@ impl Action for TrimClipInstancesAction {
|
|||
AnyLayer::Video(vl) => &vl.clip_instances,
|
||||
AnyLayer::Vector(vl) => &vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &el.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
};
|
||||
|
||||
let instance = clip_instances.iter()
|
||||
|
|
@ -267,6 +270,7 @@ impl Action for TrimClipInstancesAction {
|
|||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
AnyLayer::Group(_) => continue,
|
||||
};
|
||||
|
||||
// Apply trims
|
||||
|
|
@ -305,6 +309,7 @@ impl Action for TrimClipInstancesAction {
|
|||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
AnyLayer::Group(_) => continue,
|
||||
};
|
||||
|
||||
// Restore original trim values
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ impl VectorClip {
|
|||
AnyLayer::Audio(al) => &al.clip_instances,
|
||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &el.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
};
|
||||
for ci in clip_instances {
|
||||
let end = if let Some(td) = ci.timeline_duration {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ impl ClipboardLayerType {
|
|||
AudioLayerType::Midi => ClipboardLayerType::AudioMidi,
|
||||
},
|
||||
AnyLayer::Effect(_) => ClipboardLayerType::Effect,
|
||||
AnyLayer::Group(_) => ClipboardLayerType::Vector, // Groups don't have a direct clipboard type; treat as vector
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
//! maintained such that wherever two strokes intersect there is a vertex.
|
||||
|
||||
use crate::shape::{FillRule, ShapeColor, StrokeStyle};
|
||||
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, Point};
|
||||
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, ParamCurveNearest, Point, Shape as KurboShape};
|
||||
use rstar::{PointDistance, RTree, RTreeObject, AABB};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
|
@ -774,6 +774,274 @@ impl Dcel {
|
|||
path
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Region queries
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Return all non-deleted, non-unbounded faces whose interior lies inside `region`.
|
||||
///
|
||||
/// For each face, a representative interior point is found by offsetting from
|
||||
/// a boundary edge midpoint along the inward-facing normal (the face lies to
|
||||
/// the left of its boundary half-edges). This works for concave/crescent faces
|
||||
/// where a simple centroid could land outside the face.
|
||||
// -----------------------------------------------------------------------
|
||||
// Region extraction (split DCEL by vertex classification)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Extract the portion of the DCEL inside a closed region path.
|
||||
///
|
||||
/// After inserting the region boundary via `insert_stroke()`, all crossing
|
||||
/// edges have been split at intersection points. This method classifies
|
||||
/// every vertex as INSIDE, OUTSIDE, or BOUNDARY (on the region path),
|
||||
/// then:
|
||||
/// - In a clone (`extracted`): removes edges with any OUTSIDE endpoint
|
||||
/// - In `self`: removes edges with any INSIDE endpoint
|
||||
///
|
||||
/// Boundary edges (both endpoints on the boundary) are kept in **both**.
|
||||
/// Returns the extracted (inside) DCEL.
|
||||
pub fn extract_region(&mut self, region: &BezPath, epsilon: f64) -> Dcel {
|
||||
// Step 1: Classify every non-deleted vertex
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum VClass { Inside, Outside, Boundary }
|
||||
|
||||
let classifications: Vec<VClass> = self.vertices.iter().map(|v| {
|
||||
if v.deleted {
|
||||
return VClass::Outside; // doesn't matter, won't be referenced
|
||||
}
|
||||
// Check distance to region path
|
||||
let pos = v.position;
|
||||
if Self::point_distance_to_path(pos, region) < epsilon {
|
||||
VClass::Boundary
|
||||
} else if region.winding(pos) != 0 {
|
||||
VClass::Inside
|
||||
} else {
|
||||
VClass::Outside
|
||||
}
|
||||
}).collect();
|
||||
|
||||
// Step 2: Clone self → extracted
|
||||
let mut extracted = self.clone();
|
||||
|
||||
// Step 3: In extracted, remove edges where either endpoint is OUTSIDE
|
||||
let edges_to_remove_from_extracted: Vec<EdgeId> = extracted.edges.iter().enumerate()
|
||||
.filter_map(|(i, edge)| {
|
||||
if edge.deleted { return None; }
|
||||
let edge_id = EdgeId(i as u32);
|
||||
let [he_fwd, he_bwd] = edge.half_edges;
|
||||
let v1 = extracted.half_edges[he_fwd.idx()].origin;
|
||||
let v2 = extracted.half_edges[he_bwd.idx()].origin;
|
||||
if classifications[v1.idx()] == VClass::Outside
|
||||
|| classifications[v2.idx()] == VClass::Outside
|
||||
{
|
||||
Some(edge_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for edge_id in edges_to_remove_from_extracted {
|
||||
if !extracted.edges[edge_id.idx()].deleted {
|
||||
extracted.remove_edge(edge_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: In self, remove edges where either endpoint is INSIDE
|
||||
let edges_to_remove_from_self: Vec<EdgeId> = self.edges.iter().enumerate()
|
||||
.filter_map(|(i, edge)| {
|
||||
if edge.deleted { return None; }
|
||||
let edge_id = EdgeId(i as u32);
|
||||
let [he_fwd, he_bwd] = edge.half_edges;
|
||||
let v1 = self.half_edges[he_fwd.idx()].origin;
|
||||
let v2 = self.half_edges[he_bwd.idx()].origin;
|
||||
if classifications[v1.idx()] == VClass::Inside
|
||||
|| classifications[v2.idx()] == VClass::Inside
|
||||
{
|
||||
Some(edge_id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for edge_id in edges_to_remove_from_self {
|
||||
if !self.edges[edge_id.idx()].deleted {
|
||||
self.remove_edge(edge_id);
|
||||
}
|
||||
}
|
||||
|
||||
extracted
|
||||
}
|
||||
|
||||
/// Propagate fill properties from a snapshot DCEL to faces that lost them
|
||||
/// during `insert_stroke` (e.g., when region boundary edges split a filled face
|
||||
/// but the new sub-face didn't inherit the fill).
|
||||
///
|
||||
/// For each unfilled face, finds a robust interior sample point (centroid with
|
||||
/// winding-check, or inward-normal offset fallback), then looks it up in the
|
||||
/// snapshot to determine what fill it should have.
|
||||
pub fn propagate_fills(&mut self, snapshot: &Dcel) {
|
||||
use kurbo::ParamCurveDeriv;
|
||||
|
||||
for i in 1..self.faces.len() {
|
||||
let face = &self.faces[i];
|
||||
if face.deleted || face.outer_half_edge.is_none() {
|
||||
continue;
|
||||
}
|
||||
// Skip faces that already have fill
|
||||
if face.fill_color.is_some() || face.image_fill.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let face_id = FaceId(i as u32);
|
||||
let boundary = self.face_boundary(face_id);
|
||||
if boundary.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let face_path = self.halfedges_to_bezpath(&boundary);
|
||||
|
||||
// Strategy 1: Use the centroid of boundary vertices. For convex faces
|
||||
// (common after region splitting), this is guaranteed to be interior.
|
||||
let mut cx = 0.0;
|
||||
let mut cy = 0.0;
|
||||
let mut n_verts = 0;
|
||||
for &he_id in &boundary {
|
||||
let he = &self.half_edges[he_id.idx()];
|
||||
let v = &self.vertices[he.origin.idx()];
|
||||
cx += v.position.x;
|
||||
cy += v.position.y;
|
||||
n_verts += 1;
|
||||
}
|
||||
let mut sample_point = None;
|
||||
if n_verts > 0 {
|
||||
let centroid = Point::new(cx / n_verts as f64, cy / n_verts as f64);
|
||||
if face_path.winding(centroid) != 0 {
|
||||
sample_point = Some(centroid);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Inward-normal offset from edge midpoints (fallback for
|
||||
// non-convex faces where the centroid falls outside).
|
||||
if sample_point.is_none() {
|
||||
let epsilon = 0.5;
|
||||
for &he_id in &boundary {
|
||||
let he = &self.half_edges[he_id.idx()];
|
||||
let edge = &self.edges[he.edge.idx()];
|
||||
let is_forward = edge.half_edges[0] == he_id;
|
||||
let curve = if is_forward {
|
||||
edge.curve
|
||||
} else {
|
||||
CubicBez::new(edge.curve.p3, edge.curve.p2, edge.curve.p1, edge.curve.p0)
|
||||
};
|
||||
|
||||
let mid = curve.eval(0.5);
|
||||
let tangent = curve.deriv().eval(0.5);
|
||||
let len = (tangent.x * tangent.x + tangent.y * tangent.y).sqrt();
|
||||
if len < 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let nx = tangent.y / len;
|
||||
let ny = -tangent.x / len;
|
||||
let candidate = Point::new(mid.x + nx * epsilon, mid.y + ny * epsilon);
|
||||
if face_path.winding(candidate) != 0 {
|
||||
sample_point = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sample = match sample_point {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Look up which face this interior point was in the snapshot
|
||||
let snap_face_id = snapshot.find_face_containing_point(sample);
|
||||
if snap_face_id.0 == 0 {
|
||||
continue;
|
||||
}
|
||||
let snap_face = &snapshot.faces[snap_face_id.idx()];
|
||||
if snap_face.fill_color.is_some() || snap_face.image_fill.is_some() {
|
||||
self.faces[i].fill_color = snap_face.fill_color.clone();
|
||||
self.faces[i].image_fill = snap_face.image_fill;
|
||||
self.faces[i].fill_rule = snap_face.fill_rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the minimum distance from a point to a BezPath (treated as a polyline/curve).
|
||||
fn point_distance_to_path(point: Point, path: &BezPath) -> f64 {
|
||||
use kurbo::PathEl;
|
||||
|
||||
let mut min_dist = f64::MAX;
|
||||
let mut current = Point::ZERO;
|
||||
let mut subpath_start = Point::ZERO;
|
||||
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
current = p;
|
||||
subpath_start = p;
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
let d = Self::point_to_line_segment_dist(point, current, p);
|
||||
if d < min_dist { min_dist = d; }
|
||||
current = p;
|
||||
}
|
||||
PathEl::QuadTo(cp, p) => {
|
||||
// Approximate as cubic
|
||||
let cp1 = Point::new(
|
||||
current.x + 2.0 / 3.0 * (cp.x - current.x),
|
||||
current.y + 2.0 / 3.0 * (cp.y - current.y),
|
||||
);
|
||||
let cp2 = Point::new(
|
||||
p.x + 2.0 / 3.0 * (cp.x - p.x),
|
||||
p.y + 2.0 / 3.0 * (cp.y - p.y),
|
||||
);
|
||||
let cubic = CubicBez::new(current, cp1, cp2, p);
|
||||
let nearest = cubic.nearest(point, 0.5);
|
||||
let d = nearest.distance_sq.sqrt();
|
||||
if d < min_dist { min_dist = d; }
|
||||
current = p;
|
||||
}
|
||||
PathEl::CurveTo(cp1, cp2, p) => {
|
||||
let cubic = CubicBez::new(current, cp1, cp2, p);
|
||||
let nearest = cubic.nearest(point, 0.5);
|
||||
let d = nearest.distance_sq.sqrt();
|
||||
if d < min_dist { min_dist = d; }
|
||||
current = p;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
if (current.x - subpath_start.x).abs() > 1e-10
|
||||
|| (current.y - subpath_start.y).abs() > 1e-10
|
||||
{
|
||||
let d = Self::point_to_line_segment_dist(point, current, subpath_start);
|
||||
if d < min_dist { min_dist = d; }
|
||||
}
|
||||
current = subpath_start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
min_dist
|
||||
}
|
||||
|
||||
/// Distance from a point to a line segment.
|
||||
fn point_to_line_segment_dist(point: Point, a: Point, b: Point) -> f64 {
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
let len_sq = dx * dx + dy * dy;
|
||||
if len_sq < 1e-20 {
|
||||
return ((point.x - a.x).powi(2) + (point.y - a.y).powi(2)).sqrt();
|
||||
}
|
||||
let t = ((point.x - a.x) * dx + (point.y - a.y) * dy) / len_sq;
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let proj_x = a.x + t * dx;
|
||||
let proj_y = a.y + t * dy;
|
||||
((point.x - proj_x).powi(2) + (point.y - proj_y).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Validation (debug)
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -989,10 +1257,33 @@ impl Dcel {
|
|||
let t1 = hit.t1;
|
||||
let t2 = hit.t2.unwrap_or(0.5);
|
||||
|
||||
// Check if intersection is close to a shared endpoint vertex.
|
||||
// This handles edges that share a vertex and run nearly
|
||||
// parallel near the junction — the intersection finder can
|
||||
// report a hit a few pixels from the shared vertex.
|
||||
// Skip crossings near a shared endpoint vertex. After
|
||||
// splitting at a crossing, the sub-curves can still graze
|
||||
// each other near the shared vertex. Detect this by
|
||||
// checking whether the t-value on each edge places the
|
||||
// hit near the endpoint that IS the shared vertex.
|
||||
// Requires both edges to be near the shared vertex —
|
||||
// a T-junction has the hit near the stem's endpoint but
|
||||
// interior on the bar, so it won't be skipped.
|
||||
let near_shared = shared.iter().any(|&sv| {
|
||||
let a_near = if verts_a[0] == sv {
|
||||
t1 < 0.05
|
||||
} else {
|
||||
t1 > 0.95
|
||||
};
|
||||
let b_near = if verts_b[0] == sv {
|
||||
t2 < 0.05
|
||||
} else {
|
||||
t2 > 0.95
|
||||
};
|
||||
a_near && b_near
|
||||
});
|
||||
if near_shared {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also skip if spatially close to a shared vertex
|
||||
// (catches cases where t-based check is borderline).
|
||||
let close_to_shared = shared.iter().any(|&sv| {
|
||||
let sv_pos = self.vertex(sv).position;
|
||||
(hit.point - sv_pos).hypot() < 2.0
|
||||
|
|
@ -2557,13 +2848,35 @@ impl Dcel {
|
|||
v_keep: VertexId,
|
||||
v_remove: VertexId,
|
||||
) {
|
||||
// Re-home half-edges from v_remove → v_keep
|
||||
// If snap_vertex already merged these during split_edge, they're the
|
||||
// same vertex. Proceeding would call free_vertex on a live vertex,
|
||||
// putting it on the free list while edges still reference it.
|
||||
if v_keep == v_remove {
|
||||
return;
|
||||
}
|
||||
|
||||
let keep_pos = self.vertices[v_keep.idx()].position;
|
||||
|
||||
// Re-home half-edges from v_remove → v_keep, and fix curve endpoints
|
||||
for i in 0..self.half_edges.len() {
|
||||
if self.half_edges[i].deleted {
|
||||
continue;
|
||||
}
|
||||
if self.half_edges[i].origin == v_remove {
|
||||
self.half_edges[i].origin = v_keep;
|
||||
|
||||
// Fix the curve endpoint so it matches v_keep's position.
|
||||
// A half-edge's origin is the start of that half-edge.
|
||||
// The forward half-edge (index 0) of an edge starts at p0.
|
||||
// The backward half-edge (index 1) starts at p3.
|
||||
let edge_id = self.half_edges[i].edge;
|
||||
let edge = &self.edges[edge_id.idx()];
|
||||
let he_id = HalfEdgeId(i as u32);
|
||||
if edge.half_edges[0] == he_id {
|
||||
self.edges[edge_id.idx()].curve.p0 = keep_pos;
|
||||
} else if edge.half_edges[1] == he_id {
|
||||
self.edges[edge_id.idx()].curve.p3 = keep_pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4619,4 +4932,85 @@ mod tests {
|
|||
|
||||
dump_all_faces(&d, "After stroke 4");
|
||||
}
|
||||
|
||||
/// Reproduce the user's test case: rectangle (100,100)-(200,200),
|
||||
/// region select (0,0)-(150,150). The overlap corner face should be
|
||||
/// detected as inside the region.
|
||||
#[test]
|
||||
fn test_extract_region_rectangle_corner() {
|
||||
use crate::region_select::line_to_cubic;
|
||||
use kurbo::{Line, Shape as _};
|
||||
|
||||
let mut dcel = Dcel::new();
|
||||
|
||||
// Draw a rectangle from (100,100) to (200,200) as 4 line strokes
|
||||
let rect_sides = [
|
||||
Line::new(Point::new(100.0, 100.0), Point::new(200.0, 100.0)),
|
||||
Line::new(Point::new(200.0, 100.0), Point::new(200.0, 200.0)),
|
||||
Line::new(Point::new(200.0, 200.0), Point::new(100.0, 200.0)),
|
||||
Line::new(Point::new(100.0, 200.0), Point::new(100.0, 100.0)),
|
||||
];
|
||||
for side in &rect_sides {
|
||||
let seg = line_to_cubic(side);
|
||||
dcel.insert_stroke(&[seg], None, None, 1.0);
|
||||
}
|
||||
|
||||
// Set fill on the rectangle face (simulating paint bucket)
|
||||
let rect_face = dcel.find_face_containing_point(Point::new(150.0, 150.0));
|
||||
assert!(rect_face.0 != 0, "rectangle face should be bounded");
|
||||
dcel.face_mut(rect_face).fill_color = Some(ShapeColor::new(255, 0, 0, 255));
|
||||
|
||||
// Region select rectangle from (0,0) to (150,150) — overlaps top-left corner
|
||||
let region_sides = [
|
||||
Line::new(Point::new(0.0, 0.0), Point::new(150.0, 0.0)),
|
||||
Line::new(Point::new(150.0, 0.0), Point::new(150.0, 150.0)),
|
||||
Line::new(Point::new(150.0, 150.0), Point::new(0.0, 150.0)),
|
||||
Line::new(Point::new(0.0, 150.0), Point::new(0.0, 0.0)),
|
||||
];
|
||||
let region_segments: Vec<CubicBez> = region_sides.iter().map(|l| line_to_cubic(l)).collect();
|
||||
let snapshot = dcel.clone();
|
||||
dcel.insert_stroke(®ion_segments, None, None, 1.0);
|
||||
|
||||
// Build the region path for extract_region
|
||||
let mut region_path = BezPath::new();
|
||||
region_path.move_to(Point::new(0.0, 0.0));
|
||||
region_path.line_to(Point::new(150.0, 0.0));
|
||||
region_path.line_to(Point::new(150.0, 150.0));
|
||||
region_path.line_to(Point::new(0.0, 150.0));
|
||||
region_path.close_path();
|
||||
|
||||
// Extract, then propagate fills on extracted only (remainder keeps
|
||||
// its fills from the original data — no propagation needed there).
|
||||
let mut extracted = dcel.extract_region(®ion_path, 1.0);
|
||||
extracted.propagate_fills(&snapshot);
|
||||
|
||||
// The extracted DCEL should have at least one face with fill (the corner overlap)
|
||||
let extracted_filled_faces: Vec<_> = extracted.faces.iter().enumerate()
|
||||
.filter(|(i, f)| !f.deleted && *i > 0 && f.fill_color.is_some())
|
||||
.collect();
|
||||
assert!(
|
||||
!extracted_filled_faces.is_empty(),
|
||||
"Extracted DCEL should have at least one filled face (the corner overlap)"
|
||||
);
|
||||
|
||||
// The original DCEL (remainder) should still have filled faces (the L-shaped remainder)
|
||||
let remainder_filled_faces: Vec<_> = dcel.faces.iter().enumerate()
|
||||
.filter(|(i, f)| !f.deleted && *i > 0 && f.fill_color.is_some())
|
||||
.collect();
|
||||
assert!(
|
||||
!remainder_filled_faces.is_empty(),
|
||||
"Remainder DCEL should have at least one filled face (L-shaped remainder)"
|
||||
);
|
||||
|
||||
// The empty-space face in the remainder (outside the original rectangle)
|
||||
// should NOT have fill — verify no spurious fill propagation
|
||||
let point_outside_rect = Point::new(50.0, 50.0);
|
||||
let face_at_outside = dcel.find_face_containing_point(point_outside_rect);
|
||||
if face_at_outside.0 != 0 && !dcel.face(face_at_outside).deleted {
|
||||
assert!(
|
||||
dcel.face(face_at_outside).fill_color.is_none(),
|
||||
"Face at (50,50) should NOT have fill — it's outside the original rectangle"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,569 @@
|
|||
//! Doubly-Connected Edge List (DCEL) for planar subdivision vector drawing.
|
||||
//!
|
||||
//! Each vector layer keyframe stores a DCEL representing a Flash-style planar
|
||||
//! subdivision. Strokes live on edges, fills live on faces, and the topology is
|
||||
//! maintained such that wherever two strokes intersect there is a vertex.
|
||||
//!
|
||||
//! Half-edges leaving a vertex are maintained in sorted CCW order. This enables
|
||||
//! efficient face detection by ray-casting to the nearest edge and walking CCW.
|
||||
|
||||
pub mod topology;
|
||||
pub mod query;
|
||||
pub mod stroke;
|
||||
pub mod region;
|
||||
|
||||
use crate::shape::{FillRule, ShapeColor, StrokeStyle};
|
||||
use kurbo::{CubicBez, Point};
|
||||
use rstar::{PointDistance, RTree, RTreeObject, AABB};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Index types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
macro_rules! define_id {
|
||||
($name:ident) => {
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct $name(pub u32);
|
||||
|
||||
impl $name {
|
||||
pub const NONE: Self = Self(u32::MAX);
|
||||
|
||||
#[inline]
|
||||
pub fn is_none(self) -> bool {
|
||||
self.0 == u32::MAX
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn idx(self) -> usize {
|
||||
self.0 as usize
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if self.is_none() {
|
||||
write!(f, "{}(NONE)", stringify!($name))
|
||||
} else {
|
||||
write!(f, "{}({})", stringify!($name), self.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
define_id!(VertexId);
|
||||
define_id!(HalfEdgeId);
|
||||
define_id!(EdgeId);
|
||||
define_id!(FaceId);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core structs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A vertex in the DCEL.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Vertex {
|
||||
pub position: Point,
|
||||
/// One outgoing half-edge (any one; iteration via twin.next gives the CCW fan).
|
||||
/// NONE if the vertex is isolated (no edges).
|
||||
pub outgoing: HalfEdgeId,
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
/// A half-edge in the DCEL.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HalfEdge {
|
||||
pub origin: VertexId,
|
||||
pub twin: HalfEdgeId,
|
||||
/// Next half-edge around the face (CCW).
|
||||
pub next: HalfEdgeId,
|
||||
/// Previous half-edge around the face (CCW).
|
||||
pub prev: HalfEdgeId,
|
||||
/// Face to the left of this half-edge.
|
||||
pub face: FaceId,
|
||||
/// Parent edge (shared between this half-edge and its twin).
|
||||
pub edge: EdgeId,
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
/// Geometric and style data for an edge (shared by the two half-edges).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct EdgeData {
|
||||
/// The two half-edges: [forward, backward].
|
||||
/// Forward goes from curve.p0 to curve.p3.
|
||||
pub half_edges: [HalfEdgeId; 2],
|
||||
pub curve: CubicBez,
|
||||
pub stroke_style: Option<StrokeStyle>,
|
||||
pub stroke_color: Option<ShapeColor>,
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
/// A face (region) in the DCEL.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Face {
|
||||
/// One half-edge on the outer boundary. NONE for the unbounded face (face 0).
|
||||
pub outer_half_edge: HalfEdgeId,
|
||||
/// Half-edges on inner boundary cycles (holes).
|
||||
pub inner_half_edges: Vec<HalfEdgeId>,
|
||||
pub fill_color: Option<ShapeColor>,
|
||||
pub image_fill: Option<uuid::Uuid>,
|
||||
pub fill_rule: FillRule,
|
||||
#[serde(default)]
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spatial index for vertex snapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VertexEntry {
|
||||
pub id: VertexId,
|
||||
pub position: [f64; 2],
|
||||
}
|
||||
|
||||
impl RTreeObject for VertexEntry {
|
||||
type Envelope = AABB<[f64; 2]>;
|
||||
fn envelope(&self) -> Self::Envelope {
|
||||
AABB::from_point(self.position)
|
||||
}
|
||||
}
|
||||
|
||||
impl PointDistance for VertexEntry {
|
||||
fn distance_2(&self, point: &[f64; 2]) -> f64 {
|
||||
let dx = self.position[0] - point[0];
|
||||
let dy = self.position[1] - point[1];
|
||||
dx * dx + dy * dy
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Debug recorder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DebugRecorder {
|
||||
pub strokes: Vec<Vec<CubicBez>>,
|
||||
pub paint_points: Vec<Point>,
|
||||
}
|
||||
|
||||
impl DebugRecorder {
|
||||
pub fn record_stroke(&mut self, segments: &[CubicBez]) {
|
||||
self.strokes.push(segments.to_vec());
|
||||
}
|
||||
|
||||
pub fn record_paint(&mut self, point: Point) {
|
||||
self.paint_points.push(point);
|
||||
}
|
||||
|
||||
pub fn dump_test(&self, name: &str) {
|
||||
eprintln!(" #[test]");
|
||||
eprintln!(" fn {name}() {{");
|
||||
eprintln!(" let mut dcel = Dcel::new();");
|
||||
eprintln!();
|
||||
for (i, stroke) in self.strokes.iter().enumerate() {
|
||||
eprintln!(" // Stroke {i}");
|
||||
eprintln!(" dcel.insert_stroke(&[");
|
||||
for seg in stroke {
|
||||
eprintln!(
|
||||
" CubicBez::new(Point::new({:.1}, {:.1}), Point::new({:.1}, {:.1}), Point::new({:.1}, {:.1}), Point::new({:.1}, {:.1})),",
|
||||
seg.p0.x, seg.p0.y, seg.p1.x, seg.p1.y,
|
||||
seg.p2.x, seg.p2.y, seg.p3.x, seg.p3.y,
|
||||
);
|
||||
}
|
||||
eprintln!(" ], None, None, 5.0);");
|
||||
eprintln!();
|
||||
}
|
||||
for (i, pt) in self.paint_points.iter().enumerate() {
|
||||
eprintln!(" // Paint {i}");
|
||||
eprintln!(
|
||||
" let _f{i} = dcel.find_face_at_point(Point::new({:.1}, {:.1}));",
|
||||
pt.x, pt.y
|
||||
);
|
||||
}
|
||||
eprintln!(" }}");
|
||||
}
|
||||
|
||||
pub fn dump_and_reset(&mut self, name: &str) {
|
||||
self.dump_test(name);
|
||||
self.strokes.clear();
|
||||
self.paint_points.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Default snap epsilon in document coordinate units.
|
||||
pub const DEFAULT_SNAP_EPSILON: f64 = 0.5;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DCEL container
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Dcel {
|
||||
pub vertices: Vec<Vertex>,
|
||||
pub half_edges: Vec<HalfEdge>,
|
||||
pub edges: Vec<EdgeData>,
|
||||
pub faces: Vec<Face>,
|
||||
|
||||
free_vertices: Vec<u32>,
|
||||
free_half_edges: Vec<u32>,
|
||||
free_edges: Vec<u32>,
|
||||
free_faces: Vec<u32>,
|
||||
|
||||
#[serde(skip)]
|
||||
vertex_rtree: Option<RTree<VertexEntry>>,
|
||||
|
||||
#[serde(skip)]
|
||||
pub debug_recorder: Option<DebugRecorder>,
|
||||
}
|
||||
|
||||
impl Default for Dcel {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Dcel {
|
||||
/// Create a new empty DCEL with just the unbounded outer face (face 0).
|
||||
pub fn new() -> Self {
|
||||
let unbounded = Face {
|
||||
outer_half_edge: HalfEdgeId::NONE,
|
||||
inner_half_edges: Vec::new(),
|
||||
fill_color: None,
|
||||
image_fill: None,
|
||||
fill_rule: FillRule::NonZero,
|
||||
deleted: false,
|
||||
};
|
||||
let debug_recorder = if std::env::var("DAW_DCEL_RECORD").is_ok() {
|
||||
Some(DebugRecorder::default())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Dcel {
|
||||
vertices: Vec::new(),
|
||||
half_edges: Vec::new(),
|
||||
edges: Vec::new(),
|
||||
faces: vec![unbounded],
|
||||
free_vertices: Vec::new(),
|
||||
free_half_edges: Vec::new(),
|
||||
free_edges: Vec::new(),
|
||||
free_faces: Vec::new(),
|
||||
vertex_rtree: None,
|
||||
debug_recorder,
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Debug recording
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn set_recording(&mut self, enabled: bool) {
|
||||
if enabled {
|
||||
self.debug_recorder.get_or_insert_with(DebugRecorder::default);
|
||||
} else {
|
||||
self.debug_recorder = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.debug_recorder.is_some()
|
||||
}
|
||||
|
||||
pub fn dump_recorded_test(&mut self, name: &str) {
|
||||
if let Some(ref mut rec) = self.debug_recorder {
|
||||
rec.dump_and_reset(name);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_paint_point(&mut self, point: Point) {
|
||||
if let Some(ref mut rec) = self.debug_recorder {
|
||||
rec.record_paint(point);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Allocation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn alloc_vertex(&mut self, position: Point) -> VertexId {
|
||||
let id = if let Some(idx) = self.free_vertices.pop() {
|
||||
let id = VertexId(idx);
|
||||
self.vertices[id.idx()] = Vertex {
|
||||
position,
|
||||
outgoing: HalfEdgeId::NONE,
|
||||
deleted: false,
|
||||
};
|
||||
id
|
||||
} else {
|
||||
let id = VertexId(self.vertices.len() as u32);
|
||||
self.vertices.push(Vertex {
|
||||
position,
|
||||
outgoing: HalfEdgeId::NONE,
|
||||
deleted: false,
|
||||
});
|
||||
id
|
||||
};
|
||||
self.vertex_rtree = None;
|
||||
id
|
||||
}
|
||||
|
||||
pub fn alloc_half_edge_pair(&mut self) -> (HalfEdgeId, HalfEdgeId) {
|
||||
let tombstone = HalfEdge {
|
||||
origin: VertexId::NONE,
|
||||
twin: HalfEdgeId::NONE,
|
||||
next: HalfEdgeId::NONE,
|
||||
prev: HalfEdgeId::NONE,
|
||||
face: FaceId::NONE,
|
||||
edge: EdgeId::NONE,
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
let alloc_one = |dcel: &mut Dcel| -> HalfEdgeId {
|
||||
if let Some(idx) = dcel.free_half_edges.pop() {
|
||||
let id = HalfEdgeId(idx);
|
||||
dcel.half_edges[id.idx()] = tombstone.clone();
|
||||
id
|
||||
} else {
|
||||
let id = HalfEdgeId(dcel.half_edges.len() as u32);
|
||||
dcel.half_edges.push(tombstone.clone());
|
||||
id
|
||||
}
|
||||
};
|
||||
|
||||
let a = alloc_one(self);
|
||||
let b = alloc_one(self);
|
||||
self.half_edges[a.idx()].twin = b;
|
||||
self.half_edges[b.idx()].twin = a;
|
||||
(a, b)
|
||||
}
|
||||
|
||||
pub fn alloc_edge(&mut self, curve: CubicBez) -> EdgeId {
|
||||
let data = EdgeData {
|
||||
half_edges: [HalfEdgeId::NONE, HalfEdgeId::NONE],
|
||||
curve,
|
||||
stroke_style: None,
|
||||
stroke_color: None,
|
||||
deleted: false,
|
||||
};
|
||||
if let Some(idx) = self.free_edges.pop() {
|
||||
let id = EdgeId(idx);
|
||||
self.edges[id.idx()] = data;
|
||||
id
|
||||
} else {
|
||||
let id = EdgeId(self.edges.len() as u32);
|
||||
self.edges.push(data);
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
pub fn alloc_face(&mut self) -> FaceId {
|
||||
let face = Face {
|
||||
outer_half_edge: HalfEdgeId::NONE,
|
||||
inner_half_edges: Vec::new(),
|
||||
fill_color: None,
|
||||
image_fill: None,
|
||||
fill_rule: FillRule::NonZero,
|
||||
deleted: false,
|
||||
};
|
||||
if let Some(idx) = self.free_faces.pop() {
|
||||
let id = FaceId(idx);
|
||||
self.faces[id.idx()] = face;
|
||||
id
|
||||
} else {
|
||||
let id = FaceId(self.faces.len() as u32);
|
||||
self.faces.push(face);
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Deallocation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
pub fn free_vertex(&mut self, id: VertexId) {
|
||||
debug_assert!(!id.is_none());
|
||||
self.vertices[id.idx()].deleted = true;
|
||||
self.free_vertices.push(id.0);
|
||||
self.vertex_rtree = None;
|
||||
}
|
||||
|
||||
pub fn free_half_edge(&mut self, id: HalfEdgeId) {
|
||||
debug_assert!(!id.is_none());
|
||||
self.half_edges[id.idx()].deleted = true;
|
||||
self.free_half_edges.push(id.0);
|
||||
}
|
||||
|
||||
pub fn free_edge(&mut self, id: EdgeId) {
|
||||
debug_assert!(!id.is_none());
|
||||
self.edges[id.idx()].deleted = true;
|
||||
self.free_edges.push(id.0);
|
||||
}
|
||||
|
||||
pub fn free_face(&mut self, id: FaceId) {
|
||||
debug_assert!(!id.is_none());
|
||||
debug_assert!(id.0 != 0, "cannot free the unbounded face");
|
||||
self.faces[id.idx()].deleted = true;
|
||||
self.free_faces.push(id.0);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Accessors
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[inline]
|
||||
pub fn vertex(&self, id: VertexId) -> &Vertex {
|
||||
&self.vertices[id.idx()]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn vertex_mut(&mut self, id: VertexId) -> &mut Vertex {
|
||||
&mut self.vertices[id.idx()]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn half_edge(&self, id: HalfEdgeId) -> &HalfEdge {
|
||||
&self.half_edges[id.idx()]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn half_edge_mut(&mut self, id: HalfEdgeId) -> &mut HalfEdge {
|
||||
&mut self.half_edges[id.idx()]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn edge(&self, id: EdgeId) -> &EdgeData {
|
||||
&self.edges[id.idx()]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn edge_mut(&mut self, id: EdgeId) -> &mut EdgeData {
|
||||
&mut self.edges[id.idx()]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn face(&self, id: FaceId) -> &Face {
|
||||
&self.faces[id.idx()]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn face_mut(&mut self, id: FaceId) -> &mut Face {
|
||||
&mut self.faces[id.idx()]
|
||||
}
|
||||
|
||||
/// Destination vertex of a half-edge (origin of its twin).
|
||||
#[inline]
|
||||
pub fn half_edge_dest(&self, he: HalfEdgeId) -> VertexId {
|
||||
let twin = self.half_edge(he).twin;
|
||||
self.half_edge(twin).origin
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bezier utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Split a cubic bezier at parameter t using de Casteljau's algorithm.
|
||||
pub fn subdivide_cubic(c: CubicBez, t: f64) -> (CubicBez, CubicBez) {
|
||||
let p01 = lerp_point(c.p0, c.p1, t);
|
||||
let p12 = lerp_point(c.p1, c.p2, t);
|
||||
let p23 = lerp_point(c.p2, c.p3, t);
|
||||
let p012 = lerp_point(p01, p12, t);
|
||||
let p123 = lerp_point(p12, p23, t);
|
||||
let p0123 = lerp_point(p012, p123, t);
|
||||
(
|
||||
CubicBez::new(c.p0, p01, p012, p0123),
|
||||
CubicBez::new(p0123, p123, p23, c.p3),
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract subsegment of a cubic bezier for parameter range [t0, t1].
|
||||
pub fn subsegment_cubic(c: CubicBez, t0: f64, t1: f64) -> CubicBez {
|
||||
if (t0).abs() < 1e-10 && (t1 - 1.0).abs() < 1e-10 {
|
||||
return c;
|
||||
}
|
||||
if (t0).abs() < 1e-10 {
|
||||
subdivide_cubic(c, t1).0
|
||||
} else if (t1 - 1.0).abs() < 1e-10 {
|
||||
subdivide_cubic(c, t0).1
|
||||
} else {
|
||||
let (_, upper) = subdivide_cubic(c, t0);
|
||||
let remapped_t1 = (t1 - t0) / (1.0 - t0);
|
||||
subdivide_cubic(upper, remapped_t1).0
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn lerp_point(a: Point, b: Point, t: f64) -> Point {
|
||||
Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
|
||||
}
|
||||
|
||||
/// Convert a `BezPath` into a list of sub-paths, each a `Vec<CubicBez>`.
|
||||
pub fn bezpath_to_cubic_segments(path: &kurbo::BezPath) -> Vec<Vec<CubicBez>> {
|
||||
use kurbo::PathEl;
|
||||
|
||||
let mut result: Vec<Vec<CubicBez>> = Vec::new();
|
||||
let mut current: Vec<CubicBez> = Vec::new();
|
||||
let mut subpath_start = Point::ZERO;
|
||||
let mut cursor = Point::ZERO;
|
||||
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
if !current.is_empty() {
|
||||
result.push(std::mem::take(&mut current));
|
||||
}
|
||||
subpath_start = p;
|
||||
cursor = p;
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
let c1 = lerp_point(cursor, p, 1.0 / 3.0);
|
||||
let c2 = lerp_point(cursor, p, 2.0 / 3.0);
|
||||
current.push(CubicBez::new(cursor, c1, c2, p));
|
||||
cursor = p;
|
||||
}
|
||||
PathEl::QuadTo(p1, p2) => {
|
||||
let cp1 = Point::new(
|
||||
cursor.x + (2.0 / 3.0) * (p1.x - cursor.x),
|
||||
cursor.y + (2.0 / 3.0) * (p1.y - cursor.y),
|
||||
);
|
||||
let cp2 = Point::new(
|
||||
p2.x + (2.0 / 3.0) * (p1.x - p2.x),
|
||||
p2.y + (2.0 / 3.0) * (p1.y - p2.y),
|
||||
);
|
||||
current.push(CubicBez::new(cursor, cp1, cp2, p2));
|
||||
cursor = p2;
|
||||
}
|
||||
PathEl::CurveTo(p1, p2, p3) => {
|
||||
current.push(CubicBez::new(cursor, p1, p2, p3));
|
||||
cursor = p3;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
let dist = ((cursor.x - subpath_start.x).powi(2)
|
||||
+ (cursor.y - subpath_start.y).powi(2))
|
||||
.sqrt();
|
||||
if dist > 1e-9 {
|
||||
let c1 = lerp_point(cursor, subpath_start, 1.0 / 3.0);
|
||||
let c2 = lerp_point(cursor, subpath_start, 2.0 / 3.0);
|
||||
current.push(CubicBez::new(cursor, c1, c2, subpath_start));
|
||||
}
|
||||
cursor = subpath_start;
|
||||
if !current.is_empty() {
|
||||
result.push(std::mem::take(&mut current));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
result.push(current);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
|
@ -0,0 +1,451 @@
|
|||
//! Queries, iteration, and BezPath construction for the DCEL.
|
||||
|
||||
use super::{Dcel, EdgeId, FaceId, HalfEdgeId, VertexEntry, VertexId};
|
||||
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, Point};
|
||||
use rstar::{PointDistance, RTree};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Result of a face-at-point query.
|
||||
pub struct FaceQuery {
|
||||
/// The face currently assigned to the cycle (may be F0 if no face was created).
|
||||
pub face: FaceId,
|
||||
/// A half-edge on the enclosing cycle. Walk via `next` to traverse.
|
||||
pub cycle_he: HalfEdgeId,
|
||||
}
|
||||
|
||||
impl Dcel {
|
||||
// -------------------------------------------------------------------
|
||||
// Iteration
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Walk the half-edge cycle starting at `start`, returning all half-edges.
|
||||
pub fn walk_cycle(&self, start: HalfEdgeId) -> Vec<HalfEdgeId> {
|
||||
let mut result = vec![start];
|
||||
let mut cur = self.half_edges[start.idx()].next;
|
||||
let mut steps = 0;
|
||||
while cur != start {
|
||||
result.push(cur);
|
||||
cur = self.half_edges[cur.idx()].next;
|
||||
steps += 1;
|
||||
debug_assert!(steps < 100_000, "infinite cycle in walk_cycle");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Compute the signed area of the cycle starting at `start`.
|
||||
/// Positive = CCW (interior), negative = CW (exterior).
|
||||
pub fn cycle_signed_area(&self, start: HalfEdgeId) -> f64 {
|
||||
let mut area = 0.0;
|
||||
let mut cur = start;
|
||||
loop {
|
||||
let p0 = self.vertices[self.half_edges[cur.idx()].origin.idx()].position;
|
||||
cur = self.half_edges[cur.idx()].next;
|
||||
let p1 = self.vertices[self.half_edges[cur.idx()].origin.idx()].position;
|
||||
area += p0.x * p1.y - p1.x * p0.y;
|
||||
if cur == start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
area * 0.5
|
||||
}
|
||||
|
||||
/// Compute the signed area of the cycle using the actual Bézier curves,
|
||||
/// not just vertex positions. Uses Green's theorem: A = ½ ∫(x dy - y dx).
|
||||
/// For a cubic B(t) = (x(t), y(t)), the integral is evaluated numerically.
|
||||
pub fn cycle_curve_signed_area(&self, start: HalfEdgeId) -> f64 {
|
||||
let mut area = 0.0;
|
||||
let mut cur = start;
|
||||
loop {
|
||||
let edge_id = self.half_edges[cur.idx()].edge;
|
||||
let edge = &self.edges[edge_id.idx()];
|
||||
let [fwd, _bwd] = edge.half_edges;
|
||||
let curve = if cur == fwd {
|
||||
edge.curve
|
||||
} else {
|
||||
// Reverse the curve for backward half-edge
|
||||
kurbo::CubicBez::new(edge.curve.p3, edge.curve.p2, edge.curve.p1, edge.curve.p0)
|
||||
};
|
||||
|
||||
// Numerical integration of ½(x dy - y dx) using Simpson's rule
|
||||
let n = 16;
|
||||
let dt = 1.0 / n as f64;
|
||||
for i in 0..n {
|
||||
let t0 = i as f64 * dt;
|
||||
let t1 = (i as f64 + 0.5) * dt;
|
||||
let t2 = (i as f64 + 1.0) * dt;
|
||||
|
||||
let p0 = curve.eval(t0);
|
||||
let p1 = curve.eval(t1);
|
||||
let p2 = curve.eval(t2);
|
||||
|
||||
// Simpson's rule for the integrand x*dy/dt - y*dx/dt
|
||||
// Approximate dx, dy from finite differences
|
||||
let dx0 = (p1.x - p0.x) / (dt * 0.5);
|
||||
let dy0 = (p1.y - p0.y) / (dt * 0.5);
|
||||
let dx1 = (p2.x - p0.x) / dt;
|
||||
let dy1 = (p2.y - p0.y) / dt;
|
||||
let dx2 = (p2.x - p1.x) / (dt * 0.5);
|
||||
let dy2 = (p2.y - p1.y) / (dt * 0.5);
|
||||
|
||||
let f0 = p0.x * dy0 - p0.y * dx0;
|
||||
let f1 = p1.x * dy1 - p1.y * dx1;
|
||||
let f2 = p2.x * dy2 - p2.y * dx2;
|
||||
|
||||
area += (f0 + 4.0 * f1 + f2) * dt / 6.0;
|
||||
}
|
||||
|
||||
cur = self.half_edges[cur.idx()].next;
|
||||
if cur == start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
area * 0.5
|
||||
}
|
||||
|
||||
/// Get all half-edges on a face's outer boundary.
|
||||
pub fn face_boundary(&self, face_id: FaceId) -> Vec<HalfEdgeId> {
|
||||
let ohe = self.faces[face_id.idx()].outer_half_edge;
|
||||
if ohe.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.walk_cycle(ohe)
|
||||
}
|
||||
|
||||
/// Get all outgoing half-edges from a vertex in CCW order.
|
||||
pub fn vertex_outgoing(&self, vertex_id: VertexId) -> Vec<HalfEdgeId> {
|
||||
let start = self.vertices[vertex_id.idx()].outgoing;
|
||||
if start.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut result = vec![start];
|
||||
let twin = self.half_edges[start.idx()].twin;
|
||||
let mut cur = self.half_edges[twin.idx()].next;
|
||||
let mut steps = 0;
|
||||
while cur != start {
|
||||
result.push(cur);
|
||||
let twin = self.half_edges[cur.idx()].twin;
|
||||
cur = self.half_edges[twin.idx()].next;
|
||||
steps += 1;
|
||||
debug_assert!(steps < 100_000, "infinite fan in vertex_outgoing");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Face detection
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Find the enclosing face/cycle for a point.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Find the nearest edge to `point`
|
||||
/// 2. Pick the half-edge with `point` on its left side (cross product of tangent)
|
||||
/// 3. Walk that half-edge's cycle — this is the innermost boundary enclosing `point`
|
||||
/// 4. Return the cycle's face (which may be F0 if no face has been created yet)
|
||||
/// along with a half-edge on the cycle so the caller can create a face if needed
|
||||
///
|
||||
/// Returns F0 with NONE cycle_he if there are no edges.
|
||||
pub fn find_face_at_point(&self, point: Point) -> FaceQuery {
|
||||
let mut best: Option<(EdgeId, f64, f64)> = None;
|
||||
|
||||
for (i, edge) in self.edges.iter().enumerate() {
|
||||
if edge.deleted {
|
||||
continue;
|
||||
}
|
||||
let nearest = edge.curve.nearest(point, 0.5);
|
||||
if best.is_none() || nearest.distance_sq < best.unwrap().1 {
|
||||
best = Some((EdgeId(i as u32), nearest.distance_sq, nearest.t));
|
||||
}
|
||||
}
|
||||
|
||||
let Some((edge_id, _, t)) = best else {
|
||||
return FaceQuery {
|
||||
face: FaceId(0),
|
||||
cycle_he: HalfEdgeId::NONE,
|
||||
};
|
||||
};
|
||||
|
||||
let edge = &self.edges[edge_id.idx()];
|
||||
|
||||
// Tangent via finite difference (clamped to valid range)
|
||||
let t_lo = (t - 0.001).max(0.0);
|
||||
let t_hi = (t + 0.001).min(1.0);
|
||||
let p_lo = edge.curve.eval(t_lo);
|
||||
let p_hi = edge.curve.eval(t_hi);
|
||||
let tan_x = p_hi.x - p_lo.x;
|
||||
let tan_y = p_hi.y - p_lo.y;
|
||||
|
||||
let curve_pt = edge.curve.eval(t);
|
||||
let to_pt_x = point.x - curve_pt.x;
|
||||
let to_pt_y = point.y - curve_pt.y;
|
||||
let cross = tan_x * to_pt_y - tan_y * to_pt_x;
|
||||
|
||||
// cross > 0: point is to the left of the forward half-edge
|
||||
let he = if cross >= 0.0 {
|
||||
edge.half_edges[0]
|
||||
} else {
|
||||
edge.half_edges[1]
|
||||
};
|
||||
|
||||
// Walk the cycle to find the actual face
|
||||
let face = self.half_edges[he.idx()].face;
|
||||
|
||||
FaceQuery {
|
||||
face,
|
||||
cycle_he: he,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: just return the FaceId (backward-compatible).
|
||||
pub fn find_face_containing_point(&self, point: Point) -> FaceId {
|
||||
self.find_face_at_point(point).face
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Spatial index (vertex snapping)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
pub fn rebuild_spatial_index(&mut self) {
|
||||
let entries: Vec<VertexEntry> = self
|
||||
.vertices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, v)| !v.deleted)
|
||||
.map(|(i, v)| VertexEntry {
|
||||
id: VertexId(i as u32),
|
||||
position: [v.position.x, v.position.y],
|
||||
})
|
||||
.collect();
|
||||
self.vertex_rtree = Some(RTree::bulk_load(entries));
|
||||
}
|
||||
|
||||
pub fn ensure_spatial_index(&mut self) {
|
||||
if self.vertex_rtree.is_none() {
|
||||
self.rebuild_spatial_index();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snap_vertex(&mut self, point: Point, epsilon: f64) -> Option<VertexId> {
|
||||
self.ensure_spatial_index();
|
||||
let tree = self.vertex_rtree.as_ref().unwrap();
|
||||
let query = [point.x, point.y];
|
||||
let nearest = tree.nearest_neighbor(&query)?;
|
||||
let dist_sq = nearest.distance_2(&query);
|
||||
if dist_sq <= epsilon * epsilon {
|
||||
Some(nearest.id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// BezPath construction for rendering
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Raw bezpath from a face's outer boundary cycle.
|
||||
pub fn face_to_bezpath(&self, face_id: FaceId) -> BezPath {
|
||||
let cycle = self.face_boundary(face_id);
|
||||
self.cycle_to_bezpath(&cycle)
|
||||
}
|
||||
|
||||
/// Build a BezPath from a cycle of half-edges.
|
||||
pub fn cycle_to_bezpath(&self, cycle: &[HalfEdgeId]) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
if cycle.is_empty() {
|
||||
return path;
|
||||
}
|
||||
|
||||
let first_he = &self.half_edges[cycle[0].idx()];
|
||||
let first_pos = self.vertices[first_he.origin.idx()].position;
|
||||
path.move_to(first_pos);
|
||||
|
||||
for &he_id in cycle {
|
||||
let he = &self.half_edges[he_id.idx()];
|
||||
let edge = &self.edges[he.edge.idx()];
|
||||
if he_id == edge.half_edges[0] {
|
||||
path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3);
|
||||
} else {
|
||||
path.curve_to(edge.curve.p2, edge.curve.p1, edge.curve.p0);
|
||||
}
|
||||
}
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
|
||||
/// Bezpath with spur edges stripped (for fill rendering).
|
||||
pub fn face_to_bezpath_stripped(&self, face_id: FaceId) -> BezPath {
|
||||
let cycle = self.face_boundary(face_id);
|
||||
let stripped = self.strip_spurs(&cycle);
|
||||
self.cycle_to_bezpath(&stripped)
|
||||
}
|
||||
|
||||
/// Bezpath with outer boundary + reversed holes (for fill rendering).
|
||||
pub fn face_to_bezpath_with_holes(&self, face_id: FaceId) -> BezPath {
|
||||
let face = &self.faces[face_id.idx()];
|
||||
let mut path = self.face_to_bezpath_stripped(face_id);
|
||||
|
||||
let inner_hes: Vec<HalfEdgeId> = face.inner_half_edges.clone();
|
||||
for inner_he in inner_hes {
|
||||
if inner_he.is_none() || self.half_edges[inner_he.idx()].deleted {
|
||||
continue;
|
||||
}
|
||||
let inner_cycle = self.walk_cycle(inner_he);
|
||||
let stripped = self.strip_spurs(&inner_cycle);
|
||||
if stripped.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Append hole reversed so winding rule cuts it out
|
||||
let reversed = self.cycle_to_bezpath_reversed(&stripped);
|
||||
for el in reversed.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => path.move_to(p),
|
||||
PathEl::LineTo(p) => path.line_to(p),
|
||||
PathEl::QuadTo(p1, p2) => path.quad_to(p1, p2),
|
||||
PathEl::CurveTo(p1, p2, p3) => path.curve_to(p1, p2, p3),
|
||||
PathEl::ClosePath => path.close_path(),
|
||||
}
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Build a BezPath traversing a cycle in reverse direction.
|
||||
fn cycle_to_bezpath_reversed(&self, cycle: &[HalfEdgeId]) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
if cycle.is_empty() {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Start from the destination of the last half-edge
|
||||
let last_dest = self.half_edge_dest(*cycle.last().unwrap());
|
||||
let start_pos = self.vertices[last_dest.idx()].position;
|
||||
path.move_to(start_pos);
|
||||
|
||||
for &he_id in cycle.iter().rev() {
|
||||
let he = &self.half_edges[he_id.idx()];
|
||||
let edge = &self.edges[he.edge.idx()];
|
||||
if he_id == edge.half_edges[0] {
|
||||
// Was forward, now traversing backward
|
||||
path.curve_to(edge.curve.p2, edge.curve.p1, edge.curve.p0);
|
||||
} else {
|
||||
// Was backward, now traversing forward
|
||||
path.curve_to(edge.curve.p1, edge.curve.p2, edge.curve.p3);
|
||||
}
|
||||
}
|
||||
path.close_path();
|
||||
path
|
||||
}
|
||||
|
||||
/// Strip spur (antenna) edges from a cycle.
|
||||
///
|
||||
/// A spur traverses an edge forward then immediately backward (or vice versa).
|
||||
/// Stack-based: push half-edges; if top shares the same edge as the new one,
|
||||
/// pop (cancel the pair).
|
||||
fn strip_spurs(&self, cycle: &[HalfEdgeId]) -> Vec<HalfEdgeId> {
|
||||
if cycle.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut stack: Vec<HalfEdgeId> = Vec::with_capacity(cycle.len());
|
||||
for &he in cycle {
|
||||
if let Some(&top) = stack.last() {
|
||||
if self.half_edges[top.idx()].edge == self.half_edges[he.idx()].edge {
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
stack.push(he);
|
||||
}
|
||||
|
||||
// Handle wrap-around spurs at the seam
|
||||
while stack.len() >= 2 {
|
||||
let first_edge = self.half_edges[stack[0].idx()].edge;
|
||||
let last_edge = self.half_edges[stack.last().unwrap().idx()].edge;
|
||||
if first_edge == last_edge {
|
||||
stack.remove(0);
|
||||
stack.pop();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
stack
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Validation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// Validate DCEL invariants. Panics with a descriptive message on failure.
|
||||
pub fn validate(&self) {
|
||||
// 1. Twin symmetry
|
||||
for (i, he) in self.half_edges.iter().enumerate() {
|
||||
if he.deleted { continue; }
|
||||
let id = HalfEdgeId(i as u32);
|
||||
let twin = he.twin;
|
||||
assert!(!twin.is_none(), "HE{i} has NONE twin");
|
||||
assert!(!self.half_edges[twin.idx()].deleted, "HE{i} twin is deleted");
|
||||
assert_eq!(self.half_edges[twin.idx()].twin, id, "HE{i} twin symmetry broken");
|
||||
}
|
||||
|
||||
// 2. Next/prev consistency
|
||||
for (i, he) in self.half_edges.iter().enumerate() {
|
||||
if he.deleted { continue; }
|
||||
let id = HalfEdgeId(i as u32);
|
||||
assert!(!he.next.is_none(), "HE{i} has NONE next");
|
||||
assert!(!he.prev.is_none(), "HE{i} has NONE prev");
|
||||
assert_eq!(self.half_edges[he.next.idx()].prev, id, "HE{i} next.prev != self");
|
||||
assert_eq!(self.half_edges[he.prev.idx()].next, id, "HE{i} prev.next != self");
|
||||
}
|
||||
|
||||
// 3. Face boundary consistency: all half-edges in a cycle share the same face
|
||||
let mut visited = HashSet::new();
|
||||
for (i, he) in self.half_edges.iter().enumerate() {
|
||||
if he.deleted { continue; }
|
||||
let id = HalfEdgeId(i as u32);
|
||||
if visited.contains(&id) { continue; }
|
||||
let cycle = self.walk_cycle(id);
|
||||
let face = he.face;
|
||||
for &cid in &cycle {
|
||||
assert_eq!(
|
||||
self.half_edges[cid.idx()].face, face,
|
||||
"HE{} face {:?} != cycle leader HE{i} face {:?}",
|
||||
cid.0, self.half_edges[cid.idx()].face, face
|
||||
);
|
||||
visited.insert(cid);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Vertex outgoing consistency
|
||||
for (i, v) in self.vertices.iter().enumerate() {
|
||||
if v.deleted || v.outgoing.is_none() { continue; }
|
||||
let he = &self.half_edges[v.outgoing.idx()];
|
||||
assert!(!he.deleted, "V{i} outgoing points to deleted HE");
|
||||
assert_eq!(he.origin, VertexId(i as u32), "V{i} outgoing.origin mismatch");
|
||||
}
|
||||
|
||||
// 5. Edge ↔ half-edge consistency
|
||||
for (i, edge) in self.edges.iter().enumerate() {
|
||||
if edge.deleted { continue; }
|
||||
let [fwd, bwd] = edge.half_edges;
|
||||
assert!(!fwd.is_none() && !bwd.is_none(), "E{i} has NONE half-edges");
|
||||
assert_eq!(self.half_edges[fwd.idx()].edge, EdgeId(i as u32), "E{i} fwd.edge mismatch");
|
||||
assert_eq!(self.half_edges[bwd.idx()].edge, EdgeId(i as u32), "E{i} bwd.edge mismatch");
|
||||
assert_eq!(self.half_edges[fwd.idx()].twin, bwd, "E{i} fwd.twin != bwd");
|
||||
}
|
||||
|
||||
// 6. Curve endpoint ↔ vertex position
|
||||
for (i, edge) in self.edges.iter().enumerate() {
|
||||
if edge.deleted { continue; }
|
||||
let [fwd, bwd] = edge.half_edges;
|
||||
let v_start = self.half_edges[fwd.idx()].origin;
|
||||
let v_end = self.half_edges[bwd.idx()].origin;
|
||||
let p_start = self.vertices[v_start.idx()].position;
|
||||
let p_end = self.vertices[v_end.idx()].position;
|
||||
let d0 = (p_start.x - edge.curve.p0.x).powi(2) + (p_start.y - edge.curve.p0.y).powi(2);
|
||||
let d3 = (p_end.x - edge.curve.p3.x).powi(2) + (p_end.y - edge.curve.p3.y).powi(2);
|
||||
assert!(d0 < 1.0, "E{i} p0 far from V{}", v_start.0);
|
||||
assert!(d3 < 1.0, "E{i} p3 far from V{}", v_end.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
//! Region extraction from the DCEL.
|
||||
//!
|
||||
//! `extract_region` splits a DCEL along a closed boundary path: the inside
|
||||
//! portion is returned as a new DCEL, the outside portion stays in `self`.
|
||||
//! Boundary edges are kept in both.
|
||||
//!
|
||||
//! Vertex classification is deterministic: boundary vertices are known from
|
||||
//! inserting the region stroke, all others are classified by winding number.
|
||||
//! Faces are classified by which vertices they touch — no sampling needed.
|
||||
|
||||
use super::{Dcel, EdgeId, FaceId, VertexId};
|
||||
use kurbo::{BezPath, Point, Shape};
|
||||
|
||||
/// Vertex classification relative to the region boundary.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum VClass {
|
||||
Inside,
|
||||
Outside,
|
||||
Boundary,
|
||||
}
|
||||
|
||||
impl Dcel {
|
||||
/// Extract the sub-DCEL inside a closed region path.
|
||||
///
|
||||
/// The caller must have already inserted the region boundary via
|
||||
/// `insert_stroke`, passing the resulting vertex IDs as `boundary_vertices`.
|
||||
/// All other vertices are classified by winding number against `region`.
|
||||
///
|
||||
/// Returns the extracted (inside) DCEL. `self` is modified to contain
|
||||
/// only the outside portion. Boundary edges appear in both.
|
||||
pub fn extract_region(
|
||||
&mut self,
|
||||
region: &BezPath,
|
||||
boundary_vertices: &[VertexId],
|
||||
) -> Dcel {
|
||||
let classifications = self.classify_vertices(region, boundary_vertices);
|
||||
|
||||
// Clone → extracted
|
||||
let mut extracted = self.clone();
|
||||
|
||||
// In extracted: remove edges where either endpoint is Outside
|
||||
let to_remove: Vec<EdgeId> = extracted
|
||||
.edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, edge)| {
|
||||
if edge.deleted { return None; }
|
||||
let [fwd, bwd] = edge.half_edges;
|
||||
let v1 = extracted.half_edges[fwd.idx()].origin;
|
||||
let v2 = extracted.half_edges[bwd.idx()].origin;
|
||||
if classifications[v1.idx()] == VClass::Outside
|
||||
|| classifications[v2.idx()] == VClass::Outside
|
||||
{
|
||||
Some(EdgeId(i as u32))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for edge_id in to_remove {
|
||||
if !extracted.edges[edge_id.idx()].deleted {
|
||||
extracted.remove_edge(edge_id);
|
||||
}
|
||||
}
|
||||
|
||||
// In self: remove edges where either endpoint is Inside
|
||||
let to_remove: Vec<EdgeId> = self
|
||||
.edges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, edge)| {
|
||||
if edge.deleted { return None; }
|
||||
let [fwd, bwd] = edge.half_edges;
|
||||
let v1 = self.half_edges[fwd.idx()].origin;
|
||||
let v2 = self.half_edges[bwd.idx()].origin;
|
||||
if classifications[v1.idx()] == VClass::Inside
|
||||
|| classifications[v2.idx()] == VClass::Inside
|
||||
{
|
||||
Some(EdgeId(i as u32))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
for edge_id in to_remove {
|
||||
if !self.edges[edge_id.idx()].deleted {
|
||||
self.remove_edge(edge_id);
|
||||
}
|
||||
}
|
||||
|
||||
extracted
|
||||
}
|
||||
|
||||
/// Classify every vertex as Inside, Outside, or Boundary.
|
||||
fn classify_vertices(
|
||||
&self,
|
||||
region: &BezPath,
|
||||
boundary_vertices: &[VertexId],
|
||||
) -> Vec<VClass> {
|
||||
self.vertices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
if v.deleted {
|
||||
return VClass::Outside;
|
||||
}
|
||||
let vid = VertexId(i as u32);
|
||||
if boundary_vertices.contains(&vid) {
|
||||
VClass::Boundary
|
||||
} else if region.winding(v.position) != 0 {
|
||||
VClass::Inside
|
||||
} else {
|
||||
VClass::Outside
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Copy fill properties from `snapshot` to faces in `self` that lost
|
||||
/// them when the region boundary split filled faces.
|
||||
///
|
||||
/// For each unfilled face, walks its boundary to find an Inside vertex,
|
||||
/// then looks up the snapshot face at that vertex's position to inherit
|
||||
/// the fill. No sampling heuristic — vertex positions are exact.
|
||||
pub fn propagate_fills(
|
||||
&mut self,
|
||||
snapshot: &Dcel,
|
||||
region: &BezPath,
|
||||
boundary_vertices: &[VertexId],
|
||||
) {
|
||||
let classifications = self.classify_vertices(region, boundary_vertices);
|
||||
|
||||
for i in 1..self.faces.len() {
|
||||
let face = &self.faces[i];
|
||||
if face.deleted || face.outer_half_edge.is_none() {
|
||||
continue;
|
||||
}
|
||||
if face.fill_color.is_some() || face.image_fill.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let face_id = FaceId(i as u32);
|
||||
let boundary = self.face_boundary(face_id);
|
||||
|
||||
// Find an inside vertex on this face's boundary
|
||||
let probe = boundary.iter().find_map(|&he_id| {
|
||||
let vid = self.half_edges[he_id.idx()].origin;
|
||||
if classifications[vid.idx()] == VClass::Inside {
|
||||
Some(self.vertices[vid.idx()].position)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let probe_point = match probe {
|
||||
Some(p) => p,
|
||||
None => continue, // face has no inside vertices — skip
|
||||
};
|
||||
|
||||
let snap_face_id = snapshot.find_face_containing_point(probe_point);
|
||||
if snap_face_id.0 == 0 {
|
||||
continue;
|
||||
}
|
||||
let snap_face = &snapshot.faces[snap_face_id.idx()];
|
||||
if snap_face.fill_color.is_some() || snap_face.image_fill.is_some() {
|
||||
self.faces[i].fill_color = snap_face.fill_color.clone();
|
||||
self.faces[i].image_fill = snap_face.image_fill;
|
||||
self.faces[i].fill_rule = snap_face.fill_rule;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::{CubicBez, Point};
|
||||
|
||||
fn line_cubic(a: Point, b: Point) -> CubicBez {
|
||||
CubicBez::new(
|
||||
a,
|
||||
Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0),
|
||||
Point::new(
|
||||
a.x + 2.0 * (b.x - a.x) / 3.0,
|
||||
a.y + 2.0 * (b.y - a.y) / 3.0,
|
||||
),
|
||||
b,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_region_basic() {
|
||||
let mut dcel = Dcel::new();
|
||||
|
||||
// Two horizontal lines crossing the region boundary:
|
||||
// line A at y=30: (0,30) → (100,30)
|
||||
// line B at y=70: (0,70) → (100,70)
|
||||
let a0 = Point::new(0.0, 30.0);
|
||||
let a1 = Point::new(100.0, 30.0);
|
||||
let b0 = Point::new(0.0, 70.0);
|
||||
let b1 = Point::new(100.0, 70.0);
|
||||
|
||||
let va0 = dcel.alloc_vertex(a0);
|
||||
let va1 = dcel.alloc_vertex(a1);
|
||||
let vb0 = dcel.alloc_vertex(b0);
|
||||
let vb1 = dcel.alloc_vertex(b1);
|
||||
|
||||
dcel.insert_edge(va0, va1, FaceId(0), line_cubic(a0, a1));
|
||||
dcel.insert_edge(vb0, vb1, FaceId(0), line_cubic(b0, b1));
|
||||
|
||||
assert_eq!(dcel.edges.iter().filter(|e| !e.deleted).count(), 2);
|
||||
|
||||
// Region covers the left half: x ∈ [-10, 50]
|
||||
let mut region = BezPath::new();
|
||||
region.move_to(Point::new(-10.0, -10.0));
|
||||
region.line_to(Point::new(50.0, -10.0));
|
||||
region.line_to(Point::new(50.0, 110.0));
|
||||
region.line_to(Point::new(-10.0, 110.0));
|
||||
region.close_path();
|
||||
|
||||
// va0, vb0 are inside (x=0), va1, vb1 are outside (x=100)
|
||||
// No boundary vertices in this simple test
|
||||
let extracted = dcel.extract_region(®ion, &[]);
|
||||
|
||||
// Both edges have one inside and one outside endpoint,
|
||||
// so both are removed from both halves
|
||||
let self_edges = dcel.edges.iter().filter(|e| !e.deleted).count();
|
||||
let ext_edges = extracted.edges.iter().filter(|e| !e.deleted).count();
|
||||
assert_eq!(self_edges, 0, "edges span boundary → removed from self");
|
||||
assert_eq!(ext_edges, 0, "edges span boundary → removed from extracted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_region_with_boundary_vertices() {
|
||||
let mut dcel = Dcel::new();
|
||||
|
||||
// Build a horizontal line that will be split by the region boundary.
|
||||
// We simulate what happens after insert_stroke splits it:
|
||||
// left piece: (0,50) → (50,50) [inside → boundary]
|
||||
// right piece: (50,50) → (100,50) [boundary → outside]
|
||||
let p_left = Point::new(0.0, 50.0);
|
||||
let p_mid = Point::new(50.0, 50.0);
|
||||
let p_right = Point::new(100.0, 50.0);
|
||||
|
||||
let v_left = dcel.alloc_vertex(p_left);
|
||||
let v_mid = dcel.alloc_vertex(p_mid);
|
||||
let v_right = dcel.alloc_vertex(p_right);
|
||||
|
||||
dcel.insert_edge(v_left, v_mid, FaceId(0), line_cubic(p_left, p_mid));
|
||||
dcel.insert_edge(v_mid, v_right, FaceId(0), line_cubic(p_mid, p_right));
|
||||
|
||||
// Region: left half (x < 50)
|
||||
let mut region = BezPath::new();
|
||||
region.move_to(Point::new(-10.0, -10.0));
|
||||
region.line_to(Point::new(50.0, -10.0));
|
||||
region.line_to(Point::new(50.0, 110.0));
|
||||
region.line_to(Point::new(-10.0, 110.0));
|
||||
region.close_path();
|
||||
|
||||
// v_mid is on the boundary
|
||||
let extracted = dcel.extract_region(®ion, &[v_mid]);
|
||||
|
||||
// Left edge: inside → boundary → kept in extracted
|
||||
// Right edge: boundary → outside → kept in self
|
||||
let ext_edges = extracted.edges.iter().filter(|e| !e.deleted).count();
|
||||
let self_edges = dcel.edges.iter().filter(|e| !e.deleted).count();
|
||||
assert_eq!(ext_edges, 1, "extracted should have left edge");
|
||||
assert_eq!(self_edges, 1, "self should have right edge");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -44,14 +44,62 @@ impl GraphicsObject {
|
|||
id
|
||||
}
|
||||
|
||||
/// Get a child layer by ID
|
||||
/// Get a child layer by ID (searches direct children and recurses into groups)
|
||||
pub fn get_child(&self, id: &Uuid) -> Option<&AnyLayer> {
|
||||
self.children.iter().find(|l| &l.id() == id)
|
||||
for layer in &self.children {
|
||||
if &layer.id() == id {
|
||||
return Some(layer);
|
||||
}
|
||||
if let AnyLayer::Group(group) = layer {
|
||||
if let Some(found) = Self::find_in_group(&group.children, id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get a mutable child layer by ID
|
||||
/// Get a mutable child layer by ID (searches direct children and recurses into groups)
|
||||
pub fn get_child_mut(&mut self, id: &Uuid) -> Option<&mut AnyLayer> {
|
||||
self.children.iter_mut().find(|l| &l.id() == id)
|
||||
for layer in &mut self.children {
|
||||
if &layer.id() == id {
|
||||
return Some(layer);
|
||||
}
|
||||
if let AnyLayer::Group(group) = layer {
|
||||
if let Some(found) = Self::find_in_group_mut(&mut group.children, id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_in_group<'a>(children: &'a [AnyLayer], id: &Uuid) -> Option<&'a AnyLayer> {
|
||||
for child in children {
|
||||
if &child.id() == id {
|
||||
return Some(child);
|
||||
}
|
||||
if let AnyLayer::Group(group) = child {
|
||||
if let Some(found) = Self::find_in_group(&group.children, id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_in_group_mut<'a>(children: &'a mut [AnyLayer], id: &Uuid) -> Option<&'a mut AnyLayer> {
|
||||
for child in children {
|
||||
if &child.id() == id {
|
||||
return Some(child);
|
||||
}
|
||||
if let AnyLayer::Group(group) = child {
|
||||
if let Some(found) = Self::find_in_group_mut(&mut group.children, id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Remove a child layer by ID
|
||||
|
|
@ -371,6 +419,52 @@ impl Document {
|
|||
}
|
||||
}
|
||||
}
|
||||
crate::layer::AnyLayer::Group(group) => {
|
||||
// Recurse into group children to find their clip instance endpoints
|
||||
fn process_group_children(
|
||||
children: &[crate::layer::AnyLayer],
|
||||
doc: &Document,
|
||||
max_end: &mut f64,
|
||||
calc_end: &dyn Fn(&ClipInstance, f64) -> f64,
|
||||
) {
|
||||
for child in children {
|
||||
match child {
|
||||
crate::layer::AnyLayer::Vector(vl) => {
|
||||
for inst in &vl.clip_instances {
|
||||
if let Some(clip) = doc.vector_clips.get(&inst.clip_id) {
|
||||
*max_end = max_end.max(calc_end(inst, clip.duration));
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::layer::AnyLayer::Audio(al) => {
|
||||
for inst in &al.clip_instances {
|
||||
if let Some(clip) = doc.audio_clips.get(&inst.clip_id) {
|
||||
*max_end = max_end.max(calc_end(inst, clip.duration));
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::layer::AnyLayer::Video(vl) => {
|
||||
for inst in &vl.clip_instances {
|
||||
if let Some(clip) = doc.video_clips.get(&inst.clip_id) {
|
||||
*max_end = max_end.max(calc_end(inst, clip.duration));
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::layer::AnyLayer::Effect(el) => {
|
||||
for inst in &el.clip_instances {
|
||||
if let Some(dur) = doc.get_clip_duration(&inst.clip_id) {
|
||||
*max_end = max_end.max(calc_end(inst, dur));
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::layer::AnyLayer::Group(g) => {
|
||||
process_group_children(&g.children, doc, max_end, calc_end);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
process_group_children(&group.children, self, &mut max_end_time, &calculate_instance_end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -489,7 +583,16 @@ impl Document {
|
|||
|
||||
/// Get all layers across the entire document (root + inside all vector clips).
|
||||
pub fn all_layers(&self) -> Vec<&AnyLayer> {
|
||||
let mut layers: Vec<&AnyLayer> = self.root.children.iter().collect();
|
||||
let mut layers: Vec<&AnyLayer> = Vec::new();
|
||||
fn collect_layers<'a>(list: &'a [AnyLayer], out: &mut Vec<&'a AnyLayer>) {
|
||||
for layer in list {
|
||||
out.push(layer);
|
||||
if let AnyLayer::Group(g) = layer {
|
||||
collect_layers(&g.children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
collect_layers(&self.root.children, &mut layers);
|
||||
for clip in self.vector_clips.values() {
|
||||
layers.extend(clip.layers.root_data());
|
||||
}
|
||||
|
|
@ -718,6 +821,7 @@ impl Document {
|
|||
AnyLayer::Video(video) => &video.clip_instances,
|
||||
AnyLayer::Vector(vector) => &vector.clip_instances,
|
||||
AnyLayer::Effect(effect) => &effect.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
};
|
||||
|
||||
let instance = instances.iter().find(|inst| &inst.id == instance_id)?;
|
||||
|
|
@ -756,6 +860,7 @@ impl Document {
|
|||
AnyLayer::Video(video) => &video.clip_instances,
|
||||
AnyLayer::Vector(vector) => &vector.clip_instances,
|
||||
AnyLayer::Effect(effect) => &effect.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
};
|
||||
|
||||
for instance in instances {
|
||||
|
|
@ -799,7 +904,7 @@ impl Document {
|
|||
let desired_start = desired_start.max(0.0);
|
||||
|
||||
// Vector layers don't need overlap adjustment, but still respect timeline start
|
||||
if matches!(layer, AnyLayer::Vector(_)) {
|
||||
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
||||
return Some(desired_start);
|
||||
}
|
||||
|
||||
|
|
@ -816,6 +921,7 @@ impl Document {
|
|||
AnyLayer::Video(video) => &video.clip_instances,
|
||||
AnyLayer::Effect(effect) => &effect.clip_instances,
|
||||
AnyLayer::Vector(_) => return Some(desired_start), // Shouldn't reach here
|
||||
AnyLayer::Group(_) => return Some(desired_start), // Groups don't have own clips
|
||||
};
|
||||
|
||||
let mut occupied_ranges: Vec<(f64, f64, Uuid)> = Vec::new();
|
||||
|
|
@ -898,7 +1004,7 @@ impl Document {
|
|||
let Some(layer) = self.get_layer(layer_id) else {
|
||||
return desired_offset;
|
||||
};
|
||||
if matches!(layer, AnyLayer::Vector(_)) {
|
||||
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
||||
return desired_offset;
|
||||
}
|
||||
|
||||
|
|
@ -909,6 +1015,7 @@ impl Document {
|
|||
AnyLayer::Video(v) => &v.clip_instances,
|
||||
AnyLayer::Effect(e) => &e.clip_instances,
|
||||
AnyLayer::Vector(v) => &v.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
};
|
||||
|
||||
// Collect non-group clip ranges
|
||||
|
|
@ -966,8 +1073,8 @@ impl Document {
|
|||
};
|
||||
|
||||
// Only check audio, video, and effect layers
|
||||
if matches!(layer, AnyLayer::Vector(_)) {
|
||||
return current_timeline_start; // No limit for vector layers
|
||||
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
||||
return current_timeline_start; // No limit for vector/group layers
|
||||
};
|
||||
|
||||
// Find the nearest clip to the left
|
||||
|
|
@ -978,6 +1085,7 @@ impl Document {
|
|||
AnyLayer::Video(video) => &video.clip_instances,
|
||||
AnyLayer::Effect(effect) => &effect.clip_instances,
|
||||
AnyLayer::Vector(vector) => &vector.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
};
|
||||
|
||||
for other in instances {
|
||||
|
|
@ -1015,8 +1123,8 @@ impl Document {
|
|||
};
|
||||
|
||||
// Only check audio, video, and effect layers
|
||||
if matches!(layer, AnyLayer::Vector(_)) {
|
||||
return f64::MAX; // No limit for vector layers
|
||||
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
||||
return f64::MAX; // No limit for vector/group layers
|
||||
}
|
||||
|
||||
let instances: &[ClipInstance] = match layer {
|
||||
|
|
@ -1024,6 +1132,7 @@ impl Document {
|
|||
AnyLayer::Video(video) => &video.clip_instances,
|
||||
AnyLayer::Effect(effect) => &effect.clip_instances,
|
||||
AnyLayer::Vector(vector) => &vector.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
};
|
||||
|
||||
let mut nearest_start = f64::MAX;
|
||||
|
|
@ -1060,7 +1169,7 @@ impl Document {
|
|||
return current_effective_start;
|
||||
};
|
||||
|
||||
if matches!(layer, AnyLayer::Vector(_)) {
|
||||
if matches!(layer, AnyLayer::Vector(_) | AnyLayer::Group(_)) {
|
||||
return current_effective_start;
|
||||
}
|
||||
|
||||
|
|
@ -1069,6 +1178,7 @@ impl Document {
|
|||
AnyLayer::Video(video) => &video.clip_instances,
|
||||
AnyLayer::Effect(effect) => &effect.clip_instances,
|
||||
AnyLayer::Vector(vector) => &vector.clip_instances,
|
||||
AnyLayer::Group(_) => &[],
|
||||
};
|
||||
|
||||
let mut nearest_end = 0.0;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crate::layer::VectorLayer;
|
|||
use crate::shape::Shape;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::{Affine, BezPath, Point, Rect, Shape as KurboShape};
|
||||
use vello::kurbo::{Affine, Point, Rect, Shape as KurboShape};
|
||||
|
||||
/// Result of a hit test operation
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -216,40 +216,6 @@ pub fn hit_test_dcel_in_rect(
|
|||
result
|
||||
}
|
||||
|
||||
/// Classification of shapes relative to a clipping region
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShapeRegionClassification {
|
||||
/// Shapes entirely inside the region
|
||||
pub fully_inside: Vec<Uuid>,
|
||||
/// Shapes whose paths cross the region boundary
|
||||
pub intersecting: Vec<Uuid>,
|
||||
/// Shapes with no overlap with the region
|
||||
pub fully_outside: Vec<Uuid>,
|
||||
}
|
||||
|
||||
/// Classify shapes in a layer relative to a clipping region.
|
||||
///
|
||||
/// Uses bounding box fast-rejection, then checks path-region intersection
|
||||
/// and containment for accurate classification.
|
||||
pub fn classify_shapes_by_region(
|
||||
layer: &VectorLayer,
|
||||
time: f64,
|
||||
region: &BezPath,
|
||||
parent_transform: Affine,
|
||||
) -> ShapeRegionClassification {
|
||||
let result = ShapeRegionClassification {
|
||||
fully_inside: Vec::new(),
|
||||
intersecting: Vec::new(),
|
||||
fully_outside: Vec::new(),
|
||||
};
|
||||
|
||||
let region_bbox = region.bounding_box();
|
||||
|
||||
// TODO: Implement DCEL-based region classification
|
||||
let _ = (layer, time, parent_transform, region_bbox);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Get the bounding box of a shape in screen space
|
||||
pub fn get_shape_bounds(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ pub enum LayerType {
|
|||
Automation,
|
||||
/// Visual effects layer
|
||||
Effect,
|
||||
/// Group layer containing child layers (e.g. video + audio)
|
||||
Group,
|
||||
}
|
||||
|
||||
/// Common trait for all layer types
|
||||
|
|
@ -606,6 +608,11 @@ pub struct VideoLayer {
|
|||
/// Clip instances (references to video clips)
|
||||
/// VideoLayer can contain instances of VideoClips
|
||||
pub clip_instances: Vec<ClipInstance>,
|
||||
|
||||
/// When true, the live webcam feed is shown in the stage for this
|
||||
/// layer (at times when no clip instance is active).
|
||||
#[serde(default)]
|
||||
pub camera_enabled: bool,
|
||||
}
|
||||
|
||||
impl LayerTrait for VideoLayer {
|
||||
|
|
@ -684,10 +691,85 @@ impl VideoLayer {
|
|||
Self {
|
||||
layer: Layer::new(LayerType::Video, name),
|
||||
clip_instances: Vec::new(),
|
||||
camera_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Group layer containing child layers (e.g. video + audio).
|
||||
/// Collapsible in the timeline; when collapsed shows a merged clip view.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct GroupLayer {
|
||||
/// Base layer properties
|
||||
pub layer: Layer,
|
||||
|
||||
/// Child layers in this group (typically one VideoLayer + one AudioLayer)
|
||||
pub children: Vec<AnyLayer>,
|
||||
|
||||
/// Whether the group is expanded in the timeline
|
||||
#[serde(default = "default_true")]
|
||||
pub expanded: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl LayerTrait for GroupLayer {
|
||||
fn id(&self) -> Uuid { self.layer.id }
|
||||
fn name(&self) -> &str { &self.layer.name }
|
||||
fn set_name(&mut self, name: String) { self.layer.name = name; }
|
||||
fn has_custom_name(&self) -> bool { self.layer.has_custom_name }
|
||||
fn set_has_custom_name(&mut self, custom: bool) { self.layer.has_custom_name = custom; }
|
||||
fn visible(&self) -> bool { self.layer.visible }
|
||||
fn set_visible(&mut self, visible: bool) { self.layer.visible = visible; }
|
||||
fn opacity(&self) -> f64 { self.layer.opacity }
|
||||
fn set_opacity(&mut self, opacity: f64) { self.layer.opacity = opacity; }
|
||||
fn volume(&self) -> f64 { self.layer.volume }
|
||||
fn set_volume(&mut self, volume: f64) { self.layer.volume = volume; }
|
||||
fn muted(&self) -> bool { self.layer.muted }
|
||||
fn set_muted(&mut self, muted: bool) { self.layer.muted = muted; }
|
||||
fn soloed(&self) -> bool { self.layer.soloed }
|
||||
fn set_soloed(&mut self, soloed: bool) { self.layer.soloed = soloed; }
|
||||
fn locked(&self) -> bool { self.layer.locked }
|
||||
fn set_locked(&mut self, locked: bool) { self.layer.locked = locked; }
|
||||
}
|
||||
|
||||
impl GroupLayer {
|
||||
/// Create a new group layer
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
layer: Layer::new(LayerType::Group, name),
|
||||
children: Vec::new(),
|
||||
expanded: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a child layer to this group
|
||||
pub fn add_child(&mut self, layer: AnyLayer) {
|
||||
self.children.push(layer);
|
||||
}
|
||||
|
||||
/// Get clip instances from all child layers as (child_layer_id, &ClipInstance) pairs
|
||||
pub fn all_child_clip_instances(&self) -> Vec<(Uuid, &ClipInstance)> {
|
||||
let mut result = Vec::new();
|
||||
for child in &self.children {
|
||||
let child_id = child.id();
|
||||
let instances: &[ClipInstance] = match child {
|
||||
AnyLayer::Audio(l) => &l.clip_instances,
|
||||
AnyLayer::Video(l) => &l.clip_instances,
|
||||
AnyLayer::Vector(l) => &l.clip_instances,
|
||||
AnyLayer::Effect(l) => &l.clip_instances,
|
||||
AnyLayer::Group(_) => &[], // no nested groups
|
||||
};
|
||||
for ci in instances {
|
||||
result.push((child_id, ci));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified layer enum for polymorphic handling
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum AnyLayer {
|
||||
|
|
@ -695,6 +777,7 @@ pub enum AnyLayer {
|
|||
Audio(AudioLayer),
|
||||
Video(VideoLayer),
|
||||
Effect(EffectLayer),
|
||||
Group(GroupLayer),
|
||||
}
|
||||
|
||||
impl LayerTrait for AnyLayer {
|
||||
|
|
@ -704,6 +787,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.id(),
|
||||
AnyLayer::Video(l) => l.id(),
|
||||
AnyLayer::Effect(l) => l.id(),
|
||||
AnyLayer::Group(l) => l.id(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -713,6 +797,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.name(),
|
||||
AnyLayer::Video(l) => l.name(),
|
||||
AnyLayer::Effect(l) => l.name(),
|
||||
AnyLayer::Group(l) => l.name(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -722,6 +807,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.set_name(name),
|
||||
AnyLayer::Video(l) => l.set_name(name),
|
||||
AnyLayer::Effect(l) => l.set_name(name),
|
||||
AnyLayer::Group(l) => l.set_name(name),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -731,6 +817,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.has_custom_name(),
|
||||
AnyLayer::Video(l) => l.has_custom_name(),
|
||||
AnyLayer::Effect(l) => l.has_custom_name(),
|
||||
AnyLayer::Group(l) => l.has_custom_name(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -740,6 +827,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.set_has_custom_name(custom),
|
||||
AnyLayer::Video(l) => l.set_has_custom_name(custom),
|
||||
AnyLayer::Effect(l) => l.set_has_custom_name(custom),
|
||||
AnyLayer::Group(l) => l.set_has_custom_name(custom),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -749,6 +837,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.visible(),
|
||||
AnyLayer::Video(l) => l.visible(),
|
||||
AnyLayer::Effect(l) => l.visible(),
|
||||
AnyLayer::Group(l) => l.visible(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -758,6 +847,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.set_visible(visible),
|
||||
AnyLayer::Video(l) => l.set_visible(visible),
|
||||
AnyLayer::Effect(l) => l.set_visible(visible),
|
||||
AnyLayer::Group(l) => l.set_visible(visible),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -767,6 +857,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.opacity(),
|
||||
AnyLayer::Video(l) => l.opacity(),
|
||||
AnyLayer::Effect(l) => l.opacity(),
|
||||
AnyLayer::Group(l) => l.opacity(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -776,6 +867,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.set_opacity(opacity),
|
||||
AnyLayer::Video(l) => l.set_opacity(opacity),
|
||||
AnyLayer::Effect(l) => l.set_opacity(opacity),
|
||||
AnyLayer::Group(l) => l.set_opacity(opacity),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -785,6 +877,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.volume(),
|
||||
AnyLayer::Video(l) => l.volume(),
|
||||
AnyLayer::Effect(l) => l.volume(),
|
||||
AnyLayer::Group(l) => l.volume(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -794,6 +887,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.set_volume(volume),
|
||||
AnyLayer::Video(l) => l.set_volume(volume),
|
||||
AnyLayer::Effect(l) => l.set_volume(volume),
|
||||
AnyLayer::Group(l) => l.set_volume(volume),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -803,6 +897,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.muted(),
|
||||
AnyLayer::Video(l) => l.muted(),
|
||||
AnyLayer::Effect(l) => l.muted(),
|
||||
AnyLayer::Group(l) => l.muted(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -812,6 +907,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.set_muted(muted),
|
||||
AnyLayer::Video(l) => l.set_muted(muted),
|
||||
AnyLayer::Effect(l) => l.set_muted(muted),
|
||||
AnyLayer::Group(l) => l.set_muted(muted),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -821,6 +917,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.soloed(),
|
||||
AnyLayer::Video(l) => l.soloed(),
|
||||
AnyLayer::Effect(l) => l.soloed(),
|
||||
AnyLayer::Group(l) => l.soloed(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -830,6 +927,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.set_soloed(soloed),
|
||||
AnyLayer::Video(l) => l.set_soloed(soloed),
|
||||
AnyLayer::Effect(l) => l.set_soloed(soloed),
|
||||
AnyLayer::Group(l) => l.set_soloed(soloed),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -839,6 +937,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.locked(),
|
||||
AnyLayer::Video(l) => l.locked(),
|
||||
AnyLayer::Effect(l) => l.locked(),
|
||||
AnyLayer::Group(l) => l.locked(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -848,6 +947,7 @@ impl LayerTrait for AnyLayer {
|
|||
AnyLayer::Audio(l) => l.set_locked(locked),
|
||||
AnyLayer::Video(l) => l.set_locked(locked),
|
||||
AnyLayer::Effect(l) => l.set_locked(locked),
|
||||
AnyLayer::Group(l) => l.set_locked(locked),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -860,6 +960,7 @@ impl AnyLayer {
|
|||
AnyLayer::Audio(l) => &l.layer,
|
||||
AnyLayer::Video(l) => &l.layer,
|
||||
AnyLayer::Effect(l) => &l.layer,
|
||||
AnyLayer::Group(l) => &l.layer,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -870,6 +971,7 @@ impl AnyLayer {
|
|||
AnyLayer::Audio(l) => &mut l.layer,
|
||||
AnyLayer::Video(l) => &mut l.layer,
|
||||
AnyLayer::Effect(l) => &mut l.layer,
|
||||
AnyLayer::Group(l) => &mut l.layer,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,4 +44,10 @@ pub mod file_io;
|
|||
pub mod export;
|
||||
pub mod clipboard;
|
||||
pub mod region_select;
|
||||
pub mod dcel;
|
||||
pub mod dcel2;
|
||||
pub use dcel2 as dcel;
|
||||
pub mod snap;
|
||||
pub mod webcam;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub mod test_mode;
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ struct Crossing {
|
|||
// ── Core clipping ────────────────────────────────────────────────────────
|
||||
|
||||
/// Convert a line segment to a CubicBez
|
||||
fn line_to_cubic(line: &Line) -> CubicBez {
|
||||
pub fn line_to_cubic(line: &Line) -> CubicBez {
|
||||
let p0 = line.p0;
|
||||
let p1 = line.p1;
|
||||
let cp1 = Point::new(
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ pub fn render_document_for_compositing(
|
|||
base_transform: Affine,
|
||||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
camera_frame: Option<&crate::webcam::CaptureFrame>,
|
||||
) -> CompositeRenderResult {
|
||||
let time = document.current_time;
|
||||
|
||||
|
|
@ -211,6 +212,7 @@ pub fn render_document_for_compositing(
|
|||
base_transform,
|
||||
image_cache,
|
||||
video_manager,
|
||||
camera_frame,
|
||||
);
|
||||
rendered_layers.push(rendered);
|
||||
}
|
||||
|
|
@ -235,6 +237,7 @@ pub fn render_layer_isolated(
|
|||
base_transform: Affine,
|
||||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
camera_frame: Option<&crate::webcam::CaptureFrame>,
|
||||
) -> RenderedLayer {
|
||||
let layer_id = layer.id();
|
||||
let opacity = layer.opacity() as f32;
|
||||
|
|
@ -267,6 +270,8 @@ pub fn render_layer_isolated(
|
|||
}
|
||||
AnyLayer::Video(video_layer) => {
|
||||
let mut video_mgr = video_manager.lock().unwrap();
|
||||
// Only pass camera_frame for the layer that has camera enabled
|
||||
let layer_camera_frame = if video_layer.camera_enabled { camera_frame } else { None };
|
||||
render_video_layer_to_scene(
|
||||
document,
|
||||
time,
|
||||
|
|
@ -275,8 +280,10 @@ pub fn render_layer_isolated(
|
|||
base_transform,
|
||||
1.0, // Full opacity - layer opacity handled in compositing
|
||||
&mut video_mgr,
|
||||
layer_camera_frame,
|
||||
);
|
||||
rendered.has_content = !video_layer.clip_instances.is_empty();
|
||||
rendered.has_content = !video_layer.clip_instances.is_empty()
|
||||
|| (video_layer.camera_enabled && camera_frame.is_some());
|
||||
}
|
||||
AnyLayer::Effect(effect_layer) => {
|
||||
// Effect layers are processed during compositing, not rendered to scene
|
||||
|
|
@ -288,6 +295,17 @@ pub fn render_layer_isolated(
|
|||
.collect();
|
||||
return RenderedLayer::effect_layer(layer_id, opacity, active_effects);
|
||||
}
|
||||
AnyLayer::Group(group_layer) => {
|
||||
// Render each child layer's content into the group's scene
|
||||
for child in &group_layer.children {
|
||||
render_layer(
|
||||
document, time, child, &mut rendered.scene, base_transform,
|
||||
1.0, // Full opacity - layer opacity handled in compositing
|
||||
image_cache, video_manager, camera_frame,
|
||||
);
|
||||
}
|
||||
rendered.has_content = !group_layer.children.is_empty();
|
||||
}
|
||||
}
|
||||
|
||||
rendered
|
||||
|
|
@ -325,6 +343,7 @@ fn render_video_layer_to_scene(
|
|||
base_transform: Affine,
|
||||
parent_opacity: f64,
|
||||
video_manager: &mut crate::video::VideoManager,
|
||||
camera_frame: Option<&crate::webcam::CaptureFrame>,
|
||||
) {
|
||||
// Render using the existing function but to this isolated scene
|
||||
render_video_layer(
|
||||
|
|
@ -335,6 +354,7 @@ fn render_video_layer_to_scene(
|
|||
base_transform,
|
||||
parent_opacity,
|
||||
video_manager,
|
||||
camera_frame,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -373,10 +393,10 @@ pub fn render_document_with_transform(
|
|||
for layer in document.visible_layers() {
|
||||
if any_soloed {
|
||||
if layer.soloed() {
|
||||
render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager);
|
||||
render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager, None);
|
||||
}
|
||||
} else {
|
||||
render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager);
|
||||
render_layer(document, time, layer, scene, base_transform, 1.0, image_cache, video_manager, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -408,6 +428,7 @@ fn render_layer(
|
|||
parent_opacity: f64,
|
||||
image_cache: &mut ImageCache,
|
||||
video_manager: &std::sync::Arc<std::sync::Mutex<crate::video::VideoManager>>,
|
||||
camera_frame: Option<&crate::webcam::CaptureFrame>,
|
||||
) {
|
||||
match layer {
|
||||
AnyLayer::Vector(vector_layer) => {
|
||||
|
|
@ -418,11 +439,18 @@ fn render_layer(
|
|||
}
|
||||
AnyLayer::Video(video_layer) => {
|
||||
let mut video_mgr = video_manager.lock().unwrap();
|
||||
render_video_layer(document, time, video_layer, scene, base_transform, parent_opacity, &mut video_mgr);
|
||||
let layer_camera_frame = if video_layer.camera_enabled { camera_frame } else { None };
|
||||
render_video_layer(document, time, video_layer, scene, base_transform, parent_opacity, &mut video_mgr, layer_camera_frame);
|
||||
}
|
||||
AnyLayer::Effect(_) => {
|
||||
// Effect layers are processed during GPU compositing, not rendered to scene
|
||||
}
|
||||
AnyLayer::Group(group_layer) => {
|
||||
// Render each child layer in the group
|
||||
for child in &group_layer.children {
|
||||
render_layer(document, time, child, scene, base_transform, parent_opacity, image_cache, video_manager, camera_frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -612,7 +640,7 @@ fn render_clip_instance(
|
|||
if !layer_node.data.visible() {
|
||||
continue;
|
||||
}
|
||||
render_layer(document, clip_time, &layer_node.data, scene, instance_transform, clip_opacity, image_cache, video_manager);
|
||||
render_layer(document, clip_time, &layer_node.data, scene, instance_transform, clip_opacity, image_cache, video_manager, None);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -625,12 +653,16 @@ fn render_video_layer(
|
|||
base_transform: Affine,
|
||||
parent_opacity: f64,
|
||||
video_manager: &mut crate::video::VideoManager,
|
||||
camera_frame: Option<&crate::webcam::CaptureFrame>,
|
||||
) {
|
||||
use crate::animation::TransformProperty;
|
||||
|
||||
// Cascade opacity: parent_opacity × layer.opacity
|
||||
let layer_opacity = parent_opacity * layer.layer.opacity;
|
||||
|
||||
// Track whether any clip was rendered at the current time
|
||||
let mut clip_rendered = false;
|
||||
|
||||
// Render each video clip instance
|
||||
for clip_instance in &layer.clip_instances {
|
||||
// Get the video clip from the document
|
||||
|
|
@ -780,6 +812,49 @@ fn render_video_layer(
|
|||
None,
|
||||
&video_rect,
|
||||
);
|
||||
clip_rendered = true;
|
||||
}
|
||||
|
||||
// If no clip was rendered at this time and camera is enabled, show live preview
|
||||
if !clip_rendered && layer.camera_enabled {
|
||||
if let Some(frame) = camera_frame {
|
||||
let final_opacity = layer_opacity as f32;
|
||||
|
||||
let blob_data: Arc<dyn AsRef<[u8]> + Send + Sync> = frame.rgba_data.clone();
|
||||
let image_data = ImageData {
|
||||
data: Blob::new(blob_data),
|
||||
format: ImageFormat::Rgba8,
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
alpha_type: ImageAlphaType::Alpha,
|
||||
};
|
||||
let image = ImageBrush::new(image_data);
|
||||
let image_with_alpha = image.with_alpha(final_opacity);
|
||||
let frame_rect = Rect::new(0.0, 0.0, frame.width as f64, frame.height as f64);
|
||||
|
||||
// Scale-to-fit and center in document (same as imported video clips)
|
||||
let video_w = frame.width as f64;
|
||||
let video_h = frame.height as f64;
|
||||
let scale_x = document.width / video_w;
|
||||
let scale_y = document.height / video_h;
|
||||
let uniform_scale = scale_x.min(scale_y);
|
||||
let scaled_w = video_w * uniform_scale;
|
||||
let scaled_h = video_h * uniform_scale;
|
||||
let offset_x = (document.width - scaled_w) / 2.0;
|
||||
let offset_y = (document.height - scaled_h) / 2.0;
|
||||
|
||||
let preview_transform = base_transform
|
||||
* Affine::translate((offset_x, offset_y))
|
||||
* Affine::scale(uniform_scale);
|
||||
|
||||
scene.fill(
|
||||
Fill::NonZero,
|
||||
preview_transform,
|
||||
&image_with_alpha,
|
||||
None,
|
||||
&frame_rect,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use crate::dcel::{Dcel, EdgeId, FaceId, VertexId};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
use vello::kurbo::BezPath;
|
||||
use vello::kurbo::{Affine, BezPath};
|
||||
|
||||
/// Selection state for the editor
|
||||
///
|
||||
|
|
@ -271,9 +271,11 @@ impl Selection {
|
|||
|
||||
/// Represents a temporary region-based selection.
|
||||
///
|
||||
/// When a region select is active, elements that cross the region boundary
|
||||
/// are tracked. If the user performs an operation, the selection is
|
||||
/// committed; if they deselect, the original state is restored.
|
||||
/// When a region select is active, the region boundary is inserted into the
|
||||
/// DCEL as invisible edges, splitting existing geometry. Faces inside the
|
||||
/// region are added to the normal `Selection`. If the user performs an
|
||||
/// operation, the selection is committed; if they deselect, the DCEL is
|
||||
/// restored from the snapshot.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RegionSelection {
|
||||
/// The clipping region as a closed BezPath (polygon or rect)
|
||||
|
|
@ -282,10 +284,12 @@ pub struct RegionSelection {
|
|||
pub layer_id: Uuid,
|
||||
/// Keyframe time
|
||||
pub time: f64,
|
||||
/// Per-shape split results (legacy, kept for compatibility)
|
||||
pub splits: Vec<()>,
|
||||
/// IDs that were fully inside the region
|
||||
pub fully_inside_ids: Vec<Uuid>,
|
||||
/// Snapshot of the DCEL before region boundary insertion, for revert
|
||||
pub dcel_snapshot: Dcel,
|
||||
/// The extracted DCEL containing geometry inside the region
|
||||
pub selected_dcel: Dcel,
|
||||
/// Transform applied to the selected DCEL (e.g. from dragging)
|
||||
pub transform: Affine,
|
||||
/// Whether the selection has been committed (via an operation on the selection)
|
||||
pub committed: bool,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
//! Geometry snapping for vector editing
|
||||
//!
|
||||
//! Provides snap-to-geometry queries that find the nearest vertex, edge midpoint,
|
||||
//! or curve point within a given radius. Priority order: Vertex > Midpoint > Curve.
|
||||
|
||||
use crate::dcel::{Dcel, EdgeId, VertexId};
|
||||
use vello::kurbo::{ParamCurve, ParamCurveNearest, Point};
|
||||
|
||||
/// Default snap radius in screen pixels (converted to document space via zoom).
|
||||
pub const SNAP_SCREEN_RADIUS: f64 = 12.0;
|
||||
|
||||
/// What the cursor snapped to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum SnapTarget {
|
||||
/// Snapped to an existing vertex position.
|
||||
Vertex { vertex_id: VertexId },
|
||||
/// Snapped to the midpoint (t=0.5) of an edge.
|
||||
Midpoint { edge_id: EdgeId },
|
||||
/// Snapped to the nearest point on a curve.
|
||||
Curve { edge_id: EdgeId, parameter_t: f64 },
|
||||
}
|
||||
|
||||
/// Result of a snap query.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SnapResult {
|
||||
/// The position to snap to (in document/local space).
|
||||
pub position: Point,
|
||||
/// What type of element was snapped to.
|
||||
pub target: SnapTarget,
|
||||
/// Distance from the query point to the snap position.
|
||||
pub distance: f64,
|
||||
}
|
||||
|
||||
/// Configuration for snap behavior.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SnapConfig {
|
||||
/// Snap search radius in document units.
|
||||
pub radius: f64,
|
||||
/// Whether vertex snapping is enabled.
|
||||
pub snap_to_vertices: bool,
|
||||
/// Whether midpoint snapping is enabled.
|
||||
pub snap_to_midpoints: bool,
|
||||
/// Whether curve snapping is enabled.
|
||||
pub snap_to_curves: bool,
|
||||
}
|
||||
|
||||
impl SnapConfig {
|
||||
/// Create a snap config from a screen-pixel radius, converted to document space.
|
||||
pub fn from_screen_radius(screen_pixels: f64, zoom: f64) -> Self {
|
||||
Self {
|
||||
radius: screen_pixels / zoom,
|
||||
snap_to_vertices: true,
|
||||
snap_to_midpoints: true,
|
||||
snap_to_curves: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Elements to exclude from snap queries (self-exclusion during drag).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SnapExclusion {
|
||||
/// Vertices to skip (e.g. the vertex being dragged).
|
||||
pub vertices: Vec<VertexId>,
|
||||
/// Edges to skip (e.g. edges connected to the dragged vertex).
|
||||
pub edges: Vec<EdgeId>,
|
||||
}
|
||||
|
||||
/// Find the best snap target for a point within a DCEL.
|
||||
///
|
||||
/// Priority: Vertex > Edge Midpoint > Nearest point on Curve.
|
||||
/// Returns `None` if nothing is within the configured radius.
|
||||
pub fn find_snap_target(
|
||||
dcel: &Dcel,
|
||||
point: Point,
|
||||
config: &SnapConfig,
|
||||
exclusion: &SnapExclusion,
|
||||
) -> Option<SnapResult> {
|
||||
let radius_sq = config.radius * config.radius;
|
||||
|
||||
// Phase 1: Vertex snap (highest priority)
|
||||
if config.snap_to_vertices {
|
||||
let mut best: Option<(VertexId, Point, f64)> = None;
|
||||
for (i, vertex) in dcel.vertices.iter().enumerate() {
|
||||
if vertex.deleted {
|
||||
continue;
|
||||
}
|
||||
let vid = VertexId(i as u32);
|
||||
if exclusion.vertices.contains(&vid) {
|
||||
continue;
|
||||
}
|
||||
let dx = vertex.position.x - point.x;
|
||||
let dy = vertex.position.y - point.y;
|
||||
let dist_sq = dx * dx + dy * dy;
|
||||
if dist_sq <= radius_sq {
|
||||
if best.is_none() || dist_sq < best.unwrap().2 {
|
||||
best = Some((vid, vertex.position, dist_sq));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((vid, pos, dist_sq)) = best {
|
||||
return Some(SnapResult {
|
||||
position: pos,
|
||||
target: SnapTarget::Vertex { vertex_id: vid },
|
||||
distance: dist_sq.sqrt(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Edge midpoint snap
|
||||
if config.snap_to_midpoints {
|
||||
let mut best: Option<(EdgeId, Point, f64)> = None;
|
||||
for (i, edge) in dcel.edges.iter().enumerate() {
|
||||
if edge.deleted {
|
||||
continue;
|
||||
}
|
||||
let eid = EdgeId(i as u32);
|
||||
if exclusion.edges.contains(&eid) {
|
||||
continue;
|
||||
}
|
||||
let midpoint = edge.curve.eval(0.5);
|
||||
let dx = midpoint.x - point.x;
|
||||
let dy = midpoint.y - point.y;
|
||||
let dist_sq = dx * dx + dy * dy;
|
||||
if dist_sq <= radius_sq {
|
||||
if best.is_none() || dist_sq < best.unwrap().2 {
|
||||
best = Some((eid, midpoint, dist_sq));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((eid, pos, dist_sq)) = best {
|
||||
return Some(SnapResult {
|
||||
position: pos,
|
||||
target: SnapTarget::Midpoint { edge_id: eid },
|
||||
distance: dist_sq.sqrt(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Nearest point on curve
|
||||
if config.snap_to_curves {
|
||||
let mut best: Option<(EdgeId, f64, Point, f64)> = None;
|
||||
for (i, edge) in dcel.edges.iter().enumerate() {
|
||||
if edge.deleted {
|
||||
continue;
|
||||
}
|
||||
let eid = EdgeId(i as u32);
|
||||
if exclusion.edges.contains(&eid) {
|
||||
continue;
|
||||
}
|
||||
let nearest = edge.curve.nearest(point, 0.5);
|
||||
let dist = nearest.distance_sq.sqrt();
|
||||
if dist <= config.radius {
|
||||
if best.is_none() || dist < best.unwrap().3 {
|
||||
let snap_point = edge.curve.eval(nearest.t);
|
||||
best = Some((eid, nearest.t, snap_point, dist));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((eid, t, pos, dist)) = best {
|
||||
return Some(SnapResult {
|
||||
position: pos,
|
||||
target: SnapTarget::Curve {
|
||||
edge_id: eid,
|
||||
parameter_t: t,
|
||||
},
|
||||
distance: dist,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vello::kurbo::CubicBez;
|
||||
|
||||
fn make_dcel_with_edge() -> Dcel {
|
||||
let mut dcel = Dcel::new();
|
||||
let curve = CubicBez::new(
|
||||
Point::new(0.0, 0.0),
|
||||
Point::new(33.0, 0.0),
|
||||
Point::new(67.0, 0.0),
|
||||
Point::new(100.0, 0.0),
|
||||
);
|
||||
dcel.insert_stroke(&[curve], None, None, 0.5);
|
||||
dcel
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_to_vertex() {
|
||||
let dcel = make_dcel_with_edge();
|
||||
let config = SnapConfig {
|
||||
radius: 5.0,
|
||||
snap_to_vertices: true,
|
||||
snap_to_midpoints: true,
|
||||
snap_to_curves: true,
|
||||
};
|
||||
let exclusion = SnapExclusion::default();
|
||||
let result = find_snap_target(&dcel, Point::new(2.0, 0.0), &config, &exclusion);
|
||||
assert!(result.is_some());
|
||||
assert!(matches!(result.unwrap().target, SnapTarget::Vertex { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_to_midpoint() {
|
||||
let dcel = make_dcel_with_edge();
|
||||
let config = SnapConfig {
|
||||
radius: 5.0,
|
||||
snap_to_vertices: true,
|
||||
snap_to_midpoints: true,
|
||||
snap_to_curves: true,
|
||||
};
|
||||
let exclusion = SnapExclusion::default();
|
||||
// Point near midpoint (50, 0) but far from vertices (0,0) and (100,0)
|
||||
let result = find_snap_target(&dcel, Point::new(51.0, 0.0), &config, &exclusion);
|
||||
assert!(result.is_some());
|
||||
assert!(matches!(result.unwrap().target, SnapTarget::Midpoint { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_to_curve() {
|
||||
let dcel = make_dcel_with_edge();
|
||||
let config = SnapConfig {
|
||||
radius: 5.0,
|
||||
snap_to_vertices: true,
|
||||
snap_to_midpoints: true,
|
||||
snap_to_curves: true,
|
||||
};
|
||||
let exclusion = SnapExclusion::default();
|
||||
// Point near t=0.25 on curve (25, 0) — not near a vertex or midpoint
|
||||
let result = find_snap_target(&dcel, Point::new(25.0, 3.0), &config, &exclusion);
|
||||
assert!(result.is_some());
|
||||
assert!(matches!(result.unwrap().target, SnapTarget::Curve { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_snap_outside_radius() {
|
||||
let dcel = make_dcel_with_edge();
|
||||
let config = SnapConfig {
|
||||
radius: 5.0,
|
||||
snap_to_vertices: true,
|
||||
snap_to_midpoints: true,
|
||||
snap_to_curves: true,
|
||||
};
|
||||
let exclusion = SnapExclusion::default();
|
||||
let result = find_snap_target(&dcel, Point::new(50.0, 20.0), &config, &exclusion);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusion_skips_vertex() {
|
||||
let dcel = make_dcel_with_edge();
|
||||
let config = SnapConfig {
|
||||
radius: 5.0,
|
||||
snap_to_vertices: true,
|
||||
snap_to_midpoints: false,
|
||||
snap_to_curves: false,
|
||||
};
|
||||
// Exclude vertex 0
|
||||
let exclusion = SnapExclusion {
|
||||
vertices: vec![VertexId(0)],
|
||||
edges: vec![],
|
||||
};
|
||||
let result = find_snap_target(&dcel, Point::new(2.0, 0.0), &config, &exclusion);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
//! Debug test mode data types — input recording, panic capture & visual replay.
|
||||
//!
|
||||
//! All types are gated behind `#[cfg(debug_assertions)]` at the module level.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
/// Serializable 2D point (avoids needing kurbo serde dependency)
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||
pub struct SerPoint {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
impl From<vello::kurbo::Point> for SerPoint {
|
||||
fn from(p: vello::kurbo::Point) -> Self {
|
||||
Self { x: p.x, y: p.y }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SerPoint> for vello::kurbo::Point {
|
||||
fn from(p: SerPoint) -> Self {
|
||||
vello::kurbo::Point::new(p.x, p.y)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<egui::Vec2> for SerPoint {
|
||||
fn from(v: egui::Vec2) -> Self {
|
||||
Self {
|
||||
x: v.x as f64,
|
||||
y: v.y as f64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializable modifier keys
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct SerModifiers {
|
||||
pub ctrl: bool,
|
||||
pub shift: bool,
|
||||
pub alt: bool,
|
||||
}
|
||||
|
||||
/// All recordable event types — recorded in clip-local document coordinates
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum TestEventKind {
|
||||
MouseDown { pos: SerPoint },
|
||||
MouseUp { pos: SerPoint },
|
||||
MouseDrag { pos: SerPoint },
|
||||
MouseMove { pos: SerPoint },
|
||||
Scroll { delta_x: f32, delta_y: f32 },
|
||||
KeyDown { key: String, modifiers: SerModifiers },
|
||||
KeyUp { key: String, modifiers: SerModifiers },
|
||||
ToolChanged { tool: String },
|
||||
ActionExecuted { description: String },
|
||||
}
|
||||
|
||||
/// A single timestamped event
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TestEvent {
|
||||
pub index: usize,
|
||||
pub timestamp_ms: u64,
|
||||
pub kind: TestEventKind,
|
||||
}
|
||||
|
||||
/// Initial state snapshot for deterministic replay
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CanvasState {
|
||||
pub zoom: f32,
|
||||
pub pan_offset: (f32, f32),
|
||||
pub selected_tool: String,
|
||||
pub fill_color: [u8; 4],
|
||||
pub stroke_color: [u8; 4],
|
||||
pub stroke_width: f64,
|
||||
pub fill_enabled: bool,
|
||||
pub snap_enabled: bool,
|
||||
pub polygon_sides: u32,
|
||||
}
|
||||
|
||||
/// A complete test case (saved as pretty-printed JSON)
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TestCase {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub recorded_at: String,
|
||||
pub initial_canvas: CanvasState,
|
||||
pub events: Vec<TestEvent>,
|
||||
pub ended_with_panic: bool,
|
||||
pub panic_message: Option<String>,
|
||||
pub panic_backtrace: Option<String>,
|
||||
}
|
||||
|
||||
impl TestCase {
|
||||
/// Create a new empty test case with the given name and canvas state
|
||||
pub fn new(name: String, initial_canvas: CanvasState) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description: String::new(),
|
||||
recorded_at: chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||
initial_canvas,
|
||||
events: Vec::new(),
|
||||
ended_with_panic: false,
|
||||
panic_message: None,
|
||||
panic_backtrace: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save to a JSON file
|
||||
pub fn save_to_file(&self, path: &Path) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
||||
std::fs::write(path, json)
|
||||
}
|
||||
|
||||
/// Load from a JSON file
|
||||
pub fn load_from_file(path: &Path) -> std::io::Result<Self> {
|
||||
let json = std::fs::read_to_string(path)?;
|
||||
serde_json::from_str(&json)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,625 @@
|
|||
//! Webcam capture and recording for Lightningbeam
|
||||
//!
|
||||
//! Cross-platform webcam capture using ffmpeg libavdevice:
|
||||
//! - Linux: v4l2
|
||||
//! - macOS: avfoundation
|
||||
//! - Windows: dshow
|
||||
//!
|
||||
//! Capture runs on a dedicated thread. Frames are sent to the main thread
|
||||
//! via a bounded channel for live preview. Recording encodes directly to
|
||||
//! disk in real-time (H.264 or FFV1 lossless).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use ffmpeg_next as ffmpeg;
|
||||
|
||||
/// A camera device descriptor (platform-agnostic).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CameraDevice {
|
||||
/// Human-readable name (e.g. "Integrated Webcam")
|
||||
pub name: String,
|
||||
/// ffmpeg input format name: "v4l2", "avfoundation", "dshow"
|
||||
pub format_name: String,
|
||||
/// Device path/identifier for ffmpeg: "/dev/video0", "0", "video=..."
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// A decoded RGBA frame from the webcam.
|
||||
#[derive(Clone)]
|
||||
pub struct CaptureFrame {
|
||||
pub rgba_data: Arc<Vec<u8>>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Seconds since capture started.
|
||||
pub timestamp: f64,
|
||||
}
|
||||
|
||||
/// Codec to use when recording webcam footage.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum RecordingCodec {
|
||||
/// H.264 in MP4 — small files, lossy
|
||||
H264,
|
||||
/// FFV1 in MKV — lossless, larger files
|
||||
Lossless,
|
||||
}
|
||||
|
||||
impl Default for RecordingCodec {
|
||||
fn default() -> Self {
|
||||
RecordingCodec::H264
|
||||
}
|
||||
}
|
||||
|
||||
/// Command sent from the main thread to the capture thread.
|
||||
enum CaptureCommand {
|
||||
StartRecording {
|
||||
path: PathBuf,
|
||||
codec: RecordingCodec,
|
||||
result_tx: mpsc::Sender<Result<(), String>>,
|
||||
},
|
||||
StopRecording {
|
||||
result_tx: mpsc::Sender<Result<RecordingResult, String>>,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
/// Result returned when recording stops.
|
||||
pub struct RecordingResult {
|
||||
pub file_path: PathBuf,
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
/// Live webcam capture with optional recording.
|
||||
///
|
||||
/// Call `open()` to start capturing from a camera device. Use `poll_frame()`
|
||||
/// each frame to get the latest preview. Use `start_recording()` /
|
||||
/// `stop_recording()` to encode to disk.
|
||||
pub struct WebcamCapture {
|
||||
cmd_tx: mpsc::Sender<CaptureCommand>,
|
||||
frame_rx: mpsc::Receiver<CaptureFrame>,
|
||||
latest_frame: Option<CaptureFrame>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
recording: bool,
|
||||
thread_handle: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl WebcamCapture {
|
||||
/// Open a webcam device and start the capture thread.
|
||||
///
|
||||
/// The camera is opened once on the capture thread (not on the calling
|
||||
/// thread) to avoid blocking the UI. Resolution is reported back via a
|
||||
/// oneshot channel.
|
||||
pub fn open(device: &CameraDevice) -> Result<Self, String> {
|
||||
ffmpeg::init().map_err(|e| format!("ffmpeg init failed: {e}"))?;
|
||||
ffmpeg::device::register_all();
|
||||
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<CaptureCommand>();
|
||||
let (frame_tx, frame_rx) = mpsc::sync_channel::<CaptureFrame>(2);
|
||||
// Oneshot for the capture thread to report back resolution
|
||||
let (info_tx, info_rx) = mpsc::channel::<Result<(u32, u32), String>>();
|
||||
|
||||
let device_clone = device.clone();
|
||||
let thread_handle = thread::Builder::new()
|
||||
.name("webcam-capture".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = capture_thread_main(&device_clone, frame_tx, cmd_rx, info_tx) {
|
||||
eprintln!("[webcam] capture thread error: {e}");
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("Failed to spawn capture thread: {e}"))?;
|
||||
|
||||
// Wait for the capture thread to open the camera and report resolution
|
||||
let (width, height) = info_rx
|
||||
.recv()
|
||||
.map_err(|_| "Capture thread died during init".to_string())?
|
||||
.map_err(|e| format!("Camera open failed: {e}"))?;
|
||||
|
||||
Ok(Self {
|
||||
cmd_tx,
|
||||
frame_rx,
|
||||
latest_frame: None,
|
||||
width,
|
||||
height,
|
||||
recording: false,
|
||||
thread_handle: Some(thread_handle),
|
||||
})
|
||||
}
|
||||
|
||||
/// Drain the frame channel and return the most recent frame.
|
||||
pub fn poll_frame(&mut self) -> Option<&CaptureFrame> {
|
||||
while let Ok(frame) = self.frame_rx.try_recv() {
|
||||
self.latest_frame = Some(frame);
|
||||
}
|
||||
self.latest_frame.as_ref()
|
||||
}
|
||||
|
||||
/// Start recording to disk.
|
||||
pub fn start_recording(&mut self, path: PathBuf, codec: RecordingCodec) -> Result<(), String> {
|
||||
if self.recording {
|
||||
return Err("Already recording".into());
|
||||
}
|
||||
let (result_tx, result_rx) = mpsc::channel();
|
||||
self.cmd_tx
|
||||
.send(CaptureCommand::StartRecording {
|
||||
path,
|
||||
codec,
|
||||
result_tx,
|
||||
})
|
||||
.map_err(|_| "Capture thread not running")?;
|
||||
|
||||
let result = result_rx
|
||||
.recv()
|
||||
.map_err(|_| "Capture thread died before responding")?;
|
||||
result?;
|
||||
self.recording = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop recording and return the result.
|
||||
pub fn stop_recording(&mut self) -> Result<RecordingResult, String> {
|
||||
if !self.recording {
|
||||
return Err("Not recording".into());
|
||||
}
|
||||
let (result_tx, result_rx) = mpsc::channel();
|
||||
self.cmd_tx
|
||||
.send(CaptureCommand::StopRecording { result_tx })
|
||||
.map_err(|_| "Capture thread not running")?;
|
||||
|
||||
let result = result_rx
|
||||
.recv()
|
||||
.map_err(|_| "Capture thread died before responding")?;
|
||||
self.recording = false;
|
||||
result
|
||||
}
|
||||
|
||||
pub fn is_recording(&self) -> bool {
|
||||
self.recording
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WebcamCapture {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.cmd_tx.send(CaptureCommand::Shutdown);
|
||||
if let Some(handle) = self.thread_handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform camera enumeration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Find the ffmpeg input format by name (e.g. "v4l2", "avfoundation", "dshow").
|
||||
///
|
||||
/// Uses the FFI `av_find_input_format()` directly, since `ffmpeg::device::input::video()`
|
||||
/// only iterates device-registered formats and may miss demuxers like v4l2.
|
||||
fn find_input_format(format_name: &str) -> Option<ffmpeg::format::format::Input> {
|
||||
// Log what the device iterator sees (for diagnostics)
|
||||
let device_formats: Vec<String> = ffmpeg::device::input::video()
|
||||
.map(|f| f.name().to_string())
|
||||
.collect();
|
||||
eprintln!("[WEBCAM] Registered device input formats: {:?}", device_formats);
|
||||
|
||||
let c_name = std::ffi::CString::new(format_name).ok()?;
|
||||
unsafe {
|
||||
let ptr = ffmpeg::sys::av_find_input_format(c_name.as_ptr());
|
||||
if ptr.is_null() {
|
||||
eprintln!("[WEBCAM] av_find_input_format('{}') returned null", format_name);
|
||||
None
|
||||
} else {
|
||||
eprintln!("[WEBCAM] av_find_input_format('{}') found format", format_name);
|
||||
Some(ffmpeg::format::format::Input::wrap(ptr as *mut _))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List available camera devices for the current platform.
|
||||
pub fn list_cameras() -> Vec<CameraDevice> {
|
||||
ffmpeg::init().ok();
|
||||
ffmpeg::device::register_all();
|
||||
|
||||
let mut devices = Vec::new();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
for i in 0..10 {
|
||||
let path = format!("/dev/video{i}");
|
||||
if std::path::Path::new(&path).exists() {
|
||||
devices.push(CameraDevice {
|
||||
name: format!("Camera {i}"),
|
||||
format_name: "v4l2".into(),
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
devices.push(CameraDevice {
|
||||
name: "Default Camera".into(),
|
||||
format_name: "avfoundation".into(),
|
||||
path: "0".into(),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
devices.push(CameraDevice {
|
||||
name: "Default Camera".into(),
|
||||
format_name: "dshow".into(),
|
||||
path: "video=Integrated Camera".into(),
|
||||
});
|
||||
}
|
||||
|
||||
devices
|
||||
}
|
||||
|
||||
/// Return the first available camera, if any.
|
||||
pub fn default_camera() -> Option<CameraDevice> {
|
||||
list_cameras().into_iter().next()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Opening a camera device
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Open a camera device via ffmpeg, returning an Input context.
|
||||
///
|
||||
/// Requests 640x480 @ 30fps — universally supported by USB webcams and
|
||||
/// achievable over USB 2.0. The driver may negotiate different values;
|
||||
/// the capture thread reads whatever the driver actually provides.
|
||||
fn open_camera(device: &CameraDevice) -> Result<ffmpeg::format::context::Input, String> {
|
||||
let input_format = find_input_format(&device.format_name)
|
||||
.ok_or_else(|| format!("Input format '{}' not found — is libavdevice enabled?", device.format_name))?;
|
||||
|
||||
let mut opts = ffmpeg::Dictionary::new();
|
||||
opts.set("video_size", "640x480");
|
||||
opts.set("framerate", "30");
|
||||
|
||||
let format = ffmpeg::Format::Input(input_format);
|
||||
let ctx = ffmpeg::format::open_with(&device.path, &format, opts)
|
||||
.map_err(|e| format!("Failed to open camera '{}': {e}", device.path))?;
|
||||
|
||||
if ctx.is_input() {
|
||||
Ok(ctx.input())
|
||||
} else {
|
||||
Err("Expected input context from camera".into())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capture thread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn capture_thread_main(
|
||||
device: &CameraDevice,
|
||||
frame_tx: mpsc::SyncSender<CaptureFrame>,
|
||||
cmd_rx: mpsc::Receiver<CaptureCommand>,
|
||||
info_tx: mpsc::Sender<Result<(u32, u32), String>>,
|
||||
) -> Result<(), String> {
|
||||
let mut input = match open_camera(device) {
|
||||
Ok(input) => input,
|
||||
Err(e) => {
|
||||
let _ = info_tx.send(Err(e.clone()));
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let stream_index = input
|
||||
.streams()
|
||||
.best(ffmpeg::media::Type::Video)
|
||||
.ok_or("No video stream")?
|
||||
.index();
|
||||
|
||||
let stream = input.stream(stream_index).unwrap();
|
||||
let fps = {
|
||||
let r = f64::from(stream.avg_frame_rate());
|
||||
if r > 0.0 { r } else { 30.0 }
|
||||
};
|
||||
let codec_params = stream.parameters();
|
||||
let decoder_ctx = ffmpeg::codec::context::Context::from_parameters(codec_params)
|
||||
.map_err(|e| format!("Codec context: {e}"))?;
|
||||
let mut decoder = decoder_ctx
|
||||
.decoder()
|
||||
.video()
|
||||
.map_err(|e| format!("Video decoder: {e}"))?;
|
||||
|
||||
let width = decoder.width();
|
||||
let height = decoder.height();
|
||||
let src_format = decoder.format();
|
||||
|
||||
eprintln!("[webcam] Camera opened: {}x{} @ {:.1}fps format={:?}",
|
||||
width, height, fps, src_format);
|
||||
|
||||
// Report resolution back to the main thread
|
||||
let _ = info_tx.send(Ok((width, height)));
|
||||
|
||||
let mut scaler = ffmpeg::software::scaling::Context::get(
|
||||
src_format,
|
||||
width,
|
||||
height,
|
||||
ffmpeg::format::Pixel::RGBA,
|
||||
width,
|
||||
height,
|
||||
ffmpeg::software::scaling::Flags::BILINEAR,
|
||||
)
|
||||
.map_err(|e| format!("Scaler init: {e}"))?;
|
||||
|
||||
let mut recorder: Option<FrameRecorder> = None;
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut frame_count: u64 = 0;
|
||||
/// Number of initial frames to skip (v4l2 first buffers are often corrupt)
|
||||
const SKIP_INITIAL_FRAMES: u64 = 2;
|
||||
|
||||
let mut decoded_frame = ffmpeg::frame::Video::empty();
|
||||
let mut rgba_frame = ffmpeg::frame::Video::empty();
|
||||
|
||||
'outer: for (stream_ref, packet) in input.packets() {
|
||||
if stream_ref.index() != stream_index {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for commands (non-blocking).
|
||||
while let Ok(cmd) = cmd_rx.try_recv() {
|
||||
match cmd {
|
||||
CaptureCommand::StartRecording {
|
||||
path,
|
||||
codec,
|
||||
result_tx,
|
||||
} => {
|
||||
let result = FrameRecorder::new(&path, codec, width, height, fps);
|
||||
match result {
|
||||
Ok(rec) => {
|
||||
recorder = Some(rec);
|
||||
let _ = result_tx.send(Ok(()));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = result_tx.send(Err(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
CaptureCommand::StopRecording { result_tx } => {
|
||||
if let Some(rec) = recorder.take() {
|
||||
let _ = result_tx.send(rec.finish());
|
||||
} else {
|
||||
let _ = result_tx.send(Err("Not recording".into()));
|
||||
}
|
||||
}
|
||||
CaptureCommand::Shutdown => break 'outer,
|
||||
}
|
||||
}
|
||||
|
||||
decoder.send_packet(&packet).ok();
|
||||
|
||||
while decoder.receive_frame(&mut decoded_frame).is_ok() {
|
||||
// Skip initial corrupt frames from v4l2
|
||||
if frame_count < SKIP_INITIAL_FRAMES {
|
||||
frame_count += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
scaler.run(&decoded_frame, &mut rgba_frame).ok();
|
||||
|
||||
let timestamp = start_time.elapsed().as_secs_f64();
|
||||
|
||||
// Build tightly-packed RGBA data (remove stride padding).
|
||||
let data = rgba_frame.data(0);
|
||||
let stride = rgba_frame.stride(0);
|
||||
let row_bytes = (width * 4) as usize;
|
||||
|
||||
let rgba_data = if stride == row_bytes {
|
||||
data[..row_bytes * height as usize].to_vec()
|
||||
} else {
|
||||
let mut buf = Vec::with_capacity(row_bytes * height as usize);
|
||||
for y in 0..height as usize {
|
||||
buf.extend_from_slice(&data[y * stride..y * stride + row_bytes]);
|
||||
}
|
||||
buf
|
||||
};
|
||||
|
||||
let rgba_arc = Arc::new(rgba_data);
|
||||
|
||||
let frame = CaptureFrame {
|
||||
rgba_data: rgba_arc.clone(),
|
||||
width,
|
||||
height,
|
||||
timestamp,
|
||||
};
|
||||
let _ = frame_tx.try_send(frame);
|
||||
|
||||
if let Some(ref mut rec) = recorder {
|
||||
if let Err(e) = rec.encode_rgba(&rgba_arc, width, height, frame_count) {
|
||||
eprintln!("[webcam] recording encode error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
frame_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up: if still recording when shutting down, finalize.
|
||||
if let Some(rec) = recorder.take() {
|
||||
let _ = rec.finish();
|
||||
}
|
||||
|
||||
decoder.send_eof().ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recording encoder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct FrameRecorder {
|
||||
output: ffmpeg::format::context::Output,
|
||||
encoder: ffmpeg::encoder::Video,
|
||||
scaler: ffmpeg::software::scaling::Context,
|
||||
path: PathBuf,
|
||||
frame_count: u64,
|
||||
fps: f64,
|
||||
}
|
||||
|
||||
impl FrameRecorder {
|
||||
fn new(
|
||||
path: &PathBuf,
|
||||
codec: RecordingCodec,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: f64,
|
||||
) -> Result<Self, String> {
|
||||
let path_str = path.to_str().ok_or("Invalid path")?;
|
||||
|
||||
let mut output = ffmpeg::format::output(path_str)
|
||||
.map_err(|e| format!("Failed to create output file: {e}"))?;
|
||||
|
||||
let (codec_id, pixel_format) = match codec {
|
||||
RecordingCodec::H264 => (ffmpeg::codec::Id::H264, ffmpeg::format::Pixel::YUV420P),
|
||||
RecordingCodec::Lossless => (ffmpeg::codec::Id::FFV1, ffmpeg::format::Pixel::YUV444P),
|
||||
};
|
||||
|
||||
let ffmpeg_codec = ffmpeg::encoder::find(codec_id)
|
||||
.or_else(|| match codec_id {
|
||||
ffmpeg::codec::Id::H264 => ffmpeg::encoder::find_by_name("libx264"),
|
||||
ffmpeg::codec::Id::FFV1 => ffmpeg::encoder::find_by_name("ffv1"),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| format!("Encoder not found for {codec_id:?}"))?;
|
||||
|
||||
let mut encoder = ffmpeg::codec::Context::new_with_codec(ffmpeg_codec)
|
||||
.encoder()
|
||||
.video()
|
||||
.map_err(|e| format!("Failed to create encoder: {e}"))?;
|
||||
|
||||
let aligned_width = if codec_id == ffmpeg::codec::Id::H264 {
|
||||
((width + 15) / 16) * 16
|
||||
} else {
|
||||
width
|
||||
};
|
||||
let aligned_height = if codec_id == ffmpeg::codec::Id::H264 {
|
||||
((height + 15) / 16) * 16
|
||||
} else {
|
||||
height
|
||||
};
|
||||
|
||||
encoder.set_width(aligned_width);
|
||||
encoder.set_height(aligned_height);
|
||||
encoder.set_format(pixel_format);
|
||||
encoder.set_time_base(ffmpeg::Rational(1, fps as i32));
|
||||
encoder.set_frame_rate(Some(ffmpeg::Rational(fps as i32, 1)));
|
||||
|
||||
if codec_id == ffmpeg::codec::Id::H264 {
|
||||
encoder.set_bit_rate(4_000_000);
|
||||
encoder.set_gop(fps as u32);
|
||||
}
|
||||
|
||||
let encoder = encoder
|
||||
.open_as(ffmpeg_codec)
|
||||
.map_err(|e| format!("Failed to open encoder: {e}"))?;
|
||||
|
||||
let mut stream = output
|
||||
.add_stream(ffmpeg_codec)
|
||||
.map_err(|e| format!("Failed to add stream: {e}"))?;
|
||||
stream.set_parameters(&encoder);
|
||||
|
||||
output
|
||||
.write_header()
|
||||
.map_err(|e| format!("Failed to write header: {e}"))?;
|
||||
|
||||
let scaler = ffmpeg::software::scaling::Context::get(
|
||||
ffmpeg::format::Pixel::RGBA,
|
||||
width,
|
||||
height,
|
||||
pixel_format,
|
||||
aligned_width,
|
||||
aligned_height,
|
||||
ffmpeg::software::scaling::Flags::BILINEAR,
|
||||
)
|
||||
.map_err(|e| format!("Scaler init: {e}"))?;
|
||||
|
||||
Ok(Self {
|
||||
output,
|
||||
encoder,
|
||||
scaler,
|
||||
path: path.clone(),
|
||||
frame_count: 0,
|
||||
fps,
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_rgba(
|
||||
&mut self,
|
||||
rgba_data: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
_global_frame: u64,
|
||||
) -> Result<(), String> {
|
||||
let mut src_frame =
|
||||
ffmpeg::frame::Video::new(ffmpeg::format::Pixel::RGBA, width, height);
|
||||
|
||||
let dst_stride = src_frame.stride(0);
|
||||
let row_bytes = (width * 4) as usize;
|
||||
for y in 0..height as usize {
|
||||
let src_offset = y * row_bytes;
|
||||
let dst_offset = y * dst_stride;
|
||||
src_frame.data_mut(0)[dst_offset..dst_offset + row_bytes]
|
||||
.copy_from_slice(&rgba_data[src_offset..src_offset + row_bytes]);
|
||||
}
|
||||
|
||||
let mut dst_frame = ffmpeg::frame::Video::empty();
|
||||
self.scaler
|
||||
.run(&src_frame, &mut dst_frame)
|
||||
.map_err(|e| format!("Scale: {e}"))?;
|
||||
|
||||
dst_frame.set_pts(Some(self.frame_count as i64));
|
||||
self.frame_count += 1;
|
||||
|
||||
self.encoder
|
||||
.send_frame(&dst_frame)
|
||||
.map_err(|e| format!("Send frame: {e}"))?;
|
||||
|
||||
self.receive_packets()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn receive_packets(&mut self) -> Result<(), String> {
|
||||
let mut packet = ffmpeg::Packet::empty();
|
||||
let encoder_tb = self.encoder.time_base();
|
||||
let stream_tb = self
|
||||
.output
|
||||
.stream(0)
|
||||
.ok_or("No output stream")?
|
||||
.time_base();
|
||||
|
||||
while self.encoder.receive_packet(&mut packet).is_ok() {
|
||||
packet.set_stream(0);
|
||||
packet.rescale_ts(encoder_tb, stream_tb);
|
||||
packet
|
||||
.write_interleaved(&mut self.output)
|
||||
.map_err(|e| format!("Write packet: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Result<RecordingResult, String> {
|
||||
self.encoder
|
||||
.send_eof()
|
||||
.map_err(|e| format!("Send EOF: {e}"))?;
|
||||
self.receive_packets()?;
|
||||
|
||||
self.output
|
||||
.write_trailer()
|
||||
.map_err(|e| format!("Write trailer: {e}"))?;
|
||||
|
||||
let duration = self.frame_count as f64 / self.fps;
|
||||
Ok(RecordingResult {
|
||||
file_path: self.path,
|
||||
duration,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "lightningbeam-editor"
|
||||
version = "1.0.0-alpha"
|
||||
version = "1.0.1-alpha"
|
||||
edition = "2021"
|
||||
description = "Multimedia editor for audio, video and 2D animation"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
|
@ -124,9 +124,4 @@ source = "assets/icons/256x256.png"
|
|||
dest = "/usr/share/icons/hicolor/256x256/apps/lightningbeam-editor.png"
|
||||
mode = "644"
|
||||
|
||||
# Factory instrument presets and samples (built by build.rs into target dir)
|
||||
[[package.metadata.generate-rpm.assets]]
|
||||
source = "target/release/presets/"
|
||||
dest = "/usr/share/lightningbeam-editor/presets/"
|
||||
mode = "644"
|
||||
doc = false
|
||||
# Factory preset entries are injected by CI (see build.yml "Inject preset entries" step)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
"type": "vertical-grid",
|
||||
"percent": 10,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "toolbar" },
|
||||
{ "type": "pane", "name": "infopanel" },
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 65,
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
"percent": 50,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "stage" },
|
||||
{ "type": "pane", "name": "infopanel" }
|
||||
{ "type": "pane", "name": "assetLibrary" }
|
||||
]
|
||||
},
|
||||
{ "type": "pane", "name": "timelineV2" }
|
||||
|
|
@ -53,17 +53,17 @@
|
|||
},
|
||||
{
|
||||
"name": "Audio/DAW",
|
||||
"description": "Audio tracks prominent with mixer, node editor, and preset browser",
|
||||
"description": "Audio tracks prominent with mixer, virtual piano, and preset browser",
|
||||
"layout": {
|
||||
"type": "horizontal-grid",
|
||||
"percent": 75,
|
||||
"children": [
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 50,
|
||||
"percent": 60,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "timelineV2" },
|
||||
{ "type": "pane", "name": "nodeEditor" }
|
||||
{ "type": "pane", "name": "virtualPiano" }
|
||||
]
|
||||
},
|
||||
{ "type": "pane", "name": "presetBrowser" }
|
||||
|
|
@ -72,34 +72,20 @@
|
|||
},
|
||||
{
|
||||
"name": "Scripting",
|
||||
"description": "Code editor, object hierarchy, and console",
|
||||
"description": "Script editor with stage preview and timeline",
|
||||
"layout": {
|
||||
"type": "vertical-grid",
|
||||
"percent": 10,
|
||||
"type": "horizontal-grid",
|
||||
"percent": 50,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "toolbar" },
|
||||
{
|
||||
"type": "horizontal-grid",
|
||||
"percent": 70,
|
||||
"type": "vertical-grid",
|
||||
"percent": 60,
|
||||
"children": [
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 50,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "stage" },
|
||||
{ "type": "pane", "name": "timelineV2" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 50,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "infopanel" },
|
||||
{ "type": "pane", "name": "outlineer" }
|
||||
]
|
||||
}
|
||||
{ "type": "pane", "name": "stage" },
|
||||
{ "type": "pane", "name": "timelineV2" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{ "type": "pane", "name": "scriptEditor" }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
@ -129,32 +115,6 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "3D",
|
||||
"description": "3D viewport, camera controls, and lighting panel",
|
||||
"layout": {
|
||||
"type": "vertical-grid",
|
||||
"percent": 10,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "toolbar" },
|
||||
{
|
||||
"type": "horizontal-grid",
|
||||
"percent": 70,
|
||||
"children": [
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 70,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "stage" },
|
||||
{ "type": "pane", "name": "timelineV2" }
|
||||
]
|
||||
},
|
||||
{ "type": "pane", "name": "infopanel" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Drawing/Painting",
|
||||
"description": "Minimal UI - just canvas and drawing tools",
|
||||
|
|
@ -180,31 +140,5 @@
|
|||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Shader Editor",
|
||||
"description": "Split between viewport preview and code editor",
|
||||
"layout": {
|
||||
"type": "vertical-grid",
|
||||
"percent": 10,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "toolbar" },
|
||||
{
|
||||
"type": "horizontal-grid",
|
||||
"percent": 50,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "stage" },
|
||||
{
|
||||
"type": "vertical-grid",
|
||||
"percent": 60,
|
||||
"children": [
|
||||
{ "type": "pane", "name": "infopanel" },
|
||||
{ "type": "pane", "name": "timelineV2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use crate::keymap::KeybindingConfig;
|
||||
|
||||
/// Application configuration (persistent)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -52,6 +53,10 @@ pub struct AppConfig {
|
|||
/// Theme mode ("light", "dark", or "system")
|
||||
#[serde(default = "defaults::theme_mode")]
|
||||
pub theme_mode: String,
|
||||
|
||||
/// Custom keyboard shortcut overrides (sparse — only non-default bindings stored)
|
||||
#[serde(default)]
|
||||
pub keybindings: KeybindingConfig,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
|
|
@ -69,6 +74,7 @@ impl Default for AppConfig {
|
|||
debug: defaults::debug(),
|
||||
waveform_stereo: defaults::waveform_stereo(),
|
||||
theme_mode: defaults::theme_mode(),
|
||||
keybindings: KeybindingConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -747,6 +747,7 @@ pub fn render_frame_to_rgba_hdr(
|
|||
base_transform,
|
||||
image_cache,
|
||||
video_manager,
|
||||
None, // No webcam during export
|
||||
);
|
||||
|
||||
// Buffer specs for layer rendering
|
||||
|
|
@ -1132,6 +1133,7 @@ pub fn render_frame_to_gpu_rgba(
|
|||
base_transform,
|
||||
image_cache,
|
||||
video_manager,
|
||||
None, // No webcam during export
|
||||
);
|
||||
|
||||
// Buffer specs for layer rendering
|
||||
|
|
|
|||
|
|
@ -0,0 +1,615 @@
|
|||
//! Remappable keyboard shortcuts system
|
||||
//!
|
||||
//! Provides a unified `AppAction` enum for all bindable actions, a `KeymapManager`
|
||||
//! for runtime shortcut lookup, and `KeybindingConfig` for persistent storage of
|
||||
//! user overrides.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use eframe::egui;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::menu::{MenuAction, Shortcut, ShortcutKey};
|
||||
|
||||
/// Unified enum of every bindable action in the application.
|
||||
///
|
||||
/// Excludes virtual piano keys (keyboard-layout-dependent, not user-preference shortcuts).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum AppAction {
|
||||
// === File menu ===
|
||||
NewFile,
|
||||
NewWindow,
|
||||
Save,
|
||||
SaveAs,
|
||||
OpenFile,
|
||||
Revert,
|
||||
Import,
|
||||
ImportToLibrary,
|
||||
Export,
|
||||
Quit,
|
||||
|
||||
// === Edit menu ===
|
||||
Undo,
|
||||
Redo,
|
||||
Cut,
|
||||
Copy,
|
||||
Paste,
|
||||
Delete,
|
||||
SelectAll,
|
||||
SelectNone,
|
||||
Preferences,
|
||||
|
||||
// === Modify menu ===
|
||||
Group,
|
||||
ConvertToMovieClip,
|
||||
SendToBack,
|
||||
BringToFront,
|
||||
SplitClip,
|
||||
DuplicateClip,
|
||||
|
||||
// === Layer menu ===
|
||||
AddLayer,
|
||||
AddVideoLayer,
|
||||
AddAudioTrack,
|
||||
AddMidiTrack,
|
||||
AddTestClip,
|
||||
DeleteLayer,
|
||||
ToggleLayerVisibility,
|
||||
|
||||
// === Timeline menu ===
|
||||
NewKeyframe,
|
||||
NewBlankKeyframe,
|
||||
DeleteFrame,
|
||||
DuplicateKeyframe,
|
||||
AddKeyframeAtPlayhead,
|
||||
AddMotionTween,
|
||||
AddShapeTween,
|
||||
ReturnToStart,
|
||||
Play,
|
||||
|
||||
// === View menu ===
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
ActualSize,
|
||||
RecenterView,
|
||||
NextLayout,
|
||||
PreviousLayout,
|
||||
|
||||
// === Help ===
|
||||
About,
|
||||
|
||||
// === macOS / Window ===
|
||||
Settings,
|
||||
CloseWindow,
|
||||
|
||||
// === Tool shortcuts (no modifiers) ===
|
||||
ToolSelect,
|
||||
ToolDraw,
|
||||
ToolTransform,
|
||||
ToolRectangle,
|
||||
ToolEllipse,
|
||||
ToolPaintBucket,
|
||||
ToolEyedropper,
|
||||
ToolLine,
|
||||
ToolPolygon,
|
||||
ToolBezierEdit,
|
||||
ToolText,
|
||||
ToolRegionSelect,
|
||||
|
||||
// === Global shortcuts ===
|
||||
TogglePlayPause,
|
||||
CancelAction,
|
||||
ToggleDebugOverlay,
|
||||
#[cfg(debug_assertions)]
|
||||
ToggleTestMode,
|
||||
|
||||
// === Pane-local shortcuts ===
|
||||
PianoRollDelete,
|
||||
StageDelete,
|
||||
NodeGraphGroup,
|
||||
NodeGraphUngroup,
|
||||
NodeGraphRename,
|
||||
}
|
||||
|
||||
impl AppAction {
|
||||
/// Category name for grouping in the preferences UI
|
||||
pub fn category(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NewFile | Self::NewWindow | Self::Save | Self::SaveAs |
|
||||
Self::OpenFile | Self::Revert | Self::Import | Self::ImportToLibrary |
|
||||
Self::Export | Self::Quit => "File",
|
||||
|
||||
Self::Undo | Self::Redo | Self::Cut | Self::Copy | Self::Paste |
|
||||
Self::Delete | Self::SelectAll | Self::SelectNone | Self::Preferences => "Edit",
|
||||
|
||||
Self::Group | Self::ConvertToMovieClip | Self::SendToBack |
|
||||
Self::BringToFront | Self::SplitClip | Self::DuplicateClip => "Modify",
|
||||
|
||||
Self::AddLayer | Self::AddVideoLayer | Self::AddAudioTrack |
|
||||
Self::AddMidiTrack | Self::AddTestClip | Self::DeleteLayer |
|
||||
Self::ToggleLayerVisibility => "Layer",
|
||||
|
||||
Self::NewKeyframe | Self::NewBlankKeyframe | Self::DeleteFrame |
|
||||
Self::DuplicateKeyframe | Self::AddKeyframeAtPlayhead |
|
||||
Self::AddMotionTween | Self::AddShapeTween |
|
||||
Self::ReturnToStart | Self::Play => "Timeline",
|
||||
|
||||
Self::ZoomIn | Self::ZoomOut | Self::ActualSize | Self::RecenterView |
|
||||
Self::NextLayout | Self::PreviousLayout => "View",
|
||||
|
||||
Self::About => "Help",
|
||||
Self::Settings | Self::CloseWindow => "Window",
|
||||
|
||||
Self::ToolSelect | Self::ToolDraw | Self::ToolTransform |
|
||||
Self::ToolRectangle | Self::ToolEllipse | Self::ToolPaintBucket |
|
||||
Self::ToolEyedropper | Self::ToolLine | Self::ToolPolygon |
|
||||
Self::ToolBezierEdit | Self::ToolText | Self::ToolRegionSelect => "Tools",
|
||||
|
||||
Self::TogglePlayPause | Self::CancelAction |
|
||||
Self::ToggleDebugOverlay => "Global",
|
||||
#[cfg(debug_assertions)]
|
||||
Self::ToggleTestMode => "Global",
|
||||
|
||||
Self::PianoRollDelete | Self::StageDelete |
|
||||
Self::NodeGraphGroup | Self::NodeGraphUngroup |
|
||||
Self::NodeGraphRename => "Pane",
|
||||
}
|
||||
}
|
||||
|
||||
/// Conflict scope: actions can only conflict with other actions in the same scope.
|
||||
/// Pane-local actions each get their own scope (they're isolated to their pane),
|
||||
/// everything else shares the "global" scope.
|
||||
pub fn conflict_scope(&self) -> &'static str {
|
||||
match self {
|
||||
Self::PianoRollDelete => "pane:piano_roll",
|
||||
Self::StageDelete => "pane:stage",
|
||||
Self::NodeGraphGroup | Self::NodeGraphUngroup |
|
||||
Self::NodeGraphRename => "pane:node_graph",
|
||||
_ => "global",
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable display name for the preferences UI
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NewFile => "New File",
|
||||
Self::NewWindow => "New Window",
|
||||
Self::Save => "Save",
|
||||
Self::SaveAs => "Save As",
|
||||
Self::OpenFile => "Open File",
|
||||
Self::Revert => "Revert",
|
||||
Self::Import => "Import",
|
||||
Self::ImportToLibrary => "Import to Library",
|
||||
Self::Export => "Export",
|
||||
Self::Quit => "Quit",
|
||||
Self::Undo => "Undo",
|
||||
Self::Redo => "Redo",
|
||||
Self::Cut => "Cut",
|
||||
Self::Copy => "Copy",
|
||||
Self::Paste => "Paste",
|
||||
Self::Delete => "Delete",
|
||||
Self::SelectAll => "Select All",
|
||||
Self::SelectNone => "Select None",
|
||||
Self::Preferences => "Preferences",
|
||||
Self::Group => "Group",
|
||||
Self::ConvertToMovieClip => "Convert to Movie Clip",
|
||||
Self::SendToBack => "Send to Back",
|
||||
Self::BringToFront => "Bring to Front",
|
||||
Self::SplitClip => "Split Clip",
|
||||
Self::DuplicateClip => "Duplicate Clip",
|
||||
Self::AddLayer => "Add Layer",
|
||||
Self::AddVideoLayer => "Add Video Layer",
|
||||
Self::AddAudioTrack => "Add Audio Track",
|
||||
Self::AddMidiTrack => "Add MIDI Track",
|
||||
Self::AddTestClip => "Add Test Clip",
|
||||
Self::DeleteLayer => "Delete Layer",
|
||||
Self::ToggleLayerVisibility => "Toggle Layer Visibility",
|
||||
Self::NewKeyframe => "New Keyframe",
|
||||
Self::NewBlankKeyframe => "New Blank Keyframe",
|
||||
Self::DeleteFrame => "Delete Frame",
|
||||
Self::DuplicateKeyframe => "Duplicate Keyframe",
|
||||
Self::AddKeyframeAtPlayhead => "Add Keyframe at Playhead",
|
||||
Self::AddMotionTween => "Add Motion Tween",
|
||||
Self::AddShapeTween => "Add Shape Tween",
|
||||
Self::ReturnToStart => "Return to Start",
|
||||
Self::Play => "Play",
|
||||
Self::ZoomIn => "Zoom In",
|
||||
Self::ZoomOut => "Zoom Out",
|
||||
Self::ActualSize => "Actual Size",
|
||||
Self::RecenterView => "Recenter View",
|
||||
Self::NextLayout => "Next Layout",
|
||||
Self::PreviousLayout => "Previous Layout",
|
||||
Self::About => "About",
|
||||
Self::Settings => "Settings",
|
||||
Self::CloseWindow => "Close Window",
|
||||
Self::ToolSelect => "Select Tool",
|
||||
Self::ToolDraw => "Draw Tool",
|
||||
Self::ToolTransform => "Transform Tool",
|
||||
Self::ToolRectangle => "Rectangle Tool",
|
||||
Self::ToolEllipse => "Ellipse Tool",
|
||||
Self::ToolPaintBucket => "Paint Bucket Tool",
|
||||
Self::ToolEyedropper => "Eyedropper Tool",
|
||||
Self::ToolLine => "Line Tool",
|
||||
Self::ToolPolygon => "Polygon Tool",
|
||||
Self::ToolBezierEdit => "Bezier Edit Tool",
|
||||
Self::ToolText => "Text Tool",
|
||||
Self::ToolRegionSelect => "Region Select Tool",
|
||||
Self::TogglePlayPause => "Toggle Play/Pause",
|
||||
Self::CancelAction => "Cancel / Escape",
|
||||
Self::ToggleDebugOverlay => "Toggle Debug Overlay",
|
||||
#[cfg(debug_assertions)]
|
||||
Self::ToggleTestMode => "Toggle Test Mode",
|
||||
Self::PianoRollDelete => "Piano Roll: Delete",
|
||||
Self::StageDelete => "Stage: Delete",
|
||||
Self::NodeGraphGroup => "Node Graph: Group",
|
||||
Self::NodeGraphUngroup => "Node Graph: Ungroup",
|
||||
Self::NodeGraphRename => "Node Graph: Rename",
|
||||
}
|
||||
}
|
||||
|
||||
/// All action variants (for iteration)
|
||||
pub fn all() -> &'static [AppAction] {
|
||||
&[
|
||||
Self::NewFile, Self::NewWindow, Self::Save, Self::SaveAs,
|
||||
Self::OpenFile, Self::Revert, Self::Import, Self::ImportToLibrary,
|
||||
Self::Export, Self::Quit,
|
||||
Self::Undo, Self::Redo, Self::Cut, Self::Copy, Self::Paste,
|
||||
Self::Delete, Self::SelectAll, Self::SelectNone, Self::Preferences,
|
||||
Self::Group, Self::ConvertToMovieClip, Self::SendToBack,
|
||||
Self::BringToFront, Self::SplitClip, Self::DuplicateClip,
|
||||
Self::AddLayer, Self::AddVideoLayer, Self::AddAudioTrack,
|
||||
Self::AddMidiTrack, Self::AddTestClip, Self::DeleteLayer,
|
||||
Self::ToggleLayerVisibility,
|
||||
Self::NewKeyframe, Self::NewBlankKeyframe, Self::DeleteFrame,
|
||||
Self::DuplicateKeyframe, Self::AddKeyframeAtPlayhead,
|
||||
Self::AddMotionTween, Self::AddShapeTween,
|
||||
Self::ReturnToStart, Self::Play,
|
||||
Self::ZoomIn, Self::ZoomOut, Self::ActualSize, Self::RecenterView,
|
||||
Self::NextLayout, Self::PreviousLayout,
|
||||
Self::About, Self::Settings, Self::CloseWindow,
|
||||
Self::ToolSelect, Self::ToolDraw, Self::ToolTransform,
|
||||
Self::ToolRectangle, Self::ToolEllipse, Self::ToolPaintBucket,
|
||||
Self::ToolEyedropper, Self::ToolLine, Self::ToolPolygon,
|
||||
Self::ToolBezierEdit, Self::ToolText, Self::ToolRegionSelect,
|
||||
Self::TogglePlayPause, Self::CancelAction, Self::ToggleDebugOverlay,
|
||||
#[cfg(debug_assertions)]
|
||||
Self::ToggleTestMode,
|
||||
Self::PianoRollDelete, Self::StageDelete,
|
||||
Self::NodeGraphGroup, Self::NodeGraphUngroup, Self::NodeGraphRename,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// === Conversions between MenuAction and AppAction ===
|
||||
|
||||
impl From<MenuAction> for AppAction {
|
||||
fn from(action: MenuAction) -> Self {
|
||||
match action {
|
||||
MenuAction::NewFile => Self::NewFile,
|
||||
MenuAction::NewWindow => Self::NewWindow,
|
||||
MenuAction::Save => Self::Save,
|
||||
MenuAction::SaveAs => Self::SaveAs,
|
||||
MenuAction::OpenFile => Self::OpenFile,
|
||||
MenuAction::OpenRecent(_) => Self::OpenFile, // not directly mappable
|
||||
MenuAction::ClearRecentFiles => Self::OpenFile, // not directly mappable
|
||||
MenuAction::Revert => Self::Revert,
|
||||
MenuAction::Import => Self::Import,
|
||||
MenuAction::ImportToLibrary => Self::ImportToLibrary,
|
||||
MenuAction::Export => Self::Export,
|
||||
MenuAction::Quit => Self::Quit,
|
||||
MenuAction::Undo => Self::Undo,
|
||||
MenuAction::Redo => Self::Redo,
|
||||
MenuAction::Cut => Self::Cut,
|
||||
MenuAction::Copy => Self::Copy,
|
||||
MenuAction::Paste => Self::Paste,
|
||||
MenuAction::Delete => Self::Delete,
|
||||
MenuAction::SelectAll => Self::SelectAll,
|
||||
MenuAction::SelectNone => Self::SelectNone,
|
||||
MenuAction::Preferences => Self::Preferences,
|
||||
MenuAction::Group => Self::Group,
|
||||
MenuAction::ConvertToMovieClip => Self::ConvertToMovieClip,
|
||||
MenuAction::SendToBack => Self::SendToBack,
|
||||
MenuAction::BringToFront => Self::BringToFront,
|
||||
MenuAction::SplitClip => Self::SplitClip,
|
||||
MenuAction::DuplicateClip => Self::DuplicateClip,
|
||||
MenuAction::AddLayer => Self::AddLayer,
|
||||
MenuAction::AddVideoLayer => Self::AddVideoLayer,
|
||||
MenuAction::AddAudioTrack => Self::AddAudioTrack,
|
||||
MenuAction::AddMidiTrack => Self::AddMidiTrack,
|
||||
MenuAction::AddTestClip => Self::AddTestClip,
|
||||
MenuAction::DeleteLayer => Self::DeleteLayer,
|
||||
MenuAction::ToggleLayerVisibility => Self::ToggleLayerVisibility,
|
||||
MenuAction::NewKeyframe => Self::NewKeyframe,
|
||||
MenuAction::NewBlankKeyframe => Self::NewBlankKeyframe,
|
||||
MenuAction::DeleteFrame => Self::DeleteFrame,
|
||||
MenuAction::DuplicateKeyframe => Self::DuplicateKeyframe,
|
||||
MenuAction::AddKeyframeAtPlayhead => Self::AddKeyframeAtPlayhead,
|
||||
MenuAction::AddMotionTween => Self::AddMotionTween,
|
||||
MenuAction::AddShapeTween => Self::AddShapeTween,
|
||||
MenuAction::ReturnToStart => Self::ReturnToStart,
|
||||
MenuAction::Play => Self::Play,
|
||||
MenuAction::ZoomIn => Self::ZoomIn,
|
||||
MenuAction::ZoomOut => Self::ZoomOut,
|
||||
MenuAction::ActualSize => Self::ActualSize,
|
||||
MenuAction::RecenterView => Self::RecenterView,
|
||||
MenuAction::NextLayout => Self::NextLayout,
|
||||
MenuAction::PreviousLayout => Self::PreviousLayout,
|
||||
MenuAction::SwitchLayout(_) => Self::NextLayout, // not directly mappable
|
||||
MenuAction::About => Self::About,
|
||||
MenuAction::Settings => Self::Settings,
|
||||
MenuAction::CloseWindow => Self::CloseWindow,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<AppAction> for MenuAction {
|
||||
type Error = ();
|
||||
fn try_from(action: AppAction) -> Result<Self, ()> {
|
||||
Ok(match action {
|
||||
AppAction::NewFile => MenuAction::NewFile,
|
||||
AppAction::NewWindow => MenuAction::NewWindow,
|
||||
AppAction::Save => MenuAction::Save,
|
||||
AppAction::SaveAs => MenuAction::SaveAs,
|
||||
AppAction::OpenFile => MenuAction::OpenFile,
|
||||
AppAction::Revert => MenuAction::Revert,
|
||||
AppAction::Import => MenuAction::Import,
|
||||
AppAction::ImportToLibrary => MenuAction::ImportToLibrary,
|
||||
AppAction::Export => MenuAction::Export,
|
||||
AppAction::Quit => MenuAction::Quit,
|
||||
AppAction::Undo => MenuAction::Undo,
|
||||
AppAction::Redo => MenuAction::Redo,
|
||||
AppAction::Cut => MenuAction::Cut,
|
||||
AppAction::Copy => MenuAction::Copy,
|
||||
AppAction::Paste => MenuAction::Paste,
|
||||
AppAction::Delete => MenuAction::Delete,
|
||||
AppAction::SelectAll => MenuAction::SelectAll,
|
||||
AppAction::SelectNone => MenuAction::SelectNone,
|
||||
AppAction::Preferences => MenuAction::Preferences,
|
||||
AppAction::Group => MenuAction::Group,
|
||||
AppAction::ConvertToMovieClip => MenuAction::ConvertToMovieClip,
|
||||
AppAction::SendToBack => MenuAction::SendToBack,
|
||||
AppAction::BringToFront => MenuAction::BringToFront,
|
||||
AppAction::SplitClip => MenuAction::SplitClip,
|
||||
AppAction::DuplicateClip => MenuAction::DuplicateClip,
|
||||
AppAction::AddLayer => MenuAction::AddLayer,
|
||||
AppAction::AddVideoLayer => MenuAction::AddVideoLayer,
|
||||
AppAction::AddAudioTrack => MenuAction::AddAudioTrack,
|
||||
AppAction::AddMidiTrack => MenuAction::AddMidiTrack,
|
||||
AppAction::AddTestClip => MenuAction::AddTestClip,
|
||||
AppAction::DeleteLayer => MenuAction::DeleteLayer,
|
||||
AppAction::ToggleLayerVisibility => MenuAction::ToggleLayerVisibility,
|
||||
AppAction::NewKeyframe => MenuAction::NewKeyframe,
|
||||
AppAction::NewBlankKeyframe => MenuAction::NewBlankKeyframe,
|
||||
AppAction::DeleteFrame => MenuAction::DeleteFrame,
|
||||
AppAction::DuplicateKeyframe => MenuAction::DuplicateKeyframe,
|
||||
AppAction::AddKeyframeAtPlayhead => MenuAction::AddKeyframeAtPlayhead,
|
||||
AppAction::AddMotionTween => MenuAction::AddMotionTween,
|
||||
AppAction::AddShapeTween => MenuAction::AddShapeTween,
|
||||
AppAction::ReturnToStart => MenuAction::ReturnToStart,
|
||||
AppAction::Play => MenuAction::Play,
|
||||
AppAction::ZoomIn => MenuAction::ZoomIn,
|
||||
AppAction::ZoomOut => MenuAction::ZoomOut,
|
||||
AppAction::ActualSize => MenuAction::ActualSize,
|
||||
AppAction::RecenterView => MenuAction::RecenterView,
|
||||
AppAction::NextLayout => MenuAction::NextLayout,
|
||||
AppAction::PreviousLayout => MenuAction::PreviousLayout,
|
||||
AppAction::About => MenuAction::About,
|
||||
AppAction::Settings => MenuAction::Settings,
|
||||
AppAction::CloseWindow => MenuAction::CloseWindow,
|
||||
// Non-menu actions
|
||||
_ => return Err(()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Also need TryFrom<MenuAction> for AppAction (used in menu.rs check_shortcuts)
|
||||
impl AppAction {
|
||||
/// Try to convert from a MenuAction (fails for OpenRecent/ClearRecentFiles/SwitchLayout)
|
||||
pub fn try_from(action: MenuAction) -> Result<Self, ()> {
|
||||
match action {
|
||||
MenuAction::OpenRecent(_) | MenuAction::ClearRecentFiles | MenuAction::SwitchLayout(_) => Err(()),
|
||||
other => Ok(Self::from(other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Default bindings ===
|
||||
|
||||
/// Build the complete default bindings map from the current hardcoded shortcuts
|
||||
pub fn all_defaults() -> HashMap<AppAction, Option<Shortcut>> {
|
||||
use crate::menu::MenuItemDef;
|
||||
|
||||
let mut defaults = HashMap::new();
|
||||
|
||||
// Menu action defaults (from MenuItemDef constants)
|
||||
for def in MenuItemDef::all_with_shortcuts() {
|
||||
if let Ok(app_action) = AppAction::try_from(def.action) {
|
||||
defaults.insert(app_action, def.shortcut);
|
||||
}
|
||||
}
|
||||
|
||||
// Also add menu items without shortcuts
|
||||
let no_shortcut: &[AppAction] = &[
|
||||
AppAction::Revert, AppAction::Preferences, AppAction::ConvertToMovieClip,
|
||||
AppAction::SendToBack, AppAction::BringToFront,
|
||||
AppAction::AddVideoLayer, AppAction::AddAudioTrack, AppAction::AddMidiTrack,
|
||||
AppAction::AddTestClip, AppAction::DeleteLayer, AppAction::ToggleLayerVisibility,
|
||||
AppAction::NewBlankKeyframe, AppAction::DeleteFrame, AppAction::DuplicateKeyframe,
|
||||
AppAction::AddKeyframeAtPlayhead, AppAction::AddMotionTween, AppAction::AddShapeTween,
|
||||
AppAction::ReturnToStart, AppAction::Play, AppAction::RecenterView, AppAction::About,
|
||||
];
|
||||
for &action in no_shortcut {
|
||||
defaults.entry(action).or_insert(None);
|
||||
}
|
||||
|
||||
// Tool shortcuts (bare keys, no modifiers)
|
||||
let nc = false;
|
||||
let ns = false;
|
||||
let na = false;
|
||||
defaults.insert(AppAction::ToolSelect, Some(Shortcut::new(ShortcutKey::V, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolDraw, Some(Shortcut::new(ShortcutKey::P, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolTransform, Some(Shortcut::new(ShortcutKey::Q, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolRectangle, Some(Shortcut::new(ShortcutKey::R, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolEllipse, Some(Shortcut::new(ShortcutKey::E, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolPaintBucket, Some(Shortcut::new(ShortcutKey::B, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolEyedropper, Some(Shortcut::new(ShortcutKey::I, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolLine, Some(Shortcut::new(ShortcutKey::L, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolPolygon, Some(Shortcut::new(ShortcutKey::G, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolBezierEdit, Some(Shortcut::new(ShortcutKey::A, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolText, Some(Shortcut::new(ShortcutKey::T, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolRegionSelect, Some(Shortcut::new(ShortcutKey::S, nc, ns, na)));
|
||||
|
||||
// Global shortcuts
|
||||
defaults.insert(AppAction::TogglePlayPause, Some(Shortcut::new(ShortcutKey::Space, nc, ns, na)));
|
||||
defaults.insert(AppAction::CancelAction, Some(Shortcut::new(ShortcutKey::Escape, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToggleDebugOverlay, Some(Shortcut::new(ShortcutKey::F3, nc, ns, na)));
|
||||
#[cfg(debug_assertions)]
|
||||
defaults.insert(AppAction::ToggleTestMode, Some(Shortcut::new(ShortcutKey::F5, nc, ns, na)));
|
||||
|
||||
// Pane-local shortcuts
|
||||
defaults.insert(AppAction::PianoRollDelete, Some(Shortcut::new(ShortcutKey::Delete, nc, ns, na)));
|
||||
defaults.insert(AppAction::StageDelete, Some(Shortcut::new(ShortcutKey::Delete, nc, ns, na)));
|
||||
defaults.insert(AppAction::NodeGraphGroup, Some(Shortcut::new(ShortcutKey::G, true, ns, na)));
|
||||
defaults.insert(AppAction::NodeGraphUngroup, Some(Shortcut::new(ShortcutKey::G, true, true, na)));
|
||||
defaults.insert(AppAction::NodeGraphRename, Some(Shortcut::new(ShortcutKey::F2, nc, ns, na)));
|
||||
|
||||
defaults
|
||||
}
|
||||
|
||||
// === KeybindingConfig (persisted in AppConfig) ===
|
||||
|
||||
/// Sparse override map: only stores non-default bindings.
|
||||
/// `None` value means "unbound" (user explicitly cleared the binding).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct KeybindingConfig {
|
||||
#[serde(default)]
|
||||
pub overrides: HashMap<AppAction, Option<Shortcut>>,
|
||||
}
|
||||
|
||||
impl KeybindingConfig {
|
||||
/// Compute effective bindings by merging defaults with overrides
|
||||
pub fn effective_bindings(&self) -> HashMap<AppAction, Option<Shortcut>> {
|
||||
let mut bindings = all_defaults();
|
||||
for (action, shortcut) in &self.overrides {
|
||||
bindings.insert(*action, *shortcut);
|
||||
}
|
||||
bindings
|
||||
}
|
||||
|
||||
/// Reset all overrides (revert to defaults)
|
||||
#[allow(dead_code)]
|
||||
pub fn reset(&mut self) {
|
||||
self.overrides.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// === KeymapManager (runtime lookup) ===
|
||||
|
||||
/// Runtime shortcut lookup table, built from KeybindingConfig.
|
||||
/// Consulted everywhere shortcuts are checked.
|
||||
pub struct KeymapManager {
|
||||
/// action -> shortcut (None means unbound)
|
||||
bindings: HashMap<AppAction, Option<Shortcut>>,
|
||||
/// Reverse lookup: shortcut -> list of actions (for conflict detection)
|
||||
#[allow(dead_code)]
|
||||
reverse: HashMap<Shortcut, Vec<AppAction>>,
|
||||
}
|
||||
|
||||
impl KeymapManager {
|
||||
/// Build from a KeybindingConfig
|
||||
pub fn new(config: &KeybindingConfig) -> Self {
|
||||
let bindings = config.effective_bindings();
|
||||
let mut reverse: HashMap<Shortcut, Vec<AppAction>> = HashMap::new();
|
||||
for (&action, shortcut) in &bindings {
|
||||
if let Some(s) = shortcut {
|
||||
reverse.entry(*s).or_default().push(action);
|
||||
}
|
||||
}
|
||||
Self { bindings, reverse }
|
||||
}
|
||||
|
||||
/// Get the shortcut bound to an action (None = unbound)
|
||||
pub fn get(&self, action: AppAction) -> Option<Shortcut> {
|
||||
self.bindings.get(&action).copied().flatten()
|
||||
}
|
||||
|
||||
/// Check if the shortcut for an action was pressed this frame
|
||||
pub fn action_pressed(&self, action: AppAction, input: &egui::InputState) -> bool {
|
||||
if let Some(shortcut) = self.get(action) {
|
||||
shortcut.matches_egui_input(input)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the action was pressed, also accepting Backspace as alias for Delete
|
||||
pub fn action_pressed_with_backspace(&self, action: AppAction, input: &egui::InputState) -> bool {
|
||||
if self.action_pressed(action, input) {
|
||||
return true;
|
||||
}
|
||||
// Also check Backspace as a secondary trigger for delete-like actions
|
||||
if let Some(shortcut) = self.get(action) {
|
||||
if shortcut.key == ShortcutKey::Delete {
|
||||
let backspace_shortcut = Shortcut::new(ShortcutKey::Backspace, shortcut.ctrl, shortcut.shift, shortcut.alt);
|
||||
return backspace_shortcut.matches_egui_input(input);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find all conflicts (two+ actions in the same scope sharing the same shortcut).
|
||||
/// Pane-local actions are scoped to their pane and can't conflict across panes.
|
||||
#[allow(dead_code)]
|
||||
pub fn conflicts(&self) -> Vec<(AppAction, AppAction, Shortcut)> {
|
||||
// Group by (scope, shortcut)
|
||||
let mut by_scope: HashMap<(&str, Shortcut), Vec<AppAction>> = HashMap::new();
|
||||
for (shortcut, actions) in &self.reverse {
|
||||
for &action in actions {
|
||||
by_scope.entry((action.conflict_scope(), *shortcut)).or_default().push(action);
|
||||
}
|
||||
}
|
||||
let mut conflicts = Vec::new();
|
||||
for ((_, shortcut), actions) in &by_scope {
|
||||
if actions.len() > 1 {
|
||||
for i in 0..actions.len() {
|
||||
for j in (i + 1)..actions.len() {
|
||||
conflicts.push((actions[i], actions[j], *shortcut));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
conflicts
|
||||
}
|
||||
|
||||
/// Set a binding for live editing (used in preferences dialog).
|
||||
/// Does NOT persist — call `to_config()` to get the persistable form.
|
||||
#[allow(dead_code)]
|
||||
pub fn set_binding(&mut self, action: AppAction, shortcut: Option<Shortcut>) {
|
||||
// Remove old reverse entry
|
||||
if let Some(old) = self.bindings.get(&action).copied().flatten() {
|
||||
if let Some(actions) = self.reverse.get_mut(&old) {
|
||||
actions.retain(|a| *a != action);
|
||||
if actions.is_empty() {
|
||||
self.reverse.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Set new binding
|
||||
self.bindings.insert(action, shortcut);
|
||||
if let Some(s) = shortcut {
|
||||
self.reverse.entry(s).or_default().push(action);
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert current state to a sparse config (only non-default entries)
|
||||
#[allow(dead_code)]
|
||||
pub fn to_config(&self) -> KeybindingConfig {
|
||||
let defaults = all_defaults();
|
||||
let mut overrides = HashMap::new();
|
||||
for (&action, &shortcut) in &self.bindings {
|
||||
let default = defaults.get(&action).copied().flatten();
|
||||
if shortcut != default {
|
||||
overrides.insert(action, shortcut);
|
||||
}
|
||||
}
|
||||
KeybindingConfig { overrides }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -13,7 +13,7 @@ use muda::{
|
|||
};
|
||||
|
||||
/// Keyboard shortcut definition
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Shortcut {
|
||||
pub key: ShortcutKey,
|
||||
pub ctrl: bool,
|
||||
|
|
@ -22,19 +22,67 @@ pub struct Shortcut {
|
|||
}
|
||||
|
||||
/// Keys that can be used in shortcuts
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ShortcutKey {
|
||||
// Letters
|
||||
A, C, D, E, G, I, K, L, N, O, Q, S, V, W, X, Z,
|
||||
// Numbers
|
||||
Num0,
|
||||
A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z,
|
||||
// Digits
|
||||
Num0, Num1, Num2, Num3, Num4, Num5, Num6, Num7, Num8, Num9,
|
||||
// Function keys
|
||||
F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12,
|
||||
// Arrow keys
|
||||
ArrowUp, ArrowDown, ArrowLeft, ArrowRight,
|
||||
// Symbols
|
||||
Comma, Minus, Equals,
|
||||
#[allow(dead_code)] // Completes keyboard mapping set
|
||||
Plus,
|
||||
BracketLeft, BracketRight,
|
||||
Semicolon, Quote, Period, Slash, Backtick,
|
||||
// Special
|
||||
Delete,
|
||||
Space, Escape, Enter, Tab, Backspace, Delete,
|
||||
Home, End, PageUp, PageDown,
|
||||
}
|
||||
|
||||
impl ShortcutKey {
|
||||
/// Try to convert an egui Key to a ShortcutKey
|
||||
pub fn from_egui_key(key: egui::Key) -> Option<Self> {
|
||||
Some(match key {
|
||||
egui::Key::A => Self::A, egui::Key::B => Self::B, egui::Key::C => Self::C,
|
||||
egui::Key::D => Self::D, egui::Key::E => Self::E, egui::Key::F => Self::F,
|
||||
egui::Key::G => Self::G, egui::Key::H => Self::H, egui::Key::I => Self::I,
|
||||
egui::Key::J => Self::J, egui::Key::K => Self::K, egui::Key::L => Self::L,
|
||||
egui::Key::M => Self::M, egui::Key::N => Self::N, egui::Key::O => Self::O,
|
||||
egui::Key::P => Self::P, egui::Key::Q => Self::Q, egui::Key::R => Self::R,
|
||||
egui::Key::S => Self::S, egui::Key::T => Self::T, egui::Key::U => Self::U,
|
||||
egui::Key::V => Self::V, egui::Key::W => Self::W, egui::Key::X => Self::X,
|
||||
egui::Key::Y => Self::Y, egui::Key::Z => Self::Z,
|
||||
egui::Key::Num0 => Self::Num0, egui::Key::Num1 => Self::Num1,
|
||||
egui::Key::Num2 => Self::Num2, egui::Key::Num3 => Self::Num3,
|
||||
egui::Key::Num4 => Self::Num4, egui::Key::Num5 => Self::Num5,
|
||||
egui::Key::Num6 => Self::Num6, egui::Key::Num7 => Self::Num7,
|
||||
egui::Key::Num8 => Self::Num8, egui::Key::Num9 => Self::Num9,
|
||||
egui::Key::F1 => Self::F1, egui::Key::F2 => Self::F2,
|
||||
egui::Key::F3 => Self::F3, egui::Key::F4 => Self::F4,
|
||||
egui::Key::F5 => Self::F5, egui::Key::F6 => Self::F6,
|
||||
egui::Key::F7 => Self::F7, egui::Key::F8 => Self::F8,
|
||||
egui::Key::F9 => Self::F9, egui::Key::F10 => Self::F10,
|
||||
egui::Key::F11 => Self::F11, egui::Key::F12 => Self::F12,
|
||||
egui::Key::ArrowUp => Self::ArrowUp, egui::Key::ArrowDown => Self::ArrowDown,
|
||||
egui::Key::ArrowLeft => Self::ArrowLeft, egui::Key::ArrowRight => Self::ArrowRight,
|
||||
egui::Key::Comma => Self::Comma, egui::Key::Minus => Self::Minus,
|
||||
egui::Key::Equals => Self::Equals, egui::Key::Plus => Self::Plus,
|
||||
egui::Key::OpenBracket => Self::BracketLeft, egui::Key::CloseBracket => Self::BracketRight,
|
||||
egui::Key::Semicolon => Self::Semicolon, egui::Key::Quote => Self::Quote,
|
||||
egui::Key::Period => Self::Period, egui::Key::Slash => Self::Slash,
|
||||
egui::Key::Backtick => Self::Backtick,
|
||||
egui::Key::Space => Self::Space, egui::Key::Escape => Self::Escape,
|
||||
egui::Key::Enter => Self::Enter, egui::Key::Tab => Self::Tab,
|
||||
egui::Key::Backspace => Self::Backspace, egui::Key::Delete => Self::Delete,
|
||||
egui::Key::Home => Self::Home, egui::Key::End => Self::End,
|
||||
egui::Key::PageUp => Self::PageUp, egui::Key::PageDown => Self::PageDown,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Shortcut {
|
||||
|
|
@ -60,29 +108,78 @@ impl Shortcut {
|
|||
|
||||
let code = match self.key {
|
||||
ShortcutKey::A => Code::KeyA,
|
||||
ShortcutKey::B => Code::KeyB,
|
||||
ShortcutKey::C => Code::KeyC,
|
||||
ShortcutKey::D => Code::KeyD,
|
||||
ShortcutKey::E => Code::KeyE,
|
||||
ShortcutKey::F => Code::KeyF,
|
||||
ShortcutKey::G => Code::KeyG,
|
||||
ShortcutKey::H => Code::KeyH,
|
||||
ShortcutKey::I => Code::KeyI,
|
||||
ShortcutKey::J => Code::KeyJ,
|
||||
ShortcutKey::K => Code::KeyK,
|
||||
ShortcutKey::L => Code::KeyL,
|
||||
ShortcutKey::M => Code::KeyM,
|
||||
ShortcutKey::N => Code::KeyN,
|
||||
ShortcutKey::O => Code::KeyO,
|
||||
ShortcutKey::P => Code::KeyP,
|
||||
ShortcutKey::Q => Code::KeyQ,
|
||||
ShortcutKey::R => Code::KeyR,
|
||||
ShortcutKey::S => Code::KeyS,
|
||||
ShortcutKey::T => Code::KeyT,
|
||||
ShortcutKey::U => Code::KeyU,
|
||||
ShortcutKey::V => Code::KeyV,
|
||||
ShortcutKey::W => Code::KeyW,
|
||||
ShortcutKey::X => Code::KeyX,
|
||||
ShortcutKey::Y => Code::KeyY,
|
||||
ShortcutKey::Z => Code::KeyZ,
|
||||
ShortcutKey::Num0 => Code::Digit0,
|
||||
ShortcutKey::Num1 => Code::Digit1,
|
||||
ShortcutKey::Num2 => Code::Digit2,
|
||||
ShortcutKey::Num3 => Code::Digit3,
|
||||
ShortcutKey::Num4 => Code::Digit4,
|
||||
ShortcutKey::Num5 => Code::Digit5,
|
||||
ShortcutKey::Num6 => Code::Digit6,
|
||||
ShortcutKey::Num7 => Code::Digit7,
|
||||
ShortcutKey::Num8 => Code::Digit8,
|
||||
ShortcutKey::Num9 => Code::Digit9,
|
||||
ShortcutKey::F1 => Code::F1,
|
||||
ShortcutKey::F2 => Code::F2,
|
||||
ShortcutKey::F3 => Code::F3,
|
||||
ShortcutKey::F4 => Code::F4,
|
||||
ShortcutKey::F5 => Code::F5,
|
||||
ShortcutKey::F6 => Code::F6,
|
||||
ShortcutKey::F7 => Code::F7,
|
||||
ShortcutKey::F8 => Code::F8,
|
||||
ShortcutKey::F9 => Code::F9,
|
||||
ShortcutKey::F10 => Code::F10,
|
||||
ShortcutKey::F11 => Code::F11,
|
||||
ShortcutKey::F12 => Code::F12,
|
||||
ShortcutKey::ArrowUp => Code::ArrowUp,
|
||||
ShortcutKey::ArrowDown => Code::ArrowDown,
|
||||
ShortcutKey::ArrowLeft => Code::ArrowLeft,
|
||||
ShortcutKey::ArrowRight => Code::ArrowRight,
|
||||
ShortcutKey::Comma => Code::Comma,
|
||||
ShortcutKey::Minus => Code::Minus,
|
||||
ShortcutKey::Equals => Code::Equal,
|
||||
ShortcutKey::Plus => Code::Equal, // Same key as equals
|
||||
ShortcutKey::BracketLeft => Code::BracketLeft,
|
||||
ShortcutKey::BracketRight => Code::BracketRight,
|
||||
ShortcutKey::Semicolon => Code::Semicolon,
|
||||
ShortcutKey::Quote => Code::Quote,
|
||||
ShortcutKey::Period => Code::Period,
|
||||
ShortcutKey::Slash => Code::Slash,
|
||||
ShortcutKey::Backtick => Code::Backquote,
|
||||
ShortcutKey::Space => Code::Space,
|
||||
ShortcutKey::Escape => Code::Escape,
|
||||
ShortcutKey::Enter => Code::Enter,
|
||||
ShortcutKey::Tab => Code::Tab,
|
||||
ShortcutKey::Backspace => Code::Backspace,
|
||||
ShortcutKey::Delete => Code::Delete,
|
||||
ShortcutKey::Home => Code::Home,
|
||||
ShortcutKey::End => Code::End,
|
||||
ShortcutKey::PageUp => Code::PageUp,
|
||||
ShortcutKey::PageDown => Code::PageDown,
|
||||
};
|
||||
|
||||
Accelerator::new(if modifiers.is_empty() { None } else { Some(modifiers) }, code)
|
||||
|
|
@ -104,29 +201,78 @@ impl Shortcut {
|
|||
// Check key
|
||||
let key = match self.key {
|
||||
ShortcutKey::A => egui::Key::A,
|
||||
ShortcutKey::B => egui::Key::B,
|
||||
ShortcutKey::C => egui::Key::C,
|
||||
ShortcutKey::D => egui::Key::D,
|
||||
ShortcutKey::E => egui::Key::E,
|
||||
ShortcutKey::F => egui::Key::F,
|
||||
ShortcutKey::G => egui::Key::G,
|
||||
ShortcutKey::H => egui::Key::H,
|
||||
ShortcutKey::I => egui::Key::I,
|
||||
ShortcutKey::J => egui::Key::J,
|
||||
ShortcutKey::K => egui::Key::K,
|
||||
ShortcutKey::L => egui::Key::L,
|
||||
ShortcutKey::M => egui::Key::M,
|
||||
ShortcutKey::N => egui::Key::N,
|
||||
ShortcutKey::O => egui::Key::O,
|
||||
ShortcutKey::P => egui::Key::P,
|
||||
ShortcutKey::Q => egui::Key::Q,
|
||||
ShortcutKey::R => egui::Key::R,
|
||||
ShortcutKey::S => egui::Key::S,
|
||||
ShortcutKey::T => egui::Key::T,
|
||||
ShortcutKey::U => egui::Key::U,
|
||||
ShortcutKey::V => egui::Key::V,
|
||||
ShortcutKey::W => egui::Key::W,
|
||||
ShortcutKey::X => egui::Key::X,
|
||||
ShortcutKey::Y => egui::Key::Y,
|
||||
ShortcutKey::Z => egui::Key::Z,
|
||||
ShortcutKey::Num0 => egui::Key::Num0,
|
||||
ShortcutKey::Num1 => egui::Key::Num1,
|
||||
ShortcutKey::Num2 => egui::Key::Num2,
|
||||
ShortcutKey::Num3 => egui::Key::Num3,
|
||||
ShortcutKey::Num4 => egui::Key::Num4,
|
||||
ShortcutKey::Num5 => egui::Key::Num5,
|
||||
ShortcutKey::Num6 => egui::Key::Num6,
|
||||
ShortcutKey::Num7 => egui::Key::Num7,
|
||||
ShortcutKey::Num8 => egui::Key::Num8,
|
||||
ShortcutKey::Num9 => egui::Key::Num9,
|
||||
ShortcutKey::F1 => egui::Key::F1,
|
||||
ShortcutKey::F2 => egui::Key::F2,
|
||||
ShortcutKey::F3 => egui::Key::F3,
|
||||
ShortcutKey::F4 => egui::Key::F4,
|
||||
ShortcutKey::F5 => egui::Key::F5,
|
||||
ShortcutKey::F6 => egui::Key::F6,
|
||||
ShortcutKey::F7 => egui::Key::F7,
|
||||
ShortcutKey::F8 => egui::Key::F8,
|
||||
ShortcutKey::F9 => egui::Key::F9,
|
||||
ShortcutKey::F10 => egui::Key::F10,
|
||||
ShortcutKey::F11 => egui::Key::F11,
|
||||
ShortcutKey::F12 => egui::Key::F12,
|
||||
ShortcutKey::ArrowUp => egui::Key::ArrowUp,
|
||||
ShortcutKey::ArrowDown => egui::Key::ArrowDown,
|
||||
ShortcutKey::ArrowLeft => egui::Key::ArrowLeft,
|
||||
ShortcutKey::ArrowRight => egui::Key::ArrowRight,
|
||||
ShortcutKey::Comma => egui::Key::Comma,
|
||||
ShortcutKey::Minus => egui::Key::Minus,
|
||||
ShortcutKey::Equals => egui::Key::Equals,
|
||||
ShortcutKey::Plus => egui::Key::Plus,
|
||||
ShortcutKey::BracketLeft => egui::Key::OpenBracket,
|
||||
ShortcutKey::BracketRight => egui::Key::CloseBracket,
|
||||
ShortcutKey::Semicolon => egui::Key::Semicolon,
|
||||
ShortcutKey::Quote => egui::Key::Quote,
|
||||
ShortcutKey::Period => egui::Key::Period,
|
||||
ShortcutKey::Slash => egui::Key::Slash,
|
||||
ShortcutKey::Backtick => egui::Key::Backtick,
|
||||
ShortcutKey::Space => egui::Key::Space,
|
||||
ShortcutKey::Escape => egui::Key::Escape,
|
||||
ShortcutKey::Enter => egui::Key::Enter,
|
||||
ShortcutKey::Tab => egui::Key::Tab,
|
||||
ShortcutKey::Backspace => egui::Key::Backspace,
|
||||
ShortcutKey::Delete => egui::Key::Delete,
|
||||
ShortcutKey::Home => egui::Key::Home,
|
||||
ShortcutKey::End => egui::Key::End,
|
||||
ShortcutKey::PageUp => egui::Key::PageUp,
|
||||
ShortcutKey::PageDown => egui::Key::PageDown,
|
||||
};
|
||||
|
||||
input.key_pressed(key)
|
||||
|
|
@ -594,26 +740,45 @@ impl MenuSystem {
|
|||
None
|
||||
}
|
||||
|
||||
/// Check keyboard shortcuts from egui input and return the action
|
||||
/// This works cross-platform and complements native menus
|
||||
pub fn check_shortcuts(input: &egui::InputState) -> Option<MenuAction> {
|
||||
for def in MenuItemDef::all_with_shortcuts() {
|
||||
if let Some(shortcut) = &def.shortcut {
|
||||
if shortcut.matches_egui_input(input) {
|
||||
return Some(def.action);
|
||||
/// Check keyboard shortcuts from egui input and return the action.
|
||||
/// If a KeymapManager is provided, uses remapped bindings; otherwise falls back to static defaults.
|
||||
pub fn check_shortcuts(input: &egui::InputState, keymap: Option<&crate::keymap::KeymapManager>) -> Option<MenuAction> {
|
||||
if let Some(km) = keymap {
|
||||
// Check all menu actions through the keymap
|
||||
for def in MenuItemDef::all_with_shortcuts() {
|
||||
if let Ok(app_action) = crate::keymap::AppAction::try_from(def.action) {
|
||||
if km.action_pressed(app_action, input) {
|
||||
return Some(def.action);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
} else {
|
||||
for def in MenuItemDef::all_with_shortcuts() {
|
||||
if let Some(shortcut) = &def.shortcut {
|
||||
if shortcut.matches_egui_input(input) {
|
||||
return Some(def.action);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Render egui menu bar from the same menu structure (for Linux/Windows)
|
||||
pub fn render_egui_menu_bar(&self, ui: &mut egui::Ui, recent_files: &[std::path::PathBuf]) -> Option<MenuAction> {
|
||||
pub fn render_egui_menu_bar(
|
||||
&self,
|
||||
ui: &mut egui::Ui,
|
||||
recent_files: &[std::path::PathBuf],
|
||||
keymap: Option<&crate::keymap::KeymapManager>,
|
||||
layout_names: &[String],
|
||||
current_layout_index: usize,
|
||||
) -> Option<MenuAction> {
|
||||
let mut action = None;
|
||||
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
for menu_def in MenuItemDef::menu_structure() {
|
||||
if let Some(a) = self.render_menu_def(ui, menu_def, recent_files) {
|
||||
if let Some(a) = self.render_menu_def(ui, menu_def, recent_files, keymap, layout_names, current_layout_index) {
|
||||
action = Some(a);
|
||||
}
|
||||
}
|
||||
|
|
@ -623,10 +788,18 @@ impl MenuSystem {
|
|||
}
|
||||
|
||||
/// Recursively render a MenuDef as egui UI
|
||||
fn render_menu_def(&self, ui: &mut egui::Ui, def: &MenuDef, recent_files: &[std::path::PathBuf]) -> Option<MenuAction> {
|
||||
fn render_menu_def(
|
||||
&self,
|
||||
ui: &mut egui::Ui,
|
||||
def: &MenuDef,
|
||||
recent_files: &[std::path::PathBuf],
|
||||
keymap: Option<&crate::keymap::KeymapManager>,
|
||||
layout_names: &[String],
|
||||
current_layout_index: usize,
|
||||
) -> Option<MenuAction> {
|
||||
match def {
|
||||
MenuDef::Item(item_def) => {
|
||||
if Self::render_menu_item(ui, item_def) {
|
||||
if Self::render_menu_item(ui, item_def, keymap) {
|
||||
Some(item_def.action)
|
||||
} else {
|
||||
None
|
||||
|
|
@ -639,9 +812,8 @@ impl MenuSystem {
|
|||
MenuDef::Submenu { label, children } => {
|
||||
let mut action = None;
|
||||
ui.menu_button(*label, |ui| {
|
||||
// Special handling for "Open Recent" submenu
|
||||
if *label == "Open Recent" {
|
||||
// Render dynamic recent files
|
||||
// Special handling for "Open Recent" submenu
|
||||
for (index, path) in recent_files.iter().enumerate() {
|
||||
let display_name = path
|
||||
.file_name()
|
||||
|
|
@ -654,7 +826,6 @@ impl MenuSystem {
|
|||
}
|
||||
}
|
||||
|
||||
// Add separator and clear option if we have items
|
||||
if !recent_files.is_empty() {
|
||||
ui.separator();
|
||||
}
|
||||
|
|
@ -663,10 +834,34 @@ impl MenuSystem {
|
|||
action = Some(MenuAction::ClearRecentFiles);
|
||||
ui.close();
|
||||
}
|
||||
} else if *label == "Layout" {
|
||||
// Render static items first (Next/Previous Layout)
|
||||
for child in *children {
|
||||
if let Some(a) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index) {
|
||||
action = Some(a);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic layout list
|
||||
if !layout_names.is_empty() {
|
||||
ui.separator();
|
||||
for (index, name) in layout_names.iter().enumerate() {
|
||||
let label = if index == current_layout_index {
|
||||
format!("* {}", name)
|
||||
} else {
|
||||
name.clone()
|
||||
};
|
||||
if ui.button(label).clicked() {
|
||||
action = Some(MenuAction::SwitchLayout(index));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal submenu rendering
|
||||
for child in *children {
|
||||
if let Some(a) = self.render_menu_def(ui, child, recent_files) {
|
||||
if let Some(a) = self.render_menu_def(ui, child, recent_files, keymap, layout_names, current_layout_index) {
|
||||
action = Some(a);
|
||||
ui.close();
|
||||
}
|
||||
|
|
@ -679,8 +874,18 @@ impl MenuSystem {
|
|||
}
|
||||
|
||||
/// Render a single menu item with label and shortcut
|
||||
fn render_menu_item(ui: &mut egui::Ui, def: &MenuItemDef) -> bool {
|
||||
let shortcut_text = if let Some(shortcut) = &def.shortcut {
|
||||
fn render_menu_item(ui: &mut egui::Ui, def: &MenuItemDef, keymap: Option<&crate::keymap::KeymapManager>) -> bool {
|
||||
// Look up shortcut from keymap if available, otherwise use static default
|
||||
let effective_shortcut = if let Some(km) = keymap {
|
||||
if let Ok(app_action) = crate::keymap::AppAction::try_from(def.action) {
|
||||
km.get(app_action)
|
||||
} else {
|
||||
def.shortcut
|
||||
}
|
||||
} else {
|
||||
def.shortcut
|
||||
};
|
||||
let shortcut_text = if let Some(shortcut) = &effective_shortcut {
|
||||
Self::format_shortcut(shortcut)
|
||||
} else {
|
||||
String::new()
|
||||
|
|
@ -733,7 +938,7 @@ impl MenuSystem {
|
|||
}
|
||||
|
||||
/// Format shortcut for display (e.g., "Ctrl+S")
|
||||
fn format_shortcut(shortcut: &Shortcut) -> String {
|
||||
pub fn format_shortcut(shortcut: &Shortcut) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if shortcut.ctrl {
|
||||
|
|
@ -747,36 +952,53 @@ impl MenuSystem {
|
|||
}
|
||||
|
||||
let key_name = match shortcut.key {
|
||||
ShortcutKey::A => "A",
|
||||
ShortcutKey::C => "C",
|
||||
ShortcutKey::D => "D",
|
||||
ShortcutKey::E => "E",
|
||||
ShortcutKey::G => "G",
|
||||
ShortcutKey::I => "I",
|
||||
ShortcutKey::K => "K",
|
||||
ShortcutKey::L => "L",
|
||||
ShortcutKey::N => "N",
|
||||
ShortcutKey::O => "O",
|
||||
ShortcutKey::Q => "Q",
|
||||
ShortcutKey::S => "S",
|
||||
ShortcutKey::V => "V",
|
||||
ShortcutKey::W => "W",
|
||||
ShortcutKey::X => "X",
|
||||
ShortcutKey::Z => "Z",
|
||||
ShortcutKey::Num0 => "0",
|
||||
ShortcutKey::Comma => ",",
|
||||
ShortcutKey::Minus => "-",
|
||||
ShortcutKey::Equals => "=",
|
||||
ShortcutKey::Plus => "+",
|
||||
ShortcutKey::BracketLeft => "[",
|
||||
ShortcutKey::BracketRight => "]",
|
||||
ShortcutKey::Delete => "Del",
|
||||
ShortcutKey::A => "A", ShortcutKey::B => "B", ShortcutKey::C => "C",
|
||||
ShortcutKey::D => "D", ShortcutKey::E => "E", ShortcutKey::F => "F",
|
||||
ShortcutKey::G => "G", ShortcutKey::H => "H", ShortcutKey::I => "I",
|
||||
ShortcutKey::J => "J", ShortcutKey::K => "K", ShortcutKey::L => "L",
|
||||
ShortcutKey::M => "M", ShortcutKey::N => "N", ShortcutKey::O => "O",
|
||||
ShortcutKey::P => "P", ShortcutKey::Q => "Q", ShortcutKey::R => "R",
|
||||
ShortcutKey::S => "S", ShortcutKey::T => "T", ShortcutKey::U => "U",
|
||||
ShortcutKey::V => "V", ShortcutKey::W => "W", ShortcutKey::X => "X",
|
||||
ShortcutKey::Y => "Y", ShortcutKey::Z => "Z",
|
||||
ShortcutKey::Num0 => "0", ShortcutKey::Num1 => "1", ShortcutKey::Num2 => "2",
|
||||
ShortcutKey::Num3 => "3", ShortcutKey::Num4 => "4", ShortcutKey::Num5 => "5",
|
||||
ShortcutKey::Num6 => "6", ShortcutKey::Num7 => "7", ShortcutKey::Num8 => "8",
|
||||
ShortcutKey::Num9 => "9",
|
||||
ShortcutKey::F1 => "F1", ShortcutKey::F2 => "F2", ShortcutKey::F3 => "F3",
|
||||
ShortcutKey::F4 => "F4", ShortcutKey::F5 => "F5", ShortcutKey::F6 => "F6",
|
||||
ShortcutKey::F7 => "F7", ShortcutKey::F8 => "F8", ShortcutKey::F9 => "F9",
|
||||
ShortcutKey::F10 => "F10", ShortcutKey::F11 => "F11", ShortcutKey::F12 => "F12",
|
||||
ShortcutKey::ArrowUp => "Up", ShortcutKey::ArrowDown => "Down",
|
||||
ShortcutKey::ArrowLeft => "Left", ShortcutKey::ArrowRight => "Right",
|
||||
ShortcutKey::Comma => ",", ShortcutKey::Minus => "-",
|
||||
ShortcutKey::Equals => "=", ShortcutKey::Plus => "+",
|
||||
ShortcutKey::BracketLeft => "[", ShortcutKey::BracketRight => "]",
|
||||
ShortcutKey::Semicolon => ";", ShortcutKey::Quote => "'",
|
||||
ShortcutKey::Period => ".", ShortcutKey::Slash => "/",
|
||||
ShortcutKey::Backtick => "`",
|
||||
ShortcutKey::Space => "Space", ShortcutKey::Escape => "Esc",
|
||||
ShortcutKey::Enter => "Enter", ShortcutKey::Tab => "Tab",
|
||||
ShortcutKey::Backspace => "Backspace", ShortcutKey::Delete => "Del",
|
||||
ShortcutKey::Home => "Home", ShortcutKey::End => "End",
|
||||
ShortcutKey::PageUp => "PgUp", ShortcutKey::PageDown => "PgDn",
|
||||
};
|
||||
parts.push(key_name);
|
||||
|
||||
parts.join("+")
|
||||
}
|
||||
|
||||
/// Update native menu accelerator labels to match the current keymap
|
||||
pub fn apply_keybindings(&self, keymap: &crate::keymap::KeymapManager) {
|
||||
for (item, menu_action) in &self.items {
|
||||
if let Ok(app_action) = crate::keymap::AppAction::try_from(*menu_action) {
|
||||
let accelerator = keymap.get(app_action)
|
||||
.map(|s| s.to_muda_accelerator());
|
||||
let _ = item.set_accelerator(accelerator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update menu item text dynamically (e.g., for Undo/Redo with action names)
|
||||
#[allow(dead_code)]
|
||||
pub fn update_undo_text(&self, action_name: Option<&str>) {
|
||||
|
|
|
|||
|
|
@ -1262,6 +1262,9 @@ impl AssetLibraryPane {
|
|||
}
|
||||
}
|
||||
}
|
||||
lightningbeam_core::layer::AnyLayer::Group(_) => {
|
||||
// Group layers don't have their own clip instances
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
|
|
|
|||
|
|
@ -143,9 +143,14 @@ impl InfopanelPane {
|
|||
let tool = *shared.selected_tool;
|
||||
|
||||
// Only show tool options for tools that have options
|
||||
let has_options = matches!(
|
||||
let is_vector_tool = matches!(
|
||||
tool,
|
||||
Tool::Draw | Tool::Rectangle | Tool::Ellipse | Tool::PaintBucket | Tool::Polygon | Tool::Line | Tool::RegionSelect
|
||||
Tool::Select | Tool::BezierEdit | Tool::Draw | Tool::Rectangle
|
||||
| Tool::Ellipse | Tool::Line | Tool::Polygon
|
||||
);
|
||||
let has_options = is_vector_tool || matches!(
|
||||
tool,
|
||||
Tool::PaintBucket | Tool::RegionSelect
|
||||
);
|
||||
|
||||
if !has_options {
|
||||
|
|
@ -159,6 +164,11 @@ impl InfopanelPane {
|
|||
self.tool_section_open = true;
|
||||
ui.add_space(4.0);
|
||||
|
||||
if is_vector_tool {
|
||||
ui.checkbox(shared.snap_enabled, "Snap to Geometry");
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
|
||||
match tool {
|
||||
Tool::Draw => {
|
||||
// Stroke width
|
||||
|
|
@ -471,6 +481,19 @@ impl InfopanelPane {
|
|||
}
|
||||
});
|
||||
|
||||
// Background color
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Background:");
|
||||
let bg = document.background_color;
|
||||
let mut color = [bg.r, bg.g, bg.b];
|
||||
if ui.color_edit_button_srgb(&mut color).changed() {
|
||||
let action = SetDocumentPropertiesAction::set_background_color(
|
||||
ShapeColor::rgb(color[0], color[1], color[2]),
|
||||
);
|
||||
shared.pending_actions.push(Box::new(action));
|
||||
}
|
||||
});
|
||||
|
||||
// Layer count (read-only)
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Layers:");
|
||||
|
|
|
|||
|
|
@ -55,6 +55,15 @@ pub struct DraggingAsset {
|
|||
pub linked_audio_clip_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Command for webcam recording (issued by timeline, processed by main)
|
||||
#[derive(Debug)]
|
||||
pub enum WebcamRecordCommand {
|
||||
/// Start recording on the given video layer
|
||||
Start { layer_id: uuid::Uuid },
|
||||
/// Stop current webcam recording
|
||||
Stop,
|
||||
}
|
||||
|
||||
pub mod toolbar;
|
||||
pub mod stage;
|
||||
pub mod timeline;
|
||||
|
|
@ -195,6 +204,8 @@ pub struct SharedPaneState<'a> {
|
|||
pub stroke_width: &'a mut f64,
|
||||
/// Whether to fill shapes when drawing (Rectangle, Ellipse, Polygon)
|
||||
pub fill_enabled: &'a mut bool,
|
||||
/// Whether to snap to geometry when editing vectors
|
||||
pub snap_enabled: &'a mut bool,
|
||||
/// Fill gap tolerance for paint bucket tool
|
||||
pub paint_bucket_gap_tolerance: &'a mut f64,
|
||||
/// Number of sides for polygon tool
|
||||
|
|
@ -219,6 +230,10 @@ pub struct SharedPaneState<'a> {
|
|||
pub effect_thumbnail_cache: &'a std::collections::HashMap<Uuid, Vec<u8>>,
|
||||
/// Effect IDs whose thumbnails should be invalidated (e.g., after shader edit)
|
||||
pub effect_thumbnails_to_invalidate: &'a mut Vec<Uuid>,
|
||||
/// Latest webcam capture frame (None if no camera is active)
|
||||
pub webcam_frame: Option<lightningbeam_core::webcam::CaptureFrame>,
|
||||
/// Pending webcam recording commands (processed by main.rs after render)
|
||||
pub webcam_record_command: &'a mut Option<WebcamRecordCommand>,
|
||||
/// Surface texture format for GPU rendering (Rgba8Unorm or Bgra8Unorm depending on platform)
|
||||
pub target_format: wgpu::TextureFormat,
|
||||
/// Menu actions queued by panes (e.g. context menu items), processed by main after rendering
|
||||
|
|
@ -244,6 +259,14 @@ pub struct SharedPaneState<'a> {
|
|||
/// Set by panes (e.g. piano roll) when they handle Ctrl+C/X/V internally,
|
||||
/// so main.rs skips its own clipboard handling for the current frame
|
||||
pub clipboard_consumed: &'a mut bool,
|
||||
/// Remappable keyboard shortcut manager
|
||||
pub keymap: &'a crate::keymap::KeymapManager,
|
||||
/// Test mode state for event recording (debug builds only)
|
||||
#[cfg(debug_assertions)]
|
||||
pub test_mode: &'a mut crate::test_mode::TestModeState,
|
||||
/// Synthetic input from test mode replay (debug builds only)
|
||||
#[cfg(debug_assertions)]
|
||||
pub synthetic_input: &'a mut Option<crate::test_mode::SyntheticInput>,
|
||||
}
|
||||
|
||||
/// Trait for pane rendering
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ node_templates! {
|
|||
Pan, "Pan", "Pan", "Effects", true;
|
||||
RingModulator, "RingModulator", "Ring Modulator", "Effects", true;
|
||||
Vocoder, "Vocoder", "Vocoder", "Effects", true;
|
||||
Vibrato, "Vibrato", "Vibrato", "Effects", true;
|
||||
// Utilities
|
||||
Adsr, "ADSR", "ADSR Envelope", "Utilities", true;
|
||||
Lfo, "LFO", "LFO", "Utilities", true;
|
||||
|
|
@ -471,10 +472,10 @@ impl NodeTemplateTrait for NodeTemplate {
|
|||
}
|
||||
NodeTemplate::Filter => {
|
||||
graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
||||
graph.add_input_param(node_id, "Cutoff CV".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
||||
graph.add_input_param(node_id, "Cutoff CV".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOrConstant, true);
|
||||
// Parameters
|
||||
graph.add_input_param(node_id, "Cutoff".into(), DataType::CV,
|
||||
ValueType::float_param(1000.0, 20.0, 20000.0, " Hz", 0, None), InputParamKind::ConstantOnly, true);
|
||||
ValueType::float_param(1000.0, 20.0, 20000.0, " Hz", 0, None), InputParamKind::ConnectionOrConstant, true);
|
||||
graph.add_input_param(node_id, "Resonance".into(), DataType::CV,
|
||||
ValueType::float_param(0.0, 0.0, 1.0, "", 1, None), InputParamKind::ConstantOnly, true);
|
||||
graph.add_input_param(node_id, "Type".into(), DataType::CV,
|
||||
|
|
@ -786,6 +787,15 @@ impl NodeTemplateTrait for NodeTemplate {
|
|||
ValueType::float_param(1.0, 0.0, 1.0, "", 3, None), InputParamKind::ConstantOnly, true);
|
||||
graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio);
|
||||
}
|
||||
NodeTemplate::Vibrato => {
|
||||
graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
||||
graph.add_input_param(node_id, "Mod CV".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
||||
graph.add_input_param(node_id, "Rate".into(), DataType::CV,
|
||||
ValueType::float_param(5.0, 0.1, 14.0, " Hz", 0, None), InputParamKind::ConstantOnly, true);
|
||||
graph.add_input_param(node_id, "Depth".into(), DataType::CV,
|
||||
ValueType::float_param(0.5, 0.0, 1.0, "", 1, None), InputParamKind::ConnectionOrConstant, true);
|
||||
graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio);
|
||||
}
|
||||
NodeTemplate::AudioToCv => {
|
||||
graph.add_input_param(node_id, "Audio In".into(), DataType::Audio, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
||||
graph.add_output_param(node_id, "CV Out".into(), DataType::CV);
|
||||
|
|
|
|||
|
|
@ -3087,7 +3087,7 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
|||
// Handle pane-local keyboard shortcuts (only when pointer is over this pane)
|
||||
if ui.rect_contains_pointer(rect) {
|
||||
let ctrl_g = ui.input(|i| {
|
||||
i.key_pressed(egui::Key::G) && (i.modifiers.ctrl || i.modifiers.command)
|
||||
shared.keymap.action_pressed(crate::keymap::AppAction::NodeGraphGroup, i)
|
||||
});
|
||||
if ctrl_g && !self.state.selected_nodes.is_empty() {
|
||||
self.group_selected_nodes(shared);
|
||||
|
|
@ -3095,7 +3095,7 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
|||
|
||||
// Ctrl+Shift+G to ungroup
|
||||
let ctrl_shift_g = ui.input(|i| {
|
||||
i.key_pressed(egui::Key::G) && (i.modifiers.ctrl || i.modifiers.command) && i.modifiers.shift
|
||||
shared.keymap.action_pressed(crate::keymap::AppAction::NodeGraphUngroup, i)
|
||||
});
|
||||
if ctrl_shift_g {
|
||||
// Ungroup any selected group placeholders
|
||||
|
|
@ -3108,7 +3108,7 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
|||
}
|
||||
|
||||
// F2 to rename selected group
|
||||
let f2 = ui.input(|i| i.key_pressed(egui::Key::F2));
|
||||
let f2 = ui.input(|i| shared.keymap.action_pressed(crate::keymap::AppAction::NodeGraphRename, i));
|
||||
if f2 && self.renaming_group.is_none() {
|
||||
// Find the first selected group placeholder
|
||||
if let Some(group_id) = self.state.selected_nodes.iter()
|
||||
|
|
|
|||
|
|
@ -725,7 +725,7 @@ impl PianoRollPane {
|
|||
}
|
||||
|
||||
// Delete key
|
||||
let delete_pressed = ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace));
|
||||
let delete_pressed = ui.input(|i| shared.keymap.action_pressed_with_backspace(crate::keymap::AppAction::PianoRollDelete, i));
|
||||
if delete_pressed && !self.selected_note_indices.is_empty() {
|
||||
if let Some(clip_id) = self.selected_clip_id {
|
||||
self.delete_selected_notes(clip_id, shared, clip_data);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,16 +1,30 @@
|
|||
//! Preferences dialog UI
|
||||
//!
|
||||
//! Provides a user interface for configuring application preferences
|
||||
//! Provides a user interface for configuring application preferences,
|
||||
//! including a Keyboard Shortcuts tab with click-to-rebind support.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use eframe::egui;
|
||||
use crate::config::AppConfig;
|
||||
use crate::keymap::{self, AppAction, KeymapManager};
|
||||
use crate::menu::{MenuSystem, Shortcut, ShortcutKey};
|
||||
use crate::theme::{Theme, ThemeMode};
|
||||
|
||||
/// Which tab is selected in the preferences dialog
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PreferencesTab {
|
||||
General,
|
||||
Shortcuts,
|
||||
}
|
||||
|
||||
/// Preferences dialog state
|
||||
pub struct PreferencesDialog {
|
||||
/// Is the dialog open?
|
||||
pub open: bool,
|
||||
|
||||
/// Currently selected tab
|
||||
tab: PreferencesTab,
|
||||
|
||||
/// Working copy of preferences (allows cancel to discard changes)
|
||||
working_prefs: PreferencesState,
|
||||
|
||||
|
|
@ -19,6 +33,16 @@ pub struct PreferencesDialog {
|
|||
|
||||
/// Error message (if validation fails)
|
||||
error_message: Option<String>,
|
||||
|
||||
// --- Shortcuts tab state ---
|
||||
/// Working copy of keybindings (for live editing before save)
|
||||
working_keybindings: HashMap<AppAction, Option<Shortcut>>,
|
||||
|
||||
/// Which action is currently being rebound (waiting for key press)
|
||||
rebinding: Option<AppAction>,
|
||||
|
||||
/// Search/filter text for shortcuts list
|
||||
shortcut_filter: String,
|
||||
}
|
||||
|
||||
/// Editable preferences state (working copy)
|
||||
|
|
@ -74,19 +98,24 @@ impl Default for PreferencesState {
|
|||
}
|
||||
|
||||
/// Result returned when preferences are saved
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreferencesSaveResult {
|
||||
/// Whether audio buffer size changed (requires restart)
|
||||
pub buffer_size_changed: bool,
|
||||
/// New keymap manager if keybindings changed (caller must replace their keymap and call apply_keybindings)
|
||||
pub new_keymap: Option<KeymapManager>,
|
||||
}
|
||||
|
||||
impl Default for PreferencesDialog {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
open: false,
|
||||
tab: PreferencesTab::General,
|
||||
working_prefs: PreferencesState::default(),
|
||||
original_buffer_size: 256,
|
||||
error_message: None,
|
||||
working_keybindings: HashMap::new(),
|
||||
rebinding: None,
|
||||
shortcut_filter: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -98,12 +127,16 @@ impl PreferencesDialog {
|
|||
self.working_prefs = PreferencesState::from((config, theme));
|
||||
self.original_buffer_size = config.audio_buffer_size;
|
||||
self.error_message = None;
|
||||
self.working_keybindings = config.keybindings.effective_bindings();
|
||||
self.rebinding = None;
|
||||
self.shortcut_filter.clear();
|
||||
}
|
||||
|
||||
/// Close the dialog
|
||||
pub fn close(&mut self) {
|
||||
self.open = false;
|
||||
self.error_message = None;
|
||||
self.rebinding = None;
|
||||
}
|
||||
|
||||
/// Render the preferences dialog
|
||||
|
|
@ -129,7 +162,7 @@ impl PreferencesDialog {
|
|||
.collapsible(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(500.0);
|
||||
ui.set_width(550.0);
|
||||
|
||||
// Error message
|
||||
if let Some(error) = &self.error_message {
|
||||
|
|
@ -137,20 +170,34 @@ impl PreferencesDialog {
|
|||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
// Scrollable area for preferences sections
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(400.0)
|
||||
.show(ui, |ui| {
|
||||
self.render_general_section(ui);
|
||||
ui.add_space(8.0);
|
||||
self.render_audio_section(ui);
|
||||
ui.add_space(8.0);
|
||||
self.render_appearance_section(ui);
|
||||
ui.add_space(8.0);
|
||||
self.render_startup_section(ui);
|
||||
ui.add_space(8.0);
|
||||
self.render_advanced_section(ui);
|
||||
});
|
||||
// Tab bar
|
||||
ui.horizontal(|ui| {
|
||||
ui.selectable_value(&mut self.tab, PreferencesTab::General, "General");
|
||||
ui.selectable_value(&mut self.tab, PreferencesTab::Shortcuts, "Keyboard Shortcuts");
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
// Tab content
|
||||
match self.tab {
|
||||
PreferencesTab::General => {
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(400.0)
|
||||
.show(ui, |ui| {
|
||||
self.render_general_section(ui);
|
||||
ui.add_space(8.0);
|
||||
self.render_audio_section(ui);
|
||||
ui.add_space(8.0);
|
||||
self.render_appearance_section(ui);
|
||||
ui.add_space(8.0);
|
||||
self.render_startup_section(ui);
|
||||
ui.add_space(8.0);
|
||||
self.render_advanced_section(ui);
|
||||
});
|
||||
}
|
||||
PreferencesTab::Shortcuts => {
|
||||
self.render_shortcuts_tab(ui);
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
|
|
@ -187,6 +234,184 @@ impl PreferencesDialog {
|
|||
None
|
||||
}
|
||||
|
||||
fn render_shortcuts_tab(&mut self, ui: &mut egui::Ui) {
|
||||
// Capture key events for rebinding BEFORE rendering the rest
|
||||
if let Some(rebind_action) = self.rebinding {
|
||||
// Intercept key presses for rebinding
|
||||
let captured = ui.input(|i| {
|
||||
for event in &i.events {
|
||||
if let egui::Event::Key { key, pressed: true, modifiers, .. } = event {
|
||||
// Escape clears the binding
|
||||
if *key == egui::Key::Escape && !modifiers.ctrl && !modifiers.shift && !modifiers.alt {
|
||||
return Some(None); // Clear binding
|
||||
}
|
||||
// Any other key: set as new binding
|
||||
if let Some(shortcut_key) = ShortcutKey::from_egui_key(*key) {
|
||||
return Some(Some(Shortcut::new(
|
||||
shortcut_key,
|
||||
modifiers.ctrl || modifiers.command,
|
||||
modifiers.shift,
|
||||
modifiers.alt,
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
|
||||
if let Some(new_binding) = captured {
|
||||
self.working_keybindings.insert(rebind_action, new_binding);
|
||||
self.rebinding = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Search/filter
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Filter:");
|
||||
ui.text_edit_singleline(&mut self.shortcut_filter);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Conflict detection
|
||||
let conflicts = self.detect_conflicts();
|
||||
if !conflicts.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.colored_label(egui::Color32::from_rgb(255, 180, 50),
|
||||
format!("{} conflict(s) detected", conflicts.len()));
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
// Scrollable list of actions grouped by category
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(350.0)
|
||||
.show(ui, |ui| {
|
||||
let filter_lower = self.shortcut_filter.to_lowercase();
|
||||
|
||||
// Collect categories in display order
|
||||
let category_order = [
|
||||
"File", "Edit", "Modify", "Layer", "Timeline", "View",
|
||||
"Help", "Window", "Tools", "Global", "Pane",
|
||||
];
|
||||
|
||||
for category in &category_order {
|
||||
let actions_in_category: Vec<AppAction> = AppAction::all().iter()
|
||||
.filter(|a| a.category() == *category)
|
||||
.filter(|a| {
|
||||
if filter_lower.is_empty() {
|
||||
true
|
||||
} else {
|
||||
a.display_name().to_lowercase().contains(&filter_lower)
|
||||
|| a.category().to_lowercase().contains(&filter_lower)
|
||||
}
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
if actions_in_category.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
egui::CollapsingHeader::new(*category)
|
||||
.default_open(!filter_lower.is_empty() || *category == "Tools" || *category == "Global")
|
||||
.show(ui, |ui| {
|
||||
for action in &actions_in_category {
|
||||
self.render_shortcut_row(ui, *action, &conflicts);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render_shortcut_row(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
action: AppAction,
|
||||
conflicts: &HashMap<Shortcut, Vec<AppAction>>,
|
||||
) {
|
||||
let binding = self.working_keybindings.get(&action).copied().flatten();
|
||||
let is_rebinding = self.rebinding == Some(action);
|
||||
let has_conflict = binding
|
||||
.as_ref()
|
||||
.and_then(|s| conflicts.get(s))
|
||||
.map(|actions| actions.len() > 1)
|
||||
.unwrap_or(false);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
// Action name (fixed width)
|
||||
ui.add_sized([200.0, 20.0], egui::Label::new(action.display_name()));
|
||||
|
||||
// Binding button (click to rebind)
|
||||
let button_text = if is_rebinding {
|
||||
"Press a key...".to_string()
|
||||
} else if let Some(s) = &binding {
|
||||
MenuSystem::format_shortcut(s)
|
||||
} else {
|
||||
"None".to_string()
|
||||
};
|
||||
|
||||
let button_color = if is_rebinding {
|
||||
egui::Color32::from_rgb(100, 150, 255)
|
||||
} else if has_conflict {
|
||||
egui::Color32::from_rgb(255, 180, 50)
|
||||
} else {
|
||||
ui.visuals().widgets.inactive.text_color()
|
||||
};
|
||||
|
||||
let response = ui.add_sized(
|
||||
[140.0, 20.0],
|
||||
egui::Button::new(egui::RichText::new(&button_text).color(button_color)),
|
||||
);
|
||||
|
||||
if response.clicked() && !is_rebinding {
|
||||
self.rebinding = Some(action);
|
||||
}
|
||||
|
||||
// Show conflict tooltip
|
||||
if has_conflict {
|
||||
if let Some(s) = &binding {
|
||||
if let Some(conflicting) = conflicts.get(s) {
|
||||
let others: Vec<&str> = conflicting.iter()
|
||||
.filter(|a| **a != action)
|
||||
.map(|a| a.display_name())
|
||||
.collect();
|
||||
response.on_hover_text(format!("Conflicts with: {}", others.join(", ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear button
|
||||
if ui.small_button("x").clicked() {
|
||||
self.working_keybindings.insert(action, None);
|
||||
if self.rebinding == Some(action) {
|
||||
self.rebinding = None;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Detect all shortcut conflicts (shortcut -> list of actions sharing it).
|
||||
/// Only actions within the same conflict scope can conflict — pane-local actions
|
||||
/// are isolated to their pane and never conflict with each other or global actions.
|
||||
fn detect_conflicts(&self) -> HashMap<Shortcut, Vec<AppAction>> {
|
||||
// Group by (shortcut, conflict_scope)
|
||||
let mut by_scope: HashMap<(&str, Shortcut), Vec<AppAction>> = HashMap::new();
|
||||
for (&action, &shortcut) in &self.working_keybindings {
|
||||
if let Some(s) = shortcut {
|
||||
by_scope.entry((action.conflict_scope(), s)).or_default().push(action);
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten into shortcut -> conflicting actions (only where there are actual conflicts)
|
||||
let mut result: HashMap<Shortcut, Vec<AppAction>> = HashMap::new();
|
||||
for ((_, shortcut), actions) in by_scope {
|
||||
if actions.len() > 1 {
|
||||
result.entry(shortcut).or_default().extend(actions);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn render_general_section(&mut self, ui: &mut egui::Ui) {
|
||||
egui::CollapsingHeader::new("General")
|
||||
.default_open(true)
|
||||
|
|
@ -284,7 +509,7 @@ impl PreferencesDialog {
|
|||
});
|
||||
});
|
||||
|
||||
ui.label("⚠ Requires app restart to take effect");
|
||||
ui.label("Requires app restart to take effect");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -347,6 +572,8 @@ impl PreferencesDialog {
|
|||
|
||||
fn reset_to_defaults(&mut self) {
|
||||
self.working_prefs = PreferencesState::default();
|
||||
self.working_keybindings = keymap::all_defaults();
|
||||
self.rebinding = None;
|
||||
self.error_message = None;
|
||||
}
|
||||
|
||||
|
|
@ -378,6 +605,18 @@ impl PreferencesDialog {
|
|||
// Check if buffer size changed
|
||||
let buffer_size_changed = self.working_prefs.audio_buffer_size != self.original_buffer_size;
|
||||
|
||||
// Build new keymap from working keybindings to compute sparse overrides
|
||||
let defaults = keymap::all_defaults();
|
||||
let mut overrides = HashMap::new();
|
||||
for (&action, &shortcut) in &self.working_keybindings {
|
||||
let default = defaults.get(&action).copied().flatten();
|
||||
if shortcut != default {
|
||||
overrides.insert(action, shortcut);
|
||||
}
|
||||
}
|
||||
let keybinding_config = keymap::KeybindingConfig { overrides };
|
||||
let new_keymap = KeymapManager::new(&keybinding_config);
|
||||
|
||||
// Apply changes to config
|
||||
config.bpm = self.working_prefs.bpm;
|
||||
config.framerate = self.working_prefs.framerate;
|
||||
|
|
@ -390,6 +629,7 @@ impl PreferencesDialog {
|
|||
config.debug = self.working_prefs.debug;
|
||||
config.waveform_stereo = self.working_prefs.waveform_stereo;
|
||||
config.theme_mode = self.working_prefs.theme_mode.to_string_lower();
|
||||
config.keybindings = keybinding_config;
|
||||
|
||||
// Apply theme immediately
|
||||
theme.set_mode(self.working_prefs.theme_mode);
|
||||
|
|
@ -402,6 +642,7 @@ impl PreferencesDialog {
|
|||
|
||||
Some(PreferencesSaveResult {
|
||||
buffer_size_changed,
|
||||
new_keymap: Some(new_keymap),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,941 @@
|
|||
//! Debug test mode — input recording, panic capture, and visual replay.
|
||||
//!
|
||||
//! Gated behind `#[cfg(debug_assertions)]` at the module level in main.rs.
|
||||
|
||||
use eframe::egui;
|
||||
use lightningbeam_core::test_mode::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
use vello::kurbo::Point;
|
||||
|
||||
/// Maximum events kept in the always-on ring buffer for crash capture
|
||||
const RING_BUFFER_SIZE: usize = 1000;
|
||||
|
||||
/// How often to snapshot state for the panic hook (every N events)
|
||||
const PANIC_SNAPSHOT_INTERVAL: usize = 50;
|
||||
|
||||
// ---- Synthetic input for replay ----
|
||||
|
||||
/// Synthetic input data injected during replay, consumed by stage handle_input
|
||||
pub struct SyntheticInput {
|
||||
pub world_pos: Point,
|
||||
pub drag_started: bool,
|
||||
pub dragged: bool,
|
||||
pub drag_stopped: bool,
|
||||
#[allow(dead_code)] // Part of the synthetic input API, consumed when replay handles held-button state
|
||||
pub primary_down: bool,
|
||||
pub shift: bool,
|
||||
pub ctrl: bool,
|
||||
pub alt: bool,
|
||||
}
|
||||
|
||||
// ---- State machine ----
|
||||
|
||||
pub enum TestModeOp {
|
||||
Idle,
|
||||
Recording(TestRecorder),
|
||||
Playing(TestPlayer),
|
||||
}
|
||||
|
||||
pub struct TestRecorder {
|
||||
pub test_case: TestCase,
|
||||
pub start_time: Instant,
|
||||
event_count: usize,
|
||||
}
|
||||
|
||||
impl TestRecorder {
|
||||
fn new(name: String, canvas_state: CanvasState) -> Self {
|
||||
Self {
|
||||
test_case: TestCase::new(name, canvas_state),
|
||||
start_time: Instant::now(),
|
||||
event_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn record(&mut self, kind: TestEventKind) {
|
||||
let timestamp_ms = self.start_time.elapsed().as_millis() as u64;
|
||||
let index = self.event_count;
|
||||
self.event_count += 1;
|
||||
self.test_case.events.push(TestEvent {
|
||||
index,
|
||||
timestamp_ms,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TestPlayer {
|
||||
pub test_case: TestCase,
|
||||
/// Next event index to execute
|
||||
pub cursor: usize,
|
||||
pub auto_playing: bool,
|
||||
pub auto_play_delay_ms: u64,
|
||||
pub last_step_time: Option<Instant>,
|
||||
/// Collapse consecutive move/drag events in the event list and step through them as one batch
|
||||
pub skip_consecutive_moves: bool,
|
||||
/// When set, auto-play runs at max speed until cursor reaches this index, then stops
|
||||
batch_end: Option<usize>,
|
||||
}
|
||||
|
||||
impl TestPlayer {
|
||||
fn new(test_case: TestCase) -> Self {
|
||||
Self {
|
||||
test_case,
|
||||
cursor: 0,
|
||||
auto_playing: false,
|
||||
auto_play_delay_ms: 100,
|
||||
last_step_time: None,
|
||||
skip_consecutive_moves: true, // on by default
|
||||
batch_end: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance cursor by one event and return it, or None if finished.
|
||||
pub fn step_forward(&mut self) -> Option<&TestEvent> {
|
||||
if self.cursor >= self.test_case.events.len() {
|
||||
self.auto_playing = false;
|
||||
self.batch_end = None;
|
||||
return None;
|
||||
}
|
||||
|
||||
let idx = self.cursor;
|
||||
self.cursor += 1;
|
||||
self.last_step_time = Some(Instant::now());
|
||||
|
||||
// Check if batch is done
|
||||
if let Some(end) = self.batch_end {
|
||||
if self.cursor >= end {
|
||||
self.auto_playing = false;
|
||||
self.batch_end = None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(&self.test_case.events[idx])
|
||||
}
|
||||
|
||||
/// Start a batch-replay of consecutive move/drag events from the current cursor.
|
||||
/// Returns the first event in the batch, sets up auto-play for the rest.
|
||||
pub fn step_or_batch(&mut self) -> Option<&TestEvent> {
|
||||
if self.cursor >= self.test_case.events.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.skip_consecutive_moves {
|
||||
let disc = move_discriminant(&self.test_case.events[self.cursor].kind);
|
||||
if let Some(d) = disc {
|
||||
// Find end of consecutive run
|
||||
let mut end = self.cursor + 1;
|
||||
while end < self.test_case.events.len()
|
||||
&& move_discriminant(&self.test_case.events[end].kind) == Some(d)
|
||||
{
|
||||
end += 1;
|
||||
}
|
||||
if end > self.cursor + 1 {
|
||||
// Multi-event batch — auto-play through it at max speed
|
||||
self.batch_end = Some(end);
|
||||
self.auto_playing = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.step_forward()
|
||||
}
|
||||
|
||||
/// Whether auto-play should step this frame
|
||||
pub fn should_auto_step(&self) -> bool {
|
||||
if !self.auto_playing {
|
||||
return false;
|
||||
}
|
||||
// Batch replay: every frame, no delay
|
||||
if self.batch_end.is_some() {
|
||||
return true;
|
||||
}
|
||||
// Normal auto-play: respect delay setting
|
||||
match self.last_step_time {
|
||||
None => true,
|
||||
Some(t) => t.elapsed().as_millis() as u64 >= self.auto_play_delay_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn progress(&self) -> (usize, usize) {
|
||||
(self.cursor, self.test_case.events.len())
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.cursor = 0;
|
||||
self.auto_playing = false;
|
||||
self.last_step_time = None;
|
||||
self.batch_end = None;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Main state ----
|
||||
|
||||
pub struct TestModeState {
|
||||
/// Whether the test mode sidebar is visible
|
||||
pub active: bool,
|
||||
/// Current operation
|
||||
pub mode: TestModeOp,
|
||||
/// Directory for test case files
|
||||
pub test_dir: PathBuf,
|
||||
/// List of available test files
|
||||
pub available_tests: Vec<PathBuf>,
|
||||
/// Name field for new recordings
|
||||
pub new_test_name: String,
|
||||
/// Transient status message
|
||||
pub status_message: Option<(String, Instant)>,
|
||||
/// Shared with panic hook — periodically updated with current state
|
||||
pub panic_snapshot: Arc<Mutex<Option<TestCase>>>,
|
||||
/// Current in-flight event, set before processing. If a panic occurs during
|
||||
/// processing, the panic hook appends this to the saved test case.
|
||||
pub pending_event: Arc<Mutex<Option<TestEvent>>>,
|
||||
/// Always-on ring buffer of last N events (for crash capture outside test mode)
|
||||
pub event_ring: VecDeque<TestEvent>,
|
||||
pub ring_start_time: Instant,
|
||||
ring_event_count: usize,
|
||||
/// Counter since last panic snapshot update
|
||||
events_since_snapshot: usize,
|
||||
/// Last replayed world-space position (for ghost cursor rendering on stage)
|
||||
pub replay_cursor_pos: Option<(f64, f64)>,
|
||||
/// Shared with panic hook — when true, panics during replay don't save new crash files
|
||||
pub is_replaying: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl TestModeState {
|
||||
pub fn new(panic_snapshot: Arc<Mutex<Option<TestCase>>>, pending_event: Arc<Mutex<Option<TestEvent>>>, is_replaying: Arc<AtomicBool>) -> Self {
|
||||
let test_dir = directories::ProjectDirs::from("", "", "lightningbeam")
|
||||
.map(|dirs| dirs.data_dir().join("test_cases"))
|
||||
.unwrap_or_else(|| PathBuf::from("test_cases"));
|
||||
|
||||
Self {
|
||||
active: false,
|
||||
mode: TestModeOp::Idle,
|
||||
test_dir,
|
||||
available_tests: Vec::new(),
|
||||
new_test_name: String::new(),
|
||||
status_message: None,
|
||||
panic_snapshot,
|
||||
pending_event,
|
||||
event_ring: VecDeque::with_capacity(RING_BUFFER_SIZE),
|
||||
ring_start_time: Instant::now(),
|
||||
ring_event_count: 0,
|
||||
events_since_snapshot: 0,
|
||||
replay_cursor_pos: None,
|
||||
is_replaying,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the current in-flight event for panic capture.
|
||||
/// Called before processing so the panic hook can grab it if processing panics.
|
||||
pub fn set_pending_event(&self, kind: TestEventKind) {
|
||||
let event = TestEvent {
|
||||
index: self.ring_event_count,
|
||||
timestamp_ms: self.ring_start_time.elapsed().as_millis() as u64,
|
||||
kind,
|
||||
};
|
||||
if let Ok(mut guard) = self.pending_event.try_lock() {
|
||||
*guard = Some(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an event — always appends to ring buffer, and to active recording if any
|
||||
pub fn record_event(&mut self, kind: TestEventKind) {
|
||||
let timestamp_ms = self.ring_start_time.elapsed().as_millis() as u64;
|
||||
let index = self.ring_event_count;
|
||||
self.ring_event_count += 1;
|
||||
|
||||
let event = TestEvent {
|
||||
index,
|
||||
timestamp_ms,
|
||||
kind: kind.clone(),
|
||||
};
|
||||
|
||||
// Always append to ring buffer
|
||||
if self.event_ring.len() >= RING_BUFFER_SIZE {
|
||||
self.event_ring.pop_front();
|
||||
}
|
||||
self.event_ring.push_back(event);
|
||||
|
||||
// Append to active recording if any
|
||||
if let TestModeOp::Recording(ref mut recorder) = self.mode {
|
||||
recorder.record(kind);
|
||||
}
|
||||
|
||||
// Periodically update panic snapshot
|
||||
self.events_since_snapshot += 1;
|
||||
if self.events_since_snapshot >= PANIC_SNAPSHOT_INTERVAL {
|
||||
self.events_since_snapshot = 0;
|
||||
self.update_panic_snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new recording
|
||||
pub fn start_recording(&mut self, name: String, canvas_state: CanvasState) {
|
||||
self.mode = TestModeOp::Recording(TestRecorder::new(name, canvas_state));
|
||||
self.set_status("Recording started");
|
||||
}
|
||||
|
||||
/// Stop recording and save to disk. Returns path if saved successfully.
|
||||
pub fn stop_recording(&mut self) -> Option<PathBuf> {
|
||||
let recorder = match std::mem::replace(&mut self.mode, TestModeOp::Idle) {
|
||||
TestModeOp::Recording(r) => r,
|
||||
other => {
|
||||
self.mode = other;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let test_case = recorder.test_case;
|
||||
let filename = sanitize_filename(&test_case.name);
|
||||
let path = self.test_dir.join(format!("{}.json", filename));
|
||||
|
||||
match test_case.save_to_file(&path) {
|
||||
Ok(()) => {
|
||||
self.set_status(&format!("Saved: {}", path.display()));
|
||||
self.refresh_test_list();
|
||||
Some(path)
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_status(&format!("Save failed: {}", e));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Discard the current recording
|
||||
pub fn discard_recording(&mut self) {
|
||||
self.mode = TestModeOp::Idle;
|
||||
self.set_status("Recording discarded");
|
||||
}
|
||||
|
||||
/// Load a test case for playback
|
||||
pub fn load_test(&mut self, path: &PathBuf) {
|
||||
match TestCase::load_from_file(path) {
|
||||
Ok(test_case) => {
|
||||
self.set_status(&format!("Loaded: {} ({} events)", test_case.name, test_case.events.len()));
|
||||
self.mode = TestModeOp::Playing(TestPlayer::new(test_case));
|
||||
self.is_replaying.store(true, Ordering::SeqCst);
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_status(&format!("Load failed: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop playback and return to idle
|
||||
pub fn stop_playback(&mut self) {
|
||||
self.mode = TestModeOp::Idle;
|
||||
self.is_replaying.store(false, Ordering::SeqCst);
|
||||
self.set_status("Playback stopped");
|
||||
}
|
||||
|
||||
/// Called from panic hook — saves ring buffer or active recording as a crash test case.
|
||||
/// Also grabs the pending in-flight event (if any) so the crash-triggering event is captured.
|
||||
/// Skips saving when replaying a recorded test (to avoid duplicate crash files).
|
||||
pub fn record_panic(
|
||||
panic_snapshot: &Arc<Mutex<Option<TestCase>>>,
|
||||
pending_event: &Arc<Mutex<Option<TestEvent>>>,
|
||||
is_replaying: &Arc<AtomicBool>,
|
||||
msg: String,
|
||||
backtrace: String,
|
||||
test_dir: &PathBuf,
|
||||
) {
|
||||
if is_replaying.load(Ordering::SeqCst) {
|
||||
eprintln!("[TEST MODE] Panic during replay — not saving duplicate crash file");
|
||||
return;
|
||||
}
|
||||
if let Ok(mut guard) = panic_snapshot.lock() {
|
||||
let mut test_case = guard.take().unwrap_or_else(|| {
|
||||
TestCase::new(
|
||||
"crash_capture".to_string(),
|
||||
CanvasState {
|
||||
zoom: 1.0,
|
||||
pan_offset: (0.0, 0.0),
|
||||
selected_tool: "Unknown".to_string(),
|
||||
fill_color: [0, 0, 0, 255],
|
||||
stroke_color: [0, 0, 0, 255],
|
||||
stroke_width: 3.0,
|
||||
fill_enabled: true,
|
||||
snap_enabled: true,
|
||||
polygon_sides: 5,
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
// Append the in-flight event that was being processed when the panic occurred
|
||||
if let Ok(mut pending) = pending_event.try_lock() {
|
||||
if let Some(event) = pending.take() {
|
||||
test_case.events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
test_case.ended_with_panic = true;
|
||||
test_case.panic_message = Some(msg);
|
||||
test_case.panic_backtrace = Some(backtrace);
|
||||
|
||||
let timestamp = format_timestamp();
|
||||
let path = test_dir.join(format!("crash_{}.json", timestamp));
|
||||
|
||||
if let Err(e) = test_case.save_to_file(&path) {
|
||||
eprintln!("[TEST MODE] Failed to save crash test case: {}", e);
|
||||
} else {
|
||||
eprintln!("[TEST MODE] Crash test case saved to: {}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the list of available test files
|
||||
pub fn refresh_test_list(&mut self) {
|
||||
self.available_tests.clear();
|
||||
if let Ok(entries) = std::fs::read_dir(&self.test_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(false, |ext| ext == "json") {
|
||||
self.available_tests.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.available_tests.sort();
|
||||
}
|
||||
|
||||
fn set_status(&mut self, msg: &str) {
|
||||
self.status_message = Some((msg.to_string(), Instant::now()));
|
||||
}
|
||||
|
||||
/// Update the panic snapshot with current ring buffer state
|
||||
fn update_panic_snapshot(&self) {
|
||||
if let Ok(mut guard) = self.panic_snapshot.try_lock() {
|
||||
let events: Vec<TestEvent> = self.event_ring.iter().cloned().collect();
|
||||
let mut snapshot = TestCase::new(
|
||||
"ring_buffer_snapshot".to_string(),
|
||||
CanvasState {
|
||||
zoom: 1.0,
|
||||
pan_offset: (0.0, 0.0),
|
||||
selected_tool: "Unknown".to_string(),
|
||||
fill_color: [0, 0, 0, 255],
|
||||
stroke_color: [0, 0, 0, 255],
|
||||
stroke_width: 3.0,
|
||||
fill_enabled: true,
|
||||
snap_enabled: true,
|
||||
polygon_sides: 5,
|
||||
},
|
||||
);
|
||||
snapshot.events = events;
|
||||
*guard = Some(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Replay frame ----
|
||||
|
||||
/// Result of stepping a replay frame — carries both mouse input and non-mouse actions
|
||||
#[derive(Default)]
|
||||
pub struct ReplayFrame {
|
||||
pub synthetic_input: Option<SyntheticInput>,
|
||||
pub tool_change: Option<String>,
|
||||
}
|
||||
|
||||
// ---- Sidebar UI ----
|
||||
|
||||
/// Render the test mode sidebar panel. Returns a ReplayFrame with actions to apply.
|
||||
pub fn render_sidebar(
|
||||
ctx: &egui::Context,
|
||||
state: &mut TestModeState,
|
||||
) -> ReplayFrame {
|
||||
if !state.active {
|
||||
return ReplayFrame::default();
|
||||
}
|
||||
|
||||
let mut frame = ReplayFrame::default();
|
||||
let mut action = SidebarAction::None;
|
||||
|
||||
egui::SidePanel::right("test_mode_panel")
|
||||
.default_width(300.0)
|
||||
.min_width(250.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("TEST MODE");
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("X").clicked() {
|
||||
state.active = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
// Status message (auto-clear after 5s)
|
||||
if let Some((ref msg, when)) = state.status_message {
|
||||
if when.elapsed().as_secs() < 5 {
|
||||
ui.colored_label(egui::Color32::YELLOW, msg);
|
||||
ui.separator();
|
||||
} else {
|
||||
state.status_message = None;
|
||||
}
|
||||
}
|
||||
|
||||
match &state.mode {
|
||||
TestModeOp::Idle => {
|
||||
render_idle_ui(ui, state, &mut action);
|
||||
}
|
||||
TestModeOp::Recording(_) => {
|
||||
render_recording_ui(ui, state, &mut action);
|
||||
}
|
||||
TestModeOp::Playing(_) => {
|
||||
frame = render_playing_ui(ui, state, &mut action);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Execute deferred actions (avoid borrow conflicts)
|
||||
match action {
|
||||
SidebarAction::None => {}
|
||||
SidebarAction::StartRecording(name, canvas) => {
|
||||
state.start_recording(name, canvas);
|
||||
}
|
||||
SidebarAction::StopRecording => {
|
||||
state.stop_recording();
|
||||
}
|
||||
SidebarAction::DiscardRecording => {
|
||||
state.discard_recording();
|
||||
}
|
||||
SidebarAction::LoadTest(path) => {
|
||||
state.load_test(&path);
|
||||
}
|
||||
SidebarAction::StopPlayback => {
|
||||
state.stop_playback();
|
||||
}
|
||||
}
|
||||
|
||||
// Update ghost cursor position from the replay frame
|
||||
if let Some(ref syn) = frame.synthetic_input {
|
||||
state.replay_cursor_pos = Some((syn.world_pos.x, syn.world_pos.y));
|
||||
} else if !matches!(state.mode, TestModeOp::Playing(_)) {
|
||||
state.replay_cursor_pos = None;
|
||||
}
|
||||
|
||||
frame
|
||||
}
|
||||
|
||||
enum SidebarAction {
|
||||
None,
|
||||
StartRecording(String, CanvasState),
|
||||
StopRecording,
|
||||
DiscardRecording,
|
||||
LoadTest(PathBuf),
|
||||
StopPlayback,
|
||||
}
|
||||
|
||||
fn render_idle_ui(ui: &mut egui::Ui, state: &mut TestModeState, action: &mut SidebarAction) {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Record").clicked() {
|
||||
let name = if state.new_test_name.is_empty() {
|
||||
format!("test_{}", format_timestamp())
|
||||
} else {
|
||||
state.new_test_name.clone()
|
||||
};
|
||||
// Default canvas state — will be overwritten by caller with real values
|
||||
let canvas = CanvasState {
|
||||
zoom: 1.0,
|
||||
pan_offset: (0.0, 0.0),
|
||||
selected_tool: "Select".to_string(),
|
||||
fill_color: [100, 100, 255, 255],
|
||||
stroke_color: [0, 0, 0, 255],
|
||||
stroke_width: 3.0,
|
||||
fill_enabled: true,
|
||||
snap_enabled: true,
|
||||
polygon_sides: 5,
|
||||
};
|
||||
*action = SidebarAction::StartRecording(name, canvas);
|
||||
}
|
||||
if ui.button("Load Test...").clicked() {
|
||||
if let Some(path) = rfd::FileDialog::new()
|
||||
.add_filter("JSON", &["json"])
|
||||
.set_directory(&state.test_dir)
|
||||
.pick_file()
|
||||
{
|
||||
*action = SidebarAction::LoadTest(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name:");
|
||||
ui.text_edit_singleline(&mut state.new_test_name);
|
||||
});
|
||||
|
||||
// List available tests
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Saved Tests").strong());
|
||||
|
||||
if state.available_tests.is_empty() {
|
||||
ui.colored_label(egui::Color32::GRAY, "(none)");
|
||||
} else {
|
||||
egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
|
||||
let mut load_path = None;
|
||||
for path in &state.available_tests {
|
||||
let name = path.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if ui.selectable_label(false, &name).clicked() {
|
||||
load_path = Some(path.clone());
|
||||
}
|
||||
}
|
||||
if let Some(path) = load_path {
|
||||
*action = SidebarAction::LoadTest(path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Ring buffer info
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.colored_label(
|
||||
egui::Color32::GRAY,
|
||||
format!("Ring buffer: {} events (auto-saved on crash)", state.event_ring.len()),
|
||||
);
|
||||
}
|
||||
|
||||
fn render_recording_ui(ui: &mut egui::Ui, state: &mut TestModeState, action: &mut SidebarAction) {
|
||||
let event_count = match &state.mode {
|
||||
TestModeOp::Recording(r) => r.test_case.events.len(),
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
ui.colored_label(egui::Color32::from_rgb(255, 80, 80),
|
||||
format!("Recording... ({} events)", event_count));
|
||||
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Stop & Save").clicked() {
|
||||
*action = SidebarAction::StopRecording;
|
||||
}
|
||||
if ui.button("Discard").clicked() {
|
||||
*action = SidebarAction::DiscardRecording;
|
||||
}
|
||||
});
|
||||
|
||||
// Show recent events
|
||||
if let TestModeOp::Recording(ref recorder) = state.mode {
|
||||
render_event_list(ui, &recorder.test_case.events, None, false);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_playing_ui(
|
||||
ui: &mut egui::Ui,
|
||||
state: &mut TestModeState,
|
||||
action: &mut SidebarAction,
|
||||
) -> ReplayFrame {
|
||||
let mut frame = ReplayFrame::default();
|
||||
|
||||
let (cursor, total, test_name, has_panic, panic_msg, auto_playing, delay_ms) = match &state.mode {
|
||||
TestModeOp::Playing(p) => {
|
||||
let (c, t) = p.progress();
|
||||
(
|
||||
c, t,
|
||||
p.test_case.name.clone(),
|
||||
p.test_case.ended_with_panic,
|
||||
p.test_case.panic_message.clone(),
|
||||
p.auto_playing,
|
||||
p.auto_play_delay_ms,
|
||||
)
|
||||
}
|
||||
_ => return ReplayFrame::default(),
|
||||
};
|
||||
|
||||
ui.label(format!("Test: {}", test_name));
|
||||
if has_panic {
|
||||
ui.colored_label(egui::Color32::RED, "Ended with PANIC");
|
||||
if let Some(ref msg) = panic_msg {
|
||||
let display_msg = if msg.len() > 120 { &msg[..120] } else { msg.as_str() };
|
||||
ui.colored_label(egui::Color32::from_rgb(255, 100, 100), display_msg);
|
||||
}
|
||||
}
|
||||
ui.label(format!("{}/{} events", cursor, total));
|
||||
|
||||
// Transport controls
|
||||
ui.horizontal(|ui| {
|
||||
// Reset
|
||||
if ui.button("|<").clicked() {
|
||||
if let TestModeOp::Playing(ref mut p) = state.mode {
|
||||
p.reset();
|
||||
}
|
||||
}
|
||||
// Step back (reset to cursor - 1)
|
||||
if ui.button("<Step").clicked() {
|
||||
if let TestModeOp::Playing(ref mut p) = state.mode {
|
||||
if p.cursor > 0 {
|
||||
p.cursor -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Step forward (batches consecutive move/drag events when skip is on)
|
||||
if ui.button("Step>").clicked() {
|
||||
if let TestModeOp::Playing(ref mut p) = state.mode {
|
||||
if let Some(event) = p.step_or_batch() {
|
||||
frame = event_to_replay_frame(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Auto-play toggle
|
||||
let auto_label = if auto_playing { "||Pause" } else { ">>Auto" };
|
||||
if ui.button(auto_label).clicked() {
|
||||
if let TestModeOp::Playing(ref mut p) = state.mode {
|
||||
p.auto_playing = !p.auto_playing;
|
||||
}
|
||||
}
|
||||
// Stop
|
||||
if ui.button("Stop").clicked() {
|
||||
*action = SidebarAction::StopPlayback;
|
||||
}
|
||||
});
|
||||
|
||||
// Speed slider
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Speed:");
|
||||
let mut delay = delay_ms as f32;
|
||||
if ui.add(egui::Slider::new(&mut delay, 10.0..=500.0).suffix("ms")).changed() {
|
||||
if let TestModeOp::Playing(ref mut p) = state.mode {
|
||||
p.auto_play_delay_ms = delay as u64;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Skip consecutive moves toggle
|
||||
if let TestModeOp::Playing(ref mut p) = state.mode {
|
||||
ui.checkbox(&mut p.skip_consecutive_moves, "Skip consecutive moves");
|
||||
}
|
||||
|
||||
// Auto-step
|
||||
if auto_playing {
|
||||
if let TestModeOp::Playing(ref mut p) = state.mode {
|
||||
if p.should_auto_step() {
|
||||
if let Some(event) = p.step_forward() {
|
||||
frame = event_to_replay_frame(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Request continuous repaint during auto-play
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
|
||||
// Event list
|
||||
if let TestModeOp::Playing(ref player) = state.mode {
|
||||
render_event_list(ui, &player.test_case.events, Some(player.cursor), player.skip_consecutive_moves);
|
||||
}
|
||||
|
||||
frame
|
||||
}
|
||||
|
||||
fn render_event_list(ui: &mut egui::Ui, events: &[TestEvent], cursor: Option<usize>, skip_moves: bool) {
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Events").strong());
|
||||
|
||||
// Build filtered index list when skip_moves is on
|
||||
let filtered: Vec<usize> = if skip_moves {
|
||||
filter_consecutive_moves(events)
|
||||
} else {
|
||||
(0..events.len()).collect()
|
||||
};
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false; 2])
|
||||
.max_height(ui.available_height() - 20.0)
|
||||
.show(ui, |ui| {
|
||||
// Find the cursor position within the filtered list
|
||||
let cursor_filtered_pos = cursor.and_then(|c| {
|
||||
// Find the filtered entry closest to (but not past) cursor
|
||||
filtered.iter().rposition(|&idx| idx < c)
|
||||
});
|
||||
let focus = cursor_filtered_pos.unwrap_or(filtered.len().saturating_sub(1));
|
||||
let start = focus.saturating_sub(50);
|
||||
let end = (focus + 50).min(filtered.len());
|
||||
|
||||
for &event_idx in &filtered[start..end] {
|
||||
let event = &events[event_idx];
|
||||
let is_current = cursor.map_or(false, |c| event.index == c.saturating_sub(1));
|
||||
let (prefix, color) = event_display_info(&event.kind, is_current);
|
||||
|
||||
let text = format!("{} #{} {}", prefix, event.index, format_event_kind(&event.kind));
|
||||
let label = egui::RichText::new(text).color(color).monospace();
|
||||
ui.label(label);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Filter event indices, keeping only the last of each consecutive run of same-type moves/drags
|
||||
fn filter_consecutive_moves(events: &[TestEvent]) -> Vec<usize> {
|
||||
let mut result = Vec::with_capacity(events.len());
|
||||
let mut i = 0;
|
||||
while i < events.len() {
|
||||
let disc = move_discriminant(&events[i].kind);
|
||||
if let Some(d) = disc {
|
||||
// Scan ahead to find the last in this consecutive run
|
||||
let mut last = i;
|
||||
while last + 1 < events.len() && move_discriminant(&events[last + 1].kind) == Some(d) {
|
||||
last += 1;
|
||||
}
|
||||
result.push(last);
|
||||
i = last + 1;
|
||||
} else {
|
||||
result.push(i);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn event_display_info(kind: &TestEventKind, is_current: bool) -> (&'static str, egui::Color32) {
|
||||
let prefix = if is_current { ">" } else { " " };
|
||||
let color = match kind {
|
||||
TestEventKind::MouseMove { .. } | TestEventKind::MouseDrag { .. } => {
|
||||
egui::Color32::from_gray(140)
|
||||
}
|
||||
TestEventKind::MouseDown { .. } | TestEventKind::MouseUp { .. } => {
|
||||
egui::Color32::from_gray(200)
|
||||
}
|
||||
TestEventKind::ToolChanged { .. } => egui::Color32::from_rgb(100, 150, 255),
|
||||
TestEventKind::ActionExecuted { .. } => egui::Color32::from_rgb(100, 255, 100),
|
||||
TestEventKind::KeyDown { .. } | TestEventKind::KeyUp { .. } => {
|
||||
egui::Color32::from_rgb(200, 200, 100)
|
||||
}
|
||||
TestEventKind::Scroll { .. } => egui::Color32::from_gray(160),
|
||||
};
|
||||
(prefix, color)
|
||||
}
|
||||
|
||||
fn format_event_kind(kind: &TestEventKind) -> String {
|
||||
match kind {
|
||||
TestEventKind::MouseDown { pos } => format!("MouseDown ({:.1}, {:.1})", pos.x, pos.y),
|
||||
TestEventKind::MouseUp { pos } => format!("MouseUp ({:.1}, {:.1})", pos.x, pos.y),
|
||||
TestEventKind::MouseDrag { pos } => format!("MouseDrag ({:.1}, {:.1})", pos.x, pos.y),
|
||||
TestEventKind::MouseMove { pos } => format!("MouseMove ({:.1}, {:.1})", pos.x, pos.y),
|
||||
TestEventKind::Scroll { delta_x, delta_y } => {
|
||||
format!("Scroll ({:.1}, {:.1})", delta_x, delta_y)
|
||||
}
|
||||
TestEventKind::KeyDown { key, .. } => format!("KeyDown {}", key),
|
||||
TestEventKind::KeyUp { key, .. } => format!("KeyUp {}", key),
|
||||
TestEventKind::ToolChanged { tool } => format!("ToolChanged: {}", tool),
|
||||
TestEventKind::ActionExecuted { description } => {
|
||||
format!("ActionExecuted: \"{}\"", description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a replayed TestEvent into a ReplayFrame carrying mouse input and/or tool changes
|
||||
fn event_to_replay_frame(event: &TestEvent) -> ReplayFrame {
|
||||
let mut frame = ReplayFrame::default();
|
||||
match &event.kind {
|
||||
TestEventKind::ToolChanged { tool } => {
|
||||
frame.tool_change = Some(tool.clone());
|
||||
}
|
||||
other => {
|
||||
frame.synthetic_input = event_kind_to_synthetic(other);
|
||||
}
|
||||
}
|
||||
frame
|
||||
}
|
||||
|
||||
/// Convert a mouse event kind into a SyntheticInput for the stage pane
|
||||
fn event_kind_to_synthetic(kind: &TestEventKind) -> Option<SyntheticInput> {
|
||||
match kind {
|
||||
TestEventKind::MouseDown { pos } => Some(SyntheticInput {
|
||||
world_pos: Point::new(pos.x, pos.y),
|
||||
drag_started: true,
|
||||
dragged: true, // In egui, drag_started() implies dragged() on the same frame
|
||||
drag_stopped: false,
|
||||
primary_down: true,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
}),
|
||||
TestEventKind::MouseDrag { pos } => Some(SyntheticInput {
|
||||
world_pos: Point::new(pos.x, pos.y),
|
||||
drag_started: false,
|
||||
dragged: true,
|
||||
drag_stopped: false,
|
||||
primary_down: true,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
}),
|
||||
TestEventKind::MouseUp { pos } => Some(SyntheticInput {
|
||||
world_pos: Point::new(pos.x, pos.y),
|
||||
drag_started: false,
|
||||
dragged: true, // In egui, dragged() is still true on the release frame
|
||||
drag_stopped: true,
|
||||
primary_down: false,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
}),
|
||||
TestEventKind::MouseMove { pos } => Some(SyntheticInput {
|
||||
world_pos: Point::new(pos.x, pos.y),
|
||||
drag_started: false,
|
||||
dragged: false,
|
||||
drag_stopped: false,
|
||||
primary_down: false,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
}),
|
||||
// Non-mouse events don't produce synthetic input (handled elsewhere)
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a discriminant for "batchable" mouse motion event types.
|
||||
/// Same-discriminant events are collapsed in the event list display
|
||||
/// and replayed as a single batch when stepping.
|
||||
/// Returns None for non-batchable events (clicks, tool changes, actions, etc.)
|
||||
fn move_discriminant(kind: &TestEventKind) -> Option<u8> {
|
||||
match kind {
|
||||
TestEventKind::MouseMove { .. } => Some(0),
|
||||
TestEventKind::MouseDrag { .. } => Some(1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize a string for use as a filename
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse a tool name (from Debug format) back into a Tool enum value
|
||||
pub fn parse_tool(name: &str) -> Option<lightningbeam_core::tool::Tool> {
|
||||
use lightningbeam_core::tool::Tool;
|
||||
match name {
|
||||
"Select" => Some(Tool::Select),
|
||||
"Draw" => Some(Tool::Draw),
|
||||
"Transform" => Some(Tool::Transform),
|
||||
"Rectangle" => Some(Tool::Rectangle),
|
||||
"Ellipse" => Some(Tool::Ellipse),
|
||||
"PaintBucket" => Some(Tool::PaintBucket),
|
||||
"Eyedropper" => Some(Tool::Eyedropper),
|
||||
"Line" => Some(Tool::Line),
|
||||
"Polygon" => Some(Tool::Polygon),
|
||||
"BezierEdit" => Some(Tool::BezierEdit),
|
||||
"Text" => Some(Tool::Text),
|
||||
"RegionSelect" => Some(Tool::RegionSelect),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format current time as a compact timestamp (no chrono dependency in editor crate)
|
||||
fn format_timestamp() -> String {
|
||||
use std::time::SystemTime;
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let secs = duration.as_secs();
|
||||
// Simple but unique timestamp: seconds since epoch
|
||||
// For human-readable format we'd need chrono, but this is fine for filenames
|
||||
format!("{}", secs)
|
||||
}
|
||||
|
|
@ -108,8 +108,16 @@ pub struct PendingUpload {
|
|||
pub samples: std::sync::Arc<Vec<f32>>,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u32,
|
||||
/// If set, only upload up to this many frames (for chunked uploads).
|
||||
/// The texture is allocated at full size, but total_frames is set to
|
||||
/// the limited count so subsequent calls use the incremental path.
|
||||
pub frame_limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Maximum frames to convert and upload per frame (~250K frames ≈ 5.6s at 44.1kHz).
|
||||
/// Keeps the CPU f32→f16 conversion under ~2-3ms per frame.
|
||||
pub const UPLOAD_CHUNK_FRAMES: usize = 250_000;
|
||||
|
||||
impl WaveformGpuResources {
|
||||
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
|
||||
// Render shader
|
||||
|
|
@ -282,18 +290,22 @@ impl WaveformGpuResources {
|
|||
samples: &[f32],
|
||||
sample_rate: u32,
|
||||
channels: u32,
|
||||
frame_limit: Option<usize>,
|
||||
) -> Vec<wgpu::CommandBuffer> {
|
||||
let new_total_frames = samples.len() / channels.max(1) as usize;
|
||||
if new_total_frames == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// For incremental path, also respect frame_limit
|
||||
let effective_frames = frame_limit.map_or(new_total_frames, |lim| lim.min(new_total_frames));
|
||||
|
||||
// If entry exists and texture is large enough, do an incremental update
|
||||
let incremental = if let Some(entry) = self.entries.get(&pool_index) {
|
||||
let new_tex_height = (new_total_frames as u32 + TEX_WIDTH - 1) / TEX_WIDTH;
|
||||
if new_tex_height <= entry.tex_height && new_total_frames > entry.total_frames as usize {
|
||||
if new_tex_height <= entry.tex_height && effective_frames > entry.total_frames as usize {
|
||||
Some((entry.total_frames as usize, entry.tex_height))
|
||||
} else if new_total_frames <= entry.total_frames as usize {
|
||||
} else if effective_frames <= entry.total_frames as usize {
|
||||
return Vec::new(); // No new data
|
||||
} else {
|
||||
None // Texture too small, need full recreate
|
||||
|
|
@ -305,7 +317,7 @@ impl WaveformGpuResources {
|
|||
if let Some((old_frames, tex_height)) = incremental {
|
||||
// Write only the NEW rows into the existing texture
|
||||
let start_row = old_frames as u32 / TEX_WIDTH;
|
||||
let end_row = (new_total_frames as u32 + TEX_WIDTH - 1) / TEX_WIDTH;
|
||||
let end_row = (effective_frames as u32 + TEX_WIDTH - 1) / TEX_WIDTH;
|
||||
let rows_to_write = end_row - start_row;
|
||||
|
||||
let row_texel_count = (TEX_WIDTH * rows_to_write) as usize;
|
||||
|
|
@ -314,7 +326,7 @@ impl WaveformGpuResources {
|
|||
let row_start_frame = start_row as usize * TEX_WIDTH as usize;
|
||||
for frame in 0..(rows_to_write as usize * TEX_WIDTH as usize) {
|
||||
let global_frame = row_start_frame + frame;
|
||||
if global_frame >= new_total_frames {
|
||||
if global_frame >= effective_frames {
|
||||
break;
|
||||
}
|
||||
let sample_offset = global_frame * channels as usize;
|
||||
|
|
@ -364,11 +376,11 @@ impl WaveformGpuResources {
|
|||
TEX_WIDTH,
|
||||
tex_height,
|
||||
mip_count,
|
||||
new_total_frames as u32,
|
||||
effective_frames as u32,
|
||||
);
|
||||
|
||||
// Update total_frames after borrow of entry is done
|
||||
self.entries.get_mut(&pool_index).unwrap().total_frames = new_total_frames as u64;
|
||||
self.entries.get_mut(&pool_index).unwrap().total_frames = effective_frames as u64;
|
||||
return cmds;
|
||||
}
|
||||
|
||||
|
|
@ -378,12 +390,15 @@ impl WaveformGpuResources {
|
|||
self.per_instance.retain(|&(pi, _), _| pi != pool_index);
|
||||
|
||||
let total_frames = new_total_frames;
|
||||
// Upload only effective_frames worth of data on this call
|
||||
let upload_frames = effective_frames;
|
||||
|
||||
// For live recording (pool_index == usize::MAX), pre-allocate extra texture
|
||||
// height to avoid frequent full recreates as recording grows.
|
||||
// Allocate 60 seconds ahead so incremental updates can fill without recreating.
|
||||
// When chunking, always allocate for the full total so incremental updates fit.
|
||||
let alloc_frames = if pool_index == usize::MAX {
|
||||
let extra = sample_rate as usize * 60; // 60s of mono frames (texture is per-frame, not per-sample)
|
||||
let extra = sample_rate as usize * 60;
|
||||
total_frames + extra
|
||||
} else {
|
||||
total_frames
|
||||
|
|
@ -411,12 +426,16 @@ impl WaveformGpuResources {
|
|||
let seg_end_frame = ((seg + 1) as u64 * frames_per_segment as u64)
|
||||
.min(total_frames as u64);
|
||||
let seg_frame_count = (seg_end_frame - seg_start_frame) as u32;
|
||||
// Limit actual data processing to upload_frames (for chunked uploads)
|
||||
let seg_upload_end = ((seg + 1) as u64 * frames_per_segment as u64)
|
||||
.min(upload_frames as u64);
|
||||
let seg_upload_count = seg_upload_end.saturating_sub(seg_start_frame) as u32;
|
||||
|
||||
// Allocate texture large enough for future growth (recording) or exact fit (normal)
|
||||
// Allocate texture large enough for the FULL data (not just this chunk)
|
||||
let alloc_seg_frames = if pool_index == usize::MAX {
|
||||
(alloc_frames as u32).min(seg_frame_count + sample_rate * 60)
|
||||
} else {
|
||||
seg_frame_count
|
||||
seg_frame_count // full size so incremental updates fit
|
||||
};
|
||||
let tex_height = (alloc_seg_frames + TEX_WIDTH - 1) / TEX_WIDTH;
|
||||
let mip_count = compute_mip_count(TEX_WIDTH, tex_height);
|
||||
|
|
@ -440,12 +459,12 @@ impl WaveformGpuResources {
|
|||
});
|
||||
|
||||
// Pack raw samples into Rgba16Float data for mip 0
|
||||
// Only pack rows containing actual data (not the pre-allocated empty region)
|
||||
let data_height = (seg_frame_count + TEX_WIDTH - 1) / TEX_WIDTH;
|
||||
// Only pack rows containing data uploaded this chunk
|
||||
let data_height = (seg_upload_count + TEX_WIDTH - 1) / TEX_WIDTH;
|
||||
let data_texel_count = (TEX_WIDTH * data_height) as usize;
|
||||
let mut mip0_data: Vec<half::f16> = vec![half::f16::ZERO; data_texel_count * 4];
|
||||
|
||||
for frame in 0..seg_frame_count as usize {
|
||||
for frame in 0..seg_upload_count as usize {
|
||||
let global_frame = seg_start_frame as usize + frame;
|
||||
let sample_offset = global_frame * channels as usize;
|
||||
|
||||
|
|
@ -490,14 +509,14 @@ impl WaveformGpuResources {
|
|||
);
|
||||
}
|
||||
|
||||
// Generate mipmaps via compute shader
|
||||
// Generate mipmaps via compute shader (only for uploaded data)
|
||||
let cmds = self.generate_mipmaps(
|
||||
device,
|
||||
&texture,
|
||||
TEX_WIDTH,
|
||||
tex_height,
|
||||
mip_count,
|
||||
seg_frame_count,
|
||||
seg_upload_count,
|
||||
);
|
||||
all_command_buffers.extend(cmds);
|
||||
|
||||
|
|
@ -549,7 +568,7 @@ impl WaveformGpuResources {
|
|||
render_bind_groups,
|
||||
uniform_buffers,
|
||||
frames_per_segment,
|
||||
total_frames: total_frames as u64,
|
||||
total_frames: upload_frames as u64, // only what was uploaded this chunk
|
||||
tex_height: (alloc_frames as u32 + TEX_WIDTH - 1) / TEX_WIDTH,
|
||||
sample_rate,
|
||||
channels,
|
||||
|
|
@ -681,6 +700,7 @@ impl egui_wgpu::CallbackTrait for WaveformCallback {
|
|||
&upload.samples,
|
||||
upload.sample_rate,
|
||||
upload.channels,
|
||||
upload.frame_limit,
|
||||
);
|
||||
cmds.extend(new_cmds);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CARGO_TOML="$(dirname "$0")/../lightningbeam-ui/lightningbeam-editor/Cargo.toml"
|
||||
CHANGELOG="$(dirname "$0")/../Changelog.md"
|
||||
|
||||
# Read current version
|
||||
current=$(grep '^version' "$CARGO_TOML" | head -1 | sed 's/.*"\(.*\)"/\1/')
|
||||
echo "Current version: $current"
|
||||
|
||||
# Extract numeric prefix (e.g. 1.0.1 from 1.0.1-alpha)
|
||||
base=${current%%-*}
|
||||
suffix=${current#"$base"}
|
||||
|
||||
# Split into major.minor.patch
|
||||
IFS='.' read -r major minor patch <<< "$base"
|
||||
|
||||
# Bump patch
|
||||
new_patch=$((patch + 1))
|
||||
new_version="${major}.${minor}.${new_patch}${suffix}"
|
||||
|
||||
# Check if version was already bumped this session (uncommitted change to Cargo.toml)
|
||||
if git -C "$(dirname "$CARGO_TOML")" diff --name-only HEAD -- "$(basename "$CARGO_TOML")" | grep -q .; then
|
||||
echo "Cargo.toml already modified — skipping version bump (staying at $current)"
|
||||
new_version="$current"
|
||||
else
|
||||
echo "Bumping to: $new_version"
|
||||
sed -i "0,/^version = \"$current\"/s//version = \"$new_version\"/" "$CARGO_TOML"
|
||||
fi
|
||||
|
||||
# Edit changelog
|
||||
vim "$CHANGELOG"
|
||||
|
||||
# Commit and push
|
||||
git add "$CARGO_TOML" "$CHANGELOG"
|
||||
git commit -m "Release v${new_version}"
|
||||
git push --force origin "$(git branch --show-current):release"
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
{
|
||||
"metadata": {
|
||||
"name": "Organ",
|
||||
"description": "Classic drawbar organ with vibrato and reverb (polyphonic)",
|
||||
"author": "Lightningbeam",
|
||||
"version": 1,
|
||||
"tags": ["organ", "keyboard", "drawbar"]
|
||||
},
|
||||
"midi_targets": [0],
|
||||
"output_node": 4,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 0,
|
||||
"node_type": "MidiInput",
|
||||
"name": "MIDI In",
|
||||
"parameters": {},
|
||||
"position": [100.0, 200.0]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"node_type": "VoiceAllocator",
|
||||
"name": "Voice Allocator",
|
||||
"parameters": {
|
||||
"0": 8.0
|
||||
},
|
||||
"position": [400.0, 200.0],
|
||||
"template_graph": {
|
||||
"metadata": {
|
||||
"name": "Voice Template",
|
||||
"description": "Per-voice organ patch with drawbar harmonics",
|
||||
"author": "Lightningbeam",
|
||||
"version": 1,
|
||||
"tags": []
|
||||
},
|
||||
"midi_targets": [0],
|
||||
"output_node": 15,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 0,
|
||||
"node_type": "TemplateInput",
|
||||
"name": "Template Input",
|
||||
"parameters": {},
|
||||
"position": [-200.0, 0.0]
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"node_type": "MidiToCV",
|
||||
"name": "MIDI→CV",
|
||||
"parameters": {},
|
||||
"position": [100.0, 0.0]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"node_type": "Constant",
|
||||
"name": "-1V",
|
||||
"parameters": {
|
||||
"0": -1.0
|
||||
},
|
||||
"position": [250.0, -300.0]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"node_type": "Constant",
|
||||
"name": "+1V",
|
||||
"parameters": {
|
||||
"0": 1.0
|
||||
},
|
||||
"position": [250.0, 200.0]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"node_type": "Oscillator",
|
||||
"name": "16'",
|
||||
"parameters": {
|
||||
"0": 440.0,
|
||||
"1": 0.8,
|
||||
"2": 0.0
|
||||
},
|
||||
"position": [700.0, -300.0]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"node_type": "Oscillator",
|
||||
"name": "8'",
|
||||
"parameters": {
|
||||
"0": 440.0,
|
||||
"1": 0.65,
|
||||
"2": 0.0
|
||||
},
|
||||
"position": [700.0, -100.0]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"node_type": "Math",
|
||||
"name": "Oct Down",
|
||||
"parameters": {
|
||||
"0": 0.0
|
||||
},
|
||||
"position": [450.0, -300.0]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"node_type": "Math",
|
||||
"name": "Oct Up",
|
||||
"parameters": {
|
||||
"0": 0.0
|
||||
},
|
||||
"position": [450.0, 200.0]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"node_type": "Oscillator",
|
||||
"name": "4'",
|
||||
"parameters": {
|
||||
"0": 440.0,
|
||||
"1": 0.4,
|
||||
"2": 0.0
|
||||
},
|
||||
"position": [700.0, 100.0]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"node_type": "Constant",
|
||||
"name": "+1.585V",
|
||||
"parameters": {
|
||||
"0": 1.585
|
||||
},
|
||||
"position": [250.0, 400.0]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"node_type": "Math",
|
||||
"name": "Twelfth",
|
||||
"parameters": {
|
||||
"0": 0.0
|
||||
},
|
||||
"position": [450.0, 400.0]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"node_type": "Oscillator",
|
||||
"name": "2 2/3'",
|
||||
"parameters": {
|
||||
"0": 440.0,
|
||||
"1": 0.3,
|
||||
"2": 0.0
|
||||
},
|
||||
"position": [700.0, 300.0]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"node_type": "ADSR",
|
||||
"name": "Amp Env",
|
||||
"parameters": {
|
||||
"0": 0.005,
|
||||
"1": 0.05,
|
||||
"2": 0.9,
|
||||
"3": 0.08
|
||||
},
|
||||
"position": [700.0, 500.0]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"node_type": "Mixer",
|
||||
"name": "Drawbars",
|
||||
"parameters": {
|
||||
"0": 0.7,
|
||||
"1": 1.0,
|
||||
"2": 0.6,
|
||||
"3": 0.4
|
||||
},
|
||||
"position": [1000.0, 0.0]
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"node_type": "Gain",
|
||||
"name": "VCA",
|
||||
"parameters": {
|
||||
"0": 1.0
|
||||
},
|
||||
"position": [1250.0, 0.0]
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"node_type": "TemplateOutput",
|
||||
"name": "Template Output",
|
||||
"parameters": {},
|
||||
"position": [1500.0, 0.0]
|
||||
}
|
||||
],
|
||||
"connections": [
|
||||
{ "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 },
|
||||
{ "from_node": 1, "from_port": 0, "to_node": 5, "to_port": 0 },
|
||||
{ "from_node": 1, "from_port": 0, "to_node": 6, "to_port": 0 },
|
||||
{ "from_node": 2, "from_port": 0, "to_node": 6, "to_port": 1 },
|
||||
{ "from_node": 6, "from_port": 0, "to_node": 4, "to_port": 0 },
|
||||
{ "from_node": 1, "from_port": 0, "to_node": 7, "to_port": 0 },
|
||||
{ "from_node": 3, "from_port": 0, "to_node": 7, "to_port": 1 },
|
||||
{ "from_node": 7, "from_port": 0, "to_node": 8, "to_port": 0 },
|
||||
{ "from_node": 1, "from_port": 0, "to_node": 10, "to_port": 0 },
|
||||
{ "from_node": 9, "from_port": 0, "to_node": 10, "to_port": 1 },
|
||||
{ "from_node": 10, "from_port": 0, "to_node": 11, "to_port": 0 },
|
||||
{ "from_node": 1, "from_port": 1, "to_node": 12, "to_port": 0 },
|
||||
{ "from_node": 4, "from_port": 0, "to_node": 13, "to_port": 0 },
|
||||
{ "from_node": 5, "from_port": 0, "to_node": 13, "to_port": 1 },
|
||||
{ "from_node": 8, "from_port": 0, "to_node": 13, "to_port": 2 },
|
||||
{ "from_node": 11, "from_port": 0, "to_node": 13, "to_port": 3 },
|
||||
{ "from_node": 13, "from_port": 0, "to_node": 14, "to_port": 0 },
|
||||
{ "from_node": 12, "from_port": 0, "to_node": 14, "to_port": 1 },
|
||||
{ "from_node": 14, "from_port": 0, "to_node": 15, "to_port": 0 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"node_type": "Vibrato",
|
||||
"name": "Vibrato",
|
||||
"parameters": {
|
||||
"0": 6.0,
|
||||
"1": 0.15
|
||||
},
|
||||
"position": [700.0, 200.0]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"node_type": "Reverb",
|
||||
"name": "Reverb",
|
||||
"parameters": {
|
||||
"0": 0.4,
|
||||
"1": 0.5,
|
||||
"2": 0.3
|
||||
},
|
||||
"position": [1000.0, 200.0]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"node_type": "AudioOutput",
|
||||
"name": "Out",
|
||||
"parameters": {},
|
||||
"position": [1300.0, 200.0]
|
||||
}
|
||||
],
|
||||
"connections": [
|
||||
{ "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 },
|
||||
{ "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 },
|
||||
{ "from_node": 2, "from_port": 0, "to_node": 3, "to_port": 0 },
|
||||
{ "from_node": 3, "from_port": 0, "to_node": 4, "to_port": 0 }
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue