feat(image): compose layout fit as a separate scale so user scale stays independent (#12896) c372d0d9d4 feat(image): compose layout fit as a separate scale so user scale stays animatable Co-authored-by: hernan <hernan@rive.app>
diff --git a/.rive_head b/.rive_head index e38af11..d4162ac1 100644 --- a/.rive_head +++ b/.rive_head
@@ -1 +1 @@ -cc34cea96329320072dcc92cc4d302db44369dbe +c372d0d9d43f0adaba357ab55d212828b0a156a5
diff --git a/include/rive/file.hpp b/include/rive/file.hpp index 738bc9a..8af6aae 100644 --- a/include/rive/file.hpp +++ b/include/rive/file.hpp
@@ -81,7 +81,9 @@ /// Major version number supported by the runtime. static const int majorVersion = 7; /// Minor version number supported by the runtime. - static const int minorVersion = 0; + /// 7.2: images in a layout apply their fit as a separate scale, leaving + /// the user-facing scaleX/scaleY free to be edited/animated on top. + static const int minorVersion = 2; /// deterministicMode sets a static seed for randomization and uses /// timestamps for scrolling. static bool deterministicMode;
diff --git a/include/rive/importers/import_stack.hpp b/include/rive/importers/import_stack.hpp index e26f76e..81c0ee3 100644 --- a/include/rive/importers/import_stack.hpp +++ b/include/rive/importers/import_stack.hpp
@@ -102,9 +102,21 @@ return false; } + // The major/minor version of the file being imported. Objects can read + // this during import() to gate behavior on the file format version. + int majorVersion() const { return m_majorVersion; } + int minorVersion() const { return m_minorVersion; } + void version(int major, int minor) + { + m_majorVersion = major; + m_minorVersion = minor; + } + private: std::unordered_map<uint16_t, std::unique_ptr<ImportStackObject>> m_latests; std::vector<ImportStackObject*> m_lastAdded; + int m_majorVersion = 0; + int m_minorVersion = 0; }; } // namespace rive #endif
diff --git a/include/rive/shapes/image.hpp b/include/rive/shapes/image.hpp index 40c94a0..44a0620 100644 --- a/include/rive/shapes/image.hpp +++ b/include/rive/shapes/image.hpp
@@ -35,6 +35,17 @@ float m_layoutHeight = NAN; float m_layoutOffsetX = 0.0f; float m_layoutOffsetY = 0.0f; + // The layout-driven fit scale is stored separately (rather than written + // into scaleX/scaleY) so the user-facing scale stays free to be + // edited/animated. It is composed with the local transform in + // updateTransform(). + float m_layoutScaleX = 1.0f; + float m_layoutScaleY = 1.0f; + // Whether the file applies the layout fit as a separate scale (7.2+). + // Legacy files overwrite scaleX/scaleY with the fit instead. Set from the + // file version at import; defaults to the modern behavior for + // runtime-created images. + bool m_layoutScaleSeparate = true; void updateImageScale(); public: @@ -57,6 +68,10 @@ LayoutDirection direction) override; float width() const; float height() const; + // Effective render scale: the user-facing scaleX/scaleY composed with the + // layout fit scale. Equals the user scale when not in a layout. + float renderScaleX() const { return scaleX() * m_layoutScaleX; } + float renderScaleY() const { return scaleY() * m_layoutScaleY; } void assetUpdated() override; AABB localBounds() const override; void updateTransform() override;
diff --git a/src/file.cpp b/src/file.cpp index 84f1c6d..138465c 100644 --- a/src/file.cpp +++ b/src/file.cpp
@@ -295,6 +295,7 @@ ImportResult File::read(BinaryReader& reader, const RuntimeHeader& header) { ImportStack importStack; + importStack.version(header.majorVersion(), header.minorVersion()); #ifdef WITH_RIVE_SCRIPTING std::vector<InBandContent> inBandContent; #endif
diff --git a/src/shapes/image.cpp b/src/shapes/image.cpp index d4271a7..65f296a 100644 --- a/src/shapes/image.cpp +++ b/src/shapes/image.cpp
@@ -96,6 +96,13 @@ { return result; } + // Files exported before 7.2 overwrite scaleX/scaleY with the layout fit, so + // any stored scale on a layout image was ignored. Keep that legacy behavior + // for those files; newer files compose the fit as a separate scale so the + // user scale stays editable/animatable. + int major = importStack.majorVersion(); + int minor = importStack.minorVersion(); + m_layoutScaleSeparate = major > 7 || (major == 7 && minor >= 2); return Super::import(importStack); } @@ -129,6 +136,7 @@ Core* Image::clone() const { Image* twin = ImageBase::clone()->as<Image>(); + twin->m_layoutScaleSeparate = m_layoutScaleSeparate; if (m_fileAsset != nullptr) { twin->setAsset(m_fileAsset); @@ -230,6 +238,10 @@ void Image::updateTransform() { Super::updateTransform(); + // Compose the layout fit scale on top of the user transform (innermost), so + // the user's scaleX/scaleY (built by Super) remain free to be animated: + // M = T(offset) * UserLocal * S(fitScale) + m_Transform.scaleByValues(m_layoutScaleX, m_layoutScaleY); m_Transform[4] += m_layoutOffsetX; m_Transform[5] += m_layoutOffsetY; } @@ -323,10 +335,26 @@ newOffsetY = -scaledTop + heightRemainder * yAlign; } - if (newScaleX != scaleX() || newScaleY != scaleY()) + if (m_layoutScaleSeparate) { - scaleX(newScaleX); - scaleY(newScaleY); + if (newScaleX != m_layoutScaleX || newScaleY != m_layoutScaleY) + { + m_layoutScaleX = newScaleX; + m_layoutScaleY = newScaleY; + // Fit scale is composed in updateTransform(), so changing it + // must mark the local transform dirty (not just the world + // transform). + markTransformDirty(); + } + } + else + { + // Legacy (pre-7.2): the fit overwrites the user scale. + if (newScaleX != scaleX() || newScaleY != scaleY()) + { + scaleX(newScaleX); + scaleY(newScaleY); + } } } if (newOffsetX != m_layoutOffsetX || newOffsetY != m_layoutOffsetY)
diff --git a/src/shapes/slice_mesh.cpp b/src/shapes/slice_mesh.cpp index 39de547..e949a50 100644 --- a/src/shapes/slice_mesh.cpp +++ b/src/shapes/slice_mesh.cpp
@@ -151,8 +151,8 @@ float imageSize = forAxis == AxisType::X ? m_nslicer->image()->width() : m_nslicer->image()->height(); float imageScale = - std::abs(forAxis == AxisType::X ? m_nslicer->image()->scaleX() - : m_nslicer->image()->scaleY()); + std::abs(forAxis == AxisType::X ? m_nslicer->image()->renderScaleX() + : m_nslicer->image()->renderScaleY()); if (imageSize == 0 || imageScale == 0) { return {}; @@ -171,10 +171,10 @@ Image* image = m_nslicer->image(); float imageSize = forAxis == AxisType::X ? image->width() : image->height(); - // When doing calcualtions, we assume scale is always non-negative to keep + // When doing calculations, we assume scale is always non-negative to keep // everything in image space. - float imageScale = - std::abs(forAxis == AxisType::X ? image->scaleX() : image->scaleY()); + float imageScale = std::abs(forAxis == AxisType::X ? image->renderScaleX() + : image->renderScaleY()); if (imageSize == 0 || imageScale == 0) { return {}; @@ -233,8 +233,8 @@ // The size of each repeated tile in image space Image* image = m_nslicer->image(); - float scaleX = std::abs(image->scaleX()); - float scaleY = std::abs(image->scaleY()); + float scaleX = std::abs(image->renderScaleX()); + float scaleY = std::abs(image->renderScaleY()); if (scaleX == 0 || scaleY == 0) {
diff --git a/tests/unit_tests/assets/image_fit_alignment_updated_test.riv b/tests/unit_tests/assets/image_fit_alignment_updated_test.riv new file mode 100644 index 0000000..c30b5bb --- /dev/null +++ b/tests/unit_tests/assets/image_fit_alignment_updated_test.riv Binary files differ
diff --git a/tests/unit_tests/runtime/data_binding_images_test.cpp b/tests/unit_tests/runtime/data_binding_images_test.cpp index 25fc8e4..3337bb8 100644 --- a/tests/unit_tests/runtime/data_binding_images_test.cpp +++ b/tests/unit_tests/runtime/data_binding_images_test.cpp
@@ -8,6 +8,7 @@ #include <rive/shapes/paint/color.hpp> #include <rive/shapes/paint/fill.hpp> #include <rive/shapes/image.hpp> +#include <rive/layout_component.hpp> #include <rive/shapes/paint/solid_color.hpp> #include <rive/text/text_value_run.hpp> #include <rive/custom_property_number.hpp> @@ -427,3 +428,152 @@ CHECK(silver.matches("image_fit_alignment_3")); } + +TEST_CASE("Image fit & alignment with image scaling", "[silver]") +{ + rive::SerializingFactory silver; + auto file = + ReadRiveFile("assets/image_fit_alignment_updated_test.riv", &silver); + + auto artboard = file->artboardNamed("Main"); + REQUIRE(artboard != nullptr); + silver.frameSize(artboard->width(), artboard->height()); + + auto stateMachine = artboard->stateMachineAt(0); + int viewModelId = artboard.get()->viewModelId(); + + auto vmi = viewModelId == -1 + ? file->createViewModelInstance(artboard.get()) + : file->createViewModelInstance(viewModelId, 0); + + stateMachine->bindViewModelInstance(vmi); + stateMachine->advanceAndApply(0.1f); + + auto renderer = silver.makeRenderer(); + artboard->draw(renderer.get()); + + int frames = 60; + for (int i = 0; i < frames; i++) + { + silver.addFrame(); + stateMachine->advanceAndApply(0.016f); + artboard->draw(renderer.get()); + } + + CHECK(silver.matches("image_fit_alignment_updated_test")); +} + +// The image_fit_alignment asset stores a non-default scaleX/scaleY on its +// layout images. Pre-7.2 files overwrite that with the layout fit (the stored +// scale is ignored). 7.2+ files keep it as the user scale and compose it on top +// of the fit. We patch the file header's minor version to exercise both paths +// from the same asset. +TEST_CASE("Layout image composes user scale on top of fit for 7.2 files", + "[assets]") +{ + auto legacyBytes = ReadFile("assets/image_fit_alignment.riv"); + REQUIRE(legacyBytes.size() > 6); + REQUIRE(legacyBytes[0] == 'R'); + REQUIRE(legacyBytes[1] == 'I'); + REQUIRE(legacyBytes[2] == 'V'); + REQUIRE(legacyBytes[3] == 'E'); + // Major/minor are single-byte varuints here. + REQUIRE(legacyBytes[4] == 7); + REQUIRE(legacyBytes[5] < 2); + + auto modernBytes = legacyBytes; + modernBytes[5] = 2; // bump the minor version to 7.2 + + auto xAxisScale = [](const rive::Mat2D& m) { + return rive::Vec2D(m[0], m[1]).length(); + }; + + // Loads the file, binds its view model, and holds the file/artboard/state + // machine alive plus the layout images in artboard order (identical between + // the two files since they share bytes). + struct Loaded + { + rive::rcp<rive::File> file; + std::unique_ptr<rive::ArtboardInstance> artboard; + std::unique_ptr<rive::StateMachineInstance> stateMachine; + std::vector<rive::Image*> images; + }; + auto load = [](std::vector<uint8_t>& bytes, + rive::Factory* factory) -> Loaded { + Loaded loaded; + rive::ImportResult result; + loaded.file = rive::File::import(bytes, factory, &result); + REQUIRE(result == rive::ImportResult::success); + loaded.artboard = loaded.file->artboardNamed("Main"); + REQUIRE(loaded.artboard != nullptr); + loaded.stateMachine = loaded.artboard->stateMachineAt(0); + REQUIRE(loaded.stateMachine != nullptr); + int viewModelId = loaded.artboard->viewModelId(); + auto vmi = + viewModelId == -1 + ? loaded.file->createViewModelInstance(loaded.artboard.get()) + : loaded.file->createViewModelInstance(viewModelId, 0); + loaded.stateMachine->bindViewModelInstance(vmi); + loaded.stateMachine->advanceAndApply(0.1f); + for (auto image : loaded.artboard->objects<rive::Image>()) + { + if (image->parent() != nullptr && + image->parent()->is<rive::LayoutComponent>()) + { + loaded.images.push_back(image); + } + } + return loaded; + }; + + // Use SerializingFactory so the in-band images actually decode (it gives + // real image dimensions, which the fit depends on). One per file, kept + // alive for the file's lifetime. + rive::SerializingFactory legacyFactory; + rive::SerializingFactory modernFactory; + auto legacy = load(legacyBytes, &legacyFactory); + auto modern = load(modernBytes, &modernFactory); + REQUIRE(!legacy.images.empty()); + REQUIRE(legacy.images.size() == modern.images.size()); + + // The layout is animated and may start collapsed (fit ~ 0). Advance both + // files in lockstep (identical state) until a layout image is open, then + // pick that image. This avoids depending on async decode timing landing on + // an open frame. + rive::NoOpRenderer renderer; + size_t pick = legacy.images.size(); + for (int frame = 0; frame < 120 && pick == legacy.images.size(); frame++) + { + for (size_t i = 0; i < legacy.images.size(); i++) + { + if (xAxisScale(legacy.images[i]->worldTransform()) > 1.0f) + { + pick = i; + break; + } + } + if (pick != legacy.images.size()) + { + break; + } + legacy.stateMachine->advanceAndApply(0.016f); + modern.stateMachine->advanceAndApply(0.016f); + } + REQUIRE(pick != legacy.images.size()); + + auto legacyImage = legacy.images[pick]; + auto modernImage = modern.images[pick]; + + // 7.2 keeps the stored (non-default) user scale; legacy overwrites it with + // the fit. + float userScaleX = modernImage->scaleX(); + REQUIRE(userScaleX != Approx(1.0f)); + CHECK(legacyImage->scaleX() != Approx(userScaleX)); + + // Modern renders at fit * userScale; legacy at fit only (same layout + // state). + float legacyScale = xAxisScale(legacyImage->worldTransform()); + float modernScale = xAxisScale(modernImage->worldTransform()); + REQUIRE(legacyScale > 0.0f); + CHECK(modernScale == Approx(legacyScale * userScaleX)); +}
diff --git a/tests/unit_tests/silvers/image_fit_alignment_updated_test.sriv b/tests/unit_tests/silvers/image_fit_alignment_updated_test.sriv new file mode 100644 index 0000000..6c36974 --- /dev/null +++ b/tests/unit_tests/silvers/image_fit_alignment_updated_test.sriv Binary files differ