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