Compare commits

...

23 Commits

Author SHA1 Message Date
Skyler Lehmkuhl 78577babb1 draw thumbnails on group clips too 2026-03-01 09:44:06 -05:00
Skyler Lehmkuhl b3e1da3152 draw thumbnails on video clips 2026-03-01 09:35:31 -05:00
Skyler Lehmkuhl 6bd400d353 fix lag spike when first displaying audio clip 2026-03-01 09:22:18 -05:00
Skyler Lehmkuhl b87e4325c2 use group layers instead of linked tracks 2026-03-01 09:00:55 -05:00
Skyler Lehmkuhl 520776c6e5 background color 2026-03-01 06:37:10 -05:00
Skyler Lehmkuhl f8df4d1232 Add webcam support in video editor 2026-03-01 06:25:43 -05:00
Skyler Lehmkuhl 52b12204d4 make release script 2026-03-01 04:33:29 -05:00
Skyler Lehmkuhl 5d39627d03 Update version to 1.0.1 2026-03-01 03:17:41 -05:00
Skyler Lehmkuhl 0026ad3e02 fix dcel 2026-03-01 03:03:57 -05:00
Skyler Lehmkuhl 9edfc2086a work on dcel 2026-03-01 00:35:02 -05:00
Skyler Lehmkuhl 14a2b0a4c2 paint bucket back to functionality 2026-02-28 13:06:41 -05:00
Skyler Lehmkuhl 1462df308f Add organ preset 2026-02-26 19:30:56 -05:00
Skyler Lehmkuhl 5a19e91788 add vibrato node 2026-02-26 19:14:34 -05:00
Skyler Lehmkuhl dc27cf253d rewrite dcel 2026-02-26 18:48:21 -05:00
Skyler Lehmkuhl 1621602f41 center stage 2026-02-25 07:57:50 -05:00
Skyler Lehmkuhl 7c37e69687 adjust default layouts 2026-02-25 07:55:26 -05:00
Skyler Lehmkuhl 63c1ba8854 use start screen for new file 2026-02-25 07:42:52 -05:00
Skyler Lehmkuhl 1cc7029321 make keyboard shortcuts configurable 2026-02-25 07:36:53 -05:00
Skyler Lehmkuhl 353aec3513 Fix panic with vertex deduplication 2026-02-25 07:02:09 -05:00
Skyler Lehmkuhl 4c34c8a17d Add snapping for vector editing 2026-02-25 03:29:42 -05:00
Skyler Lehmkuhl 2b63fdd2c5 fix build assets 2026-02-25 03:29:11 -05:00
Skyler Lehmkuhl 543d99e5d5 fix rpm build 2026-02-25 01:14:33 -05:00
Skyler Lehmkuhl 4195005455 License under GPLv3 2026-02-25 00:52:06 -05:00
58 changed files with 11229 additions and 771 deletions

View File

@ -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:

View File

@ -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)

674
LICENSE Normal file
View File

@ -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>.

View File

@ -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,

View File

@ -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
}
}

View File

@ -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"

View File

@ -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;

View File

@ -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()
}

View File

@ -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());
}
}
}
}

View File

@ -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 {

View File

@ -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;

View File

@ -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

View File

@ -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());

View File

@ -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);

View File

@ -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)
}

View File

@ -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 {

View File

@ -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;

View File

@ -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()
}
}
}

View File

@ -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(())
}

View File

@ -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

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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(&region_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(&region_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"
);
}
}
}

View File

@ -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
}

View File

@ -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);
}
}
}

View File

@ -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(&region, &[]);
// 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(&region, &[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

View File

@ -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;

View File

@ -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(

View File

@ -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,
}
}

View File

@ -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;

View File

@ -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(

View File

@ -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,
);
}
}
}

View File

@ -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,
}

View File

@ -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());
}
}

View File

@ -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))
}
}

View File

@ -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,
})
}
}

View File

@ -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)

View File

@ -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" }
]
}
]
}
]
}
}
]

View File

@ -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(),
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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>) {

View File

@ -1262,6 +1262,9 @@ impl AssetLibraryPane {
}
}
}
lightningbeam_core::layer::AnyLayer::Group(_) => {
// Group layers don't have their own clip instances
}
}
}
false

View File

@ -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:");

View File

@ -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

View File

@ -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);

View File

@ -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()

View File

@ -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

View File

@ -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),
})
}
}

View File

@ -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)
}

View File

@ -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);
}

37
scripts/release.sh Executable file
View File

@ -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"

View File

@ -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 }
]
}