diff --git a/.gitattributes b/.gitattributes index dfe0770..818ec96 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,11 @@ # Auto detect text files and perform LF normalization * text=auto + +# Shell scripts must keep LF even when checked out on Windows -- a CRLF shebang +# line makes them unrunnable on macOS/Linux. +*.sh text eol=lf + +# TouchDesigner project and component files are binary; never touch line endings +# or try to merge them. +*.toe binary +*.tox binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ae2e5a..b44b2c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,94 +7,110 @@ on: branches: [ main, develop ] jobs: + # release.yml takes its release body from the CHANGELOG section matching the + # tag, and refuses to publish if there is none. That check only ever runs on a + # tag, which is the worst moment to find the section missing or renamed. Run + # the same extraction on every push instead. + # + # The version comes from CMakeLists.txt rather than a tag, so this also keeps + # the declared project version and the CHANGELOG from drifting apart -- they + # were out of step before 0.4.0, the build claiming 1.0.0 while the tags were + # 0.3.x. + changelog: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: Check the CHANGELOG covers the declared version + run: | + version=$(sed -n 's/^project(AnimationCHOP VERSION \([0-9][0-9.]*\).*/\1/p' CMakeLists.txt) + if [ -z "$version" ]; then + echo "::error::Could not read the project version from CMakeLists.txt." >&2 + exit 1 + fi + echo "Project version: $version" + + # The same extraction release.yml uses, matching the heading by prefix + # rather than by regex -- a version interpolated into one turns [0.4.0] + # into a character class. + awk -v head="## [$version]" ' + index($0, head) == 1 { found = 1; next } + found && index($0, "## [") == 1 { exit } + found && /^\[.*\]: / { exit } + found && index($0, " diff --git a/CMakeLists.txt b/CMakeLists.txt index b081c4a..187a662 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,6 @@ cmake_minimum_required(VERSION 3.14) -project(AnimationCHOP VERSION 1.0.0 LANGUAGES CXX) +# Stays on 0.x until the API is stable; see CHANGELOG.md. +project(AnimationCHOP VERSION 0.4.0 LANGUAGES CXX) set(VERBOSE_STATUS ON CACHE BOOL "Verbose status messages" FORCE) set(SUPPRESS_NOT_REFERENCED_WARNINGS ON CACHE BOOL "Suppress not referenced warnings" FORCE) @@ -17,12 +18,14 @@ set(PY_ANIM_BINDINGS_SRCS ${SRC}/py_anim_bindings/py_handle_mode.cpp ${SRC}/py_anim_bindings/py_keyframe.cpp ${SRC}/py_anim_bindings/py_point.cpp + ${SRC}/py_anim_bindings/py_range_end.cpp ) # Explicitly define sources for AnimationCHOP set(ANIMATION_CHOP_SOURCES ${PY_ANIM_BINDINGS_SRCS} ${SRC}/animation_chop.cpp + ${SRC}/animation_codec.cpp ) # Explicitly define sources for AnimationViewCHOP @@ -167,30 +170,42 @@ if(SUPPRESS_NOT_REFERENCED_WARNINGS) endforeach() endif() -# Add post-build commands to copy both DLLs to the Plugins folder +# Add post-build commands to copy both DLLs to the Plugins folders. +# +# TouchDesigner only loads Custom Operators from a Plugins/ folder beside the +# .toe, with no way to point it at a build directory, so every project that +# needs the operators gets its own copy: td/ for the example project, and +# tests/td/ for the integration-test project (see TESTING.md). +set(ANIMATIONCHOP_PLUGIN_DIRS + "${CMAKE_SOURCE_DIR}/td/Plugins" + "${CMAKE_SOURCE_DIR}/tests/td/Plugins" +) + foreach(TARGET animation_chop animation_view_chop) - add_custom_command(TARGET ${TARGET} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_SOURCE_DIR}/td/Plugins" - COMMENT "Creating Plugins directory" - ) - - if(APPLE) - # On macOS, copy the entire .plugin bundle - add_custom_command(TARGET ${TARGET} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_directory - "$" - "${CMAKE_SOURCE_DIR}/td/Plugins/$.plugin" - COMMENT "Copying ${TARGET}.plugin bundle to Plugins directory" - ) - else() - # On other platforms, copy the library file + foreach(PLUGIN_DIR ${ANIMATIONCHOP_PLUGIN_DIRS}) add_custom_command(TARGET ${TARGET} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy - "$" - "${CMAKE_SOURCE_DIR}/td/Plugins/" - COMMENT "Copying ${TARGET} to Plugins directory" + COMMAND ${CMAKE_COMMAND} -E make_directory "${PLUGIN_DIR}" + COMMENT "Creating Plugins directory: ${PLUGIN_DIR}" ) - endif() + + if(APPLE) + # On macOS, copy the entire .plugin bundle + add_custom_command(TARGET ${TARGET} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "$" + "${PLUGIN_DIR}/$.plugin" + COMMENT "Copying ${TARGET}.plugin bundle to ${PLUGIN_DIR}" + ) + else() + # On other platforms, copy the library file + add_custom_command(TARGET ${TARGET} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy + "$" + "${PLUGIN_DIR}/" + COMMENT "Copying ${TARGET} to ${PLUGIN_DIR}" + ) + endif() + endforeach() endforeach() # Add platform-specific settings for both targets @@ -226,3 +241,55 @@ install(TARGETS animation_chop animation_view_chop LIBRARY DESTINATION lib ARCHIVE DESTINATION lib ) + +# --------------------------------------------------------------------------- +# Tests +# +# tests/python runs pytest against an extension that compiles the real operator +# sources against a fake PY_Context. No TouchDesigner needed, so it runs in CI. +# +# tests/cpp is Catch2 over the .toe persistence codec -- the one piece here that +# is pure C++, and the one whose output lands in users' project files. Curve +# behaviour is anim's to test; it carries its own suite. +# +# The TouchDesigner integration test is separate and local-only (TouchDesigner +# needs a licence and a GPU, so it cannot run on a cloud runner); see +# run_td_tests.ps1 and TESTING.md. +# +# Configure with -DANIMATIONCHOP_BUILD_TESTS=ON, or use the `dev` preset. +# --------------------------------------------------------------------------- +option(ANIMATIONCHOP_BUILD_TESTS "Build the Catch2 and pytest unit tests" OFF) + +if(ANIMATIONCHOP_BUILD_TESTS) + enable_testing() + + # TouchDesigner embeds CPython 3.11, so the extension must be built and + # imported by 3.11. uv is preferred: it can supply both the interpreter and + # pytest without anything being installed globally. + find_program(UV_EXECUTABLE uv) + if(UV_EXECUTABLE) + message(STATUS "AnimationCHOP: using uv at ${UV_EXECUTABLE} to run pytest") + else() + find_package(Python3 3.11 COMPONENTS Interpreter REQUIRED) + set(ANIMATIONCHOP_PYTHON_EXECUTABLE ${Python3_EXECUTABLE}) + message(STATUS "AnimationCHOP: uv not found; running pytest with ${Python3_EXECUTABLE}") + endif() + + # A CPython extension is .pyd on Windows and .so everywhere else, including + # macOS -- CMake's default MODULE suffix there is .so already, but .dylib + # would not be importable, so be explicit. + if(WIN32) + set(ANIMATIONCHOP_PYEXT_SUFFIX ".pyd") + # Windows builds against the vendored headers/import library, matching + # the operator targets above. + set(ANIMATIONCHOP_PYTHON_INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/ext/Python/Include) + else() + set(ANIMATIONCHOP_PYEXT_SUFFIX ".so") + find_package(Python3 3.11 COMPONENTS Development.Module REQUIRED) + set(ANIMATIONCHOP_PYTHON_INCLUDE_DIRS ${Python3_INCLUDE_DIRS}) + set(ANIMATIONCHOP_PYTHON_LIBRARIES Python3::Module) + endif() + + add_subdirectory(tests/cpp) + add_subdirectory(tests/python) +endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..9116d3e --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,42 @@ +{ + "version": 6, + "cmakeMinimumRequired": { "major": 3, "minor": 25, "patch": 0 }, + "configurePresets": [ + { + "name": "dev", + "displayName": "Dev (operators + unit tests)", + "description": "Build both operators and the pytest binding suite. Uses uv for the Python 3.11 that runs pytest, when available.", + "binaryDir": "${sourceDir}/build", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "ANIMATIONCHOP_BUILD_TESTS": "ON" + } + } + ], + "buildPresets": [ + { + "name": "dev", + "configurePreset": "dev", + "configuration": "Release" + } + ], + "testPresets": [ + { + "name": "dev", + "configurePreset": "dev", + "configuration": "Release", + "output": { "outputOnFailure": true } + } + ], + "workflowPresets": [ + { + "name": "dev", + "displayName": "Configure, build, and run all unit tests", + "steps": [ + { "type": "configure", "name": "dev" }, + { "type": "build", "name": "dev" }, + { "type": "test", "name": "dev" } + ] + } + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b773015 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,108 @@ +# Contributing to AnimationCHOP + +Thanks for your interest. Bug reports and pull requests are welcome. + +## Where things live + +| Path | | +| --- | --- | +| `src/` | The two operators. `animation_chop.cpp` is the scriptable one; `animation_view_chop.cpp` exposes its internals as CHOP data. | +| `src/py_anim_bindings/` | The CPython bindings for anim's types — `Channel`, `Keyframe`, `Point`, and the enums. | +| `ext/anim/` | The animation curve library, as a submodule. Curve maths belongs [there](https://github.com/Actualize-Interactive/anim), not here. | +| `ext/td/` | Derivative's Custom Operator SDK headers. Do not edit; they are vendored verbatim. | +| `ext/Python/` | Vendored CPython 3.11 headers and import libraries for the Windows build. | +| `td/` | The example project, `Keyframer.tox`, and its Python modules and shaders. | +| `tests/python/` | pytest against a compiled test extension. No TouchDesigner needed. | +| `tests/td/` | The in-TouchDesigner integration suite and its runner. | +| `docs/` | The Python API reference. | + +**Curve behaviour changes go to `anim`.** If a fix is about how a curve +interpolates, how handles are solved, or how a channel extrapolates, it belongs +in the library, which has its own Catch2 suite. This repository is the +TouchDesigner binding. + +## Building + +```bash +git clone --recurse-submodules https://github.com/Actualize-Interactive/AnimationCHOP.git +cd AnimationCHOP +``` + +```powershell +.\build.ps1 # Windows +``` + +```bash +./build.sh # macOS +``` + +## Tests + +Run these before opening a pull request: + +```powershell +cmake --workflow --preset dev +``` + +That configures, builds both operators plus the test extension, and runs the +pytest suite. It needs no TouchDesigner install — the extension compiles the +real binding sources against a fake `PY_Context`. + +If your change touches the operators' cooking, output sizing, or parameters, +also run the integration suite against a real TouchDesigner: + +```powershell +.\run_td_tests.ps1 +``` + +See [TESTING.md](TESTING.md), including the one-time project wiring it needs. + +## Adding tests + +New Python API surface needs a test in `tests/python/`. Prefer that suite: it is +fast, runs in CI, and needs no license or GPU. Reach for `tests/td/` only for +behaviour that genuinely requires TouchDesigner — cooking, output channels, +parameters. + +If you find surprising-but-intended behaviour, pin it with a test that says why +in its docstring, rather than leaving it undocumented. There are several already +(keyframes being detached copies, the range being independent of channel +content); they exist so the next person does not read them as bugs. + +## Style + +Match the file you are editing — this codebase predates any linter and is not +uniformly formatted. Tabs and 4-space indentation both appear; follow the +surrounding block. + +Comments should explain *why*, not restate the code. If something looks odd but +is deliberate — an invariant, a TouchDesigner API constraint, an ordering that +matters — say so, since that is what a reader cannot recover from the code. + +## Pull requests + +- Branch from `main`. +- Keep a pull request to one concern. +- Say what you changed and why. If it changes behaviour, say what a user would + notice. +- Note whether you ran the TouchDesigner integration suite, since CI cannot. + +## Reporting bugs + +Include: + +- Your TouchDesigner version and platform. +- The AnimationCHOP version (release tag, or commit if built from source). +- A minimal repro — ideally a few lines of Python against a fresh + AnimationCHOP, or a small `.toe`. +- What you expected and what happened. + +Curve-shape and interpolation issues are usually better filed against +[anim](https://github.com/Actualize-Interactive/anim); if you are not sure, file +here and it can be moved. + +## License + +By contributing you agree that your contributions are licensed under the MIT +License, the same as the rest of this repository. Note that `ext/td/` and +`ext/Python/` carry their own third-party terms — see [NOTICE](NOTICE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3ad595b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025-2026 Actualize Interactive Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..4a5f895 --- /dev/null +++ b/NOTICE @@ -0,0 +1,50 @@ +AnimationCHOP +Copyright (c) 2025-2026 Actualize Interactive Inc. + +This product bundles or depends on the following third-party components. + +------------------------------------------------------------------------------- +TouchDesigner Custom Operator SDK headers + Files: ext/td/include/CPlusPlus_Common.h + ext/td/include/CHOP_CPlusPlusBase.h + Copyright (c) Derivative Inc. + +These headers are part of the TouchDesigner Custom Operator SDK (the C++ CHOP +API, version 10, with common API version 2, as shipped with TouchDesigner +2025.33070) and are redistributed here so the operators can be built without a +separate SDK download. They remain the +property of Derivative Inc. and are governed by Derivative's licensing terms +(the "Shared Use License" reproduced at the top of each file), not the MIT +license that covers the rest of this repository. The matching or newer versions +ship with TouchDesigner under + + Samples/CPlusPlus/CHOP/ + +in your TouchDesigner installation. + +------------------------------------------------------------------------------- +CPython + https://www.python.org/ (Python 3.11.1) + Copyright (c) 2001-2024 Python Software Foundation. All Rights Reserved. + Licensed under the PSF License Agreement. + + Files: ext/Python/Include/**, ext/Python/lib/x64/** + +The Windows build vendors the CPython 3.11 headers and import libraries so the +operators can be compiled without a local Python installation matching +TouchDesigner's. Only headers and import libraries are redistributed — no +CPython runtime binaries. TouchDesigner supplies the Python runtime when the +operators are loaded. The macOS build uses TouchDesigner's own Python framework +instead and vendors nothing. + +The full PSF License Agreement is at + https://docs.python.org/3/license.html + +------------------------------------------------------------------------------- +anim + https://github.com/Actualize-Interactive/anim + Copyright (c) 2025-2026 Actualize Interactive Inc. + Licensed under the MIT License. + +The animation curve library the operators are built on. Included as a git +submodule at ext/anim and built from source; not vendored in this repository. diff --git a/README.md b/README.md new file mode 100644 index 0000000..40dd9c8 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# AnimationCHOP + +[![CI](https://github.com/Actualize-Interactive/AnimationCHOP/actions/workflows/ci.yml/badge.svg)](https://github.com/Actualize-Interactive/AnimationCHOP/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +![C++20](https://img.shields.io/badge/C%2B%2B-20-blue.svg) + +Keyframe animation curves for TouchDesigner, as native C++ Custom Operators with +a full Python API. + +Build curves from keyframes with cubic Bézier interpolation, drive them from +Python, and get them back as CHOP channels — without rebuilding an animation +system out of Pattern CHOPs and Lookup CHOPs every time. + +The animation maths lives in [anim](https://github.com/Actualize-Interactive/anim), +a standalone C++20 library; these operators are the TouchDesigner binding around +it. + +## What's included + +| | | +| --- | --- | +| **AnimationCHOP** | Holds named animation channels and cooks them to CHOP samples. This is the operator you script against. | +| **AnimationViewCHOP** | Exposes an AnimationCHOP's internals as CHOP data — samples, keyframes, Bézier segments, channels — for building UI on top. | +| **Keyframer.tox** | A ready-made keyframe editor component built on both operators, with a GLSL-rendered curve view. | + +## Features + +- Named channels, each an independent curve; multiple channels per operator +- Per-keyframe interpolation: `Constant` (step), `Linear`, `Bezier` +- Bézier handle modes: `Flat`, `Smooth`, `Aligned`, `Free`, and the aligned + variants `AlignStrict` / `AlignFlex` / `AlignAdjustable` +- Per-channel extrapolation before the first and after the last keyframe: + `Hold`, `Repeat`, `Mirror` +- Output modes: a fixed time range, an auto range fitted to the content, an + index driven by an input CHOP, or a single scrubbed sequence index +- A complete Python API — create and edit channels and keyframes, evaluate the + curve at any time, and save or restore whole-animation state as a plain dict +- Animations persist inside the `.toe`: channels and keyframes are saved with + the project and restored on load, with no external files + +## Requirements + +- **TouchDesigner 2025.33070 or newer.** Earlier builds will refuse to load the + operators. That build is where the Custom Operator API gained node data + persistence (`saveData`/`loadData`), which is how animations are stored in the + `.toe`; the operators target CHOP API version 10 / common API version 2. +- **Windows or macOS.** Windows builds are x64. + +## Installation + +1. Download the archive for your platform from + [Releases](https://github.com/Actualize-Interactive/AnimationCHOP/releases). +2. Copy the operator libraries into a `Plugins` folder beside your `.toe`: + + ``` + MyProject/ + MyProject.toe + Plugins/ + AnimationCHOP.dll (or .plugin on macOS) + AnimationViewCHOP.dll + ``` + + TouchDesigner only loads Custom Operators from a `Plugins` folder next to the + project file, or from the system-wide plugin folder. +3. Open the project. The first load of a new operator build shows a prompt + asking you to trust it — approve it once. +4. Add an **AnimationCHOP** from the operator palette, or drop in the included + `Keyframer.tox` for the full editor. + +The release archive also contains an example project you can open directly. + +## Quick start + +Everything is driven from Python on the operator itself: + +```python +n = op('animation1') + +# Create a channel and key it +tx = n.create_channel('tx') +tx.create_keyframe(0, 0) +tx.create_keyframe(30, 100, n.Function.BEZIER, n.HandleMode.SMOOTH) + +# Evaluate anywhere on the curve +print(tx.evaluate(15)) + +# Edit in place... +tx.set_keyframe_value(1, 250) + +# ...or read, modify, write back +kf = tx[0] +kf.function = n.Function.LINEAR +tx[0] = kf + +# Save and restore the whole animation +state = n.state +n.clear() +n.state = state +``` + +One thing to know before writing much against it: **`Channel` is a live handle, +but `Keyframe` and `Point` are values.** `tx[0]` gives you a detached copy, so +setting a property on it does not reach the channel until you write it back. +[The docs explain why](docs/README.md#keyframes-are-values-channels-are-handles) — +it is a consequence of how keyframes have to be re-solved against their +neighbours. + +## Documentation + +Full Python API reference under [`docs/`](docs/README.md): + +- [AnimationCHOP](docs/AnimationCHOP.md) — the operator: channels, range, state +- [Channel](docs/Channel.md) — a single curve +- [Keyframe](docs/Keyframe.md) · [Point](docs/Point.md) +- [Function](docs/Function.md) · [HandleMode](docs/HandleMode.md) · [RangeEnd](docs/RangeEnd.md) + +## Building from source + +```bash +git clone --recurse-submodules https://github.com/Actualize-Interactive/AnimationCHOP.git +cd AnimationCHOP +``` + +(If you already cloned without submodules: `git submodule update --init --recursive`.) + +```powershell +# Windows +.\build.ps1 +``` + +```bash +# macOS +./build.sh +``` + +Either way the built operators are copied into `td/Plugins/` so the example +project picks them up. + +Windows vendors the CPython 3.11 headers and import libraries it needs, so there +is nothing to install. macOS builds against TouchDesigner's own Python framework +and expects TouchDesigner in `/Applications`. + +## Testing + +```powershell +cmake --workflow --preset dev # configure, build, run the unit tests +``` + +The pytest suite runs the real binding sources against a fake TouchDesigner +context, so it needs no TouchDesigner install. There is also an integration +harness that drives a real project inside TouchDesigner. See +[TESTING.md](TESTING.md). + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). + +## License + +MIT — see [LICENSE](LICENSE). + +The TouchDesigner SDK headers under `ext/td/` are Derivative Inc.'s and carry +their own terms; the vendored CPython headers are under the PSF License. See +[NOTICE](NOTICE) for the full third-party attribution. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..21af420 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,204 @@ +# AnimationCHOP Testing + +Tests live under `tests/`: + +- `tests/cpp/` — Catch2 over the `.toe` persistence codec (no TouchDesigner needed). +- `tests/python/` — pytest against a compiled test extension (no TouchDesigner needed). +- `tests/td/` — the TouchDesigner project, the in-network test modules, and the + local integration harness (`run_td_tests.ps1` / `run_td_tests.sh`). + +> Note: TouchDesigner cannot run in cloud CI (it needs a license and a GPU), so +> CI only builds the operators and runs the `tests/cpp` and `tests/python` +> suites. The `tests/td` integration test runs against a local TouchDesigner +> install. + +Curve behaviour is not tested here — that belongs to the `anim` submodule, which +carries its own Catch2 suite. What these cover is the binding and TouchDesigner +glue built on top of it. + +## Unit tests (no TouchDesigner) + +```powershell +# Configure + build + run all unit tests in one command: +cmake --workflow --preset dev + +# Re-run just the unit tests after a change: +ctest --preset dev +``` + +CMake auto-detects the uv Python 3.11 (no paths to pass), and the suite runs +through `uv run`, so pytest is fetched automatically — nothing to install first. +(Without uv, install `tests/python/requirements.txt` into the interpreter and +ctest will call `pytest` there instead.) + +`tests/cpp` covers `src/animation_codec.cpp`, the binary format the operator +writes into the `.toe`. Those bytes are persisted user data, so the suite checks +both round-trip fidelity — including that a decoded curve evaluates identically +to the one that was saved — and that a truncated, foreign or out-of-range blob +is rejected outright rather than half-loaded. Catch2 is fetched at configure +time; nothing to install. + +`tests/python` builds a small CPython extension (`animationchop`) that compiles +the *real* operator sources against a fake `PY_Context`, so the bindings are +exercised directly rather than through a copy. Once it is built you can also run +pytest on its own: + +```powershell +uv run --with pytest pytest tests/python +``` + +## Integration test (local TouchDesigner) + +`run_td_tests.ps1` is a local pre-release gate. Run the one script and wait for +pass/fail — it compiles the operators, copies them into `tests/td/Plugins/`, +launches TouchDesigner with `tests/td/test.toe`, runs the suites *inside* +TouchDesigner, writes a `results.json` sentinel, then parses the results, +terminates TouchDesigner, and exits non-zero if anything failed. + +```powershell +.\run_td_tests.ps1 # build + run + report +.\run_td_tests.ps1 -NoBuild # reuse the already-built operators +.\run_td_tests.ps1 -OpName animation1 -TimeoutSec 180 +``` + +```bash +./run_td_tests.sh # macOS +./run_td_tests.sh --no-build --op animation1 +``` + +Four modules implement it, one per operator plus a driver and a shared harness: + +- **`animation_chop_test.py`** — the AnimationCHOP suite. `run_api_tests()` + covers the bindings (Point, Keyframe, enums, animation core, channel, + advanced, error handling, state, state errors, state roundtrip) against the + real operator. `setup_cook_test()` / `check_cook_test()` then do what only an + in-TouchDesigner run can: configure the node's output parameters, let it cook, + and check that the samples it emits match what the channels evaluate to. +- **`animation_view_chop_test.py`** — the AnimationViewCHOP suite. This operator + has no Python API of its own, so *everything* about it is integration-only: + the suite steps through all five view modes (samples, keyframes, segments, + channels, animation), checking each publishes its documented channels and that + the values match the source animation. It also covers empty and + single-keyframe channels, which have no segments. +- **`td_test_runner.py`** drives them as a list of steps, a few frames apart. A + step that reconfigures a node is followed by one that reads what it cooked — + the gap is what lets the cook happen. That is what `run(..., delayFrames=)` is + for; sleeping would block the very frames being waited on. It writes + `results.json` when the last step finishes. +- **`test_result.py`** — the assertion harness both suites share. One + `TestResult` is threaded through the whole run, so the summary and + `results.json` cover everything rather than one module. + +The operators are passed in, so no module needs to know where they live. +TouchDesigner is left running; the host script terminates it once the sentinel +appears. + +### The test project + +`tests/td/test.toe` is in the repo, already wired, so `run_td_tests.ps1` works +without setup. The rest of this section is how it is put together — worth +reading if you are changing the suites, or rebuilding the project from scratch. + +**1. Create the project and the operator.** + +- Make a new project and save it as `tests/td/test.toe`. +- Build first (`.\build.ps1`) so `tests/td/Plugins/` exists — TouchDesigner only + loads Custom Operators from a `Plugins/` folder beside the `.toe`, and CMake's + post-build step puts them there. +- Add an **AnimationCHOP** and name it `animation1`. +- Add an **AnimationViewCHOP** and name it `animationview1`, with its + **Animation / Animation View CHOP** parameter pointing at `animation1`. + (Without one, its suites are skipped and recorded as skipped; everything else + still runs.) +- **First load after a (re)build:** TouchDesigner shows a modal asking you to + approve/trust the newly built Custom Operator. Click to approve. This is + interactive, so the first integration run after a rebuild may need a manual + click. + +**2. Add the test modules as DATs under `/local/modules`.** + +Each is a Text DAT synced to its file on disk, so the repo stays the source of +truth: + +| Module DAT (`/local/modules/…`) | Synced to file | +| --- | --- | +| `td_test_runner` | `tests/td/td_test_runner.py` | +| `test_result` | `tests/td/test_result.py` | +| `animation_chop_test` | `tests/td/animation_chop_test.py` | +| `animation_view_chop_test` | `tests/td/animation_view_chop_test.py` | + +In each DAT, set the **File** parameter to the path above and use **Sync to +File** so TouchDesigner imports them by name. The DAT names must match the file +names, since the modules import each other by name. + +**3. Add the bootstrap Execute DAT.** + +This is the only place that needs to know where the operators are. + +- Enable the Execute DAT's **Start** flag (the `onStart` callback). +- Paste: + + ```python + def onStart(): + import td_test_runner + td_test_runner.start(op('animation1'), op('animationview1')) + return + ``` + +- **Save** `test.toe`. + +(Omit either argument and `start()` resolves it from the `ANIMATIONCHOP_OP` / +`ANIMATIONCHOP_VIEW_OP` environment variables the host script sets, defaulting +to `animation1` and `animationview1`.) + +### Running the suite by hand + +Useful while iterating — watch the textport for the PASS/FAIL lines: + +```python +import animation_chop_test +animation_chop_test.run_api_tests(op('animation1')) +``` + +Or the whole thing — both operators, every view mode, the cooked-output checks — +which writes `results.json` as well: + +```python +import td_test_runner +td_test_runner.start(op('animation1'), op('animationview1')) +``` + +Note that the full run spans many frames by design, so it finishes a second or +two after you invoke it, not immediately. + +### Troubleshooting + +**"no results.json after N s"** — the runner never finished. Most likely: +`test.toe` has no bootstrap Execute DAT (or its Start flag is off); TouchDesigner +is sitting on the trust modal for a freshly rebuilt operator; or the project was +saved with the timeline paused, in which case the frame-delayed cook checks never +fire. Open the project by hand once and check the textport — if the API suites +printed but nothing else did, it is the paused-timeline case. + +**The build fails copying into `Plugins/`** — TouchDesigner has the operator +loaded and Windows will not let anything overwrite a DLL that is in use. The +compile itself succeeded; only the copy failed. Close TouchDesigner and build +again. + +There is no way around this short of killing the process, which the harness +deliberately does not do — you may have unsaved work open. The copy is left to +fail loudly rather than be skipped, since a silently stale plugin would mean +testing the previous build without knowing it. If you only want to compile and +run the unit tests, build the test targets alone, which do not touch `Plugins/`: + +```powershell +cmake --build build --config Release --target animationchop_testext animationchop_cpp_tests +ctest --test-dir build -C Release +``` + +**The operator does not appear in the palette** — `tests/td/Plugins/` is missing +or empty. Run `.\build.ps1`. + +**Cook checks fail but the API suites pass** — the bindings are fine and the +node's output path is not. `check_cook_test()` prints the worst sample delta, +which distinguishes "wrong values" from "wrong sample count". diff --git a/docs/Channel.md b/docs/Channel.md index 4d6b9e5..a22508b 100644 --- a/docs/Channel.md +++ b/docs/Channel.md @@ -2,6 +2,12 @@ A Channel represents an individual animation curve containing keyframes. Channels support sequence operations for easy keyframe access and iteration. +A Channel is a **live handle**: it resolves to the operator's channel on every +access, so two Channel objects naming the same channel see each other's edits, +and one held past a `remove_channel()` raises `RuntimeError`. The keyframes it +returns are **detached copies** — see +[Keyframes are values, Channels are handles](README.md#keyframes-are-values-channels-are-handles). + ## Properties | Property | Type | Description | @@ -45,17 +51,21 @@ Insert an existing keyframe into the channel. ### Keyframe Access +All accessors below return a **detached copy**. Setting a property on the +returned Keyframe changes the copy only; write it back with +`channel[index] = kf` to apply it. + #### `keyframe(index: int) -> Keyframe` -Get keyframe at specified index. +Get a copy of the keyframe at specified index. #### `prev_keyframe(time: float) -> Keyframe | None` -Get the keyframe immediately before the specified time. +Get a copy of the keyframe immediately before the specified time. #### `next_keyframe(time: float) -> Keyframe | None` -Get the keyframe immediately after the specified time. +Get a copy of the keyframe immediately after the specified time. #### `closest_keyframe(time: float) -> Keyframe | None` -Get the keyframe closest to the specified time. +Get a copy of the keyframe closest to the specified time. #### `delete_keyframe(index: int) -> None` Remove keyframe at specified index. @@ -63,7 +73,10 @@ Remove keyframe at specified index. ### Keyframe Modification #### `update_keyframe(index: int, keyframe: Keyframe) -> None` -Replace keyframe at index with new keyframe. +Replace keyframe at index with new keyframe. Equivalent to +`channel[index] = keyframe`. The time is clamped between the neighbouring +keyframes — keyframes never reorder — and the neighbouring handles are re-solved +as needed. #### `set_keyframe_time(index: int, time: float) -> None` Set the time of keyframe at index. @@ -91,14 +104,33 @@ Set the handle mode of keyframe at index. #### `evaluate(time: float) -> float` Evaluate the channel at a specific time. -#### `evaluate_range(start_time: float, end_time: float, num_samples: int) -> list[float]` +Both range methods take an optional trailing `range_end`. It defaults to +`RangeEnd.EXCLUSIVE`: the range is half-open, `end_time` is **not** sampled, and +a span of n sample periods gives n values. That is the timeline reading — a +sample covers the interval that follows it, and the end of a range is an edge — +so looping a curve or joining adjacent ranges does not repeat a value at the +seam. It is also what the operators' own output uses. + +Pass `RangeEnd.INCLUSIVE` when samples are points on the curve rather than spans +of time: plotting, building a lookup table, numeric integration. Without it the +last point falls one step short of the end. + +```python +ch.evaluate_range_by_rate(0, 2, 60) # 120 values, 0 .. 1.983 +ch.evaluate_range_by_rate(0, 2, 60, op.RangeEnd.INCLUSIVE) # 121, the last at 2.0 +``` + +#### `evaluate_range(start_time: float, end_time: float, num_samples: int, range_end: RangeEnd = RangeEnd.EXCLUSIVE) -> list[float]` Evaluate the channel over a time range with specified number of samples. -#### `evaluate_range_by_rate(start_time: float, end_time: float, sample_rate: float) -> list[float]` +#### `evaluate_range_by_rate(start_time: float, end_time: float, sample_rate: float, range_end: RangeEnd = RangeEnd.EXCLUSIVE) -> list[float]` Evaluate the channel over a time range with specified sample rate. -#### `num_samples(sample_rate: float) -> int` -Get the number of samples needed for the channel's duration at given sample rate. +A channel has no `num_samples`. It knows only the extent of its own keyframes, +which is an editing concept rather than the range a host samples over, so a +count taken from it would quietly answer about the wrong span. Use the +operator's [`num_samples`](AnimationCHOP.md), or TouchDesigner's own +`numSamples` on the cooked output. ### State Management @@ -119,10 +151,15 @@ channel = anim_chop.get_channel("tx") # Length count = len(channel) -# Indexing +# Indexing -- returns a detached copy first_keyframe = channel[0] last_keyframe = channel[-1] +# Assignment -- writes a keyframe back, same as update_keyframe(index, kf) +kf = channel[0] +kf.value = 55.0 +channel[0] = kf + # Iteration for keyframe in channel: print(f"Time: {keyframe.time}, Value: {keyframe.value}") @@ -132,6 +169,9 @@ if keyframe in channel: print("Keyframe exists in channel") ``` +Indices may be negative. `del channel[index]` is not supported — use +`delete_keyframe(index)`. + ## Examples ```python diff --git a/docs/Keyframe.md b/docs/Keyframe.md index b7586aa..296e9c6 100644 --- a/docs/Keyframe.md +++ b/docs/Keyframe.md @@ -2,6 +2,12 @@ A Keyframe represents a single animation key with position, handles, and interpolation settings. +A Keyframe is a **value, not a handle**. One obtained from a channel is a +detached copy, so the property setters below change the copy alone — write it +back with `channel[index] = kf` to apply it, or use the channel's +`set_keyframe_*` methods to edit in place. See +[Keyframes are values, Channels are handles](README.md#keyframes-are-values-channels-are-handles). + ## Properties | Property | Type | Description | @@ -81,16 +87,22 @@ kf4 = anim_chop.Keyframe( handle_mode=anim_chop.HandleMode.FREE ) -# Modify properties +# Modify properties (kf1 is standalone, so these are all it needs) kf1.time = 15 kf1.value = 75 kf1.function = anim_chop.Function.BEZIER kf1.handle_mode = anim_chop.HandleMode.SMOOTH -# Handle manipulation +# Handle manipulation. Assign a whole Point -- kf1.in_handle is itself a copy, +# so kf1.in_handle.time = 10 would change nothing. kf1.in_handle = anim_chop.Point(10, 50) kf1.out_handle = anim_chop.Point(20, 100) +# Editing a keyframe that came from a channel needs the write-back step +kf = channel[0] +kf.value = 55 +channel[0] = kf + # State operations state = kf1.state # Save keyframe state kf_copy = anim_chop.Keyframe() diff --git a/docs/Point.md b/docs/Point.md index 781f136..60eecff 100644 --- a/docs/Point.md +++ b/docs/Point.md @@ -2,6 +2,11 @@ A Point represents a time-value pair used for keyframe positions and handle coordinates. +Like [Keyframe](Keyframe.md), a Point is a **value**: one read from a keyframe is +a detached copy, so `kf.in_handle.time = 5` changes nothing. Assign a whole Point +instead — `kf.in_handle = Point(5, 20)`, or +`channel.set_keyframe_in_handle(index, Point(5, 20))`. + ## Properties | Property | Type | Description | diff --git a/docs/README.md b/docs/README.md index ba4fe75..5835222 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ This document provides comprehensive reference for the Python bindings available - [**Function**](Function.md) - Interpolation functions for keyframes - [**HandleMode**](HandleMode.md) - Handle behavior modes for keyframes +- [**RangeEnd**](RangeEnd.md) - Whether a sampled range includes its end time ## Quick Start @@ -32,6 +33,54 @@ for kf in channel: print(f"Time: {kf.time}, Value: {kf.value}") ``` +## Keyframes are values, Channels are handles + +This is the one piece of the API worth reading before you write against it. + +**`Channel` is a live handle.** It resolves to the operator's channel on every +access, so two `Channel` objects naming the same channel see each other's edits, +and a `Channel` held past a `remove_channel()` raises `RuntimeError` rather than +reading freed memory. + +**`Keyframe` and `Point` are values.** `channel.keyframe(i)`, `channel[i]`, +iteration, `prev_keyframe`, `next_keyframe` and `closest_keyframe` all hand back +a *detached copy*. Setting a property on that copy changes the copy alone: + +```python +kf = channel[0] +kf.value = 55.0 # updates the copy +channel[0].value # still the original value -- the channel is untouched +``` + +That is not an oversight. A keyframe has no identity of its own: they live in a +time-ordered list, and any edit has to clamp the keyframe's time between its +neighbours, re-solve their handles and invalidate the channel's evaluation +cache. Only the channel can do that, so only the channel exposes mutators. + +(Because times are clamped rather than re-sorted, keyframes never reorder — an +index stays valid until a keyframe is created or deleted.) + +There are two ways to edit a keyframe, and both are fine: + +```python +# In place -- best when you are changing one thing. +channel.set_keyframe_value(0, 55.0) + +# Round trip -- best when you are changing several at once. +kf = channel[0] +kf.value = 55.0 +kf.function = op.Function.LINEAR +channel[0] = kf # same as channel.update_keyframe(0, kf) +``` + +The same applies one level down: `kf.in_handle` is a copy too, so +`kf.in_handle.time = 5` does nothing. Assign a whole `Point` instead — +`kf.in_handle = op.Point(5, 20)`. + +The `Keyframe` property setters are not dead weight — they are how you build a +keyframe to hand *to* a channel, via `create_keyframe`, `emplace_keyframe` or +the round trip above. + ## State Management All objects support serializable state dictionaries for saving/loading: diff --git a/docs/RangeEnd.md b/docs/RangeEnd.md new file mode 100644 index 0000000..2ea9d1a --- /dev/null +++ b/docs/RangeEnd.md @@ -0,0 +1,59 @@ +# RangeEnd + +Whether a sampled range includes its end time. Passed as an optional trailing +argument to [`Channel.evaluate_range`](Channel.md), +`Channel.evaluate_range_by_rate`. + +## Values + +| Value | Description | +|-------|-------------| +| `RangeEnd.EXCLUSIVE` | The end time is **not** sampled; the range is `[start, end)`. The default. | +| `RangeEnd.INCLUSIVE` | The end time **is** sampled; the range is `[start, end]`. | + +## Which to use + +**`EXCLUSIVE` (the default)** is the timeline reading: a sample covers the +interval that follows it, and the end of a range is an edge rather than a point. +A span of n sample periods gives n samples. This is what frame- and audio-rate +hosts do, and it is what the operators' own CHOP output uses — a CHOP's samples +are implicitly one period apart, since the format stores no per-sample times. + +The practical consequence is that ranges join cleanly: + +```python +# Sampling 0..1 and 1..2 back to back gives exactly the same values as +# sampling 0..2 in one go -- no repeated sample at the seam. +ch.evaluate_range_by_rate(0, 1, 60) + ch.evaluate_range_by_rate(1, 2, 60) +``` + +With an inclusive range each cycle boundary would carry two identical values, +and every caller would have to know to drop one — including anything using +`Extend.REPEAT` or `Extend.MIRROR`. + +**`INCLUSIVE`** is for when samples are points on the curve rather than spans of +time: plotting a curve, building an interpolation lookup table, integrating +numerically. Without it the last point falls one step short of the end, so a +plotted line stops before the final keyframe. + +## Examples + +```python +n = op('animation1') +ch = n.get_channel('tx') # keyframed 0 .. 2 seconds + +# 120 values, the last at 1.9833 +ch.evaluate_range_by_rate(0, 2, 60) + +# 121 values, the last exactly at 2.0 +ch.evaluate_range_by_rate(0, 2, 60, n.RangeEnd.INCLUSIVE) + +# The operator's own count is always the half-open one, matching what it +# outputs; RangeEnd applies to the evaluate calls, not the node's length. +n.num_samples # 120 over a 0..2 range at 60 + +# By count rather than by rate, the two differ in spacing, not length: +# both return 100 values, but INCLUSIVE lands the last one on the end. +ch.evaluate_range(0, 2, 100) +ch.evaluate_range(0, 2, 100, n.RangeEnd.INCLUSIVE) +``` diff --git a/ext/anim b/ext/anim index 61e6168..bb0a5ba 160000 --- a/ext/anim +++ b/ext/anim @@ -1 +1 @@ -Subproject commit 61e616845df8142c399a9db8984cb36a77ddeebf +Subproject commit bb0a5ba017ef0eeb6b52728627bd42cb8d00eb00 diff --git a/ext/td/include/CHOP_CPlusPlusBase.h b/ext/td/include/CHOP_CPlusPlusBase.h index 02c71d5..7ad116c 100644 --- a/ext/td/include/CHOP_CPlusPlusBase.h +++ b/ext/td/include/CHOP_CPlusPlusBase.h @@ -31,14 +31,14 @@ * Derivative Developers:: Make sure the virtual function order * stays the same, otherwise changes won't be backwards compatible */ -#pragma warning(push) -#pragma warning(disable : 4100) #ifndef __CHOP_CPlusPlusBase__ #define __CHOP_CPlusPlusBase__ #include "CPlusPlus_Common.h" +class CHOP_CPlusPlus; + namespace TD { #pragma pack(push, 8) @@ -52,22 +52,65 @@ class CHOP_CPlusPlusBase; // from the samples folder in a newer TouchDesigner installation. // You may need to upgrade your plugin code in that case, to match // the new API requirements -const int CHOPCPlusPlusAPIVersion = 9; +const int CHOPCPlusPlusAPIVersion = 10 | (OP_CommonAPIVersion << 16); class CHOP_PluginInfo { -public: - // Must be set to CHOPCPlusPlusAPIVersion in FillCHOPPluginInfo +private: + // Set it by calling setAPIVersion() int32_t apiVersion = 0; +public: + + // Returns false if the API version is not supported + [[nodiscard]] + int32_t + getAPIVersion() const + { + return apiVersion; + } + + // Should be called with a value of CHOPCPlusPlusAPIVersion + [[nodiscard]] + bool + setAPIVersion(int32_t version) + { + apiVersion = version; + if (!isAPIVersionSupported(version)) + return false; + + return true; + } + [[nodiscard]] + bool + isAPIVersionSupported(int32_t version) + { + return checkAPIVersionSupported(version, MinAPIVersion, MaxAPIVersion); + } int32_t reserved[100]; // Information used to describe this plugin as a custom OP. OP_CustomOPInfo customOPInfo; - int32_t reserved2[20]; + int32_t reserved2[18]; + + static constexpr bool checkPrivateOffsets(); + +private: + // Will be set by the caller of FillTOPPluginInfo() + const int32_t MinAPIVersion = 0; + const int32_t MaxAPIVersion = 0; + friend class ::CHOP_CPlusPlus; }; +constexpr bool +CHOP_PluginInfo::checkPrivateOffsets() +{ + return offsetof(CHOP_PluginInfo, apiVersion) == 0 && + offsetof(CHOP_PluginInfo, MinAPIVersion) == 408 + sizeof(customOPInfo) + 18 * 4 && + offsetof(CHOP_PluginInfo, MaxAPIVersion) == 408 + sizeof(customOPInfo) + 18 * 4 + 4; +} + class CHOP_GeneralInfo { public: @@ -133,7 +176,7 @@ class CHOP_OutputInfo uint32_t startIndex; // Specify the sample rate of the channel data - // DEFAULT : whatever the timeline FPS is ($FPS) + // DEFAULT : whatever the component timeline FPS is. (me.time.rate) float sampleRate; void* reserved1; @@ -178,8 +221,9 @@ class CHOP_Output /***** FUNCTION CALL ORDER DURING INITIALIZATION ******/ /* - When the TOP loads the dll the functions will be called in this order + When the CHOP loads the dll the functions will be called in this order + loadData(const OP_NodeSaveState* saver); setupParameters(OP_ParameterManager* m); */ @@ -347,21 +391,44 @@ class CHOP_CPlusPlusBase } // This is called whenever a dynamic menu type custom parameter needs to have it's content's - // updated. It may happen often, so this could should be efficient. + // updated. It may happen often, so this call should be efficient. virtual void buildDynamicMenu(const OP_Inputs* inputs, OP_BuildDynamicMenuInfo* info, void* reserved1) { } + // Override this method if you want to save arbitrary bytedata with this operator into the toe file. + // `OP_NodeSaveState* saver` has methods like `saveEntry()` that are used to add key, value pairs to be saved. + // This is called whenever the project file is saved or the custom operator is unloaded. + // Usage example in Samples/CPlusPlus/CHOP sample project. + virtual void + saveData(OP_NodeSaveState* saver, void* reserved1) + { + } + + // Override this method if you want to load the bytedata that was saved into the toe file through `saveData()`. + // `OP_NodeLoadState* loader` has methods like `getKey()`, `getKeyCount()`, `loadEntry()` that are used to retrieve key, value pairs. + // This is called during startup of the project file, or whenever the custom operator is loaded/reloaded. + // Usage example in Samples/CPlusPlus/CHOP sample project. + virtual void + loadData(const OP_NodeLoadState* loader, void* reserved1) + { + } + + // Override this method if you want to specify a descriptor string when hovering over the input connectors of the node. + // `inputLabel->label->setString()` sets the label for the input index. + // Usage example in Samples/CPlusPlus/CHOP sample project. + virtual void + inputConnectorLabel(int index, OP_InputLabel* inputLabel, void* reserved1) + { + } + // END PUBLIC INTERFACE private: // Reserved for future features - virtual int32_t reservedFunc6() { return 0; } - virtual int32_t reservedFunc7() { return 0; } - virtual int32_t reservedFunc8() { return 0; } virtual int32_t reservedFunc9() { return 0; } virtual int32_t reservedFunc10() { return 0; } virtual int32_t reservedFunc11() { return 0; } @@ -381,7 +448,7 @@ class CHOP_CPlusPlusBase #pragma pack(pop) -static_assert(offsetof(CHOP_PluginInfo, apiVersion) == 0, "Incorrect Alignment"); +static_assert(CHOP_PluginInfo::checkPrivateOffsets(), "Incorrect Alignment"); static_assert(offsetof(CHOP_PluginInfo, customOPInfo) == 408, "Incorrect Alignment"); static_assert(sizeof(CHOP_PluginInfo) == 944, "Incorrect Size"); @@ -405,8 +472,6 @@ static_assert(offsetof(CHOP_Output, startIndex) == 12, "Incorrect Alignment"); static_assert(offsetof(CHOP_Output, names) == 16, "Incorrect Alignment"); static_assert(offsetof(CHOP_Output, channels) == 24, "Incorrect Alignment"); static_assert(sizeof(CHOP_Output) == 112, "Incorrect Size"); -#endif }; // namespace TD - -#pragma warning(pop) \ No newline at end of file +#endif diff --git a/ext/td/include/CPlusPlus_Common.h b/ext/td/include/CPlusPlus_Common.h index 2269952..0c9b0d1 100644 --- a/ext/td/include/CPlusPlus_Common.h +++ b/ext/td/include/CPlusPlus_Common.h @@ -16,8 +16,7 @@ Derivative Developers: Make sure the virtual function order stays the same, otherwise changes won't be backwards compatible ********/ -#pragma warning(push) -#pragma warning(disable : 4100) + #ifndef __CPlusPlus_Common__ #define __CPlusPlus_Common__ @@ -51,10 +50,41 @@ struct CUstream_st; typedef struct CUstream_st* cudaStream_t; class TOP_CPlusPlus; +class POP_CPlusPlus; namespace TD { +const int OP_CommonAPIVersion = 2; + +inline int32_t +extractFamilyAPIVersion(int32_t version) +{ + return version & 0xFFFF; +} + +inline int32_t +extractCommonAPIVersion(int32_t version) +{ + return (version >> 16) & 0xFFFF; +} + +inline bool +checkAPIVersionSupported(int32_t version, int32_t minVersion, int32_t maxVersion) +{ + if (extractFamilyAPIVersion(version) < extractFamilyAPIVersion(minVersion) || + extractFamilyAPIVersion(version) > extractFamilyAPIVersion(maxVersion)) + { + return false; + } + if (extractCommonAPIVersion(version) < extractCommonAPIVersion(minVersion) || + extractCommonAPIVersion(version) > extractCommonAPIVersion(maxVersion)) + { + return false; + } + return true; +} + class CHOP_PluginInfo; class CHOP_CPlusPlusBase; class DAT_PluginInfo; @@ -62,8 +92,11 @@ class DAT_CPlusPlusBase; class TOP_PluginInfo; class TOP_CPlusPlusBase; class TOP_Context; +class POP_Context; class SOP_PluginInfo; class SOP_CPlusPlusBase; +class POP_PluginInfo; +class POP_CPlusPlusBase; #pragma pack(push, 8) @@ -102,16 +135,155 @@ enum class OP_PixelFormat : int32_t MonoA16Float, MonoA32Float, - // sRGB. use SBGRA if possible since that's what most GPUs use - SBGRA8Fixed = 600, - SRGBA8Fixed, + // Previously sRGB textures, but not supported this way anymore. Use the + // OP_ColorSpace workflow instead. + UnusedReserved1 = 600, + UnusedReserved2 = 601, RGB10A2Fixed = 700, // 11-bit float, positive values only. B is actually 10 bits RGB11Float, +}; +inline bool +isFloatFormat(OP_PixelFormat f) +{ + switch (f) + { + default: + case OP_PixelFormat::Invalid: + case OP_PixelFormat::BGRA8Fixed: + case OP_PixelFormat::RGBA8Fixed: + case OP_PixelFormat::RGBA16Fixed: + case OP_PixelFormat::Mono8Fixed: + case OP_PixelFormat::Mono16Fixed: + case OP_PixelFormat::RG8Fixed: + case OP_PixelFormat::RG16Fixed: + case OP_PixelFormat::A8Fixed: + case OP_PixelFormat::A16Fixed: + case OP_PixelFormat::MonoA8Fixed: + case OP_PixelFormat::MonoA16Fixed: + case OP_PixelFormat::RGB10A2Fixed: + return false; + case OP_PixelFormat::Mono16Float: + case OP_PixelFormat::Mono32Float: + case OP_PixelFormat::RG16Float: + case OP_PixelFormat::RG32Float: + case OP_PixelFormat::A16Float: + case OP_PixelFormat::A32Float: + case OP_PixelFormat::MonoA16Float: + case OP_PixelFormat::MonoA32Float: + case OP_PixelFormat::RGBA16Float: + case OP_PixelFormat::RGBA32Float: + case OP_PixelFormat::RGB11Float: + return true; + } +} -}; +inline bool +isBGRFormat(OP_PixelFormat f) +{ + switch (f) + { + default: + case OP_PixelFormat::Invalid: + case OP_PixelFormat::RGBA8Fixed: + case OP_PixelFormat::RGBA16Fixed: + case OP_PixelFormat::Mono8Fixed: + case OP_PixelFormat::Mono16Fixed: + case OP_PixelFormat::RG8Fixed: + case OP_PixelFormat::RG16Fixed: + case OP_PixelFormat::A8Fixed: + case OP_PixelFormat::A16Fixed: + case OP_PixelFormat::MonoA8Fixed: + case OP_PixelFormat::MonoA16Fixed: + case OP_PixelFormat::RGB10A2Fixed: + case OP_PixelFormat::Mono16Float: + case OP_PixelFormat::Mono32Float: + case OP_PixelFormat::RG16Float: + case OP_PixelFormat::RG32Float: + case OP_PixelFormat::A16Float: + case OP_PixelFormat::A32Float: + case OP_PixelFormat::MonoA16Float: + case OP_PixelFormat::MonoA32Float: + case OP_PixelFormat::RGBA16Float: + case OP_PixelFormat::RGBA32Float: + case OP_PixelFormat::RGB11Float: + return false; + case OP_PixelFormat::BGRA8Fixed: + return true; + } +} + +inline bool +isMonoAlphaFormat(OP_PixelFormat f) +{ + switch (f) + { + default: + case OP_PixelFormat::Invalid: + case OP_PixelFormat::RGBA8Fixed: + case OP_PixelFormat::RGBA16Fixed: + case OP_PixelFormat::Mono8Fixed: + case OP_PixelFormat::Mono16Fixed: + case OP_PixelFormat::RG8Fixed: + case OP_PixelFormat::RG16Fixed: + case OP_PixelFormat::A8Fixed: + case OP_PixelFormat::A16Fixed: + case OP_PixelFormat::RGB10A2Fixed: + case OP_PixelFormat::Mono16Float: + case OP_PixelFormat::Mono32Float: + case OP_PixelFormat::RG16Float: + case OP_PixelFormat::RG32Float: + case OP_PixelFormat::A16Float: + case OP_PixelFormat::A32Float: + case OP_PixelFormat::RGBA16Float: + case OP_PixelFormat::RGBA32Float: + case OP_PixelFormat::RGB11Float: + case OP_PixelFormat::BGRA8Fixed: + return false; + case OP_PixelFormat::MonoA8Fixed: + case OP_PixelFormat::MonoA16Fixed: + case OP_PixelFormat::MonoA16Float: + case OP_PixelFormat::MonoA32Float: + return true; + } +} + +inline bool +isAlphaFormat(OP_PixelFormat f) +{ + switch (f) + { + default: + case OP_PixelFormat::Invalid: + case OP_PixelFormat::RGBA8Fixed: + case OP_PixelFormat::RGBA16Fixed: + case OP_PixelFormat::Mono8Fixed: + case OP_PixelFormat::Mono16Fixed: + case OP_PixelFormat::RG8Fixed: + case OP_PixelFormat::RG16Fixed: + case OP_PixelFormat::RGB10A2Fixed: + case OP_PixelFormat::Mono16Float: + case OP_PixelFormat::Mono32Float: + case OP_PixelFormat::RG16Float: + case OP_PixelFormat::RG32Float: + case OP_PixelFormat::RGBA16Float: + case OP_PixelFormat::RGBA32Float: + case OP_PixelFormat::RGB11Float: + case OP_PixelFormat::BGRA8Fixed: + case OP_PixelFormat::MonoA8Fixed: + case OP_PixelFormat::MonoA16Fixed: + case OP_PixelFormat::MonoA16Float: + case OP_PixelFormat::MonoA32Float: + return false; + case OP_PixelFormat::A8Fixed: + case OP_PixelFormat::A16Fixed: + case OP_PixelFormat::A16Float: + case OP_PixelFormat::A32Float: + return true; + } +} typedef OP_PixelFormat OP_CPUMemPixelType; @@ -124,6 +296,56 @@ enum class OP_TexDim : int32_t eCube, }; +enum class OP_WorkingColorSpace : int32_t +{ + // There is no working color space, colors are passed around as-is without any conversion. + Passthrough, + + // All colors provided and held in textures will be in ACEScg gamut (ACES AP1) with linear transfer function. + ACEScg, + + // For the below 3 color spaces, RGBA/BGRA 8-bit textures will use a sRGB transfer to store the data, + // so accessing the data in shaders etc gives a linearlized version of it. + // Otherwise it will be linear. + // All colors provided will be in sRGB gamut with linear transfer function (sRGB for 8-bit textures). + // This is the same as Rec.709 + SRGBLinear, + // All colors provided will be in Rec.2020 gamut with linear transfer function (sRGB for 8-bit textures).. + Rec2020Linear, + // All colors provided will be in DCI-P3 gamut with linear transfer function (sRGB for 8-bit textures).. + DCIP3Linear, + + // All colors provided will be in ACES2065_1 with linear transfer function. + ACES2065_1 +}; + +inline void +getWorkingColorSpacePrimaries(OP_WorkingColorSpace wcs, + float* rx, float *ry, float* gx, float* gy, float* bx, float* by, float* wx, float* wy) +{ + switch (wcs) + { + case OP_WorkingColorSpace::SRGBLinear: + *rx = 0.64f; *ry = 0.33f; *gx = 0.3f; *gy = 0.6f; *bx = 0.15f; *by = 0.06f; *wx = 0.3127f; *wy = 0.3290f; + break; + case OP_WorkingColorSpace::Rec2020Linear: + *rx = 0.708f; *ry = 0.292f; *gx = 0.170f; *gy = 0.797f; *bx = 0.131f; *by = 0.046f; *wx = 0.3127f; *wy = 0.3290f; + break; + case OP_WorkingColorSpace::ACES2065_1: + *rx = 0.7347f; *ry = 0.2653f; *gx = 0.0f; *gy = 1.0f; *bx = 0.0001f; *by = -0.077f; *wx = 0.32168f; *wy = 0.33767f; + break; + case OP_WorkingColorSpace::ACEScg: + *rx = 0.713f; *ry = 0.293f; *gx = 0.165f; *gy = 0.830f; *bx = 0.128f; *by = 0.044f; *wx = 0.32168f; *wy = 0.33767f; + break; + case OP_WorkingColorSpace::DCIP3Linear: + *rx = 0.680f; *ry = 0.320f; *gx = 0.265f; *gy = 0.690f; *bx = 0.150f; *by = 0.060f; *wx = 0.314f; *wy = 0.351f; + break; + default: + *rx = 0.0f; *ry = 0.0f; *gx = 0.0f; *gy = 0.0f; *bx = 0.0f; *by = 0.0f; *wx = 0.0f; *wy = 0.0f; + break; + } +} + class OP_String; class OP_TOPInputOpenGL; class OP_TOPInputDownloadOptionsOpenGL; @@ -140,7 +362,7 @@ class PY_GetInfo // the node's state to be up-to-date before doing it's work. bool autoCook; - int32_t reserved[50]; + int32_t reserved[50] = {}; }; class PY_Context @@ -158,7 +380,7 @@ class PY_Context // you should call this at the end of your python code. virtual void makeNodeDirty(void* reserved = nullptr) = 0; - int32_t reserved[50]; + int32_t reserved[50] = {}; }; #define OP_STRUCT_HEADER_ENTRIES 256 @@ -206,26 +428,18 @@ template class OP_SmartRef { public: - OP_SmartRef() : myTarget(nullptr) { } - OP_SmartRef(T* t) - { - if (t) - t->acquire(); - myTarget = t; - } - OP_SmartRef(const OP_SmartRef& t) : myTarget(nullptr) { operator=(t); } - OP_SmartRef(OP_SmartRef&& t) : + OP_SmartRef(OP_SmartRef&& t) noexcept : myTarget(nullptr) { operator=(std::move(t)); @@ -236,6 +450,14 @@ class OP_SmartRef release(); } + // Takes ownership, the caller should *not* call release() on the object. + void + takeOwnership(T* t) + { + release(); + myTarget = t; + } + void operator=(const OP_SmartRef& t) { @@ -250,7 +472,7 @@ class OP_SmartRef } void - operator=(OP_SmartRef&& t) + operator=(OP_SmartRef&& t) noexcept { if (this == &t || myTarget == t.myTarget) return; @@ -286,6 +508,7 @@ class OP_SmartRef T* myTarget; friend class ::TOP_CPlusPlus; + friend class ::POP_CPlusPlus; }; // Used to describe this Plugin so it can be used as a custom OP. @@ -376,12 +599,16 @@ class OP_CustomOPInfo // then fill in the stub code for the DAT here. // This will cause a Callbacks DAT parameter to be added to the first page of // your node's parameters. - // This should be setup with empty/stub functions along with comments, + // This should be setup with empty/stub functions along with comments, // similar to the way other Callback DATs are pre-filled in other nodes in TouchDesigner. // Note: This only works when the .dll is installed as a Custom OP, not as a C++ OP. const char* pythonCallbacksDAT = nullptr; - int32_t reserved[88]; + // If you want to specify a website URL to direct to when the Operator Help button is pressed + // set this to that URL + OP_String* opHelpURL = nullptr; + + int32_t reserved[85] = {}; }; // This class is used to provide direct access to the instance of a Custom OP @@ -505,13 +732,60 @@ class OP_NodeInfo HINSTANCE processHInstance; #endif + // If the project is set to have a working color space, this will be set. + // Otherwise it will be OP_WorkingColorSpace::Passthrough. + OP_WorkingColorSpace workingColorSpace; + #ifdef _WIN32 - int32_t reserved[12]; + int32_t reserved[11] = {}; #else - int32_t reserved[14]; + int32_t reserved[13] = {}; #endif }; +class OP_Parameters +{ +public: + // Returns true on success, false if the parameter does not exist. + // Note, use getParRGB and getParRGBA for RGB and RGBA parameters. + virtual bool getParDouble(const char* name, double& v, int32_t index = 0) const = 0; + virtual bool getParDouble2(const char* name, double& v0, double& v1) const = 0; + virtual bool getParDouble3(const char* name, double& v0, double& v1, double& v2) const = 0; + virtual bool getParDouble4(const char* name, double& v0, double& v1, double& v2, double& v3) const = 0; + + // Returns true on success, false if the parameter does not exist. + virtual bool getParInt(const char* name, int32_t& v, int32_t index = 0) const = 0; + virtual bool getParInt2(const char* name, int32_t& v0, int32_t& v1) const = 0; + virtual bool getParInt3(const char* name, int32_t& v0, int32_t& v1, int32_t& v2) const = 0; + virtual bool getParInt4(const char* name, int32_t& v0, int32_t& v1, int32_t& v2, int32_t& v3) const = 0; + + // Returns the requested value + // The return value is valid until the parameters are rebuilt or it is called with the same parameter name. + // Return value usable for life of parameter + // The returned string will be in UTF-8 encoding. + // Can be used to get menu entries as well. + virtual const char* getParString(const char* name) const = 0; + + // This is similar to getParString, but will return an absolute path if it exists, with + // slash direction consistent with O/S requirements. + // To get the original parameter value, use getParString. + // Return value usable for life of parameter. + // The returned string will be in UTF-8 encoding. + virtual const char* getParFilePath(const char* name) const = 0; + + // Will take the working color space into account (if enabled), and return the values in that, + // using the Parameter Color Space parameter to interpret the values. + // That is, the values written in the parameters are treated as what is set in the 'Parameter Color Space', + // parameter, and the values you get here will be in the working color space. + // Therefore, they will always be returns as one of the spaces in OP_WorkingColorSpace, + // which are always linear transfer. + virtual bool getParRGB(const char* name, double& r, double& g, double& b) const = 0; + virtual bool getParRGBA(const char* name, double& r, double& g, double& b, double& a) const = 0; +private: + + int32_t reserved[200] = {}; +}; + class OP_DATInput { public: @@ -537,29 +811,105 @@ class OP_DATInput // The number of times this node has cooked int64_t totalCooks; - // See documentation for OPCustomOPInstance + // See comments that preceed the declaration of the OP_CustomOPInstance class + // for more information const OP_CustomOPInstance* customOP; - int32_t reserved[16]; + // This can be used to read parameters from this node. + const OP_Parameters* parms = nullptr; + +private: + + int32_t reserved[14] = {}; +}; + +enum class OP_ColorSpace : uint32_t +{ + // For DefaultForWorkingColorSpace, the data is in whatever color space the project's Working Color Space. + // The Working Color Space can be obtained from OP_NodeInfo.workingColorSpace member. + // Note the special treatment for when 4-channel 8-bit textures some color spaces though. + // + // OP_WorkingColorSpace::SRGBLinear + // OP_WorkingColorSpace::Rec2020Linear + // OP_WorkingColorSpace::DCIP3Linear + // When the working color space is one of the above, and the OP_PixelFormat is RGBA8Fixed or BGRA8Fixed, + // the data will have an sRGB transfer, to maintain the extra detail for the darker colors. + // Note that this is a non-standard transfer for DCIP3, which is usually just gamma 2.6. + // But GPUs can do sRGB natively so we use that. + // All other formats are the native gamut with a linear transfer, + // including other 8-bit formats such as RG8Fixed. + DefaultForWorkingColorSpace = 0, + + // When there is no working color space or if the color space is unknown, this will be set as the color space. + // If you have data you are providing that you don't want converted to the working color space, use this color space. + Passthrough, + + // sRGB gamut with sRGB transfer function. + SRGB, + // sRGB gamut with linear transfer function. + SRGBLinear, + // ACES AP0 gamut with linear transfer function. + ACES2065_1, + // ACES AP1 gamut with linear transfer function. + ACEScg, + // ACES AP1 gamut with a log transfer function. + ACESproxy, + // These all have non-linear transfer functions, as specified by their specs + Rec601PAL, + Rec601NTSC, + Rec709, + Rec2020, + DCIP3, + DCIP3D60, + DisplayP3D65, + Rec2020ST2084PQ, + Rec2020HLG, + // Linear transfers versions of the above + DisplayP3D65Linear, + DCIP3Linear, + Rec2020Linear, +}; + +enum class OP_ReferenceWhite : uint32_t +{ + DefaultForColorSpace = 0, + + SDR, + HDR, + UI, }; class OP_TOPInputDownloadOptions { public: - OP_TOPInputDownloadOptions() - { - verticalFlip = false; - pixelFormat = OP_PixelFormat::Invalid; - } - // Set this to true if you want the image vertically flipped in the // downloaded data - bool verticalFlip; + bool verticalFlip = false; // Set this to how you want the pixel data to be give to you in CPU memory. // Leave this as Invalid if you want to download the texture in it's GPU native format. // Only 2D textures can be converted to other formats. 3D/Cube/2DArray all must have this set as Invalid. - OP_PixelFormat pixelFormat; + OP_PixelFormat pixelFormat = OP_PixelFormat::Invalid; + + // Only has an effect when the Working Color Space is not passthrough. + // Can be set to something other than OP_ColorSpace::WorkingColorSpace + // if the data should be converted to a specific color space before it's downloaded. + OP_ColorSpace colorSpace = OP_ColorSpace::DefaultForWorkingColorSpace; + + // Only has an effect when the Working Color Space is not passthrough. + OP_ReferenceWhite referenceWhite = OP_ReferenceWhite::DefaultForColorSpace; + +private: + int32_t reserved[30] = {}; +}; + +// This is seperate from TOP_InputDownloadOptions to avoid break backwards compatibility with +class OP_TOPExtraInputDownloadOptions +{ +public: + +private: + int32_t reserved[30] = {}; }; class OP_TextureDesc @@ -572,7 +922,7 @@ class OP_TextureDesc uint32_t width = 0; uint32_t height = 0; - // Depth for 3D and 2D_ARRAY textures, 1 for other texture types + // Depth for e3D and e2DArray textures, 1 for other texture types uint32_t depth = 1; OP_TexDim texDim = OP_TexDim::eInvalid; @@ -605,12 +955,15 @@ class OP_TOPDownloadResult : public OP_RefCount // and start working on the data as soon as it's ready (such as outputting to an external device). virtual void* getData() = 0; - // The size in bytes of the data. + // The size in bytes of the data. uint64_t size = 0; OP_TextureDesc textureDesc; - int32_t reserved[32]; + // When there is a working color space, this will be set to the color space of the data. + OP_ColorSpace colorSpace; + + int32_t reserved[31]; }; @@ -660,6 +1013,8 @@ class OP_TOPInput // Can only be called from a C++ TOP/Custom TOP that is working in TOP_ExecuteMode::CUDA. Will error/return nullptr in other // cases. Should only be called from within execute(), and the returned pointer will remain valids until execute() returns. // Returns a OP_CUDArrayInfo* that can be used to get the cudaArray* pointer for the texture memory for this TOP. + // This call should be done before beginCUDAOperations(), but the returned object should not be used until + // beginCUDAOperations() has been called afterwards. virtual const OP_CUDAArrayInfo* getCUDAArray(const OP_CUDAAcquireInfo& info, void* reserved2) const = 0; const char* opPath; @@ -670,10 +1025,14 @@ class OP_TOPInput // The number of times this node has cooked int64_t totalCooks; - // See documentation for OPCustomOPInstance + // See comments that preceed the declaration of the OP_CustomOPInstance class + // for more information const OP_CustomOPInstance* customOP; - int32_t reserved[12]; + // This can be used to read parameters from this node. + const OP_Parameters* parms = nullptr; + + int32_t reserved[10] = {}; protected: virtual void* reserved0() = 0; @@ -683,6 +1042,413 @@ class OP_TOPInput virtual void* reserved4() = 0; }; +enum class POP_AttributeClass : uint32_t +{ + Vertex = 0, + Point, + Primitive +}; + +enum class POP_AttributeType : uint32_t +{ + Float = 0, + Double, + Int32, + UInt32, +}; + +enum class POP_AttributeQualifier : uint32_t +{ + None, + // Treat this attribute as a direction. + // Needed for attributes such as Normals to be transformed correctly. + Direction, + + // Treat a matrix attribute as a transform matrix. + TransformMatrix, + + // Treat this attribute as a Color + Color, + + // Treat this attribute as a Quaternion + Quaternion, +}; + +class POP_AttributeInfo +{ +public: + const char* name = ""; + uint32_t numComponents = 4; + // Set this above 1 to make a matrix. + // numComponents will be the number of rows + uint32_t numColumns = 1; + + // Controls if the attribute is an array attribute instead of just a single + // attribute be element. + // 0 means not an array, >= 1 means an array of that size. + uint32_t arraySize = 0; + + POP_AttributeType type = POP_AttributeType::Float; + POP_AttributeQualifier qualifier = POP_AttributeQualifier::None; + POP_AttributeClass attribClass = POP_AttributeClass::Point; + +private: + int32_t reserved[30] = {}; +}; + +class POP_PointInfo +{ +public: + // The number of point attribute elements that have been provided in the buffers + uint32_t numPoints = 0; + +private: + int32_t reserved[20] = {}; +}; + +class POP_TopologyInfo +{ +public: + // 3 vertex triangles + // *StartIndex is the location in the index buffer that the primitives of this type start at + uint32_t trianglesStartIndex = 0; + // The number of triangles (not the number of vertices/indices) + uint32_t trianglesCount = 0; + + // 4 vertex quads + uint32_t quadsStartIndex = 0; + uint32_t quadsCount = 0; + + // Line strips that can be made up of any number of points. + // Each line strip must be terminated with an entry in the + // index buffer that is 0xFFFFFFFF, + uint32_t lineStripsStartIndex = 0; + uint32_t lineStripsCount = 0; + // This must include the restart indices as well + uint32_t lineStripsNumVertices = 0; + + // 2 vertex lines + uint32_t linesStartIndex = 0; + uint32_t linesCount = 0; + + // 1 vertex points + uint32_t pointPrimitivesStartIndex = 0; + uint32_t pointPrimitivesCount = 0; + + uint32_t + getNumPrimitives() const + { + return trianglesCount + quadsCount + lineStripsCount + linesCount + pointPrimitivesCount; + } + + uint32_t + getNumVerticies() const + { + return trianglesCount * 3 + quadsCount * 4 + lineStripsNumVertices + linesCount * 2 + pointPrimitivesCount; + } + +private: + int32_t reserved[20] = {}; +}; + +class POP_GridInfo +{ +public: + // Because we are using a 'flexible array member' at the end of this class + // to hold the gridDimensions, you should allocate your buffer using + // this function, instead of using sizeof(POP_GridInfo) + static uint64_t + getRequiredSize(uint32_t numDims) + { + // Avoid underflow + if (numDims == 0) + numDims = 1; + return sizeof(POP_GridInfo) + sizeof(uint32_t) * (numDims - 1); + } + // This can be optionally passed an array of uint32_t that denote the dimension size + // of N-dimensionalal grid metadata. + // Some POPs make use of this data to interpret grids of points. + uint32_t gridDimensionsCount = 0; + + int32_t reserved[20] = {}; + + // This class should be allocated using createBuffer() or malloc(), using the size + // which is obtained from getRequiredSize(). + // This allows us to read into this array with values larger than 1 without + // an memory overflow. + uint32_t gridDimensions[1]; +}; + +enum class POP_BufferLocation : uint32_t +{ + CPU = 0, + +#ifdef _WIN32 + CUDA = 20, + // Return the buffer where it currently resides. If it's currently on the CPU + // it will return it there, if it's currently on the GPU then it will return it + // as a CUDA buffer. + // This is only valid when getting buffers, not for creating them. + CPUOrCUDA = 21, +#endif +}; + +enum class POP_BufferMode : uint32_t +{ + // You should write to the buffer sequentially. + // Avoid random access writes, or any reads as it may have + // a high impact on performance. + SequentialWrite = 0, + + // Freely read/write the memory + ReadWrite, +}; + +enum class POP_BufferUsage : uint32_t +{ + Attribute = 0, + IndexBuffer, + PointInfoBuffer, + TopologyInfoBuffer, + LineStripsInfoBuffer, + GridInfoBuffer, +}; + +class POP_BufferInfo +{ +public: + uint64_t size = 0; + POP_BufferMode mode = POP_BufferMode::SequentialWrite; + POP_BufferUsage usage = POP_BufferUsage::Attribute; + POP_BufferLocation location = POP_BufferLocation::CPU; + + // If the location is CUDA, then this should be set to the cudaStream_t that will used the buffer + cudaStream_t stream = 0; + +private: + int32_t reserved[18] = {}; +}; + +class POP_Buffer : public OP_RefCount +{ +protected: + POP_Buffer() {} + virtual ~POP_Buffer() {} + +public: + POP_BufferInfo info; + + // When this buffer is retrieved from an input, if if the location i POP_BufferLocation::CPU, + // then getData() will stall until the GPU->CPU download has completed. + // If the location is CUDA, then you will be immediately be given the CUDA device pointer. + // + // For CPU data, you can use this to have another thread stall waiting for the data to be ready before processing. + // Or you can hold onto the POP_Buffer until a later time (to avoid the stall) and consume the data + // on the next cook. + virtual void* getData(void* reserved) = 0; + +private: + int32_t reserved[50] = {}; +}; + +class POP_GetBufferInfo +{ +public: + // Specifies where you want the data to be located when returned. If the data is not currently + // where it is requested, it will be transfered to that location. + // + // Note that when requesting CUDA memory, it must be done before beginCUDAOperations() is called, + // and only inside of a POP. + POP_BufferLocation location = POP_BufferLocation::CPU; + + // This should be set to the cudaStream_t that will be used for operations that will use + // the buffer. + cudaStream_t stream = 0; + + // You can optionally supply a previously used buffer you own as an candidate for the output buffer. + // If this buffer is suitable, the data will be copied into it instead of allocating a new + // buffer. You should not use this buffer agani after giving it to this function. + // It may get returned by the getBuffer() call. + // Currently only used if this is for a CUDA buffer. + OP_SmartRef outputBufferCandidate; +private: + int32_t reserved[18] = {}; +}; + +// When Point and/or Topology is only known on the GPU, we can't know the actual values on the CPU. +// This Info is used to keep track of the upper bounds of possible points and/or primitives. +class POP_MaxInfo +{ +public: + // Point attributes (not point primitives, that is 'pointPrims'). + uint32_t points = 0; + + // The maximum number of each prim type that may be defined in the topology. + // The actual number must be less than or equal to this maximum. + uint32_t triangles = 0; + uint32_t quads = 0; + + // The maximum number of line strips that may be defined in the topology + uint32_t lineStrips = 0; + // The maximum number of vertices any single line strip may have. + uint32_t lineStripVertices = 0; + + uint32_t lines = 0; + uint32_t pointPrims = 0; + +private: + int32_t reserved[30] = {}; +}; + +class POP_InfoBuffers +{ +public: + + // The data format of this should be POP_PointInfo + OP_SmartRef pointInfo; + + // The data format of this should be POP_TopologyInfo + OP_SmartRef topoInfo; + + // If the topology info is provided in a buffer that resides on the GPU (such as CUDA) + // Then you must also provide some maximum information. + // This will be ignored if the topoInfo is on the CPU. + POP_MaxInfo maxInfo; + + // This must be a buffer with pairs of uint32_t values, one pair for each line strip + // that is being given. The values are + // { lineStripStartIndex, lineStripNumVertices } + // where: + // lineStripStartIndex: Is the 0-based location in the index buffer where this line strip starts. + // 0 means the index where the line strips first appear in the index buffer. + // So the number of triangle/quad indices that appear before it don't matter. + // lineStripNumVertices: the number of vertices in the line strip, including the restart index. + // E.g A 5 vertex line strip followed by a 10 vertex line strip would have the entries: + // It's acceptable to have gaps where indices inthe buffer are skipped, such as in cases + // where a line strip has some vertices deleted. + // [0, 5], [5, 10] + OP_SmartRef lineStripsInfo; + + // This must be a buffer of uint32_t with the same number of entries as POP_TopologyInfo.lineStripsNumVertices + // Each entry should be the line strip primitive index that index buffer entry matches up with. + // Restart index entries should be incldued as well. + // E.g A 3 point line strip followed by a 4 point line strip would be + // [0, 0, 0, 0, 1, 1, 1, 1, 1] + OP_SmartRef lineStripsPrimIndices; + + // The data format of this should be POP_GridInfo. This should always be provided + // via a buffer on the CPU. + OP_SmartRef gridInfo; + +private: + int32_t reserved[200] = {}; +}; + +class POP_Attribute +{ +protected: + POP_Attribute() {} + virtual ~POP_Attribute() {} +public: + + POP_AttributeInfo info; + + virtual OP_SmartRef getBuffer(const POP_GetBufferInfo& info, void* reserved) const = 0; + +private: + int32_t reserved[20] = {}; +}; + +enum class POP_IndexType : uint32_t +{ + UInt32 = 0, +}; + +class POP_IndexBufferInfo +{ +public: + POP_IndexType type = POP_IndexType::UInt32; + +private: + int reserved[20] = {}; +}; + +class POP_IndexBuffer +{ +protected: + POP_IndexBuffer() {} + virtual ~POP_IndexBuffer() {} +public: + + POP_IndexBufferInfo info; + + // Get the actual index buffer, an array of uint32_t values. + virtual OP_SmartRef getBuffer(const POP_GetBufferInfo& info, void* reserved) const = 0; + +private: + int32_t reserved[20] = {}; +}; + +class OP_POPInput +{ +protected: + virtual ~OP_POPInput() + { + } +public: + const char* opPath; + uint32_t opId; + + // The number of times this node has cooked + int64_t totalCooks; + + // See comments that preceed the declaration of the OP_CustomOPInstance class + // for more information + const OP_CustomOPInstance* customOP; + + // This can be used to read parameters from this node. + const OP_Parameters* parms = nullptr; + + // Gets the number of attributes in that particular attribute class + virtual uint32_t getNumAttributes(POP_AttributeClass) const = 0; + // These calls are fast, so you can safely loop over them multiple times to query attributes. + // The same pointer is returned from multiple calls with the same arguments. + // Returns nullptr if the attribute doesn't exist + virtual const POP_Attribute* getAttribute(POP_AttributeClass, uint32_t index, void* reserved) const = 0; + virtual const POP_Attribute* getAttribute(POP_AttributeClass, const char* name, void* reserved) const = 0; + virtual const POP_IndexBuffer* getIndexBuffer(void* reserved) const = 0; + + // Get the TopologyInfo. This may be coming from the GPU or the CPU, depending on the source POP. + // Cast the resulting data to POP_TopologyInfo + virtual OP_SmartRef getTopologyInfo(const POP_GetBufferInfo& info, void* reserved) const = 0; + // Get the TopologyInfo. This may be coming from the GPU or the CPU, depending on the source POP. + // Cast the resulting data to POP_PointInfo + virtual OP_SmartRef getPointInfo(const POP_GetBufferInfo& info, void* reserved) const = 0; + + // See documentation in POP_InfoBuffers for the format of this buffer + virtual OP_SmartRef getLineStripsInfo(const POP_GetBufferInfo& info, void* reserved) const = 0; + + // See documentation in POP_InfoBuffers for the format of this buffer + virtual OP_SmartRef getLineStripsPrimIndices(const POP_GetBufferInfo& info, void* reserved) const = 0; + + // See documentation in POP_GridInfo for the format of this buffer. + // Note that this data will always be returned on the CPU. We return an empty buffer if it's + // requested to be as CUDA memory. + virtual OP_SmartRef getGridInfo(const POP_GetBufferInfo& info, void* reserved) const = 0; + + virtual void getMaxInfo(POP_MaxInfo* maxInfo, void* reserved) const = 0; + + // Helper function to get all of the Info buffers in one call, if you know you need them all. + // Note that some of the info buffers are always on the CPU, so you must ask for the data as + // POP_BufferLocation::CPU or POP_BufferLocation::CPUOrCUDA. Will return false if this fails. + virtual bool getAllInfoBuffers(POP_InfoBuffers* buffers, const POP_GetBufferInfo& info, + void* reserved) const = 0; + +protected: + + int32_t reserved[10]; +}; + class OP_String { protected: @@ -740,10 +1506,16 @@ class OP_CHOPInput // The number of times this node has cooked int64_t totalCooks; - // See documentation for OPCustomOPInstance + // See comments that preceed the declaration of the OP_CustomOPInstance class + // for more information const OP_CustomOPInstance* customOP; - int32_t reserved[16]; + // This can be used to read parameters from this node. + const OP_Parameters* parms = nullptr; + +private: + + int32_t reserved[14]; }; class OP_ObjectInput @@ -752,17 +1524,26 @@ class OP_ObjectInput const char* opPath; uint32_t opId; - // Use these methods to calculate object transforms + // These matrices are in column-vector convention. They are addressed via [r][c], + // and the translate is located in [0][3], [1][3] and [2][3]. + // The memory layout is row-by-row though. Most APIs expect the memory layout + // to be vector-by-vector (so column-by-column for a column-vector matrix), + // So it may need to be converted for your API. double worldTransform[4][4]; double localTransform[4][4]; // The number of times this node has cooked int64_t totalCooks; - int32_t reserved[18]; + // This can be used to read parameters from this node. + const OP_Parameters* parms = nullptr; + +private: + int32_t reserved[16] = {}; }; -// The type of data the attribute holds +// The type of data the attribute holds. +// For SOPs only enum class AttribType : int32_t { // One or more floats @@ -772,18 +1553,22 @@ enum class AttribType : int32_t Int, }; -// Right now we only support point attributes. +// The type of data the attribute holds. +// For SOPs only enum class AttribSet : int32_t { - Invalid, + Invalid = -1, Point = 0, + Vertex, + Primitive, }; // The type of the primitives, currently only Polygon type -// is supported +// is supported. +// For SOPs only enum class PrimitiveType : int32_t { - Invalid, + Invalid = -1, Polygon = 0, }; @@ -1118,27 +1903,27 @@ class BoundingBox // returns the bounding box length in x axis: float - sizeX() + sizeX() const { return maxX - minX; } // returns the bounding box length in y axis: float - sizeY() + sizeY() const { return maxY - minY; } // returns the bounding box length in z axis: float - sizeZ() + sizeZ() const { return maxZ - minZ; } bool - getCenter(Position* pos) + getCenter(Position* pos) const { if (!pos) return false; @@ -1150,7 +1935,7 @@ class BoundingBox // verifies if the input position (pos) is inside the current bounding box or not: bool - isInside(const Position& pos) + isInside(const Position& pos) const { if (pos.x >= minX && pos.x <= maxX && pos.y >= minY && pos.y <= maxY && @@ -1171,6 +1956,11 @@ class BoundingBox }; + +// SOP_PrimitiveInfo, all the required data for each primitive +// this info can be queried by calling getPrimitive() which accepts +// a valid index of a primitive as an input argument + class SOP_NormalInfo { public: @@ -1287,6 +2077,7 @@ class SOP_PrimitiveInfo type = PrimitiveType::Invalid; pointIndicesOffset = 0; isClosed = true; + memset(reserved, 0, sizeof(reserved)); } // number of vertices of this prim @@ -1333,16 +2124,16 @@ class OP_SOPInput // Returns an array of point positions. This array is getNumPoints() long. virtual const Position* getPointPositions() const = 0; - // Returns an array of normals. + // Returns an array of point normals. // // Returns nullptr if no normals are present virtual const SOP_NormalInfo* getNormals() const = 0; - // Returns an array of colors. + // Returns an array of point colors. // Returns nullptr if no colors are present virtual const SOP_ColorInfo* getColors() const = 0; - // Returns an array of texture coordinates. + // Returns an array of point texture coordinates. // If multiple texture coordinate layers are present, they will be placed // interleaved back-to-back. // E.g layer0 followed by layer1 followed by layer0 etc. @@ -1356,11 +2147,11 @@ class OP_SOPInput // Returns the custom attribute data with its name virtual const SOP_CustomAttribData* getCustomAttribute(const char* customAttribName) const = 0; - // Returns true if the SOP has a normal attribute of the given source + // Returns true if the SOP has a normal point attribute of the given source // attribute 'N' virtual bool hasNormals() const = 0; - // Returns true if the SOP has a color the given source + // Returns true if the SOP has a color point attribute of the given source // attribute 'Cd' virtual bool hasColors() const = 0; @@ -1373,7 +2164,7 @@ class OP_SOPInput float &hitU, float &hitV, int &hitPrimitiveIndex) = 0; // Returns the SOP_PrimitiveInfo with primIndex - const SOP_PrimitiveInfo + const SOP_PrimitiveInfo& getPrimitive(int32_t primIndex) const { return myPrimsInfo[primIndex]; @@ -1387,16 +2178,45 @@ class OP_SOPInput return myPrimPointIndices; } + // Returns an array of vertex colors. + // Returns nullptr if no colors are present + virtual const SOP_ColorInfo* getVtxColors() const = 0; + + // Returns an array of vertex texture coordinates. + // If multiple texture coordinate layers are present, they will be placed + // interleaved back-to-back. + // E.g layer0 followed by layer1 followed by layer0 etc. + // + // Returns nullptr if no texture layers are present + virtual const SOP_TextureInfo* getVtxTextures() const = 0; + + // Returns an array of primitive colors. + // Returns nullptr if no colors are present + virtual const SOP_ColorInfo* getPrimColors() const = 0; + + // Returns true if the SOP has a color vertex attribute of the given source +// attribute 'Cd' + virtual bool hasVtxColors() const = 0; + + // Returns true if the SOP has a color primitive attribute of the given source + // attribute 'Cd' + virtual bool hasPrimColors() const = 0; + SOP_PrimitiveInfo* myPrimsInfo; const int32_t* myPrimPointIndices; // The number of times this node has cooked int64_t totalCooks; - // See documentation for OPCustomOPInstance + // See comments that preceed the declaration of the OP_CustomOPInstance class + // for more information const OP_CustomOPInstance* customOP; - int32_t reserved[95]; + // This can be used to read parameters from this node. + const OP_Parameters* parms = nullptr; + +private: + int32_t reserved[93]; }; class OP_TimeInfo @@ -1439,10 +2259,6 @@ class OP_TimeInfo class OP_Inputs { public: - // NOTE: When writting a TOP, none of these functions should - // be called inside a beginGLCommands()/endGLCommands() section - // as they may require GL themselves to complete execution. - // Inputs that are wired into the node. Note that since some inputs // may not be connected this number doesn't mean that that the first N // inputs are connected. For example on a 3 input node if the 3rd input @@ -1458,6 +2274,7 @@ class OP_Inputs virtual const OP_CHOPInput* getInputCHOP(int32_t index) const = 0; // getInputSOP() declared later on in the class // getInputDAT() declared later on in the class + // getInputPOP() declared later on in the class // these are defined by parameters. // may return nullptr when invalid input @@ -1470,6 +2287,7 @@ class OP_Inputs virtual const OP_CHOPInput* getParCHOP(const char *name) const = 0; virtual const OP_ObjectInput* getParObject(const char *name) const = 0; // getParSOP() declared later on in the class + // getParPOP() declared later on in the class // these work on any type of parameter and can be interchanged // for menu types, int returns the menu selection index, string returns the item @@ -1478,11 +2296,11 @@ class OP_Inputs virtual double getParDouble(const char* name, int32_t index = 0) const = 0; // for multiple values: returns True on success/false otherwise + // Note, use getParRGB and getParRGBA for RGB and RGBA parameters. virtual bool getParDouble2(const char* name, double &v0, double &v1) const = 0; virtual bool getParDouble3(const char* name, double &v0, double &v1, double &v2) const = 0; virtual bool getParDouble4(const char* name, double &v0, double &v1, double &v2, double &v3) const = 0; - // returns the requested value virtual int32_t getParInt(const char* name, int32_t index = 0) const = 0; @@ -1552,6 +2370,20 @@ class OP_Inputs virtual const OP_TOPInput* getTOP(const char* path) const = 0; virtual const OP_TOPInput* getInputTOP(int32_t index) const = 0; virtual const OP_TOPInput* getParTOP(const char *name) const = 0; + + virtual const OP_POPInput* getInputPOP(int32_t index) const = 0; + virtual const OP_POPInput* getParPOP(const char *name) const = 0; + + // Will take the working color space into account (if enabled), and return the values in that, + // using the Parameter Color Space parameter to interpret the values. + // That is, the values written in the parameters are treated as what is set in the 'Parameter Color Space', + // parameter, and the values you get here will be in the working color space. + // Therefore, they will always be returns as one of the spaces in OP_WorkingColorSpace, + // which are always linear transfer. + virtual bool getParRGB(const char* name, double &r, double &g, double &b) const = 0; + virtual bool getParRGBA(const char* name, double &r, double &g, double &b, double &a) const = 0; + + virtual const OP_POPInput* getPOP(const char* path) const = 0; }; class OP_InfoCHOPChan @@ -1592,6 +2424,45 @@ class OP_InfoDATEntries int32_t reserved[10]; }; +// Class for specifying labels for the node input connectors. +class OP_InputLabel +{ +public: + OP_String* label; + + int32_t reserved[10] = {}; +}; + +// Class for saving and loading arbitrary bytedata into and from the toe file. +class OP_NodeSaveState +{ +public: + // Save a key, value pair into the toe file. + // `data` is assumed to be a bytedata array. + // `dataByteSize` is the size in bytes of the bytedata array `data`. + // e.g saveEntry("entry1","value1",6); + virtual void saveEntry(const char* key, const void* data, int64_t dataByteSize) = 0; + + int32_t reserved[20] = {}; +}; + +class OP_NodeLoadState +{ +public: + // Returns the value stored under `key`. + // `dataByteSize` outputs the size of the stored bytedata array. + virtual const void* loadEntry(const char* key, int64_t* dataByteSize) const = 0; + + // Return number of stored key,value pairs. + virtual int getKeyCount() const = 0; + + // Return a key based on an index. + // Use the key returned from `getKey()` to get the value via `loadEntry()`. + virtual const char* getKey(int n) const = 0; + + int32_t reserved[20] = {}; +}; + class OP_NumericParameter { public: @@ -1599,6 +2470,7 @@ class OP_NumericParameter { name = iname; label = page = nullptr; + help = nullptr; for (int i = 0; i<4; i++) { @@ -1612,7 +2484,12 @@ class OP_NumericParameter clampMins[i] = false; clampMaxes[i] = false; + + size = 1; + + section = false; } + memset(reserved, 0, sizeof(reserved)); } // Any char* values passed are copied immediately by the append parameter functions, @@ -1632,7 +2509,17 @@ class OP_NumericParameter double minSliders[4]; double maxSliders[4]; - int32_t reserved[20]; + // Set the number of values associated with the parameter. When greater than 1, the parameter will be shown as multiple adjacent fields. + // size is supported only for appendFloat(), appendInt(), appendToggle(), appendMomentary(). Will error if used otherwise. + int32_t size; + + // Set the parameter's separator status. When True, a visible separator is drawn between this parameter and the ones preceding it. + bool section; + + // Set the parameter's help text. To see any parameter's help, rollover the parameter while holding the Alt key. + const char* help; + + int32_t reserved[16]; }; @@ -1644,6 +2531,10 @@ class OP_StringParameter name = iname; label = page = nullptr; defaultValue = nullptr; + size = 1; + section = false; + help = nullptr; + memset(reserved, 0, sizeof(reserved)); } // Any char* values passed are copied immediately by the append parameter functions, @@ -1657,7 +2548,17 @@ class OP_StringParameter // This should be in UTF-8 encoding. const char* defaultValue; - int32_t reserved[20]; + // Set the number of values associated with the parameter. When greater than 1, the parameter will be shown as multiple adjacent fields. + // size is supported only for appendMenu(). Will error if used otherwise. + int32_t size; + + // Set the parameter's separator status. When True, a visible separator is drawn between this parameter and the ones preceding it. + bool section; + + // Set the parameter's help text. To see any parameter's help, rollover the parameter while holding the Alt key. + const char* help; + + int32_t reserved[16]; }; enum class OP_ParAppendResult : int32_t @@ -1686,7 +2587,6 @@ class OP_BuildDynamicMenuInfo class OP_ParameterManager { - public: // Returns OP_ParAppendResult::Success on success virtual OP_ParAppendResult appendFloat(const OP_NumericParameter &np, int32_t size = 1) = 0; @@ -1694,10 +2594,13 @@ class OP_ParameterManager virtual OP_ParAppendResult appendXY(const OP_NumericParameter &np) = 0; virtual OP_ParAppendResult appendXYZ(const OP_NumericParameter &np) = 0; + // appendXYZW() added further down virtual OP_ParAppendResult appendUV(const OP_NumericParameter &np) = 0; virtual OP_ParAppendResult appendUVW(const OP_NumericParameter &np) = 0; + // These should be evaluted with getParRGB and getParRGBA, so you get the + // values in the working color space (if any). virtual OP_ParAppendResult appendRGB(const OP_NumericParameter &np) = 0; virtual OP_ParAppendResult appendRGBA(const OP_NumericParameter &np) = 0; @@ -1706,6 +2609,7 @@ class OP_ParameterManager virtual OP_ParAppendResult appendString(const OP_StringParameter &sp) = 0; virtual OP_ParAppendResult appendFile(const OP_StringParameter &sp) = 0; + // appendFileSave() located further down in the class virtual OP_ParAppendResult appendFolder(const OP_StringParameter &sp) = 0; virtual OP_ParAppendResult appendDAT(const OP_StringParameter &sp) = 0; @@ -1714,13 +2618,20 @@ class OP_ParameterManager virtual OP_ParAppendResult appendObject(const OP_StringParameter &sp) = 0; // appendSOP() located further down in the class - + // Add a menu that will always return a value that is present as one of the entries. + // This is different from a StringMenu, which can hold values that arn't one of the entries. + // If the entry selected is not longer in the menu's entries, it will default to the first entry. + // This can happen for example if the entries in the menu change between versions of the oeprator. // Any char* values passed are copied immediately by the append parameter functions, // and do not need to be retained by the calling function. virtual OP_ParAppendResult appendMenu(const OP_StringParameter &sp, int32_t nitems, const char **names, const char **labels) = 0; + // Add a string parameter that has a > dropdown on the right with menu entries. + // This allows for a string parameter with some quick auto-fill options. + // The parameter can still be set to values that are different from any of the + // entries though. // Any char* values passed are copied immediately by the append parameter functions, // and do not need to be retained by the calling function. virtual OP_ParAppendResult appendStringMenu(const OP_StringParameter &sp, @@ -1742,15 +2653,32 @@ class OP_ParameterManager virtual OP_ParAppendResult appendMomentary(const OP_NumericParameter &np) = 0; virtual OP_ParAppendResult appendWH(const OP_NumericParameter &np) = 0; - // The buildDynamicMenu() function will be called in your class instance when required, allowing you to + // This has a confusing name, since it actually creates a menu that looks like what appendDynamicMenu() adds. + // It does not look like the one appendStringMenu adds. + // This is different from appendDynamicMenu() because the value of the menu may not match any of the entries. + // This can occur for example if the menu is a device list, and the project is loaded on another machine that + // doesn't have the device that thet the project was saved as. In that case the menu entries will show the + // devices the new machine has, but the value of the parameter stays as what was saved in the project, + // until the users picks a new entry in the dropdown for one of the current entries. + // + // The buildDynamicMenu() callback function will be called in your class instance when required, allowing you to // fill the menu with custom entries based on other parameters or external state (such as available devices). virtual OP_ParAppendResult appendDynamicStringMenu(const OP_StringParameter &sp) = 0; + // Behaves like the appendMenu() type parameter, but with a dynamic list of entries. virtual OP_ParAppendResult appendDynamicMenu(const OP_NumericParameter &np) = 0; + virtual OP_ParAppendResult appendXYZW(const OP_NumericParameter& np) = 0; + + virtual OP_ParAppendResult appendFileSave(const OP_StringParameter& sp) = 0; + + + virtual OP_ParAppendResult appendPOP(const OP_StringParameter& sp) = 0; + }; #pragma pack(pop) +#ifndef __CUDACC__ static_assert(offsetof(OP_CustomOPInfo, opType) == 0, "Incorrect Alignment"); static_assert(offsetof(OP_CustomOPInfo, opLabel) == 8, "Incorrect Alignment"); static_assert(offsetof(OP_CustomOPInfo, opIcon) == 16, "Incorrect Alignment"); @@ -1780,10 +2708,6 @@ static_assert(offsetof(OP_DATInput, cellData) == 24, "Incorrect Alignment"); static_assert(offsetof(OP_DATInput, totalCooks) == 32, "Incorrect Alignment"); static_assert(sizeof(OP_DATInput) == 112, "Incorrect Size"); -static_assert(offsetof(OP_TOPInput, opPath) == 8, "Incorrect Alignment"); -static_assert(offsetof(OP_TOPInput, opId) == 16, "Incorrect Alignment"); -static_assert(offsetof(OP_TOPInput, textureDesc) == 20, "Incorrect Alignment"); -static_assert(offsetof(OP_TOPInput, totalCooks) == 156 + 20, "Incorrect Alignment"); static_assert(sizeof(OP_TOPInput) == 156 + 28 + 56, "Incorrect Size"); static_assert(offsetof(OP_CHOPInput, opPath) == 0, "Incorrect Alignment"); @@ -1869,6 +2793,11 @@ static_assert(sizeof(OP_InfoDATSize) == 52, "Incorrect Size"); static_assert(offsetof(OP_InfoDATEntries, values) == 0, "Incorrect Alignment"); static_assert(sizeof(OP_InfoDATEntries) == 48, "Incorrect Size"); +static_assert(sizeof(OP_InputLabel) == 48, "Incorrect Size"); + +static_assert(sizeof(OP_NodeSaveState) == 88, "Incorrect Size"); +static_assert(sizeof(OP_NodeLoadState) == 88, "Incorrect Size"); + static_assert(offsetof(OP_NumericParameter, name) == 0, "Incorrect Alignment"); static_assert(offsetof(OP_NumericParameter, label) == 8, "Incorrect Alignment"); static_assert(offsetof(OP_NumericParameter, page) == 16, "Incorrect Alignment"); @@ -1900,6 +2829,21 @@ static_assert(offsetof(PY_GetInfo, autoCook) == 0, "Incorrect Alignment"); static_assert(sizeof(PY_GetInfo) == 204, "Incorrect Size"); static_assert(sizeof(PY_Context) == 208, "Incorrect Size"); static_assert(offsetof(PY_Struct, context) == OP_STRUCT_HEADER_ENTRIES * sizeof(int32_t), "Incorrect Alignment"); + +static_assert(offsetof(POP_BufferInfo, size) == 0, "Incorrect Alignment"); +static_assert(offsetof(POP_BufferInfo, mode) == 8, "Incorrect Alignment"); +static_assert(offsetof(POP_BufferInfo, usage) == 12, "Incorrect Alignment"); +static_assert(offsetof(POP_BufferInfo, location) == 16, "Incorrect Alignment"); +static_assert(offsetof(POP_BufferInfo, stream) == 24, "Incorrect Alignment"); +static_assert(sizeof(POP_BufferInfo) == 104, "Incorrect Size"); + +static_assert(offsetof(POP_GetBufferInfo, location) == 0, "Incorrect Alignment"); +static_assert(offsetof(POP_GetBufferInfo, stream) == 8, "Incorrect Alignment"); +static_assert(sizeof(POP_GetBufferInfo) == 96, "Incorrect Size"); + +static_assert(sizeof(OP_TOPDownloadResult) == sizeof(OP_RefCount) + sizeof(OP_TextureDesc) + 12 + 32 * sizeof(int32_t), "Incorrect Size"); + +#endif // CUDACC }; // These are the definitions for the C-functions that are used to @@ -1916,7 +2860,8 @@ typedef void (__cdecl *DESTROYTOPINSTANCE)(TD::TOP_CPlusPlusBase*, TD::TOP_Conte typedef void(__cdecl *FILLSOPPLUGININFO)(TD::SOP_PluginInfo *info); typedef TD::SOP_CPlusPlusBase* (__cdecl *CREATESOPINSTANCE)(const TD::OP_NodeInfo*); typedef void(__cdecl *DESTROYSOPINSTANCE)(TD::SOP_CPlusPlusBase*); +typedef void(__cdecl *FILLPOPPLUGININFO)(TD::POP_PluginInfo *info); +typedef TD::POP_CPlusPlusBase* (__cdecl *CREATEPOPINSTANCE)(const TD::OP_NodeInfo*, TD::POP_Context*); +typedef void(__cdecl *DESTROYPOPINSTANCE)(TD::POP_CPlusPlusBase*); #endif - -#pragma warning(pop) \ No newline at end of file diff --git a/run_td_tests.ps1 b/run_td_tests.ps1 new file mode 100644 index 0000000..4c45596 --- /dev/null +++ b/run_td_tests.ps1 @@ -0,0 +1,140 @@ +<# +.SYNOPSIS + TouchDesigner integration test: launch TouchDesigner, run the AnimationCHOP + test suites inside it, and gate the exit code on the result. + +.DESCRIPTION + TouchDesigner cannot run in cloud CI (it needs a license and a GPU), so this + is a LOCAL pre-release gate. It launches tests/td/test.toe, which must + contain a one-time bootstrap Execute DAT that calls td_test_runner.start() + on start (see TESTING.md). The in-TD runner writes results.json (and leaves + TouchDesigner running); this script waits for that sentinel, terminates + TouchDesigner, parses the results, and exits 0 (pass) or 1 (fail / timeout). + +.PARAMETER NoBuild Skip building; use the operator already in td/Plugins/. +.PARAMETER Toe Path to the .toe to run. Default: tests/td/test.toe +.PARAMETER OpName Name of the AnimationCHOP operator in the project. Default: animation1 +.PARAMETER ViewOpName Name of the AnimationViewCHOP operator. Default: animationview1 +.PARAMETER TimeoutSec How long to wait for results.json. Default: 180 +.PARAMETER TdPath Path to TouchDesigner.exe. Default: newest install found. + +.EXAMPLE + .\run_td_tests.ps1 # builds, copies the plugin, runs, reports pass/fail +.EXAMPLE + .\run_td_tests.ps1 -NoBuild # reuse the already-built plugin +#> +[CmdletBinding()] +param( + [switch] $NoBuild, + [string] $Toe = (Join-Path $PSScriptRoot "tests/td/test.toe"), + [string] $OpName = "animation1", + [string] $ViewOpName = "animationview1", + [int] $TimeoutSec = 180, + [string] $TdPath = "" +) + +$ErrorActionPreference = "Stop" + +function Resolve-TouchDesigner { + param([string] $Explicit) + if ($Explicit) { + if (Test-Path $Explicit) { return $Explicit } + throw "TouchDesigner.exe not found at: $Explicit" + } + if ($env:ANIMATIONCHOP_TD -and (Test-Path $env:ANIMATIONCHOP_TD)) { return $env:ANIMATIONCHOP_TD } + $candidates = Get-ChildItem "C:/Program Files/Derivative/TouchDesigner*/bin/TouchDesigner.exe" -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending + if ($candidates) { return $candidates[0].FullName } + throw "Could not find TouchDesigner.exe. Pass -TdPath or set ANIMATIONCHOP_TD." +} + +# Build by default so the operators are always freshly compiled AND copied into +# td/Plugins/ (CMake's POST_BUILD step handles the copy). The dev just runs this +# script; no manual build-or-copy step. +if (-not $NoBuild) { + Write-Host "Building AnimationCHOP (compiles + copies to td/Plugins/)..." -ForegroundColor Cyan + & (Join-Path $PSScriptRoot "build.ps1") + if ($LASTEXITCODE -ne 0) { throw "Build failed." } +} + +if (-not (Test-Path $Toe)) { + Write-Host "No project at: $Toe" -ForegroundColor Red + Write-Host "Create it and wire the bootstrap Execute DAT -- see TESTING.md." -ForegroundColor Yellow + exit 1 +} + +$toeFull = (Resolve-Path $Toe).Path +$td = Resolve-TouchDesigner -Explicit $TdPath +$resultsPath = Join-Path (Split-Path -Parent $toeFull) "results.json" + +Write-Host "TouchDesigner : $td" -ForegroundColor DarkGray +Write-Host "Project : $toeFull" -ForegroundColor DarkGray +Write-Host "Results file : $resultsPath" -ForegroundColor DarkGray + +# Clear any stale results so we only ever read this run's output. +if (Test-Path $resultsPath) { Remove-Item $resultsPath -Force } + +# The in-TD runner reads these (inherited by the child process). +$env:ANIMATIONCHOP_RESULTS = $resultsPath +$env:ANIMATIONCHOP_OP = $OpName +$env:ANIMATIONCHOP_VIEW_OP = $ViewOpName + +Write-Host "Launching TouchDesigner..." -ForegroundColor Cyan +$proc = Start-Process -FilePath $td -ArgumentList "`"$toeFull`"" -PassThru + +# Wait for the sentinel or timeout. +$deadline = (Get-Date).AddSeconds($TimeoutSec) +$found = $false +while ((Get-Date) -lt $deadline) { + if (Test-Path $resultsPath) { $found = $true; break } + if ($proc.HasExited -and -not (Test-Path $resultsPath)) { + Start-Sleep -Milliseconds 500 # let a last-moment write flush + if (Test-Path $resultsPath) { $found = $true } + break + } + Start-Sleep -Milliseconds 500 +} + +# The in-TD runner leaves TouchDesigner running; terminate it now. +if (-not $proc.HasExited) { + Write-Host "Stopping TouchDesigner..." -ForegroundColor DarkGray + try { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } catch {} +} + +if (-not $found) { + Write-Host "" + Write-Host "FAILED: no results.json after $TimeoutSec s." -ForegroundColor Red + Write-Host "Is test.toe wired with the bootstrap Execute DAT? See TESTING.md." -ForegroundColor Yellow + Write-Host "A freshly rebuilt operator also needs a one-time trust click." -ForegroundColor Yellow + exit 1 +} + +$summary = Get-Content $resultsPath -Raw | ConvertFrom-Json +Write-Host "" +Write-Host "==== AnimationCHOP integration results ====" -ForegroundColor Cyan +foreach ($r in $summary.results) { + $tag = if ($r.passed) { "PASS" } else { "FAIL" } + $color = if ($r.passed) { "Green" } else { "Red" } + Write-Host (" [{0}] {1} -- {2}" -f $tag, $r.name, $r.detail) -ForegroundColor $color +} + +# The suites run several hundred assertions, so the per-suite lines above are the +# summary and these are the detail that actually tells you what broke. +if ($summary.failures -and $summary.failures.Count -gt 0) { + Write-Host "" + Write-Host "Failures:" -ForegroundColor Red + foreach ($f in $summary.failures) { + Write-Host (" [{0}] {1} -- {2}" -f $f.suite, $f.name, $f.detail) -ForegroundColor Red + } +} + +Write-Host "" +Write-Host ("Total: {0} passed, {1} failed" -f $summary.passed, $summary.failed) -ForegroundColor Cyan + +if ($summary.success) { + Write-Host "`nAll integration tests passed." -ForegroundColor Green + exit 0 +} else { + Write-Host "`nIntegration tests failed." -ForegroundColor Red + exit 1 +} diff --git a/run_td_tests.sh b/run_td_tests.sh new file mode 100644 index 0000000..57f4558 --- /dev/null +++ b/run_td_tests.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# TouchDesigner integration test (macOS). Builds + copies the operators, launches +# TouchDesigner with tests/td/test.toe (which must contain the bootstrap Execute +# DAT — see TESTING.md), waits for the results.json sentinel the in-TD runner +# writes, then parses it and exits 0 (pass) or 1 (fail / timeout). +# +# Usage: ./run_td_tests.sh [--no-build] [--op NAME] [--timeout SEC] +# [--toe PATH] [--td APP_PATH] [--view-op NAME] +set -euo pipefail + +cd "$(dirname "$0")" + +no_build=0 +op="animation1" +view_op="animationview1" +timeout_sec=180 +toe="tests/td/test.toe" +td_app="${ANIMATIONCHOP_TD:-/Applications/TouchDesigner.app}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --no-build) no_build=1; shift ;; + --op) op="$2"; shift 2 ;; + --view-op) view_op="$2"; shift 2 ;; + --timeout) timeout_sec="$2"; shift 2 ;; + --toe) toe="$2"; shift 2 ;; + --td) td_app="$2"; shift 2 ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +if [[ ! -f "$toe" ]]; then + echo "No project at: $toe" >&2 + echo "Create it and wire the bootstrap Execute DAT — see TESTING.md." >&2 + exit 1 +fi + +toe_full="$(cd "$(dirname "$toe")" && pwd)/$(basename "$toe")" +td_bin="$td_app/Contents/MacOS/TouchDesigner" +results_path="$(dirname "$toe_full")/results.json" + +if [[ ! -x "$td_bin" ]]; then + echo "TouchDesigner not found at: $td_bin (set ANIMATIONCHOP_TD or pass --td)" >&2 + exit 1 +fi + +echo "TouchDesigner : $td_bin" +echo "Project : $toe_full" +echo "Results file : $results_path" + +if [[ $no_build -eq 0 ]]; then + echo "Building AnimationCHOP (compiles + copies to td/Plugins/)..." + ./build.sh +fi + +rm -f "$results_path" + +export ANIMATIONCHOP_RESULTS="$results_path" +export ANIMATIONCHOP_OP="$op" +export ANIMATIONCHOP_VIEW_OP="$view_op" + +echo "Launching TouchDesigner..." +"$td_bin" "$toe_full" & +td_pid=$! + +deadline=$(( $(date +%s) + timeout_sec )) +found=0 +while [[ $(date +%s) -lt $deadline ]]; do + if [[ -f "$results_path" ]]; then found=1; break; fi + if ! kill -0 "$td_pid" 2>/dev/null; then + sleep 1 + [[ -f "$results_path" ]] && found=1 + break + fi + sleep 1 +done + +# The in-TD runner leaves TouchDesigner running; terminate it now. +kill "$td_pid" 2>/dev/null || true + +if [[ $found -eq 0 ]]; then + echo "" + echo "FAILED: no results.json after ${timeout_sec}s." >&2 + echo "Is test.toe wired with the bootstrap Execute DAT? See TESTING.md." >&2 + echo "A freshly rebuilt operator also needs a one-time trust click." >&2 + exit 1 +fi + +echo "" +echo "==== AnimationCHOP integration results ====" +python3 - "$results_path" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +for r in d["results"]: + tag = "PASS" if r["passed"] else "FAIL" + detail = (" -- " + r["detail"]) if r.get("detail") else "" + print(f" [{tag}] {r['name']}{detail}") +failures = d.get("failures") or [] +if failures: + print("\nFailures:") + for f in failures: + print(f" [{f['suite']}] {f['name']} -- {f['detail']}") +print(f"\nTotal: {d['passed']} passed, {d['failed']} failed") +sys.exit(0 if d.get("success") else 1) +PY diff --git a/src/animation_chop.cpp b/src/animation_chop.cpp index 71da97c..0ca50f0 100644 --- a/src/animation_chop.cpp +++ b/src/animation_chop.cpp @@ -13,12 +13,14 @@ */ #include "animation_chop.h" +#include "animation_codec.h" #include "py_anim_bindings/py_bindings.h" #include "py_anim_bindings/py_extend.h" #include #include +#include #include #include @@ -36,6 +38,67 @@ #include #endif +namespace { + +// The output length for the range/auto-range modes. +// +// Delegates to the animation rather than recomputing, so the count cannot drift +// from the data: num_samples() shares its half-open span and its rounding rule +// with evaluate_range_by_rate(), which execute() fills the output from. A span +// of n sample periods is n samples -- end_time is not sampled -- which is also +// what a CHOP means by a sample count. +// +// Guards the rate because num_samples() throws on a non-positive one, and an +// exception must not cross TouchDesigner's C API. Clamps up because a CHOP +// cannot have zero samples, while an animation with no channels has none. +int32_t rangeSampleCount(const anim::Animation& animation, double sampleRate) +{ + if (sampleRate <= 0.0) + return 1; + + const size_t count = animation.num_samples(sampleRate); + return count < 1 ? 1 : static_cast(count); +} + +// Move the animation's range to [start, end]. +// +// set_start_time/set_end_time each clamp against the current opposite bound, so +// assigning in a fixed order clamps against a stale one whenever the whole +// range moves (a [0,30] node told to become [50,70] would land on [30,70] for a +// frame). Widening before narrowing settles it in one cook, and an inverted +// range collapses to a zero-length one rather than throwing. +void setAnimationRange(anim::Animation& animation, double start, double end) +{ + animation.set_end_time(std::max(start, end)); + animation.set_start_time(start); + animation.set_end_time(end); +} + +// Zero one output channel. +// +// TouchDesigner allocates the sample buffer but does not initialise it -- the +// SDK header promises only that it is "already allocated for you" -- so a +// sample left unwritten keeps whatever was in that memory, which surfaces as +// NaN. Every sample the CHOP asks for has to be written exactly once, so this +// is only for the cases with no data to write: an error return, or an output +// channel with no animation channel behind it. The normal path fills the buffer +// itself and must not be pre-cleared -- that would write every sample twice, +// every cook. +void silenceChannel(CHOP_Output* output, int32_t index) +{ + std::fill_n(output->channels[index], output->numSamples, 0.0f); +} + +// Zero the whole output, for the paths that bail out before producing anything. +void silenceOutput(CHOP_Output* output) +{ + for (int32_t i = 0; i < output->numChannels; ++i) { + silenceChannel(output, i); + } +} + +} // namespace + // static PyObject* py_animationFromDict(PyObject* self, PyObject* args); static PyObject* py_create_channel(PyObject* self, PyObject* args); static PyObject* py_emplace_channel(PyObject* self, PyObject* args); @@ -49,7 +112,10 @@ static PyObject* py_get_state_method(PyObject* self, PyObject* args); static PyObject* py_set_state_method(PyObject* self, PyObject* args); // --- Python method table for AnimationCHOP --- -static PyMethodDef methods[] = { +// Externally linked (and declared in animation_chop.h) so the pytest extension +// under tests/python can expose the very same table, rather than a copy that +// would drift. +PyMethodDef AnimationCHOP_pythonMethods[] = { {"create_channel", (PyCFunction)py_create_channel, METH_VARARGS, "Create a new channel."}, {"remove_channel", (PyCFunction)py_remove_channel, METH_VARARGS, "Remove a channel by name or index."}, {"has_channel", (PyCFunction)py_has_channel, METH_VARARGS, "Check if a channel exists."}, @@ -75,12 +141,14 @@ static PyObject* py_get_state(PyObject* self, void* closure); static int py_set_state(PyObject* self, PyObject* value, void* closure); // This struct lists the different getters and/or settings the Custom Operator will expose. -static PyGetSetDef getSets[] = +// Externally linked for the same reason as AnimationCHOP_pythonMethods above. +PyGetSetDef AnimationCHOP_pythonGetSets[] = { {"Point", get_point_type, nullptr, "Point type for representing time-value pairs.", nullptr}, {"HandleMode", get_handle_mode_enum, nullptr, "HandleMode enum for keyframe handle behavior.", nullptr}, {"Function", get_function_enum, nullptr, "Function enum for keyframe interpolation type.", nullptr}, {"Extend", get_extend_enum, nullptr, "Extend enum for channel extrapolation behavior.", nullptr}, + {"RangeEnd", get_range_end_enum, nullptr, "RangeEnd enum controlling whether a sampled range includes its end time.", nullptr}, {"Keyframe", get_keyframe_type, nullptr, "Keyframe type for animation curves.", nullptr}, {"channels", py_get_channels, nullptr, "Get all channels.", nullptr}, {"channel_names", py_get_channel_names, nullptr, "Get all channel names.", nullptr}, @@ -116,8 +184,11 @@ DLLEXPORT void FillCHOPPluginInfo(CHOP_PluginInfo *info) { - // Always set this to CHOPCPlusPlusAPIVersion. - info->apiVersion = CHOPCPlusPlusAPIVersion; + // Always set this to CHOPCPlusPlusAPIVersion. The version is recorded even + // when unsupported, so bailing out here lets TouchDesigner report the + // mismatch rather than loading a half-filled plugin info. + if (!info->setAPIVersion(CHOPCPlusPlusAPIVersion)) + return; // The opType is the unique name for this BasicCHOP. It must start with a // capital A-Z character, and all the following characters must lower case @@ -141,8 +212,8 @@ FillCHOPPluginInfo(CHOP_PluginInfo *info) info->customOPInfo.maxInputs = 1; info->customOPInfo.pythonVersion->setString(PY_VERSION); - info->customOPInfo.pythonMethods = methods; - info->customOPInfo.pythonGetSets = getSets; + info->customOPInfo.pythonMethods = AnimationCHOP_pythonMethods; + info->customOPInfo.pythonGetSets = AnimationCHOP_pythonGetSets; info->customOPInfo.pythonCallbacksDAT = PythonCallbacksDATStubs; } @@ -238,12 +309,8 @@ AnimationCHOP::getOutputInfo(CHOP_OutputInfo* info, const OP_Inputs* inputs, voi } case OutputMode::range: { auto start_time = inputs->getParDouble("Range", 0); auto end_time = inputs->getParDouble("Range", 1); - m_animation->set_start_time(start_time); - m_animation->set_end_time(end_time); - info->numSamples = static_cast(end_time * info->sampleRate); - if (info->numSamples < 1) { - info->numSamples = 1; - } + setAnimationRange(*m_animation, start_time, end_time); + info->numSamples = rangeSampleCount(*m_animation, info->sampleRate); break; } case OutputMode::autoRange: default: { double max_length = 0.0; @@ -251,9 +318,8 @@ AnimationCHOP::getOutputInfo(CHOP_OutputInfo* info, const OP_Inputs* inputs, voi const auto& channel = m_animation->channel(i); max_length = std::max(max_length, channel.length()); } - info->numSamples = static_cast(std::ceil(max_length * info->sampleRate)); - m_animation->set_start_time(0.0); - m_animation->set_end_time(max_length); + setAnimationRange(*m_animation, 0.0, max_length); + info->numSamples = rangeSampleCount(*m_animation, info->sampleRate); break; } } @@ -288,18 +354,26 @@ AnimationCHOP::execute(CHOP_Output* output, const OP_Inputs* inputs, void* reser switch(m_outputMode) { case OutputMode::input: { + // Each of these bails out with nothing to write, so the output has to + // be silenced rather than left as it was. Reporting the problem is not + // enough on its own: an untouched buffer reads as NaN, which looks like + // a fault in the data rather than a missing connection. const OP_CHOPInput* input_chop = inputs->getInputCHOP(0); if (!input_chop) { m_error = "No input CHOP connected."; + silenceOutput(output); return; } else if (input_chop->numChannels < 1) { m_error = "Input CHOP has no channels."; + silenceOutput(output); return; } else if (input_chop->numSamples != output->numSamples) { m_error = "Input CHOP and output CHOP have different number of samples."; + silenceOutput(output); return; } else if (input_chop->sampleRate != output->sampleRate) { m_error = "Input CHOP and output CHOP have different sample rates."; + silenceOutput(output); return; } @@ -325,10 +399,25 @@ AnimationCHOP::execute(CHOP_Output* output, const OP_Inputs* inputs, void* reser for (int j = 0; j < output->numSamples; ++j) { output->channels[i][j] = static_cast(m_animation->channel(i).evaluate(eval_times[j])); } - } + } else { + // No animation channel or no input channel to drive it, so + // nothing to evaluate -- but the sample still has to be written. + silenceChannel(output, static_cast(i)); + } } return; } case OutputMode::sequence: { + // The fill loops below skip any output channel with no animation + // channel behind it. getOutputInfo sized the output from that same + // count, so this is only reachable if the animation shrank in between + // -- from Python, between cooks -- but those samples still have to be + // written. Done once here rather than as an else on each of the six + // fill loops. + for (int32_t i = static_cast(m_animation->num_channels()); + i < output->numChannels; ++i) { + silenceChannel(output, i); + } + auto index_unit = inputs->getParString("Indexunit"); auto eval_time = inputs->getParDouble("Sequence"); @@ -401,16 +490,38 @@ AnimationCHOP::execute(CHOP_Output* output, const OP_Inputs* inputs, void* reser } case OutputMode::range: case OutputMode::autoRange: default: { size_t num_anim_channels = m_animation->num_channels(); for (int i = 0; i < output->numChannels; i++) { - if (i < static_cast(num_anim_channels)) { - auto samples = m_animation->channel(i).evaluate_range( + if (i >= static_cast(num_anim_channels)) { + silenceChannel(output, i); // no animation channel behind it + } else { + // By rate, not by count. A CHOP's samples are implicitly one + // period apart -- the format stores no per-sample times -- so + // the data has to be generated at exactly 1/sampleRate. + // evaluate_range() spreads a count across a closed interval + // instead, which only lands on that spacing for one particular + // count and skews the whole channel otherwise. + auto samples = m_animation->channel(i).evaluate_range_by_rate( m_animation->start_time(), m_animation->end_time(), - output->numSamples + output->sampleRate ); - // std::copy(samples.begin(), samples.end(), (output->channels[i])); - for (size_t j = 0; j < output->numSamples && j < samples.size(); ++j) { + + const int32_t written = static_cast(std::min( + samples.size(), static_cast(output->numSamples))); + for (int32_t j = 0; j < written; ++j) { output->channels[i][j] = static_cast(samples[j]); } + + // getOutputInfo sized the output from Animation::num_samples, + // and this fills it from evaluate_range_by_rate over the same + // span -- both route through the same rounding, so a shortfall + // should be impossible. Say so rather than absorbing it: the + // tail reads as a run of zeros either way, and a silent + // shortfall is exactly what made this class of bug hard to + // place. + if (written < output->numSamples) { + m_warning = "Generated fewer samples than the output declares. " + "Please report this."; + } } } return; @@ -427,6 +538,12 @@ AnimationCHOP::getWarningString(OP_String *warning, void* reserved1) void AnimationCHOP::getErrorString(OP_String *error, void* reserved1) { + // A failed restore outranks a cooking error: it explains why the node has + // no channels, which is usually the cause of whatever else is complaining. + if (!m_loadError.empty()) { + error->setString(m_loadError.c_str()); + return; + } error->setString(m_error); } @@ -448,7 +565,7 @@ AnimationCHOP::setupParameters(OP_ParameterManager* manager,void *reserved1) OP_StringParameter sp; sp.name = "Outputmode"; sp.label = "Output Mode"; - sp.defaultValue = "fullrange"; + sp.defaultValue = "range"; const char *names[] = { "range", "autorange", "input", "sequence" }; const char *labels[] = { "Range", "Auto Range", "Input Index (first channel)", "Sequence Index" }; @@ -478,9 +595,9 @@ AnimationCHOP::setupParameters(OP_ParameterManager* manager,void *reserved1) np.name = "Samplerate"; np.label = "Sample Rate"; np.defaultValues[0] = 60.0; - np.minSliders[0] = 120.0; + np.minSliders[0] = 1.0; np.minValues[0] = 1.0; - np.maxSliders[0] = 30.0; + np.maxSliders[0] = 120.0; np.clampMins[0] = true; OP_ParAppendResult res = manager->appendFloat(np); @@ -502,6 +619,39 @@ AnimationCHOP::pulsePressed(const char* name, void* reserved1) { } +void +AnimationCHOP::saveData(OP_NodeSaveState* saver, void* reserved1) +{ + if (!saver || !m_animation) + return; + + const std::vector blob = animation_codec::encode(*m_animation); + saver->saveEntry(animation_codec::kSaveKey, + blob.data(), + static_cast(blob.size())); +} + +void +AnimationCHOP::loadData(const OP_NodeLoadState* loader, void* reserved1) +{ + if (!loader || !m_animation) + return; + + int64_t byteSize = 0; + const void* blob = loader->loadEntry(animation_codec::kSaveKey, &byteSize); + if (!blob || byteSize <= 0) + return; // A project saved before this operator persisted anything. + + std::string error; + if (!animation_codec::decode(blob, static_cast(byteSize), *m_animation, &error)) { + // Surface it rather than silently starting empty: the user's keyframes + // are in that .toe, and a node that comes back blank with no + // explanation looks like data loss. decode() leaves the animation + // untouched on failure, so the node is still usable. + m_loadError = "Could not restore saved animation: " + error; + } +} + // --- Channel creation and insertion --- static PyObject* py_create_channel(PyObject *self, PyObject *args) { @@ -942,8 +1092,8 @@ static PyObject* py_get_num_samples(PyObject *self, void* closure) { } try { - int samples = animation->num_samples(static_cast(inst->sampleRate())); - return PyLong_FromLong(samples); + size_t samples = animation->num_samples(static_cast(inst->sampleRate())); + return PyLong_FromSize_t(samples); } catch (const std::exception& e) { PyErr_SetString(PyExc_ValueError, e.what()); return NULL; diff --git a/src/animation_chop.h b/src/animation_chop.h index db1b026..827653c 100644 --- a/src/animation_chop.h +++ b/src/animation_chop.h @@ -7,9 +7,17 @@ #include #include +#include + using namespace TD; +// The Python method/getset tables this operator registers with TouchDesigner. +// Defined in animation_chop.cpp and exposed so the pytest extension under +// tests/python can bind the identical tables without TouchDesigner. +extern PyMethodDef AnimationCHOP_pythonMethods[]; +extern PyGetSetDef AnimationCHOP_pythonGetSets[]; + class AnimationCHOP : public CHOP_CPlusPlusBase { public: @@ -28,6 +36,13 @@ class AnimationCHOP : public CHOP_CPlusPlusBase virtual void setupParameters(OP_ParameterManager* manager, void *reserved1) override; virtual void pulsePressed(const char* name, void* reserved1) override; + // Persist the animation into the .toe. TouchDesigner calls saveData() on + // every project save and whenever the operator is unloaded, and loadData() + // on load or reload -- so the channels a user keyframed survive a restart + // with no external file and no Python. + virtual void saveData(OP_NodeSaveState* saver, void* reserved1) override; + virtual void loadData(const OP_NodeLoadState* loader, void* reserved1) override; + anim::Animation* animation() { return m_animation.get(); } const anim::Animation& animation() const { return *m_animation; } float sampleRate() const { return m_sampleRate; } @@ -43,6 +58,11 @@ class AnimationCHOP : public CHOP_CPlusPlusBase const OP_NodeInfo* m_nodeInfo; const char* m_warning; const char* m_error; + // A failure to restore the saved animation, kept separately because + // execute() clears m_error on every cook and this has to stay visible: the + // user's keyframes were in that .toe. Owns its text, unlike m_error, which + // only ever points at string literals. + std::string m_loadError; float m_sampleRate { 60.0f }; std::unique_ptr m_animation; diff --git a/src/animation_codec.cpp b/src/animation_codec.cpp new file mode 100644 index 0000000..898f44f --- /dev/null +++ b/src/animation_codec.cpp @@ -0,0 +1,268 @@ +#include "animation_codec.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace animation_codec { +namespace { + +// "ACHP" -- checked before anything else so a blob written by some other +// operator, or a stale entry under the same key, is rejected rather than +// interpreted. +constexpr uint8_t kMagic[4] = { 'A', 'C', 'H', 'P' }; + +// A channel count or name length larger than this means the data is corrupt. +// Without a ceiling a bogus length would have us reserve gigabytes before the +// bounds check on the following read ever ran. +constexpr uint32_t kSaneCountLimit = 100u * 1000u * 1000u; + +// Both platforms TouchDesigner runs on are little-endian IEEE-754, so doubles +// go out raw. If that ever stops being true this is the assumption to revisit; +// a .toe is expected to move between Windows and macOS. +static_assert(sizeof(double) == 8, "Encoding assumes 8-byte doubles"); + +// --- writing --------------------------------------------------------------- + +void put(std::vector& out, const void* bytes, size_t count) +{ + const auto* p = static_cast(bytes); + out.insert(out.end(), p, p + count); +} + +void putU8(std::vector& out, uint8_t value) +{ + out.push_back(value); +} + +void putU32(std::vector& out, uint32_t value) +{ + put(out, &value, sizeof(value)); +} + +void putDouble(std::vector& out, double value) +{ + put(out, &value, sizeof(value)); +} + +void putPoint(std::vector& out, const anim::Point& point) +{ + putDouble(out, point.time); + putDouble(out, point.value); +} + +void putString(std::vector& out, const std::string& value) +{ + putU32(out, static_cast(value.size())); + put(out, value.data(), value.size()); +} + +// --- reading --------------------------------------------------------------- + +// A bounds-checked cursor. Every read goes through take(), so a truncated blob +// fails the read instead of running off the end of the buffer. +class Reader +{ +public: + Reader(const uint8_t* data, size_t size) : m_data(data), m_size(size) {} + + bool take(void* dest, size_t count) + { + if (count > m_size - m_offset) // m_offset <= m_size always, so no overflow + return false; + std::memcpy(dest, m_data + m_offset, count); + m_offset += count; + return true; + } + + bool u8(uint8_t& value) { return take(&value, sizeof(value)); } + bool u32(uint32_t& value) { return take(&value, sizeof(value)); } + bool real(double& value) { return take(&value, sizeof(value)); } + + bool point(anim::Point& value) + { + return real(value.time) && real(value.value); + } + + bool string(std::string& value, uint32_t limit) + { + uint32_t length = 0; + if (!u32(length) || length > limit || length > remaining()) + return false; + value.resize(length); + return length == 0 || take(value.data(), length); + } + + size_t remaining() const { return m_size - m_offset; } + +private: + const uint8_t* m_data; + size_t m_size; + size_t m_offset = 0; +}; + +bool fail(std::string* error, const char* message) +{ + if (error) + *error = message; + return false; +} + +// The enums are stored as their underlying byte, so a blob from a build with +// more modes than this one must not produce an out-of-range enum. +template +bool readEnum(Reader& reader, Enum& out, uint8_t limit) +{ + uint8_t raw = 0; + if (!reader.u8(raw) || raw >= limit) + return false; + out = static_cast(raw); + return true; +} + +constexpr uint8_t kFunctionCount = 3; // Constant, Linear, Bezier +constexpr uint8_t kHandleModeCount = static_cast(anim::HandleMode::Count); +constexpr uint8_t kExtendCount = 3; // Hold, Repeat, Mirror + +} // namespace + +std::vector encode(const anim::Animation& animation) +{ + std::vector out; + + put(out, kMagic, sizeof(kMagic)); + putU32(out, kFormatVersion); + putDouble(out, animation.start_time()); + putDouble(out, animation.end_time()); + + const size_t channelCount = animation.num_channels(); + putU32(out, static_cast(channelCount)); + + for (size_t c = 0; c < channelCount; ++c) { + const anim::Channel& channel = animation.channel(c); + + putString(out, channel.name()); + putU8(out, static_cast(channel.extend_start())); + putU8(out, static_cast(channel.extend_end())); + + const auto& keyframes = channel.keyframes(); + putU32(out, static_cast(keyframes.size())); + + for (const anim::Keyframe& keyframe : keyframes) { + putPoint(out, keyframe.position); + putPoint(out, keyframe.in_handle); + putPoint(out, keyframe.out_handle); + putU8(out, static_cast(keyframe.function)); + putU8(out, static_cast(keyframe.handle_mode)); + } + } + + return out; +} + +bool decode(const void* data, + size_t byteSize, + anim::Animation& animation, + std::string* error) +{ + if (!data || byteSize == 0) + return fail(error, "No data"); + + Reader reader(static_cast(data), byteSize); + + uint8_t magic[4] = {}; + if (!reader.take(magic, sizeof(magic)) || std::memcmp(magic, kMagic, sizeof(magic)) != 0) + return fail(error, "Not an AnimationCHOP blob"); + + uint32_t version = 0; + if (!reader.u32(version)) + return fail(error, "Truncated header"); + if (version != kFormatVersion) + return fail(error, "Unsupported format version"); + + double startTime = 0.0; + double endTime = 0.0; + if (!reader.real(startTime) || !reader.real(endTime)) + return fail(error, "Truncated animation range"); + + uint32_t channelCount = 0; + if (!reader.u32(channelCount) || channelCount > kSaneCountLimit) + return fail(error, "Bad channel count"); + + // Decode into a scratch animation and only commit on success, so a blob + // that fails halfway does not leave the node holding half an animation. + anim::Animation decoded; + + for (uint32_t c = 0; c < channelCount; ++c) { + std::string name; + if (!reader.string(name, kSaneCountLimit)) + return fail(error, "Bad channel name"); + + anim::Extend extendStart = anim::Extend::Hold; + anim::Extend extendEnd = anim::Extend::Hold; + if (!readEnum(reader, extendStart, kExtendCount) || + !readEnum(reader, extendEnd, kExtendCount)) + return fail(error, "Bad extend mode"); + + uint32_t keyframeCount = 0; + if (!reader.u32(keyframeCount) || keyframeCount > kSaneCountLimit) + return fail(error, "Bad keyframe count"); + + // create_channel appends, so channels come back in their saved order. + // A duplicate name in the blob would collide; anim allows it at the + // index level, and lookups by name resolve to the first, which matches + // how the animation behaved when it was saved. + anim::Channel& channel = decoded.create_channel(name); + + std::vector keyframes; + keyframes.reserve(keyframeCount < 4096 ? keyframeCount : 4096); + + for (uint32_t k = 0; k < keyframeCount; ++k) { + anim::Keyframe keyframe; + if (!reader.point(keyframe.position) || + !reader.point(keyframe.in_handle) || + !reader.point(keyframe.out_handle)) + return fail(error, "Truncated keyframe"); + + if (!readEnum(reader, keyframe.function, kFunctionCount) || + !readEnum(reader, keyframe.handle_mode, kHandleModeCount)) + return fail(error, "Bad keyframe mode"); + + keyframes.push_back(keyframe); + channel.emplace_keyframe(std::move(keyframe)); + } + + // Inserting re-solves the neighbouring handles, and it does so + // incrementally -- a keyframe's handles can be adjusted again by the + // ones inserted after it. That is correct for the derived modes + // (Flat, Smooth), which are meant to follow their neighbours, but it + // loses the explicit handles of a Free or Aligned keyframe. Replay + // them now that every neighbour exists. + for (size_t k = 0; k < keyframes.size() && k < channel.num_keyframes(); ++k) { + const anim::Keyframe& saved = keyframes[k]; + if (saved.handle_mode == anim::HandleMode::Flat || + saved.handle_mode == anim::HandleMode::Smooth) + continue; + channel.set_keyframe_in_handle(k, saved.in_handle); + channel.set_keyframe_out_handle(k, saved.out_handle); + } + + channel.set_extend_start(extendStart); + channel.set_extend_end(extendEnd); + } + + decoded.set_end_time(std::max(startTime, endTime)); + decoded.set_start_time(startTime); + decoded.set_end_time(endTime); + + animation = std::move(decoded); + return true; +} + +} // namespace animation_codec diff --git a/src/animation_codec.h b/src/animation_codec.h new file mode 100644 index 0000000..dbcdd9c --- /dev/null +++ b/src/animation_codec.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include + +#include + +// Binary serialization for an anim::Animation. +// +// This is what the operator hands to TouchDesigner's saveData()/loadData(), so +// the encoded bytes end up inside the .toe file. That means the format is +// persisted user data: anything written by a released build has to keep +// decoding in every later build, which is why the blob is versioned and why +// decode() validates rather than trusting its input. +// +// Deliberately not JSON or the Python state dict. The blob is written on every +// project save and read on every load, a keyframe-heavy animation runs to tens +// of thousands of values, and the codec has to work with no Python interpreter +// available -- loadData() runs during node construction. +// +// Kept free of both TouchDesigner and Python so it can be tested on its own; +// see tests/cpp. +namespace animation_codec { + +// Bumped only when the layout changes incompatibly. decode() refuses anything +// it does not recognize rather than guessing at the bytes. +inline constexpr uint32_t kFormatVersion = 1; + +// The key the operator stores the blob under, via OP_NodeSaveState::saveEntry. +inline constexpr const char* kSaveKey = "animation"; + +// Serializes the animation: range, then every channel with its extend modes and +// keyframes, in order. +std::vector encode(const anim::Animation& animation); + +// Rebuilds `animation` from bytes produced by encode(). +// +// Returns false and leaves `animation` untouched if the data is not a blob this +// build understands -- wrong magic, unknown version, truncated, or internally +// inconsistent. A .toe can carry a blob from a newer build, or a corrupted one; +// neither should take the node down or half-load an animation. On failure, +// `error` (when given) describes what was rejected. +bool decode(const void* data, + size_t byteSize, + anim::Animation& animation, + std::string* error = nullptr); + +} // namespace animation_codec diff --git a/src/animation_view_chop.cpp b/src/animation_view_chop.cpp index e484eea..e20059f 100644 --- a/src/animation_view_chop.cpp +++ b/src/animation_view_chop.cpp @@ -3,8 +3,26 @@ #include #include +#include #include +namespace { + +// Zero the whole output, for the paths that bail out before producing anything. +// +// TouchDesigner allocates the sample buffer but does not initialise it, so a +// sample left unwritten keeps whatever was in that memory and shows up as NaN. +// This is only for the no-data cases: the view fill loops write every sample +// themselves, and pre-clearing them would write the buffer twice every cook. +void silenceOutput(TD::CHOP_Output* output) +{ + for (int32_t i = 0; i < output->numChannels; ++i) { + std::fill_n(output->channels[i], output->numSamples, 0.0f); + } +} + +} // namespace + #ifdef _WIN32 #include @@ -125,7 +143,12 @@ static PyMethodDef viewMethods[] = { DLLEXPORT void FillCHOPPluginInfo(CHOP_PluginInfo* info) { - info->apiVersion = CHOPCPlusPlusAPIVersion; + // The version is recorded even when unsupported, so bailing out here lets + // TouchDesigner report the mismatch rather than loading a half-filled + // plugin info. + if (!info->setAPIVersion(CHOPCPlusPlusAPIVersion)) + return; + info->customOPInfo.opType->setString("Animationview"); info->customOPInfo.opLabel->setString("Animation View"); info->customOPInfo.opIcon->setString("AMV"); @@ -198,7 +221,13 @@ AnimationViewCHOP::getOutputInfo(CHOP_OutputInfo* info, const OP_Inputs* inputs, int32_t total_segments = 0; for (const auto& channel : animation->channels()) { total_keyframes += static_cast(channel->size()); - total_segments += static_cast(channel->size() - 1); + // size() is unsigned, so an empty channel would make size() - 1 + // wrap to SIZE_MAX and land here as -1, undercounting the segment + // vector that the loop below then writes past the end of. A channel + // with fewer than two keyframes simply has no segments. + if (channel->size() >= 2) { + total_segments += static_cast(channel->size() - 1); + } } // Initialize keyframe views with proper indices @@ -247,10 +276,19 @@ AnimationViewCHOP::getOutputInfo(CHOP_OutputInfo* info, const OP_Inputs* inputs, m_samplesStartTime = rangeStart / info->sampleRate; m_samplesEndTime = rangeEnd / info->sampleRate; } else { // Seconds - info->numSamples = static_cast(range_delta * info->sampleRate); + // Half-open, matching the evaluate_range_by_rate() call in + // execute(): a span of n sample periods is n samples, and the end + // time is not sampled. + info->numSamples = static_cast( + std::ceil(range_delta * info->sampleRate)); m_samplesStartTime = rangeStart; m_samplesEndTime = rangeEnd; } + // An inverted range would otherwise ask TouchDesigner for a negative + // sample count. + if (info->numSamples < 1) { + info->numSamples = 1; + } return true; } case ViewMode::keyframes: { info->numChannels = static_cast(m_keyframes_chan_names.size()); @@ -330,11 +368,19 @@ AnimationViewCHOP::execute(CHOP_Output* output, const OP_Inputs* inputs, void* r m_error = nullptr; m_warning = nullptr; + // Both of these bail out with nothing to write. TouchDesigner allocates the + // sample buffer but does not initialise it, so returning without writing + // leaves whatever was in that memory, which surfaces as NaN -- and the + // first case is the state a freshly created node is in, before a source + // operator has been picked. Only the no-data paths are silenced; the fill + // loops below write every sample themselves and must not be pre-cleared. if (!setDataInstance(inputs)) { + silenceOutput(output); return; } auto animation = animationCHOP()->animation(); if (!animation) { + silenceOutput(output); return; } @@ -344,10 +390,13 @@ AnimationViewCHOP::execute(CHOP_Output* output, const OP_Inputs* inputs, void* r size_t num_anim_channels = animation->num_channels(); for (int i = 0; i < output->numChannels; ++i) { if (i < static_cast(num_anim_channels)) { - auto samples = animation->channel(i).evaluate_range( + // By rate, matching AnimationCHOP: a CHOP's samples are one + // period apart by definition, so the data has to be generated + // at 1/sampleRate rather than spread across a closed range. + auto samples = animation->channel(i).evaluate_range_by_rate( m_samplesStartTime, m_samplesEndTime, - output->numSamples + output->sampleRate ); // std::copy(samples.begin(), samples.end(), output->channels[i]); for (size_t j = 0; j < output->numSamples && j < samples.size(); ++j) { @@ -360,6 +409,7 @@ AnimationViewCHOP::execute(CHOP_Output* output, const OP_Inputs* inputs, void* r size_t num_keyframe_channels = m_keyframes_chan_names.size(); if (output->numChannels > num_keyframe_channels) { m_error = "Not enough channels allocated"; + silenceOutput(output); return; } size_t i = 0; @@ -388,12 +438,21 @@ AnimationViewCHOP::execute(CHOP_Output* output, const OP_Inputs* inputs, void* r size_t num_segment_info_channels = m_segments_chan_names.size(); if (output->numChannels > num_segment_info_channels) { m_error = "Not enough channels allocated"; + silenceOutput(output); return; } size_t i = 0; for (size_t c = 0; c < animation->size(); ++c) { auto& channel = animation->channel(c); + // Same unsigned wrap as in setDataInstance(): without this an empty + // channel loops to SIZE_MAX and writes far past the output. + if (channel.size() < 2) { + continue; // No segments in a channel with fewer than two keyframes + } for (size_t k = 0; k < channel.size() - 1; ++k) { + if (i >= output->numSamples) { + break; + } auto start_keyframe = channel.keyframe(k); auto end_keyframe = channel.keyframe(k + 1); output->channels[0][i] = static_cast(c); // Channel index @@ -420,6 +479,7 @@ AnimationViewCHOP::execute(CHOP_Output* output, const OP_Inputs* inputs, void* r size_t num_channel_info_channels = m_channels_chan_names.size(); if (output->numChannels > num_channel_info_channels) { m_error = "Not enough channels allocated"; + silenceOutput(output); return; } int32_t start_index = 0; @@ -441,6 +501,7 @@ AnimationViewCHOP::execute(CHOP_Output* output, const OP_Inputs* inputs, void* r size_t num_animation_info_channels = m_animation_chan_names.size(); if (output->numChannels > num_animation_info_channels) { m_error = "Not enough channels allocated"; + silenceOutput(output); return; } double min_keyframe_time = std::numeric_limits::max(); @@ -526,9 +587,9 @@ AnimationViewCHOP::setupParameters(OP_ParameterManager* manager, void* reserved1 np.name = "Samplerate"; np.label = "Sample Rate"; np.defaultValues[0] = 60.0; - np.minSliders[0] = 120.0; + np.minSliders[0] = 1.0; np.minValues[0] = 1.0; - np.maxSliders[0] = 30.0; + np.maxSliders[0] = 120.0; np.clampMins[0] = true; OP_ParAppendResult res = manager->appendFloat(np); diff --git a/src/py_anim_bindings/py_bindings.h b/src/py_anim_bindings/py_bindings.h index 6cb5548..aa7a84b 100644 --- a/src/py_anim_bindings/py_bindings.h +++ b/src/py_anim_bindings/py_bindings.h @@ -6,4 +6,5 @@ #include "py_channel.h" #include "py_handle_mode.h" #include "py_function.h" -#include "py_extend.h" \ No newline at end of file +#include "py_extend.h" +#include "py_range_end.h" \ No newline at end of file diff --git a/src/py_anim_bindings/py_channel.cpp b/src/py_anim_bindings/py_channel.cpp index fc33ebb..e7f033c 100644 --- a/src/py_anim_bindings/py_channel.cpp +++ b/src/py_anim_bindings/py_channel.cpp @@ -4,6 +4,7 @@ #include "py_extend.h" // For Extend enum support #include #include +#include // Allocation/deallocation functions static PyObject* PY_Channel_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { @@ -570,10 +571,14 @@ static PyObject* PY_Channel_evaluate_range(PY_Channel *self, PyObject *args) { } double start_time, end_time; int num_samples; - if (!PyArg_ParseTuple(args, "ddi", &start_time, &end_time, &num_samples)) + PyObject* range_end_obj = NULL; + if (!PyArg_ParseTuple(args, "ddi|O", &start_time, &end_time, &num_samples, &range_end_obj)) + return NULL; + anim::RangeEnd range_end = anim::RangeEnd::Exclusive; + if (!PY_ObjectToRangeEnd(range_end_obj, range_end)) return NULL; try { - std::vector values = channelData.channel->evaluate_range(start_time, end_time, num_samples); + std::vector values = channelData.channel->evaluate_range(start_time, end_time, num_samples, range_end); PyObject* list = PyList_New(values.size()); for (size_t i = 0; i < values.size(); ++i) PyList_SET_ITEM(list, i, PyFloat_FromDouble(values[i])); @@ -591,10 +596,14 @@ static PyObject* PY_Channel_evaluate_range_by_rate(PY_Channel *self, PyObject *a return NULL; } double start_time, end_time, sample_rate; - if (!PyArg_ParseTuple(args, "ddd", &start_time, &end_time, &sample_rate)) + PyObject* range_end_obj = NULL; + if (!PyArg_ParseTuple(args, "ddd|O", &start_time, &end_time, &sample_rate, &range_end_obj)) + return NULL; + anim::RangeEnd range_end = anim::RangeEnd::Exclusive; + if (!PY_ObjectToRangeEnd(range_end_obj, range_end)) return NULL; try { - std::vector values = channelData.channel->evaluate_range_by_rate(start_time, end_time, sample_rate); + std::vector values = channelData.channel->evaluate_range_by_rate(start_time, end_time, sample_rate, range_end); PyObject* list = PyList_New(values.size()); for (size_t i = 0; i < values.size(); ++i) PyList_SET_ITEM(list, i, PyFloat_FromDouble(values[i])); @@ -689,24 +698,6 @@ static PyObject* PY_Channel_length(PY_Channel *self, void*) { return PyFloat_FromDouble(channelData.channel->length()); } -static PyObject* PY_Channel_num_samples(PY_Channel *self, PyObject* args) { - auto channelData = getChannelData(self, false); - if (!channelData.channel) { - PyErr_SetString(PyExc_RuntimeError, "Channel is not valid"); - return NULL; - } - double sample_rate; - if (!PyArg_ParseTuple(args, "d", &sample_rate)) - return NULL; - try { - size_t n = channelData.channel->num_samples(sample_rate); - return PyLong_FromSize_t(n); - } catch (const std::exception& e) { - PyErr_SetString(PyExc_RuntimeError, e.what()); - return NULL; - } -} - static PyObject* PY_Channel_get_extend_start(PY_Channel *self, void*) { auto channelData = getChannelData(self, false); if (!channelData.channel) { @@ -970,11 +961,30 @@ static PyMethodDef PY_Channel_methods[] = { {"create_keyframe_from_state", (PyCFunction)PY_Channel_create_keyframe_from_state, METH_VARARGS, "Create a keyframe from a state dictionary"}, {"emplace_keyframe", (PyCFunction)PY_Channel_emplace_keyframe, METH_VARARGS, "Emplace a keyframe (move) into the channel"}, {"delete_keyframe", (PyCFunction)PY_Channel_remove_keyframe, METH_VARARGS, "Delete a keyframe by index"}, - {"keyframe", (PyCFunction)PY_Channel_get_keyframe, METH_VARARGS, "Get a keyframe by index"}, - {"prev_keyframe", (PyCFunction)PY_Channel_prev_keyframe, METH_VARARGS, "Get previous keyframe before time"}, - {"next_keyframe", (PyCFunction)PY_Channel_next_keyframe, METH_VARARGS, "Get next keyframe after time"}, - {"closest_keyframe", (PyCFunction)PY_Channel_closest_keyframe, METH_VARARGS, "Get closest keyframe to time"}, - {"update_keyframe", (PyCFunction)PY_Channel_update_keyframe, METH_VARARGS, "Update a keyframe at index with a new keyframe object"}, + {"keyframe", (PyCFunction)PY_Channel_get_keyframe, METH_VARARGS, + "keyframe(index) -> Keyframe\n\n" + "Return a detached copy of the keyframe at index. Assigning to the copy's\n" + "properties does not touch the channel; write it back with\n" + "channel[index] = kf (or update_keyframe), or use the set_keyframe_*\n" + "methods to edit in place."}, + {"prev_keyframe", (PyCFunction)PY_Channel_prev_keyframe, METH_VARARGS, + "prev_keyframe(time) -> Keyframe\n\n" + "Return a detached copy of the keyframe immediately before time. See\n" + "keyframe() for how to write changes back."}, + {"next_keyframe", (PyCFunction)PY_Channel_next_keyframe, METH_VARARGS, + "next_keyframe(time) -> Keyframe\n\n" + "Return a detached copy of the keyframe immediately after time. See\n" + "keyframe() for how to write changes back."}, + {"closest_keyframe", (PyCFunction)PY_Channel_closest_keyframe, METH_VARARGS, + "closest_keyframe(time) -> Keyframe\n\n" + "Return a detached copy of the keyframe nearest to time. See keyframe()\n" + "for how to write changes back."}, + {"update_keyframe", (PyCFunction)PY_Channel_update_keyframe, METH_VARARGS, + "update_keyframe(index, keyframe) -> None\n\n" + "Replace the keyframe at index. This is the write half of the\n" + "read-modify-write round trip, and the same as channel[index] = keyframe.\n" + "The time is clamped between the neighbouring keyframes, so keyframes\n" + "never reorder, and the neighbouring handles are re-solved as needed."}, {"set_keyframe_time", (PyCFunction)PY_Channel_set_keyframe_time, METH_VARARGS, "Set keyframe time at index"}, {"set_keyframe_value", (PyCFunction)PY_Channel_set_keyframe_value, METH_VARARGS, "Set keyframe value at index"}, {"set_keyframe_position", (PyCFunction)PY_Channel_set_keyframe_position, METH_VARARGS, "Set keyframe position at index"}, @@ -983,9 +993,18 @@ static PyMethodDef PY_Channel_methods[] = { {"set_keyframe_function", (PyCFunction)PY_Channel_set_keyframe_function, METH_VARARGS, "Set keyframe function at index"}, {"set_keyframe_handle_mode", (PyCFunction)PY_Channel_set_keyframe_handle_mode, METH_VARARGS, "Set keyframe handle mode at index"}, {"evaluate", (PyCFunction)PY_Channel_evaluate, METH_VARARGS, "Evaluate the channel at a specific time"}, - {"evaluate_range", (PyCFunction)PY_Channel_evaluate_range, METH_VARARGS, "Evaluate the channel over a range (start_time, end_time, num_samples)"}, - {"evaluate_range_by_rate", (PyCFunction)PY_Channel_evaluate_range_by_rate, METH_VARARGS, "Evaluate the channel over a range by sample rate (start_time, end_time, sample_rate)"}, - {"num_samples", (PyCFunction)PY_Channel_num_samples, METH_VARARGS, "Get the number of samples for a given sample rate"}, + {"evaluate_range", (PyCFunction)PY_Channel_evaluate_range, METH_VARARGS, + "evaluate_range(start_time, end_time, num_samples, range_end=RangeEnd.EXCLUSIVE) -> list[float]\n\n" + "Evaluate num_samples evenly spaced values across the range. The range is\n" + "half-open by default: end_time is not sampled, so looping or joining\n" + "adjacent ranges does not repeat a value at the seam. Pass\n" + "RangeEnd.INCLUSIVE to land the last sample on end_time, which is what\n" + "plotting a curve or building a lookup table wants."}, + {"evaluate_range_by_rate", (PyCFunction)PY_Channel_evaluate_range_by_rate, METH_VARARGS, + "evaluate_range_by_rate(start_time, end_time, sample_rate, range_end=RangeEnd.EXCLUSIVE) -> list[float]\n\n" + "Evaluate the range at a fixed rate, so samples are exactly one period\n" + "apart however long the range is. Half-open by default: a span of n\n" + "periods gives n values. This is what the operator's own output uses."}, {"get_state", (PyCFunction)PY_Channel_get_state_method, METH_NOARGS, "Get Channel state as dictionary"}, {"set_state", (PyCFunction)PY_Channel_set_state_method, METH_VARARGS, "Set Channel state from dictionary"}, {NULL} // Sentinel @@ -1037,10 +1056,66 @@ static PyObject* PY_Channel_mp_subscript(PY_Channel *self, PyObject *key) { return NULL; } +// channel[index] = keyframe -- a synonym for update_keyframe(index, keyframe). +// Keyframes come out of a channel as detached copies (anim only exposes them as +// const&, since every edit has to clamp the time between its neighbours, +// re-solve the neighbouring handles and invalidate the eval cache), so +// read-modify-write is the only way to edit one through a Keyframe object. +// Assignment makes that round trip visible; without it the natural-looking +// channel[0].value = x silently updates the copy alone. +static int PY_Channel_mp_ass_subscript(PY_Channel *self, PyObject *key, PyObject *value) { + auto channelData = getChannelData(self, false); + if (!channelData.channel) { + PyErr_SetString(PyExc_RuntimeError, "Channel is not valid"); + return -1; + } + + if (!PyLong_Check(key)) { + PyErr_SetString(PyExc_TypeError, "Channel indices must be integers"); + return -1; + } + + if (value == NULL) { + PyErr_SetString(PyExc_TypeError, + "Channel does not support keyframe deletion by subscript; " + "use delete_keyframe(index)"); + return -1; + } + + Py_ssize_t index = PyLong_AsSsize_t(key); + if (index == -1 && PyErr_Occurred()) { + return -1; + } + + // Handle negative indices + if (index < 0) { + index += (Py_ssize_t)channelData.channel->num_keyframes(); + } + + anim::Keyframe kf; + if (!PY_ObjectToKeyframe(value, kf)) { + // PY_ObjectToKeyframe already sets the error + return -1; + } + + try { + channelData.channel->update_keyframe(static_cast(index), kf); + if (channelData.node_struct) { + channelData.node_struct->context->makeNodeDirty(); + } + return 0; + } catch (const std::exception& e) { + // update_keyframe throws out_of_range for a bad index, matching the + // IndexError that reading channel[index] raises for the same input. + PyErr_SetString(PyExc_IndexError, e.what()); + return -1; + } +} + static PyMappingMethods PY_Channel_as_mapping = { (lenfunc)PY_Channel_len, // mp_length (binaryfunc)PY_Channel_mp_subscript, // mp_subscript - 0, // mp_ass_subscript + (objobjargproc)PY_Channel_mp_ass_subscript, // mp_ass_subscript }; // --- Properties --- @@ -1080,7 +1155,23 @@ PyTypeObject PY_ChannelType = { PyObject_GenericSetAttr, // tp_setattro 0, // tp_as_buffer Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, // tp_flags - "Channel object", // tp_doc + // tp_doc + "A named animation curve: a time-sorted sequence of keyframes.\n\n" + "A Channel is a live handle. It resolves to the operator's channel on every\n" + "access, so two Channel objects naming the same channel see each other's\n" + "edits, and one held past a remove_channel() raises rather than reading\n" + "freed memory.\n\n" + "Keyframes are the opposite: values, not handles. keyframe(), the iterator\n" + "and channel[index] all hand back detached copies, because a keyframe has no\n" + "identity of its own -- editing one has to clamp its time between its\n" + "neighbours, re-solve their handles and invalidate the eval cache, which\n" + "only the channel can do. Edit them either in place:\n\n" + " channel.set_keyframe_value(0, 55.0)\n\n" + "or by round trip:\n\n" + " kf = channel[0]\n" + " kf.value = 55.0\n" + " channel[0] = kf\n\n" + "Setting a property on a copy alone leaves the channel unchanged.", 0, // tp_traverse 0, // tp_clear 0, // tp_richcompare @@ -1135,7 +1226,10 @@ PyObject* ChannelToPY_Object(anim::Channel* channel, PyObject* parent) { self->parent = nullptr; try { - new (&self->channel_id) anim::Id(channel->id().id); // Use placement new to initialize id + // Placement new to initialize the const id member. Copy the Id itself + // rather than rebuilding one from its raw value: anim only hands ids + // out from the library, so the raw constructor is private. + new (&self->channel_id) anim::Id(channel->id()); Py_XINCREF(parent); // Increase reference count for parent self->parent = parent; // Assign parent } catch (const std::exception& e) { @@ -1195,6 +1289,15 @@ ChannelData getChannelData(PY_Channel *self, bool autoCook) { PyErr_SetString(PyExc_RuntimeError, "Cannot retrieve anim::Animation instance."); return ChannelData(); } - // anim::Animation would need a method like getChannelById - return { animation->channel(self->channel_id), inst, td_struct }; + // channel(Id) returns a reference and throws std::out_of_range when the + // channel is gone -- which is reachable, since Python can hold a Channel + // past a remove_channel(). Every caller tests for a null channel, so + // translate the miss into that rather than letting a C++ exception escape + // through the CPython boundary. + try { + return { &animation->channel(self->channel_id), inst, td_struct }; + } catch (const std::out_of_range&) { + PyErr_SetString(PyExc_RuntimeError, "Channel no longer exists."); + return ChannelData(); + } } \ No newline at end of file diff --git a/src/py_anim_bindings/py_channel.h b/src/py_anim_bindings/py_channel.h index 045d9a0..a902d05 100644 --- a/src/py_anim_bindings/py_channel.h +++ b/src/py_anim_bindings/py_channel.h @@ -6,6 +6,7 @@ #include "py_point.h" #include "py_handle_mode.h" #include "py_function.h" +#include "py_range_end.h" #ifdef _WIN32 #include @@ -22,7 +23,10 @@ namespace TD { struct PY_Struct; } typedef struct { PyObject_HEAD - anim::Id channel_id; // Unique identifier for the channel - the internal Id is const and anim::Id has explicit constructor so we need to set on construction + // Unique identifier for the channel. Id holds a const member and its raw + // constructor is private to the library, so this is placement-new copied + // from Channel::id() rather than assigned. + anim::Id channel_id; PyObject* parent; // Reference to the parent AnimationCHOP to keep it alive } PY_Channel; diff --git a/src/py_anim_bindings/py_keyframe.cpp b/src/py_anim_bindings/py_keyframe.cpp index 70052ea..7860aea 100644 --- a/src/py_anim_bindings/py_keyframe.cpp +++ b/src/py_anim_bindings/py_keyframe.cpp @@ -521,7 +521,17 @@ PyTypeObject PY_KeyframeType = { PyObject_GenericSetAttr, // tp_setattro 0, // tp_as_buffer Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, // tp_flags - "Keyframe object", // tp_doc + // tp_doc + "A single key on an animation curve: position, Bezier handles,\n" + "interpolation function and handle mode.\n\n" + "Keyframes are values, not handles into a channel. One obtained from a\n" + "channel is a detached copy, so setting its properties changes the copy\n" + "alone; write it back with channel[index] = kf to apply it. Keyframes are\n" + "also constructible standalone, which is what the property setters are\n" + "mainly for:\n\n" + " kf = anim_chop.Keyframe(time=30, value=100)\n" + " kf.function = anim_chop.Function.LINEAR\n" + " channel.emplace_keyframe(kf)", // tp_doc 0, // tp_traverse 0, // tp_clear PY_Keyframe_richcompare, // tp_richcompare diff --git a/src/py_anim_bindings/py_point.cpp b/src/py_anim_bindings/py_point.cpp index 16da922..4d14553 100644 --- a/src/py_anim_bindings/py_point.cpp +++ b/src/py_anim_bindings/py_point.cpp @@ -226,7 +226,12 @@ PyTypeObject PY_PointType = { PyObject_GenericSetAttr, // tp_setattro 0, // tp_as_buffer Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, // tp_flags - "Point objects", // tp_doc + // tp_doc + "A (time, value) pair, used for keyframe positions and Bezier handles.\n\n" + "Like Keyframe, a Point is a value: one read from a keyframe is a detached\n" + "copy, so kf.in_handle.time = x changes nothing. Assign a whole Point\n" + "instead -- kf.in_handle = Point(x, y), or\n" + "channel.set_keyframe_in_handle(index, Point(x, y)).", // tp_doc 0, // tp_traverse 0, // tp_clear PY_Point_richcompare, // tp_richcompare diff --git a/src/py_anim_bindings/py_range_end.cpp b/src/py_anim_bindings/py_range_end.cpp new file mode 100644 index 0000000..6c9df78 --- /dev/null +++ b/src/py_anim_bindings/py_range_end.cpp @@ -0,0 +1,129 @@ +#include "py_range_end.h" + +static PyObject* range_end_enum_singleton = NULL; + +static PyObject* create_range_end_enum() { + // If we already have a singleton instance, return that + if (range_end_enum_singleton) { + Py_INCREF(range_end_enum_singleton); + return range_end_enum_singleton; + } + + PyObject* enum_module = NULL; + PyObject* int_enum_class = NULL; + PyObject* members_dict = NULL; + PyObject* range_end_enum_name = NULL; + PyObject* range_end_enum_type = NULL; // The created enum type + + enum_module = PyImport_ImportModule("enum"); + if (!enum_module) { + // PyImport_ImportModule sets an error + return NULL; + } + + int_enum_class = PyObject_GetAttrString(enum_module, "IntEnum"); + if (!int_enum_class) { + Py_DECREF(enum_module); + return NULL; + } + + members_dict = PyDict_New(); + if (!members_dict) { + Py_DECREF(int_enum_class); + Py_DECREF(enum_module); + return NULL; + } + + // Helper lambda to add members and check for errors + auto add_member = [&](const char* name, anim::RangeEnd val) -> bool { + PyObject* py_val = PyLong_FromLong(static_cast(val)); + if (!py_val) { + return false; + } + int result = PyDict_SetItemString(members_dict, name, py_val); + Py_DECREF(py_val); + if (result < 0) { + return false; + } + return true; + }; + + if (!add_member("EXCLUSIVE", anim::RangeEnd::Exclusive) || + !add_member("INCLUSIVE", anim::RangeEnd::Inclusive)) { + Py_DECREF(members_dict); + Py_DECREF(int_enum_class); + Py_DECREF(enum_module); + return NULL; + } + + range_end_enum_name = PyUnicode_FromString("RangeEnd"); + if (!range_end_enum_name) { + Py_DECREF(members_dict); + Py_DECREF(int_enum_class); + Py_DECREF(enum_module); + return NULL; + } + + // Create the IntEnum type: IntEnum("RangeEnd", {"EXCLUSIVE": 0, ...}) + range_end_enum_type = PyObject_CallFunctionObjArgs(int_enum_class, range_end_enum_name, members_dict, NULL); + if (!range_end_enum_type) { + Py_DECREF(range_end_enum_name); + Py_DECREF(members_dict); + Py_DECREF(int_enum_class); + Py_DECREF(enum_module); + return NULL; + } + + // Success path: Clean up intermediate objects + Py_DECREF(range_end_enum_name); + Py_DECREF(members_dict); + Py_DECREF(int_enum_class); + Py_DECREF(enum_module); + + // Store the created enum as our singleton + range_end_enum_singleton = range_end_enum_type; + Py_INCREF(range_end_enum_singleton); // Add extra reference to keep it alive + return range_end_enum_type; // Return new reference to the created enum type +} + +// Getter function for the RangeEnd enum +PyObject* get_range_end_enum(PyObject* self, void* closure) { + return create_range_end_enum(); +} + +// Cleanup function for module shutdown +void cleanup_range_end_enum() { + if (range_end_enum_singleton) { + Py_DECREF(range_end_enum_singleton); + range_end_enum_singleton = NULL; + } +} + +bool PY_ObjectToRangeEnd(PyObject* obj, anim::RangeEnd& range_end) { + if (!obj || obj == Py_None) { + return true; // Argument omitted; leave the caller's default in place. + } + + // RangeEnd is exposed as an IntEnum, so its members are ints. Accept a + // plain int too, matching how the other enums are handled here. + if (!PyLong_Check(obj)) { + PyErr_SetString(PyExc_TypeError, + "range_end must be a RangeEnd (or its integer value)"); + return false; + } + + long value = PyLong_AsLong(obj); + if (value == -1 && PyErr_Occurred()) { + return false; + } + + if (value != static_cast(anim::RangeEnd::Exclusive) && + value != static_cast(anim::RangeEnd::Inclusive)) { + PyErr_SetString(PyExc_ValueError, + "range_end must be RangeEnd.EXCLUSIVE or RangeEnd.INCLUSIVE"); + return false; + } + + range_end = static_cast(value); + return true; +} diff --git a/src/py_anim_bindings/py_range_end.h b/src/py_anim_bindings/py_range_end.h new file mode 100644 index 0000000..d9d3774 --- /dev/null +++ b/src/py_anim_bindings/py_range_end.h @@ -0,0 +1,20 @@ +#ifndef PY_RANGE_END_H +#define PY_RANGE_END_H + +#include +#include + +// Getter function for the RangeEnd enum +PyObject* get_range_end_enum(PyObject* self, void* closure); + +// Cleanup function for module shutdown +void cleanup_range_end_enum(); + +// Parses an optional range_end argument accepted by the sampling methods. +// +// Returns false with a Python error set if the object is not a valid RangeEnd. +// A null object leaves `range_end` alone, so callers seed it with the default +// and can pass an omitted argument straight through. +bool PY_ObjectToRangeEnd(PyObject* obj, anim::RangeEnd& range_end); + +#endif // PY_RANGE_END_H diff --git a/td/AnimationCHOP.toe b/td/AnimationCHOP.toe index b18c67f..72976e9 100644 Binary files a/td/AnimationCHOP.toe and b/td/AnimationCHOP.toe differ diff --git a/td/Plugins/AnimationCHOP.dll b/td/Plugins/AnimationCHOP.dll deleted file mode 100644 index 760373e..0000000 Binary files a/td/Plugins/AnimationCHOP.dll and /dev/null differ diff --git a/td/Plugins/AnimationViewCHOP.dll b/td/Plugins/AnimationViewCHOP.dll deleted file mode 100644 index e6013a4..0000000 Binary files a/td/Plugins/AnimationViewCHOP.dll and /dev/null differ diff --git a/td/Plugins/Plugins.json b/td/Plugins/Plugins.json deleted file mode 100644 index ba5502e..0000000 --- a/td/Plugins/Plugins.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "Version": 1, - "AllowedPlugins": { - "e20bedff": { - "ComputerName": "AEON", - "Plugins": { - "D:/AnimationCHOP/td/Plugins/AnimationViewCHOP.dll": { - "Hashes": [ - "jqmhtXzQub2DVE9lmNP8nvEEp5oamI+YeFhxWJb+BnK9GPtMy7CoiiT6bxx34vehvTJ1hsySFBfWB+VXtI/QD7NIP2RVWJHvddAJ9laQ7CKXRd7gfrGJWxTLKB6g5oAk", - "OQE1cyyo0CFR51B/FvLClFMkKfKyfSHdpLaTnwmEWCMmNenmwVNGZHkpDVAEPvNJtIMPzr+L6oopmQkFcRRte9rJokcb95uaX9C29m4NcDSvxX4oNO0WYm9jUjzBm2xT", - "6QvPrTtej9BNiA4o57qnejnFXlPVeFtg9ugtUM7IvoE7mKwxFx2dMMTyzTST4zZfYzGa/Duh6arO1qrbub89JIkYj1ij4PADxJ9/D6nQ+MmEazTH5zDTe0pRJLvU+GGY", - "kPwscKZ7CzNtxBqnZXR4pTGBEV2tBqwlUdED7AgfpF0Pwu3xk0Zp8smEWrtdHO7wAEk2KmWcU80LNH5zlEBZdPz2TSRtd31W6H6gxtsOBdiZ0ihkNxkeDbOWgPfgyyfO", - "dln9pm/ZYK3FZycRjhPY4cg9XkjCffo0KPiEcNl6NKa9PdNluMQ+4LOTZEXtiR3uu1r1VMrp/tn0RgT9mZp5fXLxC+5OWOTUtn7K2NAO9bUKslI57ViDz3dQMUbCQ/4h", - "BOdBKdaC70mcInSU2GIohnYP6QQclGeorLiLyxEuh7LPqqX8kzkdZfOvx2s8Zg3o5l4X+Lve8ReA2+lU+5I/+9KxNIxrI/tKfkP3IEpYs2LyfBhCv1NsvZKFtHRpht8w", - "7MzhXdOgNHLmvzTFWR2BDj3GpEuYouLD35vE1RBkxkPVX4CaqWAzP9dhJ3/tRwG8najM26ddAG4Wf8GpT0owuhnRlhEVjvlfIJojCqxLZOIRxc1/5MjADDL9ZEvb97k1", - "BJtonbIavl+2VxDi/fjQhqYQObzuo3aI33ZY3rb1KeCBmNCy5OQqDbvaEBXR3FUDQy+8zj/qtw76wTvard95D/Xn/RCgVgL+fdxV7Vve9c4C1yC/vikuvSjhzV8rvPSV", - "dLxmfks88L0CidAlQMIc64K6PLrhzP/9iPieiKXJU8dLupt73K9NwvgLNRWjULJutTnG01P2WTv5zoL0z9aUnziTOlDwJXw+psjCsx9gHH7q0RJLTG6KcNUqWVkpfbOm", - "y9YEMBagp+8WLVs6B7O+FsreZAx6SOfCLPbzRJ15ytSHeWZVneRU1fv34bUVXfGhGzYD6yRs5+pnSF9TiWf/T9zNo4XKjkmtVCcCfcTBAoSeX+Bj5sJp+mjStz2BTcyO" - ], - "AuthorName": "Keith Lostracco", - "AuthorEmail": "keith@actualize.vision" - }, - "D:/AnimationCHOP/td/Plugins/AnimationCHOP.dll": { - "Hashes": [ - "uN7iB8XGaCsEodxkGYa/JVTzi3a0G7V+wNwzytAWqGxH+64cdCkPmF7fzBKCon8jwPUThRMhPt/M+ma6BFVqxzVkVLPbyo5AvNDFoKVJinkKGEeTR6YSKMwtaIQTeoet", - "yAUH+c6obs7tr5V0gnLkzXHkGtV41FAsajXYpW0NIOlzCF6/mTdRfYFv/x4eNY8svBnpqV7MBgK3TxevAXbZb9Iu0poNNM4TZzXbs2zHPxhJ9SRS3zFOLVHjkJnDU8eq", - "rrKTQ4kKc3CCBEN5f3K1sjkfOG0TgyZC2W7YrrfBkT5NYOnY/7xjC7F+b41q+Vr8vcnCAhw5Eq/cdVqLfXE7JdtRget9NDzdWzGKSNrgtYcQHaQsyTHQgG0ZZs8zO05i", - "GykY2oD2sYEoH0MK129gO4A5mhuY5fEKLVW80jBsChMjskwnU7kAxsn8uBcEo7T4+sX0zLO4QQHcu0+jQ6OS9jTSJL7QJmXho7T8TsCnAGJooJPl81/FzEryBPJYUUDF", - "vkeBM9Okt+rOKp8MSPMg44jXe0fKVjsF4LSoGfyh1tRukjzznrG+KOfZhbgiF8cFqOM/SaGp6QcNc+DJ76lDMsKzdZ3c+stkMviAgV9h7QzrBP5b4gPaR5CXcxywAxIo", - "wmLJO7yWwZMnqoaJTvOZ9ZqB2ckQquIbalbK1sEp6jH6+PTm7y4+3e00u9oLQJwv7JNPGgo1sGERuS4LNQ3F0xAS3meqZ9suFUwRJXqvX2ZfK4bt6GI2RhloCjhcstGn", - "Vx4tONOQ0n8Upq8KvCiK+vRCV042zV1zm/Mh92abdMOv7k5OeguBt0foLq9BKAPIhtDa8BWtFJg+O/XhYp8zp/eXFwtApgGnPm4/1ztONIOQHEYxhMtjowBwSwXc8j17", - "pN7PIUcK1i8nrkJqvHAU0+Wavk0fg2v8C8eQLQUuvYDCh2xeAbKllV72QK3DDNHtJor3xTXUR0GhDU2HRWwmzolDYJALqV4aXs1Qt+V/1IUJICCDWiuqJV2Ex960ok6u", - "+ssAufNBGFYIrefUN45opcPB5LnaM1bR0IgzqJ+PApFuzrnN0mlVsYpqtQUJi8ykAEfw+4stuktO0IhXlXnuFYTpLQFm9xejWBXuTl1cEyC5Zof3oMwPmLvKuOMWQFib" - ], - "AuthorName": "Keith Lostracco", - "AuthorEmail": "keith@actualize.vision" - } - } - } - } -} \ No newline at end of file diff --git a/td/README.md b/td/README.md new file mode 100644 index 0000000..06f1f10 --- /dev/null +++ b/td/README.md @@ -0,0 +1,17 @@ +# Example project + +`AnimationCHOP.toe` is a demonstration project for the operators, together with +`Keyframer.tox` and the Python modules and shaders it loads. + +**This is a work in progress and will likely be removed from this repository.** +The Keyframer component is a project in its own right, and is expected to live +in the KeyframerComp repository rather than here. Treat what is in this folder +as a reference for how the operators are driven, not as a supported component to +build on — its layout, module names and parameters may change or disappear +without a deprecation period. + +The operators themselves are stable and documented; see [`../docs`](../docs) for +the Python API and [`../README.md`](../README.md) for installation. + +`Plugins/` is created by the build, which copies the compiled operators there so +this project can load them. It is not tracked. diff --git a/td/modules/keyframer.py b/td/modules/keyframer.py index 18f92bb..08e3f14 100644 --- a/td/modules/keyframer.py +++ b/td/modules/keyframer.py @@ -1,3 +1,6 @@ +import traceback + + vMath = op('vsu').module.VMath() TD_CHOP_CHANNEL = Channel pop_menu = op.TDResources.PopMenu @@ -100,9 +103,11 @@ def __init__(self, ownerComp): ] } } - self.init() + + + @property def AnimationChop(self): if self.ownerComp.par.Animationchop.eval() is not None: diff --git a/td/modules/widgets.py b/td/modules/widgets.py index 4b38730..4320ed5 100644 --- a/td/modules/widgets.py +++ b/td/modules/widgets.py @@ -153,11 +153,12 @@ def __init__(self, ownerComp, updateElements): self.doUndo = ownerComp.par.Doundo.eval() else: for i in range(vsu.getOpDepth(ownerComp)): - if hasattr(ownerComp.parent(i).par, 'Doundo'): - self.doUndo = ownerComp.parent(i).par.Doundo.eval() - break - else: - self.doUndo = True + if ownerComp.parent(i): + if hasattr(ownerComp.parent(i).par, 'Doundo'): + self.doUndo = ownerComp.parent(i).par.Doundo.eval() + break + else: + self.doUndo = True def ExpandVal(self, low, high, value, Type): value = ((high - low) * (value)) / 1 + low @@ -769,23 +770,26 @@ def OpenList(self, droplistWidget): compH = self.listItems.numRows * self.DroplistWidget.ItemHeight height = min(maxHeight, compH) width = self.DroplistWidget.ListWidth + self.ownerComp.par.w = width self.listContainer.par.w = width - if self.listContainer.par.pvscrollbar.eval(): - w2 = width - 12 - self.list.par.w = w2 + scrollbar_active = self.listContainer.par.pvscrollbar.eval() != 'off' + scrollbar_width = 12 + if scrollbar_active: + self.list.par.w = width - scrollbar_width else: self.list.par.w = width # self.list.par.w = width self.listContainer.par.h = height if opened == False: + absMouseX = self.absMouse['tx'].eval() absMouseY = self.absMouse['ty'].eval() - mouseX = (self.DroplistWidget.panel.insideu - * self.DroplistWidget.width) + mouseX = (self.DroplistWidget.op('Button').panel.insideu + * self.DroplistWidget.op('Button').width) mouseY = (self.DroplistWidget.panel.insidev * self.DroplistWidget.height) - x = width - mouseX - 8 + x = width * .5 - mouseX if absMouseY >= height + self.DroplistWidget.ItemHeight: y = - height * 0.5 - mouseY else: @@ -874,9 +878,9 @@ def GetToggles(self, init=False): if n < len(prevToggles[i]): row.append(prevToggles[i][n]) else: - row.append(self.ownerComp.par.Defaulttogglevalue.eval()) + row.append(False) else: - row.append(self.ownerComp.par.Defaulttogglevalue.eval()) + row.append(False) default.append(row) if not init and numRows != len(self.Toggles): diff --git a/tests/cpp/CMakeLists.txt b/tests/cpp/CMakeLists.txt new file mode 100644 index 0000000..269b049 --- /dev/null +++ b/tests/cpp/CMakeLists.txt @@ -0,0 +1,28 @@ +# C++ unit tests (Catch2). No Python or TouchDesigner required. +# +# The codec is the only part of this repository that is pure C++ -- everything +# else is either anim (which has its own suite) or binding glue that needs a +# Python interpreter. That makes it exactly what a C++ suite is good for, and +# it matters more than most: the bytes it writes go into users' .toe files. +include(FetchContent) +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.5.2 +) +FetchContent_MakeAvailable(Catch2) + +add_executable(animationchop_cpp_tests + test_animation_codec.cpp + ${CMAKE_SOURCE_DIR}/src/animation_codec.cpp +) +target_include_directories(animationchop_cpp_tests PRIVATE ${CMAKE_SOURCE_DIR}/src) +target_link_libraries(animationchop_cpp_tests PRIVATE + Catch2::Catch2WithMain + anim_static +) +target_compile_features(animationchop_cpp_tests PRIVATE cxx_std_20) + +list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) +include(Catch) +catch_discover_tests(animationchop_cpp_tests) diff --git a/tests/cpp/test_animation_codec.cpp b/tests/cpp/test_animation_codec.cpp new file mode 100644 index 0000000..63ebbd4 --- /dev/null +++ b/tests/cpp/test_animation_codec.cpp @@ -0,0 +1,382 @@ +// Tests for the .toe persistence codec. +// +// The bytes this codec writes end up inside users' project files, so the two +// things that matter are that a round trip is faithful, and that decode() +// refuses anything it does not fully understand rather than producing a +// half-built animation. + +#include +#include + +#include +#include + +#include "animation_codec.h" + +#include +#include + +using Catch::Matchers::WithinAbs; + +namespace { + +constexpr double kEps = 1e-9; + +void requirePointsEqual(const anim::Point& actual, const anim::Point& expected) +{ + REQUIRE_THAT(actual.time, WithinAbs(expected.time, kEps)); + REQUIRE_THAT(actual.value, WithinAbs(expected.value, kEps)); +} + +void requireKeyframesEqual(const anim::Keyframe& actual, const anim::Keyframe& expected) +{ + requirePointsEqual(actual.position, expected.position); + requirePointsEqual(actual.in_handle, expected.in_handle); + requirePointsEqual(actual.out_handle, expected.out_handle); + REQUIRE(actual.function == expected.function); + REQUIRE(actual.handle_mode == expected.handle_mode); +} + +void requireAnimationsEqual(const anim::Animation& actual, const anim::Animation& expected) +{ + REQUIRE_THAT(actual.start_time(), WithinAbs(expected.start_time(), kEps)); + REQUIRE_THAT(actual.end_time(), WithinAbs(expected.end_time(), kEps)); + REQUIRE(actual.num_channels() == expected.num_channels()); + + for (size_t c = 0; c < expected.num_channels(); ++c) { + const anim::Channel& a = actual.channel(c); + const anim::Channel& e = expected.channel(c); + + INFO("channel " << c << " (" << e.name() << ")"); + REQUIRE(a.name() == e.name()); + REQUIRE(a.extend_start() == e.extend_start()); + REQUIRE(a.extend_end() == e.extend_end()); + REQUIRE(a.num_keyframes() == e.num_keyframes()); + + for (size_t k = 0; k < e.num_keyframes(); ++k) { + INFO("keyframe " << k); + requireKeyframesEqual(a.keyframe(k), e.keyframe(k)); + } + } +} + +// Round trip and compare, returning the decoded animation for further checks. +anim::Animation roundTrip(const anim::Animation& source) +{ + const std::vector bytes = animation_codec::encode(source); + REQUIRE_FALSE(bytes.empty()); + + anim::Animation decoded; + std::string error; + REQUIRE(animation_codec::decode(bytes.data(), bytes.size(), decoded, &error)); + REQUIRE(error.empty()); + return decoded; +} + +} // namespace + +TEST_CASE("An empty animation round trips", "[codec]") +{ + anim::Animation source; + source.set_start_time(0.0); + source.set_end_time(30.0); + + const anim::Animation decoded = roundTrip(source); + + REQUIRE(decoded.num_channels() == 0); + requireAnimationsEqual(decoded, source); +} + +TEST_CASE("Channels, keyframes and the range round trip", "[codec]") +{ + anim::Animation source; + source.set_start_time(1.0); + source.set_end_time(11.0); + + anim::Channel& tx = source.create_channel("tx"); + tx.create_keyframe(0.0, 0.0); + tx.create_keyframe(5.0, 100.0); + tx.create_keyframe(10.0, 50.0); + + anim::Channel& ty = source.create_channel("ty"); + ty.create_keyframe(2.5, -1.5); + + const anim::Animation decoded = roundTrip(source); + + REQUIRE(decoded.num_channels() == 2); + REQUIRE(decoded.channel(0).name() == "tx"); + REQUIRE(decoded.channel(1).name() == "ty"); + requireAnimationsEqual(decoded, source); +} + +TEST_CASE("Channel order is preserved", "[codec]") +{ + anim::Animation source; + for (const char* name : { "z", "a", "m", "b" }) + source.create_channel(name).create_keyframe(0.0, 0.0); + + const anim::Animation decoded = roundTrip(source); + + REQUIRE(decoded.channel_names() == std::vector{ "z", "a", "m", "b" }); +} + +TEST_CASE("Every interpolation function round trips", "[codec]") +{ + anim::Animation source; + anim::Channel& channel = source.create_channel("f"); + channel.create_keyframe(0.0, 0.0, anim::Function::Constant, anim::HandleMode::Flat); + channel.create_keyframe(1.0, 1.0, anim::Function::Linear, anim::HandleMode::Flat); + channel.create_keyframe(2.0, 2.0, anim::Function::Bezier, anim::HandleMode::Flat); + + const anim::Animation decoded = roundTrip(source); + + REQUIRE(decoded.channel(0).keyframe(0).function == anim::Function::Constant); + REQUIRE(decoded.channel(0).keyframe(1).function == anim::Function::Linear); + + // Note the third is NOT Bezier as created. A channel's last keyframe + // inherits its predecessor's function and handle mode, because its own + // would govern a segment that does not exist. Assert against the source + // rather than what was asked for -- the codec's job is fidelity to the + // animation as it actually is. + REQUIRE(decoded.channel(0).keyframe(2).function == channel.keyframe(2).function); + requireAnimationsEqual(decoded, source); +} + +TEST_CASE("Restoring loses the last keyframe's pre-inheritance function", "[codec][known-gap]") +{ + // anim caches what the last keyframe's function/handle mode were before + // inheritance overwrote them, and restores them if another keyframe is + // appended after it. That cache is private, so the codec cannot see it and + // cannot persist it. + // + // Consequence: append a keyframe to a channel that has been through a save + // and reload, and the formerly-last keyframe keeps its inherited function + // instead of reverting to the one it was created with. Pinned here so the + // gap is known rather than discovered. + anim::Animation source; + anim::Channel& channel = source.create_channel("f"); + channel.create_keyframe(0.0, 0.0, anim::Function::Constant, anim::HandleMode::Flat); + channel.create_keyframe(1.0, 1.0, anim::Function::Bezier, anim::HandleMode::Smooth); + + // The second keyframe was created Bezier but shows Constant, inherited. + REQUIRE(channel.keyframe(1).function == anim::Function::Constant); + + anim::Animation decoded = roundTrip(source); + + // Append to both, which is what makes the cache observable. + channel.create_keyframe(2.0, 2.0, anim::Function::Linear, anim::HandleMode::Flat); + decoded.channel(0).create_keyframe(2.0, 2.0, anim::Function::Linear, anim::HandleMode::Flat); + + // The original restores the Bezier it had cached; the reloaded one cannot. + REQUIRE(channel.keyframe(1).function == anim::Function::Bezier); + REQUIRE(decoded.channel(0).keyframe(1).function == anim::Function::Constant); +} + +TEST_CASE("Every handle mode round trips", "[codec]") +{ + // Explicit handles are the interesting case: inserting a keyframe re-solves + // its neighbours' handles, so a naive rebuild loses whatever a Free or + // Aligned keyframe was actually holding. + anim::Animation source; + anim::Channel& channel = source.create_channel("h"); + + const auto modes = { + anim::HandleMode::Flat, + anim::HandleMode::Smooth, + anim::HandleMode::Aligned, + anim::HandleMode::Free, + anim::HandleMode::AlignStrict, + anim::HandleMode::AlignFlex, + anim::HandleMode::AlignAdjustable, + }; + + double time = 0.0; + for (anim::HandleMode mode : modes) { + channel.create_keyframe(time, time * 2.0, anim::Function::Bezier, mode); + time += 1.0; + } + + const anim::Animation decoded = roundTrip(source); + + REQUIRE(decoded.channel(0).num_keyframes() == channel.num_keyframes()); + requireAnimationsEqual(decoded, source); +} + +TEST_CASE("Free handles survive the round trip", "[codec]") +{ + anim::Animation source; + anim::Channel& channel = source.create_channel("free"); + channel.create_keyframe(0.0, 0.0, anim::Function::Bezier, anim::HandleMode::Free); + channel.create_keyframe(4.0, 10.0, anim::Function::Bezier, anim::HandleMode::Free); + channel.create_keyframe(8.0, 0.0, anim::Function::Bezier, anim::HandleMode::Free); + + channel.set_keyframe_in_handle(1, anim::Point(3.25, 7.5)); + channel.set_keyframe_out_handle(1, anim::Point(5.75, 12.5)); + + const anim::Animation decoded = roundTrip(source); + + requireAnimationsEqual(decoded, source); + requirePointsEqual(decoded.channel(0).keyframe(1).in_handle, + channel.keyframe(1).in_handle); + requirePointsEqual(decoded.channel(0).keyframe(1).out_handle, + channel.keyframe(1).out_handle); +} + +TEST_CASE("Extend modes round trip", "[codec]") +{ + anim::Animation source; + anim::Channel& channel = source.create_channel("e"); + channel.create_keyframe(0.0, 0.0); + channel.create_keyframe(1.0, 1.0); + channel.set_extend_start(anim::Extend::Repeat); + channel.set_extend_end(anim::Extend::Mirror); + + const anim::Animation decoded = roundTrip(source); + + REQUIRE(decoded.channel(0).extend_start() == anim::Extend::Repeat); + REQUIRE(decoded.channel(0).extend_end() == anim::Extend::Mirror); +} + +TEST_CASE("Channel names with awkward content round trip", "[codec]") +{ + anim::Animation source; + source.create_channel(""); // empty + source.create_channel("a name with spaces"); + source.create_channel("utf8: \xc3\xa9\xc3\xa8"); + source.create_channel(std::string("embedded\0nul", 12)); + + const anim::Animation decoded = roundTrip(source); + + REQUIRE(decoded.num_channels() == 4); + REQUIRE(decoded.channel(0).name().empty()); + REQUIRE(decoded.channel(1).name() == "a name with spaces"); + REQUIRE(decoded.channel(3).name() == std::string("embedded\0nul", 12)); +} + +TEST_CASE("The decoded curve evaluates identically", "[codec]") +{ + // The point of persistence: the curve a user gets back has to be the curve + // they saved, not merely the same keyframe values. + anim::Animation source; + anim::Channel& channel = source.create_channel("curve"); + channel.create_keyframe(0.0, 0.0, anim::Function::Bezier, anim::HandleMode::Smooth); + channel.create_keyframe(3.0, 10.0, anim::Function::Bezier, anim::HandleMode::Aligned); + channel.create_keyframe(6.0, -5.0, anim::Function::Linear, anim::HandleMode::Free); + channel.create_keyframe(9.0, 2.0, anim::Function::Constant, anim::HandleMode::Flat); + + const anim::Animation decoded = roundTrip(source); + + for (double t = -1.0; t <= 10.0; t += 0.125) { + INFO("t = " << t); + REQUIRE_THAT(decoded.channel(0).evaluate(t), + WithinAbs(channel.evaluate(t), 1e-9)); + } +} + +// --- rejecting bad input ---------------------------------------------------- + +TEST_CASE("decode rejects null and empty data", "[codec]") +{ + anim::Animation animation; + std::string error; + + REQUIRE_FALSE(animation_codec::decode(nullptr, 0, animation, &error)); + REQUIRE_FALSE(error.empty()); + + const uint8_t nothing = 0; + REQUIRE_FALSE(animation_codec::decode(¬hing, 0, animation, &error)); +} + +TEST_CASE("decode rejects a foreign blob", "[codec]") +{ + const char foreign[] = "not an animation at all, just some bytes"; + anim::Animation animation; + std::string error; + + REQUIRE_FALSE(animation_codec::decode(foreign, sizeof(foreign), animation, &error)); + REQUIRE(error == "Not an AnimationCHOP blob"); +} + +TEST_CASE("decode rejects an unknown format version", "[codec]") +{ + anim::Animation source; + source.create_channel("tx").create_keyframe(0.0, 1.0); + std::vector bytes = animation_codec::encode(source); + + // Version sits immediately after the 4-byte magic. + bytes[4] = 0xFF; + + anim::Animation animation; + std::string error; + REQUIRE_FALSE(animation_codec::decode(bytes.data(), bytes.size(), animation, &error)); + REQUIRE(error == "Unsupported format version"); +} + +TEST_CASE("decode rejects truncation at every length", "[codec]") +{ + // A .toe can be truncated, and a blob written by a newer build can be + // longer than we expect. No prefix of a valid blob should decode as + // anything but a failure. + anim::Animation source; + source.set_end_time(12.0); + anim::Channel& channel = source.create_channel("tx"); + channel.create_keyframe(0.0, 0.0); + channel.create_keyframe(6.0, 3.0); + source.create_channel("ty").create_keyframe(1.0, 1.0); + + const std::vector bytes = animation_codec::encode(source); + + for (size_t length = 1; length < bytes.size(); ++length) { + INFO("truncated to " << length << " of " << bytes.size() << " bytes"); + anim::Animation animation; + REQUIRE_FALSE(animation_codec::decode(bytes.data(), length, animation)); + } +} + +TEST_CASE("decode rejects an out-of-range enum", "[codec]") +{ + anim::Animation source; + source.create_channel("tx").create_keyframe(0.0, 1.0); + std::vector bytes = animation_codec::encode(source); + + // The keyframe's two mode bytes are the last of the blob. + bytes[bytes.size() - 1] = 0x7F; + + anim::Animation animation; + std::string error; + REQUIRE_FALSE(animation_codec::decode(bytes.data(), bytes.size(), animation, &error)); + REQUIRE(error == "Bad keyframe mode"); +} + +TEST_CASE("A failed decode leaves the target animation untouched", "[codec]") +{ + anim::Animation existing; + existing.create_channel("keep").create_keyframe(0.0, 42.0); + existing.set_end_time(7.0); + + const char garbage[] = "ACHP but then nonsense follows here"; + REQUIRE_FALSE(animation_codec::decode(garbage, sizeof(garbage), existing)); + + REQUIRE(existing.num_channels() == 1); + REQUIRE(existing.channel(0).name() == "keep"); + REQUIRE_THAT(existing.channel(0).keyframe(0).value(), WithinAbs(42.0, kEps)); + REQUIRE_THAT(existing.end_time(), WithinAbs(7.0, kEps)); +} + +TEST_CASE("A large animation round trips", "[codec]") +{ + anim::Animation source; + for (int c = 0; c < 8; ++c) { + anim::Channel& channel = source.create_channel("chan" + std::to_string(c)); + for (int k = 0; k < 500; ++k) + channel.create_keyframe(k * 0.25, static_cast(k % 17)); + } + + const std::vector bytes = animation_codec::encode(source); + anim::Animation decoded; + REQUIRE(animation_codec::decode(bytes.data(), bytes.size(), decoded)); + + REQUIRE(decoded.num_channels() == 8); + REQUIRE(decoded.channel(0).num_keyframes() == 500); + requireAnimationsEqual(decoded, source); +} diff --git a/tests/python/CMakeLists.txt b/tests/python/CMakeLists.txt new file mode 100644 index 0000000..6f46798 --- /dev/null +++ b/tests/python/CMakeLists.txt @@ -0,0 +1,76 @@ +# Python extension + pytest suite. Exposes the operator bindings without +# TouchDesigner by compiling the real operator sources against a fake +# PY_Context (see extension/animationchop_module.cpp). + +add_library(animationchop_testext MODULE + extension/animationchop_module.cpp + ${CMAKE_SOURCE_DIR}/src/animation_chop.cpp + ${CMAKE_SOURCE_DIR}/src/animation_codec.cpp + ${CMAKE_SOURCE_DIR}/src/py_anim_bindings/py_channel.cpp + ${CMAKE_SOURCE_DIR}/src/py_anim_bindings/py_extend.cpp + ${CMAKE_SOURCE_DIR}/src/py_anim_bindings/py_function.cpp + ${CMAKE_SOURCE_DIR}/src/py_anim_bindings/py_handle_mode.cpp + ${CMAKE_SOURCE_DIR}/src/py_anim_bindings/py_keyframe.cpp + ${CMAKE_SOURCE_DIR}/src/py_anim_bindings/py_point.cpp + ${CMAKE_SOURCE_DIR}/src/py_anim_bindings/py_range_end.cpp +) + +# A CPython extension is a .pyd on Windows and a .so elsewhere -- never .dylib. +set_target_properties(animationchop_testext PROPERTIES + OUTPUT_NAME "animationchop" + PREFIX "" + SUFFIX "${ANIMATIONCHOP_PYEXT_SUFFIX}" +) + +target_include_directories(animationchop_testext PRIVATE + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/ext/td/include + ${CMAKE_SOURCE_DIR}/ext/anim/include + ${ANIMATIONCHOP_PYTHON_INCLUDE_DIRS} +) + +target_link_libraries(animationchop_testext PRIVATE anim) +target_compile_features(animationchop_testext PRIVATE cxx_std_20) + +if(WIN32) + target_include_directories(animationchop_testext PRIVATE + ${CMAKE_SOURCE_DIR}/ext/Python/Include/PC) + target_link_directories(animationchop_testext PRIVATE + ${CMAKE_SOURCE_DIR}/ext/Python/lib/x64) + target_link_libraries(animationchop_testext PRIVATE python311) + target_compile_options(animationchop_testext PRIVATE /wd4100 /wd4189) + target_compile_definitions(animationchop_testext PRIVATE _CRT_SECURE_NO_WARNINGS) +else() + # Extension modules leave Python's symbols undefined; the host interpreter + # supplies them at import. + target_link_libraries(animationchop_testext PRIVATE ${ANIMATIONCHOP_PYTHON_LIBRARIES}) + target_compile_definitions(animationchop_testext PRIVATE __cdecl=) + target_compile_options(animationchop_testext PRIVATE + -Wno-unused-parameter -Wno-unused-variable) +endif() + +# Drop the module next to the pytest files so `import animationchop` resolves +# regardless of the per-configuration build subdirectory. +add_custom_command(TARGET animationchop_testext POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy + "$" + "${CMAKE_CURRENT_SOURCE_DIR}/" + COMMENT "Copying animationchop test extension to tests/python/" +) + +# Run pytest as a ctest test. Prefer `uv run`, which supplies pytest +# ephemerally so nothing has to be installed first; otherwise call pytest in +# the discovered interpreter (see requirements.txt). +if(UV_EXECUTABLE) + add_test( + NAME python_bindings + COMMAND ${UV_EXECUTABLE} run --no-project --python 3.11 --with pytest + pytest -q "${CMAKE_CURRENT_SOURCE_DIR}" + ) +else() + add_test( + NAME python_bindings + COMMAND ${ANIMATIONCHOP_PYTHON_EXECUTABLE} -m pytest -q "${CMAKE_CURRENT_SOURCE_DIR}" + ) +endif() +set_tests_properties(python_bindings PROPERTIES DEPENDS animationchop_testext) diff --git a/tests/python/conftest.py b/tests/python/conftest.py new file mode 100644 index 0000000..ff8309d --- /dev/null +++ b/tests/python/conftest.py @@ -0,0 +1,48 @@ +"""Shared fixtures for the AnimationCHOP binding tests. + +The `animationchop` extension creates a single operator instance at import and +keeps it for the session -- the same lifetime it has inside TouchDesigner, where +the node owns its anim::Animation. So the animation is reset between tests +rather than rebuilt, and tests must not rely on state from one another. +""" + +import pytest + +import animationchop + + +# The operator's range defaults to 0..30 and is independent of the channels, +# so clear() does not restore it -- the fixture has to, or a test that sets the +# range leaks it into every test that follows. +DEFAULT_START_TIME = 0.0 +DEFAULT_END_TIME = 30.0 + + +def _reset(node): + node.clear() + node.start_time = DEFAULT_START_TIME + node.end_time = DEFAULT_END_TIME + + +@pytest.fixture +def op(): + """The operator: no channels, default range, dirty counter zeroed.""" + _reset(animationchop.op) + animationchop.reset_dirty_count() + yield animationchop.op + _reset(animationchop.op) + + +@pytest.fixture +def channel(op): + """An empty channel named 'tx'.""" + return op.create_channel("tx") + + +@pytest.fixture +def ramp(op): + """A channel ramping 0 -> 100 over frames 0 -> 30.""" + ch = op.create_channel("ramp") + ch.create_keyframe(0, 0) + ch.create_keyframe(30, 100) + return ch diff --git a/tests/python/extension/animationchop_module.cpp b/tests/python/extension/animationchop_module.cpp new file mode 100644 index 0000000..e020fea --- /dev/null +++ b/tests/python/extension/animationchop_module.cpp @@ -0,0 +1,162 @@ +// Test-only CPython extension that exposes the AnimationCHOP Python bindings +// as an importable module, so they can be exercised with pytest WITHOUT +// TouchDesigner. +// +// It compiles the real operator sources (animation_chop.cpp and the +// py_anim_bindings/*.cpp) and stands in for TouchDesigner with a fake +// PY_Context. That is the entire TouchDesigner surface the bindings touch: +// they cast their `self` to a TD::PY_Struct, read `->context`, and call +// getNodeInstance() / makeNodeDirty() on it. +// +// The module exposes a single `op` object bound to the operator's own method +// and getset tables, so `op.create_channel(...)`, `op.state`, `op.Point`, etc. +// behave exactly as they do inside TouchDesigner. + +#include "animation_chop.h" +#include "CPlusPlus_Common.h" + +#include +#include + +using namespace TD; + +namespace { + +AnimationCHOP* g_instance = nullptr; +long g_dirtyCount = 0; + +// The only TouchDesigner interface the bindings use. +class FakeContext : public PY_Context +{ +public: + void* getNodeInstance(const PY_GetInfo&, void*) override + { + return g_instance; + } + + void makeNodeDirty(void*) override + { + ++g_dirtyCount; + } +}; + +FakeContext g_context; + +// A PyObject laid out like TD::PY_Struct. +// +// Inside TouchDesigner the node's Python object *is* a PY_Struct: an opaque +// header (where CPython's own object header lives), then a PY_Context*. The +// bindings cast to that layout and read `context`, so the stand-in has to put +// its context pointer at the same offset. The static_asserts below are what +// actually guarantee that; the padding is just arithmetic to get there. +struct FakeNode +{ + PyObject_HEAD + char headerPad[sizeof(int32_t) * OP_STRUCT_HEADER_ENTRIES - sizeof(PyObject)]; + PY_Context* context; +}; + +static_assert(offsetof(FakeNode, context) == offsetof(PY_Struct, context), + "FakeNode must place `context` where TD::PY_Struct does, since the " + "bindings cast between them."); +static_assert(sizeof(FakeNode) <= sizeof(PY_Struct), + "FakeNode must not claim more storage than a real PY_Struct."); + +PyObject* +FakeNode_new(PyTypeObject* type, PyObject*, PyObject*) +{ + PyObject* self = type->tp_alloc(type, 0); + if (!self) + return nullptr; + reinterpret_cast(self)->context = &g_context; + return self; +} + +PyTypeObject FakeNodeType = { + PyVarObject_HEAD_INIT(nullptr, 0) + "animationchop.AnimationCHOP", // tp_name + sizeof(FakeNode), // tp_basicsize +}; + +// --- module-level helpers, for assertions the bindings themselves cannot make --- + +PyObject* +tm_dirty_count(PyObject*, PyObject*) +{ + return PyLong_FromLong(g_dirtyCount); +} + +PyObject* +tm_reset_dirty_count(PyObject*, PyObject*) +{ + g_dirtyCount = 0; + Py_RETURN_NONE; +} + +PyMethodDef module_methods[] = { + {"dirty_count", tm_dirty_count, METH_NOARGS, + "Number of times the bindings have marked the node dirty."}, + {"reset_dirty_count", tm_reset_dirty_count, METH_NOARGS, + "Reset the makeNodeDirty() counter."}, + {nullptr, nullptr, 0, nullptr}, +}; + +PyModuleDef animationchop_module = { + PyModuleDef_HEAD_INIT, + "animationchop", + "Test-only extension exposing the AnimationCHOP bindings (no TouchDesigner).", + -1, + module_methods, + nullptr, nullptr, nullptr, nullptr, +}; + +} // namespace + +PyMODINIT_FUNC +PyInit_animationchop(void) +{ + // Bind the operator's own tables, so the module under test is the real + // binding surface rather than a reimplementation of it. + FakeNodeType.tp_flags = Py_TPFLAGS_DEFAULT; + FakeNodeType.tp_doc = "Stand-in for an AnimationCHOP node outside TouchDesigner."; + FakeNodeType.tp_methods = AnimationCHOP_pythonMethods; + FakeNodeType.tp_getset = AnimationCHOP_pythonGetSets; + FakeNodeType.tp_new = FakeNode_new; + + if (PyType_Ready(&FakeNodeType) < 0) + return nullptr; + + PyObject* module = PyModule_Create(&animationchop_module); + if (!module) + return nullptr; + + // One operator instance, alive for the session. The bindings reach it + // through FakeContext::getNodeInstance(). + if (!g_instance) { + static OP_NodeInfo nodeInfo{}; + nodeInfo.opPath = "/test/animation1"; + nodeInfo.opId = 1; + g_instance = new AnimationCHOP(&nodeInfo); + } + + PyObject* node = FakeNode_new(&FakeNodeType, nullptr, nullptr); + if (!node) { + Py_DECREF(module); + return nullptr; + } + + Py_INCREF(&FakeNodeType); + if (PyModule_AddObject(module, "AnimationCHOP", (PyObject*)&FakeNodeType) < 0) { + Py_DECREF(&FakeNodeType); + Py_DECREF(node); + Py_DECREF(module); + return nullptr; + } + if (PyModule_AddObject(module, "op", node) < 0) { + Py_DECREF(node); + Py_DECREF(module); + return nullptr; + } + + return module; +} diff --git a/tests/python/requirements.txt b/tests/python/requirements.txt new file mode 100644 index 0000000..95a8e99 --- /dev/null +++ b/tests/python/requirements.txt @@ -0,0 +1,6 @@ +# Test dependencies for the pytest binding suite. +# Only needed when uv is unavailable -- otherwise `uv run --with pytest` +# supplies these ephemerally. Install into the Python 3.11 that built the +# extension, e.g.: +# uv pip install -r tests/python/requirements.txt +pytest>=8,<9 diff --git a/tests/python/test_animation.py b/tests/python/test_animation.py new file mode 100644 index 0000000..dd698c8 --- /dev/null +++ b/tests/python/test_animation.py @@ -0,0 +1,312 @@ +"""The operator itself: channel collection, bounds and whole-animation state.""" + +import pytest + +import animationchop + + +# --- channel management ----------------------------------------------------- + +def test_starts_empty(op): + assert op.num_channels == 0 + assert op.channel_names == [] + + +def test_create_channel(op): + ch = op.create_channel("tx") + assert ch.name == "tx" + assert op.num_channels == 1 + assert op.channel_names == ["tx"] + + +def test_create_channel_at_index(op): + op.create_channel("a") + op.create_channel("b") + op.create_channel("mid", 1) + assert op.channel_names == ["a", "mid", "b"] + + +def test_has_channel(op): + op.create_channel("tx") + assert op.has_channel("tx") is True + assert op.has_channel("ty") is False + + +def test_get_channel_by_name_and_index(op): + op.create_channel("tx") + op.create_channel("ty") + assert op.get_channel("ty").name == "ty" + assert op.get_channel(0).name == "tx" + + +def test_remove_channel_by_name(op): + op.create_channel("tx") + op.create_channel("ty") + op.remove_channel("tx") + assert op.channel_names == ["ty"] + + +def test_remove_channel_by_index(op): + op.create_channel("tx") + op.create_channel("ty") + op.remove_channel(0) + assert op.channel_names == ["ty"] + + +def test_channels_returns_every_channel(op): + op.create_channel("a") + op.create_channel("b") + assert [c.name for c in op.channels] == ["a", "b"] + + +def test_clear_removes_everything(op): + op.create_channel("a") + op.create_channel("b") + op.clear() + assert op.num_channels == 0 + assert op.channel_names == [] + + +# --- bounds ----------------------------------------------------------------- + +def test_animation_range_is_independent_of_channel_content(op): + """The operator's range is its own setting, not the span of its channels. + + Adding a channel reaching frame 60 leaves the range at its 0..30 default, + so the range has to be set deliberately. + """ + long = op.create_channel("long") + long.create_keyframe(5, 0) + long.create_keyframe(60, 1) + + assert long.end_time == pytest.approx(60.0) + assert op.end_time == pytest.approx(30.0) + + +def test_animation_range_is_settable(op): + op.start_time = 10.0 + op.end_time = 70.0 + assert op.start_time == pytest.approx(10.0) + assert op.end_time == pytest.approx(70.0) + assert op.length == pytest.approx(60.0) + + +def test_range_setters_push_the_opposite_bound(op): + """Setting one bound past the other drags that one along, never inverts. + + This matters because the setters are assigned one at a time: if start + merely clamped, moving a whole range forward would silently land on the + wrong start for as long as the old end held it back. + """ + op.start_time = 0.0 + op.end_time = 30.0 + + op.start_time = 40.0 + assert (op.start_time, op.end_time) == pytest.approx((40.0, 40.0)) + + op.end_time = 10.0 + assert (op.start_time, op.end_time) == pytest.approx((10.0, 10.0)) + + +def test_range_can_be_moved_forward_in_either_order(op): + """A consequence of the above: neither assignment order loses the range.""" + op.start_time = 0.0 + op.end_time = 30.0 + + op.start_time = 40.0 + op.end_time = 70.0 + assert (op.start_time, op.end_time) == pytest.approx((40.0, 70.0)) + + op.start_time = 0.0 + op.end_time = 30.0 + + op.end_time = 70.0 + op.start_time = 40.0 + assert (op.start_time, op.end_time) == pytest.approx((40.0, 70.0)) + + +def test_clear_leaves_the_range_alone(op): + """clear() removes channels but keeps the configured range.""" + op.start_time = 10.0 + op.end_time = 70.0 + op.create_channel("tx") + + op.clear() + + assert op.num_channels == 0 + assert op.start_time == pytest.approx(10.0) + assert op.end_time == pytest.approx(70.0) + + +def test_animation_num_samples_is_empty_without_channels(op): + assert op.num_samples == 0 + + +def test_animation_num_samples_covers_the_range(ramp, op): + """The range is half-open: 30 seconds at 60 fps is 1800 samples. + + The end time is not sampled, so a span of n sample periods gives n + samples -- what a CHOP means by a sample count, and what the operator's + output length is. + """ + assert op.num_samples == 30 * 60 + + +def test_channel_has_no_num_samples(ramp): + """Counting is the operator's job, not a channel's. + + A channel only knows the extent of its own keyframes, which is an editing + concept rather than the range a host samples over -- so a count taken from + it silently answers about the wrong span. anim removed the equivalent in + 0.4.0 for the same reason. Use the operator's num_samples, or TouchDesigner's + own numSamples on the cooked output. + """ + assert not hasattr(ramp, "num_samples") + + +def test_num_samples_is_a_whole_number_of_periods(op): + """A duration that lands a few ulps off a whole number must not gain a + sample -- the count is rounded to the nearest whole period first.""" + op.start_time = 0.0 + op.end_time = 4.0 + ch = op.create_channel("tx") + ch.create_keyframe(0, 0) + ch.create_keyframe(4, 1) + + assert op.num_samples == 4 * 60 + + +def test_evaluate_range_by_rate_is_half_open(ramp): + """The samples are one period apart and stop short of the end time.""" + values = ramp.evaluate_range_by_rate(0.0, 30.0, 60.0) + + assert len(values) == 30 * 60 + assert values[0] == pytest.approx(0.0) + # Last sample sits one period before the end, not on it. + assert values[-1] == pytest.approx(ramp.evaluate(30.0 - 1.0 / 60.0)) + + +def test_evaluate_range_is_half_open_by_default(ramp): + """Both sampling methods default to half-open, and agree with each other.""" + values = ramp.evaluate_range(0.0, 30.0, 30 * 60) + + assert len(values) == 30 * 60 + assert values[0] == pytest.approx(0.0) + assert values[-1] == pytest.approx(ramp.evaluate(30.0 - 1.0 / 60.0)) + assert values == pytest.approx(ramp.evaluate_range_by_rate(0.0, 30.0, 60.0)) + + +def test_range_end_inclusive_lands_on_the_end(op, ramp): + """RangeEnd.INCLUSIVE samples the end time -- for plots and lookup tables.""" + values = ramp.evaluate_range(0.0, 30.0, 121, op.RangeEnd.INCLUSIVE) + + assert len(values) == 121 + assert values[0] == pytest.approx(0.0) + assert values[-1] == pytest.approx(ramp.evaluate(30.0)) + + +def test_range_end_inclusive_adds_the_closing_sample_by_rate(op, ramp): + """By rate, INCLUSIVE is one more sample: the one landing on the end.""" + exclusive = ramp.evaluate_range_by_rate(0.0, 30.0, 60.0) + inclusive = ramp.evaluate_range_by_rate(0.0, 30.0, 60.0, op.RangeEnd.INCLUSIVE) + + assert len(inclusive) == len(exclusive) + 1 + assert inclusive[:-1] == pytest.approx(exclusive) + assert inclusive[-1] == pytest.approx(ramp.evaluate(30.0)) + + +def test_operator_num_samples_is_the_exclusive_count(op, ramp): + """The operator's num_samples is a plain property with no RangeEnd. + + It reports what the CHOP actually outputs, which is always the half-open + count. RangeEnd is reachable through the evaluate_range calls above, where + the caller is asking for values rather than reading the node's own length. + """ + assert op.num_samples == 30 * 60 + + +def test_range_end_rejects_nonsense(op, ramp): + with pytest.raises(ValueError): + ramp.evaluate_range(0.0, 30.0, 10, 99) + with pytest.raises(TypeError): + ramp.evaluate_range(0.0, 30.0, 10, "inclusive") + + +def test_half_open_ranges_join_without_repeating(op): + """The reason the default is half-open: adjacent spans do not double up. + + Sampling 0..1 and 1..2 back to back must give the same values as sampling + 0..2 in one go -- no repeated sample at the seam. + """ + ch = op.create_channel("seam") + ch.create_keyframe(0, 0) + ch.create_keyframe(2, 100) + + joined = (ch.evaluate_range_by_rate(0.0, 1.0, 60.0) + + ch.evaluate_range_by_rate(1.0, 2.0, 60.0)) + + assert joined == pytest.approx(ch.evaluate_range_by_rate(0.0, 2.0, 60.0)) + + +# --- state ------------------------------------------------------------------ + +def test_state_shape(ramp, op): + state = op.state + assert set(state) == { + "channels", "start_time", "end_time", "length", "num_channels", + } + assert len(state["channels"]) == 1 + + +def test_state_round_trips(op): + tx = op.create_channel("tx") + tx.create_keyframe(0, 0) + tx.create_keyframe(30, 100) + ty = op.create_channel("ty") + ty.create_keyframe(0, 5) + ty.create_keyframe(10, 15) + + saved = op.state + before = (op.channel_names, tx.evaluate(15), ty.evaluate(5)) + + op.clear() + assert op.num_channels == 0 + + op.state = saved + + assert op.channel_names == before[0] + assert op.get_channel("tx").evaluate(15) == pytest.approx(before[1]) + assert op.get_channel("ty").evaluate(5) == pytest.approx(before[2]) + + +def test_set_state_replaces_existing_channels(op): + src = op.create_channel("keep") + src.create_keyframe(0, 0) + saved = op.state + + op.clear() + op.create_channel("stale") + op.set_state(saved) + + assert op.channel_names == ["keep"] + + +def test_get_state_matches_property(ramp, op): + assert op.get_state() == op.state + + +# --- node invalidation ------------------------------------------------------ + +def test_mutating_marks_the_node_dirty(op): + animationchop.reset_dirty_count() + ch = op.create_channel("tx") + ch.create_keyframe(0, 0) + assert animationchop.dirty_count() > 0 + + +def test_reading_does_not_mark_the_node_dirty(ramp): + animationchop.reset_dirty_count() + _ = ramp.num_keyframes + _ = ramp.evaluate(15) + _ = ramp.state + assert animationchop.dirty_count() == 0 diff --git a/tests/python/test_channel.py b/tests/python/test_channel.py new file mode 100644 index 0000000..65d49bd --- /dev/null +++ b/tests/python/test_channel.py @@ -0,0 +1,281 @@ +"""Channel: keyframe management, evaluation, extend behaviour and state.""" + +import pytest + + +# --- identity and size ------------------------------------------------------ + +def test_new_channel_is_named_and_empty(channel): + assert channel.name == "tx" + assert channel.empty is True + assert channel.num_keyframes == 0 + + +def test_size_tracks_num_keyframes(channel): + channel.create_keyframe(0, 0) + channel.create_keyframe(10, 1) + assert channel.num_keyframes == 2 + assert channel.size == channel.num_keyframes + assert channel.empty is False + + +# --- create_keyframe overloads --------------------------------------------- + +def test_create_keyframe_time_value(channel): + kf = channel.create_keyframe(12, 34) + assert kf.time == pytest.approx(12.0) + assert kf.value == pytest.approx(34.0) + + +def test_create_keyframe_with_function_and_handle_mode(op, channel): + kf = channel.create_keyframe(0, 0, op.Function.LINEAR, op.HandleMode.FLAT) + assert kf.function == op.Function.LINEAR + assert kf.handle_mode == op.HandleMode.FLAT + + +def test_create_keyframe_from_state(op, channel): + channel.create_keyframe(0, 0) + source = channel.keyframe(0).state + + other = op.create_channel("copy") + made = other.create_keyframe_from_state(source) + assert made.time == pytest.approx(0.0) + assert made.state["function"] == source["function"] + + +def test_keyframes_stay_sorted_by_time(channel): + for time in (30, 0, 15): + channel.create_keyframe(time, time) + times = [channel.keyframe(i).time for i in range(channel.num_keyframes)] + assert times == sorted(times) + + +# --- bounds ----------------------------------------------------------------- + +def test_start_end_and_length(ramp): + assert ramp.start_time == pytest.approx(0.0) + assert ramp.end_time == pytest.approx(30.0) + assert ramp.length == pytest.approx(30.0) + + +# --- evaluation ------------------------------------------------------------- + +def test_evaluate_hits_the_keyframes(ramp): + assert ramp.evaluate(0) == pytest.approx(0.0) + assert ramp.evaluate(30) == pytest.approx(100.0) + + +def test_evaluate_midpoint_is_between(ramp): + assert 0.0 < ramp.evaluate(15) < 100.0 + + +def test_evaluate_at_last_keyframe_is_exact(ramp): + """Regression: the end-of-range lookup used to dereference end().""" + assert ramp.evaluate(30.0) == pytest.approx(100.0) + + +def test_evaluate_range_length(ramp): + samples = ramp.evaluate_range(0, 30, 31) + assert len(samples) == 31 + assert samples[0] == pytest.approx(0.0) + # Half-open by default, so the last sample is one step short of frame 30. + assert samples[-1] == pytest.approx(ramp.evaluate(30.0 - 30.0 / 31)) + + +def test_evaluate_range_by_rate(ramp): + samples = ramp.evaluate_range_by_rate(0, 30, 1.0) + assert len(samples) > 1 + assert samples[0] == pytest.approx(0.0) + + +# --- navigation ------------------------------------------------------------- + +def test_next_and_prev_keyframe(ramp): + assert ramp.next_keyframe(10).time == pytest.approx(30.0) + assert ramp.prev_keyframe(10).time == pytest.approx(0.0) + + +def test_closest_keyframe(ramp): + assert ramp.closest_keyframe(2).time == pytest.approx(0.0) + assert ramp.closest_keyframe(28).time == pytest.approx(30.0) + + +# --- mutation --------------------------------------------------------------- + +def test_delete_keyframe(ramp): + ramp.delete_keyframe(0) + assert ramp.num_keyframes == 1 + assert ramp.keyframe(0).time == pytest.approx(30.0) + + +def test_set_keyframe_value(ramp): + ramp.set_keyframe_value(1, 250.0) + assert ramp.keyframe(1).value == pytest.approx(250.0) + + +def test_set_keyframe_time(ramp): + ramp.set_keyframe_time(1, 60.0) + assert ramp.keyframe(1).time == pytest.approx(60.0) + assert ramp.end_time == pytest.approx(60.0) + + +def test_set_keyframe_position(ramp): + ramp.set_keyframe_position(1, 45.0, 75.0) + assert ramp.keyframe(1).time == pytest.approx(45.0) + assert ramp.keyframe(1).value == pytest.approx(75.0) + + +# --- sequence protocol ------------------------------------------------------ + +def test_len_is_the_keyframe_count(ramp): + assert len(ramp) == 2 + + +def test_subscript_reads_a_keyframe(ramp): + assert ramp[0].time == pytest.approx(0.0) + assert ramp[1].value == pytest.approx(100.0) + + +def test_subscript_accepts_negative_indices(ramp): + assert ramp[-1].time == pytest.approx(30.0) + assert ramp[-2].time == pytest.approx(0.0) + + +def test_iteration_yields_keyframes_in_time_order(ramp): + assert [kf.time for kf in ramp] == pytest.approx([0.0, 30.0]) + + +def test_subscript_out_of_range_raises_index_error(ramp): + with pytest.raises(IndexError): + ramp[99] + + +def test_subscript_assignment_writes_the_keyframe_back(ramp): + kf = ramp[0] + kf.value = 42.0 + ramp[0] = kf + + assert ramp[0].value == pytest.approx(42.0) + + +def test_subscript_assignment_matches_update_keyframe(op, ramp): + other = op.create_channel("other") + other.create_keyframe(0, 0) + + kf = ramp[0] + kf.value = 42.0 + kf.function = op.Function.LINEAR + + ramp[0] = kf + other.update_keyframe(0, kf) + + assert ramp[0].value == pytest.approx(other[0].value) + assert ramp[0].function == other[0].function + + +def test_subscript_assignment_accepts_negative_indices(ramp): + kf = ramp[-1] + kf.value = 7.0 + ramp[-1] = kf + + assert ramp[1].value == pytest.approx(7.0) + + +def test_subscript_assignment_clamps_time_to_the_neighbour(ramp): + """Keyframes never reorder: a time past a neighbour clamps to it. + + Writing frame 60 into keyframe 0 pins it at its neighbour's frame 30 + rather than swapping the two, so indices stay stable under time edits. + """ + kf = ramp[0] + kf.time = 60.0 + ramp[0] = kf + + assert [k.time for k in ramp] == pytest.approx([30.0, 30.0]) + + +def test_set_keyframe_time_clamps_the_same_way(ramp): + """The in-place mutator clamps identically -- it is the same code path.""" + ramp.set_keyframe_time(0, 60.0) + assert [k.time for k in ramp] == pytest.approx([30.0, 30.0]) + + +def test_subscript_assignment_out_of_range_raises_index_error(ramp): + kf = ramp[0] + with pytest.raises(IndexError): + ramp[99] = kf + + +def test_subscript_assignment_rejects_non_keyframes(ramp): + with pytest.raises(TypeError): + ramp[0] = 5.0 + + +def test_subscript_deletion_is_not_supported(ramp): + """del points at delete_keyframe rather than silently doing nothing.""" + with pytest.raises(TypeError): + del ramp[0] + assert ramp.num_keyframes == 2 + + +# --- extend ----------------------------------------------------------------- + +def test_extend_defaults_to_hold(op, ramp): + assert ramp.extend_start == op.Extend.HOLD + assert ramp.extend_end == op.Extend.HOLD + + +def test_extend_hold_clamps_outside_the_range(ramp): + assert ramp.evaluate(-50) == pytest.approx(0.0) + assert ramp.evaluate(500) == pytest.approx(100.0) + + +def test_extend_is_settable(op, ramp): + ramp.extend_end = op.Extend.REPEAT + assert ramp.extend_end == op.Extend.REPEAT + + +def test_extend_repeat_wraps(op, ramp): + ramp.extend_end = op.Extend.REPEAT + # One full period past the end lands back at the start of the cycle. + assert ramp.evaluate(45) == pytest.approx(ramp.evaluate(15), abs=1e-6) + + +# --- state ------------------------------------------------------------------ + +def test_state_shape(ramp): + state = ramp.state + assert set(state) == { + "name", "start_time", "end_time", "length", "num_keyframes", + "empty", "extend_start", "extend_end", "keyframes", + } + assert len(state["keyframes"]) == 2 + + +def test_state_round_trips_through_a_second_channel(op, ramp): + other = op.create_channel("other") + other.state = ramp.state + + assert other.num_keyframes == ramp.num_keyframes + assert other.evaluate(15) == pytest.approx(ramp.evaluate(15)) + + +def test_set_state_replaces_rather_than_appends(op, ramp): + other = op.create_channel("other") + other.create_keyframe(0, 0) + other.create_keyframe(5, 5) + other.create_keyframe(9, 9) + + other.set_state(ramp.state) + assert other.num_keyframes == 2 + + +def test_state_preserves_extend(op, ramp): + ramp.extend_start = op.Extend.MIRROR + other = op.create_channel("other") + other.state = ramp.state + assert other.extend_start == op.Extend.MIRROR + + +def test_get_state_matches_property(ramp): + assert ramp.get_state() == ramp.state diff --git a/tests/python/test_errors.py b/tests/python/test_errors.py new file mode 100644 index 0000000..b9a8b7f --- /dev/null +++ b/tests/python/test_errors.py @@ -0,0 +1,68 @@ +"""Error handling: bad input should raise, never crash the host.""" + +import pytest + + +def test_get_missing_channel_raises(op): + with pytest.raises(Exception): + op.get_channel("nope") + + +def test_get_channel_out_of_range_raises(op): + op.create_channel("tx") + with pytest.raises(Exception): + op.get_channel(99) + + +def test_remove_missing_channel_raises(op): + with pytest.raises(Exception): + op.remove_channel("nope") + + +def test_keyframe_index_out_of_range_raises(ramp): + with pytest.raises(Exception): + ramp.keyframe(99) + + +def test_delete_keyframe_out_of_range_raises(ramp): + with pytest.raises(Exception): + ramp.delete_keyframe(99) + + +def test_set_state_rejects_non_mapping(ramp): + with pytest.raises(TypeError): + ramp.state = ["not", "a", "dict"] + + +def test_animation_set_state_rejects_non_mapping(op): + with pytest.raises(TypeError): + op.state = 42 + + +def test_animation_set_state_requires_channels(op): + with pytest.raises(ValueError): + op.state = {"start_time": 0.0} + + +def test_channel_outliving_its_removal_raises(op): + """A Python Channel held past remove_channel() must raise, not crash. + + The id lookup behind this throws std::out_of_range; letting that unwind + through the CPython boundary would take the host process down instead of + raising, so it is translated at the binding edge. + """ + ch = op.create_channel("doomed") + ch.create_keyframe(0, 0) + + op.remove_channel("doomed") + + with pytest.raises(RuntimeError): + _ = ch.num_keyframes + + +def test_channel_outliving_a_clear_raises(op): + ch = op.create_channel("doomed") + op.clear() + + with pytest.raises(RuntimeError): + _ = ch.name diff --git a/tests/python/test_keyframe.py b/tests/python/test_keyframe.py new file mode 100644 index 0000000..40c312c --- /dev/null +++ b/tests/python/test_keyframe.py @@ -0,0 +1,124 @@ +"""Keyframe: position, handles, interpolation function and handle mode.""" + +import pytest + + +def test_time_and_value(ramp): + first, last = ramp.keyframe(0), ramp.keyframe(1) + assert first.time == pytest.approx(0.0) + assert first.value == pytest.approx(0.0) + assert last.time == pytest.approx(30.0) + assert last.value == pytest.approx(100.0) + + +def test_keyframe_is_a_detached_copy(ramp): + """keyframe(i) hands back a fresh object each call, not a live view. + + Keyframes are values rather than handles: anim exposes them only as + const&, because any edit has to clamp the time between its neighbours and + re-solve their handles, so only the channel can perform one. + """ + assert ramp.keyframe(1) is not ramp.keyframe(1) + + +def test_assigning_to_a_keyframe_does_not_reach_the_channel(ramp): + """Setting a property on a returned Keyframe updates only that copy. + + The assignment succeeds and reads back on the copy, but the channel is + untouched. This is the documented contract -- the setters exist so a + standalone Keyframe can be built to hand to the channel. The two ways to + persist an edit are covered by the two tests below. + """ + kf = ramp.keyframe(1) + kf.value = 55.0 + + assert kf.value == pytest.approx(55.0) + assert ramp.keyframe(1).value == pytest.approx(100.0) + + +def test_set_keyframe_value_persists(ramp): + """Editing in place, via the channel's index-based mutators.""" + ramp.set_keyframe_value(1, 55.0) + assert ramp.keyframe(1).value == pytest.approx(55.0) + + +def test_round_tripping_a_copy_persists(ramp): + """Editing by round trip: read a copy, change it, write it back.""" + kf = ramp.keyframe(1) + kf.value = 55.0 + ramp[1] = kf + + assert ramp.keyframe(1).value == pytest.approx(55.0) + + +def test_a_handle_read_from_a_keyframe_is_also_a_copy(ramp): + """Point is a value too, so kf.in_handle.time = x changes nothing.""" + kf = ramp.keyframe(1) + original = kf.in_handle.time + + kf.in_handle.time = original + 5.0 + + assert kf.in_handle.time == pytest.approx(original) + + +def test_handles_are_points(ramp): + kf = ramp.keyframe(0) + assert hasattr(kf.in_handle, "time") + assert hasattr(kf.out_handle, "value") + + +def test_defaults_are_bezier_and_smooth(op, ramp): + kf = ramp.keyframe(0) + assert kf.function == op.Function.BEZIER + assert kf.handle_mode == op.HandleMode.SMOOTH + + +def test_function_is_settable(op, ramp): + ramp.set_keyframe_function(0, op.Function.LINEAR) + assert ramp.keyframe(0).function == op.Function.LINEAR + + +def test_handle_mode_is_settable(op, ramp): + ramp.set_keyframe_handle_mode(0, op.HandleMode.FLAT) + assert ramp.keyframe(0).handle_mode == op.HandleMode.FLAT + + +def test_state_shape(ramp): + state = ramp.keyframe(0).state + assert set(state) == { + "position", "in_handle", "out_handle", "function", "handle_mode", + } + assert set(state["position"]) == {"time", "value"} + + +def test_state_encodes_enums_as_names(ramp): + state = ramp.keyframe(0).state + assert state["function"] == "Function.BEZIER" + assert state["handle_mode"] == "HandleMode.SMOOTH" + + +def test_state_round_trips(op, ramp): + kf = ramp.keyframe(0) + original = kf.state + + kf.function = op.Function.CONSTANT + assert kf.state["function"] == "Function.CONSTANT" + + kf.state = original + assert kf.state == original + + +def test_get_state_matches_property(ramp): + kf = ramp.keyframe(0) + assert kf.get_state() == kf.state + + +def test_linear_function_interpolates_linearly(op, ramp): + ramp.set_keyframe_function(0, op.Function.LINEAR) + assert ramp.evaluate(15) == pytest.approx(50.0, abs=1e-6) + + +def test_constant_function_holds_the_start_value(op, ramp): + ramp.set_keyframe_function(0, op.Function.CONSTANT) + assert ramp.evaluate(15) == pytest.approx(0.0) + assert ramp.evaluate(29.9) == pytest.approx(0.0) diff --git a/tests/python/test_point.py b/tests/python/test_point.py new file mode 100644 index 0000000..875e737 --- /dev/null +++ b/tests/python/test_point.py @@ -0,0 +1,44 @@ +"""Point: the time/value pair used for keyframe positions and handles.""" + +import pytest + + +def test_handles_expose_time_and_value(ramp): + handle = ramp.keyframe(0).out_handle + assert isinstance(handle.time, float) + assert isinstance(handle.value, float) + + +def test_state_is_time_and_value(ramp): + state = ramp.keyframe(0).out_handle.state + assert set(state) == {"time", "value"} + assert state["time"] == pytest.approx(ramp.keyframe(0).out_handle.time) + assert state["value"] == pytest.approx(ramp.keyframe(0).out_handle.value) + + +def test_get_state_matches_property(ramp): + handle = ramp.keyframe(0).in_handle + assert handle.get_state() == handle.state + + +def test_set_state_round_trips(ramp): + handle = ramp.keyframe(0).out_handle + original = handle.state + + handle.set_state({"time": 5.0, "value": 42.0}) + assert handle.time == pytest.approx(5.0) + assert handle.value == pytest.approx(42.0) + + handle.set_state(original) + assert handle.state == original + + +def test_state_setter_property(ramp): + handle = ramp.keyframe(0).out_handle + handle.state = {"time": 3.5, "value": -7.25} + assert handle.state["time"] == pytest.approx(3.5) + assert handle.state["value"] == pytest.approx(-7.25) + + +def test_repr_names_the_type(ramp): + assert "Point" in repr(ramp.keyframe(0).out_handle) diff --git a/td/tests_scripts/basic_tests.py b/tests/td/animation_chop_test.py similarity index 76% rename from td/tests_scripts/basic_tests.py rename to tests/td/animation_chop_test.py index f212547..4b19c9b 100644 --- a/td/tests_scripts/basic_tests.py +++ b/tests/td/animation_chop_test.py @@ -1,116 +1,20 @@ -""" -Test suite for AnimationCHOP Python API -This script tests all the exposed Python functions for channel and keyframe management. -Run this in TouchDesigner with an AnimationCHOP node. +"""In-TouchDesigner test suite for the AnimationCHOP Python API. + +This is the integration half of the test story. The pytest suite under +tests/python covers the same bindings without TouchDesigner, against a fake +node; this one runs against the real operator inside a real project, so it also +exercises the parts that only exist there -- the node cooking, its output +channels, and the parameters. + +Loaded as a DAT under /local/modules and driven by td_test_runner, which passes +the operator in. Importing this module runs nothing; call run_api_tests(). """ +import math import traceback -import inspect -import sys - -class TestResult: - def __init__(self): - self.passed = 0 - self.failed = 0 - self.errors = [] - - def _get_caller_line(self): - """Get the line number of the calling test function""" - frame = inspect.currentframe() - try: - # Go up the stack to find the test function call - # currentframe -> assert_* method -> test function - caller_frame = frame.f_back.f_back - return caller_frame.f_lineno - finally: - del frame - - def _get_exception_line(self): - """Get the line number where the current exception occurred""" - try: - exc_type, exc_value, exc_traceback = sys.exc_info() - if exc_traceback: - # Walk up the traceback to find the line in our test file - tb = exc_traceback - while tb.tb_next: - tb = tb.tb_next - return tb.tb_lineno - except: - # If anything goes wrong getting line number, just return None - pass - return None - - def assert_true(self, condition, message): - line_no = self._get_caller_line() - if condition: - self.passed += 1 - print(f"✓ PASS: {message}") - else: - self.failed += 1 - error_msg = f"✗ FAIL: {message} (line {line_no})" - print(error_msg) - self.errors.append(error_msg) - - def assert_false(self, condition, message): - self.assert_true(not condition, message) - - def assert_equal(self, expected, actual, message): - line_no = self._get_caller_line() - if expected == actual: - self.passed += 1 - print(f"✓ PASS: {message} (expected: {expected}, got: {actual})") - else: - self.failed += 1 - error_msg = f"✗ FAIL: {message} (expected: {expected}, got: {actual}) (line {line_no})" - print(error_msg) - self.errors.append(error_msg) - - def assert_not_none(self, value, message): - self.assert_true(value is not None, message) - - def assert_none(self, value, message): - self.assert_true(value is None, message) - - def assert_near(self, expected, actual, tolerance, message): - line_no = self._get_caller_line() - if abs(expected - actual) <= tolerance: - self.passed += 1 - print(f"✓ PASS: {message} (expected: {expected}, got: {actual}, tolerance: {tolerance})") - else: - self.failed += 1 - error_msg = f"✗ FAIL: {message} (expected: {expected}, got: {actual}, tolerance: {tolerance}) (line {line_no})" - print(error_msg) - self.errors.append(error_msg) - - def record_exception(self, test_name, exception): - """Record an exception with its actual line number""" - try: - line_no = self._get_exception_line() - if line_no: - error_msg = f"✗ FAIL: {test_name} - {exception} (line {line_no})" - else: - error_msg = f"✗ FAIL: {test_name} - {exception}" - except: - error_msg = f"✗ FAIL: {test_name} - {exception}" - - self.failed += 1 - print(error_msg) - self.errors.append(error_msg) - - def print_summary(self): - total = self.passed + self.failed - print(f"\n{'='*50}") - print(f"TEST SUMMARY") - print(f"{'='*50}") - print(f"Total tests: {total}") - print(f"Passed: {self.passed}") - print(f"Failed: {self.failed}") - print(f"Success rate: {(self.passed/total*100) if total > 0 else 0:.1f}%") - - if self.errors: - print(f"\nFAILED TESTS:") - for error in self.errors: - print(f" {error}") + +from test_result import TestResult + def test_point_api(anim_chop, result): @@ -447,18 +351,27 @@ def test_channel_api(anim_chop, result): result.assert_equal(5, len(values_range), "Channel.evaluate_range returns correct number of samples") result.assert_true(all(isinstance(v, float) for v in values_range), "Channel.evaluate_range returns floats") + # Half-open by default: a 2-second span at 1 Hz is 2 samples, at 0.0 and + # 1.0. The end is an edge, not a sample, so looping does not repeat it. values_by_rate = channel.evaluate_range_by_rate(0.0, 2.0, 1.0) - result.assert_equal(3, len(values_by_rate), "Channel.evaluate_range_by_rate returns correct samples") + result.assert_equal(2, len(values_by_rate), "Channel.evaluate_range_by_rate returns correct samples") + + values_inclusive = channel.evaluate_range_by_rate( + 0.0, 2.0, 1.0, anim_chop.RangeEnd.INCLUSIVE) + result.assert_equal(3, len(values_inclusive), + "Channel.evaluate_range_by_rate with RangeEnd.INCLUSIVE samples the end") # Test channel timing properties result.assert_equal(0.0, channel.start_time, "Channel.start_time") result.assert_equal(4.0, channel.end_time, "Channel.end_time") result.assert_equal(4.0, channel.length, "Channel.length") - # Test num_samples calculation - num_samples = channel.num_samples(30.0) - result.assert_true(isinstance(num_samples, int), "Channel.num_samples returns int") - result.assert_true(num_samples > 0, "Channel.num_samples returns positive value") + # A channel deliberately has no num_samples: it knows only the extent + # of its own keyframes, which is not the range a host samples over, so + # a count taken from it would answer about the wrong span. Count over + # the operator's range instead, or read TouchDesigner's numSamples. + result.assert_false(hasattr(channel, "num_samples"), + "Channel has no num_samples") # Test keyframe removal channel.delete_keyframe(1) @@ -539,10 +452,10 @@ def test_advanced_features(anim_chop, result): # Test multiple evaluation ranges sample_rates = [30.0, 60.0, 120.0] for rate in sample_rates: - samples = pos_x.num_samples(rate) values = pos_x.evaluate_range_by_rate(0.0, 4.0, rate) - expected_samples = int(4.0 * rate) + 1 - result.assert_near(expected_samples, len(values), 1, f"Sample count at {rate} Hz") + # Half-open: a 4-second span is exactly 4 * rate samples. + result.assert_equal(int(4.0 * rate), len(values), + f"Sample count at {rate} Hz") result.assert_true(True, "Complex animation scenario completed") @@ -1125,60 +1038,347 @@ def test_state_roundtrip(anim_chop, result): result.record_exception("State roundtrip test error", e) -def run_tests(cleanup=False): - # Get the current operator (this should be called from the AnimationCHOP node) +# --- cooked output ----------------------------------------------------------- +# +# Everything above tests the Python bindings, which the pytest suite also covers +# headlessly. These two do what only an in-TouchDesigner run can: check that the +# operator actually cooks, and that the samples it emits match what the channels +# evaluate to. They are split in half because the node has to cook between them, +# which takes a frame -- td_test_runner supplies the delay. + +# Deliberately a non-zero start: range mode used to size its output as +# end_time * sample_rate, which ignored the start and over-ran the range. +# +# The range is half-open, matching Animation.num_samples and a CHOP's own +# meaning of a sample count: a span of n sample periods is n samples, spaced +# exactly 1/rate apart, and the end time is not sampled. +COOK_START = 1.0 +COOK_END = 3.0 +COOK_RATE = 60.0 +COOK_SAMPLES = int(math.ceil((COOK_END - COOK_START) * COOK_RATE)) +COOK_STEP = 1.0 / COOK_RATE + + +def setup_cook_test(anim_chop): + """Put the operator in a known output configuration and key a ramp.""" + anim_chop.clear() + anim_chop.par.Outputmode = 'range' + anim_chop.par.Indexunit = 'seconds' + anim_chop.par.Timeslice = 0 + anim_chop.par.Samplerate = COOK_RATE + anim_chop.par.Range1 = COOK_START + anim_chop.par.Range2 = COOK_END + + ramp = anim_chop.create_channel('cook_ramp') + ramp.create_keyframe(COOK_START, 0.0) + ramp.create_keyframe(COOK_END, 100.0) + + flat = anim_chop.create_channel('cook_flat') + flat.create_keyframe(COOK_START, 7.0) + flat.create_keyframe(COOK_END, 7.0) + + +def check_cook_test(anim_chop, result): + """Compare the cooked CHOP output against the evaluated channels.""" + result.begin_suite('cooked output') + print("\n--- Testing cooked CHOP output ---") + try: - # In TouchDesigner, 'me' refers to the current operator - anim_chop = op('Animation1') - print(f"Running tests on node: {anim_chop}") - except NameError: - print("ERROR: This script must be run from within TouchDesigner") - print("Use: op('your_animationchop_name').run_tests()") + result.assert_equal(2, anim_chop.numChans, + "Cooked output has one CHOP channel per animation channel") + result.assert_equal(['cook_ramp', 'cook_flat'], + [c.name for c in anim_chop.chans()], + "Cooked channel names match the animation channels") + result.assert_equal(COOK_SAMPLES, anim_chop.numSamples, + "Cooked sample count covers the range at the sample rate") + result.assert_equal(anim_chop.num_samples, anim_chop.numSamples, + "Cooked sample count agrees with Animation.num_samples") + except Exception as e: + result.record_exception("Cooked output shape", e) return - + + try: + ramp_out = anim_chop['cook_ramp'] + ramp_src = anim_chop.get_channel('cook_ramp') + + result.assert_near(0.0, ramp_out[0], 1e-4, + "First cooked sample matches the first keyframe") + + # Half-open: the last sample sits one period short of the range end, so + # it is NOT the final keyframe's value. Asserting that explicitly, since + # an off-by-one here is otherwise invisible on a smooth curve. + result.assert_near(ramp_src.evaluate(COOK_END - COOK_STEP), + ramp_out[COOK_SAMPLES - 1], 1e-3, + "Last cooked sample sits one period before the range end") + result.assert_true(ramp_out[COOK_SAMPLES - 1] < 100.0, + "The range end itself is not sampled") + + # The interesting one: every sample has to agree with evaluate(), which + # is the contract the CHOP output rests on. Tolerance is loose because + # the CHOP stores float32 while evaluate() returns double. + mismatches = 0 + worst = 0.0 + for i in range(COOK_SAMPLES): + t = COOK_START + i * COOK_STEP + delta = abs(ramp_out[i] - ramp_src.evaluate(t)) + worst = max(worst, delta) + if delta > 1e-3: + mismatches += 1 + result.assert_equal(0, mismatches, + f"Every cooked sample matches Channel.evaluate() " + f"(worst delta {worst:.6f})") + + flat_out = anim_chop['cook_flat'] + result.assert_near(7.0, flat_out[COOK_SAMPLES // 2], 1e-4, + "A flat channel cooks to its constant value") + except Exception as e: + result.record_exception("Cooked output values", e) + + +# --- NaN in the cooked output (issue #15) ----------------------------------- +# +# The reported symptom is a NaN in the final sample, appearing only when the +# range end lands exactly on the last keyframe's time, and going away when the +# range is nudged. +# +# It cannot come from the curve: evaluate() was swept over some 2000 channel +# geometries -- every function and handle mode, degenerate segments, extreme +# handles -- without producing one. So the NaN is a sample the operator declared +# but never wrote. TouchDesigner does not clear the sample buffer between cooks, +# so an unwritten sample keeps whatever was in that memory, and a CHOP's buffer +# is filled with NaN precisely so that an unwritten sample is visible rather +# than plausible. +# +# That makes a NaN here a real signal, not cosmetic: it means the fill loop +# produced fewer samples than getOutputInfo asked for. These configurations are +# the ones where the old sizing formula and the actual sample count could +# disagree. + +NAN_RATE = 60.0 + +# (label, keyframe times, range start, range end, sample rate) +# +# The first is the reported case exactly. The rest vary the things the old +# formula was sensitive to: whether the range starts at zero, whether the span +# is a whole number of sample periods, and whether the rate divides it evenly. +NAN_CASES = [ + ("range end on the last keyframe", [0.0, 10.0], 0.0, 10.0, NAN_RATE), + ("range end past the last keyframe", [0.0, 10.0], 0.0, 10.5, NAN_RATE), + ("range end before the last keyframe", [0.0, 10.0], 0.0, 9.5, NAN_RATE), + ("non-zero range start", [1.0, 11.0], 1.0, 11.0, NAN_RATE), + ("range start before the first key", [2.0, 8.0], 0.0, 10.0, NAN_RATE), + ("fractional span", [0.0, 1.05], 0.0, 1.05, 30.0), + ("NTSC rate", [0.0, 10.0], 0.0, 10.0, 59.94), + ("rate of 1", [0.0, 10.0], 0.0, 10.0, 1.0), + ("very high rate", [0.0, 2.0], 0.0, 2.0, 240.0), + ("single keyframe", [5.0], 0.0, 10.0, NAN_RATE), + ("zero-length range", [0.0, 10.0], 5.0, 5.0, NAN_RATE), + ("negative times", [-10.0, 0.0], -10.0, 0.0, NAN_RATE), +] + +_nan_case_index = 0 +_nan_findings = [] + + +def _is_bad(x): + # NaN is the only value that is not equal to itself; inf is caught by the + # magnitude test. Written without math.isnan so this works on whatever + # numeric type TouchDesigner hands back. + return x != x or abs(x) > 1e30 + + +def setup_nan_case(anim_chop): + """Configure the next NaN case. Returns False when they are exhausted. + + check_nan_case() is what advances the index, so the two stay paired even if + a step is skipped. + """ + if _nan_case_index >= len(NAN_CASES): + return False + + label, times, start, end, rate = NAN_CASES[_nan_case_index] + anim_chop.clear() + anim_chop.par.Outputmode = 'range' + anim_chop.par.Indexunit = 'seconds' + anim_chop.par.Timeslice = 0 + anim_chop.par.Samplerate = rate + anim_chop.par.Range1 = start + anim_chop.par.Range2 = end + + # Two channels, so a shortfall affecting only the last one is still caught. + for name, scale in (('nan_a', 1.0), ('nan_b', -3.0)): + ch = anim_chop.create_channel(name) + for i, t in enumerate(times): + ch.create_keyframe(t, i * 100.0 * scale) + return True + + +def check_nan_case(anim_chop, result): + """Scan every cooked sample of the current case for NaN.""" + global _nan_case_index + + label, times, start, end, rate = NAN_CASES[_nan_case_index] + _nan_case_index += 1 + + result.begin_suite('cooked output: NaN') + try: + n = anim_chop.numSamples + bad = [] + for c in range(anim_chop.numChans): + chan = anim_chop[c] + for i in range(n): + if _is_bad(chan[i]): + bad.append((chan.name, i)) + if len(bad) > 4: + break + if len(bad) > 4: + break + + detail = f"{label} (range {start}..{end} @ {rate}, {n} samples)" + if bad: + _nan_findings.append(f"{detail}: {bad}") + result.assert_equal([], bad, f"No NaN in cooked output -- {detail}") + + # getOutputInfo sizes the output from the Sample Rate parameter, and + # execute fills it from output->sampleRate. Those are supposed to be the + # same number, but nothing in the API guarantees TouchDesigner passes it + # through untouched -- and if it ever substitutes one (the header notes + # the rate defaults to the timeline FPS), the count and the data would + # be computed from different rates and the tail would go unwritten. + # That would be intermittent and range-sensitive, which is what was + # reported, so it is worth knowing rather than assuming. + if abs(anim_chop.rate - rate) > 1e-6: + _nan_findings.append( + f"{detail}: cooked rate {anim_chop.rate} != parameter {rate}") + result.assert_near(rate, anim_chop.rate, 1e-3, + f"Cooked sample rate matches the parameter -- {detail}") + + # The count TouchDesigner hands execute() should be the one we asked + # for. If it is not, a fill loop sized from our own figure would come up + # short no matter how correct that figure was. + expected = anim_chop.num_samples + result.assert_equal(expected, n, + f"Cooked sample count matches Animation.num_samples -- {detail}") + + # A shortfall would show up as a trailing run of untouched samples, so + # the last sample is the one to be sure about. + if n > 0 and anim_chop.numChans > 0: + result.assert_false(_is_bad(anim_chop[0][n - 1]), + f"Final sample is a real number -- {detail}") + except Exception as e: + result.record_exception(f"NaN scan: {label}", e) + + +def setup_unconnected_input_case(anim_chop): + """Input mode with nothing connected -- an error state that still cooks. + + execute() sets an error and returns without writing anything, so every + sample used to keep whatever was in TouchDesigner's buffer. The node is + meant to report the problem, not emit NaN, and this configuration is one + parameter click away from the default. + """ + anim_chop.clear() + anim_chop.par.Outputmode = 'input' + ch = anim_chop.create_channel('orphan') + ch.create_keyframe(0.0, 0.0) + ch.create_keyframe(1.0, 100.0) + + +def check_unconnected_input_case(anim_chop, result): + result.begin_suite('cooked output: NaN') + try: + bad = [] + for c in range(anim_chop.numChans): + chan = anim_chop[c] + for i in range(anim_chop.numSamples): + if _is_bad(chan[i]): + bad.append((chan.name, i)) + break + if bad: + _nan_findings.append(f"Input mode with no input connected: {bad}") + result.assert_equal([], bad, + "No NaN in cooked output -- Input mode, nothing connected") + # The node should say why it has no data rather than only looking odd. + result.assert_true(bool(anim_chop.errors()), + "Input mode with no input reports an error") + except Exception as e: + result.record_exception("NaN scan: unconnected input", e) + finally: + try: + anim_chop.par.Outputmode = 'range' + anim_chop.clear() + except Exception: + pass + + +def nan_cases_remaining(): + return _nan_case_index < len(NAN_CASES) + + +def nan_findings(): + return list(_nan_findings) + + +def run_api_tests(anim_chop, cleanup=True, result=None): + """Run the binding suites against a real AnimationCHOP operator. + + Returns the TestResult. Pass one in to share it with the other modules in a + run, so the summary covers everything rather than this module alone. The + operator is passed in rather than looked up, so this module never needs to + know where it lives in the network. + """ + if anim_chop is None: + print("ERROR: run_api_tests() needs an AnimationCHOP operator.") + print("Use: animation_chop_test.run_api_tests(op('animation1'))") + return None + + print(f"Running tests on node: {anim_chop}") + + if result is None: + result = TestResult() + try: anim_chop.clear() # Clear any existing channels print("Cleared existing channels in AnimationCHOP") except Exception as e: print(f"Failed to clear channels: {e}") - return + result.record_exception("Could not clear the AnimationCHOP", e) + return result - # Initialize test results - result = TestResult() - print("="*60) print("ANIMATIONCHOP PYTHON API TEST SUITE") print("="*60) - - try: - # Run all test suites - test_point_api(anim_chop, result) - test_keyframe_api(anim_chop, result) - test_enum_apis(anim_chop, result) - test_animation_chop_core_api(anim_chop, result) - test_channel_api(anim_chop, result) - test_advanced_features(anim_chop, result) - test_error_handling(anim_chop, result) - test_state_apis(anim_chop, result) - test_state_error_handling(anim_chop, result) - test_state_roundtrip(anim_chop, result) + suites = [ + ('point', test_point_api), + ('keyframe', test_keyframe_api), + ('enums', test_enum_apis), + ('animation core', test_animation_chop_core_api), + ('channel', test_channel_api), + ('advanced', test_advanced_features), + ('error handling', test_error_handling), + ('state', test_state_apis), + ('state errors', test_state_error_handling), + ('state roundtrip', test_state_roundtrip), + ] - except Exception as e: - print(f"\nUNEXPECTED ERROR: {e}") - print(traceback.format_exc()) - result.failed += 1 - result.errors.append(f"Unexpected error: {e}") - - finally: - if cleanup: + for name, suite in suites: + result.begin_suite(name) + # Isolate the suites from each other: one blowing up should not take the + # rest of the run with it, or the first failure hides everything after. + try: + suite(anim_chop, result) + except Exception as e: + print(f"\nUNEXPECTED ERROR in {name}: {e}") + print(traceback.format_exc()) + result.record_exception(f"{name} suite aborted", e) + + if cleanup: + try: anim_chop.clear() - else: - print("\nSkipping cleanup. Test channels will remain in AnimationCHOP.") + except Exception as e: + print(f"Failed to clear channels during cleanup: {e}") + else: + print("\nSkipping cleanup. Test channels will remain in AnimationCHOP.") - # Print final results - result.print_summary() - return result - -run_tests() diff --git a/tests/td/animation_view_chop_test.py b/tests/td/animation_view_chop_test.py new file mode 100644 index 0000000..78f3022 --- /dev/null +++ b/tests/td/animation_view_chop_test.py @@ -0,0 +1,383 @@ +"""In-TouchDesigner test suite for AnimationViewCHOP. + +AnimationViewCHOP has no Python API of its own -- it reads another operator's +animation and republishes it as CHOP data for UI to build on. So unlike +AnimationCHOP, none of it can be covered by the headless pytest suite: every +assertion here is about what the node cooks. + +The five view modes each produce a different table, and the checks below are +mostly about shape and correspondence: that the channel names are the documented +ones, that there is one sample per keyframe / segment / channel as appropriate, +and that the values agree with the source animation rather than merely being +present. + +Loaded as a DAT under /local/modules and driven by td_test_runner, which passes +both operators in. Importing this module runs nothing. +""" + +# The channel tables each view mode publishes, in order. Hard-coded rather than +# read back from the node: these names are the operator's public surface, and a +# reordering or rename would break every UI built on it, so the test should fail +# when they change. +KEYFRAME_CHANS = [ + 'channel_index', 'keyframe_index', 'time', 'value', + 'in_handle_time', 'in_handle_value', 'out_handle_time', 'out_handle_value', + 'function', 'handle_mode', 'selected', 'display', +] + +SEGMENT_CHANS = [ + 'channel_index', 'segment_index', 'start_time', 'start_value', + 'end_time', 'end_value', 'start_handle_time', 'start_handle_value', + 'end_handle_time', 'end_handle_value', 'display_start_handle', + 'display_end_handle', 'selected', 'selected_start_handles', + 'selected_end_handles', +] + +CHANNEL_CHANS = [ + 'num_keyframes', 'start_time', 'end_time', 'start_index', + 'selected', 'display', +] + +ANIMATION_CHANS = [ + 'num_channels', 'min_keyframe_time', 'max_keyframe_time', + 'min_keyframe_value', 'max_keyframe_value', +] + +VIEW_RATE = 60.0 +VIEW_START = 0.0 +VIEW_END = 2.0 + + +def _names(chop): + return [c.name for c in chop.chans()] + + +def _vals(chop, name): + """A CHOP channel's samples as a plain list. + + td.Channel supports indexing but not iteration, so list() on one raises + TypeError rather than giving its samples. + """ + chan = chop[name] + return [chan[i] for i in range(chop.numSamples)] + + +def build_fixture(anim_chop): + """A small animation with a known shape, used by every view mode. + + Two channels of three keyframes each, so there are 6 keyframes and 4 + segments, and the two differ in range so the per-channel numbers cannot + quietly come from the wrong channel. + """ + anim_chop.clear() + anim_chop.par.Outputmode = 'range' + anim_chop.par.Indexunit = 'seconds' + anim_chop.par.Timeslice = 0 + anim_chop.par.Samplerate = VIEW_RATE + anim_chop.par.Range1 = VIEW_START + anim_chop.par.Range2 = VIEW_END + + a = anim_chop.create_channel('view_a') + a.create_keyframe(0.0, 0.0) + a.create_keyframe(1.0, 10.0) + a.create_keyframe(2.0, -5.0) + + b = anim_chop.create_channel('view_b') + b.create_keyframe(0.5, 3.0) + b.create_keyframe(1.0, 4.0) + b.create_keyframe(1.5, 25.0) + + +def configure(view_chop, mode): + view_chop.par.Viewmode = mode + + +def configure_samples(view_chop): + view_chop.par.Viewmode = 'samples' + view_chop.par.Rangeunit = 'seconds' + view_chop.par.Samplerate = VIEW_RATE + view_chop.par.Range1 = VIEW_START + view_chop.par.Range2 = VIEW_END + + +# --- one check function per view mode --------------------------------------- +# +# Each runs after the node has cooked in that mode; td_test_runner steps through +# them a few frames apart. + +def check_samples_view(anim_chop, view_chop, result): + result.begin_suite('view: samples') + print("\n--- Testing AnimationViewCHOP samples view ---") + + try: + result.assert_equal(anim_chop.num_channels, view_chop.numChans, + "Samples view has one CHOP channel per animation channel") + result.assert_equal(['view_a', 'view_b'], _names(view_chop), + "Samples view channel names come from the animation") + + # Half-open, matching AnimationCHOP: a 2-second span at 60 Hz is 120. + expected = int(round((VIEW_END - VIEW_START) * VIEW_RATE)) + result.assert_equal(expected, view_chop.numSamples, + "Samples view covers the range at the sample rate") + except Exception as e: + result.record_exception("Samples view shape", e) + return + + try: + src = anim_chop.get_channel('view_a') + out = view_chop['view_a'] + + mismatches = 0 + worst = 0.0 + for i in range(view_chop.numSamples): + t = VIEW_START + i / VIEW_RATE + delta = abs(out[i] - src.evaluate(t)) + worst = max(worst, delta) + if delta > 1e-3: + mismatches += 1 + result.assert_equal(0, mismatches, + f"Every samples-view sample matches Channel.evaluate() " + f"(worst delta {worst:.6f})") + + # Half-open: the last sample sits one period short of the range end. + result.assert_near(src.evaluate(VIEW_END - 1 / VIEW_RATE), + out[view_chop.numSamples - 1], 1e-3, + "Samples view stops one period short of the range end") + except Exception as e: + result.record_exception("Samples view values", e) + + +def check_keyframes_view(anim_chop, view_chop, result): + result.begin_suite('view: keyframes') + print("\n--- Testing AnimationViewCHOP keyframes view ---") + + try: + result.assert_equal(KEYFRAME_CHANS, _names(view_chop), + "Keyframes view publishes the documented channels") + result.assert_equal(6, view_chop.numSamples, + "Keyframes view has one sample per keyframe across all channels") + except Exception as e: + result.record_exception("Keyframes view shape", e) + return + + try: + # Rows are laid out channel by channel, so the first three are view_a. + result.assert_equal([0.0, 0.0, 0.0, 1.0, 1.0, 1.0], + _vals(view_chop, 'channel_index'), + "Keyframes view channel_index groups by channel") + result.assert_equal([0.0, 1.0, 2.0, 0.0, 1.0, 2.0], + _vals(view_chop, 'keyframe_index'), + "Keyframes view keyframe_index restarts per channel") + + a = anim_chop.get_channel('view_a') + times = _vals(view_chop, 'time') + values = _vals(view_chop, 'value') + for k in range(3): + result.assert_near(a.keyframe(k).time, times[k], 1e-4, + f"Keyframes view time matches keyframe {k}") + result.assert_near(a.keyframe(k).value, values[k], 1e-4, + f"Keyframes view value matches keyframe {k}") + + result.assert_near(a.keyframe(1).in_handle.time, + view_chop['in_handle_time'][1], 1e-4, + "Keyframes view in_handle_time matches the keyframe") + result.assert_near(a.keyframe(1).out_handle.value, + view_chop['out_handle_value'][1], 1e-4, + "Keyframes view out_handle_value matches the keyframe") + result.assert_equal(float(int(a.keyframe(0).function)), + view_chop['function'][0], + "Keyframes view function matches the keyframe") + result.assert_equal(float(int(a.keyframe(0).handle_mode)), + view_chop['handle_mode'][0], + "Keyframes view handle_mode matches the keyframe") + except Exception as e: + result.record_exception("Keyframes view values", e) + + +def check_segments_view(anim_chop, view_chop, result): + result.begin_suite('view: segments') + print("\n--- Testing AnimationViewCHOP segments view ---") + + try: + result.assert_equal(SEGMENT_CHANS, _names(view_chop), + "Segments view publishes the documented channels") + # Three keyframes per channel is two segments, so four in total. + result.assert_equal(4, view_chop.numSamples, + "Segments view has one sample per keyframe gap") + except Exception as e: + result.record_exception("Segments view shape", e) + return + + try: + a = anim_chop.get_channel('view_a') + result.assert_near(a.keyframe(0).time, view_chop['start_time'][0], 1e-4, + "Segments view start_time is the segment's first keyframe") + result.assert_near(a.keyframe(1).time, view_chop['end_time'][0], 1e-4, + "Segments view end_time is the segment's second keyframe") + result.assert_near(a.keyframe(1).value, view_chop['end_value'][0], 1e-4, + "Segments view end_value is the segment's second keyframe") + result.assert_equal([0.0, 0.0, 1.0, 1.0], + _vals(view_chop, 'channel_index'), + "Segments view channel_index groups by channel") + result.assert_equal([0.0, 1.0, 0.0, 1.0], + _vals(view_chop, 'segment_index'), + "Segments view segment_index restarts per channel") + except Exception as e: + result.record_exception("Segments view values", e) + + +def check_channels_view(anim_chop, view_chop, result): + result.begin_suite('view: channels') + print("\n--- Testing AnimationViewCHOP channels view ---") + + try: + result.assert_equal(CHANNEL_CHANS, _names(view_chop), + "Channels view publishes the documented channels") + result.assert_equal(2, view_chop.numSamples, + "Channels view has one sample per animation channel") + except Exception as e: + result.record_exception("Channels view shape", e) + return + + try: + result.assert_equal([3.0, 3.0], _vals(view_chop, 'num_keyframes'), + "Channels view num_keyframes matches each channel") + result.assert_near(0.0, view_chop['start_time'][0], 1e-4, + "Channels view start_time is the first keyframe's time") + result.assert_near(2.0, view_chop['end_time'][0], 1e-4, + "Channels view end_time is the last keyframe's time") + result.assert_near(0.5, view_chop['start_time'][1], 1e-4, + "Channels view start_time is per channel, not shared") + + # start_index is the running offset into the keyframes view, which is + # what lets a UI slice that table per channel. + result.assert_equal([0.0, 3.0], _vals(view_chop, 'start_index'), + "Channels view start_index accumulates keyframe counts") + result.assert_equal([1.0, 1.0], _vals(view_chop, 'display'), + "Channels view display defaults on") + except Exception as e: + result.record_exception("Channels view values", e) + + +def check_animation_view(anim_chop, view_chop, result): + result.begin_suite('view: animation') + print("\n--- Testing AnimationViewCHOP animation view ---") + + try: + result.assert_equal(ANIMATION_CHANS, _names(view_chop), + "Animation view publishes the documented channels") + result.assert_equal(1, view_chop.numSamples, + "Animation view is a single sample") + except Exception as e: + result.record_exception("Animation view shape", e) + return + + try: + result.assert_equal(2.0, view_chop['num_channels'][0], + "Animation view num_channels counts the channels") + # Bounds are across every keyframe of every channel: view_a spans + # 0..2 and -5..10, view_b spans 0.5..1.5 and 3..25. + result.assert_near(0.0, view_chop['min_keyframe_time'][0], 1e-4, + "Animation view min_keyframe_time spans all channels") + result.assert_near(2.0, view_chop['max_keyframe_time'][0], 1e-4, + "Animation view max_keyframe_time spans all channels") + result.assert_near(-5.0, view_chop['min_keyframe_value'][0], 1e-4, + "Animation view min_keyframe_value spans all channels") + result.assert_near(25.0, view_chop['max_keyframe_value'][0], 1e-4, + "Animation view max_keyframe_value spans all channels") + except Exception as e: + result.record_exception("Animation view values", e) + + +# --- empty channels --------------------------------------------------------- + +def setup_empty_channel(anim_chop, view_chop): + """An empty channel alongside a populated one. + + A channel with no keyframes has no segments, and the segment count was + computed as size() - 1 on an unsigned type -- so an empty channel wrapped to + SIZE_MAX and undercounted the segment table, which the fill loop then wrote + past the end of. Creating a channel before keying it is completely ordinary, + so this configuration has to cook. + """ + anim_chop.clear() + populated = anim_chop.create_channel('has_keys') + populated.create_keyframe(0.0, 0.0) + populated.create_keyframe(1.0, 1.0) + populated.create_keyframe(2.0, 0.0) + anim_chop.create_channel('empty') # no keyframes + anim_chop.create_channel('single').create_keyframe(0.0, 5.0) + + view_chop.par.Viewmode = 'segments' + + +def check_empty_channel(anim_chop, view_chop, result): + result.begin_suite('view: empty channels') + print("\n--- Testing AnimationViewCHOP with empty channels ---") + + try: + # Reaching here at all is most of the test: the bug crashed the cook. + result.assert_equal(2, view_chop.numSamples, + "Segments view counts only the populated channel's gaps") + result.assert_equal([0.0, 0.0], _vals(view_chop, 'channel_index'), + "Segments view attributes both segments to the keyed channel") + result.assert_equal(3, anim_chop.num_channels, + "The empty and single-keyframe channels still exist") + except Exception as e: + result.record_exception("Empty channel handling", e) + + +_saved_source = None + + +def setup_no_source(view_chop): + """Clear the source operator -- the state a freshly created node is in. + + setDataInstance() fails without one and execute() returns having written + nothing, so every sample used to keep whatever was in TouchDesigner's + buffer. A node that has not been hooked up yet should read as zeros, not + NaN. + """ + global _saved_source + _saved_source = view_chop.par.Datasource.eval() + view_chop.par.Datasource = '' + + +def check_no_source(view_chop, result): + result.begin_suite('view: no source') + print("\n--- Testing AnimationViewCHOP with no source operator ---") + + try: + bad = [] + for c in range(view_chop.numChans): + chan = view_chop[c] + for i in range(view_chop.numSamples): + if chan[i] != chan[i] or abs(chan[i]) > 1e30: + bad.append((chan.name, i)) + break + result.assert_equal([], bad, + "No NaN in cooked output with no source operator") + except Exception as e: + result.record_exception("No-source handling", e) + finally: + try: + if _saved_source is not None: + view_chop.par.Datasource = _saved_source + except Exception: + pass + + +def check_empty_animation(anim_chop, view_chop, result): + """No channels at all -- the state a freshly created node is in.""" + result.begin_suite('view: empty animation') + print("\n--- Testing AnimationViewCHOP with no channels ---") + + try: + result.assert_true(view_chop.numSamples >= 0, + "An animation with no channels cooks without error") + # OP.error was deprecated in favour of OP.errors(); reading the old + # attribute raises rather than returning an empty string. + result.assert_equal('', view_chop.errors(), + "An animation with no channels raises no error") + except Exception as e: + result.record_exception("Empty animation handling", e) diff --git a/tests/td/td_test_runner.py b/tests/td/td_test_runner.py new file mode 100644 index 0000000..fa97457 --- /dev/null +++ b/tests/td/td_test_runner.py @@ -0,0 +1,266 @@ +"""In-TouchDesigner integration test driver. + +Loaded as a module in TouchDesigner (a DAT under /local/modules, synced to this +file) and invoked from a bootstrap Execute DAT's onStart, which passes the +operators in -- the only place that needs to know where they live: + + def onStart(): + import td_test_runner + td_test_runner.start(op('animation1'), op('animationview1')) + return + +The run is a sequence of steps. Some only need the Python API and run +immediately; the rest need the node to have cooked in a particular +configuration first, so they are split into a setup and a check a few frames +apart. run(..., delayFrames=) supplies the gap -- waiting on frames is the only +way to let a node cook, since sleeping would block the very frames being waited +on. + +When the last step finishes, this module writes the results.json sentinel. +TouchDesigner is left running; the host script (run_td_tests.ps1 / +run_td_tests.sh) detects the sentinel and terminates it. + +Environment variables (set by the host script): + ANIMATIONCHOP_RESULTS path to write results.json + (default: /results.json) + ANIMATIONCHOP_OP name of the AnimationCHOP (default: animation1) + ANIMATIONCHOP_VIEW_OP name of the AnimationViewCHOP (default: animationview1) +""" + +import os +import json +import traceback + +import animation_chop_test +import animation_view_chop_test +from test_result import TestResult + + +# Frames to wait between configuring a node and reading what it cooked. One is +# enough in principle; a few gives parameter changes room to propagate on a +# loaded project without making the run feel slow. +COOK_DELAY_FRAMES = 10 + + +# State carried across the frame gaps. Each deferred step comes back in as a +# fresh `import td_test_runner`, so module state is the handoff -- simpler than +# threading objects through run()'s args, and it does not depend on `me` +# resolving to this DAT. +_anim_chop = None +_view_chop = None +_result = None +_steps = [] +_step_index = 0 + + +def _results_path(): + default = os.path.join(project.folder, "results.json") # noqa: F821 (TD global) + return os.environ.get("ANIMATIONCHOP_RESULTS", default) + + +def _resolve(explicit, env_var, default_name): + if explicit is not None: + return explicit + return op(os.environ.get(env_var, default_name)) # noqa: F821 (TD global) + + +def start(anim_chop=None, view_chop=None): + """Run every suite against the operators and write the results sentinel. + + Pass the operators in from the bootstrap Execute DAT. If either is omitted + its name is taken from the environment (see the module docstring). + + A missing AnimationViewCHOP is not fatal -- its suites are skipped and + recorded as such, so an older test project still reports on everything else + rather than failing wholesale. + """ + global _anim_chop, _view_chop, _result, _steps, _step_index + + print("[td-test] start()") + try: + _anim_chop = _resolve(anim_chop, "ANIMATIONCHOP_OP", "animation1") + if _anim_chop is None: + _write_failure("No AnimationCHOP found. Check the Execute DAT wiring.") + return + + _view_chop = _resolve(view_chop, "ANIMATIONCHOP_VIEW_OP", "animationview1") + + _result = TestResult() + _steps = _build_steps() + _step_index = 0 + _advance() + except Exception as e: + print(f"[td-test] start() failed: {e}") + print(traceback.format_exc()) + _write_failure(f"{e}\n{traceback.format_exc()}") + + +def _build_steps(): + """The run, as a list of zero-argument callables. + + A step that configures a node returns nothing; the frame gap between every + step is what lets the node cook before the next one reads it. + """ + ac = animation_chop_test + av = animation_view_chop_test + + steps = [ + lambda: ac.run_api_tests(_anim_chop, cleanup=True, result=_result), + lambda: ac.setup_cook_test(_anim_chop), + lambda: ac.check_cook_test(_anim_chop, _result), + ] + + # One configure/scan pair per NaN case. The list is a fixed length, so this + # unrolls rather than looping at run time -- the steps carry no state of + # their own, and the module tracks which case is current. + for _ in ac.NAN_CASES: + steps.append(lambda: ac.setup_nan_case(_anim_chop)) + steps.append(lambda: ac.check_nan_case(_anim_chop, _result)) + + steps += [ + lambda: ac.setup_unconnected_input_case(_anim_chop), + lambda: ac.check_unconnected_input_case(_anim_chop, _result), + ] + + if _view_chop is None: + print("[td-test] no AnimationViewCHOP found; skipping its suites") + steps.append(lambda: _result.record_exception( + "AnimationViewCHOP suites skipped", + "No AnimationViewCHOP in the project (set ANIMATIONCHOP_VIEW_OP " + "or pass it to start())")) + return steps + + # Each view mode is a configure step followed by a check, since the node has + # to cook in that mode before its output can be read. + steps += [ + lambda: av.build_fixture(_anim_chop), + lambda: av.configure_samples(_view_chop), + lambda: av.check_samples_view(_anim_chop, _view_chop, _result), + + lambda: av.configure(_view_chop, 'keyframes'), + lambda: av.check_keyframes_view(_anim_chop, _view_chop, _result), + + lambda: av.configure(_view_chop, 'segments'), + lambda: av.check_segments_view(_anim_chop, _view_chop, _result), + + lambda: av.configure(_view_chop, 'channel'), + lambda: av.check_channels_view(_anim_chop, _view_chop, _result), + + lambda: av.configure(_view_chop, 'animation'), + lambda: av.check_animation_view(_anim_chop, _view_chop, _result), + + lambda: av.setup_empty_channel(_anim_chop, _view_chop), + lambda: av.check_empty_channel(_anim_chop, _view_chop, _result), + + lambda: _anim_chop.clear(), + lambda: av.check_empty_animation(_anim_chop, _view_chop, _result), + + lambda: av.setup_no_source(_view_chop), + lambda: av.check_no_source(_view_chop, _result), + ] + return steps + + +def _advance(): + """Run the next step, then schedule the one after it a few frames later.""" + global _step_index + + if _step_index >= len(_steps): + _finish() + return + + step = _steps[_step_index] + _step_index += 1 + + try: + step() + except Exception as e: + # One step failing should not strand the rest of the run, or the first + # failure hides everything after it -- and without the sentinel the host + # script can only report a timeout, which says nothing about why. + print(f"[td-test] step {_step_index} failed: {e}") + print(traceback.format_exc()) + if _result is not None: + _result.record_exception(f"step {_step_index} aborted", e) + + run("import td_test_runner; td_test_runner._advance()", # noqa: F821 (TD global) + delayFrames=COOK_DELAY_FRAMES) + + +def _finish(): + try: + _anim_chop.clear() + except Exception: + pass + + # Print these together at the end: a NaN in the cooked output means the + # operator declared more samples than it wrote, and which configurations + # trigger it is the whole diagnosis. + findings = animation_chop_test.nan_findings() + if findings: + print("\n[td-test] NaN found in cooked output:") + for f in findings: + print(f" {f}") + + _result.print_summary() + _write_results(_result) + + +def _summarize(records): + """Collapse per-assertion records into one entry per suite. + + The run makes several hundred assertions; the host script prints a line per + suite plus every individual failure, which is the useful shape for a gate. + """ + order = [] + suites = {} + for r in records: + name = r.get("suite", "general") + if name not in suites: + suites[name] = {"name": name, "passed": 0, "failed": 0} + order.append(name) + key = "passed" if r["passed"] else "failed" + suites[name][key] += 1 + return [suites[n] for n in order] + + +def _write_results(result): + records = result.records + failures = [r for r in records if not r["passed"]] + summary = { + "results": [ + { + "name": s["name"], + "passed": s["failed"] == 0, + "detail": f"{s['passed']} passed, {s['failed']} failed", + } + for s in _summarize(records) + ], + "failures": [ + {"suite": r["suite"], "name": r["name"], "detail": r["detail"]} + for r in failures + ], + "passed": result.passed, + "failed": result.failed, + "success": result.failed == 0 and result.passed > 0, + } + _dump(summary) + + +def _write_failure(message): + """Write a results file describing a run that never got to the assertions.""" + _dump({ + "results": [{"name": "runner", "passed": False, "detail": message}], + "failures": [{"suite": "runner", "name": "startup", "detail": message}], + "passed": 0, + "failed": 1, + "success": False, + }) + + +def _dump(summary): + path = _results_path() + with open(path, "w") as f: + json.dump(summary, f, indent=2) + print(f"[td-test] wrote {path}: " + f"{summary['passed']} passed, {summary['failed']} failed") diff --git a/tests/td/test.toe b/tests/td/test.toe new file mode 100644 index 0000000..ae2da9f Binary files /dev/null and b/tests/td/test.toe differ diff --git a/tests/td/test_result.py b/tests/td/test_result.py new file mode 100644 index 0000000..92d5c7c --- /dev/null +++ b/tests/td/test_result.py @@ -0,0 +1,144 @@ +"""Shared assertion harness for the in-TouchDesigner test modules. + +One TestResult is threaded through every suite in a run, so the summary and the +results.json the runner writes cover the whole run rather than one module. +""" + +import inspect +import sys + + +class TestResult: + """Collects assertions, printing as it goes and recording for results.json. + + Assertions are grouped into suites so a run of several hundred can be + reported as a handful of lines plus the individual failures. + """ + + def __init__(self): + self.passed = 0 + self.failed = 0 + self.errors = [] + self.records = [] + self.suite = "general" + + def begin_suite(self, name): + self.suite = name + + def _record(self, passed, message, detail=""): + self.records.append({ + "suite": self.suite, + "name": message, + "passed": bool(passed), + "detail": detail, + }) + + def _get_caller_line(self): + """Get the line number of the calling test function""" + frame = inspect.currentframe() + try: + # Go up the stack to find the test function call + # currentframe -> assert_* method -> test function + caller_frame = frame.f_back.f_back + return caller_frame.f_lineno + finally: + del frame + + def _get_exception_line(self): + """Get the line number where the current exception occurred""" + try: + exc_type, exc_value, exc_traceback = sys.exc_info() + if exc_traceback: + # Walk up the traceback to find the line in our test file + tb = exc_traceback + while tb.tb_next: + tb = tb.tb_next + return tb.tb_lineno + except: + # If anything goes wrong getting line number, just return None + pass + return None + + def assert_true(self, condition, message): + line_no = self._get_caller_line() + if condition: + self.passed += 1 + self._record(True, message) + print(f"✓ PASS: {message}") + else: + self.failed += 1 + error_msg = f"✗ FAIL: {message} (line {line_no})" + self._record(False, message, f"line {line_no}") + print(error_msg) + self.errors.append(error_msg) + + def assert_false(self, condition, message): + self.assert_true(not condition, message) + + def assert_equal(self, expected, actual, message): + line_no = self._get_caller_line() + if expected == actual: + self.passed += 1 + self._record(True, message) + print(f"✓ PASS: {message} (expected: {expected}, got: {actual})") + else: + self.failed += 1 + error_msg = f"✗ FAIL: {message} (expected: {expected}, got: {actual}) (line {line_no})" + self._record(False, message, + f"expected {expected}, got {actual} (line {line_no})") + print(error_msg) + self.errors.append(error_msg) + + def assert_not_none(self, value, message): + self.assert_true(value is not None, message) + + def assert_none(self, value, message): + self.assert_true(value is None, message) + + def assert_near(self, expected, actual, tolerance, message): + line_no = self._get_caller_line() + if abs(expected - actual) <= tolerance: + self.passed += 1 + self._record(True, message) + print(f"✓ PASS: {message} (expected: {expected}, got: {actual}, tolerance: {tolerance})") + else: + self.failed += 1 + error_msg = f"✗ FAIL: {message} (expected: {expected}, got: {actual}, tolerance: {tolerance}) (line {line_no})" + self._record(False, message, + f"expected {expected}, got {actual} " + f"(tolerance {tolerance}, line {line_no})") + print(error_msg) + self.errors.append(error_msg) + + def record_exception(self, test_name, exception): + """Record an exception with its actual line number""" + try: + line_no = self._get_exception_line() + if line_no: + error_msg = f"✗ FAIL: {test_name} - {exception} (line {line_no})" + else: + error_msg = f"✗ FAIL: {test_name} - {exception}" + except: + error_msg = f"✗ FAIL: {test_name} - {exception}" + + self.failed += 1 + self._record(False, test_name, str(exception)) + print(error_msg) + self.errors.append(error_msg) + + def print_summary(self): + total = self.passed + self.failed + print(f"\n{'='*50}") + print(f"TEST SUMMARY") + print(f"{'='*50}") + print(f"Total tests: {total}") + print(f"Passed: {self.passed}") + print(f"Failed: {self.failed}") + print(f"Success rate: {(self.passed/total*100) if total > 0 else 0:.1f}%") + + if self.errors: + print(f"\nFAILED TESTS:") + for error in self.errors: + print(f" {error}") + +