diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8d0a01..8e62d76 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -158,10 +158,14 @@ jobs: - name: Test shell: bash + working-directory: build run: | - # The sim check is headless by design, so it runs here as-is. - if [ -f build/simtest ]; then build/simtest - else build/Release/simtest.exe; fi + # The sim checks are headless by design, so they run here as-is. + # --output-on-failure prints the failing test's own log; every + # test function is registered separately, so a failure names + # itself instead of arriving as one line of a single binary's + # output. + ctest --output-on-failure --build-config Release # ===== macOS ===== - name: Install the Developer ID certificate diff --git a/.vscode/launch.json b/.vscode/launch.json index 97a0fb7..af4b2dd 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -171,14 +171,37 @@ } }, { - // Headless swarm check — populates, moves, orbits, is deterministic, - // and fully disperses when the bin empties. - "name": "Test: Fly Simulation", + // Step into ONE test. + // + // For running them, use the Testing panel (the flask in the activity + // bar) or select the `check` target and press build — CMake Tools + // populates the panel from CTest, so every test function appears + // there by name. This entry is for the other job: stopping inside a + // failing one. Pass the test function as an argument to run just it, + // e.g. "args": ["roll_doesNotLurch"]; with no arguments a Qt Test + // binary runs every slot it has. + "name": "Test: Tentacle chain (debug one)", "type": "cppdbg", "request": "launch", - "program": "${workspaceFolder}/build/simtest", + "program": "${workspaceFolder}/build/tests/tentacle/tst_tentacle_chain", "stopAtEntry": false, - "cwd": "${workspaceFolder}", + "cwd": "${workspaceFolder}/build", + "environment": [], + "args": [], + "osx": { + "MIMode": "lldb", + "targetArchitecture": "arm64" + } + }, + { + // The swarm's contract: populates, moves, lands, walks, stays over + // the bin, scales with it, and fully disperses when emptied. + "name": "Test: Fly simulation (debug one)", + "type": "cppdbg", + "request": "launch", + "program": "${workspaceFolder}/build/tests/flies/tst_fly_sim", + "stopAtEntry": false, + "cwd": "${workspaceFolder}/build", "environment": [], "args": [], "osx": { diff --git a/.vscode/settings.json b/.vscode/settings.json index 248c3a5..8136b82 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,15 @@ "cmake.useCMakePresets": "always", "cmake.configureOnOpen": true, "cmake.buildBeforeRun": true, + // Put every CTest test in the Testing panel (the flask in the activity + // bar), individually runnable. This is why each test function is + // registered with its own add_test rather than one per binary — the + // panel shows exactly what CTest knows about, so one add_test per + // executable would collapse 39 checks into 5 rows. + "cmake.ctest.testExplorerIntegrationEnabled": true, + // Tests are seconds each and independent, so run them at once. The + // slowest single check simulates 220 seconds of wave and takes ~1s. + "cmake.ctest.allowParallelJobs": true, "C_Cpp.default.compileCommands": "${workspaceFolder}/build/compile_commands.json", "C_Cpp.default.cppStandard": "c++20", "C_Cpp.errorSquiggles": "enabled", diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..6a821ab --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,44 @@ +{ + // F5 is the wrong key for this, and that is a VS Code fact rather than a + // configuration mistake: "Start Debugging" launches the DEBUG TARGET — + // one executable, chosen in the CMake status bar — and `check` is a + // custom target with no executable to launch. With cmake.buildBeforeRun + // on, F5 builds first, which is why the test binaries appear and nothing + // seems to run them. + // + // Three things that DO run them: + // * the Testing panel (flask in the activity bar) — per-test, with + // pass/fail beside each one. This is the one to use. + // * Terminal > Run Test Task, which runs the task below + // * selecting `check` as the BUILD target, then Build (Cmd-Shift-B) + "version": "2.0.0", + "tasks": [ + { + "label": "Run tests (ctest)", + "type": "shell", + "command": "cmake", + "args": [ + "--build", + "${command:cmake.buildDirectory}", + "--target", + "check" + ], + // Marks this as THE test task, so Terminal > Run Test Task picks it + // with no further prompting — and so it can be bound to a key: + // Preferences > Keyboard Shortcuts, "Tasks: Run Test Task". + "group": { + "kind": "test", + "isDefault": true + }, + // Always show it. A test run that reports only on failure is + // indistinguishable from one that never started, which is the exact + // confusion this file exists to end. + "presentation": { + "reveal": "always", + "panel": "dedicated", + "clear": true + }, + "problemMatcher": [] + } + ] +} diff --git a/CMakeLists.txt b/CMakeLists.txt index 0a832bc..18925a0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -16,6 +16,15 @@ endif() # submission, or a distro package where the package manager owns updates. option(HYPERBIN_AUTO_UPDATE "Include the auto-updater (Sparkle / WinSparkle)" ON) +# The headless test suite. ON everywhere that matters — CI, and any build +# somebody is going to change code in — and available to turn off because +# it is the one part of the tree that a packaging run has no use for. +# +# Toggle it from the CMake UI: in VS Code, "CMake: Edit CMake Cache (UI)" +# lists it as a checkbox; reconfiguring is enough to make the test targets +# and the CTest registrations appear or vanish. +option(HYPERBIN_BUILD_TESTS "Build the headless test suite" ON) + # --- versions --------------------------------------------------------------- # # Two numbers, and they do different jobs. @@ -101,7 +110,7 @@ if(NOT CMAKE_PREFIX_PATH) endif() endif() -find_package(Qt6 6.11 QUIET COMPONENTS Core Gui Widgets Svg Qml Quick QuickControls2 Quick3D ShaderTools) +find_package(Qt6 6.11 QUIET COMPONENTS Core Gui Widgets Svg Qml Quick QuickControls2 Quick3D ShaderTools Test) if(NOT Qt6_FOUND) if(WIN32) message(FATAL_ERROR @@ -123,7 +132,7 @@ if(NOT Qt6_FOUND) "have a CMakeUserPresets.json.") endif() endif() -find_package(Qt6 6.11 REQUIRED COMPONENTS Core Gui Widgets Svg Qml Quick QuickControls2 Quick3D ShaderTools) +find_package(Qt6 6.11 REQUIRED COMPONENTS Core Gui Widgets Svg Qml Quick QuickControls2 Quick3D ShaderTools Test) qt_standard_project_setup(REQUIRES 6.11) # --- auto-update ------------------------------------------------------------ @@ -180,15 +189,61 @@ set(HYPERBIN_COUNTLY_SERVER "https://analytics.hypernuclear.com" CACHE STRING "Countly server URL") if(HYPERBIN_ANALYTICS) - if(NOT EXISTS "${CMAKE_SOURCE_DIR}/third_party/countly-sdk-cpp/CMakeLists.txt") - message(FATAL_ERROR - "third_party/countly-sdk-cpp is empty. Run:\n" - " git submodule update --init --recursive\n" - "or configure with -DHYPERBIN_ANALYTICS=OFF.") + # Countly's SDK, fetched rather than demanded. + # + # It is a submodule, but "run git submodule update --init" is a wall to + # walk into on a fresh clone, and it is one the build can step over + # itself: it knows where the code lives and which commit it wants. + # Three routes, cheapest first, and analytics stays switchable off for + # anyone who would rather not have either. + set(_hb_countly "${CMAKE_SOURCE_DIR}/third_party/countly-sdk-cpp") + # Must match the gitlink recorded for third_party/countly-sdk-cpp. The + # submodule route reads that pin from the git index; the download route + # cannot, so it is repeated here. Bump both together — the two routes + # quietly building different SDKs is worse than either being stale. + set(_hb_countly_pin "e34904b9c798024223d1657c69f6bf17cb786db3") + set(_hb_countly_fetch OFF) + + if(NOT EXISTS "${_hb_countly}/CMakeLists.txt") + find_package(Git QUIET) + if(GIT_FOUND AND EXISTS "${CMAKE_SOURCE_DIR}/.git") + # Say it out loud: the SDK carries vendor submodules of its + # own (sqlite, doctest, nlohmann/json — the last of which is a + # large repository), so a first configure can sit here for + # minutes with nothing on screen. Silence in Qt Creator's + # configure pane is indistinguishable from a hang. + message(STATUS "countly-sdk-cpp is empty; fetching the submodule and " + "its vendor dependencies — this can take a few " + "minutes the first time") + execute_process( + COMMAND ${GIT_EXECUTABLE} submodule update --init --recursive + -- third_party/countly-sdk-cpp + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + RESULT_VARIABLE _hb_countly_sm) + if(NOT _hb_countly_sm EQUAL 0) + message(STATUS "countly-sdk-cpp: submodule init failed (${_hb_countly_sm})") + endif() + endif() + endif() + + if(NOT EXISTS "${_hb_countly}/CMakeLists.txt") + # No .git to work from — a source archive, or a clone whose + # submodule fetch failed. Download the same commit instead. + message(STATUS "countly-sdk-cpp: downloading ${_hb_countly_pin}" + " (-DHYPERBIN_ANALYTICS=OFF to skip analytics entirely)") + include(FetchContent) + # No GIT_SHALLOW: a bare commit SHA cannot be fetched by ref from + # most servers, and this pin is a SHA rather than a tag. + FetchContent_Declare(countly + GIT_REPOSITORY https://github.com/Countly/countly-sdk-cpp.git + GIT_TAG ${_hb_countly_pin}) + set(_hb_countly_fetch ON) endif() + # Qt already provides HTTP and SHA-256, and this app links both. The # SDK's own defaults would drag libcurl and OpenSSL into a menu-bar - # app for no gain. + # app for no gain. Set BEFORE the SDK is added, whichever route it + # arrived by. set(COUNTLY_USE_CUSTOM_HTTP ON CACHE BOOL "" FORCE) set(COUNTLY_USE_CUSTOM_SHA256 ON CACHE BOOL "" FORCE) set(COUNTLY_BUILD_TESTS OFF CACHE BOOL "" FORCE) @@ -199,8 +254,23 @@ if(HYPERBIN_ANALYTICS) set(CMAKE_POLICY_VERSION_MINIMUM 3.5 CACHE STRING "" FORCE) set(_hb_shared_saved ${BUILD_SHARED_LIBS}) set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) - add_subdirectory(third_party/countly-sdk-cpp EXCLUDE_FROM_ALL) + if(_hb_countly_fetch) + FetchContent_MakeAvailable(countly) + else() + add_subdirectory(third_party/countly-sdk-cpp EXCLUDE_FROM_ALL) + endif() set(BUILD_SHARED_LIBS ${_hb_shared_saved} CACHE BOOL "" FORCE) + + # Both routes are network operations, and a failed one leaves a + # directory that exists but builds nothing. Say so here rather than + # letting it surface as an unresolved link to `countly`. + if(NOT TARGET countly) + message(FATAL_ERROR + "countly-sdk-cpp did not provide a `countly` target.\n" + "Fetch it by hand with:\n" + " git submodule update --init --recursive\n" + "or build without analytics: -DHYPERBIN_ANALYTICS=OFF") + endif() endif() set(HYPERBIN_UPDATE_SOURCES src/update/AppUpdater.h) @@ -257,6 +327,10 @@ qt_add_executable(hyperbin src/platform/StubTrashTarget.cpp src/platform/StubTrashTarget.h src/platform/LaunchAtLogin.h + src/platform/LowPower.h + $<$:src/platform/LowPowerMac.mm> + $<$:src/platform/LowPowerWin.cpp> + $<$,$>>:src/platform/LowPowerStub.cpp> $<$:src/platform/LaunchAtLoginMac.mm> $<$:src/platform/LaunchAtLoginWin.cpp> $<$,$>>:src/platform/LaunchAtLoginStub.cpp> @@ -273,11 +347,16 @@ qt_add_executable(hyperbin src/core/EffectRegistry.h src/core/DistanceField.cpp src/core/DistanceField.h + src/core/BinMouth.cpp src/core/OozeSim.cpp + src/core/TentacleChain.cpp src/core/OozeSim.h src/effects/FliesEffect.cpp src/effects/FliesEffect.h + src/effects/IconTexture.cpp + src/effects/SpineTexture.cpp src/effects/OozeEffect.cpp + src/effects/TentacleEffect.cpp src/effects/OozeEffect.h src/effects/OozeGeometry.cpp src/effects/OozeGeometry.h @@ -305,6 +384,7 @@ qt_add_qml_module(hyperbin qml/BinGlyph.qml qml/OozeVisual.qml qml/OozeEyes.qml + qml/TentacleVisual.qml qml/Permissions.qml qml/Splash.qml qml/HyperbinLockup.qml @@ -360,14 +440,18 @@ qt_add_resources(hyperbin "hyperbin_icons" # that silently falls back to the system UI face on every machine # but the one it was designed on is not a brand. resources/Inter-SemiBold.ttf - # The eyeballs in the goo. Converted from a glTF with balsam; - # see docs/effects.md. The source model's cornea is a second, + # The eyeballs in the goo. Converted from a glTF with balsam — see + # "Bringing in a model" in docs/effects.md for the command and what + # to keep. The source model's cornea is a second, # transparent mesh — left out on purpose, because a transparent # PBR shell costs a full extra pass to produce a highlight that # is sub-pixel at the size this is drawn. resources/meshes/eye.mesh resources/maps/eye_albedo.jpg resources/maps/eye_normal.png + resources/meshes/tentacle.mesh + resources/maps/tentacle_albedo.jpg + resources/maps/tentacle_normal.jpg ) qt_add_resources(hyperbin "hyperbin_shaders3d" PREFIX "/shaders3d" @@ -377,15 +461,28 @@ qt_add_resources(hyperbin "hyperbin_shaders3d" shaders/ooze3d.frag shaders/eye3d.vert shaders/eye3d.frag + shaders/tentacle.vert + shaders/tentacle.frag ) -# Headless swarm check. No GUI, so it runs on CI and under the debugger -# without a display. -add_executable(simtest tests/simtest.cpp src/core/FlySim.cpp src/core/OozeSim.cpp - src/core/DistanceField.cpp src/app/Settings.cpp) -target_include_directories(simtest PRIVATE src/core src/app) -# Gui, not just Core: the distance field is a QImage. It needs no display -target_link_libraries(simtest PRIVATE Qt6::Core Qt6::Gui) +# Headless checks. No GUI, so they run on CI and under the debugger without +# a display. +# +# Qt Test, one executable per area, every test function registered with +# CTest individually — so `ctest -R Tentacle` runs just the solver's checks +# and a failure names itself instead of being one line of a single binary's +# output. This replaced a hand-rolled harness whose `return fail(...)` +# aborted main on the first failure, which meant a broken check hid every +# check after it. +# +# enable_testing() has to be called from THIS file rather than from +# tests/CMakeLists.txt: CTest only writes a top-level CTestTestfile.cmake +# for the directory it was enabled in, and `ctest` run from the build root +# finds nothing without one. +if(HYPERBIN_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() # Overlay app: no Dock icon, no menu bar. LSUIElement keeps it a # background agent driven from the menu-bar item. diff --git a/CMakePresets.json b/CMakePresets.json index 49db46e..f4507b5 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -18,10 +18,28 @@ }, { "name": "dev", - "displayName": "Dev (Debug)", - "description": "Qt is auto-detected from ~/Qt; set QT_ROOT or CMAKE_PREFIX_PATH to override", + "displayName": "Debug (tests on)", + "description": "Unoptimised, assertions live. Qt is auto-detected from ~/Qt; set QT_ROOT or CMAKE_PREFIX_PATH to override", "inherits": "base", - "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" }, + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "HYPERBIN_BUILD_TESTS": "ON" + }, + "condition": { + "type": "notEquals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + } + }, + { + "name": "relwithdebinfo", + "displayName": "RelWithDebInfo (tests on)", + "description": "Optimised, with symbols. What to profile and what to debug a timing-dependent bug in — a Debug build is slow enough to change the behaviour of anything that depends on frame rate, which most of this app does", + "inherits": "base", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo", + "HYPERBIN_BUILD_TESTS": "ON" + }, "condition": { "type": "notEquals", "lhs": "${hostSystemName}", @@ -30,9 +48,13 @@ }, { "name": "release", - "displayName": "Release", + "displayName": "Release (no tests)", + "description": "What ships. Tests off: a packaging run has no use for them, and this is the only preset where that is true", "inherits": "base", - "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo" }, + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "HYPERBIN_BUILD_TESTS": "OFF" + }, "condition": { "type": "notEquals", "lhs": "${hostSystemName}", @@ -42,10 +64,11 @@ { "name": "ci", "displayName": "CI (unsigned)", - "description": "CI runners have no signing identity", + "description": "CI runners have no signing identity. Tests ON — this is the one build whose whole job is to catch a regression", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", + "HYPERBIN_BUILD_TESTS": "ON", "HYPERBIN_CODESIGN_IDENTITY": "" }, "condition": { @@ -57,7 +80,34 @@ ], "buildPresets": [ { "name": "dev", "configurePreset": "dev" }, + { "name": "relwithdebinfo", "configurePreset": "relwithdebinfo" }, { "name": "release", "configurePreset": "release" }, - { "name": "ci", "configurePreset": "ci" } + { "name": "ci", "configurePreset": "ci" }, + { + "name": "check", + "displayName": "Build and run the tests", + "configurePreset": "dev", + "targets": ["check"] + } + ], + "testPresets": [ + { + "name": "dev", + "configurePreset": "dev", + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error", "stopOnFailure": false } + }, + { + "name": "relwithdebinfo", + "configurePreset": "relwithdebinfo", + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error", "stopOnFailure": false } + }, + { + "name": "ci", + "configurePreset": "ci", + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error", "stopOnFailure": false } + } ] } diff --git a/README.md b/README.md index 7520c44..3df7533 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,8 @@ invisible. Nothing draws when the Dock is auto-hidden — the icon is parked outside every display and there is nothing to sit on. -**Headless.** `./build/simtest` — the simulations only, never the -renderer, so it runs without a display. It asserts the swarm populates, +**Headless.** `cd build && ctest --output-on-failure` — the simulations +only, never the renderer, so it runs without a display. It asserts the swarm populates, moves, lands, walks, stays over the bin, scales with it and fully disperses when emptied; that the ooze creeps rather than snapping on, grows with the trash and recedes to nothing; that the overlay margins are diff --git a/assets/tentacle.glb b/assets/tentacle.glb new file mode 100644 index 0000000..2a7c920 Binary files /dev/null and b/assets/tentacle.glb differ diff --git a/docs/architecture.md b/docs/architecture.md index ebfaf15..1504781 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,7 +54,7 @@ Done and building: - `FlyItem` — batched scene-graph rendering, explicit frame clock - `StubTrashTarget` — fake geometry cycling fullness, so the sim is developable before either native backend exists -- `tests/simtest.cpp` — populates, moves, stays over the bin, scales, +- `tests/flies/` — populates, moves, stays over the bin, scales, fully disperses when emptied Verified: builds clean on macOS, runs 4s without crashing, sim test diff --git a/docs/battery.md b/docs/battery.md index a674bc1..993a3c5 100644 --- a/docs/battery.md +++ b/docs/battery.md @@ -42,9 +42,28 @@ actually matters (1) to optimise the thing that doesn't (4). - **Stop when nobody can see it**: display asleep, screen locked, session switched, a fullscreen app covering the target, the Dock auto-hidden, the desktop not the foreground (Windows). -- **Halve the rate on battery**, and stop entirely in Low Power Mode / - Battery Saver. Users forgive a paused novelty; they do not forgive - battery drain, and it is the top uninstall reason for this app class. +- **Run at the display's refresh rate**, and halve to 30fps when + conserving. A 120Hz screen gets 120Hz; the old fixed 16ms cap meant it + did not. Frame cadence is still the dominant cost, which is why the + conserving path exists at all — users forgive a slower novelty, they do + not forgive battery drain, and it is the top uninstall reason for this + app class. + + **Conserving is the user's call, not the battery's.** "On battery" was + the original rule and it was the wrong question: somebody on mains may + want the thing calm and somebody unplugged may not want it throttled + behind their back. The menu offers Low Power Mode > On / Off / Auto, and + Auto follows the OS — `NSProcessInfo.isLowPowerModeEnabled` on macOS, + Battery Saver via `GetSystemPowerStatus` on Windows. `src/platform/ + LowPower.h` is the one place that reads it; `PowerPolicy` is handed a + single already-decided boolean. + + Worth knowing why this is written so emphatically: for most of this + file's life the policy had `setOnBattery` and `setLowPowerMode`, read + both, and **nothing ever called either of them**. Everything above was + true as documentation and false as behaviour — full rate on battery, + Low Power Mode ignored. An input nobody feeds is worse than no input, + because it reads like a feature. - **Small dirty region.** The overlay window is sized to the trash icon plus a flight margin, nothing more. diff --git a/docs/effects.md b/docs/effects.md index f6ee4b3..9d24ec2 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -88,7 +88,7 @@ change meaning when the list is reordered. `Settings` deliberately does not know which effects exist. It stores an opaque string; `EffectRegistry` resolves an empty or unknown id to its default. That keeps the value store out of the renderer's dependency -graph, which matters because `simtest` links it and must stay headless. +graph, which matters because the tests link it and must stay headless. ## Looking at it @@ -114,6 +114,172 @@ Two things it took several wasted rounds to learn: all. `HYPERBIN_PREVIEW_SHOT_MS` exists for exactly that: shoot the same scene half a second apart and difference the two. +### Pinning down one gesture + +A vocabulary of moves picked by a hash cannot be verified by screenshot: +catching a particular one is luck, and *"it looked right when I happened +to see one"* is how the tentacles' roll-up shipped invisible for two +rounds — it was being computed and then erased every frame, and every +grab that would have shown it was of some other move. + +```sh +HYPERBIN_PREVIEW_CURSOR=300,180 # pin the pointer, in the item's pixels +HYPERBIN_FORCE_MOVE=1 # every arm runs this move, in enum order +HYPERBIN_DEBUG=1 # shout when a target or a joint teleports +HYPERBIN_TRACE=1 # ...and log EVERY frame: arm, move, u, jump +--empty-at 2500 # empty the bin, to see a leaving animation +``` + +`HYPERBIN_FORCE_MOVE` alternates the forced move with an idle, so the move +under test always starts from rest. Run back to back it would start from +wherever its own last instance left the arm — which read the slap's +wind-up as moving the arm *outward*, the exact opposite of what it does, +because the frames were showing the previous slap still recovering. + +Prefer `HYPERBIN_TRACE` over the threshold-triggered lines from +`HYPERBIN_DEBUG` when comparing two versions of a move. A count of alarming +frames is the wrong statistic: lengthen a move and it accrues more of them +while being calmer per frame. That reading once said a re-timed slap was +twice as bad when its per-frame motion had in fact more than halved. + +## Bringing in a model + +Most geometry here is generated, not imported — `OozeGeometry.cpp` sweeps +the whole gel body from a measured profile, and that is the pattern to +reach for first. Import a model when the shape is *sculpted* and could not +be described by a profile and a few numbers. + +There is no runtime asset loading. `Quick3DAssetImport` is not linked, and +`scripts/package-macos.sh` prunes the asset-loading plugins on the way into +the DMG. Models are converted **once, by hand**, and the converted output +is what ships: + +``` +$QT_ROOT/bin/balsam --generateMipMaps -o resources/ model.glb +``` + +Keep the `.mesh` and the maps; throw away the `.qml` balsam writes beside +them — it is a scene wrapper this app has no use for. Commit the source +`.glb` under `assets/` so the conversion can be repeated; the eye mesh's +source was not kept, and it cannot be regenerated or adjusted. + +Files under `resources/` reach QML through the `hyperbin_icons` block in +`CMakeLists.txt`, which is `PREFIX "/icons" BASE resources`. So +`resources/meshes/eye.mesh` is `qrc:/icons/meshes/eye.mesh` — the `/icons` +prefix is inherited by everything in that bucket whether or not it is an +icon. + +Two things worth knowing before modelling: + +* **Detail below a couple of pixels is wasted.** The bin is drawn around + 49 points across. Put surface detail in the normal and albedo maps, + where there is a pixel to hold it, and keep the silhouette simple. +* **Drop what will not be seen.** The eye model's cornea was a second, + transparent mesh; it was left out because a transparent PBR shell costs + a full extra pass to produce a highlight that is sub-pixel here. + +### How many triangles + +Fewer than feels right, and the limit is not performance. + +Three arms of 3,000 vertices at 30fps is 270,000 vertex-shader invocations +a second, which is nothing — what this app pays for is frame cadence and +keeping the GPU from idling, and per-vertex work is below fill cost in +`docs/battery.md`, which already calls fill "effectively free at this +size". Triangles are free. Frames are not. So the budget is set by the +point where more geometry stops *showing*, not by a cost ceiling. + +That point can be worked out rather than guessed. A tube of screen radius +`r` px approximated with `K` sides deviates from the circle by +`r(1 - cos(pi/K))`, so keeping the facets under half a pixel needs +`K > pi / acos(1 - 0.5/r)`: + +| drawn at | arm radius | sides needed | +| --- | --- | --- | +| 49pt Dock icon, 2x | 12 px | 11 | +| 96pt Dock icon, 2x | 24 px | 16 | + +**Sixteen sides** covers a Dock icon twice the default size. The same sum +along the arm, against the outer edge of the sharpest strike (4.4 radians — +see `TentacleEffect::kArmLength`), wants **20 to 24 rings**. That is the +*floor* — about 500 triangles — not a target. 2,000 is comfortable: 24 +sides by 42 rings, which is round enough for a Dock icon four times the +default and nearly twice the rings the sharpest strike needs. + +**Count vertices, not triangles.** The bend runs in the vertex shader and +the `.mesh` file is 67 bytes per vertex, so vertices are the number that +costs — and the two are not proportional. The arm in the tree is 6,568 +triangles for 4,070 vertices; a downloaded model it replaced was 2,728 +triangles for **2,920** vertices, from only **1,375 unique positions**. 53% +of that one was a single position exported more than once — not flat +shading (just 63 of 1,209 shared positions carried different normals) but a +UV map in many islands, on a mesh whose positions had been welded and whose +UVs had not. Blender's statistics showed 1,375 and gave no hint of it. + +So a *larger* triangle count, unwrapped with one seam, can cost a third of +a smaller one. Check the vertex count after export, not in the viewport. + +Two things that model got wrong and are worth not repeating: + +* **Distribution.** It put 45% of its vertices in the top quarter of the + arm, where the radius had fallen to 1–2.7 units of a 12.5-unit base — + below a pixel at Dock size — while the thick part that is actually seen + got 80 vertices a band. That is what decimating a sculpt does. Build the + low-poly tube first, sculpt on a multires or duplicated copy, and bake + down; then the budget lands where it was put rather than where the + decimator left it. +* **Taper.** It fell to a tenth of its peak radius by the tip, so most of + its length was thin whatever it was scaled by, and the girth multiplier + in `TentacleVisual.qml` was fighting that rather than setting it. The + replacement holds a third of its peak at the tip and reads as thick along + its whole length on its own — measured on screen, its cross-section + varies only between 0.12 and 0.16 of the bin's width from mid-arm to tip. + +### Low-poly that does not look low-poly + +* **Smooth (averaged) vertex normals.** The single biggest lever, and it is + free: a 16-sided tube shaded smooth reads as round, and the same tube + flat-shaded reads as a hex bolt. In Blender that is Shade Smooth with no + Edge Split modifier and no custom split normals. A vertex count above the + triangle count is the tell that something is splitting them. +* **The silhouette is the only thing a normal map cannot fake.** So spend + geometry on exactly two things — how round the tube is, and how smoothly + it bends — and put suckers, wrinkles and veins in the maps, baked from a + high-poly sculpt. `shaders/tentacle.frag` already reads a normal map + through `TANGENT`/`BINORMAL`, so export tangents and one UV map or its + orientation is undefined. +* **Do not taper to a point.** The last rings of a spike are sub-pixel and + only shimmer. End on a small rounded cap. +* **Taper less than looks right in the viewport.** The current model is at + 55% of its base radius by mid-length and 21% by three-quarters, which is + why it reads as thin however it is scaled — scaling cannot fix a taper. +* **A cylindrical unwrap with one seam on the underside** keeps the maps + continuous along the arm. A non-square texture (256x1024) spends its + texels evenly on something five times longer than it is wide. + +### What the vertex shader expects of the mesh + +`shaders/tentacle.vert` bends the arm from `VERTEX.y` alone, so the mesh +carries a contract and no rig — a skeleton was tried and went unused, +because nothing needs a joint to know how far along an arm a vertex sits: + +* **Straight up +Y, base at the origin**, and the axis on `x = z = 0`. The + shader splits `VERTEX.xz` into the bend plane and across it, treating it + as an offset from the centre line; the current import's axis is off by + about 1.9 units, which it silently carries through the bend. +* **Apply every transform before export.** An unapplied armature scale of + 15.5 cost a debugging session once already. +* **Author it 100 units long.** The length goes into the shader as a + literal (`meshLength`), so a round number is one less magic constant. +* **No animation actions.** + +Animation clips are a separate question and the answer so far has been no: +balsam emits them as `QtQuick.Timeline`, which is a module this app does +not link and the packaging script does not ship. Skinning +(`Skeleton`/`Joint`) is part of `QtQuick3D` proper and costs nothing extra, +but the house pattern is one static mesh deformed in its vertex shader — +see `shaders/ooze3d.vert`. + ## Colour, if you write a Quick3D material Three things about Qt's pipeline that are not obvious and each cost a @@ -138,13 +304,39 @@ highlight. To make something genuinely disappear, `discard` it. ## Testing -`simtest` links the simulation only, never the renderer. Keep the +The tests link the simulation only, never the renderer. Keep the behaviour in a plain class with no Qt GUI dependency (as `FlySim` is) and the effect a thin wrapper around it, and the interesting parts stay testable without a display. +Qt Test, one executable per area under `tests/`, every test function +registered with CTest on its own: + +```sh +cd build && ctest --output-on-failure +ctest -R Tentacle # just the solver +ctest -R Flies.walking # one check +``` + +Registering each function separately is not bookkeeping. The harness this +replaced ran everything from one `main()` and aborted on the first +`return fail(...)`, so a broken check hid every check after it — and it +did: the bend-limit test failing meant the wave, settling and +`pushOutside` checks never ran at all. + Every hard-won behaviour in the fly simulation has an assertion, and several of them exist because a "fix" quietly broke something else two turns later. When you tune a number, check whether a test now encodes the old taste rather than a real invariant — and widen it deliberately rather than deleting it. + +**Set a threshold by breaking the thing it guards.** A test that passes +against code with the feature ripped out is worse than no test, because it +reads as coverage. Three of the tentacle checks were written, passed, and +turned out to be exactly that — one asserted on a joint the code writes +directly, so it could never fail; one aimed at a target the solver reached +without the routine under test; one sampled a 6-second window of a +27-second cycle and made correct code look twice as bad as broken code. +Delete the implementation, watch the test go red, then put it back. The +figures those runs produce are what the bound should be built from, and +`tests/tentacle/tst_tentacle_chain.cpp` quotes them beside each one. diff --git a/docs/windows-backend.md b/docs/windows-backend.md index 92ec118..f4fcf2e 100644 --- a/docs/windows-backend.md +++ b/docs/windows-backend.md @@ -121,7 +121,7 @@ detected and warned about rather than shipped as a mystery. ### Verifying step 1 -- `simtest` must still pass untouched — it does not know about platforms, +- `ctest` must still pass untouched — the sims do not know about platforms, which is the point. It does. - `scripts/binprobe.cpp` is the port of `scripts/dockprobe.mm`: cell position, spacing, icon size, monitor DPI, item count, byte size and the @@ -173,7 +173,7 @@ status; both are re-evaluated together so neither can be forgotten. ## Step 3 — The swarm architecture is untouched -No code, by design. `simtest` passes unchanged, and the renderer, sim and +No code, by design. `ctest` passes unchanged, and the renderer, sim and shaders took no Windows-specific edits. Still to check **on screen**, which needs a visible desktop: diff --git a/qml/Main.qml b/qml/Main.qml index 06bb659..72b666c 100644 --- a/qml/Main.qml +++ b/qml/Main.qml @@ -25,7 +25,7 @@ Window { color: windowedMode ? "#cfcac2" : (paintDebug ? "#80ff0000" : "transparent") width: 480 - height: 480 + height: 540 visible: windowedMode // The bin itself, UNDER the overlay — the position the shell draws it @@ -59,6 +59,51 @@ Window { border.width: 1 } + // The measured mouth, drawn over the artwork it was measured from. + // + // --binmouth only. This is the one thing in the app whose correctness + // cannot be judged from the effect that uses it: a tentacle masked + // against a mouth ten pixels too low looks like a tentacle that is + // slightly wrong, not like a mouth that is. Drawn on the icon, a + // wrong answer is obvious in one screenshot. + Item { + id: mouthDebug + visible: windowedMode + && Qt.application.arguments.indexOf("--binmouth") >= 0 + x: flies.binRect.x + y: flies.binRect.y + width: flies.binRect.width + height: flies.binRect.height + + Rectangle { + // The opening. Ellipse via a rounded rectangle: radius at half + // the smaller side IS an ellipse, and this needs no Shape. + x: (flies.mouthCentre.x - flies.mouthHalfWidth) * parent.width + y: (flies.mouthCentre.y - flies.mouthDepth) * parent.height + width: flies.mouthHalfWidth * 2 * parent.width + height: flies.mouthDepth * 2 * parent.height + radius: Math.min(width, height) / 2 + color: "transparent" + border.color: flies.mouthMeasured ? "#ff3030" : "#ffb020" + border.width: 2 + } + Rectangle { + // The near lip: what an emerging shape has to pass behind. + y: (flies.mouthCentre.y + flies.mouthDepth) * parent.height + width: parent.width + height: 1 + color: "#30dd50" + } + Text { + y: (flies.mouthCentre.y + flies.mouthDepth) * parent.height + 4 + color: "#207030" + font.pixelSize: 11 + text: (flies.mouthMeasured ? "measured" : "FALLBACK") + + " near " + flies.mouthCentre.y.toFixed(3) + "+" + + flies.mouthDepth.toFixed(3) + } + } + EffectItem { id: flies objectName: "effect" @@ -71,7 +116,11 @@ Window { // Big, so an effect can be judged. The overlay's real // margins are proportional to the icon, so this is the // same geometry the Dock produces, just larger. - binRect = Qt.rect(90, 40, 300, 300); + // Headroom above for anything that reaches OUT of the + // bin — tentacles clear the rim by about a fifth of the + // icon and were being clipped by the window. The room + // below is unchanged, which the ooze needs for its drips. + binRect = Qt.rect(90, 100, 300, 300); // HYPERBIN_PREVIEW_FULL pins the slider, so anything that // scales with fullness — the ooze's level, how many eyes // surface — can be shot at a chosen value instead of @@ -80,9 +129,28 @@ Window { ? Qt.application.arguments[ Qt.application.arguments.indexOf("--full") + 1] : 0.85); - frameIntervalMs = 16; + // --interval pins the clock, so the cost of a given + // cadence can be measured instead of argued about. + const ii = Qt.application.arguments.indexOf("--interval"); + frameIntervalMs = ii >= 0 + ? Number(Qt.application.arguments[ii + 1]) : 16; } } + + // Dev harness: --empty-at empties the bin at a chosen moment, + // so anything that only happens on the way OUT can be shot. + // Without it a grab-and-exit run can never see a withdrawal at + // all, which is how a leaving animation ends up shipped on the + // strength of "it looked right when I dragged the slider". + readonly property int emptyAt: { + const i = Qt.application.arguments.indexOf("--empty-at"); + return i >= 0 ? Number(Qt.application.arguments[i + 1]) : 0; + } + Timer { + running: windowedMode && flies.emptyAt > 0 + interval: Math.max(1, flies.emptyAt) + onTriggered: flies.fullness = 0 + } } // Dev affordance: drag to feel the swarm grow and agitate. diff --git a/qml/OozeEyes.qml b/qml/OozeEyes.qml index 3c32e63..4430439 100644 --- a/qml/OozeEyes.qml +++ b/qml/OozeEyes.qml @@ -56,6 +56,13 @@ Node { readonly property var normals: effect ? effect.eyeNormals : [] readonly property real time: effect ? effect.time : 0 + /// The pointer, in the scene, and how much the eyes care about it. + /// Both computed in C++ — see OozeEffect::updateGaze, which also + /// explains why a POINT and not a direction. + readonly property vector3d gazeTarget: + effect ? effect.gazeTarget : Qt.vector3d(0, 0, 0) + readonly property real gazePull: effect ? effect.gazePull : 0 + /// Toward the camera. It is orthographic, so this is the same /// direction everywhere in the scene and there is no per-eye look-at /// to compute — only the lean away from it that the gel asks for. @@ -105,13 +112,53 @@ Node { : Qt.vector3d(0, 0, 0) // --- the gaze frame ----------------------------------------- - // Somewhere between the lens and the way the gel faces here. + // + // What this eye would look at with no pointer about: the lens. + // The camera is orthographic, so that is the same direction for + // every eye in the scene. + // + // ...and what it looks at when there is one: the pointer, which + // is a POSITION, so each eye gets its own direction and the + // cluster converges. That difference is the whole effect — + // parallel gazes read as the gel tilting, converging ones as + // being looked at. + readonly property vector3d toPointer: { + const d = eyes.gazeTarget.minus(eye.position) + return d.length() > 0.001 ? d.normalized() : eyes.camDir + } + readonly property vector3d aim: + eyes.camDir.times(1 - eyes.gazePull) + .plus(toPointer.times(eyes.gazePull)) + .normalized() + + // Somewhere between what it is looking at and the way the gel + // faces here. The lean toward the normal is what keeps an eye + // out on the flank from staring through its own socket, and it + // does that job for the pointer exactly as it did for the lens + // — which is why following the pointer needed no clamp of its + // own. The pull is capped below 1 for the same reason it is on + // the tentacles: leaving a little of the resting pose in stops + // fourteen eyes locking into one dead-straight stare. + // + // The lean is EASED OFF while tracking. At rest it is doing + // the work described above — keeping a flank eye from staring + // through its own socket at a camera it cannot face. Under a + // pointer it is fighting the one thing the eyes are trying to + // say: measured at the full 0.55, a pointer swung from one side + // of the bin to the other moved the irises by under a quarter + // of an eye's radius, which is present in the numbers and + // invisible on screen. Halved while tracking, the same swing + // reads. It is not taken to zero — the socket is still there, + // and an eye out on the side that ignored it would look like a + // sticker. + readonly property real bias: + eyes.gazeBias * (1 - 0.5 * eyes.gazePull) readonly property vector3d gaze: normal - ? eyes.camDir.times(1 - eyes.gazeBias) + ? aim.times(1 - bias) .plus(Qt.vector3d(normal.x, normal.y, normal.z) - .times(eyes.gazeBias)) + .times(bias)) .normalized() - : eyes.camDir + : aim // Yaw then pitch, as two nested nodes rather than one node's // eulerRotation. Which order Qt composes a pair of Euler @@ -140,8 +187,18 @@ Node { // in four, is what sells the rest: an eye that is // always darting never appears to have noticed // you. - eulerRotation.x: blank ? 0 : (eyes.rnd(fix, eye.index + 11) - 0.5) * 26 - eulerRotation.y: blank ? 0 : (eyes.rnd(fix, eye.index + 23) - 0.5) * 34 + // Damped down while the pointer has their + // attention. A saccade is what an eye does when it + // has nothing to look at; kept at full size on top + // of tracking, the eye jitters around the thing it + // is supposed to be locked on and reads as + // struggling to focus. Not removed altogether — a + // completely still eye is a dead one, and the + // remainder is small enough to pass for the + // micro-movement a real one never stops making. + readonly property real dart: 1 - eyes.gazePull * 0.85 + eulerRotation.x: (blank ? 0 : (eyes.rnd(fix, eye.index + 11) - 0.5) * 26) * dart + eulerRotation.y: (blank ? 0 : (eyes.rnd(fix, eye.index + 23) - 0.5) * 34) * dart Model { source: "qrc:/icons/meshes/eye.mesh" diff --git a/qml/OozeVisual.qml b/qml/OozeVisual.qml index 66167ce..f45b09d 100644 --- a/qml/OozeVisual.qml +++ b/qml/OozeVisual.qml @@ -96,6 +96,17 @@ Item { - root.width / 2 : 0 y: root.effect ? -(root.effect.binRect.y + root.effect.binRect.height / 2 - root.height / 2) : 0 + // The effect places the eyes and works out where the pointer is in + // the scene, and both of those need the camera's tilt. Pushed down + // rather than written out again in C++, so there is one tilt in the + // app — see OozeEffect::cameraTilt. + Binding { + target: root.effect + property: "cameraTilt" + value: cam.tilt + when: root.effect !== null + } + // The eyes go in BEFORE the gel in the scene graph so the gel's // transparent pass composites over them — an eye drawn after it // sits on top of the body instead of inside it. diff --git a/qml/TentacleVisual.qml b/qml/TentacleVisual.qml new file mode 100644 index 0000000..bdda91b --- /dev/null +++ b/qml/TentacleVisual.qml @@ -0,0 +1,309 @@ +import QtQuick +import QtQuick3D +import hyperbin + +// Tentacles coming out of the bin. +// +// Three arms seated as a triangle in the measured opening, bent in their +// own vertex shader, and cut where the bin and the rubbish stand in front +// of them. The geometry is a sculpted model with its skin and suckers +// baked into an albedo and a normal map — see "Bringing in a model" in +// docs/effects.md. It replaced chains of tapering spheres that existed +// only to prove the occlusion. +// +// The occlusion is still the part to be careful with: an arm starts below +// the lip and inside the heap, so the stretch that has not emerged must +// disappear behind artwork this effect never draws. shaders/tentacle.frag +// is where that happens and why it is done by cutting our own geometry +// rather than repainting the bin. +Item { + id: root + + required property var effect + + readonly property real binW: effect ? effect.binSize.width : 40 + readonly property real binH: effect ? effect.binSize.height : 40 + + View3D { + anchors.fill: parent + camera: cam + + environment: SceneEnvironment { + backgroundMode: SceneEnvironment.Transparent + antialiasingMode: SceneEnvironment.MSAA + antialiasingQuality: SceneEnvironment.Medium + // Mandatory, not decorative. With a single directional light + // and nothing else, everything facing away from it renders + // black — which is exactly what these looked like. The probe + // is what puts light back into the shadowed side, and the + // gel's scene already carries it, so it costs no new asset. + lightProbe: Texture { source: "qrc:/icons/env.hdr" } + probeExposure: 1.6 + probeHorizon: 0.4 + } + + // The same camera the ooze uses, and it has to be: both scenes + // are drawn over artwork the shell rendered from its own + // viewpoint, and the opening's measured ellipse only projects + // back to the right shape from this one. + OrthographicCamera { + id: cam + readonly property real tilt: 17 + readonly property real dist: 600 + y: dist * Math.sin(tilt * Math.PI / 180) + z: dist * Math.cos(tilt * Math.PI / 180) + eulerRotation.x: -tilt + horizontalMagnification: 1.0 + verticalMagnification: 1.0 + } + + DirectionalLight { + eulerRotation: Qt.vector3d(-35, -25, 0) + brightness: 1.6 + } + // The chains are solved in C++ but the camera lives here, and the + // solver needs its tilt to turn the opening's measured on-screen + // half-height into a reach in z. Pushed rather than duplicated as + // a constant on both sides. + Binding { + target: root.effect + property: "cameraTilt" + value: cam.tilt + when: root.effect !== null + } + + Node { + id: binNode + x: root.effect ? root.effect.binRect.x + root.effect.binRect.width / 2 + - root.width / 2 : 0 + y: root.effect ? -(root.effect.binRect.y + root.effect.binRect.height / 2 + - root.height / 2) : 0 + + // --- the tentacles --------------------------------------- + Repeater3D { + // CONSTANT, and only visibility follows the count. + // + // Bound to `count` this rebuilt every delegate on every + // frame the count moved, which is the whole of emerging + // and the whole of withdrawing. qml/OozeEyes.qml carries + // the same note and the reason: the ooze bubbles did + // exactly this and cost four times the entire effect. + model: root.effect ? root.effect.maxTentacles : 0 + + delegate: Node { + id: arm + required property int index + + visible: root.effect && index < root.effect.count + + readonly property real seed: { + const s = Math.sin(index * 127.1 + 311.7) * 43758.5453; + return s - Math.floor(s); + } + // Where this arm sits, as fractions of the opening — + // a triangle with its apex at the back. The table and + // the reasoning are in src/effects/TentacleEffect.h. + readonly property var seat: root.effect + && index < root.effect.seats.length + ? root.effect.seats[index] : null + // Fake perspective, the one thing the seat is still + // read for here — an orthographic camera does not + // shrink the far arm, so it is scaled by hand. + readonly property real sizeScale: seat ? seat.z : 1 + Model { + id: armModel + source: "qrc:/icons/meshes/tentacle.mesh" + // NO TRANSFORM. Position, length and bend all + // live in the solved chain, in scene units, so the + // model is a plain container sitting on the bin's + // own origin and every vertex is placed by the + // shader. It used to carry a position and an + // anisotropic scale; with a chain those would have + // to be undone before the joints could be used. + // + // Girth is the one thing left, because the chain + // is a centre line and carries no thickness. The + // multiplier is MESH-SPECIFIC — it compensates for + // the model's own radius-to-length ratio, 0.054 at + // its thickest — and lands the base at about 0.22 + // of the bin's width, which reads as an arm rather + // than a cable at Dock size. + readonly property real girth: + (root.effect ? root.effect.armLength : 20) + * arm.sizeScale / 8.932 * 1.52 + // One material per arm, and it has to be: the + // sway phase is a uniform, and five arms waving + // in step read as one object with five prongs. + // They share the shader and the maps — this is + // five uniform buffers, not five pipelines. + materials: CustomMaterial { + shadingMode: CustomMaterial.Shaded + property TextureInput iconTex: TextureInput { + texture: binTexture + } + property TextureInput albedoTex: TextureInput { + texture: albedoMap + } + property TextureInput normalTex: TextureInput { + texture: normalMap + } + // Where the bin's origin sits in the scene, so + // a fragment can work out where it lands on the + // bin whatever model it belongs to. + property vector3d binOrigin: + Qt.vector3d(binNode.x, binNode.y, 0) + property vector2d planeMin: + Qt.vector2d(-root.binW / 2, -root.binH / 2) + property vector2d planeSize: + Qt.vector2d(root.binW, root.binH) + property vector2d mouthCentre: Qt.vector2d( + root.effect ? root.effect.mouthCentreX : 0.5, + root.effect ? root.effect.mouthCentreY : 0.17) + property real mouthHalfWidth: + root.effect ? root.effect.mouthHalfWidth : 0.42 + property real mouthDepth: + root.effect ? root.effect.mouthDepthFraction : 0.075 + property real armRoughness: 0.39 + // The mesh's own length, so the shader can say + // how far along an arm a vertex is without + // knowing how it has been scaled. + property real meshLength: 8.932 + // The solved chain. Two rows an arm — joint + // positions then the frame across it — so the + // row to read is twice the index. + property TextureInput spineTex: TextureInput { + texture: spineTexture + } + property int armRow: arm.index + property int jointCount: + root.effect ? root.effect.jointCount : 16 + property real girthScale: armModel.girth + + // --- the rubbish, as a volume --------------- + // + // A mound filling the opening, for arms at the + // back of the mouth to pass behind. Its top + // and floor are measured against the artwork + // in C++; its width is the opening's, in both + // axes, which for z means the tilt-corrected + // reach and not the screen ellipse's height. + readonly property real heapZ: + root.effect ? root.effect.mouthReachZ : 20 + readonly property real heapTop: + root.effect ? root.effect.heapTopY : 0 + readonly property real heapFloor: + root.effect ? root.effect.heapFloorY : 0 + property vector3d heapCentre: Qt.vector3d( + root.effect ? root.effect.mouthX : 0, + (heapTop + heapFloor) * 0.5, 0) + property vector3d heapRadii: Qt.vector3d( + root.effect ? root.effect.mouthRadius : 20, + Math.max(1, (heapTop - heapFloor) * 0.5), + Math.max(1, heapZ)) + // Toward the eye. The camera is orthographic, + // so this is the same for every fragment — + // which is the whole reason a ray query + // against the heap is affordable at all. + property vector3d camToEye: Qt.vector3d( + 0, Math.sin(cam.tilt * Math.PI / 180), + Math.cos(cam.tilt * Math.PI / 180)) + + // Inside the bin is dark. The gradient reaches + // a tenth of the bin ABOVE the heap's crest, + // so an arm is still in shadow at the moment it + // breaks the surface and climbs out of it over + // the next sixth — referenced to the crest + // itself it would be at full brightness the + // instant it became visible. + // Darker, deeper, and reaching further up. + // + // An arm is meant to look like it is coming + // OUT of the rubbish, and the strongest cue + // for that is the light falling off as it goes + // in. At 0.30 over a sixth of the bin the + // gradient was there but too weak and too + // short to read, so the arm looked laid on top + // of the mouth rather than passing through it. + property real shadeTopY: heapTop + root.binH * 0.16 + property real shadeSpan: root.binH * 0.26 + property real binShade: 0.13 + // Aerial perspective, from the seat's depth: + // -1 at the far lip is dimmed, +1 at the near + // one is not. + property real depthShade: + 1.0 - 0.18 * (0.5 - 0.5 * (arm.seat ? arm.seat.y : 0)) + // The bin's own body, for telling an arm + // inside or behind it from one reaching down + // the outside. Both axes, because the two + // differ: x is the opening's measured + // half-width, z is that half-width's + // tilt-corrected reach into the scene. + // + // Nine tenths of the opening, not all of it — + // the bin tapers below the lip, so any single + // number has to be an average. Erring narrow + // shows a sliver of arm that is really inside; + // erring wide deletes a strike that is really + // outside. Narrow is the cheaper mistake. + // The opening's full half-width in both axes; + // the shader tapers it with depth rather than + // taking a single average. + property vector2d binHalfSize: Qt.vector2d( + (root.effect ? root.effect.mouthRadius : 20), + Math.max(1, heapZ)) + property real lipY: + root.effect ? root.effect.mouthY : 0 + property real binHeight: root.binH + // From C++, so the mask and the strike aim at + // the same wall. + property real bodyTop: + root.effect ? root.effect.bodyTaperTop : 0.98 + property real bodyFoot: + root.effect ? root.effect.bodyTaperFoot : 0.78 + vertexShader: "qrc:/shaders3d/tentacle.vert" + fragmentShader: "qrc:/shaders3d/tentacle.frag" + } + } + } + } + + } + } + + // The bin's artwork, and the arm's own maps. Declared once and + // referenced by every arm's material, so each is uploaded once. + // The solved chains. NEAREST and no mipmaps, deliberately: these are + // joint positions in scene units, not a picture, and the shader reads + // them with texelFetch. Filtering would interpolate between two arms + // at the row boundary. + Texture { + id: spineTexture + textureData: root.effect ? root.effect.spineTexture : null + minFilter: Texture.Nearest + magFilter: Texture.Nearest + mipFilter: Texture.None + generateMipmaps: false + tilingModeHorizontal: Texture.ClampToEdge + tilingModeVertical: Texture.ClampToEdge + } + Texture { + id: binTexture + textureData: root.effect ? root.effect.iconTexture : null + minFilter: Texture.Linear + magFilter: Texture.Linear + tilingModeHorizontal: Texture.ClampToEdge + tilingModeVertical: Texture.ClampToEdge + } + Texture { + id: albedoMap + source: "qrc:/icons/maps/tentacle_albedo.jpg" + generateMipmaps: true + mipFilter: Texture.Linear + } + Texture { + id: normalMap + source: "qrc:/icons/maps/tentacle_normal.jpg" + generateMipmaps: true + mipFilter: Texture.Linear + } +} diff --git a/resources/maps/tentacle_albedo.jpg b/resources/maps/tentacle_albedo.jpg new file mode 100644 index 0000000..e56da73 Binary files /dev/null and b/resources/maps/tentacle_albedo.jpg differ diff --git a/resources/maps/tentacle_normal.jpg b/resources/maps/tentacle_normal.jpg new file mode 100644 index 0000000..b8fa7c9 Binary files /dev/null and b/resources/maps/tentacle_normal.jpg differ diff --git a/resources/meshes/tentacle.mesh b/resources/meshes/tentacle.mesh new file mode 100644 index 0000000..6b96c14 Binary files /dev/null and b/resources/meshes/tentacle.mesh differ diff --git a/shaders/tentacle.frag b/shaders/tentacle.frag new file mode 100644 index 0000000..6d5372c --- /dev/null +++ b/shaders/tentacle.frag @@ -0,0 +1,179 @@ +VARYING vec2 vScreen; +VARYING vec3 vLocal; +VARYING vec3 vSpine; + +// A tentacle, cut off where the bin stands in front of it. +// +// The first version of this did the opposite: it drew the bin's front +// wall back over the tentacle, out of the same icon the shell had already +// drawn underneath. That composites invisibly — measured, it came back +// identical to the shell's own pixels bar a rounding step — but it is +// still the wrong shape of solution. It repaints a large area of +// somebody else's artwork every frame to hide a few pixels of ours, it +// has to be kept in register with a bin we do not draw, and every +// question about it ("is the shadow doubled? is the outline +// thickened?") is a question that only exists because we touched pixels +// we had no business touching. +// +// Cutting our own geometry instead answers all of them at once. Nothing +// outside this effect is written to, there is no second pass and no +// full-bin texture read per frame, and it cannot fall out of alignment +// with artwork it never touches. +void MAIN() +{ + // Into the icon's own coordinates, which run DOWN from its top. + vec2 uv = (vScreen - planeMin) / planeSize; + uv.y = 1.0 - uv.y; + + // Off the artwork entirely: there is no bin here to hide behind. + if (uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0) { + // The near lip, as the ELLIPSE it actually is rather than as a + // level line. A straight cut is right only across the middle of + // the bin; out toward the sides the real lip has already curved + // up by most of the mouth's depth, and a tentacle there was + // being sliced a good fraction of the opening too low. + float k = (uv.x - mouthCentre.x) / max(mouthHalfWidth, 1e-4); + float arc = mouthCentre.y + + mouthDepth * sqrt(max(0.0, 1.0 - k * k)); + + // Below the lip, over solid bin, AND with the bin's own body + // between us and the eye. All three, and the third is what makes + // a strike possible: an arm that has curled over and reached down + // the OUTSIDE of the bin is below the lip and over solid artwork + // too, and on those two tests alone it vanished at the exact + // moment it became interesting. + // + // "Behind the body" as the FRONT SURFACE of an ellipse, not as a + // radius. Distance from the axis cannot tell in front from behind + // — it is the same number either way — and with three arms seated + // as a triangle that stopped being an academic distinction: the + // front pair strike out over the near corners of the rim, which is + // outside the bin in z while still well inside it radially, and + // their tips were being cut off in mid-flight. + // + // The camera is orthographic and level in x, so the sight line + // runs purely along +z here and the query collapses to a single + // comparison against the ellipse's near face. (It ignores the + // line's rise in y, which only matters for points far enough + // behind the bin to be looked over the top of; nothing reaches + // there — the deepest seat is 0.8 of the way back.) + // + // TAPERED with depth, not a constant. Measured on the macOS trash + // the body is 0.98 of the opening at the lip and 0.78 near the + // foot, and this was a single average of 0.90 — which is wrong at + // both ends and was wrong in a way that showed the moment the arms + // started lingering near the rim. Everything between 0.90 and the + // real 0.98 counted as outside the bin, so arm tips draped just + // under the lip were drawn over the front wall in a band about + // twenty pixels wide. Measured, 7,135 leaked pixels, all of them + // over the shaded body and none anywhere else. + // + // Asked about the SPINE, not this fragment — see tentacle.vert for + // why, and for what testing the fragment did to the arm instead. + // SPLIT between the fragment and the spine, because the two halves + // of this test want different things and it took two goes to see + // it. Sideways, the bin's edge is a boundary in SCREEN space: a cut + // along it is a vertical line through the arm, which leaves a solid + // shape and reads as the arm passing behind the bin's edge. That + // wants the fragment, and asking the spine instead makes a whole + // cross-section visible the moment its centre line clears the edge + // — so a fat arm draped on the rim spilled half its width across + // the bin's face. Measured, 8,705 pixels on one frame. + // + // In DEPTH it is the opposite, and that is the trap: the wall faces + // the camera, so a per-fragment depth test discards the near side + // of the tube and keeps the far side, which is a hole straight + // through the arm. That half has to ask the spine, once per + // cross-section. + float below = clamp((lipY - vLocal.y) / max(binHeight, 1e-4), 0.0, 1.0); + vec2 halfSize = binHalfSize * mix(bodyTop, bodyFoot, below); + float ex = vLocal.x / halfSize.x; + float nearFace = halfSize.y * sqrt(max(0.0, 1.0 - ex * ex)); + bool underBin = uv.y > arc && abs(ex) < 1.0 && vSpine.z < nearFace; + + // --- and behind the RUBBISH ------------------------------------- + // + // Everything above is about the bin's front wall, which stops at + // the lip. Above the lip is the opening, and the opening is full: + // measured on the macOS trash, there is no dark interior visible + // at all — every opaque pixel inside the mouth is rubbish. So an + // arm rooted at the BACK of the mouth ought to come up behind that + // rubbish, and with the wall test alone it did not: it drew over + // the newspaper and the crumpled paper with its root sitting on + // top of the heap like a sticker. + // + // We do not draw the rubbish and cannot cut a hole in it. What we + // can do is give the painted heap a PLACE — a mound filling the + // opening, as wide as the measured mouth and as tall as the + // rubbish is measured to stand proud of the rim — and then throw + // away our own fragments that it stands in front of. + // + // Which is a ray query, not a depth compare, because the mound is + // a volume: does the segment from this fragment to the eye cross + // it? For an ellipsoid that is a quadratic. Substituting the ray + // into the unit sphere it maps to gives + // + // s^2|d|^2 + 2s(q.d) + |q|^2 - 1 = 0 + // + // and inside the mound (|q|^2 < 1) needs no roots at all. Outside + // it, the product of the roots is positive, so both share a sign + // and the sum settles which: a hit in FRONT of us needs q.d < 0 + // and a real discriminant. No square root, no iteration. + vec3 q = (vLocal - heapCentre) / heapRadii; + vec3 hd = camToEye / heapRadii; + float qq = dot(q, q); + float qd = dot(q, hd); + bool underHeap = qq < 1.0 + || (qd < 0.0 && qd * qd >= dot(hd, hd) * (qq - 1.0)); + + // Trimmed against the artwork's own alpha, and that is what makes + // a mound good enough. The painted heap is lumpy — it clears the + // rim in three places and not at all in between — so a smooth + // ellipsoid over-states it almost everywhere above the rim. Where + // it over-states, there is nothing painted, so the cut does not + // happen: an arm is never sliced against thin air, only against + // pixels that are really there. + if ((underBin || underHeap) && texture(iconTex, uv).a > 0.5) + discard; + } + + // LINEAR. A shaded custom material hands BASE_COLOR to the same + // lighting maths PrincipledMaterial uses, and the scene tonemaps on + // the way out — an 8-bit map fed in raw is gamma-encoded twice. + vec4 albedo = texture(albedoTex, UV0); + vec3 base = pow(max(albedo.rgb, vec3(0.0)), vec3(2.2)); + + // Two depth cues, both hand-made, because the camera is orthographic + // and supplies neither. + // + // The first is the bin's own shadow: an arm climbing out of a heap of + // rubbish is in the dark at the bottom and in the light at the top, and + // without that it reads as a sticker laid over the mouth. Measured from + // ABOVE the heap's surface rather than from the opening, which is not a + // detail — the arm is hidden everywhere below that surface, so a + // gradient referenced to the opening applied only to fragments that + // were being discarded anyway and did precisely nothing. + // + // The second is aerial perspective: the far arm is dimmed a little. + // Small, and driven off its seat's own depth, so it stays consistent + // with the triangle rather than being a per-arm fudge. + // + // Both multiplied into the LINEAR albedo, which is why they go here and + // not after the pow: darkening an sRGB-encoded value is not darkening + // light, and the result comes out muddy rather than dim. + float below = clamp((shadeTopY - vLocal.y) / max(shadeSpan, 1e-4), + 0.0, 1.0); + // Gated on being inside the bin at all, or the tail of a strike — + // which hangs down the OUTSIDE, in the light — would be shaded as + // though it were still in the hole. + float inside = 1.0 - smoothstep(0.85, 1.05, length(vLocal.xz / binHalfSize)); + BASE_COLOR = vec4(base * depthShade * mix(1.0, binShade, below * inside), + 1.0); + + vec3 nm = texture(normalTex, UV0).rgb * 2.0 - 1.0; + NORMAL = normalize(TANGENT * nm.x + BINORMAL * nm.y + normalize(NORMAL) * nm.z); + + ROUGHNESS = armRoughness; + METALNESS = 0.0; + SPECULAR_AMOUNT = 0.5; +} diff --git a/shaders/tentacle.vert b/shaders/tentacle.vert new file mode 100644 index 0000000..4340751 --- /dev/null +++ b/shaders/tentacle.vert @@ -0,0 +1,116 @@ +VARYING vec2 vScreen; +// Where this vertex is relative to the bin's AXIS, in scene units. +VARYING vec3 vLocal; +// And where the SPINE is at this point along the arm, in the same space. +// +// Two of them, because the two occlusion tests in tentacle.frag want +// different things. Anything cutting the arm across its length — the lip, +// the surface of the rubbish — should follow the arm's own outline, and +// wants the fragment. Anything cutting it along the LINE OF SIGHT — the +// bin's front wall — must not, and this is not a matter of taste: the +// wall's surface faces the camera, so testing each fragment against it +// discards the near side of the tube wherever that side passes behind the +// wall and leaves the far side drawn, which is a hole straight through the +// arm. It looked like the arm had been eaten. Asking about the spine +// instead answers once per cross-section, so an arm is behind the wall or +// in front of it as a whole and there is nothing to see through. +VARYING vec3 vSpine; + +// The arm, laid along a solved joint chain. +// +// This replaced a closed-form circular arc, and the arc is the reason the +// old motion looked stiff: the entire shape was two numbers, a total bend +// and a direction, and an arc has constant curvature by definition. The +// chain is solved on the CPU by FABRIK — see src/core/TentacleChain — and +// arrives here as a texture rather than as uniforms because CustomMaterial +// has no arrays and sixteen joints across three arms is not something to +// unroll by hand. +// +// Still one static mesh and one draw call an arm, which is the whole +// division this app runs on: what it pays for is draw calls and +// scene-graph churn, not per-vertex work. +// +// The mesh is authored straight up its own +Y with its base at the origin, +// so VERTEX.y over the mesh's length is how far along the arm a vertex +// sits. That is all the correspondence a chain needs, which is why the +// model is still unrigged and there are no weights to paint. + +/// One joint. Row `row` of the spine texture, joint `j`. +/// +/// texelFetch, not texture(): these are joint positions in scene units, +/// not a picture. Filtering between two of them would be a lie at the ends +/// of every row and mipmapping them is meaningless. +vec4 spineTexel(int row, int j) +{ + return texelFetch(spineTex, ivec2(j, row), 0); +} + +void MAIN() +{ + float s = clamp(VERTEX.y / max(meshLength, 1e-4), 0.0, 1.0); + + // Which segment of the chain this vertex falls in, and how far along + // it. The chain's segments are of equal length and the mesh is + // straight, so the mesh's own y maps to chain arc length directly. + float f = s * float(jointCount - 1); + int i = int(floor(f)); + int hi = min(i + 1, jointCount - 1); + float t = f - float(i); + + int rowP = armRow * 2; + int rowS = rowP + 1; + + vec3 pA = spineTexel(rowP, i).xyz; + vec3 pB = spineTexel(rowP, hi).xyz; + vec3 p = mix(pA, pB, t); + + // The frame, carried along the chain on the CPU and merely interpolated + // here. Rebuilding it per vertex from the tangent alone would let it + // spin wherever the arm passes near whatever axis was used to seed it, + // and this model's suckers are on ONE side — a frame that rolls does + // not soften the shading, it moves the suckers round the arm. + vec3 sA = spineTexel(rowS, i).xyz; + vec3 sB = spineTexel(rowS, hi).xyz; + vec3 side = normalize(mix(sA, sB, t)); + + vec3 tangent = pB - pA; + // A degenerate segment happens for one frame when two joints coincide; + // falling back up the arm keeps the cross-section from collapsing. + tangent = length(tangent) > 1e-5 ? normalize(tangent) : vec3(0.0, 1.0, 0.0); + side = normalize(side - tangent * dot(side, tangent)); + // RIGHT-HANDED, and it has to be checked rather than guessed. The mesh + // arrives in glTF's right-handed space with the arm up +Y, so mapping + // its (x, y, z) onto (side, tangent, up) is only a rotation if + // side x tangent = up. Built the other way round — up = tangent x side + // — the determinant is -1 and the whole arm is silently MIRRORED: + // winding inverts, normals point into the surface, and the result is + // a washed-out arm with highlights in the wrong places. It renders + // perfectly happily, which is what makes it easy to miss. + vec3 up = cross(side, tangent); + + // The cross-section, carried onto the chain's frame. The model itself + // has no transform — position, length and girth are all in the chain + // and this one scale — so VERTEX.xz is the authored cross-section and + // nothing else has touched it. + vec3 pos = p + side * (VERTEX.x * girthScale) + up * (VERTEX.z * girthScale); + + // The normal is rotated onto the same frame. It was left at its rest + // value under the arc, on the grounds that the error was invisible at + // a few pixels wide; that excuse is gone now the arm is thick, has + // sculpted suckers, and carries a normal map whose relief is lit by + // this vector. + vec3 n = normalize(NORMAL); + NORMAL = normalize(side * n.x + tangent * n.y + up * n.z); + vec3 tg = normalize(TANGENT); + TANGENT = normalize(side * tg.x + tangent * tg.y + up * tg.z); + vec3 bn = normalize(BINORMAL); + BINORMAL = normalize(side * bn.x + tangent * bn.y + up * bn.z); + + vec4 world = MODEL_MATRIX * vec4(pos, 1.0); + vLocal = world.xyz - binOrigin; + vSpine = (MODEL_MATRIX * vec4(p, 1.0)).xyz - binOrigin; + vScreen = (VIEW_MATRIX * world).xy + - (VIEW_MATRIX * vec4(binOrigin, 1.0)).xy; + + POSITION = MODELVIEWPROJECTION_MATRIX * vec4(pos, 1.0); +} diff --git a/src/app/Settings.cpp b/src/app/Settings.cpp index 7435bfa..0018ad1 100644 --- a/src/app/Settings.cpp +++ b/src/app/Settings.cpp @@ -43,6 +43,10 @@ Settings::Settings(QObject *parent, const QString &appName) // into the headless tests too. An unknown or empty id is resolved by // the registry at construction time. m_infestation = m_store.value(QStringLiteral("infestation")).toString(); + // Auto by default: the OS already knows whether the machine is being + // asked to conserve, and most people will never open this menu. + m_lowPower = LowPower(m_store.value(QStringLiteral("lowPower"), + int(LowPower::Auto)).toInt()); // Relative by default: tying the swarm to how much rubbish is // actually there is the whole idea, and the fixed steps are for // people who want it to stop being clever. @@ -85,6 +89,15 @@ void Settings::setInfestation(const QString &id) emit appearanceChanged(); } +void Settings::setLowPower(LowPower m) +{ + if (m == m_lowPower) + return; + m_lowPower = m; + m_store.setValue(QStringLiteral("lowPower"), int(m)); + flush(); + emit lowPowerChanged(m); +} void Settings::setDensity(Density d) { if (d == m_density) diff --git a/src/app/Settings.h b/src/app/Settings.h index ab01d6a..9926451 100644 --- a/src/app/Settings.h +++ b/src/app/Settings.h @@ -44,7 +44,21 @@ class Settings : public QObject }; Q_ENUM(Threshold) + /// Low Power Mode, as a THREE-way choice rather than a reading of the + /// battery. + /// + /// "On battery" was the wrong question. Somebody on mains may want the + /// thing calm, and somebody unplugged may not care — the two are only + /// correlated. So this asks what the user wants, with Auto for the + /// common case of "do whatever the OS is doing". + enum class LowPower { + Auto = 0, ///< follow the OS's own Low Power Mode / Battery Saver + On = 1, + Off = 2, + }; + Q_ENUM(LowPower) bool enabled() const { return m_enabled; } + LowPower lowPower() const { return m_lowPower; } /// Which effect is running, by id — an opaque string, not an enum /// ordinal: ordinals silently change meaning when the list is /// reordered, and this list is expected to grow. Empty means "not @@ -55,6 +69,7 @@ class Settings : public QObject Threshold threshold() const { return m_threshold; } void setEnabled(bool on); + void setLowPower(LowPower m); void setInfestation(const QString &id); void setDensity(Density d); void setThreshold(Threshold t); @@ -69,6 +84,7 @@ class Settings : public QObject signals: void enabledChanged(bool on); + void lowPowerChanged(LowPower m); void infestationChanged(const QString &id); /// Anything that changes how the swarm should look. void appearanceChanged(); @@ -79,6 +95,7 @@ class Settings : public QObject void flush(); QSettings m_store; bool m_enabled; + LowPower m_lowPower; QString m_infestation; Density m_density; Threshold m_threshold; diff --git a/src/app/TrayMenu.cpp b/src/app/TrayMenu.cpp index 1b3587a..145c6ff 100644 --- a/src/app/TrayMenu.cpp +++ b/src/app/TrayMenu.cpp @@ -305,6 +305,28 @@ void TrayMenu::build() // settings group is conventionally found. m_menu->addSeparator(); + // --- low power ------------------------------------------------------- + // Three states, not a checkbox. "On battery" was the old question and + // it was the wrong one: somebody on mains may want this calm, and + // somebody unplugged may not want it throttled. Auto is the default + // and follows the OS's own Low Power Mode / Battery Saver. + QMenu *lowPower = m_menu->addMenu(QStringLiteral("Low Power Mode")); + m_lowPowerGroup = new QActionGroup(this); + m_lowPowerGroup->setExclusive(true); + const struct { const char *label; Settings::LowPower m; } powers[] = { + {"Auto", Settings::LowPower::Auto}, + {"On", Settings::LowPower::On}, + {"Off", Settings::LowPower::Off}, + }; + for (const auto &e : powers) { + auto *a = lowPower->addAction(QString::fromLatin1(e.label)); + a->setCheckable(true); + a->setData(int(e.m)); + m_lowPowerGroup->addAction(a); + } + connect(m_lowPowerGroup, &QActionGroup::triggered, this, [this](QAction *a) { + m_settings->setLowPower(Settings::LowPower(a->data().toInt())); + }); // Open at login. Hidden entirely where we have no implementation, // rather than shown as a switch that does nothing. if (launchAtLogin::supported()) { @@ -359,6 +381,7 @@ void TrayMenu::syncFromSettings() for (QAction *a : m_effectGroup->actions()) a->setChecked(a->data().toString() == current); check(m_densityGroup, int(m_settings->density())); + check(m_lowPowerGroup, int(m_settings->lowPower())); check(m_thresholdGroup, int(m_settings->threshold())); // The threshold only means anything in Relative mode, and greying it diff --git a/src/app/TrayMenu.h b/src/app/TrayMenu.h index d06d5ab..c5855dc 100644 --- a/src/app/TrayMenu.h +++ b/src/app/TrayMenu.h @@ -94,6 +94,7 @@ class TrayMenu : public QObject QAction *m_update = nullptr; QAction *m_analytics = nullptr; QActionGroup *m_effectGroup = nullptr; + QActionGroup *m_lowPowerGroup = nullptr; QActionGroup *m_densityGroup = nullptr; QActionGroup *m_thresholdGroup = nullptr; }; diff --git a/src/core/BinMouth.cpp b/src/core/BinMouth.cpp new file mode 100644 index 0000000..2812062 --- /dev/null +++ b/src/core/BinMouth.cpp @@ -0,0 +1,162 @@ +#include "BinMouth.h" + +#include +#include +#include + +namespace hyperbin { +namespace { + +/// Alpha at which artwork counts as present at all. Low: the empty macOS +/// trash is TRANSLUCENT through its opening — you see the desktop through +/// the mouth — so a threshold set for "solid" throws the whole top of the +/// bin away and the silhouette starts below the rim. +constexpr int kAnyAlpha = 20; +/// ...and the one for "solid enough to be the body", used only for the +/// width profile, where the translucent mouth SHOULD be excluded. +constexpr int kSolidAlpha = 128; + +} // namespace + +BinMouth measureBinMouth(const QImage &icon) +{ + BinMouth m; + if (icon.isNull()) + return m; + + const QImage src = icon.convertToFormat(QImage::Format_ARGB32); + const int w = src.width(), h = src.height(); + if (w < 16 || h < 16) + return m; + + // --- the artwork's own bounds ------------------------------------- + // The icon is a square tile and the bin does not fill it, so every + // fraction below is against the ARTWORK, not the tile. + int top = -1, bottom = -1, left = w, right = -1; + std::vector width(h, 0); + std::vector solidWidth(h, 0); + for (int y = 0; y < h; ++y) { + const QRgb *row = reinterpret_cast(src.constScanLine(y)); + int lo = -1, hi = -1, solid = 0; + for (int x = 0; x < w; ++x) { + const int a = qAlpha(row[x]); + if (a > kAnyAlpha) { + if (lo < 0) + lo = x; + hi = x; + } + if (a > kSolidAlpha) + ++solid; + } + if (lo >= 0) { + if (top < 0) + top = y; + bottom = y; + left = std::min(left, lo); + right = std::max(right, hi); + width[y] = hi - lo + 1; + } + solidWidth[y] = solid; + } + if (top < 0 || bottom <= top || right <= left) + return m; + + const float artH = float(bottom - top + 1); + const float artW = float(right - left + 1); + + // --- the widest row ------------------------------------------------ + // For a tapered bin the widest part is the lip, and the row where the + // opening is widest is the height of its CENTRE — the ellipse's own + // horizontal diameter. Searched in the top half only: the puddle of + // an effect never appears here, but the bin's foot can flare, and on + // some artwork the base is as wide as the rim. + int widest = top, widestW = 0; + for (int y = top; y <= top + int(artH * 0.5f); ++y) { + if (solidWidth[y] > widestW) { + widestW = solidWidth[y]; + widest = y; + } + } + if (widestW <= 0) + return m; + + // --- the near lip --------------------------------------------------- + // Down the middle third of the artwork, the strongest luminance DROP + // in the top of the bin is where the lip's lit edge gives way to the + // shadowed front wall. That is the near arc. + // + // Why this signal and not the silhouette: the obvious construction is + // that the top of the outline is the far lip, so the centre row minus + // the top gives the ellipse's half-height. It is exactly right on an + // empty bin and wrong on a full one, because the rubbish stands PROUD + // of the rim — measured on macOS, a full bin's outline starts 22 rows + // above an empty one's and the mouth comes out half again too tall. + // + // The drop does not care what is in the bin. Measured on both macOS + // trash states at 512px it lands on row 124 in each, to the row, + // while being the largest edge in the search band by a factor of two. + const int c0 = left + int(artW / 3.0f); + const int c1 = left + int(artW * 2.0f / 3.0f); + // Scaled to the icon, so the same physical edge is found whatever + // size the shell hands over. + const int span = std::max(2, int(artH / 128.0f * 4.0f)); + const int limit = top + int(artH * 0.45f); + + std::vector lum(h, -1.0f); + for (int y = top; y <= std::min(bottom, limit + span); ++y) { + const QRgb *row = reinterpret_cast(src.constScanLine(y)); + float sum = 0.0f; + int n = 0; + for (int x = c0; x <= c1 && x < w; ++x) { + if (qAlpha(row[x]) <= kAnyAlpha) + continue; + sum += 0.299f * qRed(row[x]) + 0.587f * qGreen(row[x]) + + 0.114f * qBlue(row[x]); + ++n; + } + // A handful of pixels is antialiasing, not a surface. + if (n >= std::max(4, (c1 - c0) / 8)) + lum[y] = sum / float(n); + } + + int arc = -1; + float bestDrop = 0.0f; + for (int y = top + span; y <= limit; ++y) { + if (lum[y] < 0.0f || lum[y - span] < 0.0f) + continue; + const float drop = lum[y - span] - lum[y]; + if (drop > bestDrop) { + bestDrop = drop; + arc = y; + } + } + + // Nothing convincing: a flat-shaded or heavily stylised icon may + // simply not have a lit lip. Fall back rather than trusting a + // one-count edge, and say so. + if (arc < 0 || bestDrop < 12.0f) + return m; + + // The near arc must sit BELOW the widest row — it is the bottom of + // the same ellipse. If the strongest edge came out above it, what was + // found is not the lip and the geometry would be inside out. + if (arc <= widest + 1) + return m; + + // Against the whole TILE, not the artwork's own bounds. + // + // The tile is what gets mapped onto the bin's rect — the icon is a + // square with the bin sitting somewhere inside it, and the rect + // covers all of that. Every other measurement in this app is a + // fraction of the tile (contentLine, OozeShape's rows), and a mouth + // measured against the artwork instead would be right only for an + // icon that happened to fill its tile. + m.centre = QPointF(double(left + right + 1) * 0.5 / double(w), + double(widest) / double(h)); + m.halfWidth = float(widestW) * 0.5f / float(w); + m.depth = float(arc - widest) / float(h); + m.measured = true; + return m; +} + +} // namespace hyperbin diff --git a/src/core/BinMouth.h b/src/core/BinMouth.h new file mode 100644 index 0000000..88107ca --- /dev/null +++ b/src/core/BinMouth.h @@ -0,0 +1,54 @@ +// The bin's OPENING, measured out of the shell's artwork. +// +// The silhouette says where the bin is; it does not say where you could +// reach into it. Anything that comes out of the bin needs that second +// thing — the mouth's near lip is the line an emerging shape has to pass +// BEHIND, and without it a tentacle rising out of the trash is a tentacle +// standing in front of it. +// +// This is the one part of the bin the alpha channel cannot answer. +// Measured on macOS against both trash states; see measure() for what the +// signal actually is and how far it was checked. +#pragma once + +#include +#include + +namespace hyperbin { + +/// The opening, as an ellipse in the bin rect's own 0..1 coordinates. +/// +/// Normalised rather than in pixels, because the icon is measured at +/// whatever size the shell hands over and used at whatever size the +/// overlay happens to be — the Dock alone changes it as the user drags +/// the size slider. +struct BinMouth +{ + /// Centre of the opening, 0..1 across and down the bin's rect. This + /// is the height at which the mouth is at its WIDEST, not the top of + /// the rim. + QPointF centre {0.5, 0.17}; + /// Half the opening's width, as a fraction of the rect's width. + float halfWidth = 0.42f; + /// Half the opening's height on screen, as a fraction of the rect's + /// HEIGHT. This is the tilt made visible: a mouth seen from directly + /// above would be as tall as it is wide, and one seen edge-on would + /// be nothing at all. + float depth = 0.075f; + /// False when nothing was measured and the numbers above are the + /// generic fallback. Worth knowing before drawing something that + /// would look wrong in the wrong place. + bool measured = false; + + /// The lowest point of the near lip: the line below which the bin's + /// own front wall stands between the viewer and anything inside. + float nearArc() const { return float(centre.y()) + depth; } + /// The highest point of the far lip. + float farArc() const { return float(centre.y()) - depth; } +}; + +/// Find the opening in a bin icon. Returns a fallback with measured +/// false if the image is unusable. +BinMouth measureBinMouth(const QImage &icon); + +} // namespace hyperbin diff --git a/src/core/Effect.h b/src/core/Effect.h index 15ad9ba..8143ed4 100644 --- a/src/core/Effect.h +++ b/src/core/Effect.h @@ -18,6 +18,8 @@ // it sixty times a second costs exactly as much as real animation. #pragma once +#include "BinMouth.h" + #include #include #include @@ -64,6 +66,20 @@ class Effect : public QObject /// how, and why it works on both platforms. Effects that do not care /// where the rubbish sits can ignore it. virtual void setContentLine(float y01) { Q_UNUSED(y01); } + + /// Where the bin's OPENING is — the ellipse you could reach into. + /// + /// Everything else here describes the bin's outline. This describes + /// the hole in the top of it, which the outline cannot: the near lip + /// runs THROUGH the silhouette, not around it. An effect that has + /// something come out of the bin needs it, both to know where the way + /// out is and to know what should pass behind the front wall on the + /// way; an effect that only crawls over the outside can ignore it. + /// + /// Measured from the artwork, not assumed — see core/BinMouth. Check + /// `measured` before leaning on it hard; it falls back to a generic + /// bin rather than refusing to answer. + virtual void setMouth(const BinMouth &mouth) { Q_UNUSED(mouth); } /// The bin's artwork itself, in colour. /// /// Node-based effects get this as a texture in updateNode() and can @@ -79,14 +95,21 @@ class Effect : public QObject /// Advance by `dt` seconds. Never called while isAtRest() is true. virtual void step(float dt) = 0; - /// The slowest frame interval this effect is happy with, in ms. - /// 0 means "whatever the power policy says". - /// - /// For an effect that genuinely never rests — ooze bubbles for as - /// long as there is trash — this is the only lever left on its cost. - /// The policy still decides whether to draw AT ALL; this only asks - /// for less often when it does. - virtual int preferredFrameIntervalMs() const { return 0; } + // There is deliberately no per-effect frame interval here. + // + // There was: effects could name a slowest-acceptable cadence and the + // host took the slower of that and the policy's. It existed as a cost + // lever for effects that never rest — but NO effect in this app rests + // (every isAtRest() collapses to isEmpty()), so it was not + // distinguishing anything. What it actually did was let one effect + // quietly opt out of the policy: the gel asked for 33ms and got 30fps + // on a 120Hz display, and the tentacles did the same until it was + // noticed. + // + // Cadence now has one owner, core/PowerPolicy, which runs at the + // display's refresh rate and halves it when conserving. An effect that + // is too expensive at full rate is too expensive, and the fix is the + // effect, not a private cap the policy cannot see. // --- power ---------------------------------------------------------- diff --git a/src/core/EffectRegistry.cpp b/src/core/EffectRegistry.cpp index 1d4d796..bc1fbd4 100644 --- a/src/core/EffectRegistry.cpp +++ b/src/core/EffectRegistry.cpp @@ -2,6 +2,7 @@ #include "../effects/FliesEffect.h" #include "../effects/OozeEffect.h" +#include "../effects/TentacleEffect.h" namespace hyperbin::effects { @@ -13,6 +14,8 @@ const QVector &all() [] { return std::unique_ptr(new FliesEffect); } }, { QStringLiteral("ooze"), QStringLiteral("Ooze"), [] { return std::unique_ptr(new OozeEffect); } }, + { QStringLiteral("tentacles"), QStringLiteral("Tentacles"), + [] { return std::unique_ptr(new TentacleEffect); } }, }; return kAll; } diff --git a/src/core/OozeSim.h b/src/core/OozeSim.h index ba0abbe..a2c9a8a 100644 --- a/src/core/OozeSim.h +++ b/src/core/OozeSim.h @@ -64,13 +64,13 @@ class OozeSim bool isEmpty() const { return m_level <= 0.0005f; } /// Ooze drips for as long as there is anything to drip, so it only - /// rests once it is gone. What is done about the cost of that is in - /// preferredFrameIntervalMs() — an effect that cannot stop should at - /// least ask to be run slowly. + /// rests once it is gone — which is true of EVERY effect here, not just + /// this one, and is why none of them carries a private frame-rate cap + /// any more. This used to answer preferredFrameIntervalMs() with 33 and + /// so ran at 30fps whatever the display could do. See the note in + /// core/Effect.h. bool isAtRest() const { return isEmpty(); } - int preferredFrameIntervalMs() const { return params.frameIntervalMs; } - /// How far a drip hangs, as a fraction of the icon — and the most /// the cycle ever stretches that. The margin below the bin is /// computed from BOTH, because they are the same number seen from two @@ -118,7 +118,6 @@ class OozeSim /// This leaves at nothing, gathers pace, and settles in. float creepEase = 3.4f; // filling: about 1.4s float recedeEase = 5.0f; // and draining: emptying is a relief - int frameIntervalMs = 33; }; Params params; diff --git a/src/core/PowerPolicy.cpp b/src/core/PowerPolicy.cpp index 8377fb9..2d66c1b 100644 --- a/src/core/PowerPolicy.cpp +++ b/src/core/PowerPolicy.cpp @@ -3,14 +3,15 @@ namespace hyperbin { namespace { -// 60fps on mains. 20fps was the original budget-driven guess and it read -// as choppy rather than twitchy once there was something to look at -// (Leo, 2026-08-09), so smoothness wins and we pay for it in frames. -// Frame cadence is still the dominant power cost (docs/battery.md), so -// this is the number to revisit if measurement comes back bad — and the -// idle rules matter more than ever now that active frames cost 3x. -constexpr int kActiveMs = 16; -constexpr int kBatteryMs = 33; // halve the rate off mains +// Conserving runs at 30fps. NOT conserving runs at whatever the display +// does — see refreshMs — because a 120Hz screen should get 120Hz. +// +// 20fps was the original budget-driven guess and it read as choppy rather +// than twitchy once there was something to look at (Leo, 2026-08-09). +// Frame cadence is still the dominant power cost (docs/battery.md), which +// is exactly why the conserving path halves it rather than stopping: a +// paused novelty is forgiven, a drained battery is not. +constexpr int kLowPowerMs = 33; } // namespace PowerPolicy::PowerPolicy(QObject *parent) @@ -23,8 +24,14 @@ void PowerPolicy::setEffectIdle(bool v) { if (m_effectIdle != v) { m_effec void PowerPolicy::setEffectAtRest(bool v) { if (m_effectAtRest != v) { m_effectAtRest = v; bump(); } } void PowerPolicy::setTargetVisible(bool v) { if (m_targetVisible != v) { m_targetVisible = v; bump(); } } void PowerPolicy::setDisplayAwake(bool v) { if (m_displayAwake != v) { m_displayAwake = v; bump(); } } -void PowerPolicy::setOnBattery(bool v) { if (m_onBattery != v) { m_onBattery = v; bump(); } } -void PowerPolicy::setLowPowerMode(bool v) { if (m_lowPower != v) { m_lowPower = v; bump(); } } +void PowerPolicy::setLowPower(bool v) { if (m_lowPower != v) { m_lowPower = v; bump(); } } +void PowerPolicy::setRefreshHz(qreal v) +{ + if (qFuzzyCompare(m_refreshHz, v) || v < 20.0) + return; + m_refreshHz = v; + bump(); +} void PowerPolicy::setEnabled(bool v) { if (m_enabled != v) { m_enabled = v; bump(); } } void PowerPolicy::setDismissed(bool v) { if (m_dismissed != v) { m_dismissed = v; bump(); } } @@ -39,8 +46,6 @@ bool PowerPolicy::shouldRender() const return false; if (!m_displayAwake || !m_targetVisible) return false; - if (m_lowPower) - return false; // An empty bin costs zero — but let a departing effect finish first. if (m_binEmpty && m_effectIdle) return false; @@ -55,7 +60,13 @@ int PowerPolicy::frameIntervalMs() const // and the last frame stays on it; we simply stop producing new ones. if (m_effectAtRest) return 0; - return m_onBattery ? kBatteryMs : kActiveMs; + if (m_lowPower) + return kLowPowerMs; + // One frame per refresh. Floored at 4ms so a bogus rate cannot turn + // the clock into a spin, and never SLOWER than the conserving rate, + // which would make Low Power Mode speed things up on a 24Hz display. + const int ms = int(qRound(1000.0 / m_refreshHz)); + return qBound(4, ms, kLowPowerMs); } void PowerPolicy::bump() diff --git a/src/core/PowerPolicy.h b/src/core/PowerPolicy.h index dbb876e..40d1930 100644 --- a/src/core/PowerPolicy.h +++ b/src/core/PowerPolicy.h @@ -25,8 +25,18 @@ class PowerPolicy : public QObject void setEffectAtRest(bool atRest); void setTargetVisible(bool vis); // not occluded / not hidden void setDisplayAwake(bool awake); // display on, session unlocked - void setOnBattery(bool battery); - void setLowPowerMode(bool low); + /// Conserve: draw, but at a reduced rate. One input, not two. + /// + /// This replaced a pair — "on battery" and "low power mode" — that + /// asked different questions and were both permanently false because + /// nothing ever called them. Being on battery is not the same as + /// wanting to conserve, so the decision belongs to the user (with an + /// Auto that follows the OS) and arrives here already made. + void setLowPower(bool low); + /// The display's refresh rate. Frames are produced at this when not + /// conserving — the app should look as smooth as the screen allows, + /// not as smooth as some number written down in 2026. + void setRefreshHz(qreal hz); /// Master switch from the menu. Off means no timer, no frames, no /// overlay surface and no trash polling — the app is resident but /// does nothing at all until it's switched back on. @@ -56,8 +66,8 @@ class PowerPolicy : public QObject bool m_effectAtRest = false; bool m_targetVisible = true; bool m_displayAwake = true; - bool m_onBattery = false; - bool m_lowPower = false; + bool m_lowPower = false; + qreal m_refreshHz = 60.0; bool m_enabled = true; bool m_dismissed = false; bool m_lastRender = false; diff --git a/src/core/TentacleChain.cpp b/src/core/TentacleChain.cpp new file mode 100644 index 0000000..246d1b0 --- /dev/null +++ b/src/core/TentacleChain.cpp @@ -0,0 +1,429 @@ +#include "TentacleChain.h" + +#include +#include + +namespace hyperbin { +namespace { + +/// Normalise, or fall back — a zero-length segment happens the moment two +/// joints coincide, which FABRIK can do transiently when the target sits +/// exactly on a joint. +QVector3D dir(const QVector3D &v, const QVector3D &fallback) +{ + const float l = v.length(); + return l > 1e-5f ? v / l : fallback; +} + +} // namespace + +void TentacleChain::reset(const QVector3D &base, const QVector3D &d, float length) +{ + m_seg = length / float(kJoints - 1); + const QVector3D u = dir(d, QVector3D(0, 1, 0)); + for (int i = 0; i < kJoints; ++i) + m_p[i] = base + u * (m_seg * float(i)); + buildFrames(); +} + +void TentacleChain::solve(const QVector3D &base, const QVector3D &emerge, + const QVector3D &target, + float time, float phase, float flex, float maxBend) +{ + if (!valid()) + return; + + // The showcase's four stages, in its order. The last one is not + // redundant: the wave displaces joints off the chain, which stretches + // every segment it touches, and without a second constraint pass the + // arm visibly lengthens and shortens as the wave runs down it. + // Real seconds, from the clock the caller already passes. Clamped + // because the first frame has no previous time and a stall must not + // let the bank jump. + const float dt = m_prevTime < 0.0f ? 0.016f + : std::clamp(time - m_prevTime, 0.0f, 0.1f); + m_prevTime = time; + for (int i = 0; i < kJoints; ++i) + m_was[i] = m_p[i]; + fabrik(base, target); + constrain(base, maxBend); + holdRoot(base, emerge); + // Once a frame, before anything reads the frames. buildFrames runs + // three times over a step — twice here and again from settle — so + // updating the bank inside it would apply the rate limit three times + // and make the speed depend on how often the frames are rebuilt. + updateRoll(dt); + // FRAMES BEFORE THE WAVE, and this is not tidiness — it is what stops + // the arm shaking. + // + // The wave displaces along m_side. Built only at the END of solve, + // m_side described a chain that already had the previous frame's wave + // in it, so the wave's direction depended on its own last output: a + // feedback loop, and one with enough gain to matter. Measured with the + // target held completely still, joints were lurching up to 49 units a + // frame with a median of 7, which is the "sticking then jittering" + // exactly. Deriving the frame from the solved but UN-waved pose breaks + // the loop — the wave now rides on a shape that is a pure function of + // the target. + buildFrames(); + applyWave(time, phase, flex); + constrain(base, maxBend); + holdRoot(base, emerge); + // Adopt only part of the new pose. Run BEFORE the last constraint pass + // so the blend cannot leave segments the wrong length — a lerp between + // two valid chains is not itself a valid chain. + // + // Less of it the further out the joint is, so the tip arrives after the + // root does. See kTipLag. + for (int i = 1; i < kJoints; ++i) { + const float s = float(i) / float(kJoints - 1); + const float adopt = kAdopt * (1.0f - kTipLag * s); + m_p[i] = m_was[i] + (m_p[i] - m_was[i]) * adopt; + } + constrain(base, maxBend); + holdRoot(base, emerge); + buildFrames(); +} + +void TentacleChain::fabrik(const QVector3D &base, const QVector3D &target) +{ + for (int it = 0; it < kSolveIterations; ++it) { + // Backward: put the tip on the target and walk down, each joint + // pulled to a segment's distance from the one above it. The base + // ends up wherever it ends up. + m_p[kJoints - 1] = target; + for (int i = kJoints - 2; i >= 0; --i) + m_p[i] = m_p[i + 1] + + dir(m_p[i] - m_p[i + 1], QVector3D(0, -1, 0)) * m_seg; + + // Forward: put the base back where it belongs and walk up. The tip + // now falls short of the target by however far it was out of + // reach, which is the correct answer and needs no special case — + // an arm asked for something beyond its length simply points at it. + m_p[0] = base; + for (int i = 1; i < kJoints; ++i) + m_p[i] = m_p[i - 1] + + dir(m_p[i] - m_p[i - 1], QVector3D(0, 1, 0)) * m_seg; + } +} + +void TentacleChain::constrain(const QVector3D &base, float maxBend) +{ + // Bilateral: where a segment is the wrong length, both of its ends + // move half the error towards fixing it, and the correction ripples + // outward over the iterations. Moving only the outer joint instead + // pushes every error to the tip, where it accumulates into a visible + // flick. + for (int it = 0; it < kConstraintIterations; ++it) { + m_p[0] = base; + for (int i = 0; i < kJoints - 1; ++i) { + const QVector3D d = m_p[i + 1] - m_p[i]; + const float len = d.length(); + if (len < 1e-5f) + continue; + const QVector3D fix = d * ((len - m_seg) / len * 0.5f); + // The base is an anchor, so when it is one end of the segment + // the other end takes the whole correction. + if (i == 0) { + m_p[i + 1] -= fix * 2.0f; + } else { + m_p[i] += fix; + m_p[i + 1] -= fix; + } + } + // Interleaved with the lengths rather than run once afterwards. + // Each fights the other — straightening a kink stretches the + // segments it moved, and fixing a length re-bends the joint — so + // they have to converge together. Ten passes of both settles. + limitBend(maxBend); + } + m_p[0] = base; +} + +void TentacleChain::limitBend(float maxBend) +{ + QVector3D prev = dir(m_p[1] - m_p[0], QVector3D(0, 1, 0)); + for (int i = 1; i < kJoints - 1; ++i) { + QVector3D cur = dir(m_p[i + 1] - m_p[i], prev); + const float c = std::clamp(QVector3D::dotProduct(prev, cur), -1.0f, 1.0f); + if (std::acos(c) > maxBend) { + // Swung back to the limit in the plane the two segments + // already share, so the joint bends less rather than + // somewhere else. Built from the component of `cur` across + // `prev`, which is that plane's second axis; if there is none + // the two are collinear and there was nothing to fix. + QVector3D perp = cur - prev * c; + if (perp.lengthSquared() > 1e-10f) { + perp.normalize(); + cur = prev * std::cos(maxBend) + perp * std::sin(maxBend); + } else { + cur = prev; + } + m_p[i + 1] = m_p[i] + cur * m_seg; + } + prev = cur; + } +} + +void TentacleChain::curlUp(const QVector3D &base, const QVector3D &emerge, + float turn, float blend) +{ + if (blend <= 0.0f) { + m_curling = false; + return; + } + const QVector3D t0 = dir(m_p[1] - m_p[0], dir(emerge, QVector3D(0, 1, 0))); + // Which plane to coil in: the arm's own bend AT THE MOMENT THE ROLL + // STARTS, so it rolls the way it was already going — and then that + // plane is held, so the spiral cannot turn over underneath itself. + if (!m_curling) { + QVector3D axis = QVector3D::crossProduct(t0, m_p[kJoints - 1] - m_p[0]); + if (axis.lengthSquared() < 1e-8f) + axis = QVector3D::crossProduct(t0, QVector3D(0, 0, 1)); + if (axis.lengthSquared() < 1e-8f) + axis = QVector3D::crossProduct(t0, QVector3D(1, 0, 0)); + m_curlAxis = axis.normalized(); + m_curling = true; + } + // Re-squared against the arm's current direction. The plane is held, + // but the arm it belongs to still swings, and an axis that has drifted + // out of perpendicular makes the spiral cone outward instead of + // closing — Rodrigues below assumes the two are square. + QVector3D axis = m_curlAxis - t0 * QVector3D::dotProduct(m_curlAxis, t0); + if (axis.lengthSquared() < 1e-8f) + axis = QVector3D::crossProduct(t0, QVector3D(0, 0, 1)); + if (axis.lengthSquared() < 1e-8f) + axis = QVector3D::crossProduct(t0, QVector3D(1, 0, 0)); + axis.normalize(); + // Walk out from the base turning a little more at every joint. The + // ramp is what makes it a roll-up rather than a circle: a constant + // turn per joint IS a circle, and a fruit rollup is a spiral, tight at + // the middle and open at the outside. + QVector3D p = base; + QVector3D d = t0; + for (int i = 1; i < kJoints; ++i) { + const float s = float(i) / float(kJoints - 1); + // s^1.5 rather than s^2. The steeper ramp put nearly all of the + // turning into the last few joints, which hit the bend limit and + // stopped, so the total came out well under a full circle however + // high `turn` went. A gentler ramp spreads the same total over + // more joints and actually closes the spiral. + const float a = turn * s * std::sqrt(s); + // Rotate d about axis by a — Rodrigues, with d perpendicular to + // axis by construction so the parallel term drops out. + const QVector3D perp = QVector3D::crossProduct(axis, d); + d = dir(d * std::cos(a) + perp * std::sin(a), d); + p += d * m_seg; + m_p[i] += (p - m_p[i]) * blend; + } +} +void TentacleChain::settle(const QVector3D &base, const QVector3D &emerge, + float maxBend) +{ + constrain(base, maxBend); + holdRoot(base, emerge); + buildFrames(); +} +void TentacleChain::pushOutside(float topY, float binHeight, float halfX, + float halfZ, float taper, int fromJoint) +{ + // ONCE OUT, STAY OUT. + // + // Walking the chain in order and remembering whether it has left the + // bin yet is what settles an otherwise ambiguous question. A joint + // below the lip and near the axis is either an arm still down in the + // rubbish — which should stay hidden — or the curled-back tip of one + // that went over the rim and is hanging outside, which must not be. + // Position alone cannot tell those apart: they are the same point. + // + // Order along the chain can, because an arm cannot re-enter the bin + // through its own wall. Everything past the first joint that is out is + // also out. Without this, the tip of a slap hooked back toward the bin, + // fell under the threshold that had been holding it in front, and was + // cut off with a detached sliver left behind. + bool escaped = false; + for (int i = std::max(1, fromJoint); i < kJoints; ++i) { + const float y = m_p[i].y(); + if (y >= topY) { + escaped = true; // in the opening, where it belongs + continue; + } + const float below = std::clamp((topY - y) / std::max(binHeight, 1e-4f), + 0.0f, 1.0f); + const float sx = halfX * (1.0f + (taper - 1.0f) * below); + const float sz = halfZ * (1.0f + (taper - 1.0f) * below); + const float ex = m_p[i].x() / std::max(sx, 1e-4f); + const float ez = m_p[i].z() / std::max(sz, 1e-4f); + const float r = std::sqrt(ex * ex + ez * ez); + const float front = sz * std::sqrt(std::max(0.0f, 1.0f - ex * ex)); + + if (r >= 1.0f || m_p[i].z() >= front) { + escaped = true; // already outside it, or already ahead of it + continue; + } + + if (escaped) { + m_p[i].setZ(front * 1.04f); + continue; + } + + // Still on its way out, so the nearest surface is the right one — + // this is an arm inside the opening and the shortest way out is the + // way it came. Past two thirds of the way to the edge, though, the + // nearest surface is the SIDE, and a joint parked on the side sits + // at a small z where the mask correctly puts the bin in front of + // it. Out sideways is not out in front. + if (std::abs(ex) > 0.66f) { + m_p[i].setZ(std::max(m_p[i].z(), front * 1.04f)); + } else if (r < 1e-3f) { + m_p[i].setZ(sz * 1.02f); + } else { + const float k = 1.02f / r; + m_p[i].setX(m_p[i].x() * k); + m_p[i].setZ(m_p[i].z() * k); + } + } +} +void TentacleChain::holdRoot(const QVector3D &base, const QVector3D &emerge) +{ + // Pull the first joints back onto the line the arm leaves its hole + // along, fading out over the held span so there is no crease where the + // hold stops. Run AFTER the length pass and before the next one, so + // whatever it moves gets its segments fixed again. + const QVector3D u = dir(emerge, QVector3D(0, 1, 0)); + for (int i = 1; i <= kRootHeld && i < kJoints; ++i) { + const float w = 1.0f - float(i - 1) / float(kRootHeld); + const QVector3D want = base + u * (m_seg * float(i)); + m_p[i] += (want - m_p[i]) * w; + } +} +void TentacleChain::applyWave(float time, float phase, float flex) +{ + // THIS is the part that makes it look alive, not the solver above. + // + // A sine laid across the chain, its phase advancing with distance + // along the arm, so the crest travels from base to tip rather than the + // whole arm flapping in step. Amplitude is deliberately NOT tapered — + // the showcase leaves it constant, and tapering it to nothing at the + // tip removes the motion from the one part of the arm that has the + // freedom to show it. + // + // Two waves, not one. The showcase is 2D and has only a single axis to + // displace along; in three dimensions a lone wave is a flat flap seen + // edge-on half the time. The second runs across the first at a rate + // that does not divide into it, which turns the flap into a slow + // helical roll that never repeats. + constexpr float kFrequency = 2.0f; + constexpr float kSpeed = 3.0f; + constexpr float kAmplitude = 0.040f; // of the arm's own length + + // BREATH AND WANDER, and this is the difference between an undulation + // and a machine running. + // + // Two fixed sines produce a wave of exactly one strength at exactly one + // pace forever, and the eye finds that period quickly however + // complicated the sum of them looks. Living motion gathers and eases: + // it pushes harder for a stroke or two, then idles. + // + // The pace wander is added to the PHASE and not multiplied into the + // time, which is the only form of this that works. Scaling time by a + // varying factor makes the effective frequency d/dt[t*k(t)] = k + t*k', + // so the modulation grows without bound and the wave ends up buzzing + // after a few minutes. Displacing the phase speeds the crest up and + // slows it down around a fixed mean and stays there. + const float breath = 0.70f + 0.48f * std::sin(time * 0.23f + phase); + const float wander = 0.90f * std::sin(time * 0.17f + phase * 1.9f); + const float amp = kAmplitude * length() * flex * breath; + if (amp <= 0.0f) + return; + + for (int i = 1; i < kJoints; ++i) { + const float s = float(i) / float(kJoints - 1); + const QVector3D t = dir(m_p[i] - m_p[i - 1], QVector3D(0, 1, 0)); + const QVector3D side = dir(m_side[i] - t * QVector3D::dotProduct(m_side[i], t), + QVector3D(1, 0, 0)); + const QVector3D up = QVector3D::crossProduct(t, side); + + const float a = time * kSpeed + phase + wander + + s * kFrequency * 6.28318531f; + const float b = time * kSpeed * 0.61f + phase * 1.7f + wander * 0.7f + + s * kFrequency * 4.10318531f; + // Held at the root. A wave that displaces the first joints pulls + // the arm out of the hole it is supposed to be coming through. + const float grip = s * s; + m_p[i] += side * (std::sin(a) * amp * grip) + + up * (std::sin(b) * amp * 0.6f * grip); + } +} + +namespace { +/// Rotate v about axis by ang, for v perpendicular to axis — Rodrigues +/// without its parallel term. +QVector3D bankBy(const QVector3D &v, const QVector3D &axis, float ang) +{ + return v * std::cos(ang) + QVector3D::crossProduct(axis, v) * std::sin(ang); +} +} // namespace +void TentacleChain::updateRoll(float dt) +{ + // A BANK, in response to movement. Nothing is aimed at anything. + // + // Three earlier versions tried to point the frame somewhere — at the + // inside of the curl, then at world down — and all of them failed the + // same way. An aim is a property of the POSE: it jumps when the pose + // wobbles, and it inverts outright when a bend passes through + // straight, which is what made the suckers flip over. Worse, the frame + // is also carried by transport, so bounding the correction bounded + // nothing and the roll went round anyway. + // + // This asks a smaller question with a bounded answer: which way is the + // arm sweeping, and lean into it, a little. Like a fish banking into a + // turn. The frame underneath is untouched — the stable one that + // shipped — and the whole chain turns together on top of it. + const int j = kJoints * 2 / 3; + const QVector3D tj = dir(m_p[j] - m_p[j - 1], QVector3D(0, 1, 0)); + // The outer half's travel this frame. The root is held and barely + // moves, so including it only dilutes the direction with noise. + QVector3D moved; + for (int i = kJoints / 2; i < kJoints; ++i) + moved += m_p[i] - m_was[i]; + m_travel += (moved - m_travel) * std::min(1.0f, dt * 6.0f); + // How much of that travel is ACROSS the arm, along the frame's own + // side axis — which is the direction a bank would show in. Measured + // in segment lengths so it means the same on any size of bin. + const QVector3D side = m_side[j] - tj * QVector3D::dotProduct(m_side[j], tj); + float bank = 0.0f; + if (side.lengthSquared() > 1e-6f && m_seg > 0.0f) + bank = QVector3D::dotProduct(m_travel, side.normalized()) / m_seg; + const float want = std::clamp(bank * kRollGain, -kRollLimit, kRollLimit); + // Rate limited, so the arm is seen to roll rather than found rolled. + m_roll += std::clamp(want - m_roll, -kRollRate * dt, kRollRate * dt); + m_roll = std::clamp(m_roll, -kRollLimit, kRollLimit); +} +void TentacleChain::buildFrames() +{ + // Rotation-minimising, by projection: carry the previous joint's side + // vector forward and take out whatever component the new tangent has + // acquired. Rebuilding each frame from a fixed world axis instead + // makes the frame spin wherever the arm passes near that axis, which + // on this model rolls the sucker row round to the far side. + QVector3D t0 = dir(m_p[1] - m_p[0], QVector3D(0, 1, 0)); + QVector3D s = QVector3D::crossProduct(t0, QVector3D(0, 0, 1)); + if (s.lengthSquared() < 1e-6f) + s = QVector3D::crossProduct(t0, QVector3D(1, 0, 0)); + // The bank, laid on the seed. Transport carries it unchanged to every + // joint, so one angle turns the entire arm and no two rings can + // disagree — which is the difference between the arm rotating and the + // mesh winding up along its length. + m_side[0] = bankBy(s.normalized(), t0, m_roll); + + for (int i = 1; i < kJoints; ++i) { + const QVector3D t = dir(m_p[i] - m_p[i - 1], t0); + QVector3D carried = m_side[i - 1] + - t * QVector3D::dotProduct(m_side[i - 1], t); + m_side[i] = dir(carried, m_side[i - 1]); + t0 = t; + } +} + +} // namespace hyperbin diff --git a/src/core/TentacleChain.h b/src/core/TentacleChain.h new file mode 100644 index 0000000..0b08313 --- /dev/null +++ b/src/core/TentacleChain.h @@ -0,0 +1,229 @@ +// A tentacle as a chain of joints, solved by FABRIK. +// +// This replaced a circular arc, and the arc is worth describing because it +// is exactly why the old motion read as stiff: the whole arm was two +// numbers, a total bend and a direction, plus a small two-frequency +// wobble. An arc has CONSTANT curvature by definition — it is the least +// interesting curve that is not a straight line — so no amount of tuning +// those two numbers could produce anything that looked alive. +// +// The technique is taken from Smitner-Studio/tentacle-arm-showcase (MIT), +// a 2D Godot demo. FABRIK itself is pure vector arithmetic, so it carries +// into 3D unchanged. What is worth knowing before reading further is that +// FABRIK is NOT what makes that demo look good: it only pulls the chain at +// a target and keeps the segments from stretching. The life comes from a +// travelling sine wave laid on afterwards — see applyWave — and their own +// numbers say so, two solver iterations against ten constraint ones. +// +// Headless on purpose, in core/ rather than effects/, so tests/simtest.cpp +// can exercise it without a window. +#pragma once + +#include + +namespace hyperbin { + +class TentacleChain +{ +public: + /// Joints along one arm. The showcase uses 24 for a 128px line; this + /// is drawn a tenth that size at a Dock icon and the mesh laid over it + /// has around forty rings, so past sixteen the chain is finer than + /// anything sampling it. + static constexpr int kJoints = 16; + + /// The most one segment may turn against the one before it. + /// + /// FABRIK on its own has no idea that an arm is a physical thing: give + /// it a target well inside its reach and it will happily fold the + /// chain back through itself, because a folded chain satisfies every + /// length constraint perfectly. The first render with a real solver + /// came out crumpled and self-intersecting for exactly that reason. + /// + /// Fifteen segments at 22 degrees is 330 degrees of curl available, + /// which covers a strike over the rim and down the outside at about + /// 250. A full coil needs more than 360 and asks for it explicitly. + static constexpr float kMaxBend = 0.384f; // 22 degrees + /// Enough for one and a half turns, for the moves that curl right up. + static constexpr float kCoilBend = 0.62f; // 35 degrees + + /// How many joints the hole grips. + /// + /// FABRIK anchors the base POSITION and nothing else, so an arm asked + /// for something far to one side satisfies it by swinging everything + /// including its first segment — the anchor stays put but the arm + /// leaves it at a new angle, and what the eye reads as "the base" is + /// the first visible stretch, not the anchored point. Wrapping round + /// the bin looked like the whole tentacle sliding around the rim. + /// + /// Holding four of sixteen is about a quarter of the arm, which is + /// roughly what is buried in the rubbish and passing through the + /// opening — the part that physically could not swing. + static constexpr int kRootHeld = 4; + + /// How far the whole arm may roll about its own axis, radians. + /// + /// A BANK, not a solve. The frame underneath is left exactly as it + /// was — transported from a fixed seed, which is stable and is what + /// shipped — and this turns the entire arm on top of it by one small + /// angle. Every ring turns together, so it reads as the arm rotating + /// rather than the mesh being wrung out, and it cannot do anything + /// surprising because a scalar cannot leave its own clamp. + /// + /// Small on purpose. Earlier attempts aimed the frame at a direction — + /// the inside of the curl, then world down — and both failed the same + /// way: the aim is a property of the pose, so it jumps when the pose + /// does and inverts outright when a bend passes through straight. That + /// is the reversing. Nothing here is aimed at anything, so there is + /// nothing to invert. + static constexpr float kRollLimit = 0.42f; // 24 degrees, see above + /// How fast it may roll, radians a second: the full swing takes about + /// two thirds of a second, so it is seen to happen. + static constexpr float kRollRate = 1.3f; + /// How much bank a segment-length of sideways travel per frame asks + /// for, radians. Tuned so an ordinary sweep uses most of the range and + /// a lash reaches the stop. + static constexpr float kRollGain = 0.55f; + + + /// Straighten the arm out from `base` along `dir`, `length` long. + /// Called once, and again if the bin resizes — never per frame, or the + /// chain loses the state that makes it lag behind its target. + void reset(const QVector3D &base, const QVector3D &dir, float length); + + /// Advance one frame: reach for `target`, keep the segments honest, + /// then undulate. `flex` scales the wave — an arm mid-strike should + /// snap, not ripple. + /// `maxBend` is the most one joint may turn against the one before + /// it, in radians. A parameter and not a constant because it is what + /// separates a reach from a coil: at the default an arm cannot close a + /// circle over its sixteen joints, and a move that is supposed to curl + /// right round needs to be allowed to. + /// `emerge` is the direction the arm leaves its hole in. The first + /// few joints are held onto it — see holdRoot for why that is not + /// optional once an arm is asked to reach a long way sideways. + void solve(const QVector3D &base, const QVector3D &emerge, + const QVector3D &target, + float time, float phase, float flex, float maxBend); + /// Blend the solved chain toward a SPIRAL, tightening toward the tip. + /// + /// A separate pass and not another target, because no single target + /// can ask for this. FABRIK is told where to put the tip and finds + /// some pose that does it; a roll-up is a statement about CURVATURE + /// ALONG the arm — nearly straight at the base, tightest at the very + /// end — which is a property of every joint at once and invisible to + /// a solver that only sees the endpoint. + /// + /// `turn` is the bend per joint at the tip, radians. `blend` fades the + /// whole thing in, so a move can roll up and unroll. + void curlUp(const QVector3D &base, const QVector3D &emerge, + float turn, float blend); + /// Keep the chain OUT of the bin's body. + /// + /// FABRIK knows about lengths and nothing else, so the straight route + /// from a root in the rubbish to a target down by the foot goes + /// through the bin. The mask then correctly hides the buried stretch, + /// and the arm appears as a floating fragment with a gap where its + /// middle should be. Pushing joints out to the nearest surface is what + /// makes an arm go OVER the rim to get down the outside, which is what + /// it would have to do. + /// + /// The body is an upright elliptical cylinder from `topY` down, + /// narrowing to `taper` of its width at a bin height below. Joints + /// above `topY` are left alone: that is the opening, and an arm is + /// supposed to be in it. + void pushOutside(float topY, float binHeight, float halfX, float halfZ, + float taper, int fromJoint); + + /// Re-fix lengths, bends and the root, and rebuild the frames. For + /// callers that have moved joints themselves. + void settle(const QVector3D &base, const QVector3D &emerge, float maxBend); + bool valid() const { return m_seg > 0.0f; } + float length() const { return m_seg * float(kJoints - 1); } + + const QVector3D *joints() const { return m_p; } + /// One unit vector per joint, across the arm. Carried along the chain + /// rather than recomputed per joint: the arm's suckers are on ONE + /// side, so an unstable frame does not merely wobble the shading, it + /// rolls the suckers round the arm. + const QVector3D *sides() const { return m_side; } + /// The arm's bank about its own axis, radians. Exposed for the trace: + /// it IS the rotation this class adds, so measuring it needs no world + /// reference — which the earlier attempts did, and which is what made + /// them impossible to judge. + float roll() const { return m_roll; } + +private: + void fabrik(const QVector3D &base, const QVector3D &target); + void constrain(const QVector3D &base, float maxBend); + void limitBend(float maxBend); + void holdRoot(const QVector3D &base, const QVector3D &emerge); + void applyWave(float time, float phase, float flex); + void updateRoll(float dt); + void buildFrames(); + + + /// Two passes at reaching, ten at not stretching — the showcase's own + /// ratio, and it is the right way round. Reaching converges fast and + /// approximately, which is all a tentacle needs; segment lengths that + /// drift are immediately visible as an arm that grows and shrinks. + static constexpr int kSolveIterations = 2; + static constexpr int kConstraintIterations = 10; + + /// How much of each frame's freshly solved pose to actually adopt. + /// + /// FABRIK does not have A solution, it has many — any number of poses + /// put the tip on the target — and which one two iterations land on + /// depends on where the chain started. The wave kicks that starting + /// point every frame, so the solver wanders between equally valid + /// answers and the arm shivers. Measured with the target held + /// perfectly still, joints moved a median of 7 units and up to 58 in a + /// single frame. + /// + /// Blending toward the new pose instead of snapping to it filters that + /// out: the chatter is per-frame and gets damped hard, while the wave + /// and the moves are slow enough to pass through untouched. It also + /// gives the arm lag, which reads as weight. + static constexpr float kAdopt = 0.30f; + /// How much SLOWER the tip adopts than the root, as a share of kAdopt. + /// + /// Uniform damping moves the whole arm as one piece, which is the last + /// mechanical thing about it: everything arrives at once, so a strike + /// has no follow-through and a recovery has no trailing end. Damping + /// the tip harder than the base makes the far end arrive LATE, which is + /// what secondary motion is — the same reason a whip cracks and a + /// dropped rope keeps moving after the hand stops. + /// + /// A share of kAdopt rather than a second constant, so the one dial + /// that controls how much the arm lags still controls all of it. + static constexpr float kTipLag = 0.45f; + QVector3D m_p[kJoints]; + QVector3D m_was[kJoints]; + QVector3D m_side[kJoints]; + /// The plane the current roll-up coils in, held for the whole roll. + /// + /// Derived per frame from the chain's own bend, this was the roll's + /// jitter: the axis is a cross product with the base-to-tip vector, so + /// an arm that passes near straight has almost no bend to read the + /// plane from, and the axis swings — or flips outright — between + /// frames. curlUp then rewrites the whole chain around a spiral that + /// has turned over, and the arm snaps through itself. Seeded once when + /// the roll begins and carried, the spiral is stable whatever the + /// solver does underneath it. + QVector3D m_curlAxis; + /// The arm's bank, radians, laid on the whole chain at once. See + /// updateRoll — the only thing that moves it — and buildFrames, which + /// applies it to the seed and lets transport carry it to every joint. + float m_roll = 0.0f; + /// Where the outer half has been going, smoothed. The bank follows + /// this rather than the pose: a shape can inch across the point where + /// its bend inverts, a direction of travel cannot. + QVector3D m_travel; + /// The clock the last solve was given, for a real dt. Negative until + /// the first one arrives. + float m_prevTime = -1.0f; + bool m_curling = false; + float m_seg = 0.0f; +}; + +} // namespace hyperbin diff --git a/src/effects/IconTexture.cpp b/src/effects/IconTexture.cpp new file mode 100644 index 0000000..3a4fa4b --- /dev/null +++ b/src/effects/IconTexture.cpp @@ -0,0 +1,19 @@ +#include "IconTexture.h" + +namespace hyperbin { + +void IconTexture::setImage(const QImage &img) +{ + if (img.isNull()) { + setTextureData({}); + return; + } + const QImage rgba = img.convertToFormat(QImage::Format_RGBA8888); + setSize(rgba.size()); + setFormat(QQuick3DTextureData::RGBA8); + setHasTransparency(true); + setTextureData(QByteArray(reinterpret_cast(rgba.constBits()), + qsizetype(rgba.sizeInBytes()))); +} + +} // namespace hyperbin diff --git a/src/effects/IconTexture.h b/src/effects/IconTexture.h new file mode 100644 index 0000000..8305b77 --- /dev/null +++ b/src/effects/IconTexture.h @@ -0,0 +1,24 @@ +// The bin's artwork, handed to Qt Quick 3D as texture data. +// +// QQuick3DTextureData rather than a file or a QSGTexture: the artwork +// comes from the shell at runtime, changes when the bin fills or empties, +// and never exists on disk. +// +// Shared, because more than one effect needs the same thing and for +// different reasons — the ooze refracts the icon, the tentacles redraw +// its front wall over themselves to be occluded by it. +#pragma once + +#include +#include + +namespace hyperbin { + +class IconTexture : public QQuick3DTextureData +{ + Q_OBJECT +public: + void setImage(const QImage &img); +}; + +} // namespace hyperbin diff --git a/src/effects/OozeEffect.cpp b/src/effects/OozeEffect.cpp index c780cc9..21c7f52 100644 --- a/src/effects/OozeEffect.cpp +++ b/src/effects/OozeEffect.cpp @@ -1,38 +1,14 @@ #include "OozeEffect.h" -#include +#include "IconTexture.h" #include #include namespace hyperbin { -/// The bin's artwork, handed to Qt Quick 3D as texture data. -/// -/// QQuick3DTextureData rather than a file or a QSGTexture: the artwork -/// comes from the shell at runtime, changes when the bin fills or -/// empties, and never exists on disk. -class OozeTextureData : public QQuick3DTextureData -{ - Q_OBJECT -public: - void setImage(const QImage &img) - { - if (img.isNull()) { - setTextureData({}); - return; - } - const QImage rgba = img.convertToFormat(QImage::Format_RGBA8888); - setSize(rgba.size()); - setFormat(QQuick3DTextureData::RGBA8); - setHasTransparency(true); - setTextureData(QByteArray(reinterpret_cast(rgba.constBits()), - qsizetype(rgba.sizeInBytes()))); - } -}; - OozeEffect::OozeEffect(QObject *parent) : Effect(parent) - , m_iconTexture(new OozeTextureData) + , m_iconTexture(new IconTexture) { } @@ -70,10 +46,84 @@ void OozeEffect::setBinRect(const QRectF &binRect) void OozeEffect::setFullness(float fullness) { m_sim.setFullness(fullness); } -void OozeEffect::setCursor(const QPointF &, bool) +void OozeEffect::setCursor(const QPointF &pos, bool present) { - // Sludge is not startled. Nothing here reacts to a pointer, so the - // effect never reports itself dismissed. + // The EYES react; the sludge does not. That distinction is why this + // still never reports itself dismissed: being dismissed means the + // effect has finished responding to the pointer and can be put away, + // and an eye that follows you is doing the opposite — it is at its + // most alive exactly when the pointer is there. + m_cursor = pos; + m_cursorOn = present; +} + +void OozeEffect::updateGaze(float dt) +{ + const float binH = float(m_binSize.height()); + if (binH <= 0.0f) + return; + + // Item pixels with +y DOWN, to bin-local scene units with +y UP. + const QPointF centre = m_binRect.center(); + const float sx = float(m_cursor.x() - centre.x()); + const float sy = float(centre.y() - m_cursor.y()); + + // THE GOO'S OWN FOOTPRINT, and barely more. + // + // This was a radius from the bin's centre, out to three and a half bin + // heights — most of the screen around the icon. The eyes were + // therefore tracking almost all the time, and an eye that is always + // following you never appears to notice you: the interesting moment is + // the one where it starts. Worse, at that range the pointer is far + // enough away that every eye turns by only a few degrees, so the + // tracking is both constant and faint. + // + // Bounded by the gel instead. The puddle is the widest part of it, so + // that sets the sides; five per cent of slack either way keeps the + // edge off the silhouette, where a hard boundary would be most + // visible. Vertically it runs from under the spill to the gel's own + // surface. Inside that box the pointer is close, and close is what + // makes the eyes swing hard. + const float halfW = m_shape.poolRadius(m_sim.level()) * 1.05f; + const float top = m_shape.surfaceY(m_contentLine, m_sim.level()); + const float bottom = m_shape.poolBottom(); + // How far outside the box the pointer is, in each axis, as a share of + // the fade. Faded rather than switched: an eye that snapped to the + // pointer the instant it crossed a line would read as a trigger, and + // the whole illusion is a creature noticing something. + const float fade = std::max(binH * 0.10f, 1.0f); + const float outX = std::max(0.0f, std::abs(sx) - halfW); + const float outY = std::max(0.0f, std::max(bottom - sy, sy - top)); + float raw = 0.0f; + if (m_cursorOn) { + const float t = std::clamp(std::hypot(outX, outY) / fade, 0.0f, 1.0f); + raw = 1.0f - t * t * (3.0f - 2.0f * t); + } + // About a fifth of a second to notice, and the same to lose interest. + // Not instant: a pointer crossing the screen would otherwise snap every + // eye through ninety degrees in one frame, which reads as a glitch + // rather than as a head turning. + m_gazePull += (raw - m_gazePull) * std::min(1.0f, dt * 5.0f); + + // Un-project the pointer, then push it toward the camera. + // + // The screen y it arrives as is the scene's y foreshortened by the + // camera's tilt, so recovering the scene position divides by cos — + // the same correction the tentacles' mouth needed, and the same one + // that was missing there for a while and put every arm nine pixels + // low. Sliding along the view ray afterwards keeps it over the same + // pixel; see kGazeDepth. + const float tilt = m_cameraTilt * 3.14159265f / 180.0f; + const float c = std::max(0.2f, std::cos(tilt)); + const float d = kGazeDepth * binH; + m_gazeTarget = QVector3D(sx, + sy / c + d * std::sin(tilt), + d * std::cos(tilt)); + if (Q_UNLIKELY(qEnvironmentVariableIsSet("HYPERBIN_TRACE"))) + qInfo("gaze: cursor(%.0f,%.0f) pull %.2f target(%.0f,%.0f,%.0f) tilt %.0f", + m_cursor.x(), m_cursor.y(), double(m_gazePull), + double(m_gazeTarget.x()), double(m_gazeTarget.y()), + double(m_gazeTarget.z()), double(m_cameraTilt)); } void OozeEffect::setContentLine(float y01) @@ -99,9 +149,19 @@ void OozeEffect::setSurface(const QVector &coverage, int w, int h) emit shapeChanged(); } +void OozeEffect::setCameraTilt(float degrees) +{ + if (qFuzzyCompare(m_cameraTilt, degrees)) + return; + m_cameraTilt = degrees; + emit shapeChanged(); +} + void OozeEffect::step(float dt) { m_sim.step(dt); + // Before the eyes: where they look is part of where they are. + updateGaze(dt); updateEyes(); emit frameChanged(); @@ -544,5 +604,3 @@ void OozeEffect::updateEyes() } } // namespace hyperbin - -#include "OozeEffect.moc" diff --git a/src/effects/OozeEffect.h b/src/effects/OozeEffect.h index 461085c..c75d568 100644 --- a/src/effects/OozeEffect.h +++ b/src/effects/OozeEffect.h @@ -19,7 +19,7 @@ namespace hyperbin { -class OozeTextureData; +class IconTexture; class OozeEffect : public Effect { @@ -56,6 +56,41 @@ class OozeEffect : public Effect /// by the first and hands the second to the eyeball's own material, /// which is where the lids are drawn. Q_PROPERTY(QVariantList eyeNormals READ eyeNormals NOTIFY frameChanged) + /// WHERE THE POINTER IS, in the scene's own units, and how much the + /// eyes should care. The pair is what makes them follow it. + /// + /// A point rather than a direction, because the whole charm of a + /// cluster of eyes tracking something is that they CONVERGE on it: the + /// ones on the left and the ones on the right turn by different + /// amounts, and only a shared target produces that. Handing down one + /// direction would have them all swing in parallel, which reads as the + /// gel tilting rather than as being looked at. + /// + /// Placed on the pointer's view ray at a fixed distance in front of the + /// bin. Under an orthographic camera every point on that ray projects + /// to the same pixel, so the depth chosen does not change WHERE the + /// eyes appear to look — only how strongly they converge. See + /// kGazeDepth. + Q_PROPERTY(QVector3D gazeTarget READ gazeTarget NOTIFY frameChanged) + /// 0 when the pointer is nowhere near, 1 when it is on the bin. + /// + /// Faded by distance and then low-passed, because the host polls the + /// pointer every frame and always reports it present — the overlay is + /// click-through and gets no enter or leave events, so "present" says + /// nothing about whether it is anywhere near. Distance is the only + /// real signal, and a pointer that jumps across the screen has to + /// arrive as a movement rather than as a step. + Q_PROPERTY(float gazePull READ gazePull NOTIFY frameChanged) + /// The scene camera's downward tilt, degrees. WRITTEN BY THE SCENE. + /// + /// Needed here because the pointer arrives as a position ON SCREEN and + /// the eyes are placed in the scene: recovering one from the other + /// turns on the tilt. Pushed in rather than restated as a constant, so + /// there is one camera tilt in the app and not two that can drift + /// apart — the same arrangement TentacleEffect uses, and for the same + /// reason. + Q_PROPERTY(float cameraTilt READ cameraTilt WRITE setCameraTilt + NOTIFY shapeChanged) /// How many there can ever be. The scene builds this many delegates /// once and hides the ones that are not out; rebuilding the Repeater3D /// whenever the count moved cost four times the whole effect when the @@ -84,10 +119,6 @@ class OozeEffect : public Effect bool isEmpty() const override { return m_sim.isEmpty(); } bool isAtRest() const override { return m_sim.isAtRest(); } - int preferredFrameIntervalMs() const override - { - return m_sim.preferredFrameIntervalMs(); - } QMargins margins(qreal iconSize) const override; QUrl visualSource() const override; @@ -115,6 +146,10 @@ class OozeEffect : public Effect QVariantList eyeSpheres() const { return m_eyeSpheres; } QVariantList eyeNormals() const { return m_eyeNormals; } int maxEyes() const { return kMaxEyes; } + QVector3D gazeTarget() const { return m_gazeTarget; } + float gazePull() const { return m_gazePull; } + float cameraTilt() const { return m_cameraTilt; } + void setCameraTilt(float degrees); const OozeSim &sim() const { return m_sim; } /// The gel's silhouette, measured from the bin's artwork. Read by @@ -202,11 +237,26 @@ class OozeEffect : public Effect float m_spreadKey = 0.0f; QSizeF m_binSize {40.0, 40.0}; QRectF m_binRect; + QPointF m_cursor; + bool m_cursorOn = false; + QVector3D m_gazeTarget; + float m_gazePull = 0.0f; + float m_cameraTilt = 17.0f; + /// How far in front of the bin the gaze target is parked, in bin + /// heights. Only the CONVERGENCE depends on it — the camera is + /// orthographic, so sliding the target along the pointer's view ray + /// leaves it over the same pixel. Near, the eyes cross like something + /// examining a fly on the glass; far, they turn nearly in parallel and + /// the cluster stops reading as a group looking at one thing. Two bin + /// heights is a comfortable reading distance for a face this size. + static constexpr float kGazeDepth = 1.15f; + /// Recompute where the pointer is and how much the eyes care. + void updateGaze(float dt); float m_contentLine = 0.22f; bool m_wasEmpty = true; QVector m_coverage; int m_covW = 0, m_covH = 0; - OozeTextureData *m_iconTexture = nullptr; + IconTexture *m_iconTexture = nullptr; }; } // namespace hyperbin diff --git a/src/effects/SpineTexture.cpp b/src/effects/SpineTexture.cpp new file mode 100644 index 0000000..852757d --- /dev/null +++ b/src/effects/SpineTexture.cpp @@ -0,0 +1,46 @@ +#include "SpineTexture.h" + +#include "../core/TentacleChain.h" + +namespace hyperbin { + +void SpineTexture::setChains(const TentacleChain *chains, int count, int maxArms) +{ + constexpr int kJoints = TentacleChain::kJoints; + const int rows = maxArms * 2; + const qsizetype bytes = qsizetype(kJoints) * rows * 4 * qsizetype(sizeof(float)); + if (m_scratch.size() != bytes) + m_scratch.resize(bytes); + // Zeroed every frame rather than only on resize: an arm that is not + // out still has two rows in here, and stale joints from the last time + // it was out would be sampled by a delegate that has not yet been + // hidden. Cheap — this is a few kilobytes. + m_scratch.fill('\0'); + + auto *f = reinterpret_cast(m_scratch.data()); + for (int a = 0; a < count && a < maxArms; ++a) { + if (!chains[a].valid()) + continue; + const QVector3D *p = chains[a].joints(); + const QVector3D *s = chains[a].sides(); + float *pos = f + qsizetype(a * 2 + 0) * kJoints * 4; + float *side = f + qsizetype(a * 2 + 1) * kJoints * 4; + for (int j = 0; j < kJoints; ++j) { + pos[j * 4 + 0] = p[j].x(); + pos[j * 4 + 1] = p[j].y(); + pos[j * 4 + 2] = p[j].z(); + pos[j * 4 + 3] = 1.0f; + side[j * 4 + 0] = s[j].x(); + side[j * 4 + 1] = s[j].y(); + side[j * 4 + 2] = s[j].z(); + side[j * 4 + 3] = 0.0f; + } + } + + setSize(QSize(kJoints, rows)); + setFormat(QQuick3DTextureData::RGBA32F); + setHasTransparency(false); + setTextureData(m_scratch); +} + +} // namespace hyperbin diff --git a/src/effects/SpineTexture.h b/src/effects/SpineTexture.h new file mode 100644 index 0000000..b84de77 --- /dev/null +++ b/src/effects/SpineTexture.h @@ -0,0 +1,35 @@ +// The solved tentacle chains, handed to the vertex shader as a texture. +// +// A texture and not uniforms, and that is forced rather than chosen: +// CustomMaterial has no array uniforms, so sixteen joints across three +// arms would have to be written out as ninety-six separate vector3d +// properties and then read back by an unrolled ladder of comparisons in +// the shader. The gel's eyes are already unrolled that way over fourteen +// slots (qml/OozeEyes.qml) and it is at the limit of what is tolerable. +// +// QQuick3DTextureData is the established way round it here — the bin's own +// artwork arrives the same way, see IconTexture — and it costs almost +// nothing: three arms of sixteen joints, position and frame, is 1.5KB a +// frame against a texture upload path that already exists. +#pragma once + +#include +#include + +namespace hyperbin { + +class TentacleChain; + +class SpineTexture : public QQuick3DTextureData +{ + Q_OBJECT +public: + /// Two rows per arm: joint positions, then the frame across the arm. + /// Laid out so the shader can fetch both with one row offset. + void setChains(const TentacleChain *chains, int count, int maxArms); + +private: + QByteArray m_scratch; +}; + +} // namespace hyperbin diff --git a/src/effects/TentacleEffect.cpp b/src/effects/TentacleEffect.cpp new file mode 100644 index 0000000..9999432 --- /dev/null +++ b/src/effects/TentacleEffect.cpp @@ -0,0 +1,878 @@ +#include "TentacleEffect.h" + +#include "IconTexture.h" +#include "SpineTexture.h" + +#include + +#include +#include + +namespace hyperbin { + +TentacleEffect::TentacleEffect(QObject *parent) + : Effect(parent) + , m_iconTexture(new IconTexture) + , m_spine(new SpineTexture) +{ +} + +TentacleEffect::~TentacleEffect() +{ + delete m_iconTexture; + delete m_spine; +} + +QUrl TentacleEffect::visualSource() const +{ + return QUrl(QStringLiteral("qrc:/qt/qml/hyperbin/qml/TentacleVisual.qml")); +} + +QObject *TentacleEffect::iconTexture() const +{ + return m_iconTexture; +} + +QObject *TentacleEffect::spineTexture() const +{ + return m_spine; +} + +void TentacleEffect::setBinImage(const QImage &img) +{ + m_iconTexture->setImage(img); + emit shapeChanged(); +} + +void TentacleEffect::setBinRect(const QRectF &binRect) +{ + if (m_binRect == binRect) + return; + m_binRect = binRect; + m_binSize = binRect.size(); + emit shapeChanged(); +} + +void TentacleEffect::setSurface(const QVector &, int, int) +{ + // Nothing here reads the silhouette. What this effect needs to know + // about the bin's shape is where its OPENING is, and the coverage + // grid cannot answer that — the near lip runs through the middle of + // the silhouette, not around its edge. See setMouth. +} + +void TentacleEffect::setContentLine(float) +{ + // Where the rubbish starts does not matter to something that comes + // out from under it. +} + +void TentacleEffect::setMouth(const BinMouth &mouth) +{ + m_mouth = mouth; + emit shapeChanged(); +} + +void TentacleEffect::setFullness(float fullness) +{ + m_target = std::clamp(fullness, 0.0f, 1.0f); +} + +void TentacleEffect::setCursor(const QPointF &pos, bool present) +{ + m_cursor = pos; + m_cursorOn = present; +} + +void TentacleEffect::step(float dt) +{ + m_time += dt; + + // Critically damped, so it eases at both ends and never overshoots — + // the same spring OozeSim uses, and for the same reason. An + // exponential follow was here first: it leaves at full speed and + // creeps in, which on something the size of a Dock icon reads as a + // bar filling rather than as something climbing out. A spring starts + // at nothing, gathers pace and settles. + // + // Faster to withdraw than to emerge. Coming out is deliberate; + // going back in is a flinch. + // + // Velocity first, then position — semi-implicit, which stays stable + // at the frame intervals this runs at where the explicit form would + // not. + // The withdrawal's own clock. Starts when the bin is asked to empty + // and something is still out; cleared the moment it refills, so a bin + // that fills again mid-retreat simply carries on rather than finishing + // a flourish nobody asked for. + if (m_target <= 0.0f && m_level > 0.005f) { + if (m_retreat < 0.0f) + m_retreat = 0.0f; + m_retreat += dt; + } else if (m_target > 0.0f) { + m_retreat = -1.0f; + } + constexpr float kEmergeEase = 2.6f; + constexpr float kWithdrawEase = 4.4f; + const float omega = m_target > m_level ? kEmergeEase : kWithdrawEase; + // Frozen for as long as the arms are still making a show of leaving. + // Letting the spring run underneath would drop the arm count out from + // under the flourish and they would vanish one at a time mid-thrash. + const bool holding = m_retreat >= 0.0f && m_retreat < kRetreatHold; + if (m_retreat >= kRetreatAll) { + // The flourish is over and the arms are under the rubbish, so the + // level has no more work to do. Snapped rather than left to the + // spring: the spring took another 0.8s to crawl the last of the + // way, during which nothing was visible and isEmpty() was still + // false, so the whole exit measured 1.9s for 1.1s of animation and + // the teardown's own backstop fired before it finished. + m_level = 0.0f; + m_vel = 0.0f; + } else if (!holding) { + m_vel += (omega * omega * (m_target - m_level) - 2.0f * omega * m_vel) * dt; + m_level += m_vel * dt; + } + // A spring approaches asymptotically and would keep the clock + // running forever chasing the last thousandth. Close and slow is + // arrived — and it has to actually arrive, because reaching zero is + // what lets isEmpty() go true and the overlay be torn down. + if (std::abs(m_target - m_level) < 0.002f && std::abs(m_vel) < 0.02f) { + m_level = m_target; + m_vel = 0.0f; + } + m_level = std::clamp(m_level, 0.0f, 1.0f); + + m_dt = dt; + updateArms(); + emit frameChanged(); + + const bool empty = isEmpty(); + if (empty != m_wasEmpty) { + m_wasEmpty = empty; + emit activityChanged(); + } +} + +/// Same hash the rest of the app uses, so an arm keeps its identity. +static float armHash(float a, float b) +{ + const float s = std::sin(a * 127.1f + b * 311.7f) * 43758.5453f; + return s - std::floor(s); +} + +/// Ease in and out, 0..1. +static float smooth(float t) +{ + t = std::clamp(t, 0.0f, 1.0f); + return t * t * (3.0f - 2.0f * t); +} + +QVector3D TentacleEffect::cursorScene() const +{ + return QVector3D(float(m_cursor.x() - m_binRect.center().x()), + float(m_binRect.center().y() - m_cursor.y()), + 0.0f); +} + +void TentacleEffect::updatePull(float dt) +{ + // Faded out with distance because the host polls the pointer every + // frame and always reports it present — the overlay is click-through + // and gets no enter or leave events — so "present" says nothing at all + // about whether the pointer is anywhere near the bin. Distance is the + // only real signal there is. + const float binH = float(m_binSize.height()); + const QVector3D cursor = cursorScene(); + const float near0 = binH * 0.9f, far0 = binH * 2.4f; + for (int i = 0; i < kMaxTentacles; ++i) { + const float away = (cursor - armBase(i)).length(); + const float raw = m_cursorOn + ? 1.0f - smooth((away - near0) / std::max(far0 - near0, 1.0f)) + : 0.0f; + // About a sixth of a second to notice, and the same to forget. + m_pull[i] += (raw - m_pull[i]) * std::min(1.0f, dt * 6.0f); + } +} + +QVector3D TentacleEffect::armEmerge(int i) const +{ + const Seat &seat = kSeats[i]; + return QVector3D(std::sin(seat.strike) * 0.22f, 1.0f, + std::cos(seat.strike) * 0.10f).normalized(); +} +QVector3D TentacleEffect::armBase(int i) const +{ + const Seat &seat = kSeats[i]; + const float binH = float(m_binSize.height()); + const float sa = armHash(float(i), 5.0f); + // Slid along the arm's OWN axis, not sideways. Two rates that do not + // divide into each other, so an arm never returns to the same + // extension twice; the slower one is the breath and the quicker one is + // what makes it read as slithering rather than rising. + const float slide = 0.60f * std::sin(m_time * 0.52f + sa * 6.28318531f) + + 0.40f * std::sin(m_time * 1.27f + sa * 3.11f); + const QVector3D root(mouthX() + seat.lateral * mouthRadius(), + rootY() - seat.sink * binH, + seat.depth * mouthReachZ()); + return root + armEmerge(i) * (slide * kRootSlide * binH); +} +float TentacleEffect::moveProgress(int i) const +{ + return std::clamp(m_state[i].t / std::max(m_state[i].duration, 0.01f), 0.0f, 1.0f); +} +void TentacleEffect::advanceMove(int i, float dt) +{ + ArmState &st = m_state[i]; + st.t += dt; + if (st.t < st.duration) + return; + // Pick the next one. Weighted by hand rather than uniformly: Idle is + // the resting state and has to be common or the bin looks frantic, and + // an arm that has just done something showy should settle before doing + // another. Never the same move twice running, which is the cheapest + // way to stop a coincidence of timing reading as a pattern. + const Move was = st.move; + st.t = 0.0f; + st.seed = armHash(float(i), m_time * 0.37f + 11.0f); + // Chosen here, once, and then held for the whole move. See ArmState::aim. + st.aim = kSeats[i].strike + + kSeats[i].swing * (armHash(float(i) + 0.25f, m_time * 0.91f + 7.0f) + * 2.0f - 1.0f); + // HYPERBIN_FORCE_MOVE= pins every arm to one move, in the order the + // enum declares them. Without it a move cannot be verified at all: the + // vocabulary is picked by a hash, so catching a particular one in a + // grab-and-exit run is luck, and "it looked right when I happened to + // see one" is precisely how the fruit rollup shipped invisible for two + // rounds — it was being computed and then erased every frame, and every + // screenshot that would have shown it was of some other move. + // + // ALTERNATED WITH IDLE, which is not a detail. Run back to back a move + // never starts from rest — it starts from wherever the last instance of + // itself left the arm — so the opening of the move cannot be observed + // at all. That is not hypothetical: it read the slap's wind-up as + // moving the arm OUTWARD, the exact opposite of what it does, because + // what the frames actually showed was the previous slap still + // recovering. + if (Q_UNLIKELY(qEnvironmentVariableIsSet("HYPERBIN_FORCE_MOVE"))) { + const Move want = Move(qEnvironmentVariableIntValue("HYPERBIN_FORCE_MOVE")); + st.move = was == want ? Move::Idle : want; + st.duration = st.move == Move::Idle ? 2.0f : 2.6f; + return; + } + const float r = armHash(float(i) + 0.5f, m_time * 0.61f + 3.0f); + // Gated on the pointer being NEAR, not on it existing. + // + // This read `m_cursorOn`, which is the host's "present" flag and is + // unconditionally true — so better than half of every arm's move picks + // were a lunge at the pointer whatever the pointer was doing, including + // sitting on the far side of the screen. Reach's own ramp is not + // distance-faded either, so those lunges went to full extension. That + // is a large part of what read as the poses fighting each other. + const float pull = m_pull[i]; + if (pull > 0.25f && r < 0.55f) { + st.move = Move::Reach; + st.duration = 1.6f + 1.4f * st.seed; + } else if (was != Move::Idle && r < 0.45f) { + st.move = Move::Idle; + st.duration = 1.8f + 2.2f * st.seed; + } else if (r < 0.62f) { + st.move = Move::Slap; + // Long enough to have parts. See the envelope in armTarget: a + // wind-up, a strike, a stretch of crawling along the bin and a slow + // recovery do not fit in the 1.15s this used to run in — at that + // length the whole thing was a twitch. + st.duration = 2.6f + 0.9f * st.seed; + } else if (r < 0.78f) { + st.move = Move::Coil; + st.duration = 2.4f + 1.2f * st.seed; + } else if (r < 0.79f) { + st.move = Move::Wrap; + st.duration = 3.0f + 1.5f * st.seed; + } else if (r < 0.93f) { + st.move = Move::Roll; + st.duration = 3.2f + 1.4f * st.seed; + } else { + st.move = Move::Idle; + st.duration = 1.8f + 2.2f * st.seed; + } + if (st.move == was && st.move != Move::Idle) { + st.move = Move::Idle; + st.duration = 1.6f; + } +} +QVector3D TentacleEffect::armTarget(int i, float &flex, float &maxBend) const +{ + const Seat &seat = kSeats[i]; + const ArmState &st = m_state[i]; + const float sa = armHash(float(i), 1.0f); + const float sb = armHash(float(i), 2.0f); + const QVector3D base = armBase(i); + const float L = armLength() * seat.size; + const float binH = float(m_binSize.height()); + const float u = moveProgress(i); + flex = 1.0f; + maxBend = TentacleChain::kMaxBend; + // Which way it lashes. Wandering, so a move is not always along the + // same line; the back arm's wander is wide enough to take it over + // either side of the bin -- see kSeats. + const float dir = seat.strike + + seat.swing * std::sin(m_time * 0.37f + sb * 6.28f); + // FLATTENED IN Z, and deliberately. The camera is orthographic and + // barely tilted, so movement toward it is nearly invisible on screen + // while still being movement -- an arm leaning out along its own radius + // spent most of its travel coming at the viewer, where it read as + // barely moving and yet swung far enough forward to be legitimately in + // FRONT of the bin, which broke the occlusion for a gesture nobody + // could see. Biasing the reach into the screen plane costs nothing + // that shows and keeps the arms where the mask expects them. + const QVector3D out(std::sin(dir), 0.0f, std::cos(dir) * 0.30f); + // Near FULL STRETCH, not comfortably inside it. The first attempt put + // this at two thirds of the arm's reach on the reasoning that slack is + // what makes a chain hang in a curve. It is not: FABRIK has no notion + // of an arm being a physical object, so slack is simply folded away, + // and three arms came out as crumpled self-intersecting stubs. The + // curve comes from the bend limit and the wave; the target's job is to + // keep the arm extended enough to have a shape at all. + const QVector3D idle = base + + QVector3D(0.0f, 1.0f, 0.0f) * (L * (0.80f + 0.07f * std::sin(m_time * 0.43f + sa * 6.28f))) + // Sideways reach scaled to the BIN, not to the arm. Scaled to the + // arm it was 290 units on a bin only 160 deep, so an idle wave + // carried the tip clear past the bin's own footprint. + // Modest, because clamping the TARGET is not enough on its own: + // the chain bows, so a tip pulled back to the rim still leaves the + // arm's middle swinging well outside it. Measured with the target + // clamped to 1.18 opening-radii, arms still overhung the artwork + // by up to 219px on a 444px bin. The reach has to be smaller at + // source, not corrected afterwards. + + out * (mouthRadius() * seat.lean + * (0.36f + 0.16f * std::sin(m_time * 0.31f + sb * 6.28f))); + // Where the pointer is, in scene units, and how much to care. + // + // CONTINUOUS, and that is the fix rather than a tuning: reaching used + // to be a move, so an arm only noticed the pointer when its previous + // move happened to end AND a dice roll came up, which is an occasional + // lunge and reads as not tracking at all. Every arm now leans a little + // toward a nearby pointer whatever else it is doing, and the Reach + // move is the stronger version on top. + // + // How much it cares is measured once a frame and low-passed — see + // updatePull, which also records why "present" is not the question. + const QVector3D cursor = cursorScene(); + const float pull = m_pull[i]; + // Clamped to the arm's own reach, or an arm asked for something across + // the screen simply points at it and stops looking like an arm. + QVector3D toward = cursor; + const QVector3D rel = cursor - base; + if (rel.length() > L * 0.95f) + toward = base + rel.normalized() * (L * 0.95f); + // The move poses the arm from its plain drift. The cursor is applied + // AFTERWARDS — see the blend at the bottom — because it has to be able + // to override a move rather than be overridden by one. + // + // It used to be folded in here, into a `leaned` pose that every move + // then blended away FROM: a slap ramping to full took the arm from the + // pointer back to the bin, so hovering while anything was mid-move + // looked like the two were fighting. They were, and the move won. + const QVector3D leaned = idle; + QVector3D posed = leaned; + switch (st.move) { + case Move::Idle: + posed = leaned; break; + case Move::Slap: { + // FOUR PARTS: wind up, strike, crawl, let go. The earlier version + // had two — out over a sixth of the move and back over a third — + // which is a twitch, and the note it replaced complained about the + // opposite problem, an arm that looked STUCK to the bin. Both are + // the same mistake read from either end: contact with no travel in + // it is either too long or too short and there is no length that + // makes it good. Contact that MOVES has somewhere to spend the + // time. + // + // 0.00 wind the arm draws back off the bin before it hits + // 0.14 strike fast, and the only fast part + // 0.24 crawl pinned to the wall, dragging down and around it + // 0.66 lift slowly off, no rebound + constexpr float kWind = 0.14f, kStrike = 0.24f, kCrawl = 0.56f; + // How far along the wall the arm has dragged, 0..1. Wanted below + // as well as here, so it is computed whatever phase we are in. + const float crawl = + std::clamp((u - kStrike) / (kCrawl - kStrike), 0.0f, 1.0f); + float hit; + if (u < kWind) { + // ANTICIPATION. Negative, so the arm goes PAST its resting pose + // in the direction away from the wall — the pose is a blend + // from rest toward the wall, so a negative coefficient is a + // reach backwards along the same line and costs nothing to + // express. Nothing in animation reads as intent without it: + // a strike that begins at the moment of the strike looks like + // the arm was pushed, not like it decided. + hit = -0.26f * smooth(u / kWind); + } else if (u < kStrike) { + const float r = (u - kWind) / (kStrike - kWind); + hit = -0.26f + 1.26f * smooth(r); + } else if (u < kCrawl) { + hit = 1.0f; + } else { + // Off slowly, and with no recoil. The rebound that used to be + // here is the right ending for a hit-and-away, and the wrong + // one for a hit that has been resting on the bin for the best + // part of a second: something that has settled onto a surface + // peels off it. + // + // SQUARED, not smoothstep, and the difference is the whole + // ending. Smoothstep is slow-fast-slow, so it puts its speed in + // the middle of the release — and the chain's own lag then + // carries that peak later still, which measured as the arm + // hanging on the bin for most of the recovery and then hurrying + // up in the last tenth. Squared is fast-then-slow: the arm + // unsticks promptly and drifts the rest of the way, and the lag + // now has slack behind it to be absorbed into instead of a + // deadline to miss. + const float r = (u - kCrawl) / (1.0f - kCrawl); + hit = (1.0f - r) * (1.0f - r); + } + // Rigid through the strike, then loosened again while it crawls. + // This is what the slither is actually made of — a wave running + // down an arm that is pinned at the far end has to travel as a + // ripple ALONG the surface, because the ends cannot move. Held + // rigid the whole time the arm was a bar leaning on the bin. + flex = 1.0f - 0.75f * std::max(hit, 0.0f) + + 0.60f * smooth(std::min(crawl * 2.2f, 1.0f)) + * (1.0f - smooth(std::max((u - kCrawl) / 0.2f, 0.0f))); + // WELL BELOW the contact point, not on it. Aimed AT the wall the + // arm arrives pointing at it and pokes; aimed at a place further + // down, the last joints have to run along the wall to get there + // and the arm lands on its flat instead of its tip. That is the + // whole difference between prodding the bin and hitting it, and it + // is a property of where the target is, not of the solver. + // + // ON the wall at the depth it actually lands, not at a fixed + // radius. The bin narrows going down, so a constant lateral target + // put the tip outside a wall that was no longer there, and the + // mask -- which does know about the taper -- correctly drew it in + // front of the bin. Measured, 9,132 leaked pixels on the deepest + // slap. Both sides now read the same profile. + // + // And it TRAVELS. The contact point starts high on the wall where + // the strike lands and works its way down and across while the arm + // holds on. Because binHalfWidthAt is re-read at the new height, + // the tip stays on the taper as it descends instead of drifting + // off a wall that is narrowing under it. + // + // Aimed with st.aim, FROZEN when the move began, rather than with + // the wandering `out` the resting pose uses — see ArmState::aim for + // the teleport that costs. + const QVector3D aim(std::sin(st.aim), 0.0f, std::cos(st.aim) * 0.30f); + const float wallY = rootY() - binH * (0.44f + 0.26f * crawl); + const float sway = std::sin(crawl * 2.9f + sb * 6.28f); + const QVector3D wall( + std::copysign(binHalfWidthAt(wallY) * (0.98f - 0.06f * crawl), + aim.x()), + wallY, + (aim.z() + 0.42f * sway) * mouthReachZ() * 0.55f); + posed = leaned + (wall - leaned) * hit; + break; + } + case Move::Coil: { + // Curled right up, tip brought back near its own root. Needs the + // bend limit lifted: sixteen joints at the default cannot close a + // circle, so without this the arm merely hooks. + const float c = smooth(std::min(u * 2.0f, 1.0f)) + * (1.0f - smooth(std::max((u - 0.72f) / 0.28f, 0.0f))); + maxBend = TentacleChain::kMaxBend + + (TentacleChain::kCoilBend - TentacleChain::kMaxBend) * c; + flex = 1.0f - 0.55f * c; + const QVector3D knot = base + + QVector3D(0.0f, 1.0f, 0.0f) * (L * 0.16f) + + out * (mouthRadius() * 0.35f); + posed = leaned + (knot - leaned) * c; + break; + } + case Move::Wrap: { + // The tip travels ROUND the bin rather than to a point on it, so + // the arm lies along the rim instead of across it. A target that + // moves is the only way to get a path out of a solver that only + // knows about destinations. + const float turn = (sa * 6.28318531f) + u * 3.4f; + const float ring = smooth(std::min(u * 3.0f, 1.0f)) + * (1.0f - smooth(std::max((u - 0.78f) / 0.22f, 0.0f))); + flex = 1.0f - 0.45f * ring; + const float ringY = rootY() - binH * (0.10f + 0.16f * ring); + const QVector3D around(std::sin(turn) * binHalfWidthAt(ringY) * 0.98f, + ringY, + std::cos(turn) * mouthReachZ() * 0.85f); + posed = leaned + (around - leaned) * ring; + break; + } + case Move::Roll: { + // The shape comes from curlUp, applied after the solve. What the + // target has to do is AGREE with it. + // + // It used to be the plain resting pose, which asks for an arm at + // 0.80 of full stretch — so every frame the solver straightened the + // arm out to reach up there and curlUp then rewrote it back into a + // spiral, and the two took turns. That fight is most of what the + // roll's jitter was; the axis flip (see TentacleChain::curlUp) was + // the rest. A rolled-up arm's tip is near its own base, so the + // target is drawn in as the roll tightens and the solver arrives at + // roughly the pose curlUp is about to impose. + // + // The bend limit HAS to come up with it, and this is why the roll + // was invisible: curlUp lays a spiral turning up to 0.75 radians a + // joint, and the settle pass that follows re-applies the limit -- + // which was still the default 0.384 -- and quietly straightened + // the whole thing back out every frame. The pose was being + // computed and then erased. + flex = 0.45f; + // Tight enough to actually be a roll. At 0.85 the arm managed + // about 215 degrees -- a hook, not a spiral. A curl only reads as + // rolled up once it passes a full turn, and the limit on how tight + // that can get is geometric: the last N segments closing a circle + // need 2pi/N per joint, so five joints is 72 degrees each and + // anything past that has segments doubling back through their own + // neighbours. + maxBend = 1.32f; + { + const float c = rollAmount(i); + const QVector3D gathered = base + + QVector3D(0.0f, 1.0f, 0.0f) * (L * 0.44f) + + out * (mouthRadius() * 0.22f); + posed = leaned + (gathered - leaned) * c; + } + break; + } + case Move::Reach: { + // The full lunge, on top of the lean every arm already has. The + // pointer arrives in the host item's pixels with +y DOWN and the + // scene is bin-local with +y UP, which is the same conversion the + // mouth measurements get. + // + // Faded by `pull` like everything else that chases the pointer. It + // was not, and the lunge therefore went to full extension at a + // target clamped to the arm's reach in the pointer's direction — + // which for a pointer on the far side of the screen is simply the + // arm pointing off at nothing. An arm mid-Reach when the pointer + // walks away now relaxes instead of holding the point. + const float ring = smooth(std::min(u * 2.5f, 1.0f)) + * (1.0f - smooth(std::max((u - 0.80f) / 0.20f, 0.0f))); + posed = leaned + (toward - leaned) * (ring * pull); + break; + } + } + // The pointer has the last word. + // + // Applied over whatever the move decided, at nearly full authority, so + // a hovering pointer visibly commands the arms instead of negotiating + // with them. Not 1.0: leaving a little of the pose in stops the arms + // converging into one bundle at the cursor, and lets a strike still + // read as a strike while it happens to be aimed nearby. + constexpr float kCursorAuthority = 0.88f; + return posed + (toward - posed) * (pull * kCursorAuthority); +} +QVector3D TentacleEffect::retreatTarget(int i, float &flex, float &sink) const +{ + const float L = armLength() * kSeats[i].size; + const float binH = float(m_binSize.height()); + const QVector3D base = armBase(i); + const float t = m_retreat; + // Up. Straight up, at full stretch, whatever it was doing -- the arm + // gathers itself before it goes. + const float rise = smooth(std::min(t / kRetreatRise, 1.0f)); + // ...and thrashes there. The wave is what sells it, so this drives the + // wave hard rather than moving the target about: a target that shakes + // just drags the whole arm, where a fast wave whips its length. + const float thrash = t < kRetreatRise ? 0.0f + : smooth(std::min((t - kRetreatRise) / 0.15f, 1.0f)) + * (1.0f - smooth(std::max((t - kRetreatHold) / 0.25f, 0.0f))); + flex = 1.0f + 5.5f * thrash; + // Then down, past the root, out of sight. Eased in so it drops rather + // than starting at speed. + sink = t <= kRetreatHold ? 0.0f + : smooth(std::clamp((t - kRetreatHold) / kRetreatSink, 0.0f, 1.0f)); + const float wobble = 0.10f * std::sin(m_time * 9.0f + float(i) * 2.1f) * thrash; + const QVector3D up = base + QVector3D(wobble * L, L * (0.94f * rise), 0.0f); + // Going in, the arm COILS rather than translating. Dropping the whole + // chain by its own length does put it out of sight, but out of sight + // below the BIN — the mask only covers the artwork's own rectangle, so + // an arm pushed through the floor reappears underneath it. Measured, + // 26,766 pixels of arm hanging below the bin. Pulling the tip back to + // its own root instead gathers the arm into a knot that fits inside + // the rubbish, where the mask already hides it. + return up + (base - up) * sink; +} +QVector3D TentacleEffect::keepNear(const QVector3D &t) const +{ + // How far sideways an arm may reach, measured from the bin's AXIS + // rather than from the arm's own root. The idle reach was a radius + // added to a root that already sat 0.62 of the way out, so the outer + // pair could finish nearly two opening-radii from the centre -- well + // clear of the artwork, waving at nothing. Clamped, not scaled, so the + // arms nearest the middle still get their full travel. + const float limit = mouthRadius() * 0.82f; + const float r = std::hypot(t.x() - mouthX(), t.z()); + if (r <= limit || r < 1e-4f) + return t; + const float k = limit / r; + return QVector3D(mouthX() + (t.x() - mouthX()) * k, t.y(), t.z() * k); +} +QVector3D TentacleEffect::keepInFront(const QVector3D &t) const +{ + // Below the rim there is no "inside the bin" for an arm to reach: it + // came out of the opening, so anything lower than the lip has to be + // in FRONT of the body. Without this, a reach downward aimed straight + // down from the mouth put the tip at the bin's own depth, the mask + // correctly decided the front wall was in the way, and the arm + // vanished into the bin exactly when it was most visible. + // + // Faded in over the lip rather than switched at it, so an arm crossing + // the rim eases forward instead of jumping. + const float binH = float(m_binSize.height()); + // Faded over a THIRD of the bin, not a tenth. A slap drives its + // target down about thirty units a frame, so a ramp this short + // finished in two frames and the target leapt 150 units forward in z + // while it did — measured, a 91-unit jump every slap, and the arm + // lurched over 100 units chasing it. The ramp has to be long compared + // to how fast a target crosses it, not compared to the bin. + const float under = std::clamp((mouthY() - t.y()) / (binH * 0.35f), 0.0f, 1.0f); + const float front = mouthReachZ() * 1.10f; + return QVector3D(t.x(), t.y(), std::max(t.z(), front * under)); +} +float TentacleEffect::binHalfWidthAt(float y) const +{ + const float binH = float(m_binSize.height()); + const float below = std::clamp((mouthY() - y) / std::max(binH, 1e-4f), 0.0f, 1.0f); + return mouthRadius() * (kBodyTop + (kBodyFoot - kBodyTop) * below); +} +float TentacleEffect::rollAmount(int i) const +{ + if (m_state[i].move != Move::Roll) + return 0.0f; + // In over the first third, held, out over the last quarter, so it + // rolls up, sits curled a moment, and unrolls. + const float u = moveProgress(i); + const float c = smooth(std::min(u / 0.34f, 1.0f)) + * (1.0f - smooth(std::max((u - 0.76f) / 0.24f, 0.0f))); + // UNROLLED BY THE POINTER, and this is the reconciliation rather than a + // priority. + // + // Following the pointer and being rolled into a spiral are not two + // strengths of the same thing that can be blended: one is a statement + // about where the tip goes, the other about the curvature of every + // joint, and applying both at once is the solver and curlUp overwriting + // each other frame by frame. That is what the jitter was. + // + // So the roll yields. The pointer keeps precedence — the arm visibly + // uncurls and comes to it, which is a better answer than either winning + // outright, because the unrolling IS the reaction. + return c * (1.0f - m_pull[i]); +} +float TentacleEffect::mouthReachZ() const +{ + const float s = std::sin(m_cameraTilt * 3.14159265f / 180.0f); + return mouthDepth() / std::max(0.05f, s); +} +void TentacleEffect::setCameraTilt(float degrees) +{ + if (qFuzzyCompare(m_cameraTilt, degrees)) + return; + m_cameraTilt = degrees; + emit shapeChanged(); +} +void TentacleEffect::updateArms() +{ + const int n = count(); + // Before anything reads it: the moves are chosen from it and the roll + // is faded by it. + updatePull(m_dt); + for (int i = 0; i < n; ++i) { + advanceMove(i, m_dt); + const QVector3D base = armBase(i); + const float want = armLength() * kSeats[i].size; + // Rebuilt only when the arm's length actually changes -- which is + // when the bin resizes, and never per frame. Resetting a chain + // throws away the state that makes it lag, and a chain with no lag + // is the arc again. + if (!m_chain[i].valid() || std::abs(m_chain[i].length() - want) > 0.5f) + m_chain[i].reset(base, QVector3D(0, 1, 0), want); + float flex = 1.0f; + float maxBend = TentacleChain::kMaxBend; + QVector3D target; + float sink = 0.0f; + if (m_retreat >= 0.0f) { + // Reined in on the way out too. An arm caught mid-wrap was + // out beyond the bin when the exit began and stayed there + // while it sank, so the last thing on screen was a tentacle + // hanging outside the bin rather than going into it. + target = keepNear(retreatTarget(i, flex, sink)); + } else { + target = keepInFront(keepNear(armTarget(i, flex, maxBend))); + } + // Leaves the hole going UP and a little outward, whatever it is + // reaching for. See TentacleChain::kRootHeld. + const QVector3D emerge = armEmerge(i); + // Drawn down into the rubbish on the way out, by a fraction of + // the bin rather than by the arm's length -- far enough to be + // under the heap, never far enough to come out of the bottom. + const QVector3D sunk = + base - emerge * (sink * float(m_binSize.height()) * 0.30f); + target -= emerge * (sink * float(m_binSize.height()) * 0.30f); + // Allowed to fold up tightly while it does, or a straight arm + // simply pivots instead of gathering itself in. + if (sink > 0.0f) + maxBend = TentacleChain::kMaxBend + + (TentacleChain::kCoilBend - TentacleChain::kMaxBend) * sink; + // HYPERBIN_DEBUG: shout when a target teleports. A move change is + // meant to be continuous -- every move begins and ends at the + // resting pose precisely so the arm is never asked to be somewhere + // else in one frame. + if (Q_UNLIKELY(qEnvironmentVariableIsSet("HYPERBIN_DEBUG"))) { + const float jump = (target - m_lastTarget[i]).length(); + if (jump > float(m_binSize.height()) * 0.25f && m_lastTarget[i] != QVector3D()) + qInfo("tentacle %d: TARGET jumped %.0f (move %d)", + i, double(jump), int(m_state[i].move)); + m_lastTarget[i] = target; + } + m_chain[i].solve(sunk, emerge, target, m_time, + armHash(float(i), 3.0f) * 6.28318531f, flex, maxBend); + m_chain[i].curlUp(sunk, emerge, 1.30f, rollAmount(i)); + // Out of the bin, then lengths fixed again -- pushing joints + // sideways stretches the segments that reach them. + m_chain[i].pushOutside(mouthY(), float(m_binSize.height()), + mouthRadius(), mouthReachZ(), kBodyFoot / kBodyTop, + TentacleChain::kRootHeld + 1); + m_chain[i].settle(sunk, emerge, maxBend); + } + // ...and shout when the ARM itself lurches, which is the symptom + // rather than its cause: a target that teleports is one thing, a chain + // that thrashes while its target sits still is quite another. + if (Q_UNLIKELY(qEnvironmentVariableIsSet("HYPERBIN_DEBUG"))) { + for (int i = 0; i < n; ++i) { + float worst = 0.0f; + if (m_lastJoints[i][0] != QVector3D() || m_lastJoints[i][1] != QVector3D()) { + for (int j = 0; j < TentacleChain::kJoints; ++j) + worst = std::max(worst, + (m_chain[i].joints()[j] - m_lastJoints[i][j]).length()); + if (worst > float(m_binSize.height()) * 0.10f) + qInfo("tentacle %d: JOINT lurched %.0f in one frame (move %d)", + i, double(worst), int(m_state[i].move)); + // HYPERBIN_TRACE: every frame, not just the alarming ones. + // The threshold above counts events, and a count is the + // wrong statistic for comparing two versions of a move — + // lengthen a move and it accrues more of them while being + // calmer per frame. This once said a re-timed slap was + // twice as bad when its per-frame rate had in fact more + // than halved. + if (Q_UNLIKELY(qEnvironmentVariableIsSet("HYPERBIN_TRACE"))) + qInfo("F %d %d %.3f %.2f bank %.2f", i, int(m_state[i].move), + double(moveProgress(i)), double(worst), + double(m_chain[i].roll() * 180.0f / 3.14159265f)); + } + for (int j = 0; j < TentacleChain::kJoints; ++j) + m_lastJoints[i][j] = m_chain[i].joints()[j]; + } + } + m_spine->setChains(m_chain, n, kMaxTentacles); +} +QVariantList TentacleEffect::seats() const +{ + QVariantList out; + out.reserve(kMaxTentacles); + for (const Seat &s : kSeats) + out.append(QVariant::fromValue(QVector4D(s.lateral, s.depth, s.size, 0.0f))); + return out; +} + +float TentacleEffect::armLength() const +{ + return kArmLength * float(m_binSize.height()); +} + +int TentacleEffect::count() const +{ + if (isEmpty()) + return 0; + if (m_retreat >= 0.0f && m_retreat < kRetreatAll) + return kMaxTentacles; + return std::clamp(int(std::lround(m_level * kMaxTentacles)), 1, kMaxTentacles); +} + +QMargins TentacleEffect::margins(qreal iconSize) const +{ + // Nearly all of it above. These reach UP out of the bin and wave + // about; anything drawn past the window's edge is clipped and reads + // as broken, and the window is sized from this. + // + // The top has to cover an arm standing straight up, which is its root + // (a third of the way up the bin) plus its whole length (kArmLength, + // 1.15 bin heights) plus what the sway adds, less the half of the bin + // that is already inside the rect. At 1.6 there is room for the sway + // and for a longer arm than the current one before this has to be + // revisited. Fill costs effectively nothing at this size — see + // docs/battery.md — so the margin is cheap and clipping is not. + return QMargins(int(iconSize * 0.9), int(iconSize * 1.6), + int(iconSize * 0.9), int(iconSize * 0.15)); +} + +// --- the mouth, in scene units ------------------------------------------ +// +// The measurement arrives as a fraction of the icon's tile with +y DOWN +// from its top; the scene is bin-local pixels with the origin at the +// rect's centre and +y UP. Converted here, once, so no caller has to +// remember which way round either convention runs. + +float TentacleEffect::mouthX() const +{ + return float(m_mouth.centre.x() - 0.5) * float(m_binSize.width()); +} + +float TentacleEffect::mouthY() const +{ + // Divided by cos(tilt), and that is not a fudge — it is undoing the + // projection the measurement already went through. + // + // The mouth's height arrives as a fraction of the icon, i.e. a position + // ON SCREEN. Putting that number straight into the scene forgets that + // the camera then foreshortens y by cos(tilt), so the scene's mouth + // landed 4.4% lower on screen than the artwork's. Nine pixels on a + // 600px bin, which does not sound like much until you notice what it + // is nine pixels OF: the mask compares an arm against the measured lip + // (a screen curve) and against the bin's front face (a scene surface), + // and those two only meet at the rim if the rim is in the same place in + // both. It was not, so a thin band around the rim was judged below the + // lip and behind the wall at the same time, and arms crossing it were + // clipped. + // + // Everything derived from this — the roots, the heap, the shading + // reference — moves with it, so the shapes are unchanged; they are all + // simply nine pixels further up, where the artwork says they are. + const float c = std::cos(m_cameraTilt * 3.14159265f / 180.0f); + return float(0.5 - m_mouth.centre.y()) * float(m_binSize.height()) + / std::max(0.2f, c); +} + +float TentacleEffect::mouthRadius() const +{ + return m_mouth.halfWidth * float(m_binSize.width()); +} + +float TentacleEffect::mouthDepth() const +{ + return m_mouth.depth * float(m_binSize.height()); +} + +float TentacleEffect::heapTopY() const +{ + return mouthY() + kHeapRise * float(m_binSize.height()); +} + +float TentacleEffect::heapFloorY() const +{ + return mouthY() - kHeapSink * float(m_binSize.height()); +} + +float TentacleEffect::rootY() const +{ + return mouthY() - kRootDepth * float(m_binSize.height()); +} + +} // namespace hyperbin diff --git a/src/effects/TentacleEffect.h b/src/effects/TentacleEffect.h new file mode 100644 index 0000000..30acb5b --- /dev/null +++ b/src/effects/TentacleEffect.h @@ -0,0 +1,485 @@ +// Tentacles reaching out of the bin. +// +// Three arms seated as a triangle in the measured opening. Each is a joint +// chain solved by FABRIK — core/TentacleChain — rather than the circular +// arc this used to bend, which is the difference between an arm that has a +// shape and one that has an angle. +// +// The masking is the other half, and it needs no depth proxy and no model +// of the bin. A fragment is thrown away where it falls below the mouth's +// near lip with the bin's body between it and the eye, or behind the +// rubbish filling the opening — see shaders/tentacle.frag, which also +// records the version that did this by repainting the bin over the top +// instead, and why cutting our own geometry is the better side of the join +// to work on. +#pragma once + +#include "../core/Effect.h" +#include "../core/TentacleChain.h" + +#include +#include +#include +#include +#include + +namespace hyperbin { + +class IconTexture; +class SpineTexture; + +class TentacleEffect : public Effect +{ + Q_OBJECT + Q_PROPERTY(float time READ time NOTIFY frameChanged) + Q_PROPERTY(float level READ level NOTIFY frameChanged) + Q_PROPERTY(int count READ count NOTIFY frameChanged) + /// How many the scene should build. CONSTANT, and it matters that it + /// is: the scene builds this many once and hides the ones that are + /// not out. Driving a Repeater3D from `count` instead rebuilds every + /// delegate on the frame the count moves, which it does all the way + /// through emerging — the ooze bubbles did exactly that and cost four + /// times the whole effect before they were removed. + Q_PROPERTY(int maxTentacles READ maxTentacles CONSTANT) + Q_PROPERTY(QSizeF binSize READ binSize NOTIFY shapeChanged) + Q_PROPERTY(QRectF binRect READ binRect NOTIFY shapeChanged) + Q_PROPERTY(QObject *iconTexture READ iconTexture NOTIFY shapeChanged) + + /// The opening, in the SCENE's units: bin-local pixels, origin at the + /// centre of the bin's rect, +y up. Converted here rather than in QML + /// so the scene never has to know that the measurement arrives as a + /// fraction of the icon and with +y down. + Q_PROPERTY(float mouthX READ mouthX NOTIFY shapeChanged) + Q_PROPERTY(float mouthY READ mouthY NOTIFY shapeChanged) + Q_PROPERTY(float mouthRadius READ mouthRadius NOTIFY shapeChanged) + Q_PROPERTY(float mouthDepth READ mouthDepth NOTIFY shapeChanged) + /// The same opening again, left as FRACTIONS of the icon. + /// + /// Both forms are wanted and neither can serve for the other: the + /// scene places tentacles in its own pixels, and the mask works in + /// the icon's texture coordinates, where the lip's ellipse is + /// compared against a sampled alpha. + Q_PROPERTY(float mouthCentreX READ mouthCentreX NOTIFY shapeChanged) + Q_PROPERTY(float mouthCentreY READ mouthCentreY NOTIFY shapeChanged) + Q_PROPERTY(float mouthHalfWidth READ mouthHalfWidth NOTIFY shapeChanged) + Q_PROPERTY(float mouthDepthFraction READ mouthDepthFraction NOTIFY shapeChanged) + Q_PROPERTY(bool mouthMeasured READ mouthMeasured NOTIFY shapeChanged) + /// Every arm's SOLVED CHAIN, as texture data. Two rows per arm: joint + /// positions in scene units, then the frame across the arm. + /// + /// This replaced a pair of angles describing a circular arc. An arc is + /// two numbers for a whole arm and has constant curvature by + /// definition, which is precisely why the old motion read as stiff — + /// see core/TentacleChain, which also records where the technique + /// comes from and what in it actually does the work. + Q_PROPERTY(QObject *spineTexture READ spineTexture NOTIFY shapeChanged) + /// Joints per arm, so the shader knows the texture's width without + /// being told twice. + Q_PROPERTY(int jointCount READ jointCount CONSTANT) + + /// Where each arm SITS, as (lateral, depth, size, 0). Constant. + /// + /// Only `size` is still read by the scene, for the cross-section: the + /// other two are now consumed here, because with a chain the arm's + /// whole shape is solved in scene units and the model no longer + /// carries a transform of its own. + Q_PROPERTY(QVariantList seats READ seats CONSTANT) + + /// The scene camera's downward tilt, degrees. WRITTEN BY THE SCENE. + /// + /// Needed here because the opening is measured on screen and the + /// chains are solved in the scene: turning the mouth's on-screen + /// half-height into a reach in z divides by the sine of this. Pushed + /// in rather than duplicated as a constant, so there is one camera + /// tilt in the app and not two that can drift apart. + Q_PROPERTY(float cameraTilt READ cameraTilt WRITE setCameraTilt + NOTIFY shapeChanged) + + /// How long an arm is, in scene units. Derived from the bin, because + /// what an arm has to be able to do is reach over the rim and down + /// the outside, and how far that is is set by the bin's own size. + Q_PROPERTY(float armLength READ armLength NOTIFY shapeChanged) + + /// The RUBBISH, as a place in the scene rather than as a picture. + /// + /// Both in scene units, +y up from the centre of the bin's rect: the + /// top of the heap and the level below which it is simply solid. The + /// heap is what an arm at the back of the mouth has to pass behind, + /// and it cannot do that against something with no depth — see + /// shaders/tentacle.frag. + Q_PROPERTY(float heapTopY READ heapTopY NOTIFY shapeChanged) + Q_PROPERTY(float heapFloorY READ heapFloorY NOTIFY shapeChanged) + /// How high an arm's root sits, scene units. Below the opening: it is + /// meant to be buried in the rubbish. + Q_PROPERTY(float rootY READ rootY NOTIFY shapeChanged) + /// The opening's reach into the scene along z, scene units. + /// + /// The measurement is the on-screen half-height of what is really a + /// circle, so recovering the circle divides by the sine of the + /// camera's tilt. Exposed rather than recomputed in QML: it was, and + /// when the delegate that owned the tilt was rewritten the expression + /// silently became NaN, which turned every occlusion test false and + /// let arms draw straight through the bin. + Q_PROPERTY(float mouthReachZ READ mouthReachZ NOTIFY shapeChanged) + /// How the bin's body narrows below the lip, as shares of the opening + /// at the rim and near the foot. Owned here and pushed to the shader + /// rather than written down in both places: the mask compares an arm + /// against this profile and the strike AIMS at it, and the two + /// disagreeing puts a tip through the wall it was supposed to land on. + Q_PROPERTY(float bodyTaperTop READ bodyTaperTop CONSTANT) + Q_PROPERTY(float bodyTaperFoot READ bodyTaperFoot CONSTANT) + +public: + explicit TentacleEffect(QObject *parent = nullptr); + ~TentacleEffect() override; + + void setBinRect(const QRectF &binRect) override; + void setSurface(const QVector &coverage, int w, int h) override; + void setFullness(float fullness) override; + void setCursor(const QPointF &pos, bool present) override; + void setContentLine(float y01) override; + void setMouth(const BinMouth &mouth) override; + void setBinImage(const QImage &img) override; + void step(float dt) override; + + bool isEmpty() const override { return m_level <= 0.005f; } + bool isAtRest() const override { return isEmpty(); } + + QMargins margins(qreal iconSize) const override; + QUrl visualSource() const override; + + /// Not used: this effect draws through its QML visual. + QSGNode *updateNode(QSGNode *, QQuickWindow *, const QRectF &, + QSGTexture *) override + { + return nullptr; + } + + float time() const { return m_time; } + float level() const { return m_level; } + int count() const; + int maxTentacles() const { return kMaxTentacles; } + QVariantList seats() const; + float armLength() const; + QObject *spineTexture() const; + int jointCount() const { return TentacleChain::kJoints; } + float cameraTilt() const { return m_cameraTilt; } + void setCameraTilt(float degrees); + QSizeF binSize() const { return m_binSize; } + QRectF binRect() const { return m_binRect; } + QObject *iconTexture() const; + + float mouthReachZ() const; + float bodyTaperTop() const { return kBodyTop; } + float bodyTaperFoot() const { return kBodyFoot; } + /// The bin's half-width at a given scene height, in scene units. + float binHalfWidthAt(float y) const; + float heapTopY() const; + float heapFloorY() const; + float rootY() const; + + float mouthX() const; + float mouthY() const; + float mouthRadius() const; + float mouthDepth() const; + float mouthCentreX() const { return float(m_mouth.centre.x()); } + float mouthCentreY() const { return float(m_mouth.centre.y()); } + float mouthHalfWidth() const { return m_mouth.halfWidth; } + float mouthDepthFraction() const { return m_mouth.depth; } + bool mouthMeasured() const { return m_mouth.measured; } + +signals: + void frameChanged(); + void shapeChanged(); + +private: + /// The most that can ever be out — one draw call each. + static constexpr int kMaxTentacles = 3; + + /// An arm's place in the bin, and what it does from there. + struct Seat + { + float lateral; ///< across the opening, fraction of its half-width + float depth; ///< into it: -1 at the far lip, +1 at the near one + float size; ///< scale for the whole arm, for fake perspective + float strike; ///< which way it lashes, radians, 0 = at the camera + float swing; ///< how far that direction wanders, radians + float sink; ///< extra root depth, fractions of the bin's height + float lean; ///< how far it drifts sideways at REST, 0..1 + }; + + /// A TRIANGLE, apex at the back — one arm behind two. + /// + /// This was a golden-angle spread, which is the right answer for a + /// dozen things that must not clump and the wrong one for three, where + /// it just reads as three arms at arbitrary angles. A triangle has a + /// front and a back, and under an ORTHOGRAPHIC camera that is worth + /// arranging deliberately: there is no perspective to shrink the far + /// one, so every depth cue has to be built. The three here are the + /// back arm being occluded by the rubbish (shaders/tentacle.frag), + /// crossing in front of each other, and `size`. + /// + /// `size` is fake perspective, and it is fake on purpose: an ortho + /// camera genuinely does not shrink with distance, so the far arm is + /// scaled down by hand. Small — eight per cent — because the cue only + /// has to nudge; overdone it reads as three different creatures. + /// + /// NOT SYMMETRIC, on purpose, and named by where they appear ON SCREEN + /// rather than by which way they face — "left" meaning the arm's own + /// left is how a whole round of tuning went into the wrong one. + /// + /// The leftmost arm is where it should be and is left alone at 0.62. + /// The rightmost sat too far out and comes in to 0.30, and the centre + /// one carries a small negative offset because it measured about 18px + /// right of the bin's axis rather than on it. + /// + /// Why symmetric seats did not produce a symmetric picture is not + /// fully explained: measured at the row where the arms emerge, the + /// left crossed at exactly its seat (-110) and the right 48px beyond + /// its own (+158). The frame seed is the one thing here that is not + /// mirrored — buildFrames starts from world +x for both arms — so the + /// wave rides a different frame on each side. That is the next place + /// to look if this drifts again. The three terms stack: a base at + /// 0.62 is 136px out on a 444px bin, the rest lean adds up to another + /// 98 before the clamp, and the arm's own radius is 53 on top of + /// whatever its centre line reaches. That put the outside edge at 251 + /// against a bin that ends at 222. Cutting the seat and the lean pulls + /// the whole stack in rather than clamping the tip and leaving the + /// arm's body to bow out past it. + /// + /// Seated well inside the rim, not on it. The strike has to have + /// somewhere to go: an arm rooted at 0.8 of the opening's half-width + /// is already almost at the wall, and the curl that would carry it + /// there is so slack that it barely bends. Measured, an arm on the + /// centre line needs to travel the whole radius, which is what sets + /// how long an arm has to be — see kArmLength. + /// + /// The back arm strikes SIDEWAYS, and it has to. Outward for an arm at + /// the back of the mouth means away from the camera, so a strike along + /// its own radius would flop over the far rim and down the back of the + /// bin where none of it can be seen. Its swing is wide enough to take + /// it over either side, which is also what makes it cross the front + /// pair often enough for the occlusion to do its work. + /// The front pair strike FORWARD of their own radius, not along it. + /// + /// How far out a strike lands is solved from the lateral part of its + /// direction alone (see updateArms), so leaning it forward does not + /// change where the tip arrives across the bin — only how near the + /// camera it gets there. Along its own radius the arm came down exactly + /// on the bin's silhouette edge and read as passing beside the bin + /// rather than landing on it. Forward of it, the arm clears the front + /// wall and is drawn over the bin's face, which is the only way a slap + /// can show contact with anything. + /// The front pair sit JUST BEHIND the near lip, not forward of it. + /// At 0.40 of the way toward the camera their roots cleared the rim + /// and they read as standing in front of the bin; at 0.12 the lip + /// crosses in front of the base and they look seated in the opening, + /// which is what an arm coming out of a hole should look like. They + /// are still a triangle — the separation that matters is the lateral + /// one, and the back arm is a long way behind both. + static constexpr Seat kSeats[kMaxTentacles] = { + // lateral depth size strike swing + // sink lean + { -0.05f, -0.30f, 0.92f, 0.00f, 0.75f, 0.10f, 0.16f }, // centre + { -0.62f, 0.12f, 0.78f, -0.75f, 0.28f, 0.00f, 1.00f }, // leftmost + { 0.30f, 0.12f, 0.78f, 0.75f, 0.28f, 0.00f, 0.55f }, // rightmost + }; + + /// An arm's length, as a fraction of the bin's height. + /// + /// DERIVED, not chosen. The demanding case is the arm on the centre + /// line: for its tip to clear the side rim and come to rest a quarter + /// of the bin down the outside, it has to travel the opening's full + /// half-width outward while dropping that quarter. An arc that does + /// both has (1-cos c)/-sin c = 1.33, so c = 4.43, and the length that + /// then puts the tip on the wall is 1.15 bin heights. + /// + /// This was 0.55 and the arms could not reach: at that length the + /// widest a strike can ever throw the tip is 0.64 x 0.55 = 0.35 bin + /// heights, against the 0.37 needed just to arrive at the wall with + /// nothing left over to come down it. Slack curls looked like a + /// gesture at the bin rather than a hit on it, and no amount of tuning + /// the ANGLE could have fixed a length that was short. + static constexpr float kArmLength = 1.38f; + + /// Where a strike aims, as a share of the opening's half-width. + /// + /// Not 1.0: the bin TAPERS, and a strike lands below the rim rather + /// than on it. Measured on the macOS trash, the body is 0.98 of the + /// opening at the lip and 0.93 a quarter of the way down, which is + /// where the tip of a 4.4-radian curl arrives. + static constexpr float kStrikeTarget = 0.94f; + /// The body's width as a share of the opening, at the lip and near the + /// foot. Measured on the macOS trash: 0.98 at the lip, 0.93 a quarter + /// down, 0.78 at four fifths. + static constexpr float kBodyTop = 0.98f; + static constexpr float kBodyFoot = 0.78f; + + /// How high the rubbish stands above the opening, and how far below it + /// stays solid. Both fractions of the bin's height. + /// + /// The rise is MEASURED, not assumed: differencing the topmost opaque + /// row of the full macOS trash against the empty one, column by + /// column, the rubbish stands proud of the rim by up to 55 rows of a + /// 512px tile — 0.107 — and that peak is the newspaper on the left. + /// (Over most columns it does not clear the rim at all, which is why + /// the heap is trimmed against the artwork's own alpha rather than + /// trusted as a shape.) + /// + /// The floor is not measured because it cannot be: nothing in the + /// artwork says how deep the rubbish goes. It only has to be deeper + /// than an arm's root, so that a root reads as buried in the rubbish + /// instead of sitting on it. + /// Lowered from the measured peak of 0.107. That figure is the + /// NEWSPAPER — the single highest thing in the artwork — and using it + /// made the mound as tall as its tallest point everywhere. + /// + /// It also turned out that SINKING an arm does not make it look lower, + /// which is worth writing down because it is counter-intuitive: where + /// an arm appears to start is set by where it clears the mound, not by + /// where its root is. Burying the back arm deeper only buried more of + /// it. What moves the emergence point down is a shallower mound — and + /// pulling the arm forward, because the mound is cleared along the + /// LINE OF SIGHT, so something further back has to rise higher to get + /// over the same mound. + static constexpr float kHeapRise = 0.018f; + static constexpr float kHeapSink = 0.22f; + + /// How far below the opening an arm's root starts. Buried, so the arm + /// comes OUT of the rubbish rather than beginning at its surface, and + /// no deeper than that — every unit below here is arm that is never + /// seen. + /// Deepened, and with a per-seat extra on top. + /// + /// The back arm needed both. It sits 0.8 of the opening away from the + /// camera, and moving away from a camera looking DOWN raises you on + /// screen — so at the same root height as the others it emerged well + /// clear of the rim and floated above the rubbish rather than out of + /// it. Its own sink cancels that. + /// + /// The extra depth all round is what the slither is made of: an arm + /// with a length buried has somewhere to come out FROM. See kRootSlide. + static constexpr float kRootDepth = 0.20f; + /// How far an arm slides in and out along its own axis, as a fraction + /// of the bin's height. + /// + /// This is the movement "along the spine" that was missing. Nothing + /// else in the vocabulary changes how much of an arm is OUT — every + /// move rearranges a fixed visible length — so the arms read as three + /// fixed-length things waving rather than as something living in the + /// bin. Sliding the anchor along the emergence axis pushes the arm + /// through the mask that is already there, which costs nothing and is + /// the one motion the occlusion makes convincing for free. + static constexpr float kRootSlide = 0.185f; + /// The withdrawal, in seconds: reach up, thrash, then drop. + static constexpr float kRetreatRise = 0.26f; + static constexpr float kRetreatThrash = 0.42f; + static constexpr float kRetreatSink = 0.40f; + static constexpr float kRetreatHold = kRetreatRise + kRetreatThrash; + static constexpr float kRetreatAll = kRetreatHold + kRetreatSink; + + /// What an arm is doing. A vocabulary rather than one parameterised + /// gesture: three arms all running the same curve with different + /// phases read as one creature flexing, however well that curve is + /// tuned. These differ in what they reach for and how tightly they are + /// allowed to bend, which is enough to look like separate intentions. + enum class Move { + Idle, ///< drifting, up and out + Slap, ///< over the rim and down the wall, landing along it + Coil, ///< curled up on itself, tip near its own root + Wrap, ///< tip travelling round the bin's circumference + Roll, ///< spiralled up on itself like a fruit rollup + Reach, ///< after the pointer + }; + struct ArmState + { + Move move = Move::Idle; + float t = 0.0f; ///< seconds into the move + float duration = 2.0f; + float seed = 0.0f; ///< re-rolled per move, so repeats differ + /// Which way a STRIKE lands, radians, frozen for the whole move. + /// + /// Not read from the wandering direction the resting pose uses, and + /// that is a correctness matter rather than a preference. Where the + /// tip lands is chosen with copysign on the lateral component, and + /// copysign has a step at zero: the centre arm wanders +-0.75 rad + /// about the bin's axis, so whenever its wander crossed the axis + /// mid-strike the target jumped from one wall to the other in a + /// single frame. Measured at 167 scene units, which is most of the + /// bin's width, and it showed on screen as the arm blinking off the + /// wall and back. Lengthening the slap made a crossing likely + /// WITHIN one strike rather than merely between them. + float aim = 0.0f; + }; + /// Re-solve every chain that is out. Every step. + void updateArms(); + /// Advance one arm's move clock, picking a new move when it runs out. + void advanceMove(int i, float dt); + /// 0 at the start of the current move, 1 at its end. + float moveProgress(int i) const; + ArmState m_state[kMaxTentacles]; + QVector3D m_lastTarget[kMaxTentacles]; + QVector3D m_lastJoints[kMaxTentacles][TentacleChain::kJoints]; + QPointF m_cursor; + bool m_cursorOn = false; + /// How much the pointer commands each arm, 0..1 — and it is STATE. + /// + /// Computed fresh per call it was a pure function of where the pointer + /// is, so a pointer that jumps across the screen changed every arm's + /// authority in one frame, which is a step function driving a solver. + /// Low-passed here instead: an arm notices the pointer over a beat and + /// loses interest over a beat, and nothing downstream sees an edge. + /// + /// It is also the gate on the moves that cannot coexist with following + /// the pointer — see advanceMove and rollAmount. + float m_pull[kMaxTentacles] = {}; + + /// Where an arm is rooted, and what it is reaching for, both in scene + /// units. `hit` is 0 at rest and 1 at full strike. + QVector3D armBase(int i) const; + QVector3D armEmerge(int i) const; + /// Push a target in front of the bin if it is below the rim. + QVector3D keepInFront(const QVector3D &t) const; + /// Clamp a target to within reach of the bin, measured from its axis. + QVector3D keepNear(const QVector3D &t) const; + /// The leaving flourish: up, thrash, then down out of sight. + QVector3D retreatTarget(int i, float &flex, float &sink) const; + QVector3D armTarget(int i, float &flex, float &maxBend) const; + /// How tightly this arm is rolled up right now, 0..1. + float rollAmount(int i) const; + /// The pointer in scene units: bin-local pixels, origin at the rect's + /// centre, +y UP. It arrives from the host with +y down. + QVector3D cursorScene() const; + /// Re-measure how much the pointer commands each arm. Once per step, + /// before anything reads m_pull. + void updatePull(float dt); + + + BinMouth m_mouth; + QSizeF m_binSize {40.0, 40.0}; + QRectF m_binRect; + float m_time = 0.0f; + float m_level = 0.0f; + float m_vel = 0.0f; + float m_target = 0.0f; + bool m_wasEmpty = true; + float m_cameraTilt = 17.0f; + /// Seconds into the withdrawal, or -1 when not withdrawing. + /// + /// A timeline of its own rather than a shape of the level spring, + /// because the two want opposite things: the spring's job is to reach + /// zero so isEmpty() can go true and the overlay can be torn down, and + /// the withdrawal's job is to still be visible while it happens. The + /// level is HELD until the flourish is done and only then released. + float m_retreat = -1.0f; + float m_dt = 0.033f; + /// One per seat, and they PERSIST between frames. That is the point of + /// a chain: it is solved from where it already was, so it lags its + /// target and carries the wave that was running down it. Rebuilt from + /// scratch each frame it would be as memoryless as the arc was. + TentacleChain m_chain[kMaxTentacles]; + IconTexture *m_iconTexture = nullptr; + SpineTexture *m_spine = nullptr; +}; + +} // namespace hyperbin diff --git a/src/main.cpp b/src/main.cpp index d02c2cc..d439575 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,7 @@ #include "app/Settings.h" #include "app/TrayMenu.h" #include "core/PowerPolicy.h" +#include "platform/LowPower.h" #include "platform/TrashTarget.h" #include "render/EffectItem.h" #include "update/AppUpdater.h" @@ -68,6 +69,21 @@ class PermissionBridge : public QObject }; } // namespace namespace { +/// What to ask the shell for the bin's artwork at. +/// +/// Twice the rect, so the texture has something in hand when the overlay +/// is drawn on a retina screen — but never below 256, however small the +/// icon on screen is. That floor is not about the texture: the mouth of +/// the bin is FOUND in this image, and it is found by the lip's shading, +/// which at a 32pt Dock icon is about one pixel of a 64px tile. Measured +/// on the macOS trash, detection is exact at 512 and at 128 and gives up +/// at 64 — so the floor sits well clear of where the signal runs out. +/// See core/BinMouth. +int binIconSize(const QRect &rect) +{ + return qMax(256, int(qMax(rect.width(), rect.height()) * 2)); +} + /// Serves the preview's stand-in for the shell's trash artwork to QML, so /// dev mode can draw the bin underneath the overlay the way the Dock /// does. Dev only: nothing in the shipping path goes through this. @@ -202,6 +218,18 @@ int main(int argc, char **argv) // conclusions. const QString shot = qEnvironmentVariable("HYPERBIN_PREVIEW_SHOT"); if (!shot.isEmpty()) { + // Out of the way. A grab-and-exit run needs the window + // rendered, not focused, and this harness gets run dozens of + // times in a row while somebody is working in another app — + // left alone it steals focus and stacks on top every single + // time. Qt's own flags are not enough on macOS, where + // activation belongs to the application rather than the + // window, so the native side does the rest. + win->setFlags(win->flags() | Qt::WindowDoesNotAcceptFocus + | Qt::WindowStaysOnBottomHint); +#if defined(Q_OS_MACOS) + configurePreviewWindow(win); +#endif // HYPERBIN_PREVIEW_SHOT_MS moves the grab in time. Two grabs // at different moments is the only way to tell an animation // that is running from one that is merely present in the @@ -330,7 +358,7 @@ int main(int argc, char **argv) // Full and empty are different artwork; refresh when it flips. const QRect ir = target->iconRect(); if (!ir.isEmpty()) - fx->setBinIcon(target->iconImage(int(qMax(ir.width(), ir.height()) * 2))); + fx->setBinIcon(target->iconImage(binIconSize(ir))); if (n > 0) power.setEffectIdle(false); }); @@ -361,7 +389,7 @@ int main(int argc, char **argv) // composited over the swarm. This only works because iconRect() // now reports the artwork's true bounds rather than the // Accessibility hit area — see visualIconRect(). - fx->setBinIcon(target->iconImage(int(qMax(r.width(), r.height()) * 2))); + fx->setBinIcon(target->iconImage(binIconSize(r))); }); // HYPERBIN_DEBUG=1 reports where the overlay thinks it is. Kept because @@ -650,25 +678,115 @@ int main(int argc, char **argv) // Off means genuinely off: no frames, no overlay surface, and the // trash poll stopped as well. Leaving the poll running would be a // background app doing work the user has just told it not to do. - auto applyEnabled = [&](bool on) { - power.setEnabled(on); - if (on) { - target->start(); - power.setBinEmpty(target->itemCount() == 0); - applyTargetVisible(); - } else { - target->stop(); - if (win) - win->setVisible(false); + // Switching off asks the effect to LEAVE, then tears everything down + // once it has gone. There is exactly one teardown and nothing else + // touches the window, the poll or the power policy on the way out. + // + // The first attempt deferred only the window and the trash poll while + // still calling power.setEnabled(false) immediately, which was wrong + // twice over: the policy's own signal hides the window synchronously, + // so the surface vanished anyway — and with the policy off there are + // no frames, so the exit animation could not have run even if it had + // stayed. Anything that stops the clock has to wait for the clock's + // last job to finish. + // --- low power --------------------------------------------------------- + // The menu's three-way choice, resolved against what the OS is doing. + // This is the only place the two are combined; PowerPolicy is handed a + // single already-decided answer. + // + // Both inputs were previously never wired at all — setOnBattery and + // setLowPowerMode existed, were read by the policy, and had no callers + // — so the documented promise in docs/battery.md that the rate halves + // on battery and stops in Low Power Mode did nothing whatsoever. + auto *lowPowerWatch = new LowPowerWatch(&app); + auto applyLowPower = [&] { + bool conserve = false; + switch (settings.lowPower()) { + case Settings::LowPower::On: conserve = true; break; + case Settings::LowPower::Off: conserve = false; break; + case Settings::LowPower::Auto: conserve = lowPowerWatch->active(); break; } + power.setLowPower(conserve); + }; + QObject::connect(&settings, &Settings::lowPowerChanged, &app, + [&](Settings::LowPower) { applyLowPower(); refreshStatus(); }); + QObject::connect(lowPowerWatch, &LowPowerWatch::activeChanged, &app, + [&](bool) { applyLowPower(); refreshStatus(); }); + applyLowPower(); + + // The display's own rate, so a 120Hz screen is animated at 120Hz. It + // follows the window, because dragging the Dock to a second monitor + // changes the answer. + auto applyRefresh = [&] { + if (win && win->screen()) + power.setRefreshHz(win->screen()->refreshRate()); + }; + if (win) { + QObject::connect(win, &QWindow::screenChanged, &app, + [&](QScreen *) { applyRefresh(); }); + applyRefresh(); + } + + auto *exitTimer = new QTimer(&app); + exitTimer->setSingleShot(true); + + auto teardown = [&, exitTimer] { + exitTimer->stop(); + power.setEnabled(false); + target->stop(); + if (win) + win->setVisible(false); #if defined(Q_OS_WIN) // Off means off: the occlusion hook and its poll go too, rather // than sitting there answering a question nothing is asking. - desktop.setEnabled(on); + desktop.setEnabled(false); #endif refreshStatus(); refreshProblem(); }; + QObject::connect(exitTimer, &QTimer::timeout, &app, teardown); + QObject::connect(fx, &EffectItem::becameEmpty, &app, [&, exitTimer] { + // Only ever consumed while an exit is in flight. The bin going + // empty on its own is not a reason to shut anything down. + if (exitTimer->isActive()) + teardown(); + }); + + auto applyEnabled = [&, exitTimer](bool on) { + if (on) { + exitTimer->stop(); + power.setEnabled(true); + target->start(); + power.setBinEmpty(target->itemCount() == 0); + // PUT THE FULLNESS BACK. Switching off empties the effect so + // it has something to leave from, and nothing else ever + // restores it — density is re-applied when the settings or the + // bin's contents change, and neither happens by toggling this. + // So the effect came back on to an empty bin and stayed empty, + // and the only way out was to switch effects, which rebuilds + // from scratch. Cheap to call and idempotent. + applyDensity(); + applyTargetVisible(); +#if defined(Q_OS_WIN) + desktop.setEnabled(true); +#endif + refreshStatus(); + refreshProblem(); + } else if (fx->isEmpty()) { + // Nothing on screen to leave. Go straight out rather than + // holding the clock open for an animation with no subject. + teardown(); + } else { + // Everything stays running so the effect can play its exit — + // the policy above all, because it owns the frames. The cap is + // a backstop against an effect that never reports empty, not a + // duration: the tentacles' own withdrawal runs about 1.4s. + fx->setFullness(0.0); + exitTimer->start(2000); + refreshStatus(); + refreshProblem(); + } + }; QObject::connect(&settings, &Settings::enabledChanged, &app, [&](bool on) { applyEnabled(on); }); diff --git a/src/platform/DockTrashTarget.h b/src/platform/DockTrashTarget.h index e90e24e..137516d 100644 --- a/src/platform/DockTrashTarget.h +++ b/src/platform/DockTrashTarget.h @@ -76,6 +76,15 @@ class DockTrashTarget : public TrashTarget // Dock during magnification. static constexpr int kFastPollMs = 16; static constexpr int kSlowPollMs = 1000; + /// How far the icon must move before the overlay follows it, pixels. + /// + /// One. The Dock does not keep its items on integer pixels and hovering + /// nudges the layout, so the rounded rect flips between two neighbouring + /// values — and each flip moves the overlay window, which reads as the + /// effect shivering against an icon that is standing still. A pixel of + /// lag cannot be seen; a pixel of shimmer at 60Hz is the first thing + /// you see. Position only: see the emit in pollIconRect. + static constexpr int kRectDeadbandPx = 1; QTimer m_poll; QTimer m_permissionWatch; // runs only while waiting to be granted diff --git a/src/platform/DockTrashTarget.mm b/src/platform/DockTrashTarget.mm index 58bc924..fcd7fce 100644 --- a/src/platform/DockTrashTarget.mm +++ b/src/platform/DockTrashTarget.mm @@ -427,7 +427,18 @@ QDirIterator it(path, QDir::Files | QDir::NoDotAndDotDot | QDir::Hidden // AX reports top-left-origin points, same convention and units as Qt // screen coordinates, so this needs no conversion. - const QRect axRect(int(pos.x), int(pos.y), int(size.width), int(size.height)); + // ROUNDED, not truncated. + // + // AX hands these back as floats and the Dock does not keep its items + // on integer pixels — hovering perturbs the layout by fractions. Under + // int(), a position drifting across 100.0 reads 100, 99, 100, 99 and + // the overlay window is moved a whole pixel each time, so the gel + // shivers against the icon it is supposed to be poured over. Rounding + // halves the error and, more to the point, removes the bias: int() + // always falls toward zero, so the overlay sat consistently half a + // pixel up and to the left of the artwork. + const QRect axRect(qRound(pos.x), qRound(pos.y), + qRound(size.width), qRound(size.height)); const QRect r = visualIconRect(axRect); // An auto-hidden Dock parks its items just outside the screen it @@ -465,7 +476,25 @@ const QRect inAxSpace(int(f.origin.x), int(primaryMaxY - NSMaxY(f)), if (was != m_status) qInfo("hyperbin: trash icon at (%d,%d %dx%d) onScreen=%d", r.x(), r.y(), r.width(), r.height(), onScreen); - if (r != m_rect) { + // A DEADBAND on the position, and none on the size. + // + // Rounding stops the bias but not the oscillation: a position sitting + // near enough to a half-pixel still flips between two integers, and + // every flip moves the overlay window. One pixel of lag is invisible; + // one pixel of shimmer, thirty times a second, against a fixed icon, + // is not — which is what "the goo will not stay on the bin" was. + // + // Nothing on the size. A size change is the Dock actually resizing the + // tile, it is never noise, and the gel's whole shape is re-measured + // from it — so that has to be followed exactly or the geometry and the + // artwork disagree. + const bool moved = std::abs(r.x() - m_rect.x()) > kRectDeadbandPx + || std::abs(r.y() - m_rect.y()) > kRectDeadbandPx; + const bool resized = r.size() != m_rect.size(); + if (moved || resized) { + // Snap to the new position outright once it has genuinely moved. + // Carrying the deadband forward would leave a permanent offset + // that grows every time the icon travels. m_rect = r; emit iconRectChanged(r); } diff --git a/src/platform/LowPower.h b/src/platform/LowPower.h new file mode 100644 index 0000000..a2f9a90 --- /dev/null +++ b/src/platform/LowPower.h @@ -0,0 +1,40 @@ +// Does the OS want us to conserve right now? +// +// Both platforms have this and call it different things — macOS Low Power +// Mode, Windows Battery Saver — and both can change it while we run, so a +// one-off read at startup is not enough. This is the "Auto" behind the +// menu's Low Power Mode setting. +// +// Deliberately NOT "are we on battery". Being unplugged is not the same as +// wanting to conserve, and the old policy conflated them; a laptop on +// mains with Low Power Mode on should still be calm, and one on battery +// with it off should not be throttled behind the user's back. +#pragma once + +#include + +namespace hyperbin { + +class LowPowerWatch : public QObject +{ + Q_OBJECT +public: + explicit LowPowerWatch(QObject *parent = nullptr); + ~LowPowerWatch() override; + + /// True when the OS is in Low Power Mode / Battery Saver. Always false + /// on platforms with no such notion, which is the right answer: "the + /// OS is not asking us to conserve". + bool active() const { return m_active; } + +signals: + void activeChanged(bool active); + +private: + void set(bool v); + + bool m_active = false; + void *m_token = nullptr; ///< platform observer, freed in the dtor +}; + +} // namespace hyperbin diff --git a/src/platform/LowPowerMac.mm b/src/platform/LowPowerMac.mm new file mode 100644 index 0000000..82b93c0 --- /dev/null +++ b/src/platform/LowPowerMac.mm @@ -0,0 +1,42 @@ +#include "LowPower.h" + +#import + +namespace hyperbin { + +LowPowerWatch::LowPowerWatch(QObject *parent) + : QObject(parent) +{ + m_active = [[NSProcessInfo processInfo] isLowPowerModeEnabled]; + + // The notification is the whole point: Low Power Mode turns itself on + // when the battery reaches 20%, so a value read once at launch is + // wrong for exactly the users who care most. + id token = [[NSNotificationCenter defaultCenter] + addObserverForName:NSProcessInfoPowerStateDidChangeNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification *) { + set([[NSProcessInfo processInfo] isLowPowerModeEnabled]); + }]; + m_token = (__bridge_retained void *)token; +} + +LowPowerWatch::~LowPowerWatch() +{ + if (m_token) { + id token = (__bridge_transfer id)m_token; + [[NSNotificationCenter defaultCenter] removeObserver:token]; + m_token = nullptr; + } +} + +void LowPowerWatch::set(bool v) +{ + if (v == m_active) + return; + m_active = v; + emit activeChanged(v); +} + +} // namespace hyperbin diff --git a/src/platform/LowPowerStub.cpp b/src/platform/LowPowerStub.cpp new file mode 100644 index 0000000..d99f294 --- /dev/null +++ b/src/platform/LowPowerStub.cpp @@ -0,0 +1,19 @@ +#include "LowPower.h" + +namespace hyperbin { + +// No such notion here, so the OS is never asking us to conserve. The menu's +// Auto lands on "off", and On/Off still work — the user's own choice does +// not depend on the platform having an opinion. +LowPowerWatch::LowPowerWatch(QObject *parent) : QObject(parent) {} +LowPowerWatch::~LowPowerWatch() = default; + +void LowPowerWatch::set(bool v) +{ + if (v == m_active) + return; + m_active = v; + emit activeChanged(v); +} + +} // namespace hyperbin diff --git a/src/platform/LowPowerWin.cpp b/src/platform/LowPowerWin.cpp new file mode 100644 index 0000000..5f75ebb --- /dev/null +++ b/src/platform/LowPowerWin.cpp @@ -0,0 +1,55 @@ +#include "LowPower.h" + +#include + +#include + +namespace hyperbin { +namespace { + +/// Battery Saver, from the flags GetSystemPowerStatus already returns. +/// +/// SYSTEM_STATUS_FLAG_POWER_SAVING_ON is bit 0 of SystemStatusFlag, added +/// in Windows 10. On anything older the field reads 0, which lands on +/// "not conserving" — the same answer a machine without the feature +/// should give. +bool querySaver() +{ + SYSTEM_POWER_STATUS s{}; + if (!GetSystemPowerStatus(&s)) + return false; + return (s.SystemStatusFlag & 0x01) != 0; +} + +} // namespace + +LowPowerWatch::LowPowerWatch(QObject *parent) + : QObject(parent) +{ + m_active = querySaver(); + + // Polled rather than hooked. The event route is + // RegisterPowerSettingNotification with GUID_POWER_SAVING_STATUS, and + // it needs an HWND with a WndProc to deliver WM_POWERBROADCAST to — + // this app's only window is a Qt Quick overlay that is destroyed and + // recreated whenever the effect stops, so the hook would keep having + // to be re-registered against a moving target. A five-second poll of + // a call that reads a cached struct is not worth avoiding. + auto *t = new QTimer(this); + t->setInterval(5000); + connect(t, &QTimer::timeout, this, [this] { set(querySaver()); }); + t->start(); + m_token = nullptr; +} + +LowPowerWatch::~LowPowerWatch() = default; + +void LowPowerWatch::set(bool v) +{ + if (v == m_active) + return; + m_active = v; + emit activeChanged(v); +} + +} // namespace hyperbin diff --git a/src/platform/MacOverlay.h b/src/platform/MacOverlay.h index bf33b7c..6ecda66 100644 --- a/src/platform/MacOverlay.h +++ b/src/platform/MacOverlay.h @@ -20,6 +20,15 @@ void configureOverlayWindow(QWindow *w); /// allowed — this stops throttling, it does not keep the Mac awake. void setAnimationActivity(bool active); +/// Keep a dev-preview window out of the user's way. +/// +/// The preview harness opens a normal window, renders, grabs and exits. +/// Left alone that window activates the app, takes keyboard focus and +/// lands on top of whatever the user was doing — which for a harness that +/// gets run dozens of times in a row is genuinely disruptive. This puts it +/// below everything and stops it accepting focus, so a grab can happen +/// while somebody else is working. +void configurePreviewWindow(QWindow *w); /// Bring the app forward so a window it has just opened is actually seen. /// /// An LSUIElement agent has no Dock icon and is not in the activation diff --git a/src/platform/MacOverlay.mm b/src/platform/MacOverlay.mm index 3f2e9f5..71f28dd 100644 --- a/src/platform/MacOverlay.mm +++ b/src/platform/MacOverlay.mm @@ -64,6 +64,28 @@ void configureOverlayWindow(QWindow *w) | NSWindowCollectionBehaviorFullScreenAuxiliary; } +void configurePreviewWindow(QWindow *w) +{ + if (!w) + return; + w->create(); + NSView *view = reinterpret_cast(w->winId()); + NSWindow *win = view.window; + if (!win) + return; + // Below normal windows, and never the key window. Both are needed: + // the level keeps it visually out of the way, and refusing key status + // is what stops keystrokes being swallowed mid-render. + win.level = kCGDesktopWindowLevel + 1; + win.hidesOnDeactivate = NO; + win.animationBehavior = NSWindowAnimationBehaviorNone; + win.collectionBehavior = NSWindowCollectionBehaviorIgnoresCycle + | NSWindowCollectionBehaviorCanJoinAllSpaces; + [win setCanHide:NO]; + // Order in at the back rather than making it front. orderFront: would + // undo the level for as long as the app is active. + [win orderBack:nil]; +} void activateApp() { [NSApp activateIgnoringOtherApps:YES]; diff --git a/src/render/EffectItem.cpp b/src/render/EffectItem.cpp index 77139df..6adba13 100644 --- a/src/render/EffectItem.cpp +++ b/src/render/EffectItem.cpp @@ -1,5 +1,7 @@ #include "EffectItem.h" +#include "BinMouth.h" + #include "../core/EffectRegistry.h" #include @@ -213,6 +215,14 @@ void EffectItem::rebuildSurface() } m_effect->setSurface(cov, kCoverage, kCoverage); m_effect->setContentLine(detectContentLine()); + // The opening, measured off the FULL-resolution artwork rather than + // the coverage grid: the lip is a shading edge a few pixels thick, + // and the grid is 1-bit alpha at a fraction of the size. It would not + // survive either conversion. + const BinMouth mouth = measureBinMouth(m_binIcon); + m_effect->setMouth(mouth); + m_mouth = mouth; + emit mouthChanged(); } float EffectItem::detectContentLine() const @@ -261,27 +271,16 @@ void EffectItem::setFrameIntervalMs(int ms) } void EffectItem::applyFrameInterval() { - // The policy decides whether to draw at all; the effect may ask to be - // drawn less often when we do. Only ever slower, never faster — 0 - // still means stop. - // - // Recomputed from the REQUEST every time, and re-run whenever the - // effect changes. Folding the effect's floor straight into the stored - // interval meant it outlived the effect that asked for it: ooze wants - // 33ms, the policy wants 16, and the policy only signals when its own - // answer moves — so switching from ooze to flies left the flies - // running at half rate until something unrelated happened to change - // the power state. - int ms = m_requestedMs; - if (ms > 0 && m_effect) - ms = qMax(ms, m_effect->preferredFrameIntervalMs()); + // ONE source of truth. The policy decides both whether to draw and how + // fast; this used to take the slower of that and a per-effect floor, + // which let an effect opt out of the policy without the policy knowing + // — see the note in core/Effect.h for why that is gone. + const int ms = m_requestedMs; if (ms == m_intervalMs) return; m_intervalMs = ms; if (qEnvironmentVariableIsSet("HYPERBIN_DEBUG")) - qInfo("hyperbin: clock %dms (policy asked %d, '%s' floor %d)", ms, - m_requestedMs, qPrintable(m_effectId), - m_effect ? m_effect->preferredFrameIntervalMs() : 0); + qInfo("hyperbin: clock %dms for '%s'", ms, qPrintable(m_effectId)); if (ms <= 0) { m_clock.stop(); // no timer, no wakeups, no frames @@ -331,6 +330,20 @@ void EffectItem::tick() QPointF EffectItem::cursorLocal() const { + // Dev harness: HYPERBIN_PREVIEW_CURSOR=x,y pins the pointer, in this + // item's own coordinates. A grab-and-exit preview cannot move the real + // mouse, so anything that reacts to the pointer is otherwise + // untestable without sitting in front of the app waving at it — which + // is exactly the kind of "looks about right" verification that has + // been wrong twice in this file already. + static const QPointF pinned = [] { + const QStringList v = qEnvironmentVariable("HYPERBIN_PREVIEW_CURSOR") + .split(QLatin1Char(','), Qt::SkipEmptyParts); + return v.size() == 2 ? QPointF(v[0].toDouble(), v[1].toDouble()) + : QPointF(qQNaN(), qQNaN()); + }(); + if (!qIsNaN(pinned.x())) + return pinned; return mapFromGlobal(QPointF(QCursor::pos())); } diff --git a/src/render/EffectItem.h b/src/render/EffectItem.h index 6c28942..aaca9e3 100644 --- a/src/render/EffectItem.h +++ b/src/render/EffectItem.h @@ -38,6 +38,14 @@ class EffectItem : public QQuickItem NOTIFY frameIntervalMsChanged) Q_PROPERTY(QRectF binRect READ binRect WRITE setBinRect NOTIFY binRectChanged) Q_PROPERTY(QString effectId READ effectId WRITE setEffectId NOTIFY effectIdChanged) + /// The bin's opening, in the bin rect's own 0..1 coordinates. Exposed + /// here rather than on one effect because it describes the BIN, not + /// any animation of it — and because dev mode draws it to check the + /// measurement against the artwork it came from. + Q_PROPERTY(QPointF mouthCentre READ mouthCentre NOTIFY mouthChanged) + Q_PROPERTY(qreal mouthHalfWidth READ mouthHalfWidth NOTIFY mouthChanged) + Q_PROPERTY(qreal mouthDepth READ mouthDepth NOTIFY mouthChanged) + Q_PROPERTY(bool mouthMeasured READ mouthMeasured NOTIFY mouthChanged) public: explicit EffectItem(QQuickItem *parent = nullptr); @@ -74,7 +82,12 @@ class EffectItem : public QQuickItem /// The pointer is on the bin and the effect has finished reacting. bool isDismissed() const { return m_dismissed; } + QPointF mouthCentre() const { return m_mouth.centre; } + qreal mouthHalfWidth() const { return qreal(m_mouth.halfWidth); } + qreal mouthDepth() const { return qreal(m_mouth.depth); } + bool mouthMeasured() const { return m_mouth.measured; } signals: + void mouthChanged(); void fullnessChanged(); void frameIntervalMsChanged(); void binRectChanged(); @@ -123,6 +136,7 @@ class EffectItem : public QQuickItem QImage m_binIcon; qreal m_fullness = 0.0; bool m_binIconDirty = false; + BinMouth m_mouth; QSGTexture *m_maskTexture = nullptr; // bin silhouette, owned here /// Which effect built the current scene-graph node. Effects each /// build their own node shape and cast `old` to it, so handing one diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..c8c33e5 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,83 @@ +# Headless checks: the simulations only, never the renderer. +# +# Everything here runs without a display, which is why the sims live in +# src/core/ with no dependency on the scene graph — see docs/effects.md. +# One executable per area, every test function registered with CTest +# individually, so a failure names itself and a slow check can be run on +# its own. + +# Two translation units on purpose. A static library is pulled in an object +# file at a time, so a fixture that needs FlySim must not share a file with +# one that does not — see common/bin_silhouette.cpp. +add_library(test_common STATIC + common/bin_silhouette.cpp + common/fly_fixtures.cpp +) + +target_include_directories(test_common PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/common + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/src/core + ${CMAKE_SOURCE_DIR}/src/app +) + +target_link_libraries(test_common PUBLIC Qt6::Core Qt6::Gui Qt6::Test) + +# On Windows the test binaries need Qt's DLLs on PATH. The app bundle gets +# them from windeployqt; these are bare executables in the build tree, and +# without this every one of them fails to start with 0xc0000135. +# +# Called from each area's own CMakeLists and NOT once from here, which does +# not work: test properties are directory-scoped, so set_tests_properties +# in this file cannot see a test that add_test created in a subdirectory — +# it fails outright with "Can not find test to add properties to". +function(hyperbin_fix_test_path) + if(NOT WIN32) + return() + endif() + cmake_path(GET Qt6_DIR PARENT_PATH _qt_cmake_dir) + cmake_path(GET _qt_cmake_dir PARENT_PATH _qt_lib_dir) + cmake_path(GET _qt_lib_dir PARENT_PATH _qt_prefix) + get_property(_tests DIRECTORY PROPERTY TESTS) + if(_tests) + set_tests_properties(${_tests} PROPERTIES + ENVIRONMENT "PATH=${_qt_prefix}/bin;$ENV{PATH}") + endif() +endfunction() + +add_subdirectory(flies) +add_subdirectory(ooze) +add_subdirectory(settings) +add_subdirectory(tentacle) + +# `check` — build every test, then run them. +# +# Exists so the tests can be RUN from a UI that only knows how to build a +# target. VS Code's CMake Tools, Qt Creator and CLion all let you pick a +# build target and press go; none of them will run `ctest` for you as part +# of that. Selecting `check` as the build target turns the ordinary build +# button into a test run. +# +# --output-on-failure, because a green run should say nothing and a red one +# should say everything. USES_TERMINAL keeps CTest's progress live rather +# than arriving in a lump when it finishes. +add_custom_target(check + COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure + --build-config $ + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + COMMENT "Running the headless test suite" + USES_TERMINAL +) + +# add_dependencies, NOT the DEPENDS argument above. +# +# DEPENDS on add_custom_target means files and the outputs of +# add_custom_command — target names are not part of its contract. Naming +# the executables there did work, under both Makefiles and Ninja, which is +# exactly what makes it worth changing: it is undocumented behaviour that +# happened to be right, and the failure mode if a generator stops honouring +# it is `check` running CTest against binaries that were never built. +add_dependencies(check + tst_fly_sim tst_ooze_sim tst_distance_field tst_settings + tst_tentacle_chain +) diff --git a/tests/common/bin_silhouette.cpp b/tests/common/bin_silhouette.cpp new file mode 100644 index 0000000..433b335 --- /dev/null +++ b/tests/common/bin_silhouette.cpp @@ -0,0 +1,30 @@ +// The trash artwork's shape, as a coverage grid. +// +// Kept in its own translation unit and NOT alongside makeSim: test_common +// is a static library, so an object file is pulled in whole. With both +// fixtures in one file the distance-field test — which wants nothing but +// this grid — dragged in makeSim and failed to link on FlySim symbols it +// never calls. +#include "test_helpers.h" + +#include + +namespace hyperbin::test { + +QVector binSilhouette(int n) +{ + QVector cov(n * n, 0); + for (int y = 0; y < n; ++y) { + const double v = double(y) / n; + if (v < 0.14 || v > 0.94) continue; // above the lid / below the base + const double half = 0.27 - 0.04 * (v - 0.2); // slight taper + for (int x = 0; x < n; ++x) { + const double u = double(x) / n - 0.5; + if (std::abs(u) <= half) + cov[y * n + x] = 1; + } + } + return cov; +} + +} // namespace hyperbin::test diff --git a/tests/common/fly_fixtures.cpp b/tests/common/fly_fixtures.cpp new file mode 100644 index 0000000..ed564d0 --- /dev/null +++ b/tests/common/fly_fixtures.cpp @@ -0,0 +1,16 @@ +#include "test_helpers.h" + +#include +#include + +namespace hyperbin::test { + +FlySim makeSim(qreal side, float fullness, uint32_t seed) +{ + FlySim s(seed); + s.setBinRect(QRectF(500, 500, side, side * 0.7)); // Dock tiles are wide + s.setFullness(fullness); + return s; +} + +} // namespace hyperbin::test diff --git a/tests/common/test_helpers.h b/tests/common/test_helpers.h new file mode 100644 index 0000000..e429805 --- /dev/null +++ b/tests/common/test_helpers.h @@ -0,0 +1,26 @@ +// Fixtures shared between the headless suites. +// +// Both of these describe the SHAPE of the thing being simulated against — +// a Dock tile and the trash artwork inside it — which is the one piece of +// context every sim here needs and none of them owns. +#pragma once + +#include "FlySim.h" + +#include +#include + +namespace hyperbin::test { + +/// A swarm over a Dock tile of the given side. Dock tiles are wide, so the +/// rect is deliberately not square: a sim that assumes it is escapes the +/// overlay vertically and nothing else notices. +FlySim makeSim(qreal side, float fullness, uint32_t seed = 12345); + +/// Coverage grid shaped like the real trash artwork: it fills only the +/// middle ~54% x 78% of its tile, which is the whole reason the sim needs +/// to know about it. A grid of all ones passes every landing test and +/// tells you nothing. +QVector binSilhouette(int n); + +} // namespace hyperbin::test diff --git a/tests/flies/CMakeLists.txt b/tests/flies/CMakeLists.txt new file mode 100644 index 0000000..dd7a4fa --- /dev/null +++ b/tests/flies/CMakeLists.txt @@ -0,0 +1,52 @@ +qt_add_executable(tst_fly_sim + tst_fly_sim.cpp + ${CMAKE_SOURCE_DIR}/src/core/FlySim.cpp + ${CMAKE_SOURCE_DIR}/src/app/Settings.cpp +) + +target_link_libraries(tst_fly_sim PRIVATE test_common Qt6::Test Qt6::Core Qt6::Gui) + +add_test(NAME Flies.countFullBinStaysWithinBounds + COMMAND tst_fly_sim count_fullBin_staysWithinBounds) +add_test(NAME Flies.countNearlyEmptyBinStillDrawsOne + COMMAND tst_fly_sim count_nearlyEmptyBin_stillDrawsOne) +add_test(NAME Flies.lifetimeFliesPersist + COMMAND tst_fly_sim lifetime_fliesPersist) +add_test(NAME Flies.countLessRubbishShrinksTheSwarm + COMMAND tst_fly_sim count_lessRubbish_shrinksTheSwarm) +add_test(NAME Flies.movementBothModesOccur + COMMAND tst_fly_sim movement_bothModesOccur) +add_test(NAME Flies.fadeHappensClearOfTheBin + COMMAND tst_fly_sim fade_happensClearOfTheBin) +add_test(NAME Flies.movementFliersDoNotStall + COMMAND tst_fly_sim movement_fliersDoNotStall) +add_test(NAME Flies.depthNothingPassesUnderTheBin + COMMAND tst_fly_sim depth_nothingPassesUnderTheBin) +add_test(NAME Flies.depthSomeFlightsPassInFront + COMMAND tst_fly_sim depth_someFlightsPassInFront) +add_test(NAME Flies.cursorOnTheBinClearsAndRestoresTheSwarm + COMMAND tst_fly_sim cursor_onTheBinClearsAndRestoresTheSwarm) +add_test(NAME Flies.cursorBesideTheBinStillAllowsLanding + COMMAND tst_fly_sim cursor_besideTheBinStillAllowsLanding) +add_test(NAME Flies.startleIsABurstNotADeath + COMMAND tst_fly_sim startle_isABurstNotADeath) +add_test(NAME Flies.landingPlaysTheWholeSequence + COMMAND tst_fly_sim landing_playsTheWholeSequence) +add_test(NAME Flies.landingHappensOnTheArtwork + COMMAND tst_fly_sim landing_happensOnTheArtworkNotTheBoundingBox) +add_test(NAME Flies.landingNeverBehindTheBin + COMMAND tst_fly_sim landing_neverBehindTheBinAndNothingPopsIn) +add_test(NAME Flies.walkingCoversGround + COMMAND tst_fly_sim walking_coversGround) +add_test(NAME Flies.containmentStaysInsideTheOverlay + COMMAND tst_fly_sim containment_staysInsideTheOverlay) +add_test(NAME Flies.scalingSwarmGrowsWithTheIcon + COMMAND tst_fly_sim scaling_swarmGrowsWithTheIcon) +add_test(NAME Flies.determinismSameSeedSameSwarm + COMMAND tst_fly_sim determinism_sameSeedSameSwarm) +add_test(NAME Flies.idleEmptiedBinStopsRendering + COMMAND tst_fly_sim idle_emptiedBinStopsRendering) + +# Windows: put Qt's DLLs on PATH for these tests. Must be called here, +# in the same directory scope as the add_test calls above. +hyperbin_fix_test_path() diff --git a/tests/flies/tst_fly_sim.cpp b/tests/flies/tst_fly_sim.cpp new file mode 100644 index 0000000..db9ef2a --- /dev/null +++ b/tests/flies/tst_fly_sim.cpp @@ -0,0 +1,548 @@ +// The swarm's contract: how many flies, how long they live, where they are +// allowed to go, and that it all stops. +// +// Every check here runs the real simulation for tens of simulated seconds +// and asserts on a STATISTIC of the result rather than on any one frame. +// That is deliberate. The sim is stochastic, so a single-frame assertion +// either fixes a seed and tests one arbitrary path, or flakes; a share over +// thousands of samples is stable and still fails loudly when the behaviour +// it describes collapses. +// +// Several thresholds below are FLOORS AND CEILINGS, not targets, and say so +// where that matters. Narrowing one to whatever the current tuning happens +// to produce turns a guard against collapse into a tripwire that fires on +// every deliberate change. +#include + +#include "FlySim.h" +#include "Settings.h" +#include "test_helpers.h" + +#include +#include +#include +#include +#include +#include + +using namespace hyperbin; +using hyperbin::test::binSilhouette; +using hyperbin::test::makeSim; + +class TstFlySim : public QObject +{ + Q_OBJECT + +private slots: + // --- count: never more than 6, never fewer than 1 while occupied --- + void count_fullBin_staysWithinBounds() + { + FlySim s = makeSim(40, 1.0f); + int seen = 0, minSeen = 99; + for (int i = 0; i < 1200; ++i) { // 60s + s.step(0.05f); + seen = std::max(seen, int(s.flies().size())); + if (i > 40) // let the first one arrive + minSeen = std::min(minSeen, int(s.flies().size())); + } + qInfo("full bin over 60s: %d..%d flies", minSeen, seen); + QVERIFY2(seen <= 6, "more than 6 flies on screen"); + QVERIFY2(minSeen >= 1, "bin occupied but no flies"); + } + + // A nearly-empty bin still gets its lone fly. + void count_nearlyEmptyBin_stillDrawsOne() + { + FlySim s = makeSim(40, 0.03f); + int minSeen = 99, maxSeen = 0; + for (int i = 0; i < 600; ++i) { + s.step(0.05f); + if (i > 40) { + minSeen = std::min(minSeen, int(s.flies().size())); + maxSeen = std::max(maxSeen, int(s.flies().size())); + } + } + qInfo("nearly-empty bin: %d..%d flies", minSeen, maxSeen); + QVERIFY2(minSeen >= 1, "one item in the bin should still draw a fly"); + } + + // There is no lifespan. Rubbish does not stop attracting flies after + // a few seconds, and the constant churn of arrivals and departures + // was motion the eye kept getting drawn to for no reason. + void lifetime_fliesPersist() + { + FlySim s = makeSim(40, 1.0f); + for (int i = 0; i < 200; ++i) s.step(0.05f); // let it fill up + std::set early; + for (const Fly &f : s.flies()) early.insert(f.id); + QVERIFY2(!early.empty(), "no swarm to follow"); + + for (int i = 0; i < 1200; ++i) s.step(0.05f); // 60 more seconds + int survivors = 0; + for (const Fly &f : s.flies()) + if (early.count(f.id)) ++survivors; + qInfo("persistence: %d of %zu flies still around after 60s", + survivors, early.size()); + QCOMPARE(survivors, int(early.size())); + } + + // With nothing expiring, this is now the ONLY thing that removes a + // fly short of the pointer arriving, so it has to work. + void count_lessRubbish_shrinksTheSwarm() + { + FlySim s = makeSim(40, 1.0f); + for (int i = 0; i < 300; ++i) s.step(0.05f); + const int before = int(s.flies().size()); + s.setFullness(0.15f); + for (int i = 0; i < 600; ++i) s.step(0.05f); + const int after = int(s.flies().size()); + qInfo("less rubbish: swarm %d -> %d", before, after); + QVERIFY2(after < before, "swarm does not shrink with the bin"); + QVERIFY2(after >= 1, "swarm shrank past the floor"); + } + + void movement_bothModesOccur() + { + FlySim s = makeSim(40, 1.0f); + int crawlSamples = 0, flySamples = 0; + for (int i = 0; i < 800; ++i) { + s.step(0.05f); + for (const Fly &f : s.flies()) + (f.mode == FlyMode::Crawling ? crawlSamples : flySamples)++; + } + const double crawlShare = + double(crawlSamples) / std::max(1, crawlSamples + flySamples); + qInfo("mode mix: %.0f%% crawling, %.0f%% flying", + crawlShare * 100, (1 - crawlShare) * 100); + // Both modes must be a real part of the picture. The exact split + // is a taste setting spread across landChance, the mode durations + // and preferLanded, and it is tuned by eye — this is a guard + // against either mode collapsing, NOT a target. Do not narrow it + // to whatever the current tuning happens to produce. + QVERIFY2(crawlShare >= 0.30 && crawlShare <= 0.90, + "movement mix has collapsed; flies should both land and fly"); + } + + // A fly winking out over the icon reads as it ceasing to exist; the + // bin behind it is a fixed reference the eye anchors on. + void fade_happensClearOfTheBin() + { + const qreal side = 40.0; + const QRectF bin(500, 500, side, side * 0.7); + FlySim s = makeSim(side, 1.0f, 31337); + int over = 0, lv = 0, ar = 0, tot = 0; + for (int i = 0; i < 2400; ++i) { // 120s + s.step(0.05f); + for (const Fly &f : s.flies()) { + ++tot; + if (f.fade < 0.85f && bin.contains(f.pos)) + { ++over; (f.leaving ? lv : ar)++; } + } + } + const double share = double(over) / std::max(1, tot); + qInfo("mid-fade over the bin: %.1f%% of samples (leaving %d, arriving %d)", + share * 100, lv, ar); + // Not zero, and shouldn't be forced to zero: `bin` is the Dock's + // square tile, which is larger than the drawn bin, so a fly + // clipping its corner is usually over empty pixels. What this + // guards is the regression where flies routinely materialise and + // wink out against the icon. + QVERIFY2(share <= 0.02, "flies appear or vanish on top of the bin"); + } + + // A fly whose velocity collapses spins on the spot: the sprite's + // heading comes from the velocity vector, so at near-zero speed tiny + // changes swing it wildly. The cause has always been two steering + // terms cancelling — most recently the outward push for fading flies + // against the containment shove. + void movement_fliersDoNotStall() + { + FlySim s = makeSim(40, 1.0f, 8191); + int stalled = 0, flying = 0; + for (int i = 0; i < 2400; ++i) { + s.step(0.05f); + for (const Fly &f : s.flies()) { + if (f.mode != FlyMode::Flying || f.leaving) continue; + ++flying; + if (std::hypot(f.vel.x(), f.vel.y()) < 0.15 * 52.0) ++stalled; + } + } + const double share = double(stalled) / std::max(1, flying); + qInfo("stalled fliers: %.1f%% of flying samples", share * 100); + QVERIFY2(share <= 0.08, "fliers stall and spin on the spot"); + } + + // The bin rests on the floor of the Dock. A fly drawn behind it, or + // crawling on it, must never be below its bottom edge — there is no + // space there to move through, so the silhouette can only be entered + // or left at the left, top or right. + void depth_nothingPassesUnderTheBin() + { + const qreal side = 40.0; + const QRectF bin(500, 500, side, side * 0.7); + FlySim s = makeSim(side, 1.0f, 2027); + int under = 0; + for (int i = 0; i < 2400; ++i) { + s.step(0.05f); + for (const Fly &f : s.flies()) { + if (f.inFront && !f.onSurface) continue; // in front: fine + if (std::abs(f.pos.x() - bin.center().x()) >= bin.width() * 0.5) + continue; // clear of the icon + if (f.pos.y() > bin.bottom() + 0.5) ++under; + } + } + qInfo("occluded flies under the bin: %d", under); + QCOMPARE(under, 0); + } + + // Depth is only re-decided while a fly is clear of the icon and only + // for flies heading toward it, so it is easy for this to collapse + // without anyone noticing in the params. + void depth_someFlightsPassInFront() + { + const qreal side = 40.0; + const QRectF bin(500, 500, side, side * 0.7); + FlySim s = makeSim(side, 1.0f, 5150); + int over = 0, inFront = 0; + for (int i = 0; i < 2400; ++i) { + s.step(0.05f); + for (const Fly &f : s.flies()) { + if (f.onSurface || !bin.contains(f.pos)) continue; + ++over; + if (f.inFront) ++inFront; + } + } + const double share = double(inFront) / std::max(1, over); + qInfo("flights across the bin: %.0f%% in front", share * 100); + // A floor, not a target: the front/behind balance is a taste + // setting (params.frontShare), and this only catches it + // collapsing to nothing. + QVERIFY2(share >= 0.12, "almost nothing flies in front of the bin"); + } + + void cursor_onTheBinClearsAndRestoresTheSwarm() + { + const qreal side = 40.0; + const QRectF bin(500, 500, side, side * 0.7); + FlySim s = makeSim(side, 1.0f); + for (int i = 0; i < 200; ++i) s.step(0.05f); + QVERIFY2(!s.flies().isEmpty(), "no swarm to clear"); + s.setCursor(bin.center(), true); + int steps = 0; + while (!s.flies().isEmpty() && steps < 400) { s.step(0.05f); ++steps; } + qInfo("pointer on bin: cleared in %.1fs", steps * 0.05); + QVERIFY2(s.flies().isEmpty(), "swarm does not clear for the pointer"); + // They must SCATTER first and fade second. Clearing almost + // instantly means they dissolved where they stood, which reads as + // the pointer killing flies rather than scaring them off. + QVERIFY2(steps * 0.05 >= 0.5, "flies fade out instead of scattering"); + // ...and stays away while it's there. + for (int i = 0; i < 400; ++i) s.step(0.05f); + QVERIFY2(s.flies().isEmpty(), "flies respawn under the pointer"); + // ...and comes back once it leaves. + s.setCursor(QPointF(-9999, -9999), false); + steps = 0; + while (s.flies().isEmpty() && steps < 400) { s.step(0.05f); ++steps; } + qInfo("pointer away: swarm back in %.1fs", steps * 0.05); + QVERIFY2(!s.flies().isEmpty(), "swarm never returns"); + } + + // The scatter radius was once ~2 icon widths, which meant a pointer + // resting anywhere near the Dock held the whole swarm off the bin and + // crawling stopped entirely. Only a pointer ON the bin clears it. + void cursor_besideTheBinStillAllowsLanding() + { + const qreal side = 40.0; + const QRectF bin(500, 500, side, side * 0.7); + FlySim s = makeSim(side, 1.0f, 6060); + s.setCursor(QPointF(bin.center().x() + side * 1.1, bin.center().y()), true); + int crawl = 0, total = 0; + for (int i = 0; i < 1600; ++i) { + s.step(0.05f); + for (const Fly &f : s.flies()) { + ++total; + if (f.mode == FlyMode::Crawling) ++crawl; + } + } + const double share = double(crawl) / std::max(1, total); + qInfo("pointer beside the bin: %.0f%% still crawling", share * 100); + QVERIFY2(share >= 0.10, "a pointer near the bin stops flies landing"); + } + + // The bolt used to clamp the fly's remaining life, so the sequence + // played as pause-then-vanish instead of pause-then-fly-away. + void startle_isABurstNotADeath() + { + FlySim s = makeSim(40, 1.0f, 4711); + s.params.startleChance = 0.25f; // startle constantly + int frozen = 0, boltingSeen = 0, boltRetiring = 0; + for (int i = 0; i < 1200; ++i) { + s.step(0.05f); + for (const Fly &f : s.flies()) { + if (f.freezeLeft > 0.0f) ++frozen; + if (f.bolting) { + ++boltingSeen; + if (f.retiring || f.leaving) ++boltRetiring; + } + } + } + qInfo("startle: %d frozen samples, %d bolting, %d of those leaving", + frozen, boltingSeen, boltRetiring); + QVERIFY2(frozen > 0, "flies never freeze"); + QVERIFY2(boltingSeen > 0, "flies freeze but never bolt"); + // A startle is a burst, not a death: the fly rejoins the swarm + // afterwards. Flies have no lifespan at all now, so any bolting + // fly that is also leaving was retired for an unrelated reason. + QVERIFY2(boltRetiring <= boltingSeen / 2, + "bolting flies are being retired immediately"); + } + + // The intended read is: fly in, touch down, sit still, walk a bit, + // stop again, leave. The stillness either side of the walk is the + // part that makes it look deliberate, so check it actually happens. + void landing_playsTheWholeSequence() + { + FlySim s = makeSim(40, 1.0f, 1234); + int still = 0, crawlSamples = 0; + bool sawSettle = false, sawWalk = false, sawPreflight = false; + for (int i = 0; i < 2400; ++i) { + s.step(0.05f); + for (const Fly &f : s.flies()) { + if (f.mode != FlyMode::Crawling) continue; + ++crawlSamples; + if (std::hypot(f.vel.x(), f.vel.y()) < 0.01) ++still; + if (f.crawlStage == 0 && f.pauseLeft > 0) sawSettle = true; + if (f.crawlStage == 1) sawWalk = true; + if (f.crawlStage == 2 && f.pauseLeft > 0) sawPreflight = true; + } + } + const double stillShare = double(still) / std::max(1, crawlSamples); + qInfo("landed flies: %.0f%% of crawl time held still", stillShare * 100); + QVERIFY2(sawSettle, "flies never settle after landing"); + QVERIFY2(sawWalk, "flies land but never walk"); + QVERIFY2(sawPreflight, "flies never pause before taking off"); + QVERIFY2(stillShare >= 0.15, "landed flies barely stop moving"); + } + + // The sim used to treat the bin's bounding rect as landable while the + // renderer clipped crawling flies to the artwork's alpha. The artwork + // covers under half its tile, so most "landed" flies were invisible: + // a busy sim and an empty screen. + void landing_happensOnTheArtworkNotTheBoundingBox() + { + const qreal side = 40.0; + FlySim s = makeSim(side, 1.0f, 24680); + s.setSurface(binSilhouette(24), 24, 24); + + int landed = 0, landedOffArt = 0; + int bareFrames = 0, frames = 0; + int clippedOnArt = 0, unclippedOffArt = 0; + for (int i = 0; i < 2400; ++i) { + s.step(0.05f); + ++frames; + int onBin = 0; + for (const Fly &f : s.flies()) { + if (f.mode != FlyMode::Crawling || f.leaving) continue; + ++landed; + ++onBin; + const bool onArt = s.onSurfaceAt(f.pos); + if (!onArt) ++landedOffArt; + // The clip flag has to mean exactly one thing: this fly's + // centre has left the silhouette. Anything looser and the + // renderer goes back to slicing wings off flies that are + // plainly standing on the bin. + if (f.clipToBin && onArt) ++clippedOnArt; + if (!f.clipToBin && !onArt) ++unclippedOffArt; + } + if (onBin == 0) ++bareFrames; + } + QVERIFY2(clippedOnArt == 0 && unclippedOffArt == 0, + "clipToBin does not track the fly leaving the silhouette"); + const double offShare = double(landedOffArt) / std::max(1, landed); + const double bareShare = double(bareFrames) / frames; + qInfo("landed off the artwork: %.1f%%; bin bare %.1f%% of frames", + offShare * 100, bareShare * 100); + QVERIFY2(offShare <= 0.05, + "flies land where the renderer will clip them away"); + // ...and the bin should essentially always have someone on it. + QVERIFY2(bareShare <= 0.12, "the bin is left empty too often"); + } + + // Landing used to check only "am I over the bin", not "am I in + // front". A fly that arrived behind it is masked out completely, so + // landing there flipped it from the hidden batch to the clipped-to- + // surface batch and it appeared out of nowhere on the front. + void landing_neverBehindTheBinAndNothingPopsIn() + { + const qreal side = 40.0; + FlySim s = makeSim(side, 1.0f, 97531); + s.setSurface(binSilhouette(24), 24, 24); + + std::map wasHidden; // masked out last frame? + int landedBehind = 0, pops = 0; + for (int i = 0; i < 3000; ++i) { + s.step(0.05f); + std::map now; + for (const Fly &f : s.flies()) { + if (f.mode == FlyMode::Crawling && !f.inFront && !f.leaving) + ++landedBehind; + // A fly is invisible when it is behind the bin and over + // it: the mask erases it entirely. + const bool hidden = !f.inFront && !f.onSurface + && s.onSurfaceAt(f.pos); + const bool visible = !hidden && f.fade > 0.5f; + auto it = wasHidden.find(f.id); + if (it != wasHidden.end() && it->second && visible + && s.onSurfaceAt(f.pos)) + ++pops; // went from hidden to visible ON the bin + now[f.id] = hidden; + } + wasHidden.swap(now); + } + qInfo("landed while behind: %d; appeared on the bin: %d", + landedBehind, pops); + QVERIFY2(landedBehind == 0, + "flies land behind the bin, where they cannot be seen"); + QVERIFY2(pops == 0, "flies pop into view on top of the bin"); + } + + // A crawler used to take its heading from the noise field sampled at + // its own position, which changed as fast as it moved — so it pivoted + // on the spot rather than walking anywhere. + void walking_coversGround() + { + const qreal side = 40.0; + FlySim s = makeSim(side, 1.0f, 13579); + s.setSurface(binSilhouette(24), 24, 24); + + // Follow individual flies by id (the vector index is not stable — + // flies are removed from the middle) and score each continuous + // walking bout by how much of the distance walked was progress. + struct Bout { QPointF start, prev; double path = 0, net = 0; }; + std::map bouts; + std::vector scores; + for (int i = 0; i < 1600; ++i) { + s.step(0.05f); + std::map alive; + for (const Fly &f : s.flies()) { + const bool walking = f.mode == FlyMode::Crawling + && f.crawlStage == 1 && f.pauseLeft <= 0.0f + && !f.leaving; + alive[f.id] = walking; + auto it = bouts.find(f.id); + if (!walking) { + if (it != bouts.end()) { + if (it->second.path > 4.0) + scores.push_back(it->second.net / it->second.path); + bouts.erase(it); + } + continue; + } + if (it == bouts.end()) { + Bout b; b.start = f.pos; b.prev = f.pos; + bouts[f.id] = b; + continue; + } + Bout &b = it->second; + b.path += std::hypot(f.pos.x() - b.prev.x(), f.pos.y() - b.prev.y()); + b.prev = f.pos; + b.net = std::max(b.net, + std::hypot(f.pos.x() - b.start.x(), + f.pos.y() - b.start.y())); + } + for (auto it = bouts.begin(); it != bouts.end();) + it = alive.count(it->first) ? std::next(it) : bouts.erase(it); + } + double sum = 0; + for (double v : scores) sum += v; + const double straightness = scores.empty() ? 0.0 : sum / scores.size(); + qInfo("walking: %zu bouts, %.0f%% of distance walked was progress", + scores.size(), straightness * 100); + QVERIFY2(scores.size() >= 5, "flies hardly ever walk"); + // A fly shuffling in place scores near zero however fast its legs + // move; one walking a line scores near 1. + QVERIFY2(straightness >= 0.35, + "crawlers shuffle on the spot instead of walking"); + } + + // --- containment: must fit the asymmetric overlay margins --- + void containment_staysInsideTheOverlay_data() + { + QTest::addColumn("side"); + QTest::newRow("28px") << 28.0; + QTest::newRow("40px") << 40.0; + QTest::newRow("95px") << 95.0; + } + + void containment_staysInsideTheOverlay() + { + QFETCH(qreal, side); + FlySim s = makeSim(side, 1.0f, 4242); + const QPointF c(500 + side / 2, 500 + side * 0.35); + double worstX = 0, worstUp = 0, worstDown = 0; + for (int i = 0; i < 1200; ++i) { + s.step(0.05f); + for (const Fly &f : s.flies()) { + worstX = std::max(worstX, std::abs(f.pos.x() - c.x())); + const double dy = f.pos.y() - c.y(); + if (dy < 0) worstUp = std::max(worstUp, -dy); + else worstDown = std::max(worstDown, dy); + } + } + const int mx = FlySim::marginX(side); + const int mt = FlySim::marginTop(side); + const int mb = FlySim::marginBottom(side); + qInfo("icon %3.0fpx: x %.0f/%d up %.0f/%d down %.0f/%d", + side, worstX, mx, worstUp, mt, worstDown, mb); + QVERIFY2(worstX <= mx, "swarm escapes the overlay sideways"); + QVERIFY2(worstUp <= mt, "swarm escapes the overlay upward"); + QVERIFY2(worstDown <= mb, "swarm escapes the overlay downward"); + } + + // --- scaling: the swarm grows with a magnified Dock tile --- + void scaling_swarmGrowsWithTheIcon() + { + auto spread = [](qreal side) { + FlySim s = makeSim(side, 1.0f, 999); + const QPointF c(500 + side / 2, 500 + side * 0.35); + double sum = 0; int n = 0; + for (int i = 0; i < 600; ++i) { + s.step(0.05f); + for (const Fly &f : s.flies()) { + sum += std::hypot(f.pos.x() - c.x(), f.pos.y() - c.y()); + ++n; + } + } + return n ? sum / n : 0.0; + }; + const double ratio = spread(95) / spread(40); + qInfo("spread scales x%.2f for a x2.38 icon", ratio); + QVERIFY2(ratio >= 1.7 && ratio <= 3.1, + "swarm does not scale with the icon"); + } + + void determinism_sameSeedSameSwarm() + { + FlySim a = makeSim(40, 1.0f, 777), b = makeSim(40, 1.0f, 777); + for (int i = 0; i < 200; ++i) { a.step(0.05f); b.step(0.05f); } + QCOMPARE(a.flies().size(), b.flies().size()); + if (!a.flies().isEmpty()) + QCOMPARE(a.flies()[0].pos, b.flies()[0].pos); + } + + // --- idle: an emptied bin must stop rendering entirely --- + void idle_emptiedBinStopsRendering() + { + FlySim s = makeSim(40, 1.0f); + for (int i = 0; i < 200; ++i) s.step(0.05f); + s.setFullness(0.0f); + int steps = 0; + while (!s.isIdle() && steps < 4000) { s.step(0.05f); ++steps; } + qInfo("emptied: idle after %.1fs (%lld left)", + steps * 0.05, (long long)s.flies().size()); + QVERIFY2(s.isIdle(), "swarm never went idle"); + } +}; + +QTEST_APPLESS_MAIN(TstFlySim) +#include "tst_fly_sim.moc" diff --git a/tests/ooze/CMakeLists.txt b/tests/ooze/CMakeLists.txt new file mode 100644 index 0000000..d5d6f2e --- /dev/null +++ b/tests/ooze/CMakeLists.txt @@ -0,0 +1,34 @@ +qt_add_executable(tst_ooze_sim + tst_ooze_sim.cpp + ${CMAKE_SOURCE_DIR}/src/core/OozeSim.cpp +) +target_link_libraries(tst_ooze_sim PRIVATE test_common Qt6::Test Qt6::Core Qt6::Gui) + +# Gui, not just Core: the distance field is a QImage. It needs no display. +qt_add_executable(tst_distance_field + tst_distance_field.cpp + ${CMAKE_SOURCE_DIR}/src/core/DistanceField.cpp +) +target_link_libraries(tst_distance_field PRIVATE test_common Qt6::Test Qt6::Core Qt6::Gui) + +add_test(NAME Ooze.levelNearlyEmptyBinStaysClean + COMMAND tst_ooze_sim level_nearlyEmptyBinStaysClean) +add_test(NAME Ooze.levelFullBinCreepsRatherThanSnapping + COMMAND tst_ooze_sim level_fullBinCreepsRatherThanSnapping) +add_test(NAME Ooze.levelGrowsWithTheTrash + COMMAND tst_ooze_sim level_growsWithTheTrash) +add_test(NAME Ooze.levelEmptyingRecedesCompletely + COMMAND tst_ooze_sim level_emptyingRecedesCompletely) +add_test(NAME Ooze.marginsLeaveRoomForADrip + COMMAND tst_ooze_sim margins_leaveRoomForADripAtFullStretch) + +add_test(NAME DistanceField.buildProducesAField + COMMAND tst_distance_field build_producesAField) +add_test(NAME DistanceField.signIsNegativeInsideAndPositiveOutside + COMMAND tst_distance_field sign_isNegativeInsideAndPositiveOutside) +add_test(NAME DistanceField.gradientIsMonotonicAwayFromTheBin + COMMAND tst_distance_field gradient_isMonotonicAwayFromTheBin) + +# Windows: put Qt's DLLs on PATH for these tests. Must be called here, +# in the same directory scope as the add_test calls above. +hyperbin_fix_test_path() diff --git a/tests/ooze/tst_distance_field.cpp b/tests/ooze/tst_distance_field.cpp new file mode 100644 index 0000000..eb96c32 --- /dev/null +++ b/tests/ooze/tst_distance_field.cpp @@ -0,0 +1,79 @@ +// The signed distance field of the bin's silhouette. +// +// Everything the ooze does hangs off this being right — the coating's edge, +// the meniscus cut against the outline, where a drip may start — and it is +// the one part of that effect that CAN be checked without a screen. +#include + +#include "DistanceField.h" +#include "test_helpers.h" + +#include +#include + +using namespace hyperbin; +using hyperbin::test::binSilhouette; + +class TstDistanceField : public QObject +{ + Q_OBJECT + +private: + static constexpr int kGrid = 24; + static constexpr float kRange = 20.0f; + + static QImage field() + { + return buildSignedDistanceField(binSilhouette(kGrid), kGrid, kGrid, + 128, kRange); + } + + /// Sample in normalised coordinates, back in field cells. + static double sampleAt(const QImage &f, double u, double v) + { + const int x = std::clamp(int(u * f.width()), 0, f.width() - 1); + const int y = std::clamp(int(v * f.height()), 0, f.height() - 1); + return (f.constScanLine(y)[x] / 255.0 * 2.0 - 1.0) * kRange; + } + +private slots: + void build_producesAField() + { + const QImage f = field(); + QVERIFY2(!f.isNull(), "no distance field produced"); + QCOMPARE(f.width(), 128); + } + + // Deep inside the bin is negative, well outside is positive, and the + // sign flips across the boundary. A field with the sign the wrong way + // round coats the desktop instead of the bin. + void sign_isNegativeInsideAndPositiveOutside() + { + const QImage f = field(); + const double inside = sampleAt(f, 0.5, 0.6); + const double above = sampleAt(f, 0.5, 0.02); + const double beside = sampleAt(f, 0.02, 0.6); + qInfo("field: inside %.1f, above %.1f, beside %.1f (cells)", + inside, above, beside); + QVERIFY2(inside < 0, "the field says the bin's middle is outside it"); + QVERIFY2(above > 0, "the field says above the lid is inside"); + QVERIFY2(beside > 0, "the field says beside the bin is inside"); + } + + // Monotonic away from the surface: a field that is not makes the + // coating's edge crawl instead of sitting still. + void gradient_isMonotonicAwayFromTheBin() + { + const QImage f = field(); + double prev = -1e9; + for (int i = 0; i < 12; ++i) { + const double d = sampleAt(f, 0.5, 0.55 - i * 0.04); + QVERIFY2(d >= prev - 0.35, + "the field is not monotonic away from the bin"); + prev = d; + } + } +}; + +QTEST_APPLESS_MAIN(TstDistanceField) +#include "tst_distance_field.moc" diff --git a/tests/ooze/tst_ooze_sim.cpp b/tests/ooze/tst_ooze_sim.cpp new file mode 100644 index 0000000..3d271b2 --- /dev/null +++ b/tests/ooze/tst_ooze_sim.cpp @@ -0,0 +1,123 @@ +// The coating's level, which is all OozeSim still owns. +// +// The shape and the drips live in the shader, driven by a signed distance +// field of the bin's artwork — see tst_distance_field for the half of this +// effect that CAN be checked without a screen. What is left here is how far +// the gel has crept and whether anything is still changing. +#include + +#include "OozeSim.h" + +#include + +using namespace hyperbin; + +namespace { + +/// A bin with the given fullness, run until the level settles. +float settledLevel(float fullness) +{ + OozeSim s; + s.setBinRect(QRectF(500, 500, 40, 28)); + s.setFullness(fullness); + for (int i = 0; i < 600; ++i) s.step(0.05f); + return s.level(); +} + +} // namespace + +class TstOozeSim : public QObject +{ + Q_OBJECT + +private slots: + // A bin with one item in it is not oozing; the effect is a warning, + // not a constant. + void level_nearlyEmptyBinStaysClean() + { + OozeSim o; + o.setBinRect(QRectF(500, 500, 40, 28)); + o.setFullness(0.05f); + for (int i = 0; i < 400; ++i) o.step(0.05f); + QVERIFY2(o.isEmpty(), "a nearly-empty bin oozes"); + QVERIFY2(o.isAtRest(), "a clean bin never rests"); + } + + // A full one gets coated, and CREEPS rather than snapping on. The + // level is a critically damped spring for exactly this reason: a rate + // is a straight line, and nothing thick moves like that. + void level_fullBinCreepsRatherThanSnapping() + { + OozeSim o; + o.setBinRect(QRectF(500, 500, 40, 28)); + o.setFullness(1.0f); + o.step(0.05f); + const float afterOneStep = o.level(); + for (int i = 0; i < 400; ++i) o.step(0.05f); + const float full = o.level(); + qInfo("coating: %.2f after one step, %.2f settled", afterOneStep, full); + QVERIFY2(afterOneStep < full * 0.5f, + "the coating snaps on instead of creeping"); + QVERIFY2(full >= 0.5f, "a full bin is barely coated"); + QVERIFY2(!o.isEmpty(), "a full bin shows no ooze"); + } + + void level_growsWithTheTrash() + { + const float half = settledLevel(0.5f); + const float full = settledLevel(1.0f); + qInfo("coating level: %.2f at half, %.2f at full", half, full); + QVERIFY2(full > half * 1.15f, "the coating does not grow with the trash"); + } + + // Emptying recedes it away completely, and only then does it rest. + void level_emptyingRecedesCompletely() + { + OozeSim o; + o.setBinRect(QRectF(500, 500, 40, 28)); + o.setFullness(1.0f); + for (int i = 0; i < 400; ++i) o.step(0.05f); + + o.setFullness(0.0f); + int steps = 0; + while (!o.isEmpty() && steps < 2000) { o.step(0.05f); ++steps; } + qInfo("emptied: receded in %.1fs", steps * 0.05); + QVERIFY2(o.isEmpty(), "ooze never recedes"); + QVERIFY2(steps >= 4, "ooze vanishes instead of receding"); + // This used to assert that the ooze asked to be run slowly, on the + // reasoning that an effect which cannot rest has no other lever on + // its cost. Both halves turned out to be wrong: NO effect here + // rests (every isAtRest() collapses to isEmpty()), so it was not a + // property of this one — and the per-effect cap was overriding the + // power policy rather than cooperating with it, so the gel ran at + // 30fps on a 120Hz display. Cadence is core/PowerPolicy's alone + // now, which is where the low-power decision already lives. + QVERIFY2(o.isAtRest(), "receded ooze never rests"); + } + + // The overlay has to be tall enough for a drip at full stretch. + // Nothing else can check this: the drip lives in the shader, so a + // margin that is too short shows as the drop being sliced off at + // the window edge exactly as it lets go — the one frame anyone + // would notice. + void margins_leaveRoomForADripAtFullStretch_data() + { + QTest::addColumn("icon"); + QTest::newRow("28px") << 28.0; + QTest::newRow("40px") << 40.0; + QTest::newRow("95px") << 95.0; + } + + void margins_leaveRoomForADripAtFullStretch() + { + QFETCH(qreal, icon); + const double reach = icon * OozeSim::kDripReach * OozeSim::kDripStretch; + const int mb = OozeSim::marginBottom(icon); + qInfo("ooze %3.0fpx: drip reaches %.0f, margin %d", icon, reach, mb); + QVERIFY2(mb >= reach, + "the overlay is too short for a drip at full stretch"); + } +}; + +QTEST_APPLESS_MAIN(TstOozeSim) +#include "tst_ooze_sim.moc" diff --git a/tests/settings/CMakeLists.txt b/tests/settings/CMakeLists.txt new file mode 100644 index 0000000..75f350a --- /dev/null +++ b/tests/settings/CMakeLists.txt @@ -0,0 +1,19 @@ +qt_add_executable(tst_settings + tst_settings.cpp + ${CMAKE_SOURCE_DIR}/src/app/Settings.cpp +) + +target_link_libraries(tst_settings PRIVATE test_common Qt6::Test Qt6::Core Qt6::Gui) + +add_test(NAME Settings.densityFixedStepsAreThirds + COMMAND tst_settings density_fixedStepsAreThirds) +add_test(NAME Settings.densityRelativeScalesWithTheThreshold + COMMAND tst_settings density_relativeScalesWithTheThreshold) +add_test(NAME Settings.densityWithoutASizeDrawsNothing + COMMAND tst_settings density_withoutASizeDrawsNothing) +add_test(NAME Settings.persistenceSurvivesBeingKilled + COMMAND tst_settings persistence_survivesBeingKilled) + +# Windows: put Qt's DLLs on PATH for these tests. Must be called here, +# in the same directory scope as the add_test calls above. +hyperbin_fix_test_path() diff --git a/tests/settings/tst_settings.cpp b/tests/settings/tst_settings.cpp new file mode 100644 index 0000000..962ac4e --- /dev/null +++ b/tests/settings/tst_settings.cpp @@ -0,0 +1,137 @@ +// What the preferences mean, and that they survive being killed. +#include + +#include "Settings.h" + +#include +#include + +// _exit(), for the kill-the-writer half of the persistence test. POSIX +// declares it in , which does not exist on MSVC; there it lives +// in . Same function either way — the point is to leave without +// running a single destructor. +#if defined(_WIN32) +#include +#else +#include +#endif + +#include + +using namespace hyperbin; + +class TstSettings : public QObject +{ + Q_OBJECT + +private slots: + // The three fixed steps are thirds by design, so they line up with + // the relative mode's scale instead of being arbitrary numbers. + void density_fixedStepsAreThirds() + { + // Throwaway store: never the user's real preferences. + Settings s(nullptr, QStringLiteral("hyperbin-selftest")); + const qint64 mb = 1024 * 1024; + + s.setDensity(Settings::Density::Few); + QVERIFY2(std::abs(s.fullnessFor(0, 0) - 1.0 / 3.0) < 1e-9, + "'a few flies' is not a third"); + s.setDensity(Settings::Density::Lots); + QVERIFY2(std::abs(s.fullnessFor(0, 0) - 2.0 / 3.0) < 1e-9, + "'lots of flies' is not two thirds"); + s.setDensity(Settings::Density::TooMany); + QVERIFY2(std::abs(s.fullnessFor(0, 0) - 1.0) < 1e-9, + "'too many flies' is not full"); + // Fixed densities must ignore the bin entirely — that is the + // whole reason to offer them. + QVERIFY2(s.fullnessFor(0, 0) == s.fullnessFor(50 * mb, 12), + "a fixed density still looks at the trash"); + s.clearStore(); + } + + void density_relativeScalesWithTheThreshold() + { + Settings s(nullptr, QStringLiteral("hyperbin-selftest")); + const qint64 mb = 1024 * 1024; + + s.setDensity(Settings::Density::Relative); + s.setThreshold(Settings::Threshold::HundredMB); + QVERIFY2(s.fullnessFor(0, 0) == 0.0, "an empty bin should draw nothing"); + QVERIFY2(std::abs(s.fullnessFor(50 * mb, 3) - 0.5) < 1e-9, + "half the threshold should be half full"); + QVERIFY2(std::abs(s.fullnessFor(500 * mb, 3) - 1.0) < 1e-9, + "past the threshold should saturate, not overflow"); + s.setThreshold(Settings::Threshold::OneGB); + QVERIFY2(std::abs(s.fullnessFor(50 * mb, 3) - 50.0 / 1024.0) < 1e-9, + "threshold change does not rescale"); + s.clearStore(); + } + + // No size available means no Full Disk Access, and there is no longer + // a count to fall back to. Deliberate: Finder can be asked for a count + // without Full Disk Access but returns "missing value" for the size of + // any FOLDER, so a trashed app bundle or project directory weighed + // nothing — and the count fallback ignored the threshold outright, + // which made the whole Trash Threshold menu a no-op on a stock Mac. + // Better to draw nothing and say why than to draw something quietly + // wrong. + void density_withoutASizeDrawsNothing() + { + Settings s(nullptr, QStringLiteral("hyperbin-selftest")); + s.setDensity(Settings::Density::Relative); + s.setThreshold(Settings::Threshold::HundredMB); + QVERIFY2(s.fullnessFor(-1, 20) == 0.0, + "without a size, fullness must be zero, not guessed"); + s.clearStore(); + } + + // A menu-bar app is killed far more often than it is quit — the + // debugger's stop button, a logout, Force Quit — so "saved when we + // exit cleanly" is not saved at all. + // + // This binary re-runs ITSELF with --write-settings; that half sets two + // values and leaves via _exit, so nothing is flushed by a destructor. + // Doing it in one process proves nothing: QSettings keeps an + // in-process cache, so the second read would be answered from memory + // whether or not anything reached the disk. + void persistence_survivesBeingKilled() + { + const QString store = QStringLiteral("hyperbin-persisttest"); + Settings before(nullptr, store); + before.clearStore(); + + const QString self = QCoreApplication::applicationFilePath(); + QCOMPARE(QProcess::execute(self, {QStringLiteral("--write-settings")}), 0); + + // A fresh process wrote them; this one has never read this store, + // so its cache cannot answer for the disk. + Settings after(nullptr, store); + qInfo("across a restart: infestation=%s density=%d", + qPrintable(after.infestation()), int(after.density())); + const bool ok = after.infestation() == QStringLiteral("ooze") + && after.density() == Settings::Density::Lots; + after.clearStore(); + QVERIFY2(ok, "settings do not survive being killed"); + } +}; + +// Hand-written rather than QTEST_APPLESS_MAIN: the persistence test needs +// this same binary to act as the WRITER half, and that has to be handled +// before QTest ever looks at the arguments. +int main(int argc, char **argv) +{ + if (argc > 1 && QString::fromLocal8Bit(argv[1]) + == QStringLiteral("--write-settings")) { + Settings s(nullptr, QStringLiteral("hyperbin-persisttest")); + s.setInfestation(QStringLiteral("ooze")); + s.setDensity(Settings::Density::Lots); + _exit(0); + } + // QCoreApplication, not appless: applicationFilePath() is how the test + // finds itself to re-run, and it is empty without one. + QCoreApplication app(argc, argv); + TstSettings tc; + return QTest::qExec(&tc, argc, argv); +} + +#include "tst_settings.moc" diff --git a/tests/simtest.cpp b/tests/simtest.cpp deleted file mode 100644 index 4ee3110..0000000 --- a/tests/simtest.cpp +++ /dev/null @@ -1,766 +0,0 @@ -// Headless checks for the swarm's contract: how many flies, how long -// they live, where they're allowed to go, and that it all stops. -#include "FlySim.h" -#include "DistanceField.h" -#include -// _exit(), for the kill-the-writer half of the persistence test. POSIX -// declares it in , which does not exist on MSVC; there it lives -// in . Same function either way — the point is to leave -// without running a single destructor. -#if defined(_WIN32) -#include -#else -#include -#endif -#include "OozeSim.h" -#include "Settings.h" - -#include -#include -#include -#include -#include -#include -#include - -using namespace hyperbin; - -namespace { - -int fail(const char *msg) -{ - std::printf("FAIL: %s\n", msg); - return 1; -} - -FlySim makeSim(qreal side, float fullness, uint32_t seed = 12345) -{ - FlySim s(seed); - s.setBinRect(QRectF(500, 500, side, side * 0.7)); // Dock tiles are wide - s.setFullness(fullness); - return s; -} - -} // namespace - - -namespace { - -/// Coverage grid shaped like the real trash artwork: it fills only the -/// middle ~54% x 78% of its tile, which is the whole reason the sim needs -/// to know about it. -QVector binSilhouette(int n) -{ - QVector cov(n * n, 0); - for (int y = 0; y < n; ++y) { - const double v = double(y) / n; - if (v < 0.14 || v > 0.94) continue; // above the lid / below the base - const double half = 0.27 - 0.04 * (v - 0.2); // slight taper - for (int x = 0; x < n; ++x) { - const double u = double(x) / n - 0.5; - if (std::abs(u) <= half) - cov[y * n + x] = 1; - } - } - return cov; -} - -} // namespace - -int main(int argc, char **argv) -{ - // Writer half of the persistence test below: set two values and exit - // via _exit, so nothing is flushed by a destructor. If the settings - // still arrive, they were written through at the moment of the call. - if (argc > 1 && QString::fromLocal8Bit(argv[1]) == QStringLiteral("--write-settings")) { - Settings s(nullptr, QStringLiteral("hyperbin-persisttest")); - s.setInfestation(QStringLiteral("ooze")); - s.setDensity(Settings::Density::Lots); - _exit(0); - } - - // --- count: never more than 6, never fewer than 1 while occupied --- - { - FlySim s = makeSim(40, 1.0f); - int seen = 0, minSeen = 99; - for (int i = 0; i < 1200; ++i) { // 60s - s.step(0.05f); - seen = std::max(seen, int(s.flies().size())); - if (i > 40) // let the first one arrive - minSeen = std::min(minSeen, int(s.flies().size())); - } - std::printf("full bin over 60s: %d..%d flies\n", minSeen, seen); - if (seen > 6) return fail("more than 6 flies on screen"); - if (minSeen < 1) return fail("bin occupied but no flies"); - } - - // A nearly-empty bin still gets its lone fly. - { - FlySim s = makeSim(40, 0.03f); - int minSeen = 99, maxSeen = 0; - for (int i = 0; i < 600; ++i) { - s.step(0.05f); - if (i > 40) { - minSeen = std::min(minSeen, int(s.flies().size())); - maxSeen = std::max(maxSeen, int(s.flies().size())); - } - } - std::printf("nearly-empty bin: %d..%d flies\n", minSeen, maxSeen); - if (minSeen < 1) return fail("one item in the bin should still draw a fly"); - } - - // --- flies persist ------------------------------------------------ - // There is no lifespan. Rubbish does not stop attracting flies after - // a few seconds, and the constant churn of arrivals and departures - // was motion the eye kept getting drawn to for no reason. - { - FlySim s = makeSim(40, 1.0f); - for (int i = 0; i < 200; ++i) s.step(0.05f); // let it fill up - std::set early; - for (const Fly &f : s.flies()) early.insert(f.id); - if (early.empty()) return fail("no swarm to follow"); - - for (int i = 0; i < 1200; ++i) s.step(0.05f); // 60 more seconds - int survivors = 0; - for (const Fly &f : s.flies()) - if (early.count(f.id)) ++survivors; - std::printf("persistence: %d of %zu flies still around after 60s\n", - survivors, early.size()); - if (survivors < int(early.size())) - return fail("flies are disappearing on their own"); - } - - // --- the swarm shrinks when the bin does -------------------------- - // With nothing expiring, this is now the ONLY thing that removes a - // fly short of the pointer arriving, so it has to work. - { - FlySim s = makeSim(40, 1.0f); - for (int i = 0; i < 300; ++i) s.step(0.05f); - const int before = int(s.flies().size()); - s.setFullness(0.15f); - for (int i = 0; i < 600; ++i) s.step(0.05f); - const int after = int(s.flies().size()); - std::printf("less rubbish: swarm %d -> %d\n", before, after); - if (after >= before) return fail("swarm does not shrink with the bin"); - if (after < 1) return fail("swarm shrank past the floor"); - } - - // --- movement: both modes actually occur --- - { - FlySim s = makeSim(40, 1.0f); - int crawlSamples = 0, flySamples = 0; - for (int i = 0; i < 800; ++i) { - s.step(0.05f); - for (const Fly &f : s.flies()) - (f.mode == FlyMode::Crawling ? crawlSamples : flySamples)++; - } - const double crawlShare = - double(crawlSamples) / std::max(1, crawlSamples + flySamples); - std::printf("mode mix: %.0f%% crawling, %.0f%% flying\n", - crawlShare * 100, (1 - crawlShare) * 100); - // Both modes must be a real part of the picture. The exact split - // is a taste setting spread across landChance, the mode durations - // and preferLanded, and it is tuned by eye — this is a guard - // against either mode collapsing, NOT a target. Do not narrow it - // to whatever the current tuning happens to produce. - if (crawlShare < 0.30 || crawlShare > 0.90) - return fail("movement mix has collapsed; flies should both " - "land and fly"); - } - - // --- flies fade in and out clear of the bin ---------------------- - // A fly winking out over the icon reads as it ceasing to exist; the - // bin behind it is a fixed reference the eye anchors on. - { - const qreal side = 40.0; - const QRectF bin(500, 500, side, side * 0.7); - FlySim s = makeSim(side, 1.0f, 31337); - int over = 0, lv = 0, ar = 0, tot = 0; - for (int i = 0; i < 2400; ++i) { // 120s - s.step(0.05f); - for (const Fly &f : s.flies()) { - ++tot; - if (f.fade < 0.85f && bin.contains(f.pos)) - { ++over; (f.leaving ? lv : ar)++; } - } - } - const double share = double(over) / std::max(1, tot); - std::printf("mid-fade over the bin: %.1f%% of samples (leaving %d, arriving %d)\n", - share * 100, lv, ar); - // Not zero, and shouldn't be forced to zero: `bin` is the Dock's - // square tile, which is larger than the drawn bin, so a fly - // clipping its corner is usually over empty pixels. What this - // guards is the regression where flies routinely materialise and - // wink out against the icon. - if (share > 0.02) return fail("flies appear or vanish on top of the bin"); - } - // --- no stalled fliers ------------------------------------------- - // A fly whose velocity collapses spins on the spot: the sprite's - // heading comes from the velocity vector, so at near-zero speed tiny - // changes swing it wildly. The cause has always been two steering - // terms cancelling — most recently the outward push for fading flies - // against the containment shove. - { - FlySim s = makeSim(40, 1.0f, 8191); - int stalled = 0, flying = 0; - for (int i = 0; i < 2400; ++i) { - s.step(0.05f); - for (const Fly &f : s.flies()) { - if (f.mode != FlyMode::Flying || f.leaving) continue; - ++flying; - if (std::hypot(f.vel.x(), f.vel.y()) < 0.15 * 52.0) ++stalled; - } - } - const double share = double(stalled) / std::max(1, flying); - std::printf("stalled fliers: %.1f%% of flying samples\n", share * 100); - if (share > 0.08) return fail("fliers stall and spin on the spot"); - } - // --- nothing passes under the bin -------------------------------- - // The bin rests on the floor of the Dock. A fly drawn behind it, or - // crawling on it, must never be below its bottom edge — there is no - // space there to move through, so the silhouette can only be entered - // or left at the left, top or right. - { - const qreal side = 40.0; - const QRectF bin(500, 500, side, side * 0.7); - FlySim s = makeSim(side, 1.0f, 2027); - int under = 0; - for (int i = 0; i < 2400; ++i) { - s.step(0.05f); - for (const Fly &f : s.flies()) { - if (f.inFront && !f.onSurface) continue; // in front: fine - if (std::abs(f.pos.x() - bin.center().x()) >= bin.width() * 0.5) - continue; // clear of the icon - if (f.pos.y() > bin.bottom() + 0.5) ++under; - } - } - std::printf("occluded flies under the bin: %d\n", under); - if (under > 0) return fail("flies pass under the bin, through the Dock floor"); - } - // --- passes across the FRONT of the bin -------------------------- - // Depth is only re-decided while a fly is clear of the icon and only - // for flies heading toward it, so it is easy for this to collapse - // without anyone noticing in the params. - { - const qreal side = 40.0; - const QRectF bin(500, 500, side, side * 0.7); - FlySim s = makeSim(side, 1.0f, 5150); - int over = 0, inFront = 0; - for (int i = 0; i < 2400; ++i) { - s.step(0.05f); - for (const Fly &f : s.flies()) { - if (f.onSurface || !bin.contains(f.pos)) continue; - ++over; - if (f.inFront) ++inFront; - } - } - const double share = double(inFront) / std::max(1, over); - std::printf("flights across the bin: %.0f%% in front\n", share * 100); - // A floor, not a target: the front/behind balance is a taste - // setting (params.frontShare), and this only catches it - // collapsing to nothing. - if (share < 0.12) return fail("almost nothing flies in front of the bin"); - } - // --- pointer on the bin clears the swarm ------------------------- - { - const qreal side = 40.0; - const QRectF bin(500, 500, side, side * 0.7); - FlySim s = makeSim(side, 1.0f); - for (int i = 0; i < 200; ++i) s.step(0.05f); - if (s.flies().isEmpty()) return fail("no swarm to clear"); - s.setCursor(bin.center(), true); - int steps = 0; - while (!s.flies().isEmpty() && steps < 400) { s.step(0.05f); ++steps; } - std::printf("pointer on bin: cleared in %.1fs\n", steps * 0.05); - if (!s.flies().isEmpty()) return fail("swarm does not clear for the pointer"); - // They must SCATTER first and fade second. Clearing almost - // instantly means they dissolved where they stood, which reads as - // the pointer killing flies rather than scaring them off. - if (steps * 0.05 < 0.5) return fail("flies fade out instead of scattering"); - // ...and stays away while it's there. - for (int i = 0; i < 400; ++i) s.step(0.05f); - if (!s.flies().isEmpty()) return fail("flies respawn under the pointer"); - // ...and comes back once it leaves. - s.setCursor(QPointF(-9999, -9999), false); - steps = 0; - while (s.flies().isEmpty() && steps < 400) { s.step(0.05f); ++steps; } - std::printf("pointer away: swarm back in %.1fs\n", steps * 0.05); - if (s.flies().isEmpty()) return fail("swarm never returns"); - } - // --- a pointer NEAR the bin doesn't stop flies landing ------------ - // The scatter radius was once ~2 icon widths, which meant a pointer - // resting anywhere near the Dock held the whole swarm off the bin and - // crawling stopped entirely. Only a pointer ON the bin clears it. - { - const qreal side = 40.0; - const QRectF bin(500, 500, side, side * 0.7); - FlySim s = makeSim(side, 1.0f, 6060); - s.setCursor(QPointF(bin.center().x() + side * 1.1, bin.center().y()), true); - int crawl = 0, total = 0; - for (int i = 0; i < 1600; ++i) { - s.step(0.05f); - for (const Fly &f : s.flies()) { - ++total; - if (f.mode == FlyMode::Crawling) ++crawl; - } - } - const double share = double(crawl) / std::max(1, total); - std::printf("pointer beside the bin: %.0f%% still crawling\n", share * 100); - if (share < 0.10) return fail("a pointer near the bin stops flies landing"); - } - // --- startle is a burst, not a death ------------------------------ - // The bolt used to clamp the fly's remaining life, so the sequence - // played as pause-then-vanish instead of pause-then-fly-away. - { - FlySim s = makeSim(40, 1.0f, 4711); - s.params.startleChance = 0.25f; // startle constantly - int frozen = 0, boltingSeen = 0, boltRetiring = 0; - for (int i = 0; i < 1200; ++i) { - s.step(0.05f); - for (const Fly &f : s.flies()) { - if (f.freezeLeft > 0.0f) ++frozen; - if (f.bolting) { - ++boltingSeen; - if (f.retiring || f.leaving) ++boltRetiring; - } - } - } - std::printf("startle: %d frozen samples, %d bolting, %d of those leaving\n", - frozen, boltingSeen, boltRetiring); - if (frozen == 0) return fail("flies never freeze"); - if (boltingSeen == 0) return fail("flies freeze but never bolt"); - // A startle is a burst, not a death: the fly rejoins the swarm - // afterwards. Flies have no lifespan at all now, so any bolting - // fly that is also leaving was retired for an unrelated reason. - if (boltRetiring > boltingSeen / 2) - return fail("bolting flies are being retired immediately"); - } - // --- landing sequence -------------------------------------------- - // The intended read is: fly in, touch down, sit still, walk a bit, - // stop again, leave. The stillness either side of the walk is the - // part that makes it look deliberate, so check it actually happens. - { - FlySim s = makeSim(40, 1.0f, 1234); - int still = 0, crawlSamples = 0; - bool sawSettle = false, sawWalk = false, sawPreflight = false; - for (int i = 0; i < 2400; ++i) { - s.step(0.05f); - for (const Fly &f : s.flies()) { - if (f.mode != FlyMode::Crawling) continue; - ++crawlSamples; - if (std::hypot(f.vel.x(), f.vel.y()) < 0.01) ++still; - if (f.crawlStage == 0 && f.pauseLeft > 0) sawSettle = true; - if (f.crawlStage == 1) sawWalk = true; - if (f.crawlStage == 2 && f.pauseLeft > 0) sawPreflight = true; - } - } - const double stillShare = double(still) / std::max(1, crawlSamples); - std::printf("landed flies: %.0f%% of crawl time held still\n", - stillShare * 100); - if (!sawSettle) return fail("flies never settle after landing"); - if (!sawWalk) return fail("flies land but never walk"); - if (!sawPreflight) return fail("flies never pause before taking off"); - if (stillShare < 0.15) - return fail("landed flies barely stop moving"); - } - - // --- landed flies are on the ARTWORK, not just inside its box ----- - // The sim used to treat the bin's bounding rect as landable while the - // renderer clipped crawling flies to the artwork's alpha. The artwork - // covers under half its tile, so most "landed" flies were invisible: - // a busy sim and an empty screen. - { - const qreal side = 40.0; - const QRectF bin(500, 500, side, side * 0.7); - FlySim s = makeSim(side, 1.0f, 24680); - s.setSurface(binSilhouette(24), 24, 24); - - int landed = 0, landedOffArt = 0; - int bareFrames = 0, frames = 0; - int clippedOnArt = 0, unclippedOffArt = 0; - for (int i = 0; i < 2400; ++i) { - s.step(0.05f); - ++frames; - int onBin = 0; - for (const Fly &f : s.flies()) { - if (f.mode != FlyMode::Crawling || f.leaving) continue; - ++landed; - ++onBin; - const bool onArt = s.onSurfaceAt(f.pos); - if (!onArt) ++landedOffArt; - // The clip flag has to mean exactly one thing: this fly's - // centre has left the silhouette. Anything looser and the - // renderer goes back to slicing wings off flies that are - // plainly standing on the bin. - if (f.clipToBin && onArt) ++clippedOnArt; - if (!f.clipToBin && !onArt) ++unclippedOffArt; - } - if (onBin == 0) ++bareFrames; - } - if (clippedOnArt || unclippedOffArt) - return fail("clipToBin does not track the fly leaving the silhouette"); - const double offShare = double(landedOffArt) / std::max(1, landed); - const double bareShare = double(bareFrames) / frames; - std::printf("landed off the artwork: %.1f%%; bin bare %.1f%% of frames\n", - offShare * 100, bareShare * 100); - if (offShare > 0.05) - return fail("flies land where the renderer will clip them away"); - // ...and the bin should essentially always have someone on it. - if (bareShare > 0.12) - return fail("the bin is left empty too often"); - } - - // --- nothing lands behind the bin, and nothing pops into view ----- - // Landing used to check only "am I over the bin", not "am I in - // front". A fly that arrived behind it is masked out completely, so - // landing there flipped it from the hidden batch to the clipped-to- - // surface batch and it appeared out of nowhere on the front. - { - const qreal side = 40.0; - FlySim s = makeSim(side, 1.0f, 97531); - s.setSurface(binSilhouette(24), 24, 24); - - std::map wasHidden; // masked out last frame? - int landedBehind = 0, pops = 0; - for (int i = 0; i < 3000; ++i) { - s.step(0.05f); - std::map now; - for (const Fly &f : s.flies()) { - if (f.mode == FlyMode::Crawling && !f.inFront && !f.leaving) - ++landedBehind; - // A fly is invisible when it is behind the bin and over - // it: the mask erases it entirely. - const bool hidden = !f.inFront && !f.onSurface - && s.onSurfaceAt(f.pos); - const bool visible = !hidden && f.fade > 0.5f; - auto it = wasHidden.find(f.id); - if (it != wasHidden.end() && it->second && visible - && s.onSurfaceAt(f.pos)) - ++pops; // went from hidden to visible ON the bin - now[f.id] = hidden; - } - wasHidden.swap(now); - } - std::printf("landed while behind: %d; appeared on the bin: %d\n", - landedBehind, pops); - if (landedBehind > 0) - return fail("flies land behind the bin, where they cannot be seen"); - if (pops > 0) - return fail("flies pop into view on top of the bin"); - } - - // --- walking covers ground --------------------------------------- - // A crawler used to take its heading from the noise field sampled at - // its own position, which changed as fast as it moved — so it pivoted - // on the spot rather than walking anywhere. - { - const qreal side = 40.0; - FlySim s = makeSim(side, 1.0f, 13579); - s.setSurface(binSilhouette(24), 24, 24); - - // Follow individual flies by id (the vector index is not stable — - // flies are removed from the middle) and score each continuous - // walking bout by how much of the distance walked was progress. - struct Bout { QPointF start, prev; double path = 0, net = 0; }; - std::map bouts; - std::vector scores; - for (int i = 0; i < 1600; ++i) { - s.step(0.05f); - std::map alive; - for (const Fly &f : s.flies()) { - const bool walking = f.mode == FlyMode::Crawling - && f.crawlStage == 1 && f.pauseLeft <= 0.0f - && !f.leaving; - alive[f.id] = walking; - auto it = bouts.find(f.id); - if (!walking) { - if (it != bouts.end()) { - if (it->second.path > 4.0) - scores.push_back(it->second.net / it->second.path); - bouts.erase(it); - } - continue; - } - if (it == bouts.end()) { - Bout b; b.start = f.pos; b.prev = f.pos; - bouts[f.id] = b; - continue; - } - Bout &b = it->second; - b.path += std::hypot(f.pos.x() - b.prev.x(), f.pos.y() - b.prev.y()); - b.prev = f.pos; - b.net = std::max(b.net, - std::hypot(f.pos.x() - b.start.x(), - f.pos.y() - b.start.y())); - } - for (auto it = bouts.begin(); it != bouts.end();) - it = alive.count(it->first) ? std::next(it) : bouts.erase(it); - } - double sum = 0; - for (double v : scores) sum += v; - const double straightness = scores.empty() ? 0.0 : sum / scores.size(); - std::printf("walking: %zu bouts, %.0f%% of distance walked was progress\n", - scores.size(), straightness * 100); - if (scores.size() < 5) - return fail("flies hardly ever walk"); - // A fly shuffling in place scores near zero however fast its legs - // move; one walking a line scores near 1. - if (straightness < 0.35) - return fail("crawlers shuffle on the spot instead of walking"); - } - - // --- containment: must fit the asymmetric overlay margins --- - for (qreal side : {28.0, 40.0, 95.0}) { - FlySim s = makeSim(side, 1.0f, 4242); - const QPointF c(500 + side / 2, 500 + side * 0.35); - double worstX = 0, worstUp = 0, worstDown = 0; - for (int i = 0; i < 1200; ++i) { - s.step(0.05f); - for (const Fly &f : s.flies()) { - worstX = std::max(worstX, std::abs(f.pos.x() - c.x())); - const double dy = f.pos.y() - c.y(); - if (dy < 0) worstUp = std::max(worstUp, -dy); - else worstDown = std::max(worstDown, dy); - } - } - const int mx = FlySim::marginX(side); - const int mt = FlySim::marginTop(side); - const int mb = FlySim::marginBottom(side); - const bool ok = worstX <= mx && worstUp <= mt && worstDown <= mb; - std::printf("icon %3.0fpx: x %.0f/%d up %.0f/%d down %.0f/%d %s\n", - side, worstX, mx, worstUp, mt, worstDown, mb, - ok ? "ok" : "CLIPS"); - if (!ok) return fail("swarm escapes the overlay margins"); - } - - // --- scaling: the swarm grows with a magnified Dock tile --- - { - auto spread = [](qreal side) { - FlySim s = makeSim(side, 1.0f, 999); - const QPointF c(500 + side / 2, 500 + side * 0.35); - double sum = 0; int n = 0; - for (int i = 0; i < 600; ++i) { - s.step(0.05f); - for (const Fly &f : s.flies()) { - sum += std::hypot(f.pos.x() - c.x(), f.pos.y() - c.y()); - ++n; - } - } - return n ? sum / n : 0.0; - }; - const double ratio = spread(95) / spread(40); - std::printf("spread scales x%.2f for a x2.38 icon\n", ratio); - if (ratio < 1.7 || ratio > 3.1) - return fail("swarm does not scale with the icon"); - } - - // --- determinism: same seed, same flight path --- - { - FlySim a = makeSim(40, 1.0f, 777), b = makeSim(40, 1.0f, 777); - for (int i = 0; i < 200; ++i) { a.step(0.05f); b.step(0.05f); } - if (a.flies().size() != b.flies().size() - || (!a.flies().isEmpty() && a.flies()[0].pos != b.flies()[0].pos)) - return fail("not deterministic for a fixed seed"); - std::printf("deterministic: same seed reproduces the swarm\n"); - } - - // --- idle: an emptied bin must stop rendering entirely --- - { - FlySim s = makeSim(40, 1.0f); - for (int i = 0; i < 200; ++i) s.step(0.05f); - s.setFullness(0.0f); - int steps = 0; - while (!s.isIdle() && steps < 4000) { s.step(0.05f); ++steps; } - std::printf("emptied: idle after %.1fs (%lld left)\n", - steps * 0.05, (long long)s.flies().size()); - if (!s.isIdle()) return fail("swarm never went idle"); - } - - // --- density settings --------------------------------------------- - // The three fixed steps are thirds by design, so they line up with - // the relative mode's scale instead of being arbitrary numbers. - { - // Throwaway store: never the user's real preferences. - Settings s(nullptr, QStringLiteral("hyperbin-selftest")); - const qint64 mb = 1024 * 1024; - - s.setDensity(Settings::Density::Few); - if (std::abs(s.fullnessFor(0, 0) - 1.0 / 3.0) > 1e-9) - return fail("'a few flies' is not a third"); - s.setDensity(Settings::Density::Lots); - if (std::abs(s.fullnessFor(0, 0) - 2.0 / 3.0) > 1e-9) - return fail("'lots of flies' is not two thirds"); - s.setDensity(Settings::Density::TooMany); - if (std::abs(s.fullnessFor(0, 0) - 1.0) > 1e-9) - return fail("'too many flies' is not full"); - // Fixed densities must ignore the bin entirely — that is the - // whole reason to offer them. - if (s.fullnessFor(0, 0) != s.fullnessFor(50 * mb, 12)) - return fail("a fixed density still looks at the trash"); - - s.setDensity(Settings::Density::Relative); - s.setThreshold(Settings::Threshold::HundredMB); - if (s.fullnessFor(0, 0) != 0.0) - return fail("an empty bin should draw nothing"); - if (std::abs(s.fullnessFor(50 * mb, 3) - 0.5) > 1e-9) - return fail("half the threshold should be half full"); - if (std::abs(s.fullnessFor(500 * mb, 3) - 1.0) > 1e-9) - return fail("past the threshold should saturate, not overflow"); - s.setThreshold(Settings::Threshold::OneGB); - if (std::abs(s.fullnessFor(50 * mb, 3) - 50.0 / 1024.0) > 1e-9) - return fail("threshold change does not rescale"); - // No size available means no Full Disk Access, and there is no - // longer a count to fall back to. Deliberate: Finder can be asked - // for a count without Full Disk Access but returns "missing - // value" for the size of any FOLDER, so a trashed app bundle or - // project directory weighed nothing — and the count fallback - // ignored the threshold outright, which made the whole Trash - // Threshold menu a no-op on a stock Mac. Better to draw nothing - // and say why than to draw something quietly wrong. - if (s.fullnessFor(-1, 20) != 0.0) - return fail("without a size, fullness must be zero, not guessed"); - s.clearStore(); - std::printf("density: thirds, relative scaling and fallback all hold\n"); - } - - - // ================= ooze ============================================== - // The shape and the drips live in the shader now, driven by a signed - // distance field of the bin. What is testable headlessly is the field - // itself and the coating's level — which is all this class still owns. - { - // --- the distance field ------------------------------------------ - // Everything the ooze does hangs off this being right, and it is - // the one part that CAN be checked without a screen. - { - const int n = 24; - const QVector cov = binSilhouette(n); - const float range = 20.0f; - const QImage f = buildSignedDistanceField(cov, n, n, 128, range); - if (f.isNull() || f.width() != 128) - return fail("no distance field produced"); - - auto sample = [&](double u, double v) { - const int x = std::clamp(int(u * f.width()), 0, f.width() - 1); - const int y = std::clamp(int(v * f.height()), 0, f.height() - 1); - return (f.constScanLine(y)[x] / 255.0 * 2.0 - 1.0) * range; - }; - // Deep inside the bin is negative, well outside is positive, - // and the sign flips across the boundary. - const double inside = sample(0.5, 0.6); - const double above = sample(0.5, 0.02); - const double beside = sample(0.02, 0.6); - std::printf("field: inside %.1f, above %.1f, beside %.1f (cells)\n", - inside, above, beside); - if (inside >= 0) return fail("the field says the bin's middle is outside it"); - if (above <= 0) return fail("the field says above the lid is inside"); - if (beside <= 0) return fail("the field says beside the bin is inside"); - - // Monotonic away from the surface: a field that is not makes - // the coating's edge crawl instead of sitting still. - double prev = -1e9; - bool climbs = true; - for (int i = 0; i < 12; ++i) { - const double d = sample(0.5, 0.55 - i * 0.04); - if (d < prev - 0.35) { climbs = false; break; } - prev = d; - } - if (!climbs) return fail("the field is not monotonic away from the bin"); - std::printf("field is signed and monotonic\n"); - } - - // --- the coating's level ------------------------------------------ - OozeSim o; - o.setBinRect(QRectF(500, 500, 40, 28)); - - // A nearly-empty bin stays clean. - o.setFullness(0.05f); - for (int i = 0; i < 400; ++i) o.step(0.05f); - if (!o.isEmpty()) return fail("a nearly-empty bin oozes"); - if (!o.isAtRest()) return fail("a clean bin never rests"); - - // A full one gets coated, and creeps rather than snapping on. - o.setFullness(1.0f); - o.step(0.05f); - const float afterOneStep = o.level(); - for (int i = 0; i < 400; ++i) o.step(0.05f); - const float full = o.level(); - std::printf("coating: %.2f after one step, %.2f settled\n", - afterOneStep, full); - if (afterOneStep >= full * 0.5f) return fail("the coating snaps on instead of creeping"); - if (full < 0.5f) return fail("a full bin is barely coated"); - if (o.isEmpty()) return fail("a full bin shows no ooze"); - - // More trash, more coating. - auto settled = [&](float f) { - OozeSim s; - s.setBinRect(QRectF(500, 500, 40, 28)); - s.setFullness(f); - for (int i = 0; i < 600; ++i) s.step(0.05f); - return s.level(); - }; - const float half = settled(0.5f); - std::printf("coating level: %.2f at half, %.2f at full\n", half, full); - if (!(full > half * 1.15f)) return fail("the coating does not grow with the trash"); - - // Emptying recedes it away completely, and only then does it rest. - o.setFullness(0.0f); - int steps = 0; - while (!o.isEmpty() && steps < 2000) { o.step(0.05f); ++steps; } - std::printf("emptied: receded in %.1fs\n", steps * 0.05); - if (!o.isEmpty()) return fail("ooze never recedes"); - if (steps < 4) return fail("ooze vanishes instead of receding"); - if (!o.isAtRest()) return fail("receded ooze never rests"); - - // The overlay has to be tall enough for a drip at full stretch. - // Nothing else can check this: the drip lives in the shader, so a - // margin that is too short shows as the drop being sliced off at - // the window edge exactly as it lets go — the one frame anyone - // would notice. - for (qreal icon : {28.0, 40.0, 95.0}) { - const double reach = icon * OozeSim::kDripReach * OozeSim::kDripStretch; - const int mb = OozeSim::marginBottom(icon); - std::printf("ooze %3.0fpx: drip reaches %.0f, margin %d\n", - icon, reach, mb); - if (mb < reach) - return fail("the overlay is too short for a drip at full stretch"); - } - // An effect that cannot rest while it is visible has to at least - // ask to be animated slowly — it is the only lever left on cost. - if (o.preferredFrameIntervalMs() < 20) - return fail("ooze does not ask to be slowed"); - std::printf("ooze asks for %dms frames\n", o.preferredFrameIntervalMs()); - } - - - // --- settings survive a restart ------------------------------------ - // A menu-bar app is killed far more often than it is quit — the - // debugger's stop button, a logout, Force Quit — so "saved when we - // exit cleanly" is not saved at all. - // - // Run with one argument this binary acts as the WRITER half and exits - // without a clean shutdown; the parent then re-reads the store in a - // fresh process. Doing it in one process proves nothing: QSettings - // keeps an in-process cache, so the second read would be answered - // from memory whether or not anything reached the disk. - { - const QString store = QStringLiteral("hyperbin-persisttest"); - Settings before(nullptr, store); - before.clearStore(); - const QString self = QString::fromLocal8Bit(argv[0]); - if (QProcess::execute(self, {QStringLiteral("--write-settings")}) != 0) - return fail("could not run the settings writer"); - Settings after(nullptr, store); - // QSettings caches per process; this one is fresh, but force a - // re-read anyway so the test cannot pass on a stale cache. - std::printf("across a restart: infestation=%s density=%d\n", - qPrintable(after.infestation()), int(after.density())); - const bool ok = after.infestation() == QStringLiteral("ooze") - && after.density() == Settings::Density::Lots; - after.clearStore(); - if (!ok) - return fail("settings do not survive being killed"); - } - std::printf("PASS\n"); - return 0; -} diff --git a/tests/tentacle/CMakeLists.txt b/tests/tentacle/CMakeLists.txt new file mode 100644 index 0000000..ffa0581 --- /dev/null +++ b/tests/tentacle/CMakeLists.txt @@ -0,0 +1,27 @@ +qt_add_executable(tst_tentacle_chain + tst_tentacle_chain.cpp + ${CMAKE_SOURCE_DIR}/src/core/TentacleChain.cpp +) + +target_link_libraries(tst_tentacle_chain PRIVATE test_common Qt6::Test Qt6::Core Qt6::Gui) + +add_test(NAME Tentacle.lengthArmDoesNotGrowOrShrink + COMMAND tst_tentacle_chain length_armDoesNotGrowOrShrink) +add_test(NAME Tentacle.bendNoJointFoldsThroughItsNeighbour + COMMAND tst_tentacle_chain bend_noJointFoldsThroughItsNeighbour) +add_test(NAME Tentacle.rootHoldArmLeavesAlongTheHole + COMMAND tst_tentacle_chain rootHold_armLeavesAlongTheHole) +add_test(NAME Tentacle.rollDoesNotLurch + COMMAND tst_tentacle_chain roll_doesNotLurch) +add_test(NAME Tentacle.rollArmBanksAsOnePiece + COMMAND tst_tentacle_chain roll_armBanksAsOnePiece) +add_test(NAME Tentacle.waveDoesNotAccelerateOrChatter + COMMAND tst_tentacle_chain wave_doesNotAccelerateOrChatter) +add_test(NAME Tentacle.settlingStillArmGoesStill + COMMAND tst_tentacle_chain settling_stillArmGoesStill) +add_test(NAME Tentacle.pushOutsideArmGetsOutOfTheBin + COMMAND tst_tentacle_chain pushOutside_armGetsOutOfTheBin) + +# Windows: put Qt's DLLs on PATH for these tests. Must be called here, +# in the same directory scope as the add_test calls above. +hyperbin_fix_test_path() diff --git a/tests/tentacle/tst_tentacle_chain.cpp b/tests/tentacle/tst_tentacle_chain.cpp new file mode 100644 index 0000000..bf6ce8f --- /dev/null +++ b/tests/tentacle/tst_tentacle_chain.cpp @@ -0,0 +1,507 @@ +// The FABRIK solver behind the tentacles. +// +// This is the one piece of the effect that is testable without a window, +// which is why it lives in core/ rather than effects/. Everything here is +// an invariant that has actually been broken at least once: an arm that +// changes length, a joint that folds through its neighbour, a wave that +// speeds up the longer the app is left running. +// +// EVERY THRESHOLD BELOW WAS SET BY BREAKING THE THING IT GUARDS. Each was +// re-measured against a copy of TentacleChain with the relevant piece +// removed, and the figures from those runs are quoted where they set the +// bound. Three earlier drafts of these tests passed against the broken +// copies and had to be rewritten — see the notes on the roll and the root +// hold. If you add a check here, break the code first and watch it fail. +// +// Geometry throughout is the real thing to scale — a 300px bin with a +// 1.38-bin arm, the numbers TentacleEffect uses. +#include + +#include "TentacleChain.h" + +#include +#include +#include + +using namespace hyperbin; + +class TstTentacleChain : public QObject +{ + Q_OBJECT + +private: + static constexpr int kN = TentacleChain::kJoints; + static constexpr float kBinH = 300.0f; + static constexpr float kLen = 1.38f * kBinH; + static constexpr float kSeg = kLen / float(kN - 1); + + static QVector3D base() { return QVector3D(0.0f, -0.20f * kBinH, 0.0f); } + static QVector3D emerge() { return QVector3D(0.0f, 1.0f, 0.0f); } + + static float ease(float t) + { + t = std::clamp(t, 0.0f, 1.0f); + return t * t * (3.0f - 2.0f * t); + } + + /// The roll-up's own fade, as TentacleEffect::rollAmount shapes it: in + /// over the first third, held, out over the last quarter. + /// + /// Not a detail of the test — switching the blend from 0 to 1 in one + /// frame asks curlUp to rewrite the entire chain at once, and it duly + /// reports a 284-unit lurch on a 414-unit arm. Nothing in the app ever + /// does that, so a test that does is measuring its own harness. + static float rollAt(int f, int f0, int f1) + { + if (f < f0 || f >= f1) return 0.0f; + const float u = float(f - f0) / float(f1 - f0); + return ease(std::min(u / 0.34f, 1.0f)) + * (1.0f - ease(std::max((u - 0.76f) / 0.24f, 0.0f))); + } + + /// A target that swings the arm around most of its range, at a reach + /// the arm can nearly meet. Deliberately not a straight line: the + /// failures worth catching happen when the arm is turning. + static QVector3D swing(float t) + { + return base() + QVector3D(std::sin(t * 1.3f) * 0.75f, 0.85f, + std::cos(t * 0.9f) * 0.45f) + .normalized() * (kLen * 0.85f); + } + + /// One frame in the order updateArms runs it, so what is exercised is + /// the pipeline rather than solve() on its own. + static void step(TentacleChain &c, int f, float roll) + { + const float t = float(f) * 0.016f; + c.solve(base(), emerge(), swing(t), t, 1.0f, 1.0f, + TentacleChain::kMaxBend); + c.curlUp(base(), emerge(), 1.30f, roll); + c.pushOutside(0.0f, kBinH, 150.0f, 110.0f, 0.8f, + TentacleChain::kRootHeld + 1); + c.settle(base(), emerge(), TentacleChain::kMaxBend); + } + +private slots: + // An arm does not grow or shrink. + // + // Three separate passes move joints without regard for the segments + // that reach them — the wave, the roll-up, and the push out of the + // bin's body — and each is followed by a constraint pass for exactly + // this reason. + void length_armDoesNotGrowOrShrink() + { + TentacleChain c; + c.reset(base(), emerge(), kLen); + float worst = 0.0f, worstTotal = 0.0f, sum = 0.0f; + int n = 0; + for (int f = 0; f < 900; ++f) { + step(c, f, rollAt(f, 300, 600)); + float total = 0.0f; + for (int j = 0; j + 1 < kN; ++j) { + const float l = (c.joints()[j + 1] - c.joints()[j]).length(); + worst = std::max(worst, std::abs(l - kSeg)); + sum += std::abs(l - kSeg); + ++n; + total += l; + } + worstTotal = std::max(worstTotal, std::abs(total - kLen)); + } + qInfo("segment error mean %.2f worst %.2f of %.1f; whole arm %.2f of %.0f", + double(sum / n), double(worst), double(kSeg), + double(worstTotal), double(kLen)); + // Two thresholds, and the loose one is the important one. + // + // WHOLE-ARM length is what an eye can see: an arm that grows and + // shrinks as it moves. That is held to about 1%. + // + // A single segment is allowed to run further out because + // pushOutside slams a joint clear of the bin's body in one go and + // the ten constraint passes that follow do not fully converge from + // a displacement that large — the worst segment sits near 9% and + // the mean an order of magnitude below it, so it is a transient at + // one joint rather than a chain that has come apart. + // + // A COARSE GUARD, and worth knowing how coarse. Deleting the + // constraint pass that follows the damping blend still passes here + // — the arm goes from 1.1% to 1.7% out and the worst segment + // barely moves, because the passes that remain largely cover for + // it. What this catches is an arm that visibly grows and shrinks, + // not the loss of any one pass. + QVERIFY2(worst <= kSeg * 0.20f, "tentacle segments stretch"); + QVERIFY2(worstTotal <= kLen * 0.03f, + "the arm changes length as it moves"); + } + + // No joint folds through the one before it. + // + // FABRIK has no idea an arm is a physical object: a chain folded back + // through itself satisfies every length constraint perfectly, and the + // first render with a real solver came out crumpled for precisely that + // reason. Verified: with limitBend stubbed out this reads 2.911 rad + // against the 0.522 it holds now. + void bend_noJointFoldsThroughItsNeighbour() + { + TentacleChain c; + c.reset(base(), emerge(), kLen); + float worstBend = 0.0f; + for (int f = 0; f < 900; ++f) { + step(c, f, rollAt(f, 300, 600)); + for (int j = 1; j + 1 < kN; ++j) { + const QVector3D a = (c.joints()[j] - c.joints()[j - 1]).normalized(); + const QVector3D b = (c.joints()[j + 1] - c.joints()[j]).normalized(); + worstBend = std::max(worstBend, std::acos(std::clamp( + QVector3D::dotProduct(a, b), -1.0f, 1.0f))); + } + } + qInfo("worst bend %.3f rad against a limit of %.3f", + double(worstBend), double(TentacleChain::kMaxBend)); + // The roll-up asks for a tighter curl than kMaxBend and is settled + // back to it afterwards, so the ceiling that has to hold here is + // the coil limit, not the walking one. + QVERIFY2(worstBend <= TentacleChain::kCoilBend, + "a tentacle joint folds through its neighbour"); + } + + // The arm leaves its hole along the hole. + // + // FABRIK anchors the base POSITION and nothing else, so an arm asked + // for something far to one side satisfies it by swinging its first + // segment too — the anchor stays put and the arm leaves it at a new + // angle. What the eye reads as the base is the first visible stretch, + // so without this a wrap looked like the whole tentacle sliding around + // the rim. + // + // Measured on the WHOLE held stretch, base to joint kRootHeld, and not + // on the first joint. The first joint is written to the emergence line + // outright, so an assertion about it reads 0.00 however broken + // everything else is — it cannot fail, which makes it worse than no + // test. That was the first draft of this check. + void rootHold_armLeavesAlongTheHole() + { + TentacleChain c; + c.reset(base(), emerge(), kLen); + const QVector3D sideways = + base() + QVector3D(0.97f, 0.24f, 0.0f).normalized() * (kLen * 0.92f); + float worstLean = 0.0f; + for (int f = 0; f < 400; ++f) { + c.solve(base(), emerge(), sideways, float(f) * 0.016f, 1.0f, 1.0f, + TentacleChain::kMaxBend); + const QVector3D held = + (c.joints()[TentacleChain::kRootHeld] - base()).normalized(); + worstLean = std::max(worstLean, std::acos(std::clamp( + QVector3D::dotProduct(held, emerge()), -1.0f, 1.0f))); + } + qInfo("emerging stretch leans %.3f rad under a hard sideways reach", + double(worstLean)); + // The bend limit ALONE would allow kRootHeld * kMaxBend = 1.54 rad + // here, so 0.4 is comfortably inside what only the hold can + // deliver — it measures 0.145, and 1.232 with holdRoot stubbed out. + QVERIFY2(worstLean <= 0.4f, + "the arm does not leave its hole along the hole"); + } + + // A roll-up does not lurch. + // + // Driven the way TentacleEffect drives it: the target is pulled IN as + // the roll tightens, the bend limit is raised to let the spiral close, + // and the wave is turned down. That combination is the test. Given a + // target at full stretch instead — a straight arm being spiralled — + // the tip moves 180 units in a frame and always has; the effect avoids + // asking for that rather than the solver coping with it, which is why + // the Roll move gathers its target. + // + // WHAT THIS DOES NOT COVER, stated so nobody trusts it further than it + // goes: the bug behind the roll's jitter was curlUp deriving its coil + // plane afresh each frame, so an arm passing near straight had an + // ill-conditioned cross product and the spiral could turn over. The + // fix is measured — 65 lurch events in 45s against 8 — but only in the + // running app, with the pointer dragging the arm straight while it + // rolled. Six attempts to provoke the flip headlessly all came out + // identical on both versions, so there is no assertion here that would + // fail if the fix were reverted. Do not add one without checking it + // fails on a copy that has the fix backed out. + void roll_doesNotLurch() + { + TentacleChain c; + c.reset(base(), emerge(), kLen); + const QVector3D up(0.0f, 1.0f, 0.0f); + auto rollStep = [&](int f, float roll) { + const float t = float(f) * 0.016f; + // Rest is out at 0.80 of reach; a tight roll gathers to 0.44. + // See TentacleEffect's Move::Roll. + const QVector3D lean(std::sin(t * 0.4f) * 0.18f, 0.0f, 0.0f); + const QVector3D rest = base() + up * (kLen * 0.80f) + lean * kLen; + const QVector3D gathered = base() + up * (kLen * 0.44f) + lean * kLen; + c.solve(base(), emerge(), rest + (gathered - rest) * roll, t, 1.0f, + 0.45f, 1.32f); + c.curlUp(base(), emerge(), 1.30f, roll); + c.settle(base(), emerge(), 1.32f); + }; + for (int f = 0; f < 200; ++f) rollStep(f, 0.0f); + float worstJump = 0.0f; + QVector3D last[kN]; + for (int j = 0; j < kN; ++j) last[j] = c.joints()[j]; + for (int f = 200; f < 700; ++f) { + rollStep(f, rollAt(f, 200, 700)); + for (int j = 0; j < kN; ++j) { + worstJump = std::max(worstJump, (c.joints()[j] - last[j]).length()); + last[j] = c.joints()[j]; + } + } + qInfo("roll: worst joint jump %.1f of a %.0f arm", + double(worstJump), double(kLen)); + QVERIFY2(worstJump <= kLen * 0.08f, + "a rolling tentacle lurches between frames"); + } + + // The arm banks as ONE PIECE — it does not wind up along its length. + // + // The bank is a single angle laid on the seed, and transport carries + // it to every joint unchanged, so the twist between neighbouring + // frames is zero by construction. That is worth a test because the + // obvious-looking alternative is not: an earlier version rolled every + // joint onto its OWN bend plane, and since neighbouring joints curl in + // slightly different planes, each got a slightly different roll and + // the mesh wound along its length. Verified at 201 degrees of twist + // from base to tip — an arm being wrung out rather than turning. + // + // Run with the wave on, because that is the case where per-joint + // alignment is most tempting and most wrong. + void roll_armBanksAsOnePiece_data() + { + // Two floats rather than one QVector3D, and not for tidiness. + // + // A QTest column is stored by calling QMetaType(id).create() — + // a lookup by NUMERIC id, not by the compile-time type. QVector3D + // is a QtGui built-in, and in an appless test binary on Windows + // nothing has put QtGui's interface for that id into the registry + // by the time a _data() function runs. create() then returns + // null, the row's element is a null pointer, and QFETCH + // dereferences it: an access violation with no diagnostic, inside + // the test function, before its first line of real work. + // + // It cost a morning because every signpost pointed elsewhere: the + // rows enumerate correctly under -datatags, macOS passes (frame- + // works register their metatypes in a different order to DLLs), + // and the same solver loop lifted into a standalone binary runs + // clean at both -Od and -O2. Float columns are core types and + // marshal correctly, so the direction is carried as its + // components and rebuilt here. + QTest::addColumn("dx"); + QTest::addColumn("dz"); + QTest::newRow("sweep +x") << 1.0f << 0.0f; + QTest::newRow("sweep -x") << -1.0f << 0.0f; + QTest::newRow("sweep +z") << 0.0f << 1.0f; + QTest::newRow("sweep -z") << 0.0f << -1.0f; + } + void roll_armBanksAsOnePiece() + { + QFETCH(float, dx); + QFETCH(float, dz); + const QVector3D dir(dx, 0.0f, dz); + TentacleChain c; + c.reset(base(), emerge(), kLen); + for (int f = 0; f < 400; ++f) { + const float u = ease(f / 200.0f); + const QVector3D tgt = base() + + QVector3D(0, 1, 0) * (kLen * (0.85f - 0.45f * u)) + + dir * (kLen * 0.55f * u); + c.solve(base(), emerge(), tgt, float(f) * 0.016f, 1.0f, 0.3f, + TentacleChain::kCoilBend); + c.settle(base(), emerge(), TentacleChain::kCoilBend); + } + float twist = 0.0f; + for (int i = 2; i < kN; ++i) { + const QVector3D t = (c.joints()[i] - c.joints()[i - 1]).normalized(); + QVector3D a = c.sides()[i - 1] + - t * QVector3D::dotProduct(c.sides()[i - 1], t); + if (a.lengthSquared() < 1e-8f) + continue; + a.normalize(); + const QVector3D b = c.sides()[i]; + const float cs = std::clamp(QVector3D::dotProduct(a, b), -1.0f, 1.0f); + const float sn = QVector3D::dotProduct(QVector3D::crossProduct(a, b), t); + twist += std::abs(std::atan2(sn, cs)); + } + const float deg = twist * 180.0f / 3.14159265f; + qInfo("total twist along the arm: %.1f degrees; bank %.1f", + double(deg), double(c.roll() * 180.0f / 3.14159265f)); + QVERIFY2(deg < 5.0f, "the arm winds up along its length instead of banking"); + // ...and the bank stays inside its stop, which is what stops the + // suckers ever travelling round to the back. + QVERIFY2(std::abs(c.roll()) <= TentacleChain::kRollLimit + 1e-3f, + "the bank exceeded its limit"); + } + // The wave does not speed up the longer it runs, and does not chatter. + // + // The undulation's pace wanders so it does not read as a machine, and + // there is exactly one way to write that which works. Scaling time by + // a varying factor — sin(t * k(t)) — gives an effective frequency of + // k + t*k', which GROWS WITHOUT BOUND: fine in a test that runs for + // five seconds, a buzz in an app left open all afternoon. Displacing + // the phase instead wanders around a fixed mean and stays there. + // + // Measured as DIRECTION REVERSALS of the tip, which is the frequency + // itself. Summed displacement will not do: the amplitude is fixed, so + // once the wave outruns the frame rate a faster one covers the same + // ground per frame, and a metric that saturates cannot tell fast from + // far too fast. Against a copy carrying the time-scaled form, + // displacement separated the two by x1.14 and reversals by x6.8. + void wave_doesNotAccelerateOrChatter() + { + const float early = tipReversalsPerSecond(10.0f); + const float late = tipReversalsPerSecond(600.0f); + qInfo("wave: %.2f tip reversals/s at 10s, %.2f at 600s (x%.2f)", + double(early), double(late), + double(late / std::max(early, 1e-4f))); + // The correct form measures x0.90 here and the time-scaled trap + // x6.79, so this sits with better than a factor of two either side + // rather than being fitted to the current number. + QVERIFY2(late <= early * 2.0f, + "the wave gets faster the longer the app runs"); + // The same number, read as an absolute, catches the OTHER way this + // goes wrong. A tip riding the wave turns about 2.5 times a + // second; a tip carrying the solver's chatter on top turns 12 + // times a second, because FABRIK does not have one solution and + // wanders between the equally valid ones every frame. Built + // against a copy with the damping removed — kAdopt at 1.0 — it + // measures 12.34 against 2.51, so 6 separates them with room on + // both sides. This is the assertion that would notice kAdopt being + // tuned away; settling_stillArmGoesStill does not, because an + // undamped chain also settles once the wave is switched off. + QVERIFY2(early <= 6.0f, + "the arm chatters: the solver is not being damped"); + } + + // A still arm goes still. + // + // With the wave switched off (flex 0) and the target held, there is + // one right answer and the solver has to arrive at it and stay. + // + // This does NOT test the damping, which is what it was written to do: + // run against a copy with kAdopt at 1.0 it still reads exactly 0.0000, + // because an undamped chain converges perfectly well once nothing is + // kicking it. Damping is what stops the solver wandering between + // equally valid solutions WHILE the wave kicks it, so the guard for + // that is the absolute reversal rate above. What is left here is still + // worth having — a solver that never converges, or one left creeping + // by a constraint pass that fights itself, shows up as a non-zero + // number — but it is a weaker claim. + void settling_stillArmGoesStill() + { + TentacleChain c; + c.reset(base(), emerge(), kLen); + const QVector3D still = + base() + QVector3D(-0.3f, 0.9f, 0.15f).normalized() * (kLen * 0.85f); + for (int f = 0; f < 400; ++f) + c.solve(base(), emerge(), still, float(f) * 0.016f, 1.0f, 0.0f, + TentacleChain::kMaxBend); + QVector3D last[kN]; + for (int j = 0; j < kN; ++j) last[j] = c.joints()[j]; + float worst = 0.0f; + for (int f = 400; f < 500; ++f) { + c.solve(base(), emerge(), still, float(f) * 0.016f, 1.0f, 0.0f, + TentacleChain::kMaxBend); + for (int j = 0; j < kN; ++j) { + worst = std::max(worst, (c.joints()[j] - last[j]).length()); + last[j] = c.joints()[j]; + } + } + qInfo("settled arm still moves %.4f a frame", double(worst)); + QVERIFY2(worst <= kLen * 0.002f, + "a tentacle shivers with nothing driving it"); + } + + // An arm gets out of the bin it came from. + // + // The straight route from a root buried in the rubbish to a target + // down by the foot goes THROUGH the bin, which the mask then correctly + // hides — so the arm appears as a floating fragment with a gap where + // its middle should be. Pushing joints to the nearest surface is what + // makes an arm go over the rim to get down the outside, which is what + // it would physically have to do. + void pushOutside_armGetsOutOfTheBin() + { + TentacleChain c; + c.reset(base(), emerge(), kLen); + constexpr float kTopY = 0.0f, kHalfX = 150.0f, kHalfZ = 110.0f; + constexpr float kTaper = 0.8f; + // STRAIGHT DOWN, through the middle of the bin. + // + // Not down the front, which was the first choice and proves + // nothing: a target already outside the near face is one FABRIK + // reaches without ever entering the body, so that case measures 0 + // buried joints whether pushOutside runs or not. Straight down is + // the case the routine exists for — it leaves 3 joints inside the + // bin when pushOutside is skipped and none when it runs. + // + // A target on the FAR side is worse with pushOutside than without + // (3 buried against 0): shoving joints toward the near face is the + // wrong way out when the tip is behind the bin. Nothing asks for + // that today — keepInFront drags every below-the-rim target + // forward before the solver sees it — so it is recorded here + // rather than guarded. + const QVector3D down(0.0f, -0.8f * kBinH, 0.0f); + for (int f = 0; f < 400; ++f) { + c.solve(base(), emerge(), down, float(f) * 0.016f, 1.0f, 1.0f, + TentacleChain::kMaxBend); + c.pushOutside(kTopY, kBinH, kHalfX, kHalfZ, kTaper, + TentacleChain::kRootHeld + 1); + c.settle(base(), emerge(), TentacleChain::kMaxBend); + } + int buried = 0; + for (int j = TentacleChain::kRootHeld + 1; j < kN; ++j) { + const QVector3D &p = c.joints()[j]; + if (p.y() >= kTopY) continue; // in the opening + const float below = std::clamp((kTopY - p.y()) / kBinH, 0.0f, 1.0f); + const float sx = kHalfX * (1.0f + (kTaper - 1.0f) * below); + const float sz = kHalfZ * (1.0f + (kTaper - 1.0f) * below); + const float ex = p.x() / sx, ez = p.z() / sz; + // Inside the body AND behind its near face is the state that + // draws as a hole in the arm. + if (ex * ex + ez * ez < 1.0f + && p.z() < sz * std::sqrt(std::max(0.0f, 1.0f - ex * ex))) + ++buried; + } + qInfo("%d of %d joints left inside the bin's body", buried, kN); + QCOMPARE(buried, 0); + } + +private: + /// Direction reversals of the tip per second, averaged over 110 + /// seconds — four full cycles of the wave's own 27-second breath. + /// + /// A short window lands at a different point in that breath at each + /// epoch, which on the first attempt showed the CORRECT implementation + /// running x2.36 faster and would have made any useful threshold a + /// coin toss. + static float tipReversalsPerSecond(float startT) + { + TentacleChain c; + c.reset(base(), emerge(), kLen); + constexpr int kWin = int(110.0 / 0.016); + const int f0 = int(startT / 0.016f); + // A target held perfectly still, so what is measured is the wave + // and nothing else. + const QVector3D still = + base() + QVector3D(0.1f, 0.95f, 0.2f).normalized() * (kLen * 0.85f); + for (int f = f0; f < f0 + 40; ++f) + c.solve(base(), emerge(), still, float(f) * 0.016f, 1.0f, 1.0f, + TentacleChain::kMaxBend); + int reversals = 0; + float prevX = c.joints()[kN - 1].x(), prevD = 0.0f; + for (int f = f0 + 40; f < f0 + 40 + kWin; ++f) { + c.solve(base(), emerge(), still, float(f) * 0.016f, 1.0f, 1.0f, + TentacleChain::kMaxBend); + const float x = c.joints()[kN - 1].x(), d = x - prevX; + if (d * prevD < 0.0f) ++reversals; + if (std::abs(d) > 1e-4f) prevD = d; + prevX = x; + } + return float(reversals) / (float(kWin) * 0.016f); + } +}; + +QTEST_APPLESS_MAIN(TstTentacleChain) +#include "tst_tentacle_chain.moc"