diff --git a/README.md b/README.md index f396648..446357b 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ Fly adds a controlled leaf-like drift in arbitrary PDF directions, with the same reproducible review assets. Push transfers momentum between two softly compressing slide blocks in all four PDF directions. Dissolve fractures the outgoing slide into deterministic glass shards that clear to reveal the next page. +Glitter releases directional fragments with brief warm sparks. Wipe sweeps a +3D wiper with a bowed spine, pressure brackets and subtle flex across the slide in all four cardinal directions, revealing +the next page without stretching either image. Fade remains the next planned +effect; Replace is instant. [![CI](https://github.com/baol/pdfcube/actions/workflows/ci.yml/badge.svg)](https://github.com/baol/pdfcube/actions/workflows/ci.yml) diff --git a/docs/transitions/README.md b/docs/transitions/README.md index 8cc15a1..17c51cd 100644 --- a/docs/transitions/README.md +++ b/docs/transitions/README.md @@ -12,13 +12,77 @@ review, to accepted; a passing build is not visual approval. | 4 | Fly | A page drifting like a leaf on a controlled gust | Direction, scale, readable flight, controlled landing | | 5 | Push | Cube pushes a soft cube | Deterministic contact and recovery | | 6 | Dissolve / Glitter | Glass shatter / flare and sparks | Stable geometry, bounded memory and brightness | -| 7 | Wipe / Fade / Replace | Physical wiper / crossfade / instant | Direction, clean endpoints | +| 7 | Wipe | Raised physical wiper sweeping the outgoing image away | Four directions, exact reveal mask and endpoints | +| 8 | Fade / Replace | Crossfade (next) / instant (already available) | Clean endpoints | Box keeps the classic cube. Unsupported PDF effects currently replace the page. Future effects must preserve document ratio, prepare full-resolution source images before motion, and finish on the exact static destination image. Geometry is a function of progress, never an uncontrolled physics simulation. +## Wipe: experimental, awaiting visual approval + +A three-dimensional wiper sweeps straight across the viewport, clearing the outgoing +page and revealing the destination behind it. A rubber contact lip, bowed pressure +spine, raised brackets and metal pivot give it a mechanical silhouette. Directional +lighting shows the depth of each part. A small damped flex settles during the sweep. Beveled matte surfaces and a +soft contact shadow keep the blade grounded against the page. The pages stay flat, readable +and at full resolution. Travel eases in and out and extends beyond both edges, +so the blade enters and leaves completely. There is no simulation or accumulating +state, and no per-frame geometry allocation. + +PDF `/Di` values 0, 90, 180 and 270 select right, up, left and down. Missing or +invalid angles default to right. `--program 4:wipe` uses the same effect and the +normal 750 ms programmed duration; document playback uses PDF `/Trans /D`, with +`/Dur` reserved for dwell time. Explicit cube and instant navigation are unchanged. + +Each page keeps its own fitted aspect ratio. The unrevealed region includes the +outgoing letterbox, so a differently shaped incoming page cannot show through +early. The clipping edge is pixel-aligned and concealed by the blade. At 0% and +100% the renderer bypasses the wiper and draws the exact static page. + +Try the [right](../../examples/transitions/wipe-right.pdf), +[up](../../examples/transitions/wipe-up.pdf), [left](../../examples/transitions/wipe-left.pdf) +and [down](../../examples/transitions/wipe-down.pdf) diagnostic PDFs, or the +[Beamer showcase](../../examples/showcase/README.md). Candidate contact sheets: + +![Wipe right](wipe-right.png) + +![Wipe up](wipe-up.png) + +Other directions: [left](wipe-left.png), [down](wipe-down.png). +These are software-renderer candidates, not visual acceptance or hardware +performance measurements. Fade is the next unimplemented roadmap effect; +Replace already changes the page instantly. + +Local Wipe captures were inspected with GTK 4.22.4, Poppler 26.01 and software +OpenGL, including a midpoint from the updated Beamer deck. All 26 Wipe cases +pass with address/undefined-behavior sanitizers. In the optimized-blade run, 12 sanitizer cases +missed the 500 ms SLO (519–661 ms); this is recorded separately from +functional correctness and does not establish a hardware performance pass. + +### Wipe frame profiling + +A local 1920×1080 headless run on the RTX 3060, with `glFinish` after each +frame, measured 180 frames at explicit progress values after 20 warmup frames. +The debug build changed as follows; these measurements exclude the compositor: + +| Renderer | Median before → after | p95 before → after | +| --- | --- | --- | +| NVIDIA RTX 3060 | 0.65 → 0.52 ms | 0.83 → 0.80 ms | +| llvmpipe software | 10.4 → 8.1 ms | 14.1 → 12.0 ms | + +The incoming page is now clipped to its revealed region, avoiding hidden-page +fragment work. Lighting is evaluated once per quad rather than once per vertex; +the optimized mesh microbenchmark improved from 0.092 to 0.055 ms per update. +A second GPU comparison measured 0.68 → 0.63 ms median but 0.90 → 1.65 ms +p95, so these samples do not establish a consistent GPU tail-latency improvement. +Cold PDF rasterization/upload remains a separate cost (190–249 ms on the GPU +runs); warmed frame throughput does not establish that first-frame latency is solved. +The RSS replay check disables ASan's freed-block quarantine only for that check, +so retained freed driver buffers do not masquerade as live memory growth. Other +sanitizer frame cases retain their ordinary quarantine. + ## Glitter: experimental, awaiting visual approval A directional front releases small textured pieces of the outgoing slide, @@ -241,16 +305,16 @@ python3 tools/transition_gallery.py build/src/pdfcube build/transition-gallery Add `--update-docs` to regenerate the checked-in contact sheets from those same captures. The default 33 samples include exact quarter-progress frames. -Use `--effects Cover Uncover` for a focused page-flip review; CI captures all -implemented effects by default. Gallery capture reuses one Xvfb display and +Use `--effects Wipe` for a focused wiper review. The manual transition-review +workflow captures all implemented effects by default. Gallery capture reuses one Xvfb display and captures up to four frames concurrently. Use `--jobs N` to tune that limit for the available CPU and memory; this does not change the generated frames. Open build/transition-gallery/index.html. Each effect/axis/direction has a looping video, five-frame contact sheet, full-resolution individual frames, and its input PDF. The manifest records the source revision and binary version. Rebuild first; -captures must come from the revision under review. CI uploads this bundle on -every pull request. Download and unzip it to view locally. +captures must come from the revision under review. The manual transition-review +workflow uploads the bundle; ordinary CI keeps only one GUI smoke capture. Download and unzip it to view locally. Candidate captures are never automatically promoted to golden references. The reviewer checks framing, direction, readability, motion and final alignment, @@ -291,6 +355,11 @@ native-platform or motion approval. - Dissolve checks deterministic fracture geometry, bounded shard parameters, portrait/landscape/mixed ratios, PDF metadata, program selection, distinct sampled stages, exact endpoints and a reproducible example. +- Wipe checks bounded and monotonic clipping, symmetric travel, all four directions, + omitted/invalid angle fallback, exact revealed/unrevealed pixel regions including + mixed page letterboxes, deterministic replay, GL state cleanup, PDF metadata, + program dispatch, complete frame sweeps, repeated-playback RSS bounds in one + shared context, and reproducible example PDFs. - Pending manual approval: realistic Beamer deck; HD/2K/4K frame-time measurements; native Wayland/NVIDIA, X11, Windows and macOS playback; long-running playback memory checks. Still-frame and geometry checks are not substitutes for these. diff --git a/docs/transitions/wipe-down.png b/docs/transitions/wipe-down.png new file mode 100644 index 0000000..576ea8e Binary files /dev/null and b/docs/transitions/wipe-down.png differ diff --git a/docs/transitions/wipe-left.png b/docs/transitions/wipe-left.png new file mode 100644 index 0000000..e1791e2 Binary files /dev/null and b/docs/transitions/wipe-left.png differ diff --git a/docs/transitions/wipe-right.png b/docs/transitions/wipe-right.png new file mode 100644 index 0000000..c475778 Binary files /dev/null and b/docs/transitions/wipe-right.png differ diff --git a/docs/transitions/wipe-up.png b/docs/transitions/wipe-up.png new file mode 100644 index 0000000..ca9b872 Binary files /dev/null and b/docs/transitions/wipe-up.png differ diff --git a/examples/showcase/README.md b/examples/showcase/README.md index a4e5008..420d8b0 100644 --- a/examples/showcase/README.md +++ b/examples/showcase/README.md @@ -92,9 +92,10 @@ MP4 output. Recording ignores interactive demo mode and requires no `--program`. - Push and Wipe: all four cardinal directions. - Dissolve, Glitter (right/down/lower right), Fade and Replace. -Glitter, Wipe and Fade currently fall back to an instant change -in PDFCube. Their slides say so; they preserve the real PDF metadata to provide -a showcase as these effects are implemented. Other viewers may implement them +Glitter and Wipe are animated in PDFCube. Glitter releases fading fragments and +sparks; Wipe moves a raised blade in the requested direction. Fade currently +falls back to an instant change; its slide says so and preserves the real PDF +metadata for the planned implementation. Other viewers may implement them or ignore PDF transitions entirely. Replace is deliberately instant. PDF transitions are stored on the **destination** page. Each `\ShowSlide` call diff --git a/examples/showcase/showcase.tex b/examples/showcase/showcase.tex index d2c3c02..fd78c8f 100644 --- a/examples/showcase/showcase.tex +++ b/examples/showcase/showcase.tex @@ -171,21 +171,21 @@ % Slide 37: transition entering this page. \ShowSlide{Push /Di 270 /D 1.25}{Push / down}{Soft slide blocks transfer\\momentum and briefly deform.}{Dissolve}{36}{night}{Animated in PDFCube} % Slide 38: transition entering this page. -\ShowSlide{Dissolve /D 1.25}{Dissolve}{The outgoing slide cracks\\into falling glass shards.}{Glitter / right}{37}{sea}{Animated in PDFCube} +\ShowSlide{Dissolve /D 1.25}{Dissolve}{The outgoing slide shatters\\into tumbling glass fragments.}{Glitter / right}{37}{sea}{Animated in PDFCube} % Slide 39: transition entering this page. -\ShowSlide{Glitter /Di 0 /D 1.25}{Glitter / right}{A standard PDF sparkle effect\\ready for a future renderer.}{Glitter / down}{38}{plum}{PDFCube currently replaces instantly} +\ShowSlide{Glitter /Di 0 /D 1.25}{Glitter / right}{A moving front releases pieces\\with a brief trail of sparks.}{Glitter / down}{38}{plum}{Animated in PDFCube} % Slide 40: transition entering this page. -\ShowSlide{Glitter /Di 270 /D 1.25}{Glitter / down}{A standard PDF sparkle effect\\ready for a future renderer.}{Glitter / lower right}{39}{night}{PDFCube currently replaces instantly} +\ShowSlide{Glitter /Di 270 /D 1.25}{Glitter / down}{A moving front releases pieces\\with a brief trail of sparks.}{Glitter / lower right}{39}{night}{Animated in PDFCube} % Slide 41: transition entering this page. -\ShowSlide{Glitter /Di 315 /D 1.25}{Glitter / lower right}{A standard PDF sparkle effect\\ready for a future renderer.}{Wipe / right}{40}{sea}{PDFCube currently replaces instantly} +\ShowSlide{Glitter /Di 315 /D 1.25}{Glitter / lower right}{A moving front releases pieces\\with a brief trail of sparks.}{Wipe / right}{40}{sea}{Animated in PDFCube} % Slide 42: transition entering this page. -\ShowSlide{Wipe /Di 0 /D 1.25}{Wipe / right}{A standard PDF directional reveal\\ready for a future renderer.}{Wipe / up}{41}{plum}{PDFCube currently replaces instantly} +\ShowSlide{Wipe /Di 0 /D 1.25}{Wipe / right}{A raised wiper sweeps right\\and reveals the next slide.}{Wipe / up}{41}{plum}{Animated in PDFCube} % Slide 43: transition entering this page. -\ShowSlide{Wipe /Di 90 /D 1.25}{Wipe / up}{A standard PDF directional reveal\\ready for a future renderer.}{Wipe / left}{42}{night}{PDFCube currently replaces instantly} +\ShowSlide{Wipe /Di 90 /D 1.25}{Wipe / up}{A raised wiper sweeps up\\and reveals the next slide.}{Wipe / left}{42}{night}{Animated in PDFCube} % Slide 44: transition entering this page. -\ShowSlide{Wipe /Di 180 /D 1.25}{Wipe / left}{A standard PDF directional reveal\\ready for a future renderer.}{Wipe / down}{43}{sea}{PDFCube currently replaces instantly} +\ShowSlide{Wipe /Di 180 /D 1.25}{Wipe / left}{A raised wiper sweeps left\\and reveals the next slide.}{Wipe / down}{43}{sea}{Animated in PDFCube} % Slide 45: transition entering this page. -\ShowSlide{Wipe /Di 270 /D 1.25}{Wipe / down}{A standard PDF directional reveal\\ready for a future renderer.}{Fade}{44}{plum}{PDFCube currently replaces instantly} +\ShowSlide{Wipe /Di 270 /D 1.25}{Wipe / down}{A raised wiper sweeps down\\and reveals the next slide.}{Fade}{44}{plum}{Animated in PDFCube} % Slide 46: transition entering this page. \ShowSlide{Fade /D 1.25}{Fade}{A standard PDF crossfade\\ready for a future renderer.}{Replace}{45}{night}{PDFCube currently replaces instantly} % Slide 47: transition entering this page. diff --git a/examples/transitions/README.md b/examples/transitions/README.md index ac2f25e..48aea80 100644 --- a/examples/transitions/README.md +++ b/examples/transitions/README.md @@ -23,6 +23,10 @@ Ready-to-open diagnostic slides: - Push: [right](push-right.pdf), [up](push-up.pdf), [left](push-left.pdf), [down](push-down.pdf) - [Dissolve](dissolve.pdf) +- Glitter: [right](glitter-right.pdf), [down](glitter-down.pdf), + [lower right](glitter-down-right.pdf) +- Wipe: [right](wipe-right.pdf), [up](wipe-up.pdf), + [left](wipe-left.pdf), [down](wipe-down.pdf) From the repository root: @@ -56,6 +60,13 @@ python3 tests/transition_fixture.py examples/transitions The generator is the source of truth; do not edit the PDFs by hand. See the [review roadmap](../../docs/transitions/README.md) for videos and tests. +## Wipe + +The four Wipe examples move a raised, beveled blade in the named PDF direction. +The destination is revealed behind its rubber lip; the unrevealed part stays +exactly on the outgoing page. Both pages retain their aspect ratios. Use the +normal Space/Page Down playback or `--program 4:wipe`. + ## Glitter `glitter-right.pdf`, `glitter-down.pdf` and `glitter-down-right.pdf` demonstrate diff --git a/examples/transitions/wipe-down.pdf b/examples/transitions/wipe-down.pdf new file mode 100644 index 0000000..026ab1e Binary files /dev/null and b/examples/transitions/wipe-down.pdf differ diff --git a/examples/transitions/wipe-left.pdf b/examples/transitions/wipe-left.pdf new file mode 100644 index 0000000..d1702f5 Binary files /dev/null and b/examples/transitions/wipe-left.pdf differ diff --git a/examples/transitions/wipe-right.pdf b/examples/transitions/wipe-right.pdf new file mode 100644 index 0000000..8a35530 Binary files /dev/null and b/examples/transitions/wipe-right.pdf differ diff --git a/examples/transitions/wipe-up.pdf b/examples/transitions/wipe-up.pdf new file mode 100644 index 0000000..dcdf7d9 Binary files /dev/null and b/examples/transitions/wipe-up.pdf differ diff --git a/man/pdfcube.1 b/man/pdfcube.1 index c2c8d1f..78e0e08 100644 --- a/man/pdfcube.1 +++ b/man/pdfcube.1 @@ -79,6 +79,12 @@ selects the soft-block Push effect with a 750 ms animation and the document's direction. .B dissolve selects the glass-shatter Dissolve effect with a 750 ms animation. +.B glitter +selects directional fragments and sparks with a 750 ms animation. +.B wipe +selects a raised physical wiper with a 750 ms animation and the PDF direction +(0/right, 90/up, 180/left, 270/down; other angles default to right). +Both pages retain their fitted aspect ratios. The last pair is repeated for remaining slides. For example, .B \-\-program 5:c:3:p diff --git a/src/options.cc b/src/options.cc index fc44202..ec07b28 100644 --- a/src/options.cc +++ b/src/options.cc @@ -66,7 +66,7 @@ ParseResult parse_options(int argc, char** argv) { ("experimental", po::value()->implicit_value(true), "Enable experimental animation details, including thin glass shard edges.") ("program", po::value(), - "Transition program duration:type pairs (c, p, blinds, split, cover, uncover, fly, push, dissolve, glitter), for example 5:c:3:p.") + "Transition program duration:type pairs (c, p, blinds, split, cover, uncover, fly, push, dissolve, glitter, wipe), for example 5:c:3:p.") ("performance-log", po::value(), "Write buffered animation frame timings to a CSV file on exit.") ("no-fullscreen,n", po::value()->implicit_value(true), diff --git a/src/playback.hpp b/src/playback.hpp index 159917c..668e67f 100644 --- a/src/playback.hpp +++ b/src/playback.hpp @@ -18,6 +18,7 @@ inline Transition transition_from_name(const std::string& effect) { if (effect == "fly") return Transition::fly; if (effect == "push") return Transition::push; if (effect == "dissolve") return Transition::dissolve; + if (effect == "wipe") return Transition::wipe; if (effect == "glitter") return Transition::glitter; return Transition::switch_page; } @@ -32,6 +33,7 @@ inline Transition transition_from_page(const PageInfo& page) { case POPPLER_PAGE_TRANSITION_FLY: return Transition::fly; case POPPLER_PAGE_TRANSITION_PUSH: return Transition::push; case POPPLER_PAGE_TRANSITION_DISSOLVE: return Transition::dissolve; + case POPPLER_PAGE_TRANSITION_WIPE: return Transition::wipe; case POPPLER_PAGE_TRANSITION_GLITTER: return Transition::glitter; default: return Transition::switch_page; } diff --git a/src/renderer.cc b/src/renderer.cc index dbd3e50..cf249fe 100644 --- a/src/renderer.cc +++ b/src/renderer.cc @@ -8,6 +8,7 @@ #include "push.hpp" #include "dissolve.hpp" #include "glitter.hpp" +#include "wipe.hpp" #include #include @@ -296,6 +297,17 @@ void Renderer::initialize() { reinterpret_cast(6 * sizeof(float))); glEnableVertexAttribArray(3); + glGenVertexArrays(1, &wiper_vao_); + glGenBuffers(1, &wiper_vbo_); + glBindVertexArray(wiper_vao_); + glBindBuffer(GL_ARRAY_BUFFER, wiper_vbo_); + glBufferData(GL_ARRAY_BUFFER, sizeof(wiper_mesh_.vertices), nullptr, GL_DYNAMIC_DRAW); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(WiperVertex), nullptr); + glEnableVertexAttribArray(0); + glVertexAttribPointer(2, 1, GL_FLOAT, GL_FALSE, sizeof(WiperVertex), + reinterpret_cast(3 * sizeof(float))); + glEnableVertexAttribArray(2); + matrix_uniform_ = glGetUniformLocation(program_, "transform"); uv_rect_uniform_ = glGetUniformLocation(program_, "uv_rect"); shade_uniform_ = glGetUniformLocation(program_, "shade"); @@ -320,6 +332,9 @@ void Renderer::release() { if (shard_vbo_ != 0) glDeleteBuffers(1, &shard_vbo_); if (vao_ != 0) glDeleteVertexArrays(1, &vao_); if (shard_vao_ != 0) glDeleteVertexArrays(1, &shard_vao_); + if (wiper_vbo_ != 0) glDeleteBuffers(1, &wiper_vbo_); + if (wiper_vao_ != 0) glDeleteVertexArrays(1, &wiper_vao_); + wiper_vbo_ = wiper_vao_ = 0; if (program_ != 0) glDeleteProgram(program_); vbo_ = vao_ = shard_vbo_ = shard_vao_ = program_ = 0; } @@ -555,6 +570,77 @@ void Renderer::render(int width, int height, const FrameState& frame) { texture_size, Matrix::identity().value.data()); return; } + if (frame.transition == Transition::wipe) { + const auto wipe = wipe_geometry(frame.progress, frame.direction_degrees, width, height); + const auto draw_fitted = [&](int index) { + const auto& info = document_.page_info(index); + const auto fit = content_viewport(width, height, info.width / info.height); + glViewport(fit.x, fit.y, fit.width, fit.height); + draw_page(index, page_size(index), Matrix::identity().value.data()); + }; + glDisable(GL_DEPTH_TEST); + glEnable(GL_SCISSOR_TEST); + // Shade each page only where it is visible. Drawing the incoming page + // beneath the covered area wastes up to a full viewport of fragment work. + const bool positive = wipe.direction == 0 || wipe.direction == 90; + if (wipe.horizontal) { + const int revealed = width - wipe.outgoing.width; + glScissor(positive ? 0 : wipe.outgoing.width, 0, revealed, height); + } else { + const int revealed = height - wipe.outgoing.height; + glScissor(0, positive ? 0 : wipe.outgoing.height, width, revealed); + } + draw_fitted(frame.next_page); + glScissor(wipe.outgoing.x, wipe.outgoing.y, + wipe.outgoing.width, wipe.outgoing.height); + // The outgoing letterbox is part of its image too. Clear the unrevealed + // region before drawing it, so a taller incoming page cannot leak through. + glClear(GL_COLOR_BUFFER_BIT); + draw_fitted(frame.current_page); + glDisable(GL_SCISSOR_TEST); + + // The rubber stays on the reveal plane; raised pressure bridges flex + // slightly above it. Fixed-capacity geometry makes replay deterministic. + glViewport(0, 0, width, height); + const Matrix view = perspective(2 * std::atan(1 / flip_camera), 1, .5F, 10) * + translate(0, 0, -flip_camera); + const Matrix rail = translate(wipe.horizontal ? wipe.center : 0, + wipe.horizontal ? 0 : wipe.center, 0) * + rotate_z(wipe.direction * pi / 180); + // A restrained contact shadow anchors the rubber to the page. Each layer + // is deterministic and follows the same straight reveal edge. + glEnable(GL_BLEND); + glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ZERO, GL_ONE); + for (int layer=3;layer>=1;--layer) { + draw_color(Color{0,0,0,.045F}, + (view * rail * translate(-wipe.half_width*.55F,0,0) * + scale(wipe.half_width*(1+layer*.32F),1.18F,1)).value.data()); + } + glDisable(GL_BLEND); + update_wiper_mesh(frame.progress, wipe.half_width, wipe.direction, wiper_mesh_); + glEnable(GL_DEPTH_TEST); + glBindVertexArray(wiper_vao_); + glBindBuffer(GL_ARRAY_BUFFER, wiper_vbo_); + glBufferSubData(GL_ARRAY_BUFFER, 0, wiper_mesh_.count * sizeof(WiperVertex), + wiper_mesh_.vertices.data()); + const Matrix transform = view * rail; + glUniformMatrix4fv(matrix_uniform_, 1, GL_FALSE, transform.value.data()); + glUniform1i(textured_uniform_, false); + glUniform1f(shade_uniform_, 1); + glVertexAttrib1f(3, 0); + const std::array colors{{{.035F,.045F,.055F,1}, + {.115F,.125F,.135F,1}, {.43F,.46F,.49F,1}}}; + for (int material=0; material<3; ++material) { + const auto& color = colors[material]; + glUniform4f(color_uniform_, color.red, color.green, color.blue, color.alpha); + const auto range = wiper_mesh_.materials[material]; + glDrawArrays(GL_TRIANGLES, range.first, range.count); + } + glBindVertexArray(vao_); + glVertexAttrib1f(2, 1); + glVertexAttrib1f(3, 0); + return; + } if (frame.transition == Transition::push) { const PushPose pose = push_pose(frame.progress, frame.direction_degrees); const Matrix view = perspective(2 * std::atan(1 / flip_camera), 1, .5F, 10) * diff --git a/src/renderer.hpp b/src/renderer.hpp index 6d61be5..ffff85a 100644 --- a/src/renderer.hpp +++ b/src/renderer.hpp @@ -3,6 +3,7 @@ #include "document.hpp" #include "dissolve.hpp" #include "options.hpp" +#include "wiper_blade.hpp" #include @@ -17,14 +18,15 @@ namespace pdfcube { enum class Transition { none, cube_forward, cube_backward, switch_page, blinds, split, cover, uncover, fly, push, - dissolve, glitter + dissolve, glitter, wipe }; inline bool page_effect(Transition transition) { return transition == Transition::blinds || transition == Transition::split || transition == Transition::cover || transition == Transition::uncover || transition == Transition::fly || transition == Transition::push || - transition == Transition::dissolve || transition == Transition::glitter; + transition == Transition::dissolve || transition == Transition::glitter || + transition == Transition::wipe; } struct FrameState { @@ -93,6 +95,9 @@ class Renderer { GLuint program_{0}; GLuint vao_{0}; GLuint vbo_{0}; + WiperMesh wiper_mesh_; + GLuint wiper_vao_{0}; + GLuint wiper_vbo_{0}; GLuint shard_vao_{0}; GLuint shard_vbo_{0}; GLint matrix_uniform_{-1}; diff --git a/src/wipe.hpp b/src/wipe.hpp new file mode 100644 index 0000000..7e8bf37 --- /dev/null +++ b/src/wipe.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +namespace pdfcube { + +inline int wipe_direction(int degrees) { + return degrees == 90 || degrees == 180 || degrees == 270 ? degrees : 0; +} + +struct WipeClip { int x, y, width, height; }; +struct WipeGeometry { + WipeClip outgoing; + bool horizontal; + float center; // Clip-space blade center along its travel axis. + float half_width; + int direction; +}; + +// The blade travels just beyond both edges, including its bevel. Pixel-aligned +// clipping leaves no partially sampled seam between independently fitted pages. +inline WipeGeometry wipe_geometry(double progress, int degrees, int width, int height) { + const int direction = wipe_direction(degrees); + const bool horizontal = direction == 0 || direction == 180; + const bool positive = direction == 0 || direction == 90; + const int extent = horizontal ? width : height; + const float half_pixels = std::max(2.0F, .014F * std::min(width, height)); + const float margin = 4 * half_pixels / extent; + const double p = std::clamp(progress, 0.0, 1.0); + const double eased = p * p * (3 - 2 * p); + const double front = -margin + (1 + 2 * margin) * eased; + const int swept = std::clamp(static_cast(std::lround(front * extent)), 0, extent); + WipeClip clip{0, 0, width, height}; + if (horizontal) { + clip.x = positive ? swept : 0; + clip.width = width - swept; + } else { + clip.y = positive ? swept : 0; + clip.height = height - swept; + } + return {clip, horizontal, static_cast((positive ? 1 : -1) * (2 * front - 1)), + 2 * half_pixels / extent, direction}; +} + +} // namespace pdfcube diff --git a/src/wiper_blade.hpp b/src/wiper_blade.hpp new file mode 100644 index 0000000..72ede3f --- /dev/null +++ b/src/wiper_blade.hpp @@ -0,0 +1,141 @@ +#pragma once + +#include "math.hpp" +#include +#include +#include +#include + +namespace pdfcube { + +struct WiperVertex { float x, y, z, light; }; +struct WiperRange { int first, count; }; +struct WiperMesh { + std::array vertices{}; + std::array materials{}; // Rubber, painted frame, steel. + int count{0}; +}; + +inline float wiper_flex(double progress) { + constexpr double pi = 3.14159265358979323846; + const double p = std::clamp(progress, 0.0, 1.0); + if (p <= 0 || p >= 1) return 0; + // A small damped response, evaluated directly rather than integrated. + return static_cast(.07 * std::sin(5*pi*p) * std::sin(pi*p) * std::exp(-3*p)); +} + +// Fixed-capacity, closed, lit meshes. All part geometry is regenerated in-place; +// there are no per-frame allocations, random values or accumulated simulation state. +inline void update_wiper_mesh(double progress, float half, int direction, WiperMesh& mesh) { + constexpr float pi = 3.14159265358979323846F; + using Point = std::array; + mesh.count = 0; + const float p = static_cast(std::clamp(progress,0.0,1.0)); + const Matrix flex = translate(-half*.22F,0,.018F) * rotate_y(.48F + wiper_flex(p)); + const float angle = direction * pi / 180; + const float cosine = std::cos(angle), sine = std::sin(angle); + const auto height = [&](float y) { + const float bow = std::max(0.0F,1-y*y/(1.14F*1.14F)); + return .026F + (.055F + .006F*std::sin(pi*p))*bow; + }; + for (int material=0; material<3; ++material) { + const int first = mesh.count; + const auto lighting = [&](const Matrix& model, Point normal) { + const auto& m = model.value; + const float nx=m[0]*normal[0]+m[4]*normal[1]+m[8]*normal[2]; + const float ny=m[1]*normal[0]+m[5]*normal[1]+m[9]*normal[2]; + const float nz=m[2]*normal[0]+m[6]*normal[1]+m[10]*normal[2]; + const float world_x=cosine*nx-sine*ny, world_y=sine*nx+cosine*ny; + const float diffuse=std::max(0.0F,-.42F*world_x+.36F*world_y+.83F*nz); + const float highlight=.14F*std::pow(std::max(nz,0.0F),16); + return .30F+.65F*diffuse+highlight; + }; + const auto position = [&](const Matrix& model, Point point, float light) { + if (mesh.count >= static_cast(mesh.vertices.size())) + throw std::length_error("wiper mesh exceeded fixed capacity"); + const auto& m = model.value; + const float x=m[0]*point[0]+m[4]*point[1]+m[8]*point[2]+m[12]; + const float y=m[1]*point[0]+m[5]*point[1]+m[9]*point[2]+m[13]; + const float z=m[2]*point[0]+m[6]*point[1]+m[10]*point[2]+m[14]; + mesh.vertices[mesh.count++]={x,y,z,light}; + }; + const auto vertex = [&](const Matrix& model, Point point, Point normal) { + position(model,point,lighting(model,normal)); + }; + const auto quad = [&](const Matrix& model, std::array corners, Point normal) { + const float light=lighting(model,normal); + for (int index : {0,1,2,0,2,3}) position(model,corners[index],light); + }; + const auto box = [&](int kind,const Matrix& model,float x,float y,float z) { + if (kind != material) return; + // Beveled extrusion: broad matte faces and narrow rounded shoulders. + // Narrow shoulders catch light without a painted outline. + const float bevel=std::min(x*.28F,z*.55F); + const std::array ring{{ + {-x+bevel,0,z},{x-bevel,0,z},{x,0,z-bevel},{x,0,-z+bevel}, + {x-bevel,0,-z},{-x+bevel,0,-z},{-x,0,-z+bevel},{-x,0,z-bevel}}}; + for (int face=0;face<8;++face) { + const auto a=ring[face], b=ring[(face+1)%8]; + const float dx=b[0]-a[0], dz=b[2]-a[2]; + const float length=std::hypot(dx,dz); + const Point normal{-dz/length,0,dx/length}; + quad(model,{{{a[0],-y,a[2]},{b[0],-y,b[2]}, + {b[0],y,b[2]},{a[0],y,a[2]}}},normal); + vertex(model,{0,y,0},{0,1,0}); + vertex(model,{a[0],y,a[2]},{0,1,0}); + vertex(model,{b[0],y,b[2]},{0,1,0}); + vertex(model,{0,-y,0},{0,-1,0}); + vertex(model,{b[0],-y,b[2]},{0,-1,0}); + vertex(model,{a[0],-y,a[2]},{0,-1,0}); + } + }; + // The flexible rubber lip stays at the reveal edge while the frame flexes above it. + box(0,translate(0,0,.004F),half*.74F,1.16F,.004F); + box(0,translate(-half*.2F,0,.018F),half*.50F,1.15F,.012F); + // Eight short sections follow the bowed pressure spine, with a narrow steel edge. + for (int section=0; section<8; ++section) { + const float a=-1.14F+section*.285F, b=a+.285F; + const float y=(a+b)/2, z=(height(a)+height(b))/2; + const float slope=std::atan2(height(b)-height(a),b-a); + const float length=std::hypot(b-a,height(b)-height(a))/2; + const Matrix pose=flex*translate(-half*.35F,y,z)*rotate_x(slope); + box(1,pose,half*.50F,length,.012F); + box(2,pose*translate(-half*.37F,0,.012F),half*.035F,length,.001F); + } + // Pressure clips and two articulated bridges are separate, visibly raised parts. + for (float y : {-.86F,-.40F,.40F,.86F}) { + box(1,flex*translate(0,y,.030F),half*.72F,.013F,.012F); + box(2,flex*translate(-half*.1F,y,.050F),half*.12F,.010F,.003F); + } + const auto bridge = [&](float a,float b,float za,float zb) { + const float length=std::hypot(b-a,zb-za)/2; + const Matrix pose=flex*translate(-half*.70F,(a+b)/2,(za+zb)/2)* + rotate_x(std::atan2(zb-za,b-a)); + box(1,pose,half*.40F,length,.010F); + }; + bridge(-.40F,0,.085F,.137F); bridge(0,.40F,.137F,.085F); + bridge(-.86F,-.40F,.048F,.085F); bridge(.40F,.86F,.085F,.048F); + box(1,flex*translate(-half*.4F,0,.137F),half*.83F,.043F,.016F); + for (float side : {-1.0F,1.0F}) + box(1,flex*translate(-half*.4F+side*half*.67F,0,.132F)*rotate_y(side*.60F), + half*.24F,.040F,.010F); + // A round metal pivot pin gives the center coupling a mechanical silhouette. + if (material == 2) { + const Matrix pose=flex*translate(-half*.4F,0,.161F); + const float radius=half*.18F; + for (int side=0;side<12;++side) { + const float a=2*pi*side/12, b=2*pi*(side+1)/12; + const Point front_a{radius*std::cos(a),radius*std::sin(a),.004F}; + const Point front_b{radius*std::cos(b),radius*std::sin(b),.004F}; + vertex(pose,{0,0,.004F},{0,0,1}); + vertex(pose,front_a,{0,0,1}); vertex(pose,front_b,{0,0,1}); + quad(pose,{{front_a,{front_a[0],front_a[1],-.004F}, + {front_b[0],front_b[1],-.004F},front_b}}, + {std::cos((a+b)/2),std::sin((a+b)/2),0}); + } + } + mesh.materials[material]={first,mesh.count-first}; + } +} + +} // namespace pdfcube diff --git a/tests/README.md b/tests/README.md index 6c6fb4b..5c1b329 100644 --- a/tests/README.md +++ b/tests/README.md @@ -31,6 +31,11 @@ and reads back all 61 frames of a 60 Hz second, including experimental glass. A small Fontconfig fixture uses DejaVu Sans instead of scanning all system font folders; both cold font and shader initialization remain inside the measured budget. +Wipe cases compare the regions ahead of and behind the blade with exact static +page pixels, including mixed page letterboxes. They verify that the next static +frame is unaffected by clipping/depth state. Geometry tests sample 1001 explicit +progress values per direction and viewport shape without wall-clock waits. + Clock and auto-advance tests use synthetic timestamps and a recording scheduler. They verify frame progress and the exact millisecond delays sent to the backend, including cancellation and idle dispatch. They never sleep. @@ -61,3 +66,8 @@ and decoding. It retains dwell/transition frame counts, endpoint, orientation, codec, dimensions, faststart, and parser-guard checks. On the local runner it takes about 0.6 seconds; report its 500 ms SLO miss rather than dropping checks. Publication/README replacement tests mock remote calls and remain below 500 ms. + +Wipe geometry coverage also checks fixed mesh capacity, contiguous material ranges, +finite lit vertices, deterministic flex and mesh replay at explicit progress values. +The replay memory check warms three cycles and measures five more in one command, reusing readback storage +so parser and harness allocation retention does not contaminate the renderer bound. diff --git a/tests/meson.build b/tests/meson.build index 47943ee..b5465eb 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -3,6 +3,7 @@ python = import('python').find_installation('python3') test_watchdog = 10 foreach item : [ + ['physical wiper geometry', 'test_wipe.cc'], ['directional glitter geometry', 'test_glitter.cc'], ['deterministic glass fracture', 'test_dissolve.cc'], ['Fly leaf trajectory', 'test_fly.cc'], @@ -74,7 +75,7 @@ if host_machine.system() == 'linux' render_environment.set('FONTCONFIG_FILE', meson.current_source_dir() / 'fonts.conf') render_environment.set('XDG_CACHE_HOME', meson.current_build_dir() / 'native-test-cache') # Every case targets 500 ms, including cold setup. Serialize to avoid GPU contention. - foreach effect : ['Blinds', 'Split', 'Cover', 'Uncover', 'Fly', 'Push', 'Dissolve', 'Glitter'] + foreach effect : ['Blinds', 'Split', 'Cover', 'Uncover', 'Fly', 'Push', 'Dissolve', 'Glitter', 'Wipe'] axes = ['H'] angles = ['none'] motions = ['I'] @@ -84,7 +85,7 @@ if host_machine.system() == 'linux' if effect == 'Split' or effect == 'Fly' motions = ['I', 'O'] endif - if effect == 'Cover' or effect == 'Uncover' or effect == 'Push' + if effect == 'Cover' or effect == 'Uncover' or effect == 'Push' or effect == 'Wipe' angles = ['0', '90', '180', '270'] elif effect == 'Fly' angles = ['0', '45', '90', '135', '180', '225', '270', '315'] @@ -112,6 +113,9 @@ if host_machine.system() == 'linux' endforeach endforeach endforeach + test('Wipe repeated playback memory', python, + args: [files('test_wipe_replay.py'), render_driver], + timeout: test_watchdog, is_parallel: false, suite: ['render'], env: render_environment) test('rendering compatibility', python, args: [files('test_render.py'), render_driver, files('fixture.pdf'), files('fixture-wide.pdf')], timeout: test_watchdog, is_parallel: false, suite: ['render'], env: render_environment) diff --git a/tests/render_client.py b/tests/render_client.py index 131c028..e935441 100644 --- a/tests/render_client.py +++ b/tests/render_client.py @@ -7,7 +7,7 @@ class Frames: - def __init__(self, driver, root, slo=.45): + def __init__(self, driver, root, slo=.45, environment=None): self.started = time.perf_counter() self.slo = slo cache = os.environ.get("XDG_CACHE_HOME", str(Path(driver).resolve().parent/".pdfcube-test-cache")) @@ -15,7 +15,8 @@ def __init__(self, driver, root, slo=.45): [str(Path(driver).resolve())], text=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=dict(os.environ, HOME=str(root), XDG_CACHE_HOME=cache, - MESA_SHADER_CACHE_DIR=str(Path(cache)/"mesa"), LIBGL_ALWAYS_SOFTWARE="1")) + MESA_SHADER_CACHE_DIR=str(Path(cache)/"mesa"), LIBGL_ALWAYS_SOFTWARE="1", + **(environment or {}))) assert self.process.stdout.readline().strip() == "READY", self.process.stderr.read() def render(self, *arguments): diff --git a/tests/render_driver.cc b/tests/render_driver.cc index 1a7689b..419d625 100644 --- a/tests/render_driver.cc +++ b/tests/render_driver.cc @@ -50,6 +50,18 @@ struct Context { eglTerminate(display); } }; +std::size_t resident_bytes() { + std::ifstream status("/proc/self/status"); + for (std::string line; std::getline(status,line);) { + if (line.starts_with("VmRSS:")) { + std::istringstream value(line.substr(6)); + std::size_t kib=0; + value >> kib; + return kib*1024; + } + } + throw std::runtime_error("cannot read renderer RSS"); +} struct Pages { pdfcube::Document document; pdfcube::Renderer renderer; @@ -71,7 +83,8 @@ int main() { std::istringstream request(line); std::vector arguments{"pdfcube"}; for (std::string value; request >> std::quoted(value);) arguments.push_back(value); - const bool sequence = arguments.size() > 1 && arguments[1] == "@sequence"; + const bool replay = arguments.size() > 1 && arguments[1] == "@replay"; + const bool sequence = replay || (arguments.size() > 1 && arguments[1] == "@sequence"); if (sequence) arguments.erase(arguments.begin()+1); std::vector pointers; for (auto& argument : arguments) pointers.push_back(argument.data()); @@ -120,12 +133,21 @@ int main() { // Render every frame of a 60 Hz second directly, without PNG encoding // or advancing a wall clock. Readback catches deferred GL failures. std::vector pixels(320*240*4); - for (int index=0;index<=60;++index) { - frame.progress = index/60.0; - pages->renderer.render(320,240,frame); - glReadPixels(0,0,320,240,GL_RGBA,GL_UNSIGNED_BYTE,pixels.data()); - if (glGetError() != GL_NO_ERROR) throw std::runtime_error("frame rendering failed"); + std::ofstream memory; + if (replay) memory.open(options.snapshot); + // Warm three cycles for driver streaming buffers, then measure five. + // Reuse parsed options and the readback buffer across replays. ASan + // quarantine of harness allocations must not masquerade as a renderer leak. + for (int cycle=0;cycle<(replay ? 8 : 1);++cycle) { + for (int index=0;index<=60;++index) { + frame.progress = index/60.0; + pages->renderer.render(320,240,frame); + glReadPixels(0,0,320,240,GL_RGBA,GL_UNSIGNED_BYTE,pixels.data()); + if (glGetError() != GL_NO_ERROR) throw std::runtime_error("frame rendering failed"); + } + if (replay && cycle >= 3) memory << resident_bytes() << '\n'; } + if (replay && !memory) throw std::runtime_error("cannot write replay memory report"); } else { pages->renderer.render(320,240,frame); pages->renderer.write_png(options.snapshot,320,240); diff --git a/tests/test_playback.cc b/tests/test_playback.cc index 534285e..f875ff5 100644 --- a/tests/test_playback.cc +++ b/tests/test_playback.cc @@ -10,7 +10,8 @@ int main() { {POPPLER_PAGE_TRANSITION_COVER,"cover"}, {POPPLER_PAGE_TRANSITION_UNCOVER,"uncover"}, {POPPLER_PAGE_TRANSITION_FLY,"fly"}, {POPPLER_PAGE_TRANSITION_PUSH,"push"}, {POPPLER_PAGE_TRANSITION_DISSOLVE,"dissolve"}, - {POPPLER_PAGE_TRANSITION_GLITTER,"glitter"}}; + {POPPLER_PAGE_TRANSITION_GLITTER,"glitter"}, + {POPPLER_PAGE_TRANSITION_WIPE,"wipe"}}; for (const auto& [kind,name] : kinds) { pdfcube::PageInfo page; page.transition_type=kind; diff --git a/tests/test_transition_metadata.cc b/tests/test_transition_metadata.cc index 8c53cb7..ebe4017 100644 --- a/tests/test_transition_metadata.cc +++ b/tests/test_transition_metadata.cc @@ -12,6 +12,7 @@ int main(int argc, char** argv) { const bool fly = argc >= 5 && argv[3][0] == 'F'; const bool push = argc >= 5 && argv[3][0] == 'P'; const bool dissolve = argc >= 5 && argv[3][0] == 'D'; + const bool wipe = argc >= 5 && argv[3][0] == 'W'; const bool glitter = argc >= 5 && argv[3][0] == 'G'; const auto type = split ? POPPLER_PAGE_TRANSITION_SPLIT : cover ? POPPLER_PAGE_TRANSITION_COVER : @@ -19,6 +20,7 @@ int main(int argc, char** argv) { fly ? POPPLER_PAGE_TRANSITION_FLY : push ? POPPLER_PAGE_TRANSITION_PUSH : dissolve ? POPPLER_PAGE_TRANSITION_DISSOLVE : + wipe ? POPPLER_PAGE_TRANSITION_WIPE : glitter ? POPPLER_PAGE_TRANSITION_GLITTER : POPPLER_PAGE_TRANSITION_BLINDS; const bool success = page.transition_type == type && (argc < 6 || page.angle == std::atoi(argv[5])) && diff --git a/tests/test_transitions.py b/tests/test_transitions.py index bc163f0..d90d4a8 100644 --- a/tests/test_transitions.py +++ b/tests/test_transitions.py @@ -17,6 +17,7 @@ push = effect == "Push" dissolve = effect == "Dissolve" glitter = effect == "Glitter" +wipe = effect == "Wipe" with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -57,7 +58,7 @@ def capture(pdf, progress=None, *extra): sys.exit(0) assert mode == "checkpoints", "unknown rendering test mode" metadata = [metadata_test, str(deck), orientation, effect, motion] - extra_metadata = [str(angle)] if flip or push or glitter else \ + extra_metadata = [str(angle)] if flip or push or glitter or wipe else \ [str(angle), ".35", "1"] if fly else [] subprocess.run(metadata + extra_metadata, check=True) make_pdf(first, orientation, sizes, (0,)) @@ -79,7 +80,7 @@ def capture(pdf, progress=None, *extra): fallback = root / "fallback.pdf" make_pdf(fallback, orientation, sizes, effect=effect, angle=value) assert capture(fallback, .5) == middle, "default/invalid direction differs" - if (push or glitter) and angle == 0: + if (push or glitter or wipe) and angle == 0: for value in (None, 45, -90): fallback = root / "fallback.pdf" make_pdf(fallback, orientation, sizes, effect=effect, angle=value) @@ -107,6 +108,23 @@ def capture(pdf, progress=None, *extra): make_pdf(opposite, orientation, sizes, effect=effect, motion="O" if motion == "I" else "I") assert middle != capture(opposite, .5), "inward/outward direction ignored" + if wipe: + from PIL import Image + from io import BytesIO + # Pixels safely behind/ahead of the blade must be exact page + # pixels, including the independent letterboxes in mixed ratios. + before = Image.open(BytesIO(start)) + after = Image.open(BytesIO(end)) + wiped = Image.open(BytesIO(middle)) + w,h = wiped.size + if angle in (0,180): + low,high = (0,0,w*2//5,h),(w*3//5,0,w,h) + else: + low,high = (0,0,w,h*2//5),(0,h*3//5,w,h) + revealed,covered = (low,high) if angle in (0,270) else (high,low) + assert wiped.crop(revealed).tobytes() == after.crop(revealed).tobytes() + assert wiped.crop(covered).tobytes() == before.crop(covered).tobytes() + assert capture(first) == start, "wiper leaked GL state into a static frame" quarter, three_quarters = capture(deck, .25), capture(deck, .75) assert len({start, quarter, middle, three_quarters, end}) == 5, "cascade stalled" if not dissolve: @@ -114,7 +132,7 @@ def capture(pdf, progress=None, *extra): if glitter: make_pdf(changed, orientation, sizes, effect=effect, angle={0: 270, 270: 315, 315: 0}[angle]) - elif flip or fly or push: + elif flip or fly or push or wipe: make_pdf(changed, orientation, sizes, effect=effect, motion=motion, angle=(angle+180)%360) else: diff --git a/tests/test_wipe.cc b/tests/test_wipe.cc new file mode 100644 index 0000000..b2354d4 --- /dev/null +++ b/tests/test_wipe.cc @@ -0,0 +1,67 @@ +#include "wipe.hpp" +#include "wiper_blade.hpp" + +#include +#include + +int main() { + using namespace pdfcube; + for (const int direction : {0,90,180,270}) { + for (const auto size : {WipeClip{0,0,320,240},WipeClip{0,0,180,320},WipeClip{0,0,1,1}}) { + const auto first = wipe_geometry(0,direction,size.width,size.height); + const auto last = wipe_geometry(1,direction,size.width,size.height); + assert(first.outgoing.width == size.width && first.outgoing.height == size.height); + assert(last.outgoing.width == 0 || last.outgoing.height == 0); + int previous = size.width * size.height; + float previous_center = first.center; + for (int step=0; step<=1000; ++step) { + const auto pose = wipe_geometry(step/1000.0,direction,size.width,size.height); + const auto again = wipe_geometry(step/1000.0,direction,size.width,size.height); + const auto clip = pose.outgoing; + assert(clip.x >= 0 && clip.y >= 0 && clip.width >= 0 && clip.height >= 0); + assert(clip.x + clip.width <= size.width && clip.y + clip.height <= size.height); + const int area = clip.width * clip.height; + assert(area <= previous); + previous = area; + assert(pose.center == again.center && pose.half_width == again.half_width); + assert(pose.half_width > 0 && std::isfinite(pose.center)); + if (direction == 0 || direction == 90) assert(pose.center >= previous_center); + else assert(pose.center <= previous_center); + previous_center = pose.center; + } + assert(wipe_geometry(-1,direction,size.width,size.height).center == first.center); + assert(wipe_geometry(2,direction,size.width,size.height).center == last.center); + // Symmetric travel and a midpoint exactly at the center, without time integration. + const auto quarter = wipe_geometry(.25,direction,size.width,size.height); + const auto three = wipe_geometry(.75,direction,size.width,size.height); + assert(std::abs(quarter.center + three.center) < .00001F); + assert(std::abs(wipe_geometry(.5,direction,size.width,size.height).center) < .00001F); + } + } + for (const int invalid : {-90,45,135,360}) { + assert(wipe_direction(invalid) == 0); + assert(wipe_geometry(.37,invalid,320,240).center == wipe_geometry(.37,0,320,240).center); + } + assert(wiper_flex(0) == 0 && wiper_flex(1) == 0); + WiperMesh mesh, again; + for (int direction : {0,90,180,270}) for (int step=0; step<=32; ++step) { + const double p = step/32.0; + update_wiper_mesh(p,.025F,direction,mesh); + update_wiper_mesh(p,.025F,direction,again); + assert(mesh.count > 0 && mesh.count <= static_cast(mesh.vertices.size())); + assert(mesh.count == again.count && std::abs(wiper_flex(p)) < .07F); + int end=0; + for (auto range : mesh.materials) { + assert(range.first == end && range.count > 0 && range.count % 3 == 0); + end += range.count; + } + assert(end == mesh.count); + for (int i=0;i= -.001F && v.light >= .3F && v.light <= 1.2F); + assert(v.x == other.x && v.y == other.y && v.z == other.z && v.light == other.light); + } + } + +} diff --git a/tests/test_wipe_replay.py b/tests/test_wipe_replay.py new file mode 100644 index 0000000..ed97d2c --- /dev/null +++ b/tests/test_wipe_replay.py @@ -0,0 +1,25 @@ +"""Replay in one live headless renderer; RSS growth must stay bounded.""" +import os +from pathlib import Path +import sys +import tempfile + +from render_client import Frames +from transition_fixture import make_pdf + +with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + deck = root / 'wipe.pdf' + make_pdf(deck,effect='Wipe',angle=270) + # RSS measures live/pool storage, not ASan's deliberately retained freed blocks. + # Other frame tests keep the standard quarantine for use-after-free coverage. + sanitizer = os.environ.get('ASAN_OPTIONS','') + sanitizer += ':quarantine_size_mb=0:thread_local_quarantine_size_kb=0' + frames = Frames(Path(sys.argv[1]),root,environment={'ASAN_OPTIONS': sanitizer}) + report = root/'memory.txt' + frames.render('@replay','--snapshot',report,'--snapshot-progress','0',deck) + usage = [int(line) for line in report.read_text().splitlines()] + assert len(usage) == 5 and min(usage) > 0 + baseline = usage[0] # First measured cycle follows three explicit warmup cycles. + assert max(usage[1:]) <= baseline + 4*1024*1024, f'Wipe RSS grew: {usage}' + frames.close() diff --git a/tests/transition_fixture.py b/tests/transition_fixture.py index d6fec8e..fb1c586 100644 --- a/tests/transition_fixture.py +++ b/tests/transition_fixture.py @@ -21,6 +21,9 @@ EXAMPLES += [(f"glitter-{name}", "Glitter", "H", "I", angle) for angle, name in ((0, "right"), (270, "down"), (315, "down-right"))] +EXAMPLES += [(f"wipe-{name}", "Wipe", "H", "I", angle) + for angle,name in ((0,"right"),(90,"up"),(180,"left"),(270,"down"))] + def make_pdf(path, orientation="H", sizes=((640, 360), (640, 360)), pages=(0, 1), effect="Blinds", motion="I", angle=None, start_scale=.35, rectangular=True): diff --git a/tools/transition_gallery.py b/tools/transition_gallery.py index 919aadc..888b5e1 100644 --- a/tools/transition_gallery.py +++ b/tools/transition_gallery.py @@ -21,7 +21,7 @@ help="Number of snapshots to capture concurrently (default: up to 4)") parser.add_argument("--update-docs", action="store_true", help="Refresh the checked-in contact sheets from these captures") -parser.add_argument("--effects", nargs="+", choices=("Blinds", "Split", "Cover", "Uncover", "Fly", "Push", "Dissolve", "Glitter"), +parser.add_argument("--effects", nargs="+", choices=("Blinds", "Split", "Cover", "Uncover", "Fly", "Push", "Dissolve", "Glitter", "Wipe"), help="Capture only selected effects (default: all)") parser.add_argument("--inside-xvfb", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args()