aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSamuel Freilich <sfreilich@google.com>2022-03-28 12:51:25 -0400
committerSamuel Freilich <sfreilich@google.com>2022-03-28 15:23:35 -0400
commit715f1592070b613eb965113826e67ec72ac7283b (patch)
tree2121159be508e1b0ca7b94bc015e8d5ca5a237a5
parent2aac1188e2dfae73361c2cd9d7464e5fd21c1010 (diff)
parent789fe66bb8fecd066288d6b7b4466da5d3026ded (diff)
downloadink-stroke-modeler-715f1592070b613eb965113826e67ec72ac7283b.tar.gz
Sync external/ink-stroke-modeler to upstream head
Change-Id: I4fa402384d71c1dc0d9f000057c0ebec7542011b
-rw-r--r--.bazelignore4
-rw-r--r--.bazelrc2
-rw-r--r--.github/workflows/bazel-test.yaml18
-rw-r--r--.github/workflows/cmake-test.yaml24
-rw-r--r--.gitignore12
-rw-r--r--BUILD28
-rw-r--r--CMakeLists.txt78
-rw-r--r--CODE_OF_CONDUCT.md92
-rw-r--r--CONTRIBUTING.md29
-rw-r--r--LICENSE202
-rw-r--r--README.md829
-rw-r--r--SECURITY.md4
-rw-r--r--WORKSPACE16
-rw-r--r--_config.yml2
-rw-r--r--_includes/head-custom.html8
-rw-r--r--cmake/InkBazelEquivalents.cmake45
-rw-r--r--ink_stroke_modeler/BUILD.bazel97
-rw-r--r--ink_stroke_modeler/CMakeLists.txt102
-rw-r--r--ink_stroke_modeler/internal/BUILD.bazel143
-rw-r--r--ink_stroke_modeler/internal/CMakeLists.txt156
-rw-r--r--ink_stroke_modeler/internal/internal_types.h73
-rw-r--r--ink_stroke_modeler/internal/position_modeler.h138
-rw-r--r--ink_stroke_modeler/internal/position_modeler_test.cc317
-rw-r--r--ink_stroke_modeler/internal/prediction/BUILD.bazel83
-rw-r--r--ink_stroke_modeler/internal/prediction/CMakeLists.txt86
-rw-r--r--ink_stroke_modeler/internal/prediction/input_predictor.h57
-rw-r--r--ink_stroke_modeler/internal/prediction/kalman_filter/BUILD.bazel58
-rw-r--r--ink_stroke_modeler/internal/prediction/kalman_filter/CMakeLists.txt54
-rw-r--r--ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor.cc98
-rw-r--r--ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor.h62
-rw-r--r--ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor_test.cc100
-rw-r--r--ink_stroke_modeler/internal/prediction/kalman_filter/kalman_filter.cc79
-rw-r--r--ink_stroke_modeler/internal/prediction/kalman_filter/kalman_filter.h96
-rw-r--r--ink_stroke_modeler/internal/prediction/kalman_filter/matrix.h270
-rw-r--r--ink_stroke_modeler/internal/prediction/kalman_filter/matrix_test.cc215
-rw-r--r--ink_stroke_modeler/internal/prediction/kalman_predictor.cc265
-rw-r--r--ink_stroke_modeler/internal/prediction/kalman_predictor.h103
-rw-r--r--ink_stroke_modeler/internal/prediction/kalman_predictor_test.cc215
-rw-r--r--ink_stroke_modeler/internal/prediction/stroke_end_predictor.cc52
-rw-r--r--ink_stroke_modeler/internal/prediction/stroke_end_predictor.h58
-rw-r--r--ink_stroke_modeler/internal/prediction/stroke_end_predictor_test.cc137
-rw-r--r--ink_stroke_modeler/internal/stylus_state_modeler.cc101
-rw-r--r--ink_stroke_modeler/internal/stylus_state_modeler.h82
-rw-r--r--ink_stroke_modeler/internal/stylus_state_modeler_test.cc269
-rw-r--r--ink_stroke_modeler/internal/type_matchers.cc69
-rw-r--r--ink_stroke_modeler/internal/type_matchers.h42
-rw-r--r--ink_stroke_modeler/internal/utils.h99
-rw-r--r--ink_stroke_modeler/internal/utils_test.cc87
-rw-r--r--ink_stroke_modeler/internal/validation.h48
-rw-r--r--ink_stroke_modeler/internal/validation_test.cc82
-rw-r--r--ink_stroke_modeler/internal/wobble_smoother.cc70
-rw-r--r--ink_stroke_modeler/internal/wobble_smoother.h57
-rw-r--r--ink_stroke_modeler/internal/wobble_smoother_test.cc104
-rw-r--r--ink_stroke_modeler/params.cc157
-rw-r--r--ink_stroke_modeler/params.h224
-rw-r--r--ink_stroke_modeler/params_test.cc259
-rw-r--r--ink_stroke_modeler/stroke_modeler.cc253
-rw-r--r--ink_stroke_modeler/stroke_modeler.h99
-rw-r--r--ink_stroke_modeler/stroke_modeler_test.cc1395
-rw-r--r--ink_stroke_modeler/types.cc36
-rw-r--r--ink_stroke_modeler/types.h356
-rw-r--r--ink_stroke_modeler/types_test.cc212
-rw-r--r--position_model.svg183
-rw-r--r--workspace.bzl70
64 files changed, 8861 insertions, 0 deletions
diff --git a/.bazelignore b/.bazelignore
new file mode 100644
index 0000000..ef8804c
--- /dev/null
+++ b/.bazelignore
@@ -0,0 +1,4 @@
+bin
+lib
+Testing
+_deps
diff --git a/.bazelrc b/.bazelrc
new file mode 100644
index 0000000..81e2812
--- /dev/null
+++ b/.bazelrc
@@ -0,0 +1,2 @@
+# Set up to use C++17.
+build --cxxopt='-std=c++17' \ No newline at end of file
diff --git a/.github/workflows/bazel-test.yaml b/.github/workflows/bazel-test.yaml
new file mode 100644
index 0000000..9234190
--- /dev/null
+++ b/.github/workflows/bazel-test.yaml
@@ -0,0 +1,18 @@
+# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
+name: BazelTest
+
+on: [push, workflow_dispatch]
+
+jobs:
+ bazel_test:
+ strategy:
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Test
+ run: bazel test --test_output=errors //...
diff --git a/.github/workflows/cmake-test.yaml b/.github/workflows/cmake-test.yaml
new file mode 100644
index 0000000..a5eeaea
--- /dev/null
+++ b/.github/workflows/cmake-test.yaml
@@ -0,0 +1,24 @@
+# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
+name: CMakeTest
+
+on: [push, workflow_dispatch]
+
+jobs:
+ cmake_test:
+ strategy:
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Configure CMake
+ run: cmake .
+
+ - name: Build
+ run: cmake --build .
+
+ - name: Test
+ run: ctest
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0c0c7e7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+*\#
+*~
+/bazel-*
+/bin/
+/lib/
+/Testing/
+/_deps/
+CMakeCache.txt
+CMakeFiles
+cmake_install.cmake
+CTestTestfile.cmake
+Makefile
diff --git a/BUILD b/BUILD
new file mode 100644
index 0000000..cefdd4d
--- /dev/null
+++ b/BUILD
@@ -0,0 +1,28 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])
+
+exports_files(["LICENSE"])
+
+# Use this when testonly libraries must depend on :gtest, that requires
+# different behavior in the upstream repo.
+alias(
+ name = "gtest_for_library_testonly",
+ testonly = 1,
+ actual = "@com_google_googletest//:gtest",
+ visibility = ["//:__subpackages__"],
+)
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..ae976a4
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,78 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+cmake_minimum_required(VERSION 3.19)
+Project(InkStrokeModeler VERSION 0.1 LANGUAGES CXX)
+
+enable_testing()
+
+if(CMAKE_SOURCE_DIR STREQUAL PROJECT_SOURCE_DIR)
+ set(CMAKE_CXX_STANDARD 17)
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
+endif()
+
+include(FetchContent)
+
+option(INK_STROKE_MODELER_FIND_GTEST
+ "If ON, use find_package to load an existing GoogleTest dependency."
+ OFF)
+
+option(INK_STROKE_MODELER_FIND_ABSL
+ "If ON, use find_package to load an existing Abseil dependency."
+ OFF)
+
+if(INK_STROKE_MODELER_FIND_GTEST)
+ find_package(gtest REQUIRED)
+else()
+ FetchContent_Declare(
+ gtest
+ GIT_REPOSITORY https://github.com/google/googletest.git
+ GIT_TAG release-1.11.0
+ GIT_SHALLOW TRUE
+ GIT_PROGRESS TRUE
+ )
+ FetchContent_MakeAvailable(gtest)
+endif()
+
+set(ABSL_PROPAGATE_CXX_STD ON)
+# No reason to get two different versions of Googletest.
+set(ABSL_USE_EXTERNAL_GOOGLETEST ON)
+set(ABSL_FIND_GOOGLETEST ON)
+if(INK_STROKE_MODELER_FIND_ABSL)
+ find_package(absl REQUIRED)
+else()
+ FetchContent_Declare(
+ absl
+ GIT_REPOSITORY https://github.com/abseil/abseil-cpp.git
+ GIT_TAG 20211102.0
+ GIT_SHALLOW TRUE
+ GIT_PROGRESS TRUE
+ )
+ FetchContent_MakeAvailable(absl)
+endif()
+
+if(CMAKE_CXX_STANDARD LESS 17)
+ message(FATAL_ERROR
+ "${PROJECT_NAME} requires CMAKE_CXX_STANDARD >= 17 (got: ${CMAKE_CXX_STANDARD})")
+endif()
+
+include_directories("${CMAKE_SOURCE_DIR}")
+list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake")
+include(InkBazelEquivalents)
+
+set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
+set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
+set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
+
+add_subdirectory(ink_stroke_modeler)
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..7b02820
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,92 @@
+# Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, gender identity and expression, level of
+experience, education, socio-economic status, nationality, personal appearance,
+race, religion, or sexual identity and orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+ advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+ address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, or to ban temporarily or permanently any
+contributor for other behaviors that they deem inappropriate, threatening,
+offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+This Code of Conduct also applies outside the project spaces when the Project
+Steward has a reasonable belief that an individual's behavior may have a
+negative impact on the project or its community.
+
+## Conflict Resolution
+
+We do not believe that all conflict is bad; healthy debate and disagreement
+often yield positive results. However, it is never okay to be disrespectful or
+to engage in behavior that violates the project’s code of conduct.
+
+If you see someone violating the code of conduct, you are encouraged to address
+the behavior directly with those involved. Many issues can be resolved quickly
+and easily, and this gives people more control over the outcome of their
+dispute. If you are unable to resolve the matter for any reason, or if the
+behavior is threatening or harassing, report it. We are dedicated to providing
+an environment where participants feel welcome and safe.
+
+Reports should be directed to Jonathan Feinberg (feinberg@google.com), the
+Project Steward for Ink. It is the Project Steward’s duty to receive and address
+reported violations of the code of conduct. They will then work with a committee
+consisting of representatives from the Open Source Programs Office and the
+Google Open Source Strategy team. If for any reason you are uncomfortable
+reaching out to the Project Steward, please email opensource@google.com.
+
+We will investigate every complaint, but you may not receive a direct response.
+We will use our discretion in determining when and how to follow up on reported
+incidents, which may range from not taking action to permanent expulsion from
+the project and project-sponsored spaces. We will notify the accused of the
+report and provide them an opportunity to discuss it before any action is taken.
+The identity of the reporter will be omitted from the details of the report
+supplied to the accused. In potentially harmful situations, such as ongoing
+harassment or threats to anyone's safety, we may take action without notice.
+
+## Attribution
+
+This Code of Conduct is adapted from the Contributor Covenant, version 1.4,
+available at
+https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..e5c3b28
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,29 @@
+# How to Contribute
+
+We'd love to accept your patches and contributions to this project. There are
+just a few small guidelines you need to follow.
+
+## Contributor License Agreement
+
+Contributions to this project must be accompanied by a Contributor License
+Agreement (CLA). You (or your employer) retain the copyright to your
+contribution; this simply gives us permission to use and redistribute your
+contributions as part of the project. Head over to
+<https://cla.developers.google.com/> to see your current agreements on file or
+to sign a new one.
+
+You generally only need to submit a CLA once, so if you've already submitted one
+(even if it was for a different project), you probably don't need to do it
+again.
+
+## Code Reviews
+
+All submissions, including submissions by project members, require review. We
+use GitHub pull requests for this purpose. Consult
+[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
+information on using pull requests.
+
+## Community Guidelines
+
+This project follows
+[Google's Open Source Community Guidelines](https://opensource.google/conduct/).
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4823b96
--- /dev/null
+++ b/README.md
@@ -0,0 +1,829 @@
+# Ink Stroke Modeler
+
+[![BazelTest](https://github.com/google/ink-stroke-modeler/actions/workflows/bazel-test.yaml/badge.svg)](https://github.com/google/ink-stroke-modeler/actions/workflows/bazel-test.yaml)
+[![CMakeTest](https://github.com/google/ink-stroke-modeler/actions/workflows/cmake-test.yaml/badge.svg)](https://github.com/google/ink-stroke-modeler/actions/workflows/cmake-test.yaml)
+
+This library smooths raw freehand input and predicts the input's motion to
+minimize display latency. It turns noisy pointer input from touch/stylus/etc.
+into the beautiful stroke patterns of brushes/markers/pens/etc.
+
+Be advised that this library was designed to model handwriting, and as such,
+prioritizes smooth, good-looking curves over precise recreation of the input.
+
+This library is designed to have minimal dependencies; the library itself relies
+only on the C++ Standard Library and [Abseil](https://abseil.io/), and the tests
+use the [GoogleTest](https://google.github.io/googletest/) framework.
+
+## Build System
+
+### Bazel
+
+Ink Stroke Modeler can be built and the tests run from the GitHub repo root
+with:
+
+```shell
+bazel test ...
+```
+
+To use Ink Stroke Modeler in another Bazel project, put the following in the
+`WORKSPACE` file to download the code from GitHub head and set up dependencies:
+
+```bazel
+load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
+
+git_repository(
+ name = "ink_stroke_modeler",
+ remote = "https://github.com/google/ink-stroke-modeler.git",
+ branch = "main",
+)
+load("@ink_stroke_modeler//:workspace.bzl", "ink_stroke_modeler_workspace")
+ink_stroke_modeler_workspace()
+```
+
+If you want to depend on a specific version, you can change the options passed
+to [`git_repository`](https://bazel.build/rules/lib/repo/git#git_repository). Or
+if you want to use a local checkout of Ink Stroke Modeler instead, use the
+[`local_repository`](https://bazel.build/reference/be/workspace#local_repository)
+workspace rule instead of `git_repository`.
+
+Since Ink Stroke Modler requires C++17, it must be built with
+`--cxxopt='-std=c++17'` (or similar indicating a newer version). You can put the
+following in your project's `.bazelrc` to use this by default:
+
+```none
+build --cxxopt='-std=c++17'
+```
+
+Then you can include the following in your targets' `deps`:
+
+* `@ink_stroke_modeler//ink_stroke_modeler:stroke_modeler`:
+ [`ink_stroke_modeler/stroke_modeler.h`](ink_stroke_modeler/stroke_modeler.h)
+* `@ink_stroke_modeler//ink_stroke_modeler:types`:
+ [`ink_stroke_modeler/types.h`](ink_stroke_modeler/types.h)
+* `@ink_stroke_modeler//ink_stroke_modeler:params`:
+ [`ink_stroke_modeler/types.h`](ink_stroke_modeler/params.h)
+
+### CMake
+
+Ink Stroke Modeler can be built and the tests run from the GitHub repo root
+with:
+
+```shell
+cmake .
+cmake --build .
+ctest
+```
+
+To use Ink Stroke Modeler in another CMake project, you can add the project as a
+submodule:
+
+```shell
+git submodule add https://github.com/google/ink-stroke-modeler
+```
+
+And then include it in your `CMakeLists.txt`, requiring at least C++17:
+
+```cmake
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+# If you want to use installed (or already fetched) versions of Abseil and/or
+# GTest (for example, if you've installed libabsl-dev and libgtest-dev), add:
+# set(INK_STROKE_MODELER_FIND_ABSL ON)
+# set(INK_STROKE_MODELER_FIND_GTEST ON)
+add_subdirectory(ink-stroke-modeler)
+```
+
+Then you can depend on the following with `target_link_libraries`:
+
+* `InkStrokeModeler::stroke_modeler`: `ink_stroke_modeler/stroke_modeler.h`
+* `InkStrokeModeler::types`: `ink_stroke_modeler/types.h`
+* `InkStrokeModeler::params`: `ink_stroke_modeler/types.h`
+
+CMake does not have a mechanism for enforcing target visibility, but consumers
+should depend only on the non-test top-level targets defined in
+`ink_stroke_modeler/CMakeLists.txt`.
+
+The CMake build uses the abstractions defined in
+`cmake/InkBazelEquivalents.cmake` to structure targets in a way that's similar
+to the Bazel BUILD files to make it easier to keep those in sync. The main
+difference is that CMake has a flat structure for target names, e.g.
+`@com_google_absl//absl/types:optional` in Bazel is `absl::optional` in CMake.
+This means that targets need to be given unique names within the entire project
+in CMake.
+
+## Usage
+
+The Ink Stroke Modeler API is in the namespace `ink::stroke_model`. The primary
+surface of the API is the `StrokeModeler` class, found in `stroke_modeler.h`.
+You'll also simple data types and operators in `types.h`, and the parameters
+which determine the behavior of the model in `params.h`.
+
+To begin, you'll need choose parameters for the model (the parameters for your
+use case may vary; see `params.h`), instantiate a `StrokeModeler`, and set the
+parameters via `StrokeModeler::Reset()`:
+
+```c++
+StrokeModelParams params{
+ .wobble_smoother_params{
+ .timeout{.04}, .speed_floor = 1.31, .speed_ceiling = 1.44},
+ .position_modeler_params{.spring_mass_constant = 11.f / 32400,
+ .drag_constant = 72.f},
+ .sampling_params{.min_output_rate = 180,
+ .end_of_stroke_stopping_distance = .001,
+ .end_of_stroke_max_iterations = 20},
+ .stylus_state_modeler_params{.max_input_samples = 20},
+ .prediction_params = StrokeEndPredictorParams()};
+
+StrokeModeler modeler;
+if (absl::Status status = modeler.Reset(params); !status.ok()) {
+ // Handle error.
+}
+```
+
+You may also call `Reset()` on an already-initialized `StrokeModeler` to set new
+parameters; this also clears the in-progress stroke, if any. If the `Update` or
+`Predict` functions are called before `Reset`, they will return
+`absl::FailedPreconditionError`.
+
+To use the model, pass in an `Input` object to `StrokeModeler::Update()` each
+time you recieve an input event:
+
+```c++
+Input input{
+ .event_type = Input::EventType::kDown,
+ .position{2, 3}, // x, y coordinates in some consistent units
+ .time{.2}, // Time in some consistent units
+ // The following fields are optional:
+ .pressure = .2, // Pressure from 0 to 1
+ .orientation = M_PI // Angle in plane of screen in radians
+ .tilt = 0, // Angle elevated from plane of screen in radians
+};
+absl::StatusOr<std::vector<Result>> result = modeler.Update(input);
+if (status.ok()) {
+ std::vector<Result> smoothed_input = *result;
+ // Do something with the result.
+} else {
+ absl::Status error_status = result.status();
+ // Handle error.
+}
+```
+
+`Input`s are expected to come in a *stream*, starting with a `kDown` event,
+followed by zero or more `kMove` events, and ending with a `kUp` event. If the
+modeler is given out-of-order inputs or duplicate inputs, it will return
+`absl::InvalidArgumentError`. `Input.time` may not be before the `time` of the
+previous input.
+
+`Input`s are expected to come from a single stroke produced with an input
+device. Extraneous inputs, like touches with the palm of the hand in
+touch-screen stylus input, should be filtered before passing the correct input
+events to `Update`. If the input device allows for multiple strokes at once (for
+example, drawing with two fingers simultaneously), the inputs for each stroke
+must be passed to separate `StrokeModler` instances.
+
+The `time` values of the returned `Result` objects start at the `time` of the
+first input and end either at the time of the last `Input` (for in-progress
+strokes) or a hair after (for completed ones). The position of the first
+`Result` exactly matches the position of the first `Input` and the `position` of
+the last `Result` attempts to very closely match the match the `position` of the
+last `Input`, wile the `Result` objects in the middle have their `position`
+adjusted to smooth out and interpolate between the input values.
+
+To construct a prediction, call `StrokeModeler::Predict()` while an input stream
+is in-progress:
+
+```c++
+if (absl::StatusOr<std::vector<Result>> = modeler.Predict()) {
+ std::vector<Result> smoothed_input = *result;
+ // Do something with the result.
+} else {
+ absl::Status error_status = result.status();
+ // Handle error.
+}
+```
+
+If no input stream is in-progress, it will instead return
+`absl::FailedPreconditionError`.
+
+When the model is configured with `StrokeEndPredictorParams`, the last `Result`
+returned by `Predict` will have a `time` slightly after the most recent `Input`.
+(This is the same as the points that would be added to the `Result`s returned by
+`Update` if the most recent input was `kUp` instead of `kMove`.) When the model
+is configured with `KalmanPredictorParams`, the prediction may take more time to
+"catch up" with the position of the last input, then take an additional interval
+to extend the stroke beyond that position.
+
+The state of the most recent stroke can be represented by concatenating the
+vectors of `Result`s returned by a series of successful calls to `Update`,
+starting with the most recent `kDown` event. If the input stream does not end
+with a `kUp` event, you can optionally append the vector of `Result`s returned
+by the most recent call to `Predict`.
+
+## Implementation Details
+
+<p class="hidden-in-github-pages">(<em>Note:</em> Mathematical formulas below
+are rendered with <a href="https://www.mathjax.org/">MathJax</a>, which GitHub's
+native Markdown rendering does not support. You can view the rendered version
+<a href="https://google.github.io/ink-stroke-modeler/#implementation-details">on
+GitHub pages</a>.)</p>
+
+The design goals for this library were:
+
+* Minimize overall latency.
+* Accurate reproduction of intent for drawing and handwriting (preservation of
+ inflection points).
+* Smooth digitizer noise (high frequency).
+* Smooth human created noise (low and high frequency). For example:
+ * For most people, drawing with direct mouse input is very accurate, but
+ looks bad.
+ * For most people, drawing with a real pen looks bad. We should capture
+ some of the differences between professional and amateur pen control.
+
+At a high level, we start with an assumption that all reported input events are
+estimates (from both the person and hardware). We keep track of an idealized pen
+constrained by a physical model of motion (mass, friction, etc.) and feed these
+position estimates into this idealized pen. This model lags behind reported
+events, as with most smoothing algorithms, but because we have the modeled
+representation we can sample its location at arbitrary and even future times.
+Using this, we predict forward from the delayed model to estimate where we will
+go. The actual implementation is fairly simple: a fixed timestep Euler
+integration to update the model, in which input events are treated as spring
+forces on that model.
+
+Like many real-time smoothing algorithms, the stroke modeler lags behind the raw
+input. To compensate for this, the stroke modeler has the capability to predict
+the upcoming points, which are used to "catch up" to the raw input.
+
+### The Model
+
+Typically, we expect the stroke modeler to receive raw inputs in real-time; for
+each input it receives, it produces one or more modeled results.
+
+When asked for a prediction, the stroke modeler computes it based on its current
+state -- this means that the prediction is invalid as soon as the next input is
+received.
+
+> NOTE: The definitions and algorithms in this document are defined as though
+> the input streams are given as a complete set of data, even though the engine
+> actually receives raw inputs one by one. It's written this way for ease of
+> understanding.
+>
+> However, one of the features of these algorithms is that any result that has
+> been constructed will remain the same even if more inputs are added, i.e. if
+> we view the algorithm as a function $$F$$ that maps from an input stream to a
+> set of results, then $$F(\{i_0, \ldots, i_j\}) \subseteq F(\{i_0, \ldots, i_j,
+> i_{j + 1}\})$$. Applying the algorithm in real-time is then effectively the
+> same as evaluating the function on increasingly large subsets of the input
+> stream, i.e. $$F(\{i_0\}), F(\{i_0, i_1\}), F(\{i_0, i_1, i_2\}), \ldots$$
+
+##### Math Stuff
+
+**Definition**: We define the *pressure* $$\sigma$$ as the amount of force which
+is applied to the writing surface with the stylus, as reported by the platform.
+We expect $$\sigma \in [0, 1]$$ for devices that report pressure, with 0 and 1
+representing the minimum and maximum detectable pressure, or $$\sigma =
+\emptyset$$ if the device does not report pressure. $$\square$$
+
+**Definition**: We define the *tilt* $$\theta$$ as the angle between the stylus,
+and a vector perpendicular to the writing surface. We expect $$\theta \in [0,
+\frac{\pi}{2}]$$. $$\square$$
+
+**Definition:** We define the *orientation* $$\phi$$ as the counter-clockwise
+angle between the projection of the stylus onto the writing surface, and the
+writing surface's x-axis. We expect $$\phi \in [0, 2 \pi)$$. $$\square$$
+
+NOTE: In the code, we use a sentinel value of -1 to represent unreported
+pressure, tilt, or orientation.
+
+**Definition:** A *raw input* is a `struct` $$\{p, t, \sigma, \theta, \phi\}$$,
+where $$p$$ is a `Vec2` position, $$t$$ is a `Time`, $$\sigma$$ is a `float`
+representing the pressure, $$\theta$$ is a `float` representing tilt, and
+$$\phi$$ is a `float` representing orientation. For any raw input $$i$$, we
+denote its position, time, pressure, tilt, and orientation as $$i^p$$, $$i^t$$,
+$$i^\sigma$$, $$i^\theta$$, and $$i^\phi$$ respectively. $$\square$$
+
+**Definition:** An *input stream* is a finite sequence of raw inputs, ordered by
+time strictly non-decreasing, such that the first raw input is the `kDown`, and
+each subsequent raw input is a `kMove`, with the exception of the final one,
+which may alternatively be the `kUp`. Additionally, we say that a *complete
+input stream* is a one such that the final raw input is the `kUp`. $$\square$$
+
+**Definition:** A *tip state* is a `struct` $$\{p, v, t\}$$, where $$p$$ a
+`Vec2` position, $$v$$ is a `Vec2` velocity, and $$t$$ is a `Time`. Similarly to
+raw input, for a tip state $$q$$, we denote its position, velocity, and time as
+$$q^p$$, $$q^v$$, and $$q^t$$, respectively. $$\square$$
+
+**Definition:** A *stylus state* is a `struct` $$\{\sigma, \theta, \phi\}$$,
+where $$\sigma$$ is a `float` representing the pressure, $$\theta$$ is a `float`
+representing tilt, and $$\phi$$ is a `float` representing orientation. As above,
+for a stylus state $$s$$, we denote its pressure, tilt, and orientation as
+$$s^\sigma$$, $$s^\theta$$, and $$s^\phi$$ respectively. $$\square$$
+
+**Definition:** We define the *rounding function* $$R$$ such that $$R(x) =
+\begin{cases} \lfloor x \rfloor, & x - \lfloor x \rfloor \leq \frac{1}{2} \\
+\lceil x \rceil & o.w. \end{cases}$$. $$\square$$
+
+**Definition:** We define the *linear interpolation function* $$L$$ such that
+$$L(a, b, \lambda) = a + \lambda (b - a)$$. $$\square$$
+
+**Definition:** We define the *normalization function* $$N$$ such that $$N(a, b,
+\lambda) = \frac{\lambda - a}{b - a}$$. $$\square$$
+
+**Definition:** We define the *clamp function* $$C$$ such that $$C(a, b,
+\lambda) = \max(a, \min(b, \lambda))$$. $$\square$$
+
+#### Wobble Smoothing
+
+The positions of the raw input tend to have some noise, due to discretization on
+the digitizer or simply an unsteady hand. This noise produces wobbles in the
+model and prediction, especially at slow speeds.
+
+To reduce high-frequency noise, we take a time-variant moving average of the
+position instead of the position itself -- this serves as a low-pass filter.
+This, however, introduces some lag between the raw input position and the
+filtered position. To compensate for that, we use a linearly-interpolated value
+between the filtered and raw position, based on the moving average of the
+velocity of the input.
+
+##### Math Stuff
+
+**Algorithm #1:** Given an input stream $$\{i_0, i_1, \ldots, i_n\}$$, a
+duration $$\Delta$$ over which to take the moving average, and minimum and
+maximum velocities $$v_{min}$$ and $$v_{max}$$ we construct the noise-filtered
+input stream $$\{i_0', i_1', \ldots, i_n'\}$$ as follows:
+
+> NOTE: The values of $$\Delta$$, $$v_{min}$$, and $$v_{max}$$ are taken from
+> `WobbleSmootherParams`:
+>
+> * $$\Delta$$ = `timeout`
+> * $$v_{min}$$ = `speed_floor`
+> * $$v_{max}$$ = `speed_ceiling`
+
+For each raw input $$i_j$$, we can then find the set of raw inputs that occurred
+within the previous duration $$\Delta$$:
+
+$$I_j^{<\Delta} = \left\{i_k \middle| i_j^t - i_k^t \in [0, \Delta]\right\}$$
+
+We then calculate the moving averages of position $$\overline p_j$$ and velocity
+$$\overline v_j$$:
+
+$$\begin{align*}
+\overline p_j & = \begin{cases}
+i_j^p, & j = 0 \vee j = n \\
+\frac{1}{\|I_j^{<\Delta}\|} \sum\limits_k i_k^p
+\left[i_k \in I_j^{<\Delta} \right],
+& \mathrm{o.w.} \end{cases} \\
+\overline v_j & = \begin{cases}
+0, & j = 0 \\
+\frac{1}{\|I_j^{<\Delta}\|} \sum\limits_k \frac{\|i_k^p - i_{k - 1}^p\|}
+{i_k^t - i_{k - 1}^t} \left[i_k \in I_j^{<\Delta} \right],
+& \mathrm{o.w.}
+\end{cases}
+\end{align*}$$
+
+NOTE: In the above equations, the square braces denote the
+[Iverson bracket](https://en.wikipedia.org/wiki/Iverson_bracket).
+
+We then normalize $$\overline v_j$$ between $$v_{min}$$ and $$v_{max}$$, and
+find the filtered position $$p'_j$$ by linearly interpolating between $$i_j^p$$
+and $$\overline p_j$$:
+
+$$\begin{align*}
+\lambda_j & = \begin{cases}
+0, & \overline v_j < v_0 \\
+1, & \overline v_j > v_1 \\
+N(v_{min}, v_{max}, v_j), & \mathrm{o.w.}
+\end{cases} \\
+p'_j & = L\left(\overline p_j, i_j^p, \lambda_j\right)
+\end{align*}$$
+
+Finally, we construct raw input $$i'_j = \left\{p'_j, i_j^t, i_j^\sigma\right\}.
+\square$$
+
+### Resampling
+
+Input packets may come in at a variety of rates, depending on the platform and
+input device. Before performing position modeling, we upsample the input to some
+minimum rate.
+
+##### Math Stuff
+
+**Algorithm #2:** Given an input stream $$I = \{i_0, i_1, \ldots, i_n\}$$, and a
+maximum time between inputs $$\Delta_{max}$$, we construct the upsampled input
+stream $$U \supseteq I$$ as follows:
+
+NOTE: The value of $$\Delta_{target}$$ is `1 / SamplingParams::min_output_rate`.
+
+We construct a sequence $$\{a_1, a_2, \ldots, a_n\}$$ such that $$a_j = \left
+\lceil \frac{i_j^t - i_{j - 1}^t}{\Delta_{target}} \right \rceil$$, representing
+the required number of interpolations between each pair of raw inputs.
+
+We then construct a sequence of sequences $$\{u_1, u_2, \ldots, u_n\}$$ such
+that $$u_j = \{L(i_{j - 1}, i_j, \frac{k}{a_j} \, | \, k = 1, 2, \ldots,
+a_j\}$$. Finally, we construct the upsampled input stream by concatenation:
+$$U = \{i_0, u_1, u_2, \ldots, u_n\}$$. $$\square$$
+
+### Position Modeling
+
+The position of the pen is modeled as a weight connected by a spring to an
+anchor. The anchor moves along the resampled raw inputs, pulling the weight
+along with it across a surface, with some amount of friction. We then use Euler
+integration to solve for the position of the pen.
+
+NOTE: For simplicity, we say that the spring has a relaxed length of zero, and
+that the anchor is unafected by outside forces such as friction or the drag from
+the pen.
+
+![Position Model Diagram](./position_model.svg){height="200"}
+
+##### Math Stuff
+
+**Lemma:** We can model the position of the pen with the following ordinary
+differential equation:
+
+$$\frac{d^2s}{dt^2} = \frac{k_s}{m_{pen}}(\Phi(t) - s(t)) - k_d \frac{ds}{dt}$$
+
+where $$t$$ is the time, $$s(t)$$ is the position of the pen, $$\Phi(t)$$ is the
+position of the anchor, $$m_{pen}$$ is a the mass of the pen, $$k_s$$ is the
+spring constant, and $$k_d$$ is the drag constant.
+
+**Proof:** From the positions of the pen and the anchor, we can use Hooke's Law
+to find the force exerted by the spring:
+
+$$F = k_s (\Phi(t) - s(t))$$
+
+Combining the above with Newton's Second Law of Motion, we can then find the
+acceleration of the pen $$a(t)$$:
+
+$$F = m_{pen} \cdot a(t) \\
+a(t) = \frac{k_s}{m_{pen}}(\Phi(t) - s(t))$$
+
+We also apply a drag to the pen, applying an acceleration of $$-k_dv(t)$$, where
+$$v(t)$$ is the velocity of the pen. This gives us:
+
+$$a(t) = \frac{k_s}{m_{pen}}(\Phi(t) - s(t)) - k_dv(t)$$
+
+NOTE: This isn't exactly how you would calculate friction of an object being
+dragged across a surface. However, if you assume that $$m_{pen} = 1$$, then it
+matches the form of the equation for a sphere moving through a viscous fluid.
+See [Kinetic Friction](https://en.wikipedia.org/wiki/Friction#Kinetic_friction)
+and [Stokes' Law](https://en.wikipedia.org/wiki/Stokes%27_law).
+
+From the equations of motion, we know that $$v(t) = \frac{ds}{dt}$$ and $$a(t) =
+\frac{dv}{dt} = \frac{d^2s}{dt^2}$$. By substitution, we arrive at the ordinary
+differential equation stated above. $$\square$$
+
+**Algorithm #3:** Given an incomplete input stream $$\{i_0, i_1, \ldots,
+i_n\}$$, initial position $$s_0$$, and initial velocity $$v_0$$, we construct
+tip states $$\{q_0, q_1, \ldots\}$$ as follows:
+
+NOTE: When modeling a stroke, we choose $$s_0 = i_0^p$$ and $$v_0 = 0$$, such
+that pen is at stationary at the first raw input point. The `StrokeEndPredictor`
+also makes use of this algorithm with different initial values, however.
+
+We define the function $$\Phi$$ such that $$\forall j, \Phi(i_j^t) = i_j^p$$.
+From above, we know that we can model the position $$s(t)$$ with an ordinary
+differential equation:
+
+$$\frac{d^2s}{dt^2} = \frac{k_s}{m_{pen}}(\Phi(t) - s(t)) - k_d \frac{ds}{dt}$$
+
+Because the spring constant and mass are so closely coupled in our model, we
+combine them into a single "shape mass" constant $$M = \frac{m_{pen}}{k_s}$$,
+giving us:
+
+$$\frac{d^2s}{dt^2} = \frac{\Phi(t) - s(t)}{M} - k_d \frac{ds}{dt}$$
+
+NOTE: The values of $$M$$ and $$k_d$$ are
+`PositionModelerParams::spring_mass_constant` and
+`PositionModelerParams::drag_constant`, respectively.
+
+We define $$s_j = s(i_j^t)$$, $$v_j = \frac{ds}{dt}(i_j^t)$$, and $$a_j =
+\frac{d^2s}{dt^2}(i_j^t)$$, and use the given $$s_0$$ and $$v_0$$. Recalling
+that $$\Phi(i_j^t) = i_j^p$$, we can iteratively construct the each following
+position:
+
+$$\begin{align*}
+\Delta_j & = i_j^t - i_{j - 1}^t \\
+a_j & = \frac{i_j^p - s_{j - 1}}{M} - k_d v_{j - 1} \\
+v_j & = v_{j - 1} + \Delta_j a_j \\
+s_j & = s_{j - 1} + \Delta_j v_j
+\end{align*}$$
+
+Finally, we construct tip state $$q_j = \{s_j, i_j^t, v_j\}.\square$$
+
+#### Stroke End
+
+The position modeling algorithm, like many real-time smoothing algorithms, tends
+to trail behind the raw inputs by some distance. At the end of the stroke, we
+iterate on the position modeling algorithm a few additional times, using the
+final raw input position as the anchor, to allow the stroke to "catch up."
+
+##### Math Stuff
+
+**Algorithm #4:** Given the final anchor position $$\omega$$, the final tip
+state $$q_{final}$$, the maximum number $$K_{max}$$ of end-of-stroke tip states
+to create, the initial time between tip states $$\Delta_{initial}$$, and a
+stopping distance $$d_{stop}$$, we construct the next tip state $$q_{next}$$ as
+follows:
+
+> NOTE: The values of $$\Delta_{initial}$$, $$K_{max}$$, and $$d_{stop}$$ are
+> taken from `SamplingParams`:
+>
+> * $$\Delta_{initial}$$ = `min_output_rate`
+> * $$K_{max}$$ = `end_of_stroke_max_iterations`
+> * $$d_{stop}$$ = `end_of_stroke_stopping_distance`
+
+Let $$\Delta_{next} = \Delta_{initial}$$. Using the update equations from
+**algorithm #3**, we construct a candidate tip state $$q_c$$:
+
+$$\begin{align*}
+a & = \frac{\omega - q_{final}^p}{M} - k_d q_{final}^v \\
+v & = q_{final}^v + \Delta_{next} a \\
+s & = q_{final}^p + \Delta_{next} v \\
+q_c & = \{s, q_{final}^t + \Delta_{next}, v\}
+\end{align*}$$
+
+If the distance traveled $$\|q_c - q_{final}\|$$ is less than $$d_{stop}$$, we
+discard the candidate, stop iterating, and declare the stroke complete. Further
+iterations are unlikely to yield any significant progress.
+
+We then construct the segment $$S$$ connecting $$q_{final}^p$$ and $$q_c^p$$,
+and find the point $$S_\omega$$ on $$S$$ nearest to $$\omega$$. If $$S_\omega
+\neq q_c^p$$, it means that the candidate has overshot the anchor. We discard
+the candidate, and find a new candidate using a smaller timestep, setting
+$$\Delta_{next}$$ to one half its previous value. This is repeated until either
+we find a candidate that does not overshoot the anchor, or the minimum distance
+traveled check fails.
+
+Once we have found a suitable candidate, we let $$q_{next} = q_c$$.
+
+If the remaining distance $$\|\omega - q_{next}^p\|$$ is greater than
+$$d_{stop}$$ and the total number of iterations performed, including discarded
+candidates, is not greater than $$K_{max}$$, we use this same process to
+construct an additional tip state for the end-of-stroke, substituting
+$$q_{next}$$ and $$\Delta_{next}$$ for $$q_{final}$$ and $$\Delta_{initial}$$,
+respectively. $$\square$$
+
+### Prediction
+
+As mentioned above, the position modeling algorithm tends to trail behind the
+raw inputs. In addition to correcting for this at the end-of-stroke, we
+construct a set of predicted tip states every frame to present the illusion of
+lower latency.
+
+The are two predictors: the `StrokeEndPredictor` (found in
+`internal/prediction/stroke_end_predictor.h`) uses a slight variation of the
+end-of-stroke algorithm, and the `KalmanPredictor` (found in
+`internal/prediction/kalman_predictor.h`) uses a Kalman filter to estimate the
+current state of the pen tip and construct a cubic prediction.
+
+Regardless of the strategy used, the predictor is fed each position and
+timestamp of raw input as it is received by the model. The model's most recent
+tip state is given to the predictor when the predicted tip states are
+constructed.
+
+#### Stroke End Predictor
+
+The `StrokeEndPredictor` constructs a prediction using **algorithm #4** to model
+what the stroke would be if the last raw input had been a `kUp`, letting
+$$\omega$$ be the most recent tip state, and $$q_{final}$$ be the last raw
+input.
+
+#### Kalman Predictor
+
+The `KalmanPredictor` uses a pair of
+[Kalman filters](https://en.wikipedia.org/wiki/Kalman_filter), one each for the
+x- and y-components, to estimate the current state of the pen from the given
+inputs.
+
+The Kalman filter implementation operates on the assumption that all states have
+one unit of time between them; because of this, we need to rescale the
+estimation to account for the actual input and output rates. Additionally, our
+experiments suggest that linear predictions look and feel better than non-linear
+ones; as such, we also provide weight factors that may be used to reduce the
+effect of the acceleration and jerk coefficients on the estimate and prediction.
+
+Once we have the estimated state, we construct the prediction in two parts, each
+of which is a sequence of tip states described by a cubic polynomial curve. The
+first smoothly connects the last tip state to the estimate state, and the second
+projects where the expected position of the pen is in the future.
+
+##### Math Stuff
+
+**Algorithm #5**: Given a process noise factor $$\epsilon_p$$ and measurement
+noise factor $$\epsilon_m$$, we construct the Kalman filter as follows:
+
+The difference in time between states, $$\Delta_t$$, is always assumed to be
+equal to 1.
+
+The *state transition model* is derived from the equations of motion: given
+$$s$$ = position, $$v$$ = velocity, $$a$$ = acceleration, and $$j$$ = jerk, we
+can find the new values $$s'$$, $$v'$$, $$a'$$, and $$j'$$:
+
+$$\begin{align*}
+s' & = s + v \Delta_t + \frac{a \Delta_t^2}{2} + \frac{j \Delta_t^3}{6} \\
+v' & = v + a \Delta_t + \frac{j \Delta_t^2}{2} \\
+a' & = a + j \Delta_t \\
+j' & = j \\
+\end{align*}$$
+
+The *process noise covariance* is modeled as noisy force; from the equations of
+motion, we can say that the impact of that noise on each state is equal to:
+
+$$\epsilon_p
+\begin{bmatrix}\frac{\Delta_t^3}{6} \\ \frac{\Delta_t^2}{2} \\
+\Delta_t \\ 1 \end{bmatrix}
+\begin{bmatrix}\frac{\Delta_t^3}{6} & \frac{\Delta_t^2}{2} & \Delta_t & 1
+\end{bmatrix}$$
+
+The *measurement model* is simply $$\begin{bmatrix}1 & 0 & 0 & 0\end{bmatrix}$$,
+representing the fact that we only observe the position of the input.
+
+The *measurement noise covariance* is set to $$\epsilon_m$$. $$\square$$
+
+> NOTE: The values of $$\epsilon_p$$ and $$\epsilon_m$$ are taken from
+> `KalmanPredictorParams`:
+>
+> * $$\epsilon_p$$ = `process_noise`
+> * $$\epsilon_m$$ = `measurement_noise`
+
+**Algorithm #6:** Given the input stream $$\{i_0, i_1, \ldots, i_n\}$$, the
+Kalman filter's estimation $$E_K = \{s_K, v_K, a_K, j_K\}$$, the number of
+samples to use $$N_{max}$$, and weights $$w_a$$ and $$w_j$$ for the acceleration
+and jerk, we construct the state estimate $$E = \{s, v, a, j\}$$ as follows:
+
+We first determine the average amount of time $$\Delta_a$$ between the
+$$N_{max}$$ most recent raw inputs:
+
+$$\begin{align*}N_{actual} & = \max(n, N_{max}) \\
+\Delta_a & = \frac{i_n^t - i_{n - N_{actual}}^t}{N_{actual}}
+\end{align*}$$
+
+We then construct the state estimate:
+
+$$E = \left \{ s_K, \frac{v_K}{\Delta_a}, w_a \frac{a_K}{\Delta_a^2}, w_j \frac{j_K}{\Delta_a^3} \right \} \square$$
+
+> NOTE: The values of $$N_{max}$$, $$w_a$$, and $$w_j$$ are taken from
+> `KalmanPredictorParams`:
+>
+> * $$N_{max}$$ = `max_time_samples`
+> * $$w_a$$ = `acceleration_weight`
+> * $$w_j$$ = `jerk_weight`
+
+**Algorithm #7:** Given the last tip state produced $$q$$, the target time
+between inputs $$\Delta_t$$, and the estimated pen position $$s_K$$ and velocity
+$$v_K$$, we construct the connecting tip states $$\{p_1, p_2, \ldots\}$$ as
+follows:
+
+NOTE: The value of $$\Delta_t$$ is `1 / SamplingParams::min_output_rate`.
+
+We first determine how many points to sample along the curve, based on the
+velocities at the endpoints, and the distance between them:
+
+$$n = \left \lceil \frac{\|s_K - q^p\|}{\Delta_t \max(\|q^v\|, \|v_K\|)} \right \rceil$$
+
+From this, we say that the time when the connecting curve reaches the estimated
+pen position is $$t_{end} = n \cdot \Delta_t$$. We then construct a cubic
+polynomial function $$F_C$$ such that:
+
+$$\begin{align*}
+F_C(0) & = q^p \\
+\frac{d F_C}{d t}(0) & = q^v \\
+F_C(t_{end}) & = s_K \\
+\frac{d F_C}{d t}(t_{end}) & = v_K
+\end{align*}$$
+
+NOTE: The full derivation of $$F_C$$ can be found in the comments of
+`KalmanPredictor::ConstructCubicConnector`, in
+`internal/prediction/kalman_predictor.cc`.
+
+We construct the times at which to sample the curve $$t_i = i \cdot n \cdot
+\Delta_t, i \in \{1, 2, \ldots, n\}$$, and construct the tip states $$p_i =
+\{F_C(t_i), \frac{d F_C}{d t}(t_i), q^t + t_i\}$$. $$\square$$
+
+**Algorithm #8:** Given the number of raw inputs received so far $$n$$, the last
+raw input received $$i_R$$, the target prediction duration $$t_p$$, the
+estimated state $$\{s_K, v_K, a_K, j_K\}$$, the desired number of samples
+$$N_s$$, the maximum distance $$\varepsilon_{max}$$ between the estimated
+position and the last observed state, the minimum and maximum speeds $$V_{min}$$
+and $$V_{max}$$, maximum deviation from the linear prediction $$\mu_max$$, and
+baseline linearity confidence $$B_\mu$$, we estimate our confidence $$\gamma$$
+in the prediction using the following heuristic:
+
+There are four coefficients that factor into the confidence:
+
+* A sample ratio $$\gamma_s$$, which captures how many raw inputs have been
+ observed so far. The rationale for this coefficient is that, as we
+ accumulate more observations, the error in any individual observation
+ affects the estimate less.
+* An error coefficient $$\gamma_\varepsilon$$, based on the distance between
+ the estimated state and the last observed state. The rationale for this
+ coefficient is that it should be indicative of the variance in the
+ distribution used for the estimate.
+* A speed coefficient $$\gamma_V$$, based on the approximate speed that the
+ prediction travels at. The rationale for this coefficient is that when the
+ prediction travels slowly, changes in direction become more jarring,
+ appearing wobbly.
+* A linearity coefficient $$\gamma_\mu$$, based on how the much the cubic
+ prediction differs from a linear one. The rationale for this coefficient is
+ that testers have expressed that linear predictions look and feel better.
+
+We first construct the final positions $$s_l$$ and $$s_c$$, of the linear and
+cubic predictions, respectively:
+
+$$\begin{align*}
+s_l & = s_K + t_p v_K \\
+s_c & = s_K + t_p v_K + \frac{1}{2} t_p^2 a_K + \frac{1}{6} t_p^3 j_K \\
+\end{align*}$$
+
+We then construct the coefficients as follows:
+
+$$\begin{align*}
+\gamma_s & = \frac{n}{N_s} \\
+\gamma_\varepsilon & = C(1 - N(0, \varepsilon_{max}, \|s_K - i_R^p\|)) \\
+\gamma_V & = C(N(V_{min}, V_{max}, \frac{\|s_c - s_K\|}{t_p})) \\
+\gamma_\mu & = L(B_\mu, 1, C(0, 1, N(0, \mu_{max}, \|s_c - s_l\|))) \\
+\end{align*}$$
+
+Finally, we take the product of the coefficients to find the confidence
+$$\gamma = \gamma_s \gamma_\varepsilon \gamma_V \gamma_\mu$$. $$\square$$
+
+> NOTE: The value of $$t_p$$ = `KalmanPredictorParams::prediction_interval`, and
+> the values of $$N_s$$, $$\varepsilon_{max}$$, $$V_{min}$$, $$V_{max}$$,
+> $$\mu_max$$, and $$B_\mu$$ are taken from
+> `KalmanPredictorParams::ConfidenceParams`:
+>
+> * $$N_s$$ = `desired_number_of_samples`
+> * $$\varepsilon_{max}$$ = `max_estimation_distance`
+> * $$V_{min}$$ = `min_travel_speed`
+> * $$V_{max}$$ = `max_travel_speed`
+> * $$\mu_max$$ = `max_linear_deviation`
+> * $$B_\mu$$ = `baseline_linearity_confidence`
+
+**Algorithm #9:** Given the target time between inputs $$\Delta_t$$, the time at
+which prediction starts $$t_0$$, the target prediction duration $$t_p$$, the
+confidence $$\gamma$$, and the estimated pen position $$s_K$$, velocity $$v_K$$,
+acceleration $$a_K$$, and jerk $$j_K$$, we construct the prediction tip states
+$$\{p_1, p_2, \ldots\}$$ as follows:
+
+We first determine the number of tip states $$n$$ to construct:
+
+$$n = \left \lceil \gamma \frac{t_p}{\Delta_t} \right \rceil$$
+
+We then iteratively construct a cubic prediction of the state at intervals of
+$$\Delta_t$$:
+
+$$\begin{align*}
+s_{i+1} & = s_i + \Delta_t v_i + \frac{1}{2} \Delta_t^2 a_i + \frac{1}{6} \Delta_t^3 j_i \\
+v_{i+1} & = v_i + \Delta_t a_i + \frac{1}{2} \Delta_t^2 j_i \\
+a_{i+1} & = a_i + \Delta_t j_i \\
+j_{i+1} & = j_i \\
+\end{align*}$$
+
+We let $$s_0 = s_K$$, $$v_0 = v_K$$, $$a_0 = a_K$$, and $$j_0 = j_K$$, and then
+finally construct the tip states $$p_i = \{s_i, v_i, t_0 + i \Delta_t\}$$ for
+$$i \in \{1, 2, \ldots, n\}$$. $$\square$$
+
+### Stylus Modeling
+
+Once the tip states have been modeled, we need to construct stylus states for
+each of them. For input streams that contain stylus information, we find the
+closest pair of sequential raw inputs within a set window, and interpolate
+between them to get pressure, tilt, and orientation. For input streams that
+don't contain any stylus information, we simply leave the pressure, tilt, and
+orientation unspecified.
+
+#### Math Stuff
+
+**Algorithm #10:** Given an input stream $$\{i_0, i_1, \ldots, i_n\}$$ with
+stylus information, a tip state $$q$$, and the number of raw inputs
+$$n_{search}$$ to use as the search window, we construct the stylus state $$s$$
+as follows:
+
+NOTE: The value of $$n_{search}$$ is
+`StylusStateModelerParams::max_input_samples`.
+
+We first construct line segments $$\{s_1, s_2, \ldots, s_n\}$$ from the
+positions of the input stream, such that $$s_j$$ starts at $$i_{j - 1}$$ and
+ends at $$i_j$$.
+
+We next find the index $$k > n - n_{search}$$ of the closest segment, such that
+for all $$j > n - n_{search}, \mathrm{distance}(s_k, q^p) \leq
+\mathrm{distance}(s_j, q^p)$$.
+
+We define $$r$$ be the ratio of $$s_k$$'s length at which the closest point to
+$$q^p$$ lies, and use this to interpolate between the stylus values of $$i_{k -
+1}$$ and $$i_k$$, allowing for the orientation to "wrap" at $$0 = 2 \pi$$:
+
+$$\begin{align*}
+\rho & = L(i_{k - 1}^\rho, i_k^\rho, r) \\
+\theta & = L(i_{k - 1}^\theta, i_k^\theta, r) \\
+\phi & = \begin{cases}
+L(i_{k - 1}^\phi + 2 \pi, i_k^\phi, r), & i_k^\phi - i_{k - 1}^\phi > \pi \\
+L(i_{k - 1}^\phi, i_k^\phi + 2 \pi, r), & i_k^\phi - i_{k - 1}^\phi < -\pi \\
+L(i_{k - 1}^\phi, i_k^\phi, r), & \mathrm{o.w.} \\
+\end{cases} \\
+\end{align*}$$
+
+Finally, we construct the stylus state $$s = \{\rho, \theta, \mod(\phi, 2
+\pi)\}$$. $$\square$$
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..4648e5e
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,4 @@
+To report a security issue, please use [https://g.co/vulnz](https://g.co/vulnz).
+We use g.co/vulnz for our intake, and do coordination and disclosure here on
+GitHub (including using GitHub Security Advisory). The Google Security Team will
+respond within 5 working days of your report on g.co/vulnz.
diff --git a/WORKSPACE b/WORKSPACE
new file mode 100644
index 0000000..371fa3e
--- /dev/null
+++ b/WORKSPACE
@@ -0,0 +1,16 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+load(":workspace.bzl", "ink_stroke_modeler_workspace")
+ink_stroke_modeler_workspace()
diff --git a/_config.yml b/_config.yml
new file mode 100644
index 0000000..f507792
--- /dev/null
+++ b/_config.yml
@@ -0,0 +1,2 @@
+# GitHub Pages config
+theme: jekyll-theme-cayman
diff --git a/_includes/head-custom.html b/_includes/head-custom.html
new file mode 100644
index 0000000..32d666b
--- /dev/null
+++ b/_includes/head-custom.html
@@ -0,0 +1,8 @@
+<script type="text/javascript" async
+ src="https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-MML-AM_CHTML">
+</script>
+<style>
+.hidden-in-github-pages {
+ display: none;
+}
+</style>
diff --git a/cmake/InkBazelEquivalents.cmake b/cmake/InkBazelEquivalents.cmake
new file mode 100644
index 0000000..fa3f03b
--- /dev/null
+++ b/cmake/InkBazelEquivalents.cmake
@@ -0,0 +1,45 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+function(ink_cc_library)
+ cmake_parse_arguments(INK_CC_LIB
+ ""
+ "NAME"
+ "HDRS;SRCS;DEPS"
+ ${ARGN}
+ )
+ set(_NAME "ink_stroke_modeler_${INK_CC_LIB_NAME}")
+ if(NOT DEFINED INK_CC_LIB_SRCS)
+ add_library(${_NAME} INTERFACE ${INK_CC_LIB_HDRS})
+ set_target_properties(${_NAME} PROPERTIES LINKER_LANGUAGE CXX)
+ target_link_libraries(${_NAME} INTERFACE ${INK_CC_LIB_DEPS})
+ else()
+ add_library(${_NAME} ${INK_CC_LIB_SRCS} ${INK_CC_LIB_HDRS})
+ target_link_libraries(${_NAME} PUBLIC ${INK_CC_LIB_DEPS})
+ endif()
+ add_library(InkStrokeModeler::${INK_CC_LIB_NAME} ALIAS ${_NAME})
+endfunction()
+
+function(ink_cc_test)
+ cmake_parse_arguments(INK_CC_TEST
+ ""
+ "NAME"
+ "SRCS;DEPS"
+ ${ARGN}
+ )
+ set(_NAME "ink_stroke_modeler_${INK_CC_TEST_NAME}")
+ add_executable(${_NAME} ${INK_CC_TEST_SRCS})
+ target_link_libraries(${_NAME} ${INK_CC_TEST_DEPS})
+ add_test(NAME ${_NAME} COMMAND ${_NAME})
+endfunction()
diff --git a/ink_stroke_modeler/BUILD.bazel b/ink_stroke_modeler/BUILD.bazel
new file mode 100644
index 0000000..d48f1ea
--- /dev/null
+++ b/ink_stroke_modeler/BUILD.bazel
@@ -0,0 +1,97 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])
+
+cc_library(
+ name = "params",
+ srcs = ["params.cc"],
+ hdrs = ["params.h"],
+ visibility = ["//visibility:public"],
+ deps = [
+ ":types",
+ "//ink_stroke_modeler/internal:validation",
+ "@com_google_absl//absl/status",
+ "@com_google_absl//absl/strings",
+ "@com_google_absl//absl/types:variant",
+ ],
+)
+
+cc_test(
+ name = "params_test",
+ srcs = ["params_test.cc"],
+ deps = [
+ ":params",
+ "@com_google_absl//absl/status",
+ "@com_google_googletest//:gtest_main",
+ ],
+)
+
+cc_library(
+ name = "types",
+ srcs = ["types.cc"],
+ hdrs = ["types.h"],
+ visibility = ["//visibility:public"],
+ deps = [
+ "//ink_stroke_modeler/internal:validation",
+ "@com_google_absl//absl/status",
+ ],
+)
+
+cc_test(
+ name = "types_test",
+ srcs = ["types_test.cc"],
+ deps = [
+ ":types",
+ "//ink_stroke_modeler/internal:type_matchers",
+ "@com_google_googletest//:gtest_main",
+ ],
+)
+
+cc_library(
+ name = "stroke_modeler",
+ srcs = ["stroke_modeler.cc"],
+ hdrs = ["stroke_modeler.h"],
+ deps = [
+ ":params",
+ ":types",
+ "//ink_stroke_modeler/internal:internal_types",
+ "//ink_stroke_modeler/internal:position_modeler",
+ "//ink_stroke_modeler/internal:stylus_state_modeler",
+ "//ink_stroke_modeler/internal:wobble_smoother",
+ "//ink_stroke_modeler/internal/prediction:input_predictor",
+ "//ink_stroke_modeler/internal/prediction:kalman_predictor",
+ "//ink_stroke_modeler/internal/prediction:stroke_end_predictor",
+ "@com_google_absl//absl/base:core_headers",
+ "@com_google_absl//absl/memory",
+ "@com_google_absl//absl/status",
+ "@com_google_absl//absl/status:statusor",
+ "@com_google_absl//absl/types:optional",
+ "@com_google_absl//absl/types:variant",
+ ],
+)
+
+cc_test(
+ name = "stroke_modeler_test",
+ srcs = ["stroke_modeler_test.cc"],
+ deps = [
+ ":params",
+ ":stroke_modeler",
+ "//ink_stroke_modeler/internal:type_matchers",
+ "@com_google_absl//absl/status",
+ "@com_google_googletest//:gtest_main",
+ ],
+)
diff --git a/ink_stroke_modeler/CMakeLists.txt b/ink_stroke_modeler/CMakeLists.txt
new file mode 100644
index 0000000..736663a
--- /dev/null
+++ b/ink_stroke_modeler/CMakeLists.txt
@@ -0,0 +1,102 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+add_subdirectory(internal)
+
+ink_cc_library(
+ NAME
+ params
+ SRCS
+ params.cc
+ HDRS
+ params.h
+ DEPS
+ InkStrokeModeler::types
+ absl::status
+ absl::strings
+ absl::variant
+ InkStrokeModeler::validation
+)
+
+ink_cc_test(
+ NAME
+ params_test
+ SRCS
+ params_test.cc
+ DEPS
+ InkStrokeModeler::params
+ absl::status
+ GTest::gtest_main
+)
+
+ink_cc_library(
+ NAME
+ types
+ SRCS
+ types.cc
+ HDRS
+ types.h
+ DEPS
+ absl::status
+ InkStrokeModeler::validation
+)
+
+ink_cc_test(
+ NAME
+ types_test
+ SRCS
+ types_test.cc
+ DEPS
+ InkStrokeModeler::types
+ InkStrokeModeler::type_matchers
+ GTest::gmock_main
+)
+
+ink_cc_library(
+ NAME
+ stroke_modeler
+ SRCS
+ stroke_modeler.cc
+ HDRS
+ stroke_modeler.h
+ DEPS
+ InkStrokeModeler::params
+ InkStrokeModeler::types
+ InkStrokeModeler::internal_types
+ InkStrokeModeler::position_modeler
+ InkStrokeModeler::stylus_state_modeler
+ InkStrokeModeler::wobble_smoother
+ InkStrokeModeler::input_predictor
+ InkStrokeModeler::kalman_predictor
+ InkStrokeModeler::stroke_end_predictor
+ absl::core_headers
+ absl::memory
+ absl::status
+ absl::statusor
+ absl::optional
+ absl::variant
+)
+
+ink_cc_test(
+ NAME
+ stroke_modeler_test
+ SRCS
+ stroke_modeler_test.cc
+ DEPS
+ InkStrokeModeler::params
+ InkStrokeModeler::stroke_modeler
+ InkStrokeModeler::type_matchers
+ absl::status
+ GTest::gmock_main
+)
diff --git a/ink_stroke_modeler/internal/BUILD.bazel b/ink_stroke_modeler/internal/BUILD.bazel
new file mode 100644
index 0000000..f4e1d98
--- /dev/null
+++ b/ink_stroke_modeler/internal/BUILD.bazel
@@ -0,0 +1,143 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+package(
+ default_visibility = ["//ink_stroke_modeler:__subpackages__"],
+)
+
+licenses(["notice"])
+
+cc_library(
+ name = "internal_types",
+ hdrs = ["internal_types.h"],
+ deps = ["//ink_stroke_modeler:types"],
+)
+
+cc_library(
+ name = "type_matchers",
+ testonly = 1,
+ srcs = ["type_matchers.cc"],
+ hdrs = ["type_matchers.h"],
+ deps = [
+ ":internal_types",
+ "//:gtest_for_library_testonly",
+ "//ink_stroke_modeler:types",
+ ],
+)
+
+cc_library(
+ name = "utils",
+ hdrs = ["utils.h"],
+ deps = ["//ink_stroke_modeler:types"],
+)
+
+cc_test(
+ name = "utils_test",
+ srcs = ["utils_test.cc"],
+ deps = [
+ ":type_matchers",
+ ":utils",
+ "@com_google_googletest//:gtest_main",
+ ],
+)
+
+cc_library(
+ name = "wobble_smoother",
+ srcs = ["wobble_smoother.cc"],
+ hdrs = ["wobble_smoother.h"],
+ deps = [
+ ":utils",
+ "//ink_stroke_modeler:params",
+ "//ink_stroke_modeler:types",
+ ],
+)
+
+cc_test(
+ name = "wobble_smoother_test",
+ srcs = ["wobble_smoother_test.cc"],
+ deps = [
+ ":type_matchers",
+ ":wobble_smoother",
+ "//ink_stroke_modeler:params",
+ "//ink_stroke_modeler:types",
+ "@com_google_googletest//:gtest_main",
+ ],
+)
+
+cc_library(
+ name = "position_modeler",
+ hdrs = ["position_modeler.h"],
+ deps = [
+ ":internal_types",
+ ":utils",
+ "//ink_stroke_modeler:params",
+ "//ink_stroke_modeler:types",
+ ],
+)
+
+cc_test(
+ name = "position_modeler_test",
+ srcs = ["position_modeler_test.cc"],
+ deps = [
+ ":internal_types",
+ ":position_modeler",
+ ":type_matchers",
+ "//ink_stroke_modeler:params",
+ "//ink_stroke_modeler:types",
+ "@com_google_googletest//:gtest_main",
+ ],
+)
+
+cc_library(
+ name = "stylus_state_modeler",
+ srcs = ["stylus_state_modeler.cc"],
+ hdrs = ["stylus_state_modeler.h"],
+ deps = [
+ ":internal_types",
+ ":utils",
+ "//ink_stroke_modeler:params",
+ "//ink_stroke_modeler:types",
+ ],
+)
+
+cc_test(
+ name = "stylus_state_modeler_test",
+ srcs = ["stylus_state_modeler_test.cc"],
+ deps = [
+ ":internal_types",
+ ":stylus_state_modeler",
+ ":type_matchers",
+ "//ink_stroke_modeler:params",
+ "//ink_stroke_modeler:types",
+ "@com_google_googletest//:gtest_main",
+ ],
+)
+
+cc_library(
+ name = "validation",
+ hdrs = ["validation.h"],
+ deps = [
+ "@com_google_absl//absl/status",
+ "@com_google_absl//absl/strings",
+ ],
+)
+
+cc_test(
+ name = "validaiton_test",
+ srcs = ["validation_test.cc"],
+ deps = [
+ ":validation",
+ "@com_google_googletest//:gtest_main",
+ ],
+)
diff --git a/ink_stroke_modeler/internal/CMakeLists.txt b/ink_stroke_modeler/internal/CMakeLists.txt
new file mode 100644
index 0000000..b1886df
--- /dev/null
+++ b/ink_stroke_modeler/internal/CMakeLists.txt
@@ -0,0 +1,156 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+add_subdirectory(prediction)
+
+ink_cc_library(
+ NAME
+ internal_types
+ HDRS
+ internal_types.h
+ DEPS
+ InkStrokeModeler::types
+)
+
+ink_cc_library(
+ NAME
+ type_matchers
+ SRCS
+ type_matchers.cc
+ HDRS
+ type_matchers.h
+ DEPS
+ InkStrokeModeler::internal_types
+ InkStrokeModeler::types
+ GTest::gtest_main
+)
+
+ink_cc_library(
+ NAME
+ utils
+ HDRS
+ utils.h
+ DEPS
+ InkStrokeModeler::types
+)
+
+ink_cc_test(
+ NAME
+ utils_test
+ SRCS
+ utils_test.cc
+ DEPS
+ InkStrokeModeler::type_matchers
+ InkStrokeModeler::utils
+ GTest::gmock_main
+)
+
+ink_cc_library(
+ NAME
+ wobble_smoother
+ SRCS
+ wobble_smoother.cc
+ HDRS
+ wobble_smoother.h
+ DEPS
+ InkStrokeModeler::utils
+ InkStrokeModeler::params
+ InkStrokeModeler::types
+)
+
+ink_cc_test(
+ NAME
+ wobble_smoother_test
+ SRCS
+ wobble_smoother_test.cc
+ DEPS
+ InkStrokeModeler::type_matchers
+ InkStrokeModeler::wobble_smoother
+ InkStrokeModeler::params
+ InkStrokeModeler::types
+ GTest::gmock_main
+)
+
+ink_cc_library(
+ NAME
+ position_modeler
+ HDRS
+ position_modeler.h
+ DEPS
+ InkStrokeModeler::internal_types
+ InkStrokeModeler::utils
+ InkStrokeModeler::params
+ InkStrokeModeler::types
+)
+
+ink_cc_test(
+ NAME
+ position_modeler_test
+ SRCS
+ position_modeler_test.cc
+ DEPS
+ InkStrokeModeler::internal_types
+ InkStrokeModeler::position_modeler
+ InkStrokeModeler::type_matchers
+ InkStrokeModeler::params
+ InkStrokeModeler::types
+ GTest::gmock_main
+)
+
+ink_cc_library(
+ NAME
+ stylus_state_modeler
+ SRCS
+ stylus_state_modeler.cc
+ HDRS
+ stylus_state_modeler.h
+ DEPS
+ InkStrokeModeler::internal_types
+ InkStrokeModeler::utils
+ InkStrokeModeler::params
+)
+
+ink_cc_test(
+ NAME
+ stylus_state_modeler_test
+ SRCS
+ stylus_state_modeler_test.cc
+ DEPS
+ InkStrokeModeler::internal_types
+ InkStrokeModeler::stylus_state_modeler
+ InkStrokeModeler::type_matchers
+ InkStrokeModeler::params
+ InkStrokeModeler::types
+ GTest::gmock_main
+)
+
+ink_cc_library(
+ NAME
+ validation
+ HDRS
+ validation.h
+ DEPS
+ absl::status
+ absl::strings
+)
+
+ink_cc_test(
+ NAME
+ validation_test
+ SRCS
+ validation_test.cc
+ DEPS
+ InkStrokeModeler::validation
+ GTest::gtest_main
+)
diff --git a/ink_stroke_modeler/internal/internal_types.h b/ink_stroke_modeler/internal/internal_types.h
new file mode 100644
index 0000000..1c6b671
--- /dev/null
+++ b/ink_stroke_modeler/internal/internal_types.h
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_INTERNAL_INTERNAL_TYPES_H_
+#define INK_STROKE_MODELER_INTERNAL_INTERNAL_TYPES_H_
+
+#include <ostream>
+
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+
+// This struct contains the position and velocity of the modeled pen tip at
+// the indicated time.
+struct TipState {
+ Vec2 position{0};
+ Vec2 velocity{0};
+ Time time{0};
+};
+
+std::ostream &operator<<(std::ostream &s, const TipState &tip_state);
+
+// This struct contains information about the state of the stylus. See the
+// corresponding fields on the Input struct for more info.
+struct StylusState {
+ float pressure = -1;
+ float tilt = -1;
+ float orientation = -1;
+};
+
+bool operator==(const StylusState &lhs, const StylusState &rhs);
+std::ostream &operator<<(std::ostream &s, const StylusState &stylus_state);
+
+////////////////////////////////////////////////////////////////////////////////
+// Inline function definitions
+////////////////////////////////////////////////////////////////////////////////
+
+inline bool operator==(const StylusState &lhs, const StylusState &rhs) {
+ return lhs.pressure == rhs.pressure && lhs.tilt == rhs.tilt &&
+ lhs.orientation == rhs.orientation;
+}
+
+inline std::ostream &operator<<(std::ostream &s, const TipState &tip_state) {
+ return s << "TipState: pos: " << tip_state.position
+ << ", velocity: " << tip_state.velocity
+ << ", time: " << tip_state.time << ">";
+}
+
+inline std::ostream &operator<<(std::ostream &s,
+ const StylusState &stylus_state) {
+ return s << "<Result: pressure: " << stylus_state.pressure
+ << ", tilt: " << stylus_state.tilt
+ << ", orientation: " << stylus_state.orientation << ">";
+}
+
+} // namespace stroke_model
+} // namespace ink
+
+#endif // INK_STROKE_MODELER_INTERNAL_INTERNAL_TYPES_H_
diff --git a/ink_stroke_modeler/internal/position_modeler.h b/ink_stroke_modeler/internal/position_modeler.h
new file mode 100644
index 0000000..c3276e8
--- /dev/null
+++ b/ink_stroke_modeler/internal/position_modeler.h
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_INTERNAL_POSITION_MODELER_H_
+#define INK_STROKE_MODELER_INTERNAL_POSITION_MODELER_H_
+
+#include "ink_stroke_modeler/internal/internal_types.h"
+#include "ink_stroke_modeler/internal/utils.h"
+#include "ink_stroke_modeler/params.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+
+// This class models the movement of the pen tip based on the laws of motion.
+// The pen tip is represented as a mass, connected by a spring to a moving
+// anchor; as the anchor moves, it drags the pen tip along behind it.
+class PositionModeler {
+ public:
+ void Reset(const TipState& state, PositionModelerParams params) {
+ state_ = state;
+ params_ = params;
+ }
+
+ // Given the position of the anchor and the time, updates the model and
+ // returns the state of the pen tip.
+ TipState Update(Vec2 anchor_position, Time time) {
+ Duration delta_time = time - state_.time;
+ float float_delta = delta_time.Value();
+ auto acceleration =
+ (anchor_position - state_.position) / params_.spring_mass_constant -
+ params_.drag_constant * state_.velocity;
+ state_.velocity += float_delta * acceleration;
+ state_.position += float_delta * state_.velocity;
+ state_.time = time;
+
+ return state_;
+ }
+
+ const TipState& CurrentState() const { return state_; }
+ const PositionModelerParams& Params() const { return params_; }
+
+ // This helper function linearly interpolates between the between the start
+ // and end anchor position and time, updating the model at each step and
+ // storing the result in the given output iterator.
+ //
+ // NOTE: Because the expected use case is to repeatedly call this function on
+ // a sequence of anchor positions/times, the start position/time is not sent
+ // to the model. This prevents us from duplicating those inputs, but it does
+ // mean that the first input must be provided on its own, via either Reset()
+ // or Update(). This also means that the interpolation values are
+ // (1 ... n) / n, as opposed to (0 ... (n - 1)) / (n - 1).
+ //
+ // Template parameter OutputIt is expected to be an output iterator over
+ // TipState.
+ template <typename OutputIt>
+ void UpdateAlongLinearPath(Vec2 start_anchor_position, Time start_time,
+ Vec2 end_anchor_position, Time end_time,
+ int n_samples, OutputIt output) {
+ for (int i = 1; i <= n_samples; ++i) {
+ auto interp_value = static_cast<float>(i) / n_samples;
+ auto position =
+ Interp(start_anchor_position, end_anchor_position, interp_value);
+ auto time = Interp(start_time, end_time, interp_value);
+ *output++ = Update(position, time);
+ }
+ }
+
+ // This helper function models the end of the stroke, by repeatedly updating
+ // with the final anchor position. It attempts to stop at the closest point to
+ // the anchor, by checking if it has overshot, and retrying with successively
+ // smaller time steps.
+ //
+ // It halts when any of these three conditions is met:
+ // - It has taken more than max_iterations steps (including discarded steps)
+ // - The distance between the current state and the anchor is less than
+ // stop_distance
+ // - The distance between the previous state and the current state is less
+ // than stop_distance
+ //
+ // Template parameter OutputIt is expected to be an output iterator over
+ // TipState.
+ template <typename OutputIt>
+ void ModelEndOfStroke(Vec2 anchor_position, Duration delta_time,
+ int max_iterations, float stop_distance,
+ OutputIt output) {
+ for (int i = 0; i < max_iterations; ++i) {
+ // The call to Update modifies the state, so we store a copy of the
+ // previous state so we can retry with a smaller step if necessary.
+ const TipState previous_state = state_;
+ TipState candidate =
+ Update(anchor_position, previous_state.time + delta_time);
+ if (Distance(previous_state.position, candidate.position) <
+ stop_distance) {
+ // We're no longer making any significant progress, which means that
+ // we're about as close as we can get without looping around.
+ return;
+ }
+
+ float closest_t = NearestPointOnSegment(
+ previous_state.position, candidate.position, anchor_position);
+ if (closest_t < 1) {
+ // We're overshot the anchor, retry with a smaller step.
+ delta_time *= .5;
+ state_ = previous_state;
+ continue;
+ }
+ *output++ = candidate;
+
+ if (Distance(candidate.position, anchor_position) < stop_distance) {
+ // We're within tolerance of the anchor.
+ return;
+ }
+ }
+ }
+
+ private:
+ PositionModelerParams params_;
+ TipState state_;
+};
+
+} // namespace stroke_model
+} // namespace ink
+
+#endif // INK_STROKE_MODELER_INTERNAL_POSITION_MODELER_H_
diff --git a/ink_stroke_modeler/internal/position_modeler_test.cc b/ink_stroke_modeler/internal/position_modeler_test.cc
new file mode 100644
index 0000000..7898ea1
--- /dev/null
+++ b/ink_stroke_modeler/internal/position_modeler_test.cc
@@ -0,0 +1,317 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/position_modeler.h"
+
+#include <cmath>
+#include <iterator>
+#include <vector>
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "ink_stroke_modeler/internal/internal_types.h"
+#include "ink_stroke_modeler/internal/type_matchers.h"
+#include "ink_stroke_modeler/params.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+using ::testing::ElementsAre;
+
+const Duration kDefaultTimeStep(1. / 180);
+constexpr float kTol = .00005;
+
+// The expected position values are taken directly from results the old
+// TipDynamics class. The expected velocity values are from the same source, but
+// a multiplier of 300 (i.e. dt / (1 - drag)) had to be applied to account for
+// the fact that PositionModeler uses the time step correctly.
+
+TEST(PositionModelerTest, StraightLine) {
+ PositionModeler modeler;
+ Time current_time(0);
+ modeler.Reset({{0, 0}, {0, 0}, current_time}, PositionModelerParams());
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(modeler.Update({1, 0}, current_time),
+ TipStateNear({{.0909, 0}, {16.3636, 0}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(modeler.Update({2, 0}, current_time),
+ TipStateNear({{.319, 0}, {41.0579, 0}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(modeler.Update({3, 0}, current_time),
+ TipStateNear({{.6996, 0}, {68.5055, 0}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(modeler.Update({4, 0}, current_time),
+ TipStateNear({{1.228, 0}, {95.1099, 0}, current_time}, kTol));
+}
+
+TEST(PositionModelerTest, ZigZag) {
+ PositionModeler modeler;
+ Time current_time(3);
+ modeler.Reset({{-1, -1}, {0, 0}, current_time}, PositionModelerParams());
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(modeler.Update({-.5, -1}, current_time),
+ TipStateNear({{-.9545, -1}, {8.1818, 0}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update({-.5, -.5}, current_time),
+ TipStateNear({{-.886, -.9545}, {12.3471, 8.1818}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update({0, -.5}, current_time),
+ TipStateNear({{-.7643, -.886}, {21.9056, 12.3471}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update({0, 0}, current_time),
+ TipStateNear({{-.6218, -.7643}, {25.6493, 21.9056}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update({.5, 0}, current_time),
+ TipStateNear({{-.4343, -.6218}, {33.7456, 25.6493}, current_time}, kTol));
+}
+
+TEST(PositionModelerTest, SharpTurn) {
+ PositionModeler modeler;
+ Time current_time(1.6);
+ modeler.Reset({{0, 0}, {0, 0}, current_time}, PositionModelerParams());
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update({.25, .25}, current_time),
+ TipStateNear({{.0227, .0227}, {4.0909, 4.0909}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update({.5, .5}, current_time),
+ TipStateNear({{.0798, .0798}, {10.2645, 10.2645}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update({.75, .75}, current_time),
+ TipStateNear({{.1749, .1749}, {17.1264, 17.1264}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update({1, 1}, current_time),
+ TipStateNear({{.307, .307}, {23.7775, 23.7775}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update({1.25, .75}, current_time),
+ TipStateNear({{.472, .4265}, {29.6975, 21.5157}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update({1.5, .5}, current_time),
+ TipStateNear({{.6644, .5049}, {34.6406, 14.1117}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update({1.75, .25}, current_time),
+ TipStateNear({{.8786, .5288}, {38.5482, 4.2955}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update({2, 0}, current_time),
+ TipStateNear({{1.109, .495}, {41.4794, -6.0756}, current_time}, kTol));
+}
+
+TEST(PositionModelerTest, SmoothTurn) {
+ auto point_on_circle = [](float theta) {
+ return Vec2{std::cos(theta), std::sin(theta)};
+ };
+
+ PositionModeler modeler;
+ Time current_time(10.1);
+ modeler.Reset({point_on_circle(0), {0, 0}, current_time},
+ PositionModelerParams());
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update(point_on_circle(M_PI * .125), current_time),
+ TipStateNear({{.9931, .0348}, {-1.2456, 6.2621}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update(point_on_circle(M_PI * .25), current_time),
+ TipStateNear({{0.9629, 0.1168}, {-5.4269, 14.7588}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(modeler.Update(point_on_circle(M_PI * .375), current_time),
+ TipStateNear(
+ {{0.8921, 0.2394}, {-12.7511, 22.0623}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(modeler.Update(point_on_circle(M_PI * .5), current_time),
+ TipStateNear(
+ {{0.7685, 0.3820}, {-22.2485, 25.6844}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(modeler.Update(point_on_circle(M_PI * .625), current_time),
+ TipStateNear(
+ {{0.5897, 0.5169}, {-32.1865, 24.2771}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(modeler.Update(point_on_circle(M_PI * .75), current_time),
+ TipStateNear(
+ {{0.3645, 0.6151}, {-40.5319, 17.6785}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update(point_on_circle(M_PI * .875), current_time),
+ TipStateNear({{0.1123, 0.6529}, {-45.4017, 6.8034}, current_time}, kTol));
+
+ current_time += kDefaultTimeStep;
+ EXPECT_THAT(
+ modeler.Update(point_on_circle(M_PI), current_time),
+ TipStateNear({{-0.1402, 0.6162}, {-45.4417, -6.6022}, current_time},
+ kTol));
+}
+
+TEST(PositionModelerTest, UpdateAlongLinearPath) {
+ PositionModeler modeler;
+ modeler.Reset({{5, 10}, {0, 0}, Time{3}}, PositionModelerParams());
+
+ std::vector<TipState> result;
+ modeler.UpdateAlongLinearPath({5, 10}, Time{3}, {15, 10}, Time{3.05}, 5,
+ std::back_inserter(result));
+ EXPECT_THAT(
+ result,
+ ElementsAre(
+ TipStateNear({{5.5891, 10}, {58.9091, 0}, Time{3.01}}, kTol),
+ TipStateNear({{6.7587, 10}, {116.9613, 0}, Time{3.02}}, kTol),
+ TipStateNear({{8.3355, 10}, {157.6746, 0}, Time{3.03}}, kTol),
+ TipStateNear({{10.1509, 10}, {181.5411, 0}, Time{3.04}}, kTol),
+ TipStateNear({{12.0875, 10}, {193.6607, 0}, Time{3.05}}, kTol)));
+
+ result.clear();
+ modeler.UpdateAlongLinearPath({15, 10}, Time{3.05}, {15, 16}, Time{3.08}, 3,
+ std::back_inserter(result));
+ EXPECT_THAT(
+ result,
+ ElementsAre(
+ TipStateNear({{13.4876, 10.5891}, {140.0123, 58.9091}, Time{3.06}},
+ kTol),
+ TipStateNear({{14.3251, 11.7587}, {83.7508, 116.9613}, Time{3.07}},
+ kTol),
+ TipStateNear({{14.7584, 13.3355}, {43.3291, 157.6746}, Time{3.08}},
+ kTol)));
+}
+
+TEST(PositionModelerTest, ModelEndOfStrokeStationary) {
+ PositionModeler modeler;
+ modeler.Reset({{4, -2}, {0, 0}, Time{0}}, PositionModelerParams());
+
+ std::vector<TipState> result;
+ modeler.ModelEndOfStroke({3, -1}, Duration(1. / 180), 20, .01,
+ std::back_inserter(result));
+ EXPECT_THAT(
+ result,
+ ElementsAre(
+ TipStateNear({{3.9091, -1.9091}, {-16.3636, 16.3636}, Time{0.0056}},
+ kTol),
+ TipStateNear({{3.7719, -1.7719}, {-24.6942, 24.6942}, Time{0.0111}},
+ kTol),
+ TipStateNear({{3.6194, -1.6194}, {-27.4476, 27.4476}, Time{0.0167}},
+ kTol),
+ TipStateNear({{3.4716, -1.4716}, {-26.6045, 26.6044}, Time{0.0222}},
+ kTol),
+ TipStateNear({{3.3401, -1.3401}, {-23.6799, 23.6799}, Time{0.0278}},
+ kTol),
+ TipStateNear({{3.2302, -1.2302}, {-19.7725, 19.7725}, Time{0.0333}},
+ kTol),
+ TipStateNear({{3.1434, -1.1434}, {-15.6306, 15.6306}, Time{0.0389}},
+ kTol),
+ TipStateNear({{3.0782, -1.0782}, {-11.7244, 11.7244}, Time{0.0444}},
+ kTol),
+ TipStateNear({{3.0320, -1.0320}, {-8.3149, 8.3149}, Time{0.0500}},
+ kTol),
+ TipStateNear({{3.0014, -1.0014}, {-5.5133, 5.5133}, Time{0.0556}},
+ kTol)));
+}
+
+TEST(PositionModelerTest, ModelEndOfStrokeInMotion) {
+ PositionModeler modeler;
+ modeler.Reset({{-1, 2}, {40, 10}, Time{1}}, PositionModelerParams());
+
+ std::vector<TipState> result;
+ modeler.ModelEndOfStroke({7, 2}, Duration(1. / 120), 20, .01,
+ std::back_inserter(result));
+ EXPECT_THAT(
+ result,
+ ElementsAre(
+ TipStateNear({{0.7697, 2.0333}, {212.3636, 4.0000}, Time{1.0083}},
+ kTol),
+ TipStateNear({{2.7520, 2.0398}, {237.8711, 0.7818}, Time{1.0167}},
+ kTol),
+ TipStateNear({{4.4138, 2.0343}, {199.4186, -0.6654}, Time{1.0250}},
+ kTol),
+ TipStateNear({{5.6075, 2.0251}, {143.2474, -1.1081}, Time{1.0333}},
+ kTol),
+ TipStateNear({{6.3698, 2.0162}, {91.4784, -1.0586}, Time{1.0417}},
+ kTol),
+ TipStateNear({{6.8037, 2.0094}, {52.0592, -0.8222}, Time{1.0500}},
+ kTol),
+ TipStateNear({{6.9655, 2.0065}, {38.8512, -0.6909}, Time{1.0542}},
+ kTol),
+ TipStateNear({{6.9850, 2.0062}, {37.4471, -0.6750}, Time{1.0547}},
+ kTol)));
+}
+
+TEST(PositionModelerTest, ModelEndOfStrokeMaxIterationsReached) {
+ PositionModeler modeler;
+ modeler.Reset({{8, -3}, {-100, -150}, Time{1}}, PositionModelerParams());
+
+ std::vector<TipState> result;
+ modeler.ModelEndOfStroke({-9, -10}, Duration(.0001), 10, .001,
+ std::back_inserter(result));
+ EXPECT_THAT(
+ result,
+ ElementsAre(
+ TipStateNear(
+ {{7.9896, -3.0151}, {-104.2873, -150.9818}, Time{1.0001}}, kTol),
+ TipStateNear(
+ {{7.9787, -3.0303}, {-108.5406, -151.9521}, Time{1.0002}}, kTol),
+ TipStateNear(
+ {{7.9674, -3.0456}, {-112.7601, -152.9110}, Time{1.0003}}, kTol),
+ TipStateNear(
+ {{7.9557, -3.0610}, {-116.9459, -153.8584}, Time{1.0004}}, kTol),
+ TipStateNear(
+ {{7.9436, -3.0764}, {-121.0982, -154.7945}, Time{1.0005}}, kTol),
+ TipStateNear(
+ {{7.9311, -3.0920}, {-125.2169, -155.7193}, Time{1.0006}}, kTol),
+ TipStateNear(
+ {{7.9182, -3.1077}, {-129.3023, -156.6328}, Time{1.0007}}, kTol),
+ TipStateNear(
+ {{7.9048, -3.1234}, {-133.3545, -157.5351}, Time{1.0008}}, kTol),
+ TipStateNear(
+ {{7.8911, -3.1393}, {-137.3736, -158.4263}, Time{1.0009}}, kTol),
+ TipStateNear(
+ {{7.8770, -3.1552}, {-141.3597, -159.3065}, Time{1.0010}},
+ kTol)));
+}
+
+} // namespace
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/internal/prediction/BUILD.bazel b/ink_stroke_modeler/internal/prediction/BUILD.bazel
new file mode 100644
index 0000000..2d94289
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/BUILD.bazel
@@ -0,0 +1,83 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+package(
+ default_visibility = ["//ink_stroke_modeler:__subpackages__"],
+)
+
+licenses(["notice"])
+
+cc_library(
+ name = "input_predictor",
+ hdrs = ["input_predictor.h"],
+ deps = [
+ "//ink_stroke_modeler:params",
+ "//ink_stroke_modeler:types",
+ "//ink_stroke_modeler/internal:internal_types",
+ ],
+)
+
+cc_library(
+ name = "kalman_predictor",
+ srcs = ["kalman_predictor.cc"],
+ hdrs = ["kalman_predictor.h"],
+ deps = [
+ ":input_predictor",
+ "//ink_stroke_modeler:params",
+ "//ink_stroke_modeler:types",
+ "//ink_stroke_modeler/internal:internal_types",
+ "//ink_stroke_modeler/internal:utils",
+ "//ink_stroke_modeler/internal/prediction/kalman_filter",
+ ],
+)
+
+cc_test(
+ name = "kalman_predictor_test",
+ srcs = ["kalman_predictor_test.cc"],
+ deps = [
+ ":input_predictor",
+ ":kalman_predictor",
+ "//ink_stroke_modeler:params",
+ "//ink_stroke_modeler:types",
+ "//ink_stroke_modeler/internal:type_matchers",
+ "@com_google_absl//absl/types:optional",
+ "@com_google_googletest//:gtest_main",
+ ],
+)
+
+cc_library(
+ name = "stroke_end_predictor",
+ srcs = ["stroke_end_predictor.cc"],
+ hdrs = ["stroke_end_predictor.h"],
+ deps = [
+ ":input_predictor",
+ "//ink_stroke_modeler:params",
+ "//ink_stroke_modeler:types",
+ "//ink_stroke_modeler/internal:internal_types",
+ "//ink_stroke_modeler/internal:position_modeler",
+ "@com_google_absl//absl/types:optional",
+ ],
+)
+
+cc_test(
+ name = "stroke_end_predictor_test",
+ srcs = ["stroke_end_predictor_test.cc"],
+ deps = [
+ ":input_predictor",
+ ":stroke_end_predictor",
+ "//ink_stroke_modeler:params",
+ "//ink_stroke_modeler/internal:type_matchers",
+ "@com_google_googletest//:gtest_main",
+ ],
+)
diff --git a/ink_stroke_modeler/internal/prediction/CMakeLists.txt b/ink_stroke_modeler/internal/prediction/CMakeLists.txt
new file mode 100644
index 0000000..e54dbbe
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/CMakeLists.txt
@@ -0,0 +1,86 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+add_subdirectory(kalman_filter)
+
+ink_cc_library(
+ NAME
+ input_predictor
+ HDRS
+ input_predictor.h
+ DEPS
+ InkStrokeModeler::params
+ InkStrokeModeler::types
+ InkStrokeModeler::internal_types
+)
+
+ink_cc_library(
+ NAME
+ kalman_predictor
+ SRCS
+ kalman_predictor.cc
+ HDRS
+ kalman_predictor.h
+ DEPS
+ InkStrokeModeler::input_predictor
+ InkStrokeModeler::params
+ InkStrokeModeler::types
+ InkStrokeModeler::internal_types
+ InkStrokeModeler::utils
+ InkStrokeModeler::kalman_filter
+)
+
+ink_cc_test(
+ NAME
+ kalman_predictor_test
+ SRCS
+ kalman_predictor_test.cc
+ DEPS
+ InkStrokeModeler::input_predictor
+ InkStrokeModeler::kalman_predictor
+ InkStrokeModeler::params
+ InkStrokeModeler::types
+ InkStrokeModeler::type_matchers
+ absl::optional
+ GTest::gmock_main
+)
+
+ink_cc_library(
+ NAME
+ stroke_end_predictor
+ SRCS
+ stroke_end_predictor.cc
+ HDRS
+ stroke_end_predictor.h
+ DEPS
+ InkStrokeModeler::input_predictor
+ InkStrokeModeler::params
+ InkStrokeModeler::types
+ InkStrokeModeler::internal_types
+ InkStrokeModeler::position_modeler
+ absl::optional
+)
+
+ink_cc_test(
+ NAME
+ stroke_end_predictor_test
+ SRCS
+ stroke_end_predictor_test.cc
+ DEPS
+ InkStrokeModeler::input_predictor
+ InkStrokeModeler::stroke_end_predictor
+ InkStrokeModeler::params
+ InkStrokeModeler::type_matchers
+ GTest::gmock_main
+)
diff --git a/ink_stroke_modeler/internal/prediction/input_predictor.h b/ink_stroke_modeler/internal/prediction/input_predictor.h
new file mode 100644
index 0000000..4147953
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/input_predictor.h
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_INTERNAL_PREDICTION_INPUT_PREDICTOR_H_
+#define INK_STROKE_MODELER_INTERNAL_PREDICTION_INPUT_PREDICTOR_H_
+
+#include <vector>
+
+#include "ink_stroke_modeler/internal/internal_types.h"
+#include "ink_stroke_modeler/params.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+
+// Interface for input predictors that generate points based on past input.
+class InputPredictor {
+ public:
+ virtual ~InputPredictor() {}
+
+ // Resets the predictor's internal model.
+ virtual void Reset() = 0;
+
+ // Updates the predictor's internal model with the given input.
+ virtual void Update(Vec2 position, Time time) = 0;
+
+ // Constructs a prediction based from the given state, based on the
+ // predictor's internal model. The result may be empty if the predictor has
+ // not yet accumulated enough data, via Update(), to construct a reasonable
+ // prediction.
+ //
+ // Subclasses are expected to maintain the following invariants:
+ // - The given state must not appear in the prediction.
+ // - The time delta between each state in the prediction, and between the
+ // given state and the first predicted state, must conform to
+ // SamplingParams::min_output_rate.
+ virtual std::vector<TipState> ConstructPrediction(
+ const TipState &last_state) const = 0;
+};
+
+} // namespace stroke_model
+} // namespace ink
+
+#endif // INK_STROKE_MODELER_INTERNAL_PREDICTION_INPUT_PREDICTOR_H_
diff --git a/ink_stroke_modeler/internal/prediction/kalman_filter/BUILD.bazel b/ink_stroke_modeler/internal/prediction/kalman_filter/BUILD.bazel
new file mode 100644
index 0000000..e487c16
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/kalman_filter/BUILD.bazel
@@ -0,0 +1,58 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+package(
+ default_visibility = ["//ink_stroke_modeler:__subpackages__"],
+)
+
+licenses(["notice"])
+
+cc_library(
+ name = "matrix",
+ hdrs = ["matrix.h"],
+)
+
+cc_test(
+ name = "matrix_test",
+ srcs = ["matrix_test.cc"],
+ deps = [
+ ":matrix",
+ "@com_google_googletest//:gtest_main",
+ ],
+)
+
+cc_library(
+ name = "kalman_filter",
+ srcs = [
+ "axis_predictor.cc",
+ "kalman_filter.cc",
+ ],
+ hdrs = [
+ "axis_predictor.h",
+ "kalman_filter.h",
+ ],
+ deps = [
+ ":matrix",
+ "@com_google_absl//absl/memory",
+ ],
+)
+
+cc_test(
+ name = "axis_predictor_test",
+ srcs = ["axis_predictor_test.cc"],
+ deps = [
+ ":kalman_filter",
+ "@com_google_googletest//:gtest_main",
+ ],
+)
diff --git a/ink_stroke_modeler/internal/prediction/kalman_filter/CMakeLists.txt b/ink_stroke_modeler/internal/prediction/kalman_filter/CMakeLists.txt
new file mode 100644
index 0000000..b3c8d88
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/kalman_filter/CMakeLists.txt
@@ -0,0 +1,54 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+ink_cc_library(
+ NAME
+ matrix
+ HDRS
+ matrix.h
+)
+
+ink_cc_test(
+ NAME
+ matrix_test
+ SRCS
+ matrix_test.cc
+ DEPS
+ InkStrokeModeler::matrix
+ GTest::gtest_main
+)
+
+ink_cc_library(
+ NAME
+ kalman_filter
+ SRCS
+ axis_predictor.cc
+ kalman_filter.cc
+ HDRS
+ axis_predictor.h
+ kalman_filter.h
+ DEPS
+ InkStrokeModeler::matrix
+ absl::memory
+)
+
+ink_cc_test(
+ NAME
+ axis_predictor_test
+ SRCS
+ axis_predictor_test.cc
+ DEPS
+ InkStrokeModeler::kalman_filter
+ GTest::gtest_main
+)
diff --git a/ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor.cc b/ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor.cc
new file mode 100644
index 0000000..fab9363
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor.cc
@@ -0,0 +1,98 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor.h"
+
+#include "absl/memory/memory.h"
+#include "ink_stroke_modeler/internal/prediction/kalman_filter/matrix.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+constexpr int kPositionIndex = 0;
+constexpr int kVelocityIndex = 1;
+constexpr int kAccelerationIndex = 2;
+constexpr int kJerkIndex = 3;
+
+constexpr double kDt = 1.0;
+constexpr double kDtSquared = kDt * kDt;
+constexpr double kDtCubed = kDt * kDt * kDt;
+} // namespace
+
+AxisPredictor::AxisPredictor(double process_noise, double measurement_noise,
+ int min_stable_iteration) {
+ // State translation matrix is basic physics.
+ // new_pos = pre_pos + v * dt + 1/2 * a * dt^2 + 1/6 * J * dt^3.
+ // new_v = v + a * dt + 1/2 * J * dt^2.
+ // new_a = a + J * dt.
+ // new_j = J.
+ Matrix4 state_transition(1, kDt, .5 * kDtSquared, 1.0 / 6 * kDtCubed, //
+ 0, 1, kDt, .5 * kDtSquared, //
+ 0, 0, 1, kDt, //
+ 0, 0, 0, 1);
+ // We model the system noise as noisy force on the pen.
+ // The following matrix describes the impact of that noise on each state.
+ Vec4 process_noise_vector(1.0 / 6 * kDtCubed, 0.5 * kDtSquared, kDt, 1.0);
+ Matrix4 process_noise_covariance =
+ OuterProduct(process_noise_vector, process_noise_vector) * process_noise;
+
+ // Sensor only detects location. Thus measurement only impact the position.
+ Vec4 measurement_vector(1.0, 0.0, 0.0, 0.0);
+
+ kalman_filter_ = absl::make_unique<KalmanFilter>(
+ state_transition, process_noise_covariance, measurement_vector,
+ measurement_noise, min_stable_iteration);
+}
+
+bool AxisPredictor::Stable() const {
+ return kalman_filter_ && kalman_filter_->Stable();
+}
+
+void AxisPredictor::Reset() {
+ if (kalman_filter_) kalman_filter_->Reset();
+}
+
+void AxisPredictor::Update(double observation) {
+ if (kalman_filter_) kalman_filter_->Update(observation);
+}
+
+int AxisPredictor::NumIterations() const {
+ return kalman_filter_ ? kalman_filter_->NumIterations() : 0;
+}
+
+double AxisPredictor::GetPosition() const {
+ if (kalman_filter_)
+ return kalman_filter_->GetStateEstimation()[kPositionIndex];
+ else
+ return 0.0;
+}
+
+double AxisPredictor::GetVelocity() const {
+ if (kalman_filter_)
+ return kalman_filter_->GetStateEstimation()[kVelocityIndex];
+ else
+ return 0.0;
+}
+
+double AxisPredictor::GetAcceleration() const {
+ return kalman_filter_->GetStateEstimation()[kAccelerationIndex];
+}
+
+double AxisPredictor::GetJerk() const {
+ return kalman_filter_->GetStateEstimation()[kJerkIndex];
+}
+
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor.h b/ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor.h
new file mode 100644
index 0000000..3ab02ee
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor.h
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_INTERNAL_PREDICTION_KALMAN_FILTER_AXIS_PREDICTOR_H_
+#define INK_STROKE_MODELER_INTERNAL_PREDICTION_KALMAN_FILTER_AXIS_PREDICTOR_H_
+
+#include <memory>
+
+#include "ink_stroke_modeler/internal/prediction/kalman_filter/kalman_filter.h"
+
+namespace ink {
+namespace stroke_model {
+
+// Class to predict on axis.
+//
+// This predictor use one instance of Kalman filter to predict one dimension of
+// stylus movement.
+class AxisPredictor {
+ public:
+ AxisPredictor(double process_noise, double measurement_noise,
+ int min_stable_iteration);
+
+ // Return true if the underlying Kalman filter is stable.
+ bool Stable() const;
+
+ // Reset the underlying Kalman filter.
+ void Reset();
+
+ // Update the predictor with a new observation.
+ void Update(double observation);
+
+ // Returns the number of times Update() has been called since the last time
+ // the AxisPredictor was reset.
+ int NumIterations() const;
+
+ // Get the predicted values from the underlying Kalman filter.
+ double GetPosition() const;
+ double GetVelocity() const;
+ double GetAcceleration() const;
+ double GetJerk() const;
+
+ private:
+ std::unique_ptr<KalmanFilter> kalman_filter_;
+};
+
+} // namespace stroke_model
+} // namespace ink
+
+#endif // INK_STROKE_MODELER_INTERNAL_PREDICTION_KALMAN_FILTER_AXIS_PREDICTOR_H_
diff --git a/ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor_test.cc b/ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor_test.cc
new file mode 100644
index 0000000..40ba9f4
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor_test.cc
@@ -0,0 +1,100 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor.h"
+
+#include <vector>
+
+#include "gtest/gtest.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+constexpr int kStableIterNum = 4;
+
+constexpr double kProcessNoise = 0.01;
+constexpr double kMeasurementNoise = 1.0;
+
+} // namespace
+
+struct DataSet {
+ double initial_observation;
+ std::vector<double> observation;
+ std::vector<double> position;
+ std::vector<double> velocity;
+ std::vector<double> acceleration;
+ std::vector<double> jerk;
+};
+
+void ValidateAxisPredictor(AxisPredictor* predictor, const DataSet& data) {
+ predictor->Reset();
+ predictor->Update(data.initial_observation);
+ for (decltype(data.observation.size()) i = 0; i < data.observation.size();
+ i++) {
+ predictor->Update(data.observation[i]);
+ EXPECT_NEAR(data.position[i], predictor->GetPosition(), 0.0001);
+ EXPECT_NEAR(data.velocity[i], predictor->GetVelocity(), 0.0001);
+ EXPECT_NEAR(data.acceleration[i], predictor->GetAcceleration(), 0.0001);
+ EXPECT_NEAR(data.jerk[i], predictor->GetJerk(), 0.0001);
+ }
+}
+
+// Test that the predictor will stable.
+TEST(AxisPredictorTest, ShouldStable) {
+ AxisPredictor predictor(kProcessNoise, kMeasurementNoise, kStableIterNum);
+ for (int i = 0; i < kStableIterNum; i++) {
+ EXPECT_FALSE(predictor.Stable());
+ predictor.Update(1);
+ }
+ EXPECT_TRUE(predictor.Stable());
+}
+
+// Test the kalman filter behavior. The data set is generated by a "known to
+// work" kalman filter.
+TEST(AxisPredictorTest, PredictedValue) {
+ AxisPredictor predictor(kProcessNoise, kMeasurementNoise, kStableIterNum);
+ DataSet data;
+ data.initial_observation = 0;
+ data.observation = {1, 2, 3, 4, 5, 6};
+ data.position = {0.6949411066858742, 1.8880162111305765, 3.0596776689233476,
+ 4.080666568886563, 5.039574058758894, 5.990101744132957};
+ data.velocity = {0.48326413015846115, 1.349212968908908, 1.5150757723942188,
+ 1.2449353797925855, 0.9823147273054352, 0.831418084705206};
+ data.acceleration = {0.20388102703160751, 0.6602537865634062,
+ 0.46392675203046707, 0.0691864035645362,
+ -0.1571001901104591, -0.2303438651979314};
+ data.jerk = {0.051351580374544535, 0.17805019978769315,
+ 0.06592110190532013, -0.06063794909774803,
+ -0.10198612906906362, -0.09541445938944032};
+
+ ValidateAxisPredictor(&predictor, data);
+
+ data.initial_observation = 0;
+ data.observation = {1, 2, 4, 8, 16, 32};
+ data.position = {0.6949411066858742, 1.8880162111305765, 3.9597202826804603,
+ 7.9052737853848285, 15.720340533540115, 31.24662046486774};
+ data.velocity = {0.48326413015846115, 1.349212968908908, 2.492271225870179,
+ 4.610844489557212, 8.828231877380588, 16.987494416071463};
+ data.acceleration = {0.20388102703160751, 0.6602537865634062,
+ 1.090991623810185, 1.885675547541351,
+ 3.4586206593783526, 6.34082285106952};
+ data.jerk = {0.051351580374544535, 0.17805019978769315, 0.25373225050247916,
+ 0.4023497012294069, 0.6945464157568688, 1.1947316519015612};
+
+ ValidateAxisPredictor(&predictor, data);
+}
+
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/internal/prediction/kalman_filter/kalman_filter.cc b/ink_stroke_modeler/internal/prediction/kalman_filter/kalman_filter.cc
new file mode 100644
index 0000000..45238f7
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/kalman_filter/kalman_filter.cc
@@ -0,0 +1,79 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/prediction/kalman_filter/kalman_filter.h"
+
+#include "ink_stroke_modeler/internal/prediction/kalman_filter/matrix.h"
+
+namespace ink {
+namespace stroke_model {
+
+KalmanFilter::KalmanFilter(const Matrix4& state_transition,
+ const Matrix4& process_noise_covariance,
+ const Vec4& measurement_vector,
+ double measurement_noise_variance,
+ int min_stable_iteration)
+ : state_transition_matrix_(state_transition),
+ process_noise_covariance_matrix_(process_noise_covariance),
+ measurement_vector_(measurement_vector),
+ measurement_noise_variance_(measurement_noise_variance),
+ min_stable_iteration_(min_stable_iteration),
+ iter_num_(0) {}
+
+void KalmanFilter::Predict() {
+ // X = F * X
+ state_estimation_ = state_transition_matrix_ * state_estimation_;
+ // P = F * P * F' + Q
+ error_covariance_matrix_ = state_transition_matrix_ *
+ error_covariance_matrix_ *
+ state_transition_matrix_.Transpose() +
+ process_noise_covariance_matrix_;
+}
+
+void KalmanFilter::Update(double observation) {
+ if (iter_num_++ == 0) {
+ // We only update the state estimation in the first iteration.
+ state_estimation_[0] = observation;
+ return;
+ }
+ Predict();
+ // Y = z - H * X
+ double y = observation - DotProduct(measurement_vector_, state_estimation_);
+ // S = H * P * H' + R
+ double S = DotProduct(measurement_vector_ * error_covariance_matrix_,
+ measurement_vector_) +
+ measurement_noise_variance_;
+ // K = P * H' * inv(S)
+ Vec4 kalman_gain = measurement_vector_ * error_covariance_matrix_ / S;
+
+ // X = X + K * Y
+ state_estimation_ = state_estimation_ + kalman_gain * y;
+
+ // I_HK = eye(P) - K * H
+ Matrix4 I_KH = Matrix4() - OuterProduct(kalman_gain, measurement_vector_);
+
+ // P = I_KH * P * I_KH' + K * R * K'
+ error_covariance_matrix_ =
+ I_KH * error_covariance_matrix_ * I_KH.Transpose() +
+ OuterProduct(kalman_gain, kalman_gain) * measurement_noise_variance_;
+}
+
+void KalmanFilter::Reset() {
+ state_estimation_ = {0, 0, 0, 0};
+ error_covariance_matrix_ = Matrix4(); // identity
+ iter_num_ = 0;
+}
+
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/internal/prediction/kalman_filter/kalman_filter.h b/ink_stroke_modeler/internal/prediction/kalman_filter/kalman_filter.h
new file mode 100644
index 0000000..65fb55f
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/kalman_filter/kalman_filter.h
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_INTERNAL_PREDICTION_KALMAN_FILTER_KALMAN_FILTER_H_
+#define INK_STROKE_MODELER_INTERNAL_PREDICTION_KALMAN_FILTER_KALMAN_FILTER_H_
+
+#include "ink_stroke_modeler/internal/prediction/kalman_filter/matrix.h"
+
+namespace ink {
+namespace stroke_model {
+
+// Generates a state estimation based upon observations which can then be used
+// to compute predicted values.
+class KalmanFilter {
+ public:
+ KalmanFilter(const Matrix4& state_transition,
+ const Matrix4& process_noise_covariance,
+ const Vec4& measurement_vector,
+ double measurement_noise_variance, int min_stable_iteration);
+
+ // Get the estimation of current state.
+ const Vec4& GetStateEstimation() const { return state_estimation_; }
+
+ // Will return true only if the Kalman filter has seen enough data and is
+ // considered as stable.
+ bool Stable() const { return iter_num_ >= min_stable_iteration_; }
+
+ // Update the observation of the system.
+ void Update(double observation);
+
+ void Reset();
+
+ // Returns the number of times Update() has been called since the last time
+ // the KalmanFilter was reset.
+ int NumIterations() const { return iter_num_; }
+
+ private:
+ void Predict();
+
+ // Estimate of the latent state
+ // Symbol: X
+ // Dimension: state_vector_dim_
+ Vec4 state_estimation_;
+
+ // The covariance of the difference between prior predicted latent
+ // state and posterior estimated latent state (the so-called "innovation".
+ // Symbol: P
+ Matrix4 error_covariance_matrix_;
+
+ // For position, state transition matrix is derived from basic physics:
+ // new_x = x + v * dt + 1/2 * a * dt^2 + 1/6 * jerk * dt^3
+ // new_v = v + a * dt + 1/2 * jerk * dt^2
+ // ...
+ // Matrix that transmit current state to next state
+ // Symbol: F
+ Matrix4 state_transition_matrix_;
+
+ // Process_noise_covariance_matrix_ is a time-varying parameter that will be
+ // estimated as part of the Kalman filter process.
+ // Symbol: Q
+ Matrix4 process_noise_covariance_matrix_;
+
+ // Vector to transform estimate to measurement.
+ // Symbol: H
+ const Vec4 measurement_vector_{0, 0, 0, 0};
+
+ // measurement_noise_ is a time-varying parameter that will be estimated as
+ // part of the Kalman filter process.
+ // Symbol: R
+ double measurement_noise_variance_;
+
+ // The first iteration at which the Kalman filter is considered stable enough
+ // to make a good estimate of the state.
+ int min_stable_iteration_;
+
+ // Tracks the number of update iterations that have occurred.
+ int iter_num_;
+};
+
+} // namespace stroke_model
+} // namespace ink
+
+#endif // INK_STROKE_MODELER_INTERNAL_PREDICTION_KALMAN_FILTER_KALMAN_FILTER_H_
diff --git a/ink_stroke_modeler/internal/prediction/kalman_filter/matrix.h b/ink_stroke_modeler/internal/prediction/kalman_filter/matrix.h
new file mode 100644
index 0000000..2c12bae
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/kalman_filter/matrix.h
@@ -0,0 +1,270 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_INTERNAL_PREDICTION_KALMAN_FILTER_MATRIX_H_
+#define INK_STROKE_MODELER_INTERNAL_PREDICTION_KALMAN_FILTER_MATRIX_H_
+
+#include <array>
+#include <cstddef>
+#include <ostream>
+
+namespace ink {
+namespace stroke_model {
+
+// These classes provide the matrix arithmetic needed for the Kalman filter.
+//
+// This is intentionally limited to just the required functions, so some
+// common matrix arithmetic operations aren't present (e.g. inversion), and
+// some operators' symmetric counterparts are missing (e.g. Vec4 * double is
+// defined, but double * Vec4 is not).
+
+// A double-precision vector in 4-dimensional space.
+class Vec4 {
+ public:
+ constexpr Vec4() : Vec4(0, 0, 0, 0) {}
+ constexpr Vec4(double x, double y, double z, double w)
+ : array_({x, y, z, w}) {}
+
+ double& operator[](size_t i) { return array_[i]; }
+ double operator[](size_t i) const { return array_[i]; }
+
+ private:
+ std::array<double, 4> array_;
+};
+
+// A double-precision 4x4 matrix.
+class Matrix4 {
+ public:
+ // Constructs an identity matrix.
+ constexpr Matrix4()
+ : Matrix4(1, 0, 0, 0, //
+ 0, 1, 0, 0, //
+ 0, 0, 1, 0, //
+ 0, 0, 0, 1) {}
+
+ // Constructs a matrix with the given values, in row-major order.
+ constexpr Matrix4(double m00, double m01, double m02, double m03, //
+ double m10, double m11, double m12, double m13, //
+ double m20, double m21, double m22, double m23, //
+ double m30, double m31, double m32, double m33)
+ : array_{{{m00, m01, m02, m03},
+ {m10, m11, m12, m13},
+ {m20, m21, m22, m23},
+ {m30, m31, m32, m33}}} {}
+
+ // Constructs a matrix s.t. all values are zero.
+ static constexpr Matrix4 Zero() {
+ return {0, 0, 0, 0, //
+ 0, 0, 0, 0, //
+ 0, 0, 0, 0, //
+ 0, 0, 0, 0};
+ }
+
+ // Returns a copy of the matrix with its rows and columns swapped, i.e.
+ // original.At(i, j) == transposed.At(j, i).
+ Matrix4 Transpose() const {
+ Matrix4 result;
+ for (int i = 0; i < 4; ++i) {
+ for (int j = 0; j < 4; ++j) {
+ result.At(i, j) = At(j, i);
+ }
+ }
+ return result;
+ }
+
+ double& At(size_t row, size_t column) { return array_[row][column]; }
+ double At(size_t row, size_t column) const { return array_[row][column]; }
+
+ private:
+ std::array<Vec4, 4> array_;
+};
+
+// Computes the dot product of two vectors. Given vectors a and b, this is
+// equivalent to the matrix product:
+// [a₀ a₁ a₂ a₃]⎡b₀⎤
+// ⎢b₁⎥
+// ⎢b₂⎥
+// ⎣b₃⎦
+double DotProduct(const Vec4& lhs, const Vec4& rhs);
+
+// Computes the outer product of two vectors. Given vectors a and b, this is
+// equivalent to the matrix product:
+// ⎡a₀⎤[b₀ b₁ b₂ b₃]
+// ⎢a₁⎥
+// ⎢a₂⎥
+// ⎣a₃⎦
+Matrix4 OuterProduct(const Vec4& lhs, const Vec4& rhs);
+
+bool operator==(const Vec4& lhs, const Vec4& rhs);
+bool operator!=(const Vec4& lhs, const Vec4& rhs);
+Vec4 operator+(const Vec4& lhs, const Vec4& rhs);
+Vec4 operator*(const Vec4& v, double k);
+Vec4 operator/(const Vec4& v, double k);
+
+bool operator==(const Matrix4& lhs, const Matrix4& rhs);
+bool operator!=(const Matrix4& lhs, const Matrix4& rhs);
+Matrix4 operator*(const Matrix4& lhs, const Matrix4& rhs);
+Matrix4 operator+(const Matrix4& lhs, const Matrix4& rhs);
+Matrix4 operator-(const Matrix4& lhs, const Matrix4& rhs);
+
+Matrix4 operator*(const Matrix4& m, double k);
+Vec4 operator*(const Matrix4& m, const Vec4& v);
+Vec4 operator*(const Vec4& v, const Matrix4& m);
+
+std::ostream& operator<<(std::ostream& stream, const Vec4& v);
+std::ostream& operator<<(std::ostream& stream, const Matrix4& m);
+
+// ============================================================================
+// Inline function implementations
+// ============================================================================
+
+inline double DotProduct(const Vec4& lhs, const Vec4& rhs) {
+ double result = 0;
+ for (int i = 0; i < 4; ++i) result += lhs[i] * rhs[i];
+ return result;
+}
+
+inline Matrix4 OuterProduct(const Vec4& lhs, const Vec4& rhs) {
+ Matrix4 result = Matrix4::Zero();
+ for (int i = 0; i < 4; ++i) {
+ for (int j = 0; j < 4; ++j) {
+ result.At(i, j) = lhs[i] * rhs[j];
+ }
+ }
+ return result;
+}
+
+inline bool operator==(const Vec4& lhs, const Vec4& rhs) {
+ for (int i = 0; i < 4; ++i) {
+ if (lhs[i] != rhs[i]) return false;
+ }
+ return true;
+}
+inline bool operator!=(const Vec4& lhs, const Vec4& rhs) {
+ return !(lhs == rhs);
+}
+
+inline Vec4 operator+(const Vec4& lhs, const Vec4& rhs) {
+ Vec4 result;
+ for (int i = 0; i < 4; ++i) result[i] = lhs[i] + rhs[i];
+ return result;
+}
+
+inline Vec4 operator*(const Vec4& v, double k) {
+ Vec4 result;
+ for (int i = 0; i < 4; ++i) result[i] = v[i] * k;
+ return result;
+}
+
+inline Vec4 operator/(const Vec4& v, double k) {
+ Vec4 result;
+ for (int i = 0; i < 4; ++i) result[i] = v[i] / k;
+ return result;
+}
+
+inline bool operator==(const Matrix4& lhs, const Matrix4& rhs) {
+ for (int i = 0; i < 4; ++i) {
+ for (int j = 0; j < 4; ++j) {
+ if (lhs.At(i, j) != rhs.At(i, j)) return false;
+ }
+ }
+ return true;
+}
+
+inline bool operator!=(const Matrix4& lhs, const Matrix4& rhs) {
+ return !(lhs == rhs);
+}
+
+inline Matrix4 operator*(const Matrix4& lhs, const Matrix4& rhs) {
+ Matrix4 result = Matrix4::Zero();
+ for (int i = 0; i < 4; ++i) {
+ for (int j = 0; j < 4; ++j) {
+ for (int k = 0; k < 4; ++k) {
+ result.At(i, j) += lhs.At(i, k) * rhs.At(k, j);
+ }
+ }
+ }
+ return result;
+}
+
+inline Matrix4 operator+(const Matrix4& lhs, const Matrix4& rhs) {
+ Matrix4 result;
+ for (int i = 0; i < 4; ++i) {
+ for (int j = 0; j < 4; ++j) {
+ result.At(i, j) = lhs.At(i, j) + rhs.At(i, j);
+ }
+ }
+ return result;
+}
+
+inline Matrix4 operator-(const Matrix4& lhs, const Matrix4& rhs) {
+ Matrix4 result;
+ for (int i = 0; i < 4; ++i) {
+ for (int j = 0; j < 4; ++j) {
+ result.At(i, j) = lhs.At(i, j) - rhs.At(i, j);
+ }
+ }
+ return result;
+}
+
+inline Matrix4 operator*(const Matrix4& m, double k) {
+ Matrix4 result;
+ for (int i = 0; i < 4; ++i) {
+ for (int j = 0; j < 4; ++j) {
+ result.At(i, j) = m.At(i, j) * k;
+ }
+ }
+ return result;
+}
+
+inline Vec4 operator*(const Matrix4& m, const Vec4& v) {
+ Vec4 result;
+ for (int i = 0; i < 4; ++i) {
+ for (int j = 0; j < 4; ++j) {
+ result[i] += v[j] * m.At(i, j);
+ }
+ }
+ return result;
+}
+
+inline Vec4 operator*(const Vec4& v, const Matrix4& m) {
+ Vec4 result;
+ for (int i = 0; i < 4; ++i) {
+ for (int j = 0; j < 4; ++j) {
+ result[i] += v[j] * m.At(j, i);
+ }
+ }
+ return result;
+}
+
+inline std::ostream& operator<<(std::ostream& stream, const Vec4& v) {
+ stream << "(" << v[0];
+ for (int i = 1; i < 4; ++i) stream << ", " << v[i];
+ return stream << ")";
+}
+
+inline std::ostream& operator<<(std::ostream& stream, const Matrix4& m) {
+ for (int i = 0; i < 4; ++i) {
+ stream << '\n' << m.At(i, 0);
+ for (int j = 1; j < 4; ++j) stream << '\t' << m.At(i, j);
+ }
+ return stream;
+}
+
+} // namespace stroke_model
+} // namespace ink
+
+#endif // INK_STROKE_MODELER_INTERNAL_PREDICTION_KALMAN_FILTER_MATRIX_H_
diff --git a/ink_stroke_modeler/internal/prediction/kalman_filter/matrix_test.cc b/ink_stroke_modeler/internal/prediction/kalman_filter/matrix_test.cc
new file mode 100644
index 0000000..b11970d
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/kalman_filter/matrix_test.cc
@@ -0,0 +1,215 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/prediction/kalman_filter/matrix.h"
+
+#include <sstream>
+#include <string>
+
+#include "gtest/gtest.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+TEST(MatrixTest, Vec4Equality) {
+ EXPECT_EQ(Vec4(1, 2, 3, 4), Vec4(1, 2, 3, 4));
+ EXPECT_NE(Vec4(1, 2, 3, 4), Vec4(1, 2, 7, 4));
+ EXPECT_NE(Vec4(.1, .7, -4, 6), Vec4(-1, 64, .3, 200));
+}
+
+TEST(MatrixTest, Vec4Addition) {
+ EXPECT_EQ(Vec4(0, 1, 2, 4) + Vec4(5, -1, 6, 7), Vec4(5, 0, 8, 11));
+ EXPECT_EQ(Vec4(0.25, -3, 17, 0) + Vec4(.5, -.5, -1, 2),
+ Vec4(.75, -3.5, 16, 2));
+}
+
+TEST(MatrixTest, Vec4ScalarMultiplication) {
+ EXPECT_EQ(Vec4(6, -12, 7, .25) * 2, Vec4(12, -24, 14, .5));
+ EXPECT_EQ(Vec4(17, -3, 5.5, 0) * -.25, Vec4(-4.25, .75, -1.375, 0));
+}
+
+TEST(MatrixTest, Vec4ScalarDivision) {
+ EXPECT_EQ(Vec4(13, -8, 0, 100) / -2, Vec4(-6.5, 4, 0, -50));
+ EXPECT_EQ(Vec4(0, -3, 20, 1) / .2, Vec4(0, -15, 100, 5));
+}
+
+TEST(MatrixTest, Matrix4Equality) {
+ EXPECT_EQ(Matrix4(0, 1, 2, 3, //
+ 4, 5, 6, 7, //
+ 8, 9, 10, 11, //
+ 12, 13, 14, 15),
+ Matrix4(0, 1, 2, 3, //
+ 4, 5, 6, 7, //
+ 8, 9, 10, 11, //
+ 12, 13, 14, 15));
+ EXPECT_NE(Matrix4(0, 1, 2, 3, //
+ 4, 5, 6, 7, //
+ 8, 9, 10, 11, //
+ 12, 13, 14, 15),
+ Matrix4(1, 2, 0, 4, //
+ 4, 9, 6, 12, //
+ 9, 9, 9, 9, //
+ -1, -2, 14, 99));
+}
+
+TEST(MatrixTest, Matrix4IdentityCtor) {
+ EXPECT_EQ(Matrix4(), Matrix4(1, 0, 0, 0, //
+ 0, 1, 0, 0, //
+ 0, 0, 1, 0, //
+ 0, 0, 0, 1));
+}
+
+TEST(MatrixTest, Matrix4Zero) {
+ EXPECT_EQ(Matrix4::Zero(), Matrix4(0, 0, 0, 0, //
+ 0, 0, 0, 0, //
+ 0, 0, 0, 0, //
+ 0, 0, 0, 0));
+}
+
+TEST(MatrixTest, Matrix4Transpose) {
+ Matrix4 m(0, 1, 2, 3, //
+ 4, 5, 6, 7, //
+ 8, 9, 10, 11, //
+ 12, 13, 14, 15);
+ EXPECT_EQ(m.Transpose(), Matrix4(0, 4, 8, 12, //
+ 1, 5, 9, 13, //
+ 2, 6, 10, 14, //
+ 3, 7, 11, 15));
+}
+
+TEST(MatrixTest, Matrix4Multiplication) {
+ Matrix4 a(-4, 4, 2, 9, //
+ -2, -5, 6, 1, //
+ -2, 7, 10, 1, //
+ -4, -5, 2, 6);
+ Matrix4 b(-1, 7, 9, 3, //
+ 0, 7, -3, 8, //
+ -9, 7, 7, -10, //
+ 1, -1, -3, -1);
+ EXPECT_EQ(a * b, Matrix4(-5, 5, -61, -9, //
+ -51, -8, 36, -107, //
+ -87, 104, 28, -51, //
+ -8, -55, -25, -78));
+ EXPECT_EQ(b * a, Matrix4(-40, 9, 136, 25, //
+ -40, -96, 28, 52, //
+ 48, 28, 74, -127, //
+ 8, -7, -36, -1));
+}
+
+TEST(MatrixTest, Matrix4Addition) {
+ Matrix4 a(2, 0, -10, -1, //
+ -4, -4, -7, 3, //
+ 7, -1, 7, 3, //
+ -7, -4, -4, -4);
+ Matrix4 b(9, -6, -10, 0, //
+ 6, 1, -5, 9, //
+ -7, -4, -3, -6, //
+ 7, 7, -10, -9);
+ EXPECT_EQ(a + b, Matrix4(11, -6, -20, -1, //
+ 2, -3, -12, 12, //
+ 0, -5, 4, -3, //
+ 0, 3, -14, -13));
+ EXPECT_EQ(b + a, Matrix4(11, -6, -20, -1, //
+ 2, -3, -12, 12, //
+ 0, -5, 4, -3, //
+ 0, 3, -14, -13));
+}
+
+TEST(MatrixTest, Matrix4Subtraction) {
+ Matrix4 a(-7, -9, 9, 9, //
+ -4, 10, -3, -1, //
+ 8, 9, 6, 4, //
+ 9, -7, 7, 4);
+ Matrix4 b(2, -1, 2, 6, //
+ -1, -8, -1, 10, //
+ 3, 0, -6, -1, //
+ -6, -3, 6, 7);
+ EXPECT_EQ(a - b, Matrix4(-9, -8, 7, 3, //
+ -3, 18, -2, -11, //
+ 5, 9, 12, 5, //
+ 15, -4, 1, -3));
+ EXPECT_EQ(b - a, Matrix4(9, 8, -7, -3, //
+ 3, -18, 2, 11, //
+ -5, -9, -12, -5, //
+ -15, 4, -1, 3));
+}
+
+TEST(MatrixTest, Matrix4ScalarMultiplication) {
+ Matrix4 m(6, 8, 6, 7, //
+ 2, -4, 10, 8, //
+ -1, 4, 9, -7, //
+ -2, -9, 10, 10);
+ EXPECT_EQ(m * 3, Matrix4(18, 24, 18, 21, //
+ 6, -12, 30, 24, //
+ -3, 12, 27, -21, //
+ -6, -27, 30, 30));
+ EXPECT_EQ(m * .5, Matrix4(3, 4, 3, 3.5, //
+ 1, -2, 5, 4, //
+ -.5, 2, 4.5, -3.5, //
+ -1, -4.5, 5, 5));
+}
+
+TEST(MatrixTest, Matrix4VectorMultiplication) {
+ Matrix4 m(3, 0, 4, 3, //
+ 7, -6, 7, -10, //
+ 6, -2, -10, -5, //
+ -3, 9, 1, -5);
+ Vec4 v(-6, 9, -7, -10);
+ EXPECT_EQ(m * v, Vec4(-76, -45, 66, 142));
+ EXPECT_EQ(v * m, Vec4(33, -130, 99, -23));
+}
+
+TEST(MatrixTest, DotProduct) {
+ Vec4 a(0, -3, 0, -5);
+ Vec4 b(6, 4, -4, 6);
+ EXPECT_EQ(DotProduct(a, b), -42);
+ EXPECT_EQ(DotProduct(b, a), -42);
+}
+
+TEST(MatrixTest, OuterProduct) {
+ Vec4 a(-8, -3, 6, -8);
+ Vec4 b(4, -9, -1, 10);
+ EXPECT_EQ(OuterProduct(a, b), Matrix4(-32, 72, 8, -80, //
+ -12, 27, 3, -30, //
+ 24, -54, -6, 60, //
+ -32, 72, 8, -80));
+ EXPECT_EQ(OuterProduct(b, a), Matrix4(-32, -12, 24, -32, //
+ 72, 27, -54, 72, //
+ 8, 3, -6, 8, //
+ -80, -30, 60, -80));
+}
+
+TEST(MatrixTest, Vec4Stream) {
+ std::stringstream s;
+ s << Vec4(1.28, -9, .9, 2.7);
+ EXPECT_EQ(s.str(), "(1.28, -9, 0.9, 2.7)");
+}
+
+TEST(MatrixTest, Matrix4Stream) {
+ std::stringstream s;
+ s << Matrix4(7.5, -7.7, -4.6, 8, //
+ 6.4, -8.52, 0, 8.8, //
+ -3.5, -5.2, -.5, 9, //
+ -2.6, -3.4, 5.5, 8.3);
+ EXPECT_EQ(s.str(),
+ "\n7.5\t-7.7\t-4.6\t8"
+ "\n6.4\t-8.52\t0\t8.8"
+ "\n-3.5\t-5.2\t-0.5\t9"
+ "\n-2.6\t-3.4\t5.5\t8.3");
+}
+
+} // namespace
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/internal/prediction/kalman_predictor.cc b/ink_stroke_modeler/internal/prediction/kalman_predictor.cc
new file mode 100644
index 0000000..cc8949c
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/kalman_predictor.cc
@@ -0,0 +1,265 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/prediction/kalman_predictor.h"
+
+#include <algorithm>
+#include <cmath>
+#include <limits>
+#include <vector>
+
+#include "ink_stroke_modeler/internal/internal_types.h"
+#include "ink_stroke_modeler/internal/utils.h"
+#include "ink_stroke_modeler/params.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+KalmanPredictor::State EvaluateCubic(const KalmanPredictor::State &start_state,
+ Duration delta_time) {
+ float dt = delta_time.Value();
+ auto dt_squared = dt * dt;
+ auto dt_cubed = dt_squared * dt;
+
+ KalmanPredictor::State end_state;
+ end_state.position = start_state.position + start_state.velocity * dt +
+ start_state.acceleration * dt_squared / 2.f +
+ start_state.jerk * dt_cubed / 6.f;
+ end_state.velocity = start_state.velocity + start_state.acceleration * dt +
+ start_state.jerk * dt_squared / 2.f;
+ end_state.acceleration = start_state.acceleration + start_state.jerk * dt;
+ end_state.jerk = start_state.jerk;
+
+ return end_state;
+}
+
+} // namespace
+
+void KalmanPredictor::Reset() {
+ x_predictor_.Reset();
+ y_predictor_.Reset();
+ sample_times_.clear();
+ last_position_received_ = absl::nullopt;
+}
+
+void KalmanPredictor::Update(Vec2 position, Time time) {
+ last_position_received_ = position;
+ sample_times_.push_back(time);
+ if (predictor_params_.max_time_samples < 0 ||
+ sample_times_.size() > (uint)predictor_params_.max_time_samples)
+ sample_times_.pop_front();
+
+ x_predictor_.Update(position.x);
+ y_predictor_.Update(position.y);
+}
+
+absl::optional<KalmanPredictor::State> KalmanPredictor::GetEstimatedState()
+ const {
+ if (!IsStable() || sample_times_.empty()) return absl::nullopt;
+
+ State estimated_state;
+ estimated_state.position = {static_cast<float>(x_predictor_.GetPosition()),
+ static_cast<float>(y_predictor_.GetPosition())};
+ estimated_state.velocity = {static_cast<float>(x_predictor_.GetVelocity()),
+ static_cast<float>(y_predictor_.GetVelocity())};
+ estimated_state.acceleration = {
+ static_cast<float>(x_predictor_.GetAcceleration()),
+ static_cast<float>(y_predictor_.GetAcceleration())};
+ estimated_state.jerk = {static_cast<float>(x_predictor_.GetJerk()),
+ static_cast<float>(y_predictor_.GetJerk())};
+
+ // The axis predictors are not time-aware, assuming that the time delta
+ // between measurements is always 1. To correct for this, we divide the
+ // velocity, acceleration, and jerk by the average observed time delta, raised
+ // to the appropriate power.
+ auto dt = static_cast<float>(
+ (sample_times_.back() - sample_times_.front()).Value()) /
+ sample_times_.size();
+ auto dt_squared = dt * dt;
+ auto dt_cubed = dt_squared * dt;
+ estimated_state.velocity /= dt;
+ estimated_state.acceleration /= dt_squared;
+ estimated_state.jerk /= dt_cubed;
+
+ // We want our predictions to tend more towards linearity -- to achieve this,
+ // we reduce the acceleration and jerk.
+ estimated_state.acceleration *= predictor_params_.acceleration_weight;
+ estimated_state.jerk *= predictor_params_.jerk_weight;
+
+ return estimated_state;
+}
+
+std::vector<TipState> KalmanPredictor::ConstructPrediction(
+ const TipState &last_state) const {
+ auto estimated_state = GetEstimatedState();
+ if (!estimated_state || !last_position_received_) {
+ // We don't yet have enough data to construct a prediction.
+ return {};
+ }
+
+ Duration sample_dt{1. / sampling_params_.min_output_rate};
+ std::vector<TipState> prediction;
+ ConstructCubicConnector(last_state, *estimated_state, predictor_params_,
+ sample_dt, &prediction);
+ auto start_time =
+ prediction.empty() ? last_state.time : prediction.back().time;
+ ConstructCubicPrediction(*estimated_state, predictor_params_, start_time,
+ sample_dt, NumberOfPointsToPredict(*estimated_state),
+ &prediction);
+ return prediction;
+}
+
+void KalmanPredictor::ConstructCubicPrediction(
+ const State &estimated_state, const KalmanPredictorParams &params,
+ Time start_time, Duration sample_dt, int n_samples,
+ std::vector<TipState> *output) {
+ auto current_state = estimated_state;
+ auto current_time = start_time;
+ for (int i = 0; i < n_samples; ++i) {
+ auto next_state = EvaluateCubic(current_state, sample_dt);
+ current_time += sample_dt;
+ output->push_back({next_state.position, next_state.velocity, current_time});
+ current_state = next_state;
+ }
+}
+
+void KalmanPredictor::ConstructCubicConnector(
+ const TipState &last_tip_state, const State &estimated_state,
+ const KalmanPredictorParams &params, Duration sample_dt,
+ std::vector<TipState> *output) {
+ // Estimate how long it will take for the tip to travel from its last position
+ // to the estimated position, based on the start and end velocities. We define
+ // a minimum "reasonable" velocity to avoid division by zero.
+ auto distance_traveled =
+ Distance(last_tip_state.position, estimated_state.position);
+ auto max_velocity_at_ends = std::max(last_tip_state.velocity.Magnitude(),
+ estimated_state.velocity.Magnitude());
+ Duration target_duration{
+ distance_traveled /
+ std::max(max_velocity_at_ends, params.min_catchup_velocity)};
+
+ // Determine how many samples this will give us, ensuring that there's always
+ // at least one. Then, pick a duration that's a multiple of the sample dt.
+ int n_points = std::max(std::ceil(static_cast<float>(target_duration.Value() /
+ sample_dt.Value())),
+ 1.f);
+ auto duration = n_points * sample_dt;
+
+ // We want to construct a cubic curve connecting the last tip state and the
+ // estimated state. Given positions p₀ and p₁, velocities v₀ and v₁, and times
+ // t₀ and t₁ at the start and end of the curve, we define a pair of functions,
+ // f and g, such that the curve is described by the composite function
+ // f(g(t)):
+ // f(x) = ax³ + bx² + cx + d
+ // g(t) = (t - t₀) / (t₁ - t₀)
+ // We then find the derivatives:
+ // f'(x) = 3ax² + 2bx + c
+ // g'(t) = 1 / (t₁ - t₀)
+ // (f∘g)'(t) = f'(g(t)) ⋅ g'(t) = (3ax² + 2bx + c) / (t₁ - t₀)
+ // We then plug in the given values:
+ // f(g(t₀)) = f(0) = p₀
+ // ax³ + bx² + cx + d
+ // f(g(t₁)) = f(1) = p₁
+ // (f∘g)'(t₀) = f'(0) ⋅ g'(t₀) = v₀
+ // (f∘g)'(t₁) = f'(1) ⋅ g'(t₁) = v₁
+ // This gives us four linear equations:
+ // a⋅0³ + b⋅0² + c⋅0 + d = p₀
+ // a⋅1³ + b⋅1² + c⋅1 + d = p₁
+ // (3a⋅0² + 2b⋅0 + c) / (t₁ - t₀) = v₀
+ // (3a⋅1² + 2b⋅1 + c) / (t₁ - t₀) = v₁
+ // Finally, we can solve for a, b, c, and d:
+ // a = 2p₀ - 2p₁ + (v₀ + v₁)(t₁ - t₀)
+ // b = -3p₀ + 3p₁ - (2v₀ + v₁)(t₁ - t₀)
+ // c = v₀(t₁ - t₀)
+ // d = p₀
+ float float_duration = duration.Value();
+ auto a =
+ 2.f * last_tip_state.position - 2.f * estimated_state.position +
+ (last_tip_state.velocity + estimated_state.velocity) * float_duration;
+ auto b = -3.f * last_tip_state.position + 3.f * estimated_state.position -
+ (2.f * last_tip_state.velocity + estimated_state.velocity) *
+ float_duration;
+ auto c = last_tip_state.velocity * float_duration;
+ auto d = last_tip_state.position;
+
+ output->reserve(output->size() + n_points);
+ for (int i = 1; i <= n_points; ++i) {
+ float t = static_cast<float>(i) / n_points;
+ float t_squared = t * t;
+ float t_cubed = t_squared * t;
+ auto position = a * t_cubed + b * t_squared + c * t + d;
+ auto velocity = 3.f * a * t_squared + 2.f * b * t + c;
+ auto time = last_tip_state.time + duration * t;
+ output->push_back({position, velocity / float_duration, time});
+ }
+}
+
+int KalmanPredictor::NumberOfPointsToPredict(
+ const State &estimated_state) const {
+ const KalmanPredictorParams::ConfidenceParams &confidence_params =
+ predictor_params_.confidence_params;
+
+ auto target_number =
+ static_cast<float>(predictor_params_.prediction_interval.Value() *
+ sampling_params_.min_output_rate);
+
+ // The more samples we've received, the less effect the noise from each
+ // individual input affects the result.
+ float sample_ratio =
+ std::min(1.f, static_cast<float>(x_predictor_.NumIterations()) /
+ confidence_params.desired_number_of_samples);
+
+ // The further the last given position is from the estimated position, the
+ // less confidence we have in the result.
+ float estimated_error =
+ Distance(*last_position_received_, estimated_state.position);
+ float normalized_error =
+ 1.f - Normalize01(0.f, confidence_params.max_estimation_distance,
+ estimated_error);
+
+ // This is the state that the prediction would end at if we predicted the full
+ // interval (i.e. if confidence == 1).
+ auto end_state =
+ EvaluateCubic(estimated_state, predictor_params_.prediction_interval);
+
+ // If the prediction is not traveling quickly, then changes in direction
+ // become more apparent, making the prediction appear wobbly.
+ float travel_speed =
+ Distance(estimated_state.position, end_state.position) /
+ static_cast<float>(predictor_params_.prediction_interval.Value());
+ float normalized_distance =
+ Normalize01(confidence_params.min_travel_speed,
+ confidence_params.max_travel_speed, travel_speed);
+
+ // If the actual prediction differs too much from the linear prediction, it
+ // suggests that the acceleration and jerk components overtake the velocity,
+ // resulting in a prediction that flies far off from the stroke.
+ float deviation_from_linear_prediction = Distance(
+ end_state.position,
+ estimated_state.position +
+ static_cast<float>(predictor_params_.prediction_interval.Value()) *
+ estimated_state.velocity);
+ float linearity =
+ Interp(confidence_params.baseline_linearity_confidence, 1.f,
+ 1.f - Normalize01(0.f, confidence_params.max_linear_deviation,
+ deviation_from_linear_prediction));
+
+ auto confidence =
+ sample_ratio * normalized_error * normalized_distance * linearity;
+ return std::ceil(target_number * confidence);
+}
+
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/internal/prediction/kalman_predictor.h b/ink_stroke_modeler/internal/prediction/kalman_predictor.h
new file mode 100644
index 0000000..b848770
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/kalman_predictor.h
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_INTERNAL_PREDICTION_KALMAN_PREDICTOR_H_
+#define INK_STROKE_MODELER_INTERNAL_PREDICTION_KALMAN_PREDICTOR_H_
+
+#include <deque>
+#include <vector>
+
+#include "ink_stroke_modeler/internal/internal_types.h"
+#include "ink_stroke_modeler/internal/prediction/input_predictor.h"
+#include "ink_stroke_modeler/internal/prediction/kalman_filter/axis_predictor.h"
+#include "ink_stroke_modeler/params.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+
+// This class constructs a prediction by using a pair of Kalman filters (one
+// each for the x- and y-dimension) to model the true state of the tip, assuming
+// that the data we receive contains some noise.
+// To construct a prediction, we first fetch the estimation of the position,
+// velocity, acceleration, and jerk from the Kalman filters. The prediction is
+// then constructed in two parts: one cubic spline that connects the last tip
+// state to the estimation, constructed from the positions and velocities at the
+// endpoints; and one cubic spline that extends into the future, constructed
+// from the estimated position, velocity, acceleration, and jerk.
+class KalmanPredictor : public InputPredictor {
+ public:
+ explicit KalmanPredictor(const KalmanPredictorParams &predictor_params,
+ const SamplingParams &sampling_params)
+ : predictor_params_(predictor_params),
+ sampling_params_(sampling_params),
+ x_predictor_(predictor_params_.process_noise,
+ predictor_params_.measurement_noise,
+ predictor_params_.min_stable_iteration),
+ y_predictor_(predictor_params_.process_noise,
+ predictor_params_.measurement_noise,
+ predictor_params_.min_stable_iteration) {}
+
+ void Reset() override;
+ void Update(Vec2 position, Time time) override;
+ std::vector<TipState> ConstructPrediction(
+ const TipState &last_state) const override;
+
+ struct State {
+ Vec2 position{0};
+ Vec2 velocity{0};
+ Vec2 acceleration{0};
+ Vec2 jerk{0};
+ };
+
+ // Returns the current estimate of the tip's true state, as modeled by the
+ // Kalman filters, or absl::nullopt if the predictor does not yet have enough
+ // data to make a reasonable estimate.
+ absl::optional<State> GetEstimatedState() const;
+
+ private:
+ bool IsStable() const {
+ return x_predictor_.Stable() && y_predictor_.Stable();
+ }
+
+ static void ConstructCubicConnector(const TipState &last_tip_state,
+ const State &estimated_state,
+ const KalmanPredictorParams &params,
+ Duration sample_dt,
+ std::vector<TipState> *output);
+
+ static void ConstructCubicPrediction(const State &estimated_state,
+ const KalmanPredictorParams &params,
+ Time start_time, Duration sample_dt,
+ int n_samples,
+ std::vector<TipState> *output);
+
+ int NumberOfPointsToPredict(const State &estimated_state) const;
+
+ KalmanPredictorParams predictor_params_;
+ SamplingParams sampling_params_;
+
+ absl::optional<Vec2> last_position_received_;
+
+ std::deque<Time> sample_times_;
+
+ AxisPredictor x_predictor_;
+ AxisPredictor y_predictor_;
+};
+
+} // namespace stroke_model
+} // namespace ink
+#endif // INK_STROKE_MODELER_INTERNAL_PREDICTION_KALMAN_PREDICTOR_H_
diff --git a/ink_stroke_modeler/internal/prediction/kalman_predictor_test.cc b/ink_stroke_modeler/internal/prediction/kalman_predictor_test.cc
new file mode 100644
index 0000000..66bac66
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/kalman_predictor_test.cc
@@ -0,0 +1,215 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/prediction/kalman_predictor.h"
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "absl/types/optional.h"
+#include "ink_stroke_modeler/internal/prediction/input_predictor.h"
+#include "ink_stroke_modeler/internal/type_matchers.h"
+#include "ink_stroke_modeler/params.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+using ::testing::ElementsAre;
+using ::testing::Matcher;
+using ::testing::Optional;
+
+constexpr float kTol = 1e-4;
+
+const KalmanPredictorParams kDefaultKalmanParams{
+ .process_noise = .00026458,
+ .measurement_noise = .026458,
+ .min_catchup_velocity = .01,
+ .prediction_interval = Duration(1. / 60),
+ .confidence_params{.max_estimation_distance = .04,
+ .min_travel_speed = 3,
+ .max_travel_speed = 15,
+ .max_linear_deviation = .2}};
+constexpr SamplingParams kDefaultSamplingParams{
+ .min_output_rate = 180, .end_of_stroke_stopping_distance = .001};
+
+// Matcher for the State. Note that, because each of position,
+// velocity, acceleration, and jerk are divided by increasing powers of the time
+// delta, the values grow exponentially. As such, this uses a relative tolerance
+// unless one of the arguments is exactly zero.
+MATCHER_P5(StateNearMatcher, position, velocity, acceleration, jerk,
+ relative_tol, "") {
+ auto within_relative_tol = [](float lhs, float rhs, float tol) {
+ if (lhs == 0 || rhs == 0) {
+ return std::abs(lhs - rhs) < tol;
+ }
+ return std::abs(lhs / rhs - 1.f) < tol;
+ };
+
+ if (within_relative_tol(position.x, arg.position.x, relative_tol) &&
+ within_relative_tol(position.y, arg.position.y, relative_tol) &&
+ within_relative_tol(velocity.x, arg.velocity.x, relative_tol) &&
+ within_relative_tol(velocity.y, arg.velocity.y, relative_tol) &&
+ within_relative_tol(acceleration.x, arg.acceleration.x, relative_tol) &&
+ within_relative_tol(acceleration.y, arg.acceleration.y, relative_tol) &&
+ within_relative_tol(jerk.x, arg.jerk.x, relative_tol) &&
+ within_relative_tol(jerk.y, arg.jerk.y, relative_tol)) {
+ return true;
+ }
+
+ *result_listener << "\n expected:" //
+ << "\n p = " << position //
+ << "\n v = " << velocity //
+ << "\n a = " << acceleration //
+ << "\n j = " << jerk //
+ << "\n actual:" //
+ << "\n p = " << arg.position //
+ << "\n v = " << arg.velocity //
+ << "\n a = " << arg.acceleration //
+ << "\n j = " << arg.jerk;
+ return false;
+}
+
+// Wrapping the matcher in a function allows the compiler to perform template
+// deduction, so we can specify arguments as initializer lists.
+Matcher<KalmanPredictor::State> StateNear(Vec2 position, Vec2 velocity,
+ Vec2 acceleration, Vec2 jerk,
+ float tolerance) {
+ return StateNearMatcher(position, velocity, acceleration, jerk, tolerance);
+}
+
+TEST(KalmanPredictorTest, EmptyPrediction) {
+ KalmanPredictor predictor{kDefaultKalmanParams, kDefaultSamplingParams};
+ EXPECT_EQ(predictor.GetEstimatedState(), absl::nullopt);
+ EXPECT_TRUE(
+ predictor.ConstructPrediction({{4, 3}, {2, -4}, Time{3}}).empty());
+
+ predictor.Update({1, 3}, Time{4});
+ EXPECT_EQ(predictor.GetEstimatedState(), absl::nullopt);
+ EXPECT_TRUE(
+ predictor.ConstructPrediction({{1, 3}, {0, 0}, Time{3.1}}).empty());
+}
+
+TEST(KalmanPredictorTest, TypicalCase) {
+ KalmanPredictor predictor{kDefaultKalmanParams, kDefaultSamplingParams};
+
+ predictor.Update({0, 0}, Time{0});
+ predictor.Update({.1, 0}, Time{.01});
+ predictor.Update({.2, 0}, Time{.02});
+ EXPECT_EQ(predictor.GetEstimatedState(), absl::nullopt);
+ EXPECT_TRUE(
+ predictor.ConstructPrediction({{4, 3}, {2, -4}, Time{3}}).empty());
+
+ predictor.Update({.3, 0}, Time{.03});
+ EXPECT_THAT(predictor.GetEstimatedState(),
+ Optional(StateNear({.30078, 0}, {13.584, 0}, {-66.806, 0},
+ {-3382.8, 0}, kTol)));
+ EXPECT_THAT(
+ predictor.ConstructPrediction({{.2, 0}, {10, 0}, Time{.03}}),
+ ElementsAre(TipStateNear({{.2454, 0}, {7.7094, 0}, Time{.0356}}, kTol),
+ TipStateNear({{.3008, 0}, {13.5837, 0}, Time{.0411}}, kTol),
+ TipStateNear({{.3751, 0}, {13.1604, 0}, Time{.0467}}, kTol)));
+
+ predictor.Update({.5, .1}, Time{.04});
+ EXPECT_THAT(predictor.GetEstimatedState(),
+ Optional(StateNear({.49705, .097146}, {28.217, 16.732},
+ {671.91, 813.82}, {4454.3, 6998.2}, kTol)));
+ EXPECT_THAT(
+ predictor.ConstructPrediction({{.3, 0}, {10, 0}, Time{.04}}),
+ ElementsAre(
+ TipStateNear({{.3732, .0253}, {17.047, 8.9317}, Time{.0456}}, kTol),
+ TipStateNear({{.497, .0971}, {28.2172, 16.7319}, Time{.0511}}, kTol),
+ TipStateNear({{.6643, .2029}, {32.0188, 21.3611}, Time{.0567}},
+ kTol)));
+}
+
+TEST(KalmanPredictorTest, AlternateParams) {
+ auto kalman_params = kDefaultKalmanParams;
+ auto sampling_params = kDefaultSamplingParams;
+ kalman_params.prediction_interval = Duration(.025);
+ sampling_params.min_output_rate = 200;
+ KalmanPredictor predictor{kalman_params, sampling_params};
+
+ predictor.Update({2, 5}, Time{1});
+ predictor.Update({2.2, 4.9}, Time{1.02});
+ predictor.Update({2.3, 4.7}, Time{1.04});
+ predictor.Update({2.3, 4.4}, Time{1.06});
+ EXPECT_THAT(
+ predictor.GetEstimatedState(),
+ Optional(StateNear({2.3016, 4.3992}, {-3.9981, -24.374},
+ {-338.22, -288.12}, {-1852.9, -584.31}, kTol)));
+ EXPECT_THAT(
+ predictor.ConstructPrediction({{2.25, 4.75}, {1, -20}, Time{1.06}}),
+ ElementsAre(
+ TipStateNear({{2.27, 4.6417}, {5.917, -23.0547}, Time{1.065}}, kTol),
+ TipStateNear({{2.2982, 4.5221}, {4.251, -24.5126}, Time{1.07}}, kTol),
+ TipStateNear({{2.3016, 4.3992}, {-3.9981, -24.3736}, Time{1.075}},
+ kTol),
+ TipStateNear({{2.2773, 4.2738}, {-5.7123, -25.8215}, Time{1.08}},
+ kTol)));
+
+ predictor.Update({2.2, 4.2}, Time{1.08});
+ EXPECT_THAT(predictor.GetEstimatedState(),
+ Optional(StateNear({2.1987, 4.1933}, {-11.457, -11.953},
+ {-328.01, 185.32}, {-1133.8, 1569.8}, kTol)));
+ EXPECT_THAT(
+ predictor.ConstructPrediction({{2.25, 4.5}, {-1, -20}, Time{1.08}}),
+ ElementsAre(
+ TipStateNear({{2.2499, 4.407}, {.5082, -17.2661}, Time{1.085}}, kTol),
+ TipStateNear({{2.2505, 4.3265}, {-.7319, -15.0137}, Time{1.09}},
+ kTol),
+ TipStateNear({{2.238, 4.2561}, {-4.7203, -13.2427}, Time{1.095}},
+ kTol),
+ TipStateNear({{2.1987, 4.1933}, {-11.4569, -11.9531}, Time{1.1}},
+ kTol),
+ TipStateNear({{2.1373, 4.1359}, {-13.1112, -11.0068}, Time{1.105}},
+ kTol)));
+}
+
+TEST(KalmanPredictorTest, Reset) {
+ KalmanPredictor predictor{kDefaultKalmanParams, kDefaultSamplingParams};
+
+ predictor.Update({4, -4}, Time{6});
+ predictor.Update({-6, 9}, Time{6.03});
+ predictor.Update({10, 5}, Time{6.06});
+ EXPECT_EQ(predictor.GetEstimatedState(), absl::nullopt);
+ EXPECT_TRUE(
+ predictor.ConstructPrediction({{1, 1}, {6, -3}, Time{6.06}}).empty());
+
+ predictor.Update({2, 4}, Time{6.09});
+ EXPECT_NE(predictor.GetEstimatedState(), absl::nullopt);
+ EXPECT_FALSE(
+ predictor.ConstructPrediction({{1, 1}, {6, -3}, Time{6.06}}).empty());
+
+ predictor.Reset();
+ EXPECT_EQ(predictor.GetEstimatedState(), absl::nullopt);
+ EXPECT_TRUE(
+ predictor.ConstructPrediction({{1, 1}, {6, -3}, Time{6.09}}).empty());
+
+ predictor.Update({-9, 3}, Time{2});
+ predictor.Update({-6, -1}, Time{2.1});
+ predictor.Update({6, -6}, Time{2.2});
+ EXPECT_EQ(predictor.GetEstimatedState(), absl::nullopt);
+ EXPECT_TRUE(
+ predictor.ConstructPrediction({{1, 1}, {6, -3}, Time{2.2}}).empty());
+
+ predictor.Update({3, 6}, Time{2.3});
+ EXPECT_NE(predictor.GetEstimatedState(), absl::nullopt);
+ EXPECT_FALSE(
+ predictor.ConstructPrediction({{1, 1}, {6, -3}, Time{2.3}}).empty());
+}
+
+} // namespace
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/internal/prediction/stroke_end_predictor.cc b/ink_stroke_modeler/internal/prediction/stroke_end_predictor.cc
new file mode 100644
index 0000000..3e83720
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/stroke_end_predictor.cc
@@ -0,0 +1,52 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/prediction/stroke_end_predictor.h"
+
+#include <iterator>
+#include <vector>
+
+#include "absl/types/optional.h"
+#include "ink_stroke_modeler/internal/internal_types.h"
+#include "ink_stroke_modeler/internal/position_modeler.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+
+void StrokeEndPredictor::Update(Vec2 position, Time time) {
+ last_position_ = position;
+}
+
+std::vector<TipState> StrokeEndPredictor::ConstructPrediction(
+ const TipState &last_state) const {
+ if (!last_position_) {
+ // We don't yet have enough data to construct a prediction.
+ return {};
+ }
+
+ std::vector<TipState> prediction;
+ prediction.reserve(sampling_params_.end_of_stroke_max_iterations);
+ PositionModeler modeler;
+ modeler.Reset(last_state, position_modeler_params_);
+ modeler.ModelEndOfStroke(*last_position_,
+ Duration(1. / sampling_params_.min_output_rate),
+ sampling_params_.end_of_stroke_max_iterations,
+ sampling_params_.end_of_stroke_stopping_distance,
+ std::back_inserter(prediction));
+ return prediction;
+}
+
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/internal/prediction/stroke_end_predictor.h b/ink_stroke_modeler/internal/prediction/stroke_end_predictor.h
new file mode 100644
index 0000000..420d596
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/stroke_end_predictor.h
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_INTERNAL_PREDICTION_STROKE_END_PREDICTOR_H_
+#define INK_STROKE_MODELER_INTERNAL_PREDICTION_STROKE_END_PREDICTOR_H_
+
+#include <vector>
+
+#include "absl/types/optional.h"
+#include "ink_stroke_modeler/internal/internal_types.h"
+#include "ink_stroke_modeler/internal/prediction/input_predictor.h"
+#include "ink_stroke_modeler/params.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+
+// This class constructs a prediction using the same PositionModeler class as
+// the SpringBasedModeler, fixing the anchor position and allowing the stroke to
+// "catch up". The way the prediction is constructed is very similar to how the
+// SpringBasedModeler models the end of a stroke.
+class StrokeEndPredictor : public InputPredictor {
+ public:
+ explicit StrokeEndPredictor(
+ const PositionModelerParams &position_modeler_params,
+ const SamplingParams &sampling_params)
+ : position_modeler_params_(position_modeler_params),
+ sampling_params_(sampling_params) {}
+
+ void Reset() override { last_position_ = absl::nullopt; }
+ void Update(Vec2 position, Time time) override;
+ std::vector<TipState> ConstructPrediction(
+ const TipState &last_state) const override;
+
+ private:
+ PositionModelerParams position_modeler_params_;
+ SamplingParams sampling_params_;
+
+ absl::optional<Vec2> last_position_;
+};
+
+} // namespace stroke_model
+} // namespace ink
+
+#endif // INK_STROKE_MODELER_INTERNAL_PREDICTION_STROKE_END_PREDICTOR_H_
diff --git a/ink_stroke_modeler/internal/prediction/stroke_end_predictor_test.cc b/ink_stroke_modeler/internal/prediction/stroke_end_predictor_test.cc
new file mode 100644
index 0000000..5a7750d
--- /dev/null
+++ b/ink_stroke_modeler/internal/prediction/stroke_end_predictor_test.cc
@@ -0,0 +1,137 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/prediction/stroke_end_predictor.h"
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "ink_stroke_modeler/internal/prediction/input_predictor.h"
+#include "ink_stroke_modeler/internal/type_matchers.h"
+#include "ink_stroke_modeler/params.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+using ::testing::ElementsAre;
+using ::testing::IsEmpty;
+using ::testing::Not;
+
+constexpr float kTol = 1e-4;
+
+constexpr SamplingParams kDefaultSamplingParams{
+ .min_output_rate = 180,
+ .end_of_stroke_stopping_distance = .001,
+ .end_of_stroke_max_iterations = 20};
+
+TEST(StrokeEndPredictorTest, EmptyPrediction) {
+ StrokeEndPredictor predictor{PositionModelerParams{}, kDefaultSamplingParams};
+ EXPECT_THAT(predictor.ConstructPrediction({{4, 6}, {-1, 1}, Time{5}}),
+ IsEmpty());
+
+ predictor.Reset();
+ EXPECT_THAT(predictor.ConstructPrediction({{-2, 11}, {0, 0}, Time{1}}),
+ IsEmpty());
+}
+
+TEST(StrokeEndPredictorTest, SingleInput) {
+ StrokeEndPredictor predictor{PositionModelerParams{}, kDefaultSamplingParams};
+ predictor.Update({4, 5}, Time{2});
+
+ EXPECT_THAT(predictor.ConstructPrediction({{4, 5}, {0, 0}, Time{2}}),
+ IsEmpty());
+}
+
+TEST(StrokeEndPredictorTest, MultipleInputs) {
+ StrokeEndPredictor predictor{PositionModelerParams{}, kDefaultSamplingParams};
+
+ predictor.Update({-1, 1}, Time{1});
+ EXPECT_THAT(predictor.ConstructPrediction({{-1, 1}, {0, 0}, Time{1}}),
+ IsEmpty());
+
+ predictor.Update({-1, 1.2}, Time{1.02});
+ EXPECT_THAT(
+ predictor.ConstructPrediction({{-1, 1.1}, {0, 5}, Time{1.02}}),
+ ElementsAre(
+ TipStateNear({{-1, 1.1258}, {0, 4.6364}, Time{1.0256}}, kTol),
+ TipStateNear({{-1, 1.1480}, {0, 3.9967}, Time{1.0311}}, kTol),
+ TipStateNear({{-1, 1.1660}, {0, 3.2496}, Time{1.0367}}, kTol),
+ TipStateNear({{-1, 1.1799}, {0, 2.5059}, Time{1.0422}}, kTol),
+ TipStateNear({{-1, 1.1901}, {0, 1.8318}, Time{1.0478}}, kTol),
+ TipStateNear({{-1, 1.1971}, {0, 1.2609}, Time{1.0533}}, kTol),
+ TipStateNear({{-1, 1.2000}, {0, 1.0323}, Time{1.0561}}, kTol)));
+
+ predictor.Update({-1, 1.4}, Time{1.04});
+ EXPECT_THAT(
+ predictor.ConstructPrediction({{-1, 1.2}, {0, 5}, Time{1.04}}),
+ ElementsAre(
+ TipStateNear({{-1, 1.2348}, {0, 6.2727}, Time{1.0455}}, kTol),
+ TipStateNear({{-1, 1.2708}, {0, 6.4661}, Time{1.0511}}, kTol),
+ TipStateNear({{-1, 1.3041}, {0, 5.9943}, Time{1.0566}}, kTol),
+ TipStateNear({{-1, 1.3328}, {0, 5.1663}, Time{1.0622}}, kTol),
+ TipStateNear({{-1, 1.3561}, {0, 4.1998}, Time{1.0677}}, kTol),
+ TipStateNear({{-1, 1.3741}, {0, 3.2381}, Time{1.0733}}, kTol),
+ TipStateNear({{-1, 1.3872}, {0, 2.3668}, Time{1.0788}}, kTol),
+ TipStateNear({{-1, 1.3963}, {0, 1.6288}, Time{1.0844}}, kTol),
+ TipStateNear({{-1, 1.4000}, {0, 1.3333}, Time{1.0872}}, kTol)));
+}
+
+TEST(StrokeEndPredictorTest, Reset) {
+ StrokeEndPredictor predictor{PositionModelerParams{}, kDefaultSamplingParams};
+
+ predictor.Update({-9, 6}, Time{5});
+ EXPECT_THAT(predictor.ConstructPrediction({{-9, 6}, {0, 0}, Time{5}}),
+ IsEmpty());
+ predictor.Update({1, 4}, Time{7});
+ EXPECT_THAT(predictor.ConstructPrediction({{-4, 5}, {5, -1}, Time{7}}),
+ Not(IsEmpty()));
+
+ predictor.Reset();
+ EXPECT_THAT(predictor.ConstructPrediction({{0, 1}, {0, 0}, Time{1}}),
+ IsEmpty());
+}
+
+TEST(StrokeEndPredictorTest, AlternateSamplingParams) {
+ StrokeEndPredictor predictor{
+ PositionModelerParams{},
+ SamplingParams{.min_output_rate = 200,
+ .end_of_stroke_stopping_distance = .005}};
+
+ predictor.Update({4, -7}, Time{3});
+ EXPECT_THAT(predictor.ConstructPrediction({{4, -7}, {0, 0}, Time{3}}),
+ IsEmpty());
+
+ predictor.Update({4.2, -6.8}, Time{3.01});
+ EXPECT_THAT(
+ predictor.ConstructPrediction({{4.1, -6.9}, {2, 2}, Time{3.01}}),
+ ElementsAre(
+ TipStateNear({{4.1138, -6.8862}, {2.7527, 2.7527}, Time{3.015}},
+ kTol),
+ TipStateNear({{4.1289, -6.8711}, {3.0318, 3.0318}, Time{3.02}}, kTol),
+ TipStateNear({{4.1439, -6.8561}, {2.9871, 2.9871}, Time{3.025}},
+ kTol),
+ TipStateNear({{4.1576, -6.8424}, {2.7386, 2.7386}, Time{3.03}}, kTol),
+ TipStateNear({{4.1694, -6.8306}, {2.3779, 2.3779}, Time{3.035}},
+ kTol),
+ TipStateNear({{4.1793, -6.8207}, {1.9719, 1.9719}, Time{3.04}}, kTol),
+ TipStateNear({{4.1871, -6.8129}, {1.5669, 1.5669}, Time{3.045}},
+ kTol),
+ TipStateNear({{4.1931, -6.8069}, {1.1923, 1.1923}, Time{3.05}}, kTol),
+ TipStateNear({{4.1974, -6.8026}, {0.8647, 0.8647}, Time{3.055}},
+ kTol)));
+}
+
+} // namespace
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/internal/stylus_state_modeler.cc b/ink_stroke_modeler/internal/stylus_state_modeler.cc
new file mode 100644
index 0000000..e7c1bbc
--- /dev/null
+++ b/ink_stroke_modeler/internal/stylus_state_modeler.cc
@@ -0,0 +1,101 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/stylus_state_modeler.h"
+
+#include <limits>
+
+#include "ink_stroke_modeler/internal/internal_types.h"
+#include "ink_stroke_modeler/internal/utils.h"
+#include "ink_stroke_modeler/params.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+
+void StylusStateModeler::Update(Vec2 position, const StylusState &state) {
+ if (state.pressure < 0) received_unknown_pressure_ = true;
+ if (state.tilt < 0) received_unknown_tilt_ = true;
+ if (state.orientation < 0) received_unknown_orientation_ = true;
+
+ if (received_unknown_pressure_ && received_unknown_tilt_ &&
+ received_unknown_orientation_) {
+ // We've stopped tracking all fields, so there's no need to keep updating.
+ positions_and_states_.clear();
+ return;
+ }
+
+ positions_and_states_.push_back({position, state});
+
+ if (params_.max_input_samples < 0 ||
+ positions_and_states_.size() > (uint)params_.max_input_samples) {
+ positions_and_states_.pop_front();
+ }
+}
+
+void StylusStateModeler::Reset(const StylusStateModelerParams &params) {
+ params_ = params;
+ positions_and_states_.clear();
+ received_unknown_pressure_ = false;
+ received_unknown_tilt_ = false;
+ received_unknown_orientation_ = false;
+}
+
+StylusState StylusStateModeler::Query(Vec2 position) const {
+ if (positions_and_states_.empty())
+ return {.pressure = -1, .tilt = -1, .orientation = -1};
+
+ if (positions_and_states_.size() == 1) {
+ const auto &state = positions_and_states_.front().state;
+ return {
+ .pressure = received_unknown_pressure_ ? -1 : state.pressure,
+ .tilt = received_unknown_tilt_ ? -1 : state.tilt,
+ .orientation = received_unknown_orientation_ ? -1 : state.orientation};
+ }
+
+ int closest_segment = -1;
+ float min_distance = std::numeric_limits<float>::infinity();
+ float interp_value = 0;
+ for (decltype(positions_and_states_.size()) i = 0;
+ i < positions_and_states_.size() - 1; ++i) {
+ const Vec2 segment_start = positions_and_states_[i].position;
+ const Vec2 segment_end = positions_and_states_[i + 1].position;
+ float param = NearestPointOnSegment(segment_start, segment_end, position);
+ float distance =
+ Distance(position, Interp(segment_start, segment_end, param));
+ if (distance <= min_distance) {
+ closest_segment = i;
+ min_distance = distance;
+ interp_value = param;
+ }
+ }
+
+ auto from_state = positions_and_states_[closest_segment].state;
+ auto to_state = positions_and_states_[closest_segment + 1].state;
+ return StylusState{
+ .pressure =
+ received_unknown_pressure_
+ ? -1
+ : Interp(from_state.pressure, to_state.pressure, interp_value),
+ .tilt = received_unknown_tilt_
+ ? -1
+ : Interp(from_state.tilt, to_state.tilt, interp_value),
+ .orientation = received_unknown_orientation_
+ ? -1
+ : InterpAngle(from_state.orientation,
+ to_state.orientation, interp_value)};
+}
+
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/internal/stylus_state_modeler.h b/ink_stroke_modeler/internal/stylus_state_modeler.h
new file mode 100644
index 0000000..a5b925f
--- /dev/null
+++ b/ink_stroke_modeler/internal/stylus_state_modeler.h
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_INTERNAL_STYLUS_STATE_MODELER_H_
+#define INK_STROKE_MODELER_INTERNAL_STYLUS_STATE_MODELER_H_
+
+#include <deque>
+#include <ostream>
+
+#include "ink_stroke_modeler/internal/internal_types.h"
+#include "ink_stroke_modeler/params.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+
+// This class is used to model the state of the stylus for a given position,
+// based on the state of the stylus at the original input points.
+//
+// The stylus is modeled by storing the last max_input_samples positions and
+// states received via Update(); when queried, it treats the stored positions as
+// a polyline, and finds the closest segment. The returned stylus state is a
+// linear interpolation between the states associated with the endpoints of the
+// segment, correcting angles to account for the "wraparound" that occurs at 0
+// and 2π. The value used for interpolation is based on how far along the
+// segment the closest point lies.
+//
+// If Update() is called with a state in which a field (i.e. pressure, tilt, or
+// orientation) has a negative value (indicating no information), then the
+// results of Query() will be -1 for that field until Reset() is called. This is
+// tracked independently for each field; e.g., if you pass in tilt = -1, then
+// pressure and orientation will continue to be interpolated normally.
+class StylusStateModeler {
+ public:
+ // Adds a position and state pair to the model. During stroke modeling, these
+ // values will be taken from the raw input.
+ void Update(Vec2 position, const StylusState &state);
+
+ // Clear the model and reset.
+ void Reset(const StylusStateModelerParams &params);
+
+ // Query the model for the state at the given position. During stroke
+ // modeling, the position will be taken from the modeled input.
+ //
+ // If no Update() calls have been received since the last Reset(), this will
+ // return {.pressure = -1, .tilt = -1, .orientation = -1}.
+ StylusState Query(Vec2 position) const;
+
+ private:
+ struct PositionAndState {
+ Vec2 position{0};
+ StylusState state;
+
+ PositionAndState(Vec2 position_in, const StylusState &state_in)
+ : position(position_in), state(state_in) {}
+ };
+
+ bool received_unknown_pressure_ = false;
+ bool received_unknown_tilt_ = false;
+ bool received_unknown_orientation_ = false;
+
+ std::deque<PositionAndState> positions_and_states_;
+ StylusStateModelerParams params_;
+};
+
+} // namespace stroke_model
+} // namespace ink
+
+#endif // INK_STROKE_MODELER_INTERNAL_STYLUS_STATE_MODELER_H_
diff --git a/ink_stroke_modeler/internal/stylus_state_modeler_test.cc b/ink_stroke_modeler/internal/stylus_state_modeler_test.cc
new file mode 100644
index 0000000..c905ec4
--- /dev/null
+++ b/ink_stroke_modeler/internal/stylus_state_modeler_test.cc
@@ -0,0 +1,269 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/stylus_state_modeler.h"
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "ink_stroke_modeler/internal/internal_types.h"
+#include "ink_stroke_modeler/internal/type_matchers.h"
+#include "ink_stroke_modeler/params.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+constexpr float kTol = 1e-5;
+constexpr StylusState kUnknown{.pressure = -1, .tilt = -1, .orientation = -1};
+
+TEST(StylusStateModelerTest, QueryEmpty) {
+ StylusStateModeler modeler;
+ EXPECT_EQ(modeler.Query({0, 0}), kUnknown);
+ EXPECT_EQ(modeler.Query({-5, 3}), kUnknown);
+}
+
+TEST(StylusStateModelerTest, QuerySingleInput) {
+ StylusStateModeler modeler;
+ modeler.Update({0, 0}, {.pressure = 0.75, .tilt = 0.75, .orientation = 0.75});
+ EXPECT_THAT(modeler.Query({0, 0}),
+ StylusStateNear(
+ {.pressure = .75, .tilt = .75, .orientation = .75}, kTol));
+ EXPECT_THAT(modeler.Query({1, 1}),
+ StylusStateNear(
+ {.pressure = .75, .tilt = .75, .orientation = .75}, kTol));
+}
+
+TEST(StylusStateModelerTest, QueryMultipleInputs) {
+ StylusStateModeler modeler;
+ modeler.Update({.5, 1.5}, {.pressure = .3, .tilt = .8, .orientation = .1});
+ modeler.Update({2, 1.5}, {.pressure = .6, .tilt = .5, .orientation = .7});
+ modeler.Update({3, 3.5}, {.pressure = .8, .tilt = .1, .orientation = .3});
+ modeler.Update({3.5, 4}, {.pressure = .2, .tilt = .2, .orientation = .2});
+
+ EXPECT_THAT(
+ modeler.Query({0, 2}),
+ StylusStateNear({.pressure = .3, .tilt = .8, .orientation = .1}, kTol));
+ EXPECT_THAT(
+ modeler.Query({1, 2}),
+ StylusStateNear({.pressure = .4, .tilt = .7, .orientation = .3}, kTol));
+ EXPECT_THAT(
+ modeler.Query({2, 1.5}),
+ StylusStateNear({.pressure = .6, .tilt = .5, .orientation = .7}, kTol));
+ EXPECT_THAT(
+ modeler.Query({2.5, 1.875}),
+ StylusStateNear({.pressure = .65, .tilt = .4, .orientation = .6}, kTol));
+ EXPECT_THAT(
+ modeler.Query({2.5, 3.125}),
+ StylusStateNear({.pressure = .75, .tilt = .2, .orientation = .4}, kTol));
+ EXPECT_THAT(
+ modeler.Query({2.5, 4}),
+ StylusStateNear({.pressure = .8, .tilt = .1, .orientation = .3}, kTol));
+ EXPECT_THAT(
+ modeler.Query({3, 4}),
+ StylusStateNear({.pressure = .5, .tilt = .15, .orientation = .25}, kTol));
+ EXPECT_THAT(
+ modeler.Query({4, 4}),
+ StylusStateNear({.pressure = .2, .tilt = .2, .orientation = .2}, kTol));
+}
+
+TEST(StylusStateModelerTest, QueryStaleInputsAreDiscarded) {
+ StylusStateModeler modeler;
+ modeler.Update({1, 1}, {.pressure = .6, .tilt = .5, .orientation = .4});
+ modeler.Update({-1, 2}, {.pressure = .3, .tilt = .7, .orientation = .6});
+ modeler.Update({-4, 0}, {.pressure = .9, .tilt = .7, .orientation = .3});
+ modeler.Update({-6, -3}, {.pressure = .4, .tilt = .3, .orientation = .5});
+ modeler.Update({-5, -5}, {.pressure = .3, .tilt = .3, .orientation = .1});
+ modeler.Update({-3, -4}, {.pressure = .6, .tilt = .8, .orientation = .3});
+ modeler.Update({-6, -7}, {.pressure = .9, .tilt = .8, .orientation = .1});
+ modeler.Update({-9, -8}, {.pressure = .8, .tilt = .2, .orientation = .2});
+ modeler.Update({-11, -5}, {.pressure = .2, .tilt = .4, .orientation = .7});
+ modeler.Update({-10, -2}, {.pressure = .7, .tilt = .3, .orientation = .2});
+
+ EXPECT_THAT(
+ modeler.Query({2, 0}),
+ StylusStateNear({.pressure = .6, .tilt = .5, .orientation = .4}, kTol));
+ EXPECT_THAT(
+ modeler.Query({1, 3.5}),
+ StylusStateNear({.pressure = .45, .tilt = .6, .orientation = .5}, kTol));
+ EXPECT_THAT(
+ modeler.Query({-3, 17. / 6}),
+ StylusStateNear({.pressure = .5, .tilt = .7, .orientation = .5}, kTol));
+
+ // This causes the point at {1, 1} to be discarded.
+ modeler.Update({-8, 0}, {.pressure = .6, .tilt = .8, .orientation = .9});
+ EXPECT_THAT(
+ modeler.Query({2, 0}),
+ StylusStateNear({.pressure = .3, .tilt = .7, .orientation = .6}, kTol));
+ EXPECT_THAT(
+ modeler.Query({1, 3.5}),
+ StylusStateNear({.pressure = .3, .tilt = .7, .orientation = .6}, kTol));
+ EXPECT_THAT(
+ modeler.Query({-3, 17. / 6}),
+ StylusStateNear({.pressure = .5, .tilt = .7, .orientation = .5}, kTol));
+
+ // This causes the point at {-1, 2} to be discarded.
+ modeler.Update({-8, 0}, {.pressure = .6, .tilt = .8, .orientation = .9});
+ EXPECT_THAT(
+ modeler.Query({2, 0}),
+ StylusStateNear({.pressure = .9, .tilt = .7, .orientation = .3}, kTol));
+ EXPECT_THAT(
+ modeler.Query({1, 3.5}),
+ StylusStateNear({.pressure = .9, .tilt = .7, .orientation = .3}, kTol));
+ EXPECT_THAT(
+ modeler.Query({-3, 17. / 6}),
+ StylusStateNear({.pressure = .9, .tilt = .7, .orientation = .3}, kTol));
+}
+
+TEST(StylusStateModelerTest, QueryCyclicOrientationInterpolation) {
+ StylusStateModeler modeler;
+ modeler.Update({0, 0}, {.pressure = 0, .tilt = 0, .orientation = 1.8 * M_PI});
+ modeler.Update({0, 1}, {.pressure = 0, .tilt = 0, .orientation = .2 * M_PI});
+ modeler.Update({0, 2}, {.pressure = 0, .tilt = 0, .orientation = 1.6 * M_PI});
+
+ EXPECT_NEAR(modeler.Query({0, .25}).orientation, 1.9 * M_PI, 1e-5);
+ EXPECT_NEAR(modeler.Query({0, .75}).orientation, .1 * M_PI, 1e-5);
+ EXPECT_NEAR(modeler.Query({0, 1.25}).orientation, .05 * M_PI, 1e-5);
+ EXPECT_NEAR(modeler.Query({0, 1.75}).orientation, 1.75 * M_PI, 1e-5);
+}
+
+TEST(StylusStateModelerTest, QueryAndReset) {
+ StylusStateModeler modeler;
+
+ modeler.Update({4, 5}, {.pressure = .4, .tilt = .9, .orientation = .1});
+ modeler.Update({7, 8}, {.pressure = .1, .tilt = .2, .orientation = .5});
+ EXPECT_THAT(
+ modeler.Query({10, 12}),
+ StylusStateNear({.pressure = .1, .tilt = .2, .orientation = .5}, kTol));
+
+ modeler.Reset(StylusStateModelerParams{});
+ EXPECT_EQ(modeler.Query({10, 12}), kUnknown);
+
+ modeler.Update({-1, 4}, {.pressure = .4, .tilt = .6, .orientation = .8});
+ EXPECT_THAT(
+ modeler.Query({6, 7}),
+ StylusStateNear({.pressure = .4, .tilt = .6, .orientation = .8}, kTol));
+
+ modeler.Update({-3, 0}, {.pressure = .7, .tilt = .2, .orientation = .5});
+ EXPECT_THAT(
+ modeler.Query({-2, 2}),
+ StylusStateNear({.pressure = .55, .tilt = .4, .orientation = .65}, kTol));
+ EXPECT_THAT(
+ modeler.Query({0, 5}),
+ StylusStateNear({.pressure = .4, .tilt = .6, .orientation = .8}, kTol));
+}
+
+TEST(StylusStateModelerTest, UpdateWithUnknownState) {
+ StylusStateModeler modeler;
+
+ modeler.Update({1, 2}, {.pressure = .1, .tilt = .2, .orientation = .3});
+ modeler.Update({2, 3}, {.pressure = .3, .tilt = .4, .orientation = .5});
+ EXPECT_THAT(
+ modeler.Query({2, 2}),
+ StylusStateNear({.pressure = .2, .tilt = .3, .orientation = .4}, kTol));
+
+ modeler.Update({5, 5}, kUnknown);
+ EXPECT_EQ(modeler.Query({5, 5}), kUnknown);
+
+ modeler.Update({2, 3}, {.pressure = .3, .tilt = .4, .orientation = .5});
+ EXPECT_EQ(modeler.Query({1, 2}), kUnknown);
+
+ modeler.Update({-1, 3}, kUnknown);
+ EXPECT_EQ(modeler.Query({7, 9}), kUnknown);
+
+ modeler.Reset(StylusStateModelerParams{});
+ modeler.Update({3, 3}, {.pressure = .7, .tilt = .6, .orientation = .5});
+ EXPECT_THAT(
+ modeler.Query({3, 3}),
+ StylusStateNear({.pressure = .7, .tilt = .6, .orientation = .5}, kTol));
+}
+
+TEST(StylusStateModelerTest, ModelPressureOnly) {
+ StylusStateModeler modeler;
+
+ modeler.Update({0, 0}, {.pressure = .5, .tilt = -2, .orientation = -.1});
+ EXPECT_THAT(
+ modeler.Query({1, 1}),
+ StylusStateNear({.pressure = .5, .tilt = -1, .orientation = -1}, kTol));
+
+ modeler.Update({2, 0}, {.pressure = .7, .tilt = -2, .orientation = -.1});
+ EXPECT_THAT(
+ modeler.Query({1, 1}),
+ StylusStateNear({.pressure = .6, .tilt = -1, .orientation = -1}, kTol));
+}
+
+TEST(StylusStateModelerTest, ModelTiltOnly) {
+ StylusStateModeler modeler;
+
+ modeler.Update({0, 0}, {.pressure = -2, .tilt = .5, .orientation = -.1});
+ EXPECT_THAT(
+ modeler.Query({1, 1}),
+ StylusStateNear({.pressure = -1, .tilt = .5, .orientation = -1}, kTol));
+
+ modeler.Update({2, 0}, {.pressure = -2, .tilt = .3, .orientation = -.1});
+ EXPECT_THAT(
+ modeler.Query({1, 1}),
+ StylusStateNear({.pressure = -1, .tilt = .4, .orientation = -1}, kTol));
+}
+
+TEST(StylusStateModelerTest, ModelOrientationOnly) {
+ StylusStateModeler modeler;
+
+ modeler.Update({0, 0}, {.pressure = -2, .tilt = -.1, .orientation = 1});
+ EXPECT_THAT(
+ modeler.Query({1, 1}),
+ StylusStateNear({.pressure = -1, .tilt = -1, .orientation = 1}, kTol));
+
+ modeler.Update({2, 0}, {.pressure = -2, .tilt = -.3, .orientation = 2});
+ EXPECT_THAT(
+ modeler.Query({1, 1}),
+ StylusStateNear({.pressure = -1, .tilt = -1, .orientation = 1.5}, kTol));
+}
+
+TEST(StylusStateModelerTest, DropFieldsOneByOne) {
+ StylusStateModeler modeler;
+
+ modeler.Update({0, 0}, {.pressure = .5, .tilt = .5, .orientation = .5});
+ EXPECT_THAT(
+ modeler.Query({1, 0}),
+ StylusStateNear({.pressure = .5, .tilt = .5, .orientation = .5}, kTol));
+
+ modeler.Update({2, 0}, {.pressure = .3, .tilt = .7, .orientation = -1});
+ EXPECT_THAT(
+ modeler.Query({1, 0}),
+ StylusStateNear({.pressure = .4, .tilt = .6, .orientation = -1}, kTol));
+
+ modeler.Update({4, 0}, {.pressure = .1, .tilt = -1, .orientation = 1});
+ EXPECT_THAT(
+ modeler.Query({3, 0}),
+ StylusStateNear({.pressure = .2, .tilt = -1, .orientation = -1}, kTol));
+
+ modeler.Update({6, 0}, {.pressure = -1, .tilt = .2, .orientation = 0});
+ EXPECT_THAT(modeler.Query({5, 0}), StylusStateNear(kUnknown, kTol));
+
+ modeler.Update({8, 0}, {.pressure = .3, .tilt = .4, .orientation = .5});
+ EXPECT_THAT(modeler.Query({7, 0}), StylusStateNear(kUnknown, kTol));
+
+ modeler.Reset(StylusStateModelerParams{});
+ EXPECT_THAT(modeler.Query({1, 0}), StylusStateNear(kUnknown, kTol));
+
+ modeler.Update({0, 0}, {.pressure = .1, .tilt = .8, .orientation = .3});
+ EXPECT_THAT(
+ modeler.Query({1, 0}),
+ StylusStateNear({.pressure = .1, .tilt = .8, .orientation = .3}, kTol));
+}
+
+} // namespace
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/internal/type_matchers.cc b/ink_stroke_modeler/internal/type_matchers.cc
new file mode 100644
index 0000000..f974b61
--- /dev/null
+++ b/ink_stroke_modeler/internal/type_matchers.cc
@@ -0,0 +1,69 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/type_matchers.h"
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "ink_stroke_modeler/internal/internal_types.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+using ::testing::DoubleNear;
+using ::testing::FloatEq;
+using ::testing::FloatNear;
+using ::testing::Matcher;
+using ::testing::Matches;
+
+MATCHER_P(Vec2EqMatcher, expected, "") {
+ return Matches(FloatEq(expected.x))(arg.x) &&
+ Matches(FloatEq(expected.y))(arg.y);
+}
+
+MATCHER_P2(Vec2NearMatcher, expected, tolerance, "") {
+ return Matches(FloatNear(expected.x, tolerance))(arg.x) &&
+ Matches(FloatNear(expected.y, tolerance))(arg.y);
+}
+MATCHER_P2(TipStateNearMatcher, expected, tolerance, "") {
+ return Matches(Vec2Near(expected.position, tolerance))(arg.position) &&
+ Matches(Vec2Near(expected.velocity, tolerance))(arg.velocity) &&
+ Matches(DoubleNear(expected.time.Value(), tolerance))(
+ arg.time.Value());
+}
+
+MATCHER_P2(StylusStateNearMatcher, expected, tolerance, "") {
+ return Matches(FloatNear(expected.pressure, tolerance))(arg.pressure) &&
+ Matches(FloatNear(expected.tilt, tolerance))(arg.tilt) &&
+ Matches(FloatNear(expected.orientation, tolerance))(arg.orientation);
+}
+
+} // namespace
+
+Matcher<Vec2> Vec2Eq(const Vec2 v) { return Vec2EqMatcher(v); }
+Matcher<Vec2> Vec2Near(const Vec2 v, float tolerance) {
+ return Vec2NearMatcher(v, tolerance);
+}
+Matcher<TipState> TipStateNear(const TipState &expected, float tolerance) {
+ return TipStateNearMatcher(expected, tolerance);
+}
+Matcher<StylusState> StylusStateNear(const StylusState &expected,
+ float tolerance) {
+ return StylusStateNearMatcher(expected, tolerance);
+}
+
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/internal/type_matchers.h b/ink_stroke_modeler/internal/type_matchers.h
new file mode 100644
index 0000000..97f8876
--- /dev/null
+++ b/ink_stroke_modeler/internal/type_matchers.h
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_INTERNAL_TYPE_MATCHERS_H_
+#define INK_STROKE_MODELER_INTERNAL_TYPE_MATCHERS_H_
+
+#include "gtest/gtest.h"
+#include "ink_stroke_modeler/internal/internal_types.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+
+// These matchers compare Vec2s component-wise, delegating to
+// ::testing::FloatEq() and ::testing::FloatNear(), respectively.
+::testing::Matcher<Vec2> Vec2Eq(const Vec2 v);
+::testing::Matcher<Vec2> Vec2Near(const Vec2 v, float tolerance);
+
+// These convenience matchers perform comparisons using ::testing::FloatNear(),
+// ::testing::DoubleNear(), and Vec2Near().
+::testing::Matcher<TipState> TipStateNear(const TipState &expected,
+ float tolerance);
+::testing::Matcher<StylusState> StylusStateNear(const StylusState &expected,
+ float tolerance);
+
+} // namespace stroke_model
+} // namespace ink
+
+#endif // INK_STROKE_MODELER_INTERNAL_TYPE_MATCHERS_H_
diff --git a/ink_stroke_modeler/internal/utils.h b/ink_stroke_modeler/internal/utils.h
new file mode 100644
index 0000000..6bf2a27
--- /dev/null
+++ b/ink_stroke_modeler/internal/utils.h
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_INTERNAL_UTILS_H_
+#define INK_STROKE_MODELER_INTERNAL_UTILS_H_
+
+#include <cmath>
+
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+
+// General utility functions for use within the stroke model.
+
+// Clamps the given value to the range [0, 1].
+inline float Clamp01(float value) {
+ if (value < 0.f) return 0.f;
+ if (value > 1.f) return 1.f;
+ return value;
+}
+
+// Returns the ratio of the difference from `start` to `value` and the
+// difference from `start` to `end`, clamped to the range [0, 1]. If
+// `start` == `end`, returns 1 if `value` > `start`, 0 otherwise.
+inline float Normalize01(float start, float end, float value) {
+ if (start == end) {
+ return value > start ? 1 : 0;
+ }
+ return Clamp01((value - start) / (end - start));
+}
+
+// Linearly interpolates between `start` and `end`, clamping the interpolation
+// value to the range [0, 1].
+template <typename ValueType>
+inline ValueType Interp(ValueType start, ValueType end, float interp_amount) {
+ return start + (end - start) * Clamp01(interp_amount);
+}
+
+// Linearly interpolates from `start` to `end`, traveling around the shorter
+// path (e.g. interpolating from π/4 to 7π/4 is equivalent to interpolating from
+// π/4 to 0, then 2π to 7π/4). The returned angle will be normalized to the
+// interval [0, 2π). All angles are measured in radians.
+inline float InterpAngle(float start, float end, float interp_amount) {
+ auto normalize_angle = [](float angle) {
+ while (angle < 0) angle += 2 * M_PI;
+ while (angle > 2 * M_PI) angle -= 2 * M_PI;
+ return angle;
+ };
+
+ start = normalize_angle(start);
+ end = normalize_angle(end);
+ float delta = end - start;
+ if (delta < -M_PI) {
+ end += 2 * M_PI;
+ } else if (delta > M_PI) {
+ end -= 2 * M_PI;
+ }
+ return normalize_angle(Interp(start, end, interp_amount));
+}
+
+// Returns the distance between two points.
+inline float Distance(Vec2 start, Vec2 end) {
+ return (end - start).Magnitude();
+}
+
+// Returns the point on the line segment from `segment_start` to `segment_end`
+// that is closest to `point`, represented as the ratio of the length along the
+// segment.
+inline float NearestPointOnSegment(Vec2 segment_start, Vec2 segment_end,
+ Vec2 point) {
+ if (segment_start == segment_end) return 0;
+
+ auto dot_product = [](Vec2 lhs, Vec2 rhs) {
+ return lhs.x * rhs.x + lhs.y * rhs.y;
+ };
+ Vec2 segment_vector = segment_end - segment_start;
+ Vec2 projection_vector = point - segment_start;
+ return Clamp01(dot_product(projection_vector, segment_vector) /
+ dot_product(segment_vector, segment_vector));
+}
+
+} // namespace stroke_model
+} // namespace ink
+
+#endif // INK_STROKE_MODELER_INTERNAL_UTILS_H_
diff --git a/ink_stroke_modeler/internal/utils_test.cc b/ink_stroke_modeler/internal/utils_test.cc
new file mode 100644
index 0000000..b50d21a
--- /dev/null
+++ b/ink_stroke_modeler/internal/utils_test.cc
@@ -0,0 +1,87 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/utils.h"
+
+#include <cmath>
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "ink_stroke_modeler/internal/type_matchers.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+TEST(UtilsTest, Clamp01) {
+ EXPECT_FLOAT_EQ(Clamp01(-2), 0);
+ EXPECT_FLOAT_EQ(Clamp01(0), 0);
+ EXPECT_FLOAT_EQ(Clamp01(.3), .3);
+ EXPECT_FLOAT_EQ(Clamp01(.7), .7);
+ EXPECT_FLOAT_EQ(Clamp01(1), 1);
+ EXPECT_FLOAT_EQ(Clamp01(1.1), 1);
+}
+
+TEST(UtilsTest, Normalize01) {
+ EXPECT_FLOAT_EQ(Normalize01(1, 2, 1.5), .5);
+ EXPECT_FLOAT_EQ(Normalize01(7, 3, 4), .75);
+ EXPECT_FLOAT_EQ(Normalize01(-1, 1, 2), 1);
+ EXPECT_FLOAT_EQ(Normalize01(1, 1, 1), 0);
+ EXPECT_FLOAT_EQ(Normalize01(1, 1, 0), 0);
+ EXPECT_FLOAT_EQ(Normalize01(1, 1, 2), 1);
+}
+
+TEST(UtilsTest, InterpFloat) {
+ EXPECT_FLOAT_EQ(Interp(5, 10, .2), 6);
+ EXPECT_FLOAT_EQ(Interp(10, -2, .75), 1);
+ EXPECT_FLOAT_EQ(Interp(-1, 2, -3), -1);
+ EXPECT_FLOAT_EQ(Interp(5, 7, 20), 7);
+}
+
+TEST(UtilsTest, InterpVec2) {
+ EXPECT_THAT(Interp(Vec2{1, 2}, {3, 5}, .5), Vec2Eq({2, 3.5}));
+ EXPECT_THAT(Interp(Vec2{-5, 5}, {-15, 0}, .4), Vec2Eq({-9, 3}));
+ EXPECT_THAT(Interp(Vec2{7, 9}, {25, 30}, -.1), Vec2Eq({7, 9}));
+ EXPECT_THAT(Interp(Vec2{12, 5}, {13, 14}, 3.2), Vec2Eq({13, 14}));
+}
+
+TEST(UtilsTest, InterpAngle) {
+ EXPECT_NEAR(InterpAngle(.25 * M_PI, .5 * M_PI, .4), .35 * M_PI, 1e-6);
+ EXPECT_NEAR(InterpAngle(1.05 * M_PI, .25 * M_PI, .5), .65 * M_PI, 1e-6);
+ EXPECT_NEAR(InterpAngle(.25 * M_PI, 1.75 * M_PI, .1), .2 * M_PI, 1e-6);
+ EXPECT_NEAR(InterpAngle(.25 * M_PI, 1.75 * M_PI, .7), 1.9 * M_PI, 1e-6);
+ EXPECT_NEAR(InterpAngle(1.6 * M_PI, .4 * M_PI, .25), 1.8 * M_PI, 1e-6);
+ EXPECT_NEAR(InterpAngle(1.6 * M_PI, .4 * M_PI, .625), .1 * M_PI, 1e-6);
+}
+
+TEST(UtilsTest, Distance) {
+ EXPECT_FLOAT_EQ(Distance({0, 0}, {1, 0}), 1);
+ EXPECT_FLOAT_EQ(Distance({1, 1}, {-2, 5}), 5);
+}
+
+TEST(UtilsTest, NearestPointOnSegment) {
+ EXPECT_FLOAT_EQ(NearestPointOnSegment({0, 0}, {1, 0}, {.25, .5}), .25);
+ EXPECT_FLOAT_EQ(NearestPointOnSegment({3, 4}, {5, 6}, {-1, -1}), 0);
+ EXPECT_FLOAT_EQ(NearestPointOnSegment({20, 10}, {10, 5}, {2, 2}), 1);
+ EXPECT_FLOAT_EQ(NearestPointOnSegment({0, 5}, {5, 0}, {3, 3}), .5);
+}
+
+TEST(UtilsTest, NearestPointOnSegmentDegenerateCase) {
+ EXPECT_FLOAT_EQ(NearestPointOnSegment({0, 0}, {0, 0}, {5, 10}), 0);
+ EXPECT_FLOAT_EQ(NearestPointOnSegment({3, 7}, {3, 7}, {0, -20}), 0);
+}
+
+} // namespace
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/internal/validation.h b/ink_stroke_modeler/internal/validation.h
new file mode 100644
index 0000000..e01aa1a
--- /dev/null
+++ b/ink_stroke_modeler/internal/validation.h
@@ -0,0 +1,48 @@
+#ifndef INK_STROKE_MODELER_INTERNAL_VALIDATION_H_
+#define INK_STROKE_MODELER_INTERNAL_VALIDATION_H_
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/substitute.h"
+
+template <typename T>
+absl::Status ValidateIsFiniteNumber(T value, absl::string_view label) {
+ if (std::isnan(value)) {
+ return absl::InvalidArgumentError(absl::Substitute("$0 is NaN", label));
+ }
+ if (std::isinf(value)) {
+ return absl::InvalidArgumentError(
+ absl::Substitute("$0 is infinite", label));
+ }
+ return absl::OkStatus();
+}
+
+template <typename T>
+absl::Status ValidateGreaterThanZero(T value, absl::string_view label) {
+ if (absl::Status status = ValidateIsFiniteNumber(value, label);
+ !status.ok()) {
+ return status;
+ }
+ if (value <= 0) {
+ return absl::InvalidArgumentError(absl::Substitute(
+ "$0 must be greater than zero. Actual value: $1", label, value));
+ }
+ return absl::OkStatus();
+}
+
+template <typename T>
+absl::Status ValidateGreaterThanOrEqualToZero(T value,
+ absl::string_view label) {
+ if (absl::Status status = ValidateIsFiniteNumber(value, label);
+ !status.ok()) {
+ return status;
+ }
+ if (value < 0) {
+ return absl::InvalidArgumentError(absl::Substitute(
+ "$0 must be greater than or equal to zero. Actual value: $1", label,
+ value));
+ }
+ return absl::OkStatus();
+}
+
+#endif // INK_STROKE_MODELER_INTERNAL_VALIDATION_H_
diff --git a/ink_stroke_modeler/internal/validation_test.cc b/ink_stroke_modeler/internal/validation_test.cc
new file mode 100644
index 0000000..51482ff
--- /dev/null
+++ b/ink_stroke_modeler/internal/validation_test.cc
@@ -0,0 +1,82 @@
+#include "ink_stroke_modeler/internal/validation.h"
+
+#include <cmath>
+
+#include "gtest/gtest.h"
+
+namespace {
+
+TEST(ValidateIsFiniteNumberTest, AcceptFinite) {
+ ASSERT_TRUE(ValidateIsFiniteNumber(1, "foo").ok());
+}
+
+TEST(ValidateIsFiniteNumberTest, RejectNan) {
+ absl::Status status = ValidateIsFiniteNumber(NAN, "foo");
+ ASSERT_FALSE(status.ok());
+ ASSERT_EQ(status.message(), "foo is NaN");
+}
+
+TEST(ValidateIsFiniteNumberTest, RejectInf) {
+ absl::Status status = ValidateIsFiniteNumber(INFINITY, "foo");
+ ASSERT_FALSE(status.ok());
+ ASSERT_EQ(status.message(), "foo is infinite");
+}
+
+TEST(ValidateGreaterThanZeroTest, AcceptPositive) {
+ ASSERT_TRUE(ValidateGreaterThanZero(1, "foo").ok());
+}
+
+TEST(ValidateGreaterThanZeroTest, RejectZero) {
+ absl::Status status = ValidateGreaterThanZero(0, "foo");
+ ASSERT_FALSE(status.ok());
+ ASSERT_EQ(status.message(), "foo must be greater than zero. Actual value: 0");
+}
+
+TEST(ValidateGreaterThanZeroTest, RejectNegative) {
+ absl::Status status = ValidateGreaterThanZero(-1, "foo");
+ ASSERT_FALSE(status.ok());
+ ASSERT_EQ(status.message(),
+ "foo must be greater than zero. Actual value: -1");
+}
+
+TEST(ValidateGreaterThanZeroTest, RejectNan) {
+ absl::Status status = ValidateGreaterThanZero(NAN, "foo");
+ ASSERT_FALSE(status.ok());
+ ASSERT_EQ(status.message(), "foo is NaN");
+}
+
+TEST(ValidateGreaterThanZeroTest, RejectInf) {
+ absl::Status status = ValidateGreaterThanZero(INFINITY, "foo");
+ ASSERT_FALSE(status.ok());
+ ASSERT_EQ(status.message(), "foo is infinite");
+}
+
+TEST(ValidateGreaterThanOrEqualToZeroTest, AcceptPositive) {
+ ASSERT_TRUE(ValidateGreaterThanOrEqualToZero(1, "foo").ok());
+}
+
+TEST(ValidateGreaterThanOrEqualToZeroTest, AcceptZero) {
+ absl::Status status = ValidateGreaterThanOrEqualToZero(0, "foo");
+ ASSERT_TRUE(ValidateGreaterThanOrEqualToZero(0, "foo").ok());
+}
+
+TEST(ValidateGreaterThanOrEqualToZeroTest, RejectNegative) {
+ absl::Status status = ValidateGreaterThanOrEqualToZero(-1, "foo");
+ ASSERT_FALSE(status.ok());
+ ASSERT_EQ(status.message(),
+ "foo must be greater than or equal to zero. Actual value: -1");
+}
+
+TEST(ValidateGreaterThanOrEqualToZeroTest, RejectNan) {
+ absl::Status status = ValidateGreaterThanOrEqualToZero(NAN, "foo");
+ ASSERT_FALSE(status.ok());
+ ASSERT_EQ(status.message(), "foo is NaN");
+}
+
+TEST(ValidateGreaterThanOrEqualToZeroTest, RejectInf) {
+ absl::Status status = ValidateGreaterThanOrEqualToZero(INFINITY, "foo");
+ ASSERT_FALSE(status.ok());
+ ASSERT_EQ(status.message(), "foo is infinite");
+}
+
+} // namespace
diff --git a/ink_stroke_modeler/internal/wobble_smoother.cc b/ink_stroke_modeler/internal/wobble_smoother.cc
new file mode 100644
index 0000000..a403ecb
--- /dev/null
+++ b/ink_stroke_modeler/internal/wobble_smoother.cc
@@ -0,0 +1,70 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/wobble_smoother.h"
+
+#include <algorithm>
+
+#include "ink_stroke_modeler/internal/utils.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+
+void WobbleSmoother::Reset(const WobbleSmootherParams& params, Vec2 position,
+ Time time) {
+ params_ = params;
+ samples_.clear();
+ // Initialize with the "fast" speed -- otherwise, we'll lag behind at the
+ // start of the stroke.
+ position_sum_ = position;
+ speed_sum_ = params_.speed_ceiling;
+ samples_.push_back(
+ {.position = position, .speed = params_.speed_ceiling, .time = time});
+}
+
+Vec2 WobbleSmoother::Update(Vec2 position, Time time) {
+ // The moving average acts as a low-pass signal filter, removing
+ // high-frequency fluctuations in the position caused by the discrete nature
+ // of the touch digitizer. To compensate for the distance between the average
+ // position and the actual position, we interpolate between them, based on
+ // speed, to determine the position to use for the input model.
+ float distance = Distance(position, samples_.back().position);
+ Duration delta_time = time - samples_.back().time;
+ float speed = 0;
+ if (delta_time == Duration(0)) {
+ // We're going to assume that you're not actually moving infinitely fast.
+ speed = std::max(params_.speed_ceiling, speed_sum_ / samples_.size());
+ } else {
+ speed = distance / delta_time.Value();
+ }
+
+ samples_.push_back({.position = position, .speed = speed, .time = time});
+ position_sum_ += position;
+ speed_sum_ += speed;
+ while (samples_.front().time < time - params_.timeout) {
+ position_sum_ -= samples_.front().position;
+ speed_sum_ -= samples_.front().speed;
+ samples_.pop_front();
+ }
+
+ Vec2 avg_position = position_sum_ / samples_.size();
+ float avg_speed = speed_sum_ / samples_.size();
+ return Interp(
+ avg_position, position,
+ Normalize01(params_.speed_floor, params_.speed_ceiling, avg_speed));
+}
+
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/internal/wobble_smoother.h b/ink_stroke_modeler/internal/wobble_smoother.h
new file mode 100644
index 0000000..7eb85b8
--- /dev/null
+++ b/ink_stroke_modeler/internal/wobble_smoother.h
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_INTERNAL_WOBBLE_SMOOTHER_H_
+#define INK_STROKE_MODELER_INTERNAL_WOBBLE_SMOOTHER_H_
+
+#include <deque>
+
+#include "ink_stroke_modeler/params.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+
+// This class smooths "wobble" in input positions from high-frequency noise. It
+// does so by maintaining a moving average of the positions, and interpolating
+// between the given input and the moving average based on how quickly it's
+// moving. When moving at a speed above the ceiling in the WobbleSmootherParams,
+// the result will be the unmodified input; when moving at a speed below the
+// floor, the result will be the moving average.
+class WobbleSmoother {
+ public:
+ void Reset(const WobbleSmootherParams &params, Vec2 position, Time time);
+
+ // Updates the average position and speed, and returns the smoothed position.
+ Vec2 Update(Vec2 position, Time time);
+
+ private:
+ struct Sample {
+ Vec2 position{0};
+ float speed{0};
+ Time time{0};
+ };
+ std::deque<Sample> samples_;
+ Vec2 position_sum_{0};
+ float speed_sum_{0};
+
+ WobbleSmootherParams params_;
+};
+
+} // namespace stroke_model
+} // namespace ink
+
+#endif // INK_STROKE_MODELER_INTERNAL_WOBBLE_SMOOTHER_H_
diff --git a/ink_stroke_modeler/internal/wobble_smoother_test.cc b/ink_stroke_modeler/internal/wobble_smoother_test.cc
new file mode 100644
index 0000000..7bffd37
--- /dev/null
+++ b/ink_stroke_modeler/internal/wobble_smoother_test.cc
@@ -0,0 +1,104 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/internal/wobble_smoother.h"
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "ink_stroke_modeler/internal/type_matchers.h"
+#include "ink_stroke_modeler/params.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+const WobbleSmootherParams kDefaultParams{
+ .timeout = Duration(.04), .speed_floor = 1.31, .speed_ceiling = 1.44};
+
+TEST(WobbleSmootherTest, SlowStraightLine) {
+ // The line moves at 1 cm/s, which is below the floor of 1.31 cm/s.
+ WobbleSmoother filter;
+ filter.Reset(kDefaultParams, {3, 4}, Time{1});
+ EXPECT_THAT(filter.Update({3.016, 4}, Time{1.016}), Vec2Eq({3.008, 4}));
+ EXPECT_THAT(filter.Update({3.032, 4}, Time{1.032}), Vec2Eq({3.016, 4}));
+ EXPECT_THAT(filter.Update({3.048, 4}, Time{1.048}), Vec2Eq({3.032, 4}));
+ EXPECT_THAT(filter.Update({3.064, 4}, Time{1.064}), Vec2Eq({3.048, 4}));
+}
+
+TEST(WobbleSmootherTest, SlowStraightLineEqualFloorAndCeiling) {
+ // The line moves at 1 cm/s, which is below the floor of 1.31 cm/s.
+ WobbleSmootherParams equal_floor_and_ceiling_params{
+ .timeout = Duration(.04), .speed_floor = 1.31, .speed_ceiling = 1.31};
+ WobbleSmoother filter;
+ filter.Reset(equal_floor_and_ceiling_params, {3, 4}, Time{1});
+ EXPECT_THAT(filter.Update({3.016, 4}, Time{1.016}), Vec2Eq({3.008, 4}));
+ EXPECT_THAT(filter.Update({3.032, 4}, Time{1.032}), Vec2Eq({3.016, 4}));
+ EXPECT_THAT(filter.Update({3.048, 4}, Time{1.048}), Vec2Eq({3.032, 4}));
+ EXPECT_THAT(filter.Update({3.064, 4}, Time{1.064}), Vec2Eq({3.048, 4}));
+}
+
+TEST(WobbleSmootherTest, FastStraightLine) {
+ // The line moves at 1.5 cm/s, which is above the ceiling of 1.44 cm/s.
+ WobbleSmoother filter;
+ filter.Reset(kDefaultParams, {-1, 0}, Time{0});
+ EXPECT_THAT(filter.Update({-1, .024}, Time{.016}), Vec2Eq({-1, .024}));
+ EXPECT_THAT(filter.Update({-1, .048}, Time{.032}), Vec2Eq({-1, .048}));
+ EXPECT_THAT(filter.Update({-1, .072}, Time{.048}), Vec2Eq({-1, .072}));
+}
+
+TEST(WobbleSmootherTest, FastStraightLineEqualFloorAndCeiling) {
+ // The line moves at 1.5 cm/s, which is above the ceiling of 1.44 cm/s.
+ WobbleSmoother filter;
+ WobbleSmootherParams equal_floor_and_ceiling_params{
+ .timeout = Duration(.04), .speed_floor = 1.41, .speed_ceiling = 1.41};
+ filter.Reset(equal_floor_and_ceiling_params, {-1, 0}, Time{0});
+ EXPECT_THAT(filter.Update({-1, .024}, Time{.016}), Vec2Eq({-1, .024}));
+ EXPECT_THAT(filter.Update({-1, .048}, Time{.032}), Vec2Eq({-1, .048}));
+ EXPECT_THAT(filter.Update({-1, .072}, Time{.048}), Vec2Eq({-1, .072}));
+}
+
+TEST(WobbleSmootherTest, SlowZigZag) {
+ // The line moves at 1 cm/s, which is below the floor of 1.31 cm/s.
+ WobbleSmoother filter;
+ filter.Reset(kDefaultParams, {1, 2}, Time{5});
+ EXPECT_THAT(filter.Update({1.016, 2}, Time{5.016}), Vec2Eq({1.008, 2}));
+ EXPECT_THAT(filter.Update({1.016, 2.016}, Time{5.032}),
+ Vec2Eq({1.0106667, 2.0053333}));
+ EXPECT_THAT(filter.Update({1.032, 2.016}, Time{5.048}),
+ Vec2Eq({1.0213333, 2.0106667}));
+ EXPECT_THAT(filter.Update({1.032, 2.032}, Time{5.064}),
+ Vec2Eq({1.0266667, 2.0213333}));
+ EXPECT_THAT(filter.Update({1.048, 2.032}, Time{5.080}),
+ Vec2Eq({1.0373333, 2.0266667}));
+ EXPECT_THAT(filter.Update({1.048, 2.048}, Time{5.096}),
+ Vec2Eq({1.0426667, 2.0373333}));
+}
+
+TEST(WobbleSmootherTest, FastZigZag) {
+ // The line moves at 1.5 cm/s, which is above the ceiling of 1.44 cm/s.
+ WobbleSmoother filter;
+ filter.Reset(kDefaultParams, {7, 3}, Time{8});
+ EXPECT_THAT(filter.Update({7, 3.024}, Time{8.016}), Vec2Eq({7, 3.024}));
+ EXPECT_THAT(filter.Update({7.024, 3.024}, Time{8.032}),
+ Vec2Eq({7.024, 3.024}));
+ EXPECT_THAT(filter.Update({7.024, 3.048}, Time{8.048}),
+ Vec2Eq({7.024, 3.048}));
+ EXPECT_THAT(filter.Update({7.048, 3.048}, Time{8.064}),
+ Vec2Eq({7.048, 3.048}));
+}
+
+} // namespace
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/params.cc b/ink_stroke_modeler/params.cc
new file mode 100644
index 0000000..99542e1
--- /dev/null
+++ b/ink_stroke_modeler/params.cc
@@ -0,0 +1,157 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/params.h"
+
+#include <cmath>
+
+#include "absl/status/status.h"
+#include "absl/strings/string_view.h"
+#include "absl/strings/substitute.h"
+#include "absl/types/variant.h"
+#include "ink_stroke_modeler/internal/validation.h"
+
+// This convenience macro evaluates the given expression, and if it does not
+// return an OK status, returns and propagates the status.
+#define RETURN_IF_ERROR(expr) \
+ do { \
+ if (auto status = (expr); !status.ok()) return status; \
+ } while (false)
+
+namespace ink {
+namespace stroke_model {
+
+absl::Status ValidatePositionModelerParams(
+ const PositionModelerParams& params) {
+ RETURN_IF_ERROR(ValidateGreaterThanZero(params.spring_mass_constant,
+ "PredictionParams::spring_mass"));
+ return ValidateGreaterThanZero(params.drag_constant,
+ "PredictionParams::drag_ratio");
+}
+
+absl::Status ValidateSamplingParams(const SamplingParams& params) {
+ RETURN_IF_ERROR(ValidateGreaterThanZero(params.min_output_rate,
+ "PredictionParams::min_output_rate"));
+ RETURN_IF_ERROR(ValidateGreaterThanZero(
+ params.end_of_stroke_stopping_distance,
+ "PredictionParams::end_of_stroke_stopping_distance"));
+ return ValidateGreaterThanZero(
+ params.end_of_stroke_max_iterations,
+ "PredictionParams::end_of_stroke_stopping_distance");
+}
+
+absl::Status ValidateStylusStateModelerParams(
+ const StylusStateModelerParams& params) {
+ return ValidateGreaterThanZero(params.max_input_samples,
+ "StylusStateModelerParams::max_input_samples");
+}
+
+absl::Status ValidateWobbleSmootherParams(const WobbleSmootherParams& params) {
+ RETURN_IF_ERROR(ValidateGreaterThanOrEqualToZero(
+ params.timeout.Value(), "WobbleSmootherParams::timeout"));
+ RETURN_IF_ERROR(ValidateGreaterThanOrEqualToZero(
+ params.speed_floor, "WobbleSmootherParams::speed_floor"));
+ RETURN_IF_ERROR(ValidateIsFiniteNumber(
+ params.speed_ceiling, "WobbleSmootherParams::speed_ceiling"));
+ if (params.speed_ceiling < params.speed_floor) {
+ return absl::InvalidArgumentError(absl::Substitute(
+ "WobbleSmootherParams::speed_ceiling must be greater than or "
+ "equal to WobbleSmootherParams::speed_floor ($0). Actual "
+ "value: $1",
+ params.speed_floor, params.speed_ceiling));
+ }
+ return absl::OkStatus();
+}
+
+absl::Status ValidatePredictionParams(const PredictionParams& params) {
+ if (absl::holds_alternative<StrokeEndPredictorParams>(params)) {
+ // Nothing to validate.
+ return absl::OkStatus();
+ }
+
+ const KalmanPredictorParams& kalman_params =
+ absl::get<KalmanPredictorParams>(params);
+ RETURN_IF_ERROR(ValidateGreaterThanZero(
+ kalman_params.process_noise, "KalmanPredictorParams::process_noise"));
+ RETURN_IF_ERROR(
+ ValidateGreaterThanZero(kalman_params.measurement_noise,
+ "KalmanPredictorParams::measurement_noise"));
+ RETURN_IF_ERROR(
+ ValidateGreaterThanZero(kalman_params.min_stable_iteration,
+ "KalmanPredictorParams::min_stable_iteration"));
+ RETURN_IF_ERROR(
+ ValidateGreaterThanZero(kalman_params.max_time_samples,
+ "KalmanPredictorParams::max_time_samples"));
+ RETURN_IF_ERROR(
+ ValidateGreaterThanZero(kalman_params.min_catchup_velocity,
+ "KalmanPredictorParams::min_catchup_velocity"));
+ RETURN_IF_ERROR(
+ ValidateIsFiniteNumber(kalman_params.acceleration_weight,
+ "KalmanPredictorParams::acceleration_weight"));
+ RETURN_IF_ERROR(ValidateIsFiniteNumber(kalman_params.jerk_weight,
+ "KalmanPredictorParams::jerk_weight"));
+ RETURN_IF_ERROR(
+ ValidateGreaterThanZero(kalman_params.prediction_interval.Value(),
+ "KalmanPredictorParams::jerk_weight"));
+
+ const KalmanPredictorParams::ConfidenceParams& confidence_params =
+ kalman_params.confidence_params;
+ RETURN_IF_ERROR(ValidateGreaterThanZero(
+ confidence_params.desired_number_of_samples,
+ "KalmanPredictorParams::ConfidenceParams::desired_number_of_samples"));
+ RETURN_IF_ERROR(ValidateGreaterThanZero(
+ confidence_params.max_estimation_distance,
+ "KalmanPredictorParams::ConfidenceParams::max_estimation_distance"));
+ RETURN_IF_ERROR(ValidateGreaterThanOrEqualToZero(
+ confidence_params.min_travel_speed,
+ "KalmanPredictorParams::ConfidenceParams::min_travel_speed"));
+ RETURN_IF_ERROR(ValidateIsFiniteNumber(
+ confidence_params.max_travel_speed,
+ "KalmanPredictorParams::ConfidenceParams::max_travel_speed"));
+ if (confidence_params.max_travel_speed < confidence_params.min_travel_speed) {
+ return absl::InvalidArgumentError(
+ absl::Substitute("KalmanPredictorParams::ConfidenceParams::max_"
+ "travel_speed must be greater than or equal to "
+ "KalmanPredictorParams::ConfidenceParams::min_"
+ "travel_speed ($0). Actual value: $1",
+ confidence_params.min_travel_speed,
+ confidence_params.max_travel_speed));
+ }
+ RETURN_IF_ERROR(ValidateGreaterThanZero(
+ confidence_params.max_linear_deviation,
+ "KalmanPredictorParams::ConfidenceParams::max_linear_deviation"));
+ if (confidence_params.baseline_linearity_confidence < 0 ||
+ confidence_params.baseline_linearity_confidence > 1) {
+ return absl::InvalidArgumentError(absl::Substitute(
+ "KalmanPredictorParams::ConfidenceParams::baseline_linearity_"
+ "confidence must lie in the interval [0, 1]. Actual value: $0",
+ confidence_params.baseline_linearity_confidence));
+ }
+ return absl::OkStatus();
+}
+
+absl::Status ValidateStrokeModelParams(const StrokeModelParams& params) {
+ RETURN_IF_ERROR(ValidateWobbleSmootherParams(params.wobble_smoother_params));
+ RETURN_IF_ERROR(
+ ValidatePositionModelerParams(params.position_modeler_params));
+ RETURN_IF_ERROR(ValidateSamplingParams(params.sampling_params));
+ RETURN_IF_ERROR(
+ ValidateStylusStateModelerParams(params.stylus_state_modeler_params));
+ return ValidatePredictionParams(params.prediction_params);
+}
+
+} // namespace stroke_model
+} // namespace ink
+
+#undef RETURN_IF_ERROR
diff --git a/ink_stroke_modeler/params.h b/ink_stroke_modeler/params.h
new file mode 100644
index 0000000..102edba
--- /dev/null
+++ b/ink_stroke_modeler/params.h
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_PARAMS_H_
+#define INK_STROKE_MODELER_PARAMS_H_
+
+#include "absl/status/status.h"
+#include "absl/types/variant.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+
+// These structs contain parameters for tuning the behavior of the stroke
+// modeler.
+//
+// The stroke modeler is unit-agnostic, in both time and space. That is, the
+// stroke modeler does not know or care whether the inputs and parameters are
+// specified in feet and minutes, meters and seconds, or millimeters and years.
+// As such, instead of referring to specific units, we refer to "unit distance"
+// and "unit time".
+//
+// These parameters will need to be "tuned" to your use case. Because of this,
+// and because of the modeler's unit-agnosticism, it's impossible to define
+// "reasonable" default values for many of the parameters -- these parameters
+// instead default to -1, which will cause the validation functions to return an
+// error.
+//
+// Where possible, we've indicated what a good starting point for tuning might
+// be, but you'll likely need to adjust these for best results.
+
+// These parameters are used for modeling the position of the pen.
+struct PositionModelerParams {
+ // The mass of the "weight" being pulled along the path, multiplied by the
+ // spring constant.
+ float spring_mass_constant = 11.f / 32400;
+
+ // The ratio of the pen's velocity that is subtracted from the pen's
+ // acceleration, to simulate drag.
+ float drag_constant = 72.f;
+};
+
+// These parameters are used for sampling.
+struct SamplingParams {
+ // The minimum number of modeled inputs to output per unit time. If inputs are
+ // received at a lower rate, they will be upsampled to produce output of at
+ // least min_output_rate. If inputs are received at a higher rate, the
+ // output rate will match the input rate.
+ double min_output_rate = -1;
+
+ // This determines stop condition for end-of-stroke modeling; if the position
+ // is within this distance of the final raw input, or if the last update
+ // iteration moved less than this distance, it stop iterating.
+ //
+ // This should be a small distance; a good starting point is 2-3 orders of
+ // magnitude smaller than the expected distance between input points.
+ float end_of_stroke_stopping_distance = -1;
+
+ // The maximum number of iterations to perform at the end of the stroke, if it
+ // does not stop due to the constraints of end_of_stroke_stopping_distance.
+ int end_of_stroke_max_iterations = 20;
+};
+
+// These parameters are used modeling the state of the stylus once the position
+// has been modeled.
+struct StylusStateModelerParams {
+ // The maximum number of raw inputs to look at when finding the nearest states
+ // for interpolation.
+ int max_input_samples = 10;
+};
+
+// These parameters are used for applying smoothing to the input to reduce
+// wobble in the prediction.
+struct WobbleSmootherParams {
+ // The length of the window over which the moving average of speed and
+ // position are calculated.
+ //
+ // A good starting point is 2.5 divided by the expected number of inputs per
+ // unit time.
+ Duration timeout{-1};
+
+ // The range of speeds considered for wobble smoothing. At speed_floor, the
+ // maximum amount of smoothing is applied. At speed_ceiling, no smoothing is
+ // applied.
+ //
+ // Good starting points are 2% and 3% of the expected speed of the inputs.
+ float speed_floor = -1;
+ float speed_ceiling = -1;
+};
+
+// This struct indicates the "stroke end" prediction strategy should be used,
+// which models a prediction as though the last seen input was the
+// end-of-stroke. There aren't actually any tunable parameters for this; it uses
+// the same PositionModelerParams and SamplingParams as the overall model. Note
+// that this "prediction" doesn't actually predict substantially into the
+// future, it only allows for very quickly "catching up" to the position of the
+// raw input.
+struct StrokeEndPredictorParams {};
+
+// This struct indicates that the Kalman filter-based prediction strategy should
+// be used, and provides the parameters for tuning it.
+//
+// Unlike the "stroke end" predictor, this strategy can predict an extension
+// of the stroke beyond the last Input position, in addition to the "catch up"
+// step.
+struct KalmanPredictorParams {
+ // The variance of the noise inherent to the stroke itself.
+ double process_noise = -1;
+
+ // The variance of the noise that rises from errors in measurement of the
+ // stroke.
+ double measurement_noise = -1;
+
+ // The minimum number of inputs received before the Kalman predictor is
+ // considered stable enough to make a prediction.
+ int min_stable_iteration = 4;
+
+ // The Kalman filter assumes that input is received in uniform time steps, but
+ // this is not always the case. We hold on to the most recent input timestamps
+ // for use in calculating the correction for this. This determines the maximum
+ // number of timestamps to save.
+ int max_time_samples = 20;
+
+ // The minimum allowed velocity of the "catch up" portion of the prediction,
+ // which covers the distance between the last Result (the last corrected
+ // position) and the
+ //
+ // A good starting point is 3 orders of magnitude smaller than the expected
+ // speed of the inputs.
+ float min_catchup_velocity = -1;
+
+ // These weights are applied to the acceleration (x²) and jerk (x³) terms of
+ // the cubic prediction polynomial. The closer they are to zero, the more
+ // linear the prediction will be.
+ float acceleration_weight = .5;
+ float jerk_weight = .1;
+
+ // This value is a hint to the predictor, indicating the desired duration of
+ // of the portion of the prediction extending beyond the position of the last
+ // input. The actual duration of that portion of the prediction may be less
+ // than this, based on the predictor's confidence, but it will never be
+ // greater.
+ Duration prediction_interval{-1};
+
+ // The Kalman predictor uses several heuristics to evaluate confidence in the
+ // prediction. Each heuristic produces a confidence value between 0 and 1, and
+ // then we take their product as the total confidence.
+ // These parameters may be used to tune those heuristics.
+ struct ConfidenceParams {
+ // The first heuristic simply increases confidence as we receive more sample
+ // (i.e. input points). It evaluates to 0 at no samples, and 1 at
+ // desired_number_of_samples.
+ int desired_number_of_samples = 20;
+
+ // The second heuristic is based on the distance between the last sample
+ // and the current estimate. If the distance is 0, it evaluates to 1, and if
+ // the distance is greater than or equal to max_estimation_distance, it
+ // evaluates to 0.
+ //
+ // A good starting point is 1.5 times measurement_noise.
+ float max_estimation_distance = -1;
+
+ // The third heuristic is based on the speed of the prediction, which is
+ // approximated by measuring the from the start of the prediction to the
+ // projected endpoint (if it were extended for the full
+ // prediction_interval). It evaluates to 0 at min_travel_speed, and 1
+ // at max_travel_speed.
+ //
+ // Good starting points are 5% and 25% of the expected speed of the inputs.
+ float min_travel_speed = -1;
+ float max_travel_speed = -1;
+
+ // The fourth heuristic is based on the linearity of the prediction, which
+ // is approximated by comparing the endpoint of the prediction with the
+ // endpoint of a linear prediction (again, extended for the full
+ // prediction_interval). It evaluates to 1 at zero distance, and
+ // baseline_linearity_confidence at a distance of max_linear_deviation.
+ //
+ // A good starting point is an 10 times the measurement_noise.
+ float max_linear_deviation = -1;
+ float baseline_linearity_confidence = .4;
+ };
+ ConfidenceParams confidence_params;
+};
+using PredictionParams =
+ absl::variant<StrokeEndPredictorParams, KalmanPredictorParams>;
+
+// This convenience struct is a collection of the parameters for the individual
+// parameter structs.
+struct StrokeModelParams {
+ WobbleSmootherParams wobble_smoother_params;
+ PositionModelerParams position_modeler_params;
+ SamplingParams sampling_params;
+ StylusStateModelerParams stylus_state_modeler_params;
+ PredictionParams prediction_params = StrokeEndPredictorParams{};
+};
+
+// These validation functions will return an error if the given parameters are
+// invalid.
+absl::Status ValidatePositionModelerParams(const PositionModelerParams& params);
+absl::Status ValidateSamplingParams(const SamplingParams& params);
+absl::Status ValidateStylusStateModelerParams(
+ const StylusStateModelerParams& params);
+absl::Status ValidateWobbleSmootherParams(const WobbleSmootherParams& params);
+absl::Status ValidatePredictionParams(const PredictionParams& params);
+absl::Status ValidateStrokeModelParams(const StrokeModelParams& params);
+
+} // namespace stroke_model
+} // namespace ink
+
+#endif // INK_STROKE_MODELER_PARAMS_H_
diff --git a/ink_stroke_modeler/params_test.cc b/ink_stroke_modeler/params_test.cc
new file mode 100644
index 0000000..15bad8a
--- /dev/null
+++ b/ink_stroke_modeler/params_test.cc
@@ -0,0 +1,259 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/params.h"
+
+#include <limits>
+
+#include "gtest/gtest.h"
+#include "absl/status/status.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+const KalmanPredictorParams kGoodKalmanParams{
+ .process_noise = .01,
+ .measurement_noise = .1,
+ .min_stable_iteration = 2,
+ .max_time_samples = 10,
+ .min_catchup_velocity = 1,
+ .acceleration_weight = -1,
+ .jerk_weight = 200,
+ .prediction_interval{Duration(1)},
+ .confidence_params{.desired_number_of_samples = 10,
+ .max_estimation_distance = 1,
+ .min_travel_speed = 6,
+ .max_travel_speed = 50,
+ .max_linear_deviation = 2,
+ .baseline_linearity_confidence = .5}};
+
+const StrokeModelParams kGoodStrokeModelParams{
+ .wobble_smoother_params{
+ .timeout = Duration(.5), .speed_floor = 1, .speed_ceiling = 20},
+ .position_modeler_params{.spring_mass_constant = .2, .drag_constant = 4},
+ .sampling_params{.min_output_rate = 3,
+ .end_of_stroke_stopping_distance = 1e-6,
+ .end_of_stroke_max_iterations = 1},
+ .stylus_state_modeler_params{.max_input_samples = 7},
+ .prediction_params = StrokeEndPredictorParams{}};
+
+TEST(ParamsTest, ValidatePositionModelerParams) {
+ EXPECT_TRUE(ValidatePositionModelerParams(
+ {.spring_mass_constant = 1, .drag_constant = 3})
+ .ok());
+
+ EXPECT_EQ(ValidatePositionModelerParams(
+ {.spring_mass_constant = 0, .drag_constant = 1})
+ .code(),
+ absl::StatusCode::kInvalidArgument);
+ EXPECT_EQ(ValidatePositionModelerParams(
+ {.spring_mass_constant = 1, .drag_constant = 0})
+ .code(),
+ absl::StatusCode::kInvalidArgument);
+}
+
+TEST(ParamsTest, ValidateSamplingParams) {
+ EXPECT_TRUE(ValidateSamplingParams({.min_output_rate = 10,
+ .end_of_stroke_stopping_distance = .1,
+ .end_of_stroke_max_iterations = 3})
+ .ok());
+
+ EXPECT_EQ(ValidateSamplingParams({.min_output_rate = 0,
+ .end_of_stroke_stopping_distance = .1,
+ .end_of_stroke_max_iterations = 3})
+ .code(),
+ absl::StatusCode::kInvalidArgument);
+ EXPECT_EQ(ValidateSamplingParams({.min_output_rate = 1,
+ .end_of_stroke_stopping_distance = 0,
+ .end_of_stroke_max_iterations = 3})
+ .code(),
+ absl::StatusCode::kInvalidArgument);
+ EXPECT_EQ(ValidateSamplingParams({.min_output_rate = 1,
+ .end_of_stroke_stopping_distance = 5,
+ .end_of_stroke_max_iterations = 0})
+ .code(),
+ absl::StatusCode::kInvalidArgument);
+}
+
+TEST(ParamsTest, ValidateStylusStateModelerParams) {
+ EXPECT_TRUE(ValidateStylusStateModelerParams({.max_input_samples = 1}).ok());
+
+ EXPECT_EQ(ValidateStylusStateModelerParams({.max_input_samples = 0}).code(),
+ absl::StatusCode::kInvalidArgument);
+}
+
+TEST(ParamsTest, ValidateWobbleSmootherParams) {
+ EXPECT_TRUE(
+ ValidateWobbleSmootherParams(
+ {.timeout = Duration(1), .speed_floor = 2, .speed_ceiling = 3})
+ .ok());
+ EXPECT_TRUE(
+ ValidateWobbleSmootherParams(
+ {.timeout = Duration(0), .speed_floor = 0, .speed_ceiling = 0})
+ .ok());
+
+ EXPECT_EQ(ValidateWobbleSmootherParams(
+ {.timeout = Duration(-1), .speed_floor = 2, .speed_ceiling = 5})
+ .code(),
+ absl::StatusCode::kInvalidArgument);
+ EXPECT_EQ(ValidateWobbleSmootherParams(
+ {.timeout = Duration(1), .speed_floor = -2, .speed_ceiling = 1})
+ .code(),
+ absl::StatusCode::kInvalidArgument);
+ EXPECT_EQ(ValidateWobbleSmootherParams(
+ {.timeout = Duration(1), .speed_floor = 7, .speed_ceiling = 4})
+ .code(),
+ absl::StatusCode::kInvalidArgument);
+}
+
+TEST(ParamsTest, ValidateStrokeEndPredictorParams) {
+ EXPECT_TRUE(ValidatePredictionParams(StrokeEndPredictorParams()).ok());
+}
+
+TEST(ParamsTest, ValidateKalmanPredictorParams) {
+ EXPECT_TRUE(ValidatePredictionParams(kGoodKalmanParams).ok());
+ {
+ auto bad_params = kGoodKalmanParams;
+ bad_params.process_noise = 0;
+ EXPECT_EQ(ValidatePredictionParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+ {
+ auto bad_params = kGoodKalmanParams;
+ bad_params.measurement_noise = 0;
+ EXPECT_EQ(ValidatePredictionParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+ {
+ auto bad_params = kGoodKalmanParams;
+ bad_params.min_stable_iteration = 0;
+ EXPECT_EQ(ValidatePredictionParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+ {
+ auto bad_params = kGoodKalmanParams;
+ bad_params.max_time_samples = 0;
+ EXPECT_EQ(ValidatePredictionParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+ {
+ auto bad_params = kGoodKalmanParams;
+ bad_params.prediction_interval = Duration(0);
+ EXPECT_EQ(ValidatePredictionParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+}
+
+TEST(ParamsTest, ValidateKalmanPredictorConfidenceParams) {
+ EXPECT_TRUE(ValidatePredictionParams(kGoodKalmanParams).ok());
+ {
+ auto bad_params = kGoodKalmanParams;
+ bad_params.confidence_params.desired_number_of_samples = 0;
+ EXPECT_EQ(ValidatePredictionParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+ {
+ auto bad_params = kGoodKalmanParams;
+ bad_params.confidence_params.max_estimation_distance = 0;
+ EXPECT_EQ(ValidatePredictionParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+ {
+ auto bad_params = kGoodKalmanParams;
+ bad_params.confidence_params.min_travel_speed = -1;
+ EXPECT_EQ(ValidatePredictionParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+ {
+ auto bad_params = kGoodKalmanParams;
+ bad_params.confidence_params.min_travel_speed = 10;
+ bad_params.confidence_params.max_travel_speed = 1;
+ EXPECT_EQ(ValidatePredictionParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+ {
+ auto bad_params = kGoodKalmanParams;
+ bad_params.confidence_params.max_linear_deviation = 0;
+ EXPECT_EQ(ValidatePredictionParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+ {
+ auto bad_params = kGoodKalmanParams;
+ bad_params.confidence_params.baseline_linearity_confidence = -.3;
+ EXPECT_EQ(ValidatePredictionParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+ {
+ auto bad_params = kGoodKalmanParams;
+ bad_params.confidence_params.baseline_linearity_confidence = 1.01;
+ EXPECT_EQ(ValidatePredictionParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+}
+
+TEST(ParamsTest, ValidateStrokeModelParams) {
+ EXPECT_TRUE(ValidateStrokeModelParams(kGoodStrokeModelParams).ok());
+ {
+ auto bad_params = kGoodStrokeModelParams;
+ bad_params.wobble_smoother_params.timeout = Duration(-10);
+ EXPECT_EQ(ValidateStrokeModelParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+ {
+ auto bad_params = kGoodStrokeModelParams;
+ bad_params.position_modeler_params.spring_mass_constant = -1;
+ EXPECT_EQ(ValidateStrokeModelParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+ {
+ auto bad_params = kGoodStrokeModelParams;
+ bad_params.stylus_state_modeler_params.max_input_samples = 0;
+ EXPECT_EQ(ValidateStrokeModelParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+ {
+ auto bad_params = kGoodStrokeModelParams;
+ bad_params.sampling_params.end_of_stroke_max_iterations = -3;
+ EXPECT_EQ(ValidateStrokeModelParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+ {
+ auto bad_params = kGoodStrokeModelParams;
+ bad_params.prediction_params =
+ KalmanPredictorParams{.prediction_interval = Duration(-1)};
+ EXPECT_EQ(ValidateStrokeModelParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+ }
+}
+
+TEST(ParamsTest, NaNIsNotAValidValue) {
+ auto bad_params = kGoodStrokeModelParams;
+ bad_params.position_modeler_params.spring_mass_constant =
+ std::numeric_limits<float>::quiet_NaN();
+ EXPECT_EQ(ValidateStrokeModelParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+}
+
+TEST(ParamsTest, InfinityIsNotAValidValue) {
+ auto bad_params = kGoodStrokeModelParams;
+ bad_params.position_modeler_params.spring_mass_constant =
+ std::numeric_limits<float>::infinity();
+ EXPECT_EQ(ValidateStrokeModelParams(bad_params).code(),
+ absl::StatusCode::kInvalidArgument);
+}
+
+} // namespace
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/stroke_modeler.cc b/ink_stroke_modeler/stroke_modeler.cc
new file mode 100644
index 0000000..37d17f5
--- /dev/null
+++ b/ink_stroke_modeler/stroke_modeler.cc
@@ -0,0 +1,253 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/stroke_modeler.h"
+
+#include <iterator>
+#include <type_traits>
+#include <vector>
+
+#include "absl/base/attributes.h"
+#include "absl/memory/memory.h"
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/types/optional.h"
+#include "absl/types/variant.h"
+#include "ink_stroke_modeler/internal/internal_types.h"
+#include "ink_stroke_modeler/internal/position_modeler.h"
+#include "ink_stroke_modeler/internal/prediction/input_predictor.h"
+#include "ink_stroke_modeler/internal/prediction/kalman_predictor.h"
+#include "ink_stroke_modeler/internal/prediction/stroke_end_predictor.h"
+#include "ink_stroke_modeler/internal/stylus_state_modeler.h"
+#include "ink_stroke_modeler/params.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+std::vector<Result> ModelStylus(
+ const std::vector<TipState> &tip_states,
+ const StylusStateModeler &stylus_state_modeler) {
+ std::vector<Result> result;
+ result.reserve(tip_states.size());
+ for (const auto &tip_state : tip_states) {
+ auto stylus_state = stylus_state_modeler.Query(tip_state.position);
+ result.push_back({.position = tip_state.position,
+ .velocity = tip_state.velocity,
+ .time = tip_state.time,
+ .pressure = stylus_state.pressure,
+ .tilt = stylus_state.tilt,
+ .orientation = stylus_state.orientation});
+ }
+ return result;
+}
+
+int GetNumberOfSteps(Time start_time, Time end_time, double min_rate) {
+ float float_delta = (end_time - start_time).Value();
+ return std::ceil(float_delta * min_rate);
+}
+
+template <typename>
+ABSL_ATTRIBUTE_UNUSED inline constexpr bool kAlwaysFalse = false;
+
+} // namespace
+
+absl::Status StrokeModeler::Reset(
+ const StrokeModelParams &stroke_model_params) {
+ if (auto status = ValidateStrokeModelParams(stroke_model_params);
+ !status.ok()) {
+ return status;
+ }
+
+ // Note that many of the sub-modelers require some knowledge about the stroke
+ // (e.g. start position, input type) when resetting, and as such are reset in
+ // ProcessTDown() instead.
+ stroke_model_params_ = stroke_model_params;
+ last_input_ = absl::nullopt;
+
+ absl::visit(
+ [this](auto &&params) {
+ using ParamType = std::decay_t<decltype(params)>;
+ if constexpr (std::is_same_v<ParamType, KalmanPredictorParams>) {
+ predictor_ = absl::make_unique<KalmanPredictor>(
+ params, stroke_model_params_->sampling_params);
+ } else if constexpr (std::is_same_v<ParamType,
+ StrokeEndPredictorParams>) {
+ predictor_ = absl::make_unique<StrokeEndPredictor>(
+ stroke_model_params_->position_modeler_params,
+ stroke_model_params_->sampling_params);
+ } else {
+ static_assert(kAlwaysFalse<ParamType>,
+ "Unknown prediction parameter type");
+ }
+ },
+ stroke_model_params_->prediction_params);
+ return absl::OkStatus();
+}
+
+absl::StatusOr<std::vector<Result>> StrokeModeler::Update(const Input &input) {
+ if (!stroke_model_params_.has_value()) {
+ return absl::FailedPreconditionError(
+ "Stroke model has not yet been initialized");
+ }
+
+ if (absl::Status status = ValidateInput(input); !status.ok()) {
+ return status;
+ }
+
+ if (last_input_) {
+ if (last_input_->input == input) {
+ return absl::InvalidArgumentError("Received duplicate input");
+ }
+
+ if (input.time < last_input_->input.time) {
+ return absl::InvalidArgumentError("Inputs travel backwards in time");
+ }
+ }
+
+ switch (input.event_type) {
+ case Input::EventType::kDown:
+ return ProcessDownEvent(input);
+ case Input::EventType::kMove:
+ return ProcessMoveEvent(input);
+ case Input::EventType::kUp:
+ return ProcessUpEvent(input);
+ }
+ return absl::InvalidArgumentError("Invalid EventType.");
+}
+
+absl::StatusOr<std::vector<Result>> StrokeModeler::Predict() const {
+ if (!stroke_model_params_.has_value()) {
+ return absl::FailedPreconditionError(
+ "Stroke model has not yet been initialized");
+ }
+
+ if (last_input_ == std::nullopt) {
+ return absl::FailedPreconditionError(
+ "Cannot construct prediction when no stroke is in-progress");
+ }
+
+ return ModelStylus(
+ predictor_->ConstructPrediction(position_modeler_.CurrentState()),
+ stylus_state_modeler_);
+}
+
+absl::StatusOr<std::vector<Result>> StrokeModeler::ProcessDownEvent(
+ const Input &input) {
+ if (last_input_) {
+ return absl::FailedPreconditionError(
+ "Received down event while stroke is in-progress");
+ }
+
+ // Note that many of the sub-modelers require some knowledge about the stroke
+ // (e.g. start position, input type) when resetting, and as such are reset
+ // here instead of in Reset().
+ wobble_smoother_.Reset(stroke_model_params_->wobble_smoother_params,
+ input.position, input.time);
+ position_modeler_.Reset({input.position, {0, 0}, input.time},
+ stroke_model_params_->position_modeler_params);
+ stylus_state_modeler_.Reset(
+ stroke_model_params_->stylus_state_modeler_params);
+ stylus_state_modeler_.Update(input.position,
+ {.pressure = input.pressure,
+ .tilt = input.tilt,
+ .orientation = input.orientation});
+
+ const TipState &tip_state = position_modeler_.CurrentState();
+ predictor_->Reset();
+ predictor_->Update(input.position, input.time);
+
+ // We don't correct the position on the down event, so we set
+ // corrected_position to use the input position.
+ last_input_ = {.input = input, .corrected_position = input.position};
+ return {{{.position = tip_state.position,
+ .velocity = tip_state.velocity,
+ .time = tip_state.time,
+ .pressure = input.pressure,
+ .tilt = input.tilt,
+ .orientation = input.orientation}}};
+}
+
+absl::StatusOr<std::vector<Result>> StrokeModeler::ProcessUpEvent(
+ const Input &input) {
+ if (!last_input_) {
+ return absl::FailedPreconditionError(
+ "Received up event while no stroke is in-progress");
+ }
+
+ int n_steps =
+ GetNumberOfSteps(last_input_->input.time, input.time,
+ stroke_model_params_->sampling_params.min_output_rate);
+ std::vector<TipState> tip_states;
+ tip_states.reserve(
+ n_steps +
+ stroke_model_params_->sampling_params.end_of_stroke_max_iterations);
+ position_modeler_.UpdateAlongLinearPath(
+ last_input_->corrected_position, last_input_->input.time, input.position,
+ input.time, n_steps, std::back_inserter(tip_states));
+
+ position_modeler_.ModelEndOfStroke(
+ input.position,
+ Duration(1. / stroke_model_params_->sampling_params.min_output_rate),
+ stroke_model_params_->sampling_params.end_of_stroke_max_iterations,
+ stroke_model_params_->sampling_params.end_of_stroke_stopping_distance,
+ std::back_inserter(tip_states));
+
+ if (tip_states.empty()) {
+ // If we haven't generated any new states, add the current state. This can
+ // happen if the TUp has the same timestamp as the last in-contact input.
+ tip_states.push_back(position_modeler_.CurrentState());
+ }
+
+ stylus_state_modeler_.Update(input.position,
+ {.pressure = input.pressure,
+ .tilt = input.tilt,
+ .orientation = input.orientation});
+
+ // This indicates that we've finished the stroke.
+ last_input_ = absl::nullopt;
+
+ return ModelStylus(tip_states, stylus_state_modeler_);
+}
+
+absl::StatusOr<std::vector<Result>> StrokeModeler::ProcessMoveEvent(
+ const Input &input) {
+ if (!last_input_) {
+ return absl::FailedPreconditionError(
+ "Received move event while no stroke is in-progress");
+ }
+
+ Vec2 corrected_position = wobble_smoother_.Update(input.position, input.time);
+ stylus_state_modeler_.Update(corrected_position,
+ {.pressure = input.pressure,
+ .tilt = input.tilt,
+ .orientation = input.orientation});
+
+ int n_steps =
+ GetNumberOfSteps(last_input_->input.time, input.time,
+ stroke_model_params_->sampling_params.min_output_rate);
+ std::vector<TipState> tip_states;
+ tip_states.reserve(n_steps);
+ position_modeler_.UpdateAlongLinearPath(
+ last_input_->corrected_position, last_input_->input.time,
+ corrected_position, input.time, n_steps, std::back_inserter(tip_states));
+
+ predictor_->Update(corrected_position, input.time);
+ last_input_ = {.input = input, .corrected_position = corrected_position};
+ return ModelStylus(tip_states, stylus_state_modeler_);
+}
+
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/stroke_modeler.h b/ink_stroke_modeler/stroke_modeler.h
new file mode 100644
index 0000000..a25b971
--- /dev/null
+++ b/ink_stroke_modeler/stroke_modeler.h
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_STROKE_MODELER_H_
+#define INK_STROKE_MODELER_STROKE_MODELER_H_
+
+#include <memory>
+#include <vector>
+
+#include "absl/status/status.h"
+#include "absl/status/statusor.h"
+#include "absl/types/optional.h"
+#include "ink_stroke_modeler/internal/position_modeler.h"
+#include "ink_stroke_modeler/internal/prediction/input_predictor.h"
+#include "ink_stroke_modeler/internal/stylus_state_modeler.h"
+#include "ink_stroke_modeler/internal/wobble_smoother.h"
+#include "ink_stroke_modeler/params.h"
+#include "ink_stroke_modeler/types.h"
+
+namespace ink {
+namespace stroke_model {
+
+// This class models a stroke from a raw input stream. The modeling is performed
+// in several stages, which are delegated to component classes:
+// - Wobble Smoothing: Dampens high-frequency noise from quantization error.
+// - Position Modeling: Models the pen tip as a mass, connected by a spring, to
+// a moving anchor.
+// - Stylus State Modeling: Constructs stylus states for modeled positions by
+// interpolating over the raw input.
+//
+// Additionally, this class provides prediction of the modeled stroke.
+//
+// StrokeModeler is completely unit-agnostic. That is, it doesn't matter what
+// units or coordinate-system the input is given in; the output will be given in
+// the same coordinate-system and units.
+class StrokeModeler {
+ public:
+ // Clears any in-progress stroke, and initializes (or re-initializes) the
+ // model with the given parameters. Returns an error if the parameters are
+ // invalid.
+ absl::Status Reset(const StrokeModelParams &stroke_model_params);
+
+ // Updates the model with a raw input, returning the generated results. Any
+ // previously generated results are stable, i.e. any previously returned
+ // Results are still valid.
+ //
+ // Returns an error if the the model has not yet been initialized (via Reset)
+ // or if the input stream is malformed (e.g decreasing time, Up event before
+ // Down event).
+ //
+ // If this does not return an error, the result will contain at least one
+ // Result, and potentially more than one if the inputs are slower than
+ // the minimum output rate.
+ absl::StatusOr<std::vector<Result>> Update(const Input &input);
+
+ // Model the given input prediction without changing the internal model state.
+ //
+ // Returns an error if the the model has not yet been initialized (via Reset),
+ // or if there is no stroke in progress. The output is limited to results
+ // where the predictor has sufficient confidence,
+ absl::StatusOr<std::vector<Result>> Predict() const;
+
+ private:
+ absl::StatusOr<std::vector<Result>> ProcessDownEvent(const Input &input);
+ absl::StatusOr<std::vector<Result>> ProcessMoveEvent(const Input &input);
+ absl::StatusOr<std::vector<Result>> ProcessUpEvent(const Input &input);
+
+ std::unique_ptr<InputPredictor> predictor_;
+
+ absl::optional<StrokeModelParams> stroke_model_params_;
+
+ WobbleSmoother wobble_smoother_;
+ PositionModeler position_modeler_;
+ StylusStateModeler stylus_state_modeler_;
+
+ struct InputAndCorrectedPosition {
+ Input input;
+ Vec2 corrected_position{0};
+ };
+ absl::optional<InputAndCorrectedPosition> last_input_;
+};
+
+} // namespace stroke_model
+} // namespace ink
+
+#endif // INK_STROKE_MODELER_STROKE_MODELER_H_
diff --git a/ink_stroke_modeler/stroke_modeler_test.cc b/ink_stroke_modeler/stroke_modeler_test.cc
new file mode 100644
index 0000000..00f1dba
--- /dev/null
+++ b/ink_stroke_modeler/stroke_modeler_test.cc
@@ -0,0 +1,1395 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/stroke_modeler.h"
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "absl/status/status.h"
+#include "ink_stroke_modeler/internal/type_matchers.h"
+#include "ink_stroke_modeler/params.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+using ::testing::DoubleNear;
+using ::testing::ElementsAre;
+using ::testing::FloatNear;
+using ::testing::IsEmpty;
+using ::testing::Matches;
+using ::testing::Not;
+
+constexpr float kTol = 1e-4;
+
+// These parameters use cm for distance and seconds for time.
+const StrokeModelParams kDefaultParams{
+ .wobble_smoother_params{
+ .timeout = Duration(.04), .speed_floor = 1.31, .speed_ceiling = 1.44},
+ .position_modeler_params{.spring_mass_constant = 11.f / 32400,
+ .drag_constant = 72.f},
+ .sampling_params{.min_output_rate = 180,
+ .end_of_stroke_stopping_distance = .001,
+ .end_of_stroke_max_iterations = 20},
+ .stylus_state_modeler_params{.max_input_samples = 20},
+ .prediction_params = StrokeEndPredictorParams()};
+
+MATCHER_P2(ResultNearMatcher, expected, tolerance, "") {
+ if (Matches(Vec2Near(expected.position, tolerance))(arg.position) &&
+ Matches(Vec2Near(expected.velocity, tolerance))(arg.velocity) &&
+ Matches(DoubleNear(expected.time.Value(), tolerance))(arg.time.Value()) &&
+ Matches(FloatNear(expected.pressure, tolerance))(arg.pressure) &&
+ Matches(FloatNear(expected.tilt, tolerance))(arg.tilt) &&
+ Matches(FloatNear(expected.orientation, tolerance))(arg.orientation)) {
+ return true;
+ }
+
+ return false;
+}
+
+::testing::Matcher<Result> ResultNear(const Result &expected, float tolerance) {
+ return ResultNearMatcher(expected, tolerance);
+}
+
+TEST(StrokeModelerTest, NoPredictionUponInit) {
+ StrokeModeler modeler;
+ ASSERT_TRUE(modeler.Reset(kDefaultParams).ok());
+ EXPECT_EQ(modeler.Predict().status().code(),
+ absl::StatusCode::kFailedPrecondition);
+}
+
+TEST(StrokeModelerTest, InputRateSlowerThanMinOutputRate) {
+ const Duration kDeltaTime{1. / 30};
+
+ StrokeModeler modeler;
+ ASSERT_TRUE(modeler.Reset(kDefaultParams).ok());
+
+ Time time{0};
+ absl::StatusOr<std::vector<Result>> results =
+ modeler.Update({.event_type = Input::EventType::kDown,
+ .position = {3, 4},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(
+ *results,
+ ElementsAre(ResultNear(
+ {.position = {3, 4}, .velocity = {0, 0}, .time = Time(0)}, kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, IsEmpty());
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {3.2, 4.2},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {3.0019, 4.0019},
+ .velocity = {0.4007, 0.4007},
+ .time = Time(0.0048)},
+ kTol),
+ ResultNear({.position = {3.0069, 4.0069},
+ .velocity = {1.0381, 1.0381},
+ .time = Time(0.0095)},
+ kTol),
+ ResultNear({.position = {3.0154, 4.0154},
+ .velocity = {1.7883, 1.7883},
+ .time = Time(0.0143)},
+ kTol),
+ ResultNear({.position = {3.0276, 4.0276},
+ .velocity = {2.5626, 2.5626},
+ .time = Time(0.0190)},
+ kTol),
+ ResultNear({.position = {3.0433, 4.0433},
+ .velocity = {3.3010, 3.3010},
+ .time = Time(0.0238)},
+ kTol),
+ ResultNear({.position = {3.0622, 4.0622},
+ .velocity = {3.9665, 3.9665},
+ .time = Time(0.0286)},
+ kTol),
+ ResultNear({.position = {3.0838, 4.0838},
+ .velocity = {4.5397, 4.5397},
+ .time = Time(0.0333)},
+ kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {3.1095, 4.1095},
+ .velocity = {4.6253, 4.6253},
+ .time = Time(0.0389)},
+ kTol),
+ ResultNear({.position = {3.1331, 4.1331},
+ .velocity = {4.2563, 4.2563},
+ .time = Time(0.0444)},
+ kTol),
+ ResultNear({.position = {3.1534, 4.1534},
+ .velocity = {3.6479, 3.6479},
+ .time = Time(0.0500)},
+ kTol),
+ ResultNear({.position = {3.1698, 4.1698},
+ .velocity = {2.9512, 2.9512},
+ .time = Time(0.0556)},
+ kTol),
+ ResultNear({.position = {3.1824, 4.1824},
+ .velocity = {2.2649, 2.2649},
+ .time = Time(0.0611)},
+ kTol),
+ ResultNear({.position = {3.1915, 4.1915},
+ .velocity = {1.6473, 1.6473},
+ .time = Time(0.0667)},
+ kTol),
+ ResultNear({.position = {3.1978, 4.1978},
+ .velocity = {1.1269, 1.1269},
+ .time = Time(0.0722)},
+ kTol),
+ ResultNear({.position = {3.1992, 4.1992},
+ .velocity = {1.0232, 1.0232},
+ .time = Time(0.0736)},
+ kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {3.5, 4.2},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {3.1086, 4.1058},
+ .velocity = {5.2142, 4.6131},
+ .time = Time(0.0381)},
+ kTol),
+ ResultNear({.position = {3.1368, 4.1265},
+ .velocity = {5.9103, 4.3532},
+ .time = Time(0.0429)},
+ kTol),
+ ResultNear({.position = {3.1681, 4.1450},
+ .velocity = {6.5742, 3.8917},
+ .time = Time(0.0476)},
+ kTol),
+ ResultNear({.position = {3.2022, 4.1609},
+ .velocity = {7.1724, 3.3285},
+ .time = Time(0.0524)},
+ kTol),
+ ResultNear({.position = {3.2388, 4.1739},
+ .velocity = {7.6876, 2.7361},
+ .time = Time(0.0571)},
+ kTol),
+ ResultNear({.position = {3.2775, 4.1842},
+ .velocity = {8.1138, 2.1640},
+ .time = Time(0.0619)},
+ kTol),
+ ResultNear({.position = {3.3177, 4.1920},
+ .velocity = {8.4531, 1.6436},
+ .time = Time(0.0667)},
+ kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {3.3625, 4.1982},
+ .velocity = {8.0545, 1.1165},
+ .time = Time(0.0722)},
+ kTol),
+ ResultNear({.position = {3.4018, 4.2021},
+ .velocity = {7.0831, 0.6987},
+ .time = Time(0.0778)},
+ kTol),
+ ResultNear({.position = {3.4344, 4.2043},
+ .velocity = {5.8564, 0.3846},
+ .time = Time(0.0833)},
+ kTol),
+ ResultNear({.position = {3.4598, 4.2052},
+ .velocity = {4.5880, 0.1611},
+ .time = Time(0.0889)},
+ kTol),
+ ResultNear({.position = {3.4788, 4.2052},
+ .velocity = {3.4098, 0.0124},
+ .time = Time(0.0944)},
+ kTol),
+ ResultNear({.position = {3.4921, 4.2048},
+ .velocity = {2.3929, -0.0780},
+ .time = Time(0.1000)},
+ kTol),
+ ResultNear({.position = {3.4976, 4.2045},
+ .velocity = {1.9791, -0.1015},
+ .time = Time(0.1028)},
+ kTol),
+ ResultNear({.position = {3.5001, 4.2044},
+ .velocity = {1.7911, -0.1098},
+ .time = Time(0.1042)},
+ kTol)));
+
+ time += kDeltaTime;
+ // We get more results at the end of the stroke as it tries to "catch up" to
+ // the raw input.
+ results = modeler.Update({.event_type = Input::EventType::kUp,
+ .position = {3.7, 4.4},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {3.3583, 4.1996},
+ .velocity = {8.5122, 1.5925},
+ .time = Time(0.0714)},
+ kTol),
+ ResultNear({.position = {3.3982, 4.2084},
+ .velocity = {8.3832, 1.8534},
+ .time = Time(0.0762)},
+ kTol),
+ ResultNear({.position = {3.4369, 4.2194},
+ .velocity = {8.1393, 2.3017},
+ .time = Time(0.0810)},
+ kTol),
+ ResultNear({.position = {3.4743, 4.2329},
+ .velocity = {7.8362, 2.8434},
+ .time = Time(0.0857)},
+ kTol),
+ ResultNear({.position = {3.5100, 4.2492},
+ .velocity = {7.5143, 3.4101},
+ .time = Time(0.0905)},
+ kTol),
+ ResultNear({.position = {3.5443, 4.2680},
+ .velocity = {7.2016, 3.9556},
+ .time = Time(0.0952)},
+ kTol),
+ ResultNear({.position = {3.5773, 4.2892},
+ .velocity = {6.9159, 4.4505},
+ .time = Time(0.1000)},
+ kTol),
+ ResultNear({.position = {3.6115, 4.3141},
+ .velocity = {6.1580, 4.4832},
+ .time = Time(0.1056)},
+ kTol),
+ ResultNear({.position = {3.6400, 4.3369},
+ .velocity = {5.1434, 4.0953},
+ .time = Time(0.1111)},
+ kTol),
+ ResultNear({.position = {3.6626, 4.3563},
+ .velocity = {4.0671, 3.4902},
+ .time = Time(0.1167)},
+ kTol),
+ ResultNear({.position = {3.6796, 4.3719},
+ .velocity = {3.0515, 2.8099},
+ .time = Time(0.1222)},
+ kTol),
+ ResultNear({.position = {3.6916, 4.3838},
+ .velocity = {2.1648, 2.1462},
+ .time = Time(0.1278)},
+ kTol),
+ ResultNear({.position = {3.6996, 4.3924},
+ .velocity = {1.4360, 1.5529},
+ .time = Time(0.1333)},
+ kTol),
+ ResultNear({.position = {3.7028, 4.3960},
+ .velocity = {1.1520, 1.3044},
+ .time = Time(0.1361)},
+ kTol)));
+
+ // The stroke is finished, so there's nothing to predict anymore.
+ EXPECT_EQ(modeler.Predict().status().code(),
+ absl::StatusCode::kFailedPrecondition);
+}
+
+TEST(StrokeModelerTest, InputRateFasterThanMinOutputRate) {
+ const Duration kDeltaTime{1. / 300};
+
+ StrokeModeler modeler;
+ ASSERT_TRUE(modeler.Reset(kDefaultParams).ok());
+
+ Time time{2};
+ absl::StatusOr<std::vector<Result>> results =
+ modeler.Update({.event_type = Input::EventType::kDown,
+ .position = {5, -3},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(
+ *results,
+ ElementsAre(ResultNear(
+ {.position = {5, -3}, .velocity = {0, 0}, .time = Time(2)}, kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, IsEmpty());
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {5, -3.1},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {5, -3.0033},
+ .velocity = {0, -0.9818},
+ .time = Time(2.0033)},
+ kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {5, -3.0153},
+ .velocity = {0, -2.1719},
+ .time = Time(2.0089)},
+ kTol),
+ ResultNear({.position = {5, -3.0303},
+ .velocity = {0, -2.6885},
+ .time = Time(2.0144)},
+ kTol),
+ ResultNear({.position = {5, -3.0456},
+ .velocity = {0, -2.7541},
+ .time = Time(2.0200)},
+ kTol),
+ ResultNear({.position = {5, -3.0597},
+ .velocity = {0, -2.5430},
+ .time = Time(2.0256)},
+ kTol),
+ ResultNear({.position = {5, -3.0718},
+ .velocity = {0, -2.1852},
+ .time = Time(2.0311)},
+ kTol),
+ ResultNear({.position = {5, -3.0817},
+ .velocity = {0, -1.7719},
+ .time = Time(2.0367)},
+ kTol),
+ ResultNear({.position = {5, -3.0893},
+ .velocity = {0, -1.3628},
+ .time = Time(2.0422)},
+ kTol),
+ ResultNear({.position = {5, -3.0948},
+ .velocity = {0, -0.9934},
+ .time = Time(2.0478)},
+ kTol),
+ ResultNear({.position = {5, -3.0986},
+ .velocity = {0, -0.6815},
+ .time = Time(2.0533)},
+ kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {4.975, -3.175},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.9992, -3.0114},
+ .velocity = {-0.2455, -2.4322},
+ .time = Time(2.0067)},
+ kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.9962, -3.0344},
+ .velocity = {-0.5430, -4.1368},
+ .time = Time(2.0122)},
+ kTol),
+ ResultNear({.position = {4.9924, -3.0609},
+ .velocity = {-0.6721, -4.7834},
+ .time = Time(2.0178)},
+ kTol),
+ ResultNear({.position = {4.9886, -3.0873},
+ .velocity = {-0.6885, -4.7365},
+ .time = Time(2.0233)},
+ kTol),
+ ResultNear({.position = {4.9851, -3.1110},
+ .velocity = {-0.6358, -4.2778},
+ .time = Time(2.0289)},
+ kTol),
+ ResultNear({.position = {4.9820, -3.1311},
+ .velocity = {-0.5463, -3.6137},
+ .time = Time(2.0344)},
+ kTol),
+ ResultNear({.position = {4.9796, -3.1471},
+ .velocity = {-0.4430, -2.8867},
+ .time = Time(2.0400)},
+ kTol),
+ ResultNear({.position = {4.9777, -3.1593},
+ .velocity = {-0.3407, -2.1881},
+ .time = Time(2.0456)},
+ kTol),
+ ResultNear({.position = {4.9763, -3.1680},
+ .velocity = {-0.2484, -1.5700},
+ .time = Time(2.0511)},
+ kTol),
+ ResultNear({.position = {4.9754, -3.1739},
+ .velocity = {-0.1704, -1.0564},
+ .time = Time(2.0567)},
+ kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {4.9, -3.2},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.9953, -3.0237},
+ .velocity = {-1.1603, -3.7004},
+ .time = Time(2.0100)},
+ kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.9828, -3.0521},
+ .velocity = {-2.2559, -5.1049},
+ .time = Time(2.0156)},
+ kTol),
+ ResultNear({.position = {4.9677, -3.0825},
+ .velocity = {-2.7081, -5.4835},
+ .time = Time(2.0211)},
+ kTol),
+ ResultNear({.position = {4.9526, -3.1115},
+ .velocity = {-2.7333, -5.2122},
+ .time = Time(2.0267)},
+ kTol),
+ ResultNear({.position = {4.9387, -3.1369},
+ .velocity = {-2.4999, -4.5756},
+ .time = Time(2.0322)},
+ kTol),
+ ResultNear({.position = {4.9268, -3.1579},
+ .velocity = {-2.1326, -3.7776},
+ .time = Time(2.0378)},
+ kTol),
+ ResultNear({.position = {4.9173, -3.1743},
+ .velocity = {-1.7184, -2.9554},
+ .time = Time(2.0433)},
+ kTol),
+ ResultNear({.position = {4.9100, -3.1865},
+ .velocity = {-1.3136, -2.1935},
+ .time = Time(2.0489)},
+ kTol),
+ ResultNear({.position = {4.9047, -3.1950},
+ .velocity = {-0.9513, -1.5369},
+ .time = Time(2.0544)},
+ kTol),
+ ResultNear({.position = {4.9011, -3.2006},
+ .velocity = {-0.6475, -1.0032},
+ .time = Time(2.0600)},
+ kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {4.825, -3.2},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.9868, -3.0389},
+ .velocity = {-2.5540, -4.5431},
+ .time = Time(2.0133)},
+ kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.9636, -3.0687},
+ .velocity = {-4.1801, -5.3627},
+ .time = Time(2.0189)},
+ kTol),
+ ResultNear({.position = {4.9370, -3.0985},
+ .velocity = {-4.7757, -5.3670},
+ .time = Time(2.0244)},
+ kTol),
+ ResultNear({.position = {4.9109, -3.1256},
+ .velocity = {-4.6989, -4.8816},
+ .time = Time(2.0300)},
+ kTol),
+ ResultNear({.position = {4.8875, -3.1486},
+ .velocity = {-4.2257, -4.1466},
+ .time = Time(2.0356)},
+ kTol),
+ ResultNear({.position = {4.8677, -3.1671},
+ .velocity = {-3.5576, -3.3287},
+ .time = Time(2.0411)},
+ kTol),
+ ResultNear({.position = {4.8520, -3.1812},
+ .velocity = {-2.8333, -2.5353},
+ .time = Time(2.0467)},
+ kTol),
+ ResultNear({.position = {4.8401, -3.1914},
+ .velocity = {-2.1411, -1.8288},
+ .time = Time(2.0522)},
+ kTol),
+ ResultNear({.position = {4.8316, -3.1982},
+ .velocity = {-1.5312, -1.2386},
+ .time = Time(2.0578)},
+ kTol),
+ ResultNear({.position = {4.8280, -3.2010},
+ .velocity = {-1.2786, -1.0053},
+ .time = Time(2.0606)},
+ kTol),
+ ResultNear({.position = {4.8272, -3.2017},
+ .velocity = {-1.2209, -0.9529},
+ .time = Time(2.0613)},
+ kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {4.75, -3.225},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.9726, -3.0565},
+ .velocity = {-4.2660, -5.2803},
+ .time = Time(2.0167)},
+ kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.9381, -3.0894},
+ .velocity = {-6.2018, -5.9261},
+ .time = Time(2.0222)},
+ kTol),
+ ResultNear({.position = {4.9004, -3.1215},
+ .velocity = {-6.7995, -5.7749},
+ .time = Time(2.0278)},
+ kTol),
+ ResultNear({.position = {4.8640, -3.1501},
+ .velocity = {-6.5400, -5.1591},
+ .time = Time(2.0333)},
+ kTol),
+ ResultNear({.position = {4.8319, -3.1741},
+ .velocity = {-5.7897, -4.3207},
+ .time = Time(2.0389)},
+ kTol),
+ ResultNear({.position = {4.8051, -3.1932},
+ .velocity = {-4.8133, -3.4248},
+ .time = Time(2.0444)},
+ kTol),
+ ResultNear({.position = {4.7841, -3.2075},
+ .velocity = {-3.7898, -2.5759},
+ .time = Time(2.0500)},
+ kTol),
+ ResultNear({.position = {4.7683, -3.2176},
+ .velocity = {-2.8312, -1.8324},
+ .time = Time(2.0556)},
+ kTol),
+ ResultNear({.position = {4.7572, -3.2244},
+ .velocity = {-1.9986, -1.2198},
+ .time = Time(2.0611)},
+ kTol),
+ ResultNear({.position = {4.7526, -3.2271},
+ .velocity = {-1.6580, -0.9805},
+ .time = Time(2.0639)},
+ kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {4.7, -3.3},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.9529, -3.0778},
+ .velocity = {-5.9184, -6.4042},
+ .time = Time(2.0200)},
+ kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.9101, -3.1194},
+ .velocity = {-7.6886, -7.4784},
+ .time = Time(2.0256)},
+ kTol),
+ ResultNear({.position = {4.8654, -3.1607},
+ .velocity = {-8.0518, -7.4431},
+ .time = Time(2.0311)},
+ kTol),
+ ResultNear({.position = {4.8235, -3.1982},
+ .velocity = {-7.5377, -6.7452},
+ .time = Time(2.0367)},
+ kTol),
+ ResultNear({.position = {4.7872, -3.2299},
+ .velocity = {-6.5440, -5.7133},
+ .time = Time(2.0422)},
+ kTol),
+ ResultNear({.position = {4.7574, -3.2553},
+ .velocity = {-5.3529, -4.5748},
+ .time = Time(2.0478)},
+ kTol),
+ ResultNear({.position = {4.7344, -3.2746},
+ .velocity = {-4.1516, -3.4758},
+ .time = Time(2.0533)},
+ kTol),
+ ResultNear({.position = {4.7174, -3.2885},
+ .velocity = {-3.0534, -2.5004},
+ .time = Time(2.0589)},
+ kTol),
+ ResultNear({.position = {4.7056, -3.2979},
+ .velocity = {-2.1169, -1.6879},
+ .time = Time(2.0644)},
+ kTol),
+ ResultNear({.position = {4.7030, -3.3000},
+ .velocity = {-1.9283, -1.5276},
+ .time = Time(2.0658)},
+ kTol),
+ ResultNear({.position = {4.7017, -3.3010},
+ .velocity = {-1.8380, -1.4512},
+ .time = Time(2.0665)},
+ kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {4.675, -3.4},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.9288, -3.1046},
+ .velocity = {-7.2260, -8.0305},
+ .time = Time(2.0233)},
+ kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.8816, -3.1582},
+ .velocity = {-8.4881, -9.6525},
+ .time = Time(2.0289)},
+ kTol),
+ ResultNear({.position = {4.8345, -3.2124},
+ .velocity = {-8.4738, -9.7482},
+ .time = Time(2.0344)},
+ kTol),
+ ResultNear({.position = {4.7918, -3.2619},
+ .velocity = {-7.6948, -8.9195},
+ .time = Time(2.0400)},
+ kTol),
+ ResultNear({.position = {4.7555, -3.3042},
+ .velocity = {-6.5279, -7.6113},
+ .time = Time(2.0456)},
+ kTol),
+ ResultNear({.position = {4.7264, -3.3383},
+ .velocity = {-5.2343, -6.1345},
+ .time = Time(2.0511)},
+ kTol),
+ ResultNear({.position = {4.7043, -3.3643},
+ .velocity = {-3.9823, -4.6907},
+ .time = Time(2.0567)},
+ kTol),
+ ResultNear({.position = {4.6884, -3.3832},
+ .velocity = {-2.8691, -3.3980},
+ .time = Time(2.0622)},
+ kTol),
+ ResultNear({.position = {4.6776, -3.3961},
+ .velocity = {-1.9403, -2.3135},
+ .time = Time(2.0678)},
+ kTol),
+ ResultNear({.position = {4.6752, -3.3990},
+ .velocity = {-1.7569, -2.0983},
+ .time = Time(2.0692)},
+ kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {4.675, -3.525},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.9022, -3.1387},
+ .velocity = {-7.9833, -10.2310},
+ .time = Time(2.0267)},
+ kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.8549, -3.2079},
+ .velocity = {-8.5070, -12.4602},
+ .time = Time(2.0322)},
+ kTol),
+ ResultNear({.position = {4.8102, -3.2783},
+ .velocity = {-8.0479, -12.6650},
+ .time = Time(2.0378)},
+ kTol),
+ ResultNear({.position = {4.7711, -3.3429},
+ .velocity = {-7.0408, -11.6365},
+ .time = Time(2.0433)},
+ kTol),
+ ResultNear({.position = {4.7389, -3.3983},
+ .velocity = {-5.7965, -9.9616},
+ .time = Time(2.0489)},
+ kTol),
+ ResultNear({.position = {4.7137, -3.4430},
+ .velocity = {-4.5230, -8.0510},
+ .time = Time(2.0544)},
+ kTol),
+ ResultNear({.position = {4.6951, -3.4773},
+ .velocity = {-3.3477, -6.1727},
+ .time = Time(2.0600)},
+ kTol),
+ ResultNear({.position = {4.6821, -3.5022},
+ .velocity = {-2.3381, -4.4846},
+ .time = Time(2.0656)},
+ kTol),
+ ResultNear({.position = {4.6737, -3.5192},
+ .velocity = {-1.5199, -3.0641},
+ .time = Time(2.0711)},
+ kTol),
+ ResultNear({.position = {4.6718, -3.5231},
+ .velocity = {-1.3626, -2.7813},
+ .time = Time(2.0725)},
+ kTol)));
+
+ time += kDeltaTime;
+ // We get more results at the end of the stroke as it tries to "catch up" to
+ // the raw input.
+ results = modeler.Update({.event_type = Input::EventType::kUp,
+ .position = {4.7, -3.6},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {4.8753, -3.1797},
+ .velocity = {-8.0521, -12.3049},
+ .time = Time(2.0300)},
+ kTol),
+ ResultNear({.position = {4.8325, -3.2589},
+ .velocity = {-7.7000, -14.2607},
+ .time = Time(2.0356)},
+ kTol),
+ ResultNear({.position = {4.7948, -3.3375},
+ .velocity = {-6.7888, -14.1377},
+ .time = Time(2.0411)},
+ kTol),
+ ResultNear({.position = {4.7636, -3.4085},
+ .velocity = {-5.6249, -12.7787},
+ .time = Time(2.0467)},
+ kTol),
+ ResultNear({.position = {4.7390, -3.4685},
+ .velocity = {-4.4152, -10.8015},
+ .time = Time(2.0522)},
+ kTol),
+ ResultNear({.position = {4.7208, -3.5164},
+ .velocity = {-3.2880, -8.6333},
+ .time = Time(2.0578)},
+ kTol),
+ ResultNear({.position = {4.7079, -3.5528},
+ .velocity = {-2.3128, -6.5475},
+ .time = Time(2.0633)},
+ kTol),
+ ResultNear({.position = {4.6995, -3.5789},
+ .velocity = {-1.5174, -4.7008},
+ .time = Time(2.0689)},
+ kTol),
+ ResultNear({.position = {4.6945, -3.5965},
+ .velocity = {-0.9022, -3.1655},
+ .time = Time(2.0744)},
+ kTol),
+ ResultNear({.position = {4.6942, -3.5976},
+ .velocity = {-0.8740, -3.0899},
+ .time = Time(2.0748)},
+ kTol)));
+
+ // The stroke is finished, so there's nothing to predict anymore.
+ EXPECT_EQ(modeler.Predict().status().code(),
+ absl::StatusCode::kFailedPrecondition);
+}
+
+TEST(StrokeModelerTest, WobbleSmoothed) {
+ const Duration kDeltaTime{.0167};
+
+ StrokeModeler modeler;
+ ASSERT_TRUE(modeler.Reset(kDefaultParams).ok());
+
+ Time time{4};
+ absl::StatusOr<std::vector<Result>> results =
+ modeler.Update({.event_type = Input::EventType::kDown,
+ .position = {-6, -2},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(
+ *results,
+ ElementsAre(ResultNear(
+ {.position = {-6, -2}, .velocity = {0, 0}, .time = Time(4)}, kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {-6.02, -2},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {-6.0001, -2},
+ .velocity = {-0.0328, 0},
+ .time = Time(4.0042)},
+ kTol),
+ ResultNear({.position = {-6.0005, -2},
+ .velocity = {-0.0869, 0},
+ .time = Time(4.0084)},
+ kTol),
+ ResultNear({.position = {-6.0011, -2},
+ .velocity = {-0.1531, 0},
+ .time = Time(4.0125)},
+ kTol),
+ ResultNear({.position = {-6.0021, -2},
+ .velocity = {-0.2244, 0},
+ .time = Time(4.0167)},
+ kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {-6.02, -2.02},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {-6.0032, -2.0001},
+ .velocity = {-0.2709, -0.0205},
+ .time = Time(4.0209)},
+ kTol),
+ ResultNear({.position = {-6.0044, -2.0003},
+ .velocity = {-0.2977, -0.0543},
+ .time = Time(4.0251)},
+ kTol),
+ ResultNear({.position = {-6.0057, -2.0007},
+ .velocity = {-0.3093, -0.0956},
+ .time = Time(4.0292)},
+ kTol),
+ ResultNear({.position = {-6.0070, -2.0013},
+ .velocity = {-0.3097, -0.1401},
+ .time = Time(4.0334)},
+ kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {-6.04, -2.02},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {-6.0084, -2.0021},
+ .velocity = {-0.3350, -0.1845},
+ .time = Time(4.0376)},
+ kTol),
+ ResultNear({.position = {-6.0100, -2.0030},
+ .velocity = {-0.3766, -0.2266},
+ .time = Time(4.0418)},
+ kTol),
+ ResultNear({.position = {-6.0118, -2.0041},
+ .velocity = {-0.4273, -0.2649},
+ .time = Time(4.0459)},
+ kTol),
+ ResultNear({.position = {-6.0138, -2.0054},
+ .velocity = {-0.4818, -0.2986},
+ .time = Time(4.0501)},
+ kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {-6.04, -2.04},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({.position = {-6.0160, -2.0068},
+ .velocity = {-0.5157, -0.3478},
+ .time = Time(4.0543)},
+ kTol),
+ ResultNear({.position = {-6.0182, -2.0085},
+ .velocity = {-0.5334, -0.4054},
+ .time = Time(4.0585)},
+ kTol),
+ ResultNear({.position = {-6.0204, -2.0105},
+ .velocity = {-0.5389, -0.4658},
+ .time = Time(4.0626)},
+ kTol),
+ ResultNear({.position = {-6.0227, -2.0126},
+ .velocity = {-0.5356, -0.5251},
+ .time = Time(4.0668)},
+ kTol)));
+}
+
+TEST(StrokeModelerTest, Reset) {
+ const Duration kDeltaTime{1. / 50};
+
+ StrokeModeler modeler;
+ ASSERT_TRUE(modeler.Reset(kDefaultParams).ok());
+
+ Time time{0};
+ absl::StatusOr<std::vector<Result>> results =
+ modeler.Update({.event_type = Input::EventType::kDown,
+ .position = {-8, -10},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, Not(IsEmpty()));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, IsEmpty());
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {-10, -8},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, Not(IsEmpty()));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, Not(IsEmpty()));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {-11, -5},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, Not(IsEmpty()));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, Not(IsEmpty()));
+
+ ASSERT_TRUE(modeler.Reset(kDefaultParams).ok());
+ EXPECT_EQ(modeler.Predict().status().code(),
+ absl::StatusCode::kFailedPrecondition);
+}
+
+TEST(StrokeModelerTest, IgnoreInputsBeforeTDown) {
+ StrokeModeler modeler;
+ ASSERT_TRUE(modeler.Reset(kDefaultParams).ok());
+
+ EXPECT_EQ(modeler
+ .Update({.event_type = Input::EventType::kMove,
+ .position = {0, 0},
+ .time = Time(0)})
+ .status()
+ .code(),
+ absl::StatusCode::kFailedPrecondition);
+
+ EXPECT_EQ(modeler
+ .Update({.event_type = Input::EventType::kUp,
+ .position = {0, 0},
+ .time = Time(1)})
+ .status()
+ .code(),
+ absl::StatusCode::kFailedPrecondition);
+}
+
+TEST(StrokeModelerTest, IgnoreTDownWhileStrokeIsInProgress) {
+ StrokeModeler modeler;
+ ASSERT_TRUE(modeler.Reset(kDefaultParams).ok());
+
+ absl::StatusOr<std::vector<Result>> results =
+ modeler.Update({.event_type = Input::EventType::kDown,
+ .position = {0, 0},
+ .time = Time(0)});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, Not(IsEmpty()));
+
+ EXPECT_EQ(modeler
+ .Update({.event_type = Input::EventType::kDown,
+ .position = {1, 1},
+ .time = Time(1)})
+ .status()
+ .code(),
+ absl::StatusCode::kFailedPrecondition);
+
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {1, 1},
+ .time = Time(1)});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, Not(IsEmpty()));
+
+ EXPECT_EQ(modeler
+ .Update({.event_type = Input::EventType::kDown,
+ .position = {2, 2},
+ .time = Time(2)})
+ .status()
+ .code(),
+ absl::StatusCode::kFailedPrecondition);
+}
+
+TEST(StrokeModelerTest, AlternateParams) {
+ const Duration kDeltaTime{1. / 50};
+
+ StrokeModelParams stroke_model_params = kDefaultParams;
+ stroke_model_params.sampling_params.min_output_rate = 70;
+
+ StrokeModeler modeler;
+ ASSERT_TRUE(modeler.Reset(stroke_model_params).ok());
+
+ Time time{3};
+ absl::StatusOr<std::vector<Result>> results =
+ modeler.Update({.event_type = Input::EventType::kDown,
+ .position = {0, 0},
+ .time = time,
+ .pressure = .5,
+ .tilt = .2,
+ .orientation = .4});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear(
+ {{0, 0}, {0, 0}, Time{3}, .5, .2, .4}, kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, IsEmpty());
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {0, .5},
+ .time = time,
+ .pressure = .4,
+ .tilt = .3,
+ .orientation = .3});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(
+ *results,
+ ElementsAre(
+ ResultNear(
+ {{0, 0.0736}, {0, 7.3636}, Time{3.0100}, 0.4853, 0.2147, 0.3853},
+ kTol),
+ ResultNear(
+ {{0, 0.2198}, {0, 14.6202}, Time{3.0200}, 0.4560, 0.2440, 0.3560},
+ kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(
+ *results,
+ ElementsAre(
+ ResultNear(
+ {{0, 0.3823}, {0, 11.3709}, Time{3.0343}, 0.4235, 0.2765, 0.3235},
+ kTol),
+ ResultNear(
+ {{0, 0.4484}, {0, 4.6285}, Time{3.0486}, 0.4103, 0.2897, 0.3103},
+ kTol),
+ ResultNear(
+ {{0, 0.4775}, {0, 2.0389}, Time{3.0629}, 0.4045, 0.2955, 0.3045},
+ kTol),
+ ResultNear(
+ {{0, 0.4902}, {0, 0.8873}, Time{3.0771}, 0.4020, 0.2980, 0.3020},
+ kTol),
+ ResultNear(
+ {{0, 0.4957}, {0, 0.3868}, Time{3.0914}, 0.4009, 0.2991, 0.3009},
+ kTol),
+ ResultNear(
+ {{0, 0.4981}, {0, 0.1686}, Time{3.1057}, 0.4004, 0.2996, 0.3004},
+ kTol),
+ ResultNear(
+ {{0, 0.4992}, {0, 0.0735}, Time{3.1200}, 0.4002, 0.2998, 0.3002},
+ kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {.2, 1},
+ .time = time,
+ .pressure = .3,
+ .tilt = .4,
+ .orientation = .2});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({{0.0295, 0.4169},
+ {2.9455, 19.7093},
+ Time{3.0300},
+ 0.4166,
+ 0.2834,
+ 0.3166},
+ kTol),
+ ResultNear({{0.0879, 0.6439},
+ {5.8481, 22.6926},
+ Time{3.0400},
+ 0.3691,
+ 0.3309,
+ 0.2691},
+ kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({{0.1529, 0.8487},
+ {4.5484, 14.3374},
+ Time{3.0543},
+ 0.3293,
+ 0.3707,
+ 0.2293},
+ kTol),
+ ResultNear({{0.1794, 0.9338},
+ {1.8514, 5.9577},
+ Time{3.0686},
+ 0.3128,
+ 0.3872,
+ 0.2128},
+ kTol),
+ ResultNear({{0.1910, 0.9712},
+ {0.8156, 2.6159},
+ Time{3.0829},
+ 0.3056,
+ 0.3944,
+ 0.2056},
+ kTol),
+ ResultNear({{0.1961, 0.9874},
+ {0.3549, 1.1389},
+ Time{3.0971},
+ 0.3024,
+ 0.3976,
+ 0.2024},
+ kTol),
+ ResultNear({{0.1983, 0.9945},
+ {0.1547, 0.4965},
+ Time{3.1114},
+ 0.3011,
+ 0.3989,
+ 0.2011},
+ kTol),
+ ResultNear({{0.1993, 0.9976},
+ {0.0674, 0.2164},
+ Time{3.1257},
+ 0.3005,
+ 0.3995,
+ 0.2005},
+ kTol),
+ ResultNear({{0.1997, 0.9990},
+ {0.0294, 0.0943},
+ Time{3.1400},
+ 0.3002,
+ 0.3998,
+ 0.2002},
+ kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {.4, 1.4},
+ .time = time,
+ .pressure = .2,
+ .tilt = .7,
+ .orientation = 0});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({{0.1668, 0.8712},
+ {7.8837, 22.7349},
+ Time{3.0500},
+ 0.3245,
+ 0.3755,
+ 0.2245},
+ kTol),
+ ResultNear({{0.2575, 1.0906},
+ {9.0771, 21.9411},
+ Time{3.0600},
+ 0.2761,
+ 0.4716,
+ 0.1522},
+ kTol)));
+
+ results = modeler.Predict();
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({{0.3395, 1.2676},
+ {5.7349, 12.3913},
+ Time{3.0743},
+ 0.2325,
+ 0.6024,
+ 0.0651},
+ kTol),
+ ResultNear({{0.3735, 1.3421},
+ {2.3831, 5.2156},
+ Time{3.0886},
+ 0.2142,
+ 0.6573,
+ 0.0284},
+ kTol),
+ ResultNear({{0.3885, 1.3748},
+ {1.0463, 2.2854},
+ Time{3.1029},
+ 0.2062,
+ 0.6814,
+ 0.0124},
+ kTol),
+ ResultNear({{0.3950, 1.3890},
+ {0.4556, 0.9954},
+ Time{3.1171},
+ 0.2027,
+ 0.6919,
+ 0.0054},
+ kTol),
+ ResultNear({{0.3978, 1.3952},
+ {0.1986, 0.4339},
+ Time{3.1314},
+ 0.2012,
+ 0.6965,
+ 0.0024},
+ kTol),
+ ResultNear({{0.3990, 1.3979},
+ {0.0866, 0.1891},
+ Time{3.1457},
+ 0.2005,
+ 0.6985,
+ 0.0010},
+ kTol),
+ ResultNear({{0.3996, 1.3991},
+ {0.0377, 0.0824},
+ Time{3.1600},
+ 0.2002,
+ 0.6993,
+ 0.0004},
+ kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kUp,
+ .position = {.7, 1.7},
+ .time = time,
+ .pressure = .1,
+ .tilt = 1,
+ .orientation = 0});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, ElementsAre(ResultNear({{0.3691, 1.2874},
+ {11.1558, 19.6744},
+ Time{3.0700},
+ 0.2256,
+ 0.6231,
+ 0.0512},
+ kTol),
+ ResultNear({{0.4978, 1.4640},
+ {12.8701, 17.6629},
+ Time{3.0800},
+ 0.1730,
+ 0.7809,
+ 0},
+ kTol),
+ ResultNear({{0.6141, 1.5986},
+ {8.1404, 9.4261},
+ Time{3.0943},
+ 0.1312,
+ 0.9064,
+ 0},
+ kTol),
+ ResultNear({{0.6624, 1.6557},
+ {3.3822, 3.9953},
+ Time{3.1086},
+ 0.1136,
+ 0.9591,
+ 0},
+ kTol),
+ ResultNear({{0.6836, 1.6807},
+ {1.4851, 1.7488},
+ Time{3.1229},
+ 0.1059,
+ 0.9822,
+ 0},
+ kTol),
+ ResultNear({{0.6929, 1.6916},
+ {0.6466, 0.7618},
+ Time{3.1371},
+ 0.1026,
+ 0.9922,
+ 0},
+ kTol),
+ ResultNear({{0.6969, 1.6963},
+ {0.2819, 0.3321},
+ Time{3.1514},
+ 0.1011,
+ 0.9966,
+ 0},
+ kTol),
+ ResultNear({{0.6986, 1.6984},
+ {0.1229, 0.1447},
+ Time{3.1657},
+ 0.1005,
+ 0.9985,
+ 0},
+ kTol),
+ ResultNear({{0.6994, 1.6993},
+ {0.0535, 0.0631},
+ Time{3.1800},
+ 0.1002,
+ 0.9994,
+ 0},
+ kTol)));
+
+ EXPECT_EQ(modeler.Predict().status().code(),
+ absl::StatusCode::kFailedPrecondition);
+}
+
+TEST(StrokeModelerTest, GenerateOutputOnTUpEvenIfNoTimeDelta) {
+ const Duration kDeltaTime{1. / 500};
+
+ StrokeModeler modeler;
+ ASSERT_TRUE(modeler.Reset(kDefaultParams).ok());
+
+ Time time{0};
+ absl::StatusOr<std::vector<Result>> results =
+ modeler.Update({.event_type = Input::EventType::kDown,
+ .position = {5, 5},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(
+ *results,
+ ElementsAre(ResultNear(
+ {.position = {5, 5}, .velocity = {0, 0}, .time = Time(0)}, kTol)));
+
+ time += kDeltaTime;
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {5, 5},
+ .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results,
+ ElementsAre(ResultNear(
+ {.position = {5, 5}, .velocity = {0, 0}, .time = Time(0.002)},
+ kTol)));
+
+ results = modeler.Update(
+ {.event_type = Input::EventType::kUp, .position = {5, 5}, .time = time});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(
+ *results,
+ ElementsAre(ResultNear(
+ {.position = {5, 5}, .velocity = {0, 0}, .time = Time(0.0076)},
+ kTol)));
+}
+
+TEST(StrokeModelerTest, RejectInputIfNegativeTimeDelta) {
+ StrokeModeler modeler;
+ ASSERT_TRUE(modeler.Reset(kDefaultParams).ok());
+
+ absl::StatusOr<std::vector<Result>> results =
+ modeler.Update({.event_type = Input::EventType::kDown,
+ .position = {0, 0},
+ .time = Time(0)});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, Not(IsEmpty()));
+
+ EXPECT_EQ(modeler
+ .Update({.event_type = Input::EventType::kMove,
+ .position = {1, 1},
+ .time = Time(-.1)})
+ .status()
+ .code(),
+ absl::StatusCode::kInvalidArgument);
+
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {1, 1},
+ .time = Time(1)});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, Not(IsEmpty()));
+
+ EXPECT_EQ(modeler
+ .Update({.event_type = Input::EventType::kUp,
+ .position = {1, 1},
+ .time = Time(.9)})
+ .status()
+ .code(),
+ absl::StatusCode::kInvalidArgument);
+}
+
+TEST(StrokeModelerTest, RejectDuplicateInput) {
+ StrokeModeler modeler;
+ ASSERT_TRUE(modeler.Reset(kDefaultParams).ok());
+
+ absl::StatusOr<std::vector<Result>> results =
+ modeler.Update({.event_type = Input::EventType::kDown,
+ .position = {0, 0},
+ .time = Time(0),
+ .pressure = .2,
+ .tilt = .3,
+ .orientation = .4});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, Not(IsEmpty()));
+
+ EXPECT_EQ(modeler
+ .Update({.event_type = Input::EventType::kDown,
+ .position = {0, 0},
+ .time = Time(0),
+ .pressure = .2,
+ .tilt = .3,
+ .orientation = .4})
+ .status()
+ .code(),
+ absl::StatusCode::kInvalidArgument);
+
+ results = modeler.Update({.event_type = Input::EventType::kMove,
+ .position = {1, 2},
+ .time = Time(1),
+ .pressure = .1,
+ .tilt = .2,
+ .orientation = .3});
+ ASSERT_TRUE(results.ok());
+ EXPECT_THAT(*results, Not(IsEmpty()));
+
+ EXPECT_EQ(modeler
+ .Update({.event_type = Input::EventType::kMove,
+ .position = {1, 2},
+ .time = Time(1),
+ .pressure = .1,
+ .tilt = .2,
+ .orientation = .3})
+ .status()
+ .code(),
+ absl::StatusCode::kInvalidArgument);
+}
+
+} // namespace
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/types.cc b/ink_stroke_modeler/types.cc
new file mode 100644
index 0000000..9864ea3
--- /dev/null
+++ b/ink_stroke_modeler/types.cc
@@ -0,0 +1,36 @@
+#include "ink_stroke_modeler/types.h"
+
+#include "absl/status/status.h"
+#include "ink_stroke_modeler/internal/validation.h"
+
+// This convenience macro evaluates the given expression, and if it does not
+// return an OK status, returns and propagates the status.
+#define RETURN_IF_ERROR(expr) \
+ do { \
+ if (auto status = (expr); !status.ok()) return status; \
+ } while (false)
+
+namespace ink {
+namespace stroke_model {
+
+absl::Status ValidateInput(const Input& input) {
+ switch (input.event_type) {
+ case Input::EventType::kUp:
+ case Input::EventType::kMove:
+ case Input::EventType::kDown:
+ break;
+ default:
+ return absl::InvalidArgumentError("Unknown Input.event_type.");
+ }
+ RETURN_IF_ERROR(ValidateIsFiniteNumber(input.position.x, "Input.position.x"));
+ RETURN_IF_ERROR(ValidateIsFiniteNumber(input.position.y, "Input.position.y"));
+ RETURN_IF_ERROR(ValidateIsFiniteNumber(input.time.Value(), "Input.time"));
+ RETURN_IF_ERROR(ValidateIsFiniteNumber(input.pressure, "Input.pressure"));
+ RETURN_IF_ERROR(ValidateIsFiniteNumber(input.tilt, "Input.tilt"));
+ RETURN_IF_ERROR(
+ ValidateIsFiniteNumber(input.orientation, "Input.orientation"));
+ return absl::OkStatus();
+}
+
+} // namespace stroke_model
+} // namespace ink
diff --git a/ink_stroke_modeler/types.h b/ink_stroke_modeler/types.h
new file mode 100644
index 0000000..cc77565
--- /dev/null
+++ b/ink_stroke_modeler/types.h
@@ -0,0 +1,356 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef INK_STROKE_MODELER_TYPES_H_
+#define INK_STROKE_MODELER_TYPES_H_
+
+#include <cmath>
+#include <ostream>
+
+#include "absl/status/status.h"
+
+namespace ink {
+namespace stroke_model {
+
+// A vector (or point) in 2D space.
+struct Vec2 {
+ float x = 0;
+ float y = 0;
+
+ // The length of the vector, i.e. its distance from the origin.
+ float Magnitude() const { return std::sqrt(x * x + y * y); }
+};
+
+bool operator==(Vec2 lhs, Vec2 rhs);
+bool operator!=(Vec2 lhs, Vec2 rhs);
+
+Vec2 operator+(Vec2 lhs, Vec2 rhs);
+Vec2 operator-(Vec2 lhs, Vec2 rhs);
+Vec2 operator*(float scalar, Vec2 v);
+Vec2 operator*(Vec2 v, float scalar);
+Vec2 operator/(Vec2 v, float scalar);
+
+Vec2 &operator+=(Vec2 &lhs, Vec2 rhs);
+Vec2 &operator-=(Vec2 &lhs, Vec2 rhs);
+Vec2 &operator*=(Vec2 &lhs, float scalar);
+Vec2 &operator/=(Vec2 &lhs, float scalar);
+
+std::ostream &operator<<(std::ostream &stream, Vec2 v);
+
+// This represents a duration of time, i.e. the difference between two points in
+// time (as represented by class Time, below). This class is unit-agnostic; it
+// could represent e.g. hours, seconds, or years.
+class Duration {
+ public:
+ Duration() : Duration(0) {}
+ explicit Duration(double value) : value_(value) {}
+ double Value() const { return value_; }
+
+ private:
+ double value_ = 0;
+};
+
+Duration operator+(Duration lhs, Duration rhs);
+Duration operator-(Duration lhs, Duration rhs);
+Duration operator*(Duration duration, double scalar);
+Duration operator*(double scalar, Duration duration);
+Duration operator/(Duration duration, double scalar);
+
+Duration &operator+=(Duration &lhs, Duration rhs);
+Duration &operator-=(Duration &lhs, Duration rhs);
+Duration &operator*=(Duration &duration, double scalar);
+Duration &operator/=(Duration &duration, double scalar);
+
+bool operator==(Duration lhs, Duration rhs);
+bool operator!=(Duration lhs, Duration rhs);
+bool operator<(Duration lhs, Duration rhs);
+bool operator>(Duration lhs, Duration rhs);
+bool operator<=(Duration lhs, Duration rhs);
+bool operator>=(Duration lhs, Duration rhs);
+
+std::ostream &operator<<(std::ostream &s, Duration duration);
+
+// This represents a point in time. This class is unit- and offset-agnostic; it
+// could be measured in e.g. hours, seconds, or years, and Time(0) has no
+// specific meaning outside of the context it is used in.
+class Time {
+ public:
+ Time() : Time(0) {}
+ explicit Time(double value) : value_(value) {}
+ double Value() const { return value_; }
+
+ private:
+ double value_ = 0;
+};
+
+Time operator+(Time time, Duration duration);
+Time operator+(Duration duration, Time time);
+Time operator-(Time time, Duration duration);
+Duration operator-(Time lhs, Time rhs);
+
+Time &operator+=(Time &time, Duration duration);
+Time &operator-=(Time &time, Duration duration);
+
+bool operator==(Time lhs, Time rhs);
+bool operator!=(Time lhs, Time rhs);
+bool operator<(Time lhs, Time rhs);
+bool operator>(Time lhs, Time rhs);
+bool operator<=(Time lhs, Time rhs);
+bool operator>=(Time lhs, Time rhs);
+
+std::ostream &operator<<(std::ostream &s, Time time);
+
+// The input passed to the stroke modeler.
+struct Input {
+ // The type of event represented by the input. A "kDown" event represents
+ // the beginning of the stroke, a "kUp" event represents the end of the
+ // stroke, and all events in between are "kMove" events.
+ enum class EventType { kDown, kMove, kUp };
+ EventType event_type;
+
+ // The position of the input.
+ Vec2 position{0};
+
+ // The time at which the input occurs.
+ Time time{0};
+
+ // The amount of pressure applied to the stylus. This is expected to lie in
+ // the range [0, 1]. A negative value indicates unknown pressure.
+ float pressure = -1;
+
+ // The angle between the stylus and the plane of the device's screen. This
+ // is expected to lie in the range [0, π/2]. A value of 0 indicates that the
+ // stylus is perpendicular to the screen, while a value of π/2 indicates
+ // that it is flush with the screen. A negative value indicates unknown
+ // tilt.
+ float tilt = -1;
+
+ // The angle between the projection of the stylus onto the screen and the
+ // positive x-axis, measured counter-clockwise. This is expected to lie in
+ // the range [0, 2π). A negative value indicates unknown orientation.
+ float orientation = -1;
+};
+
+bool operator==(const Input &lhs, const Input &rhs);
+bool operator!=(const Input &lhs, const Input &rhs);
+
+std::ostream &operator<<(std::ostream &s, Input::EventType event_type);
+std::ostream &operator<<(std::ostream &s, const Input &input);
+
+absl::Status ValidateInput(const Input &input);
+
+// A modeled input produced by the stroke modeler.
+struct Result {
+ // The position and velocity of the stroke tip.
+ Vec2 position{0};
+ Vec2 velocity{0};
+
+ // The time at which this input occurs.
+ Time time{0};
+
+ // These pressure, tilt, and orientation of the stylus. See the
+ // corresponding fields on the Input struct for more info.
+ float pressure = -1;
+ float tilt = -1;
+ float orientation = -1;
+};
+
+std::ostream &operator<<(std::ostream &s, const Result &result);
+
+////////////////////////////////////////////////////////////////////////////////
+// Inline function definitions
+////////////////////////////////////////////////////////////////////////////////
+
+inline bool operator==(Vec2 lhs, Vec2 rhs) {
+ return lhs.x == rhs.x && lhs.y == rhs.y;
+}
+inline bool operator!=(Vec2 lhs, Vec2 rhs) { return !(lhs == rhs); }
+
+inline Vec2 operator+(Vec2 lhs, Vec2 rhs) {
+ return {.x = lhs.x + rhs.x, .y = lhs.y + rhs.y};
+}
+inline Vec2 operator-(Vec2 lhs, Vec2 rhs) {
+ return {.x = lhs.x - rhs.x, .y = lhs.y - rhs.y};
+}
+inline Vec2 operator*(float scalar, Vec2 v) {
+ return {.x = scalar * v.x, .y = scalar * v.y};
+}
+inline Vec2 operator*(Vec2 v, float scalar) { return scalar * v; }
+inline Vec2 operator/(Vec2 v, float scalar) {
+ return {.x = v.x / scalar, .y = v.y / scalar};
+}
+
+inline Vec2 &operator+=(Vec2 &lhs, Vec2 rhs) {
+ lhs.x += rhs.x;
+ lhs.y += rhs.y;
+ return lhs;
+}
+inline Vec2 &operator-=(Vec2 &lhs, Vec2 rhs) {
+ lhs.x -= rhs.x;
+ lhs.y -= rhs.y;
+ return lhs;
+}
+inline Vec2 &operator*=(Vec2 &lhs, float scalar) {
+ lhs.x *= scalar;
+ lhs.y *= scalar;
+ return lhs;
+}
+inline Vec2 &operator/=(Vec2 &lhs, float scalar) {
+ lhs.x /= scalar;
+ lhs.y /= scalar;
+ return lhs;
+}
+
+inline std::ostream &operator<<(std::ostream &stream, Vec2 v) {
+ return stream << "(" << v.x << ", " << v.y << ")";
+}
+
+inline Duration operator+(Duration lhs, Duration rhs) {
+ return Duration(lhs.Value() + rhs.Value());
+}
+inline Duration operator-(Duration lhs, Duration rhs) {
+ return Duration(lhs.Value() - rhs.Value());
+}
+inline Duration operator*(Duration duration, double scalar) {
+ return Duration(duration.Value() * scalar);
+}
+inline Duration operator*(double scalar, Duration duration) {
+ return Duration(scalar * duration.Value());
+}
+inline Duration operator/(Duration duration, double scalar) {
+ return Duration(duration.Value() / scalar);
+}
+
+inline Duration &operator+=(Duration &lhs, Duration rhs) {
+ lhs = lhs + rhs;
+ return lhs;
+}
+inline Duration &operator-=(Duration &lhs, Duration rhs) {
+ lhs = lhs - rhs;
+ return lhs;
+}
+inline Duration &operator*=(Duration &duration, double scalar) {
+ duration = duration * scalar;
+ return duration;
+}
+inline Duration &operator/=(Duration &duration, double scalar) {
+ duration = duration / scalar;
+ return duration;
+}
+
+inline bool operator==(Duration lhs, Duration rhs) {
+ return lhs.Value() == rhs.Value();
+}
+inline bool operator!=(Duration lhs, Duration rhs) {
+ return lhs.Value() != rhs.Value();
+}
+inline bool operator<(Duration lhs, Duration rhs) {
+ return lhs.Value() < rhs.Value();
+}
+inline bool operator>(Duration lhs, Duration rhs) {
+ return lhs.Value() > rhs.Value();
+}
+inline bool operator<=(Duration lhs, Duration rhs) {
+ return lhs.Value() <= rhs.Value();
+}
+inline bool operator>=(Duration lhs, Duration rhs) {
+ return lhs.Value() >= rhs.Value();
+}
+
+inline Time operator+(Time time, Duration duration) {
+ return Time(time.Value() + duration.Value());
+}
+inline Time operator+(Duration duration, Time time) {
+ return Time(time.Value() + duration.Value());
+}
+inline Time operator-(Time time, Duration duration) {
+ return Time(time.Value() - duration.Value());
+}
+inline Duration operator-(Time lhs, Time rhs) {
+ return Duration(lhs.Value() - rhs.Value());
+}
+
+inline Time &operator+=(Time &time, Duration duration) {
+ time = time + duration;
+ return time;
+}
+inline Time &operator-=(Time &time, Duration duration) {
+ time = time - duration;
+ return time;
+}
+
+inline bool operator==(Time lhs, Time rhs) {
+ return lhs.Value() == rhs.Value();
+}
+inline bool operator!=(Time lhs, Time rhs) {
+ return lhs.Value() != rhs.Value();
+}
+inline bool operator<(Time lhs, Time rhs) { return lhs.Value() < rhs.Value(); }
+inline bool operator>(Time lhs, Time rhs) { return lhs.Value() > rhs.Value(); }
+inline bool operator<=(Time lhs, Time rhs) {
+ return lhs.Value() <= rhs.Value();
+}
+inline bool operator>=(Time lhs, Time rhs) {
+ return lhs.Value() >= rhs.Value();
+}
+
+inline bool operator==(const Input &lhs, const Input &rhs) {
+ return lhs.event_type == rhs.event_type && lhs.position == rhs.position &&
+ lhs.time == rhs.time && lhs.pressure == rhs.pressure &&
+ lhs.tilt == rhs.tilt && lhs.orientation == rhs.orientation;
+}
+inline bool operator!=(const Input &lhs, const Input &rhs) {
+ return !(lhs == rhs);
+}
+
+inline std::ostream &operator<<(std::ostream &s, Duration duration) {
+ return s << duration.Value();
+}
+
+inline std::ostream &operator<<(std::ostream &s, Time time) {
+ return s << time.Value();
+}
+
+inline std::ostream &operator<<(std::ostream &s, Input::EventType event_type) {
+ switch (event_type) {
+ case Input::EventType::kDown:
+ return s << "Down";
+ case Input::EventType::kMove:
+ return s << "Move";
+ case Input::EventType::kUp:
+ return s << "Up";
+ }
+ return s << "UnknownEventType<" << event_type << ">";
+}
+
+inline std::ostream &operator<<(std::ostream &s, const Input &input) {
+ return s << "<Input: " << input.event_type << ", pos: " << input.position
+ << ", time: " << input.time << ", pressure: " << input.pressure
+ << ", tilt: " << input.tilt << ", orientation: " << input.orientation
+ << ">";
+}
+
+inline std::ostream &operator<<(std::ostream &s, const Result &result) {
+ return s << "<Result: pos: " << result.position
+ << ", velocity: " << result.velocity << ", time: " << result.time
+ << ", pressure: " << result.pressure << ", tilt: " << result.tilt
+ << ", orientation: " << result.orientation << ">";
+}
+
+} // namespace stroke_model
+} // namespace ink
+
+#endif // INK_STROKE_MODELER_TYPES_H_
diff --git a/ink_stroke_modeler/types_test.cc b/ink_stroke_modeler/types_test.cc
new file mode 100644
index 0000000..e812d76
--- /dev/null
+++ b/ink_stroke_modeler/types_test.cc
@@ -0,0 +1,212 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ink_stroke_modeler/types.h"
+
+#include <cmath>
+#include <limits>
+#include <sstream>
+#include <string>
+
+#include "gmock/gmock.h"
+#include "gtest/gtest.h"
+#include "ink_stroke_modeler/internal/type_matchers.h"
+
+namespace ink {
+namespace stroke_model {
+namespace {
+
+using ::testing::Not;
+
+TEST(TypesTest, Vec2Equality) {
+ EXPECT_EQ((Vec2{1, 2}), (Vec2{1, 2}));
+ EXPECT_EQ((Vec2{-.4, 17}), (Vec2{-.4, 17}));
+
+ EXPECT_NE((Vec2{1, 2}), (Vec2{1, 5}));
+ EXPECT_NE((Vec2{3, 2}), (Vec2{.7, 2}));
+ EXPECT_NE((Vec2{-4, .3}), (Vec2{.5, 12}));
+}
+
+TEST(TypesTest, Vec2EqMatcher) {
+ EXPECT_THAT((Vec2{1, 2}), Vec2Eq({1, 2}));
+ EXPECT_THAT((Vec2{3, 4}), Not(Vec2Eq({3, 5})));
+ EXPECT_THAT((Vec2{5, 6}), Not(Vec2Eq({4, 6})));
+
+ // Vec2Eq delegates to FloatEq, which uses a tolerance of 4 ULP.
+ constexpr float kEps = std::numeric_limits<float>::epsilon();
+ EXPECT_THAT((Vec2{1, 1}), Vec2Eq({1 + kEps, 1 - kEps}));
+}
+
+TEST(TypesTest, Vec2NearMatcher) {
+ EXPECT_THAT((Vec2{1, 2}), Vec2Near({1.05, 1.95}, .1));
+ EXPECT_THAT((Vec2{3, 4}), Not(Vec2Near({3, 5}, .5)));
+ EXPECT_THAT((Vec2{5, 6}), Not(Vec2Near({4, 6}, .5)));
+}
+
+TEST(TypesTest, Vec2Magnitude) {
+ EXPECT_FLOAT_EQ((Vec2{1, 1}).Magnitude(), std::sqrt(2));
+ EXPECT_FLOAT_EQ((Vec2{-3, 4}).Magnitude(), 5);
+ EXPECT_FLOAT_EQ((Vec2{0, 0}).Magnitude(), 0);
+ EXPECT_FLOAT_EQ((Vec2{0, 17}).Magnitude(), 17);
+}
+
+TEST(TypesTest, Vec2Addition) {
+ Vec2 a{3, 0};
+ Vec2 b{-1, .3};
+ Vec2 c{2.7, 4};
+
+ EXPECT_THAT(a + b, Vec2Eq({2, .3}));
+ EXPECT_THAT(a + c, Vec2Eq({5.7, 4}));
+ EXPECT_THAT(b + c, Vec2Eq({1.7, 4.3}));
+}
+
+TEST(TypesTest, Vec2Subtraction) {
+ Vec2 a{0, -2};
+ Vec2 b{.5, 19};
+ Vec2 c{1.1, -3.4};
+
+ EXPECT_THAT(a - b, Vec2Eq({-.5, -21}));
+ EXPECT_THAT(a - c, Vec2Eq({-1.1, 1.4}));
+ EXPECT_THAT(b - c, Vec2Eq({-.6, 22.4}));
+}
+
+TEST(TypesTest, Vec2ScalarMultiplication) {
+ Vec2 a{.7, -3};
+ Vec2 b{3, 5};
+
+ EXPECT_THAT(a * 2, Vec2Eq({1.4, -6}));
+ EXPECT_THAT(.1 * a, Vec2Eq({.07, -.3}));
+ EXPECT_THAT(b * -.3, Vec2Eq({-.9, -1.5}));
+ EXPECT_THAT(4 * b, Vec2({12, 20}));
+}
+
+TEST(TypesTest, Vec2ScalarDivision) {
+ Vec2 a{7, .9};
+ Vec2 b{-4.5, -2};
+
+ EXPECT_THAT(a / 2, Vec2Eq({3.5, .45}));
+ EXPECT_THAT(a / -.1, Vec2Eq({-70, -9}));
+ EXPECT_THAT(b / 5, Vec2Eq({-.9, -.4}));
+ EXPECT_THAT(b / .2, Vec2Eq({-22.5, -10}));
+}
+
+TEST(TypesTest, Vec2AddAssign) {
+ Vec2 a{1, 2};
+ a += {.x = 3, .y = -1};
+ EXPECT_THAT(a, Vec2Eq({4, 1}));
+ a += {.x = -.5, .y = 2};
+ EXPECT_THAT(a, Vec2Eq({3.5, 3}));
+}
+
+TEST(TypesTest, Vec2SubractAssign) {
+ Vec2 a{1, 2};
+ a -= {.x = 3, .y = -1};
+ EXPECT_THAT(a, Vec2Eq({-2, 3}));
+ a -= {.x = -.5, .y = 2};
+ EXPECT_THAT(a, Vec2Eq({-1.5, 1}));
+}
+
+TEST(TypesTest, Vec2ScalarMultiplyAssign) {
+ Vec2 a{1, 2};
+ a *= 2;
+ EXPECT_THAT(a, Vec2Eq({2, 4}));
+ a *= -.4;
+ EXPECT_THAT(a, Vec2Eq({-.8, -1.6}));
+}
+
+TEST(TypesTest, Vec2ScalarDivideAssign) {
+ Vec2 a{1, 2};
+ a /= 2;
+ EXPECT_THAT(a, Vec2Eq({.5, 1}));
+ a /= -.4;
+ EXPECT_THAT(a, Vec2Eq({-1.25, -2.5}));
+}
+
+TEST(TypesTest, Vec2Stream) {
+ std::stringstream s;
+ s << Vec2{3.5, -2.7};
+ EXPECT_EQ(s.str(), "(3.5, -2.7)");
+}
+
+TEST(TypesTest, DurationArithmetic) {
+ EXPECT_EQ(Duration(1) + Duration(2), Duration(3));
+ EXPECT_EQ(Duration(6) - Duration(1), Duration(5));
+ EXPECT_EQ(Duration(3) * 2, Duration(6));
+ EXPECT_EQ(.5 * Duration(7), Duration(3.5));
+ EXPECT_EQ(Duration(12) / 4, Duration(3));
+}
+
+TEST(TypesTest, DurationArithmeticAssignment) {
+ Duration d{5};
+ d += Duration(2);
+ EXPECT_EQ(d, Duration(7));
+ d -= Duration(3);
+ EXPECT_EQ(d, Duration(4));
+ d *= 5;
+ EXPECT_EQ(d, Duration(20));
+ d /= 2;
+ EXPECT_EQ(d, Duration(10));
+}
+
+TEST(TypesTest, DurationComparison) {
+ EXPECT_EQ(Duration(0), Duration(0));
+ EXPECT_NE(Duration(0), Duration(1));
+ EXPECT_LT(Duration(1), Duration(2));
+ EXPECT_LE(Duration(4), Duration(5));
+ EXPECT_LE(Duration(2), Duration(2));
+ EXPECT_GT(Duration(10), Duration(9));
+ EXPECT_GE(Duration(7), Duration(5));
+ EXPECT_GE(Duration(5), Duration(5));
+}
+
+TEST(TypesTest, TimeArithmetic) {
+ EXPECT_EQ(Time(5) + Duration(1), Time(6));
+ EXPECT_EQ(Duration(7) + Time(12), Time(19));
+ EXPECT_EQ(Time(23) - Duration(5), Time(18));
+ EXPECT_EQ(Time(35) - Time(7), Duration(28));
+}
+
+TEST(TypesTest, TimeArithmeticAssignment) {
+ Time t{20};
+ t += Duration(10);
+ EXPECT_EQ(t, Time(30));
+ t -= Duration(24);
+ EXPECT_EQ(t, Time(6));
+}
+
+TEST(TypesTest, TimeComparison) {
+ EXPECT_EQ(Time(0), Time(0));
+ EXPECT_NE(Time(0), Time(1));
+ EXPECT_LT(Time(1), Time(2));
+ EXPECT_LE(Time(4), Time(5));
+ EXPECT_LE(Time(2), Time(2));
+ EXPECT_GT(Time(10), Time(9));
+ EXPECT_GE(Time(7), Time(5));
+ EXPECT_GE(Time(5), Time(5));
+}
+
+TEST(TypesTest, InputEquality) {
+ const Input kBaseline{};
+ EXPECT_EQ(kBaseline, Input());
+ EXPECT_NE(kBaseline, Input{.event_type = Input::EventType::kMove});
+ EXPECT_NE(kBaseline, (Input{.position = {1, -1}}));
+ EXPECT_NE(kBaseline, Input{.time = Time(1)});
+ EXPECT_NE(kBaseline, Input{.pressure = .5});
+ EXPECT_NE(kBaseline, Input{.tilt = .2});
+ EXPECT_NE(kBaseline, Input{.orientation = .7});
+}
+
+} // namespace
+} // namespace stroke_model
+} // namespace ink
diff --git a/position_model.svg b/position_model.svg
new file mode 100644
index 0000000..2a17b8e
--- /dev/null
+++ b/position_model.svg
@@ -0,0 +1,183 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="350"
+ height="110"
+ id="svg2"
+ version="1.1"
+ inkscape:version="0.48.4 r9939"
+ sodipodi:docname="New document 1">
+ <defs
+ id="defs4" />
+ <sodipodi:namedview
+ id="base"
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1.0"
+ inkscape:pageopacity="0.0"
+ inkscape:pageshadow="2"
+ inkscape:zoom="2.1911357"
+ inkscape:cx="190.40267"
+ inkscape:cy="79.809517"
+ inkscape:document-units="px"
+ inkscape:current-layer="layer2"
+ showgrid="false"
+ inkscape:window-width="956"
+ inkscape:window-height="564"
+ inkscape:window-x="0"
+ inkscape:window-y="634"
+ inkscape:window-maximized="0"
+ inkscape:object-paths="true"
+ inkscape:snap-intersection-paths="true"
+ inkscape:object-nodes="true"
+ inkscape:snap-midpoints="true"
+ inkscape:snap-object-midpoints="true"
+ inkscape:snap-page="true"
+ inkscape:snap-smooth-nodes="true"
+ inkscape:snap-global="true">
+ <inkscape:grid
+ type="xygrid"
+ id="grid2985"
+ empspacing="4"
+ visible="true"
+ enabled="true"
+ snapvisiblegridlinesonly="true"
+ spacingx="10px"
+ spacingy="10px" />
+ </sodipodi:namedview>
+ <metadata
+ id="metadata7">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ <dc:title></dc:title>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g
+ inkscape:groupmode="layer"
+ id="layer2"
+ inkscape:label="layer 1"
+ style="display:inline"
+ transform="translate(0,-290)">
+ <path
+ style="fill:none;stroke:#c00000;stroke-width:0.75;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:0.25098039;stroke-dasharray:none;stroke-dashoffset:0"
+ d="m 10,370 60,-30 20,-30 60,-10 70,20 30,40 90,30"
+ id="path2983"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="ccccccc" />
+ <path
+ style="fill:none;stroke:#808080;stroke-width:0.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
+ d="m 232.80696,344.32161 6.78641,3.77819 3.41937,-2.14987 -1.74892,7.1334 5.14211,-5.24431 -1.74892,7.1334 5.14211,-5.24431 -1.7489,7.13341 5.14212,-5.24432 -1.74892,7.13341 5.14212,-5.24431 -1.74892,7.13341 5.14212,-5.24432 -1.74892,7.13341 5.14211,-5.24432 -1.74892,7.13341 5.14212,-5.24431 -1.74892,7.1334 5.14212,-5.24431 -1.74891,7.13341 3.41936,-2.14989 6.7864,3.77819"
+ id="path3123"
+ inkscape:connector-curvature="0"
+ inkscape:transform-center-x="-23.34097"
+ sodipodi:nodetypes="cccccccccccccccccccccc"
+ inkscape:transform-center-y="12.99459" />
+ <rect
+ style="fill:#404080;fill-opacity:1;stroke:none;display:inline"
+ id="rect3115"
+ width="7.9867258"
+ height="7.9867258"
+ x="362.9173"
+ y="190.92964"
+ transform="matrix(0.88351706,0.46839898,-0.46839898,0.88351706,0,0)" />
+ <path
+ sodipodi:type="arc"
+ style="fill:#804040;fill-opacity:1;stroke:none;display:inline"
+ id="path3117"
+ sodipodi:cx="269.95132"
+ sodipodi:cy="322.64285"
+ sodipodi:rx="3.6510746"
+ sodipodi:ry="3.6510746"
+ d="m 273.6024,322.64285 c 0,2.01644 -1.63464,3.65108 -3.65108,3.65108 -2.01643,0 -3.65107,-1.63464 -3.65107,-3.65108 0,-2.01643 1.63464,-3.65107 3.65107,-3.65107 2.01644,0 3.65108,1.63464 3.65108,3.65107 z"
+ transform="matrix(0.78596118,0,0,0.78596118,65.72684,115.71461)" />
+ <path
+ style="fill:none;stroke:#0000ff;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:0.50196078;stroke-dasharray:1, 1;stroke-dashoffset:0;display:inline"
+ d="m 10,370 c 0,0 24.265222,-11.43312 35.958534,-17.97927 6.285095,-3.51852 12.669275,-6.98548 18.341922,-11.42434 3.100352,-2.42603 5.941611,-5.21271 8.471202,-8.22916 2.46351,-2.93765 4.026586,-6.55268 6.454249,-9.52002 2.080335,-2.5428 4.173645,-5.20514 6.900758,-7.03721 3.108152,-2.08806 6.850055,-3.0539 10.39862,-4.249 5.460415,-1.83899 11.053655,-3.3084 16.696345,-4.47377 7.77965,-1.60671 15.66498,-2.77491 23.57956,-3.45591 5.79947,-0.49901 11.63786,-0.61079 17.45671,-0.45638 6.00675,0.1594 12.05509,0.36832 17.97012,1.4262 5.79564,1.03652 11.54877,2.56841 17.00032,4.79203 5.87813,2.39762 11.64569,5.30642 16.71508,9.12769 3.2428,2.44439 5.73728,5.74917 8.55721,8.6713 2.57817,2.67162 4.92872,5.56914 7.64443,8.10082 3.38029,3.15123 10.72503,8.7854 10.72503,8.7854"
+ id="path3110"
+ inkscape:connector-curvature="0"
+ sodipodi:nodetypes="cssssssssssssssc" />
+ <path
+ sodipodi:type="arc"
+ style="fill:#ff0000;fill-opacity:1;stroke:none;display:inline"
+ id="path2985"
+ sodipodi:cx="165"
+ sodipodi:cy="285"
+ sodipodi:rx="5"
+ sodipodi:ry="5"
+ d="m 170,285 c 0,2.76142 -2.23858,5 -5,5 -2.76142,0 -5,-2.23858 -5,-5 0,-2.76142 2.23858,-5 5,-5 2.76142,0 5,2.23858 5,5 z"
+ transform="matrix(0.375,0,0,0.375,8.1249999,233.125)" />
+ <path
+ sodipodi:type="arc"
+ style="fill:#ff0000;fill-opacity:1;stroke:none;display:inline"
+ id="path2985-3"
+ sodipodi:cx="165"
+ sodipodi:cy="285"
+ sodipodi:rx="5"
+ sodipodi:ry="5"
+ d="m 170,285 c 0,2.76142 -2.23858,5 -5,5 -2.76142,0 -5,-2.23858 -5,-5 0,-2.76142 2.23858,-5 5,-5 2.76142,0 5,2.23858 5,5 z"
+ transform="matrix(0.375,0,0,0.375,-51.875,263.125)" />
+ <path
+ sodipodi:type="arc"
+ style="fill:#ff0000;fill-opacity:1;stroke:none;display:inline"
+ id="path2985-6"
+ sodipodi:cx="165"
+ sodipodi:cy="285"
+ sodipodi:rx="5"
+ sodipodi:ry="5"
+ d="m 170,285 c 0,2.76142 -2.23858,5 -5,5 -2.76142,0 -5,-2.23858 -5,-5 0,-2.76142 2.23858,-5 5,-5 2.76142,0 5,2.23858 5,5 z"
+ transform="matrix(0.375,0,0,0.375,28.125,203.125)" />
+ <path
+ sodipodi:type="arc"
+ style="fill:#ff0000;fill-opacity:1;stroke:none;display:inline"
+ id="path2985-7"
+ sodipodi:cx="165"
+ sodipodi:cy="285"
+ sodipodi:rx="5"
+ sodipodi:ry="5"
+ d="m 170,285 c 0,2.76142 -2.23858,5 -5,5 -2.76142,0 -5,-2.23858 -5,-5 0,-2.76142 2.23858,-5 5,-5 2.76142,0 5,2.23858 5,5 z"
+ transform="matrix(0.375,0,0,0.375,88.125,193.125)" />
+ <path
+ sodipodi:type="arc"
+ style="fill:#ff0000;fill-opacity:1;stroke:none;display:inline"
+ id="path2985-5"
+ sodipodi:cx="165"
+ sodipodi:cy="285"
+ sodipodi:rx="5"
+ sodipodi:ry="5"
+ d="m 170,285 c 0,2.76142 -2.23858,5 -5,5 -2.76142,0 -5,-2.23858 -5,-5 0,-2.76142 2.23858,-5 5,-5 2.76142,0 5,2.23858 5,5 z"
+ transform="matrix(0.375,0,0,0.375,158.125,213.125)" />
+ <path
+ sodipodi:type="arc"
+ style="fill:#ff0000;fill-opacity:1;stroke:none;display:inline"
+ id="path2985-35"
+ sodipodi:cx="165"
+ sodipodi:cy="285"
+ sodipodi:rx="5"
+ sodipodi:ry="5"
+ d="m 170,285 c 0,2.76142 -2.23858,5 -5,5 -2.76142,0 -5,-2.23858 -5,-5 0,-2.76142 2.23858,-5 5,-5 2.76142,0 5,2.23858 5,5 z"
+ transform="matrix(0.375,0,0,0.375,188.125,253.125)" />
+ <path
+ sodipodi:type="arc"
+ style="fill:#ff0000;fill-opacity:1;stroke:none;display:inline"
+ id="path2985-62"
+ sodipodi:cx="165"
+ sodipodi:cy="285"
+ sodipodi:rx="5"
+ sodipodi:ry="5"
+ d="m 170,285 c 0,2.76142 -2.23858,5 -5,5 -2.76142,0 -5,-2.23858 -5,-5 0,-2.76142 2.23858,-5 5,-5 2.76142,0 5,2.23858 5,5 z"
+ transform="matrix(0.375,0,0,0.375,278.125,283.125)" />
+ </g>
+</svg>
diff --git a/workspace.bzl b/workspace.bzl
new file mode 100644
index 0000000..00fc679
--- /dev/null
+++ b/workspace.bzl
@@ -0,0 +1,70 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Set up dependencies for Ink Stroke Modeler.
+
+To use this from a consumer, add the following to your WORKSPACE setup:
+
+load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
+
+# https://bazel.build/rules/lib/repo/git#git_repository
+git_repository(
+ name = "ink_stroke_modeler",
+ remote = "https://github.com/google/ink-stroke-modeler.git",
+ branch = "main",
+)
+load("@ink_stroke_modeler//:workspace.bzl", "ink_stroke_modeler_workspace")
+ink_stroke_modeler_workspace()
+
+If you want to make it possible for your consumer to be consumed by other
+Bazel projects, factor that setup into a workspace.bzl file, following
+the pattern below.
+
+If you want to use a local version of this repo instead, use local_repository
+instead of git_repository:
+
+# https://bazel.build/reference/be/workspace#local_repository
+local_repository(
+ name = "ink_stroke_modeler",
+ path = "path/to/ink-stroke-modeler",
+)
+
+Ink Stroke Modeler requires C++17. This is not currently the default for Bazel,
+--cxxopt='-std=c++17' (or newer) is required. You can put the following in
+.bazelrc at your project's root:
+
+build --cxxopt='-std=c++17'
+"""
+
+load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository")
+load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe")
+
+def ink_stroke_modeler_workspace():
+ maybe(
+ git_repository,
+ name = "com_google_absl",
+ remote = "https://github.com/abseil/abseil-cpp.git",
+ # tag = "20211102.0",
+ commit = "215105818dfde3174fe799600bb0f3cae233d0bf",
+ shallow_since = "1635953174 -0400",
+ )
+
+ maybe(
+ git_repository,
+ name = "com_google_googletest",
+ remote = "https://github.com/google/googletest.git",
+ # tag = "release-1.11.0",
+ commit = "e2239ee6043f73722e7aa812a459f54a28552929",
+ shallow_since = "1623433346 -0700",
+ )