Skip to content

Commit b0b126f

Browse files
bodymovinbodymovin
andcommitted
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>
1 parent b5b9976 commit b0b126f

10 files changed

Lines changed: 220 additions & 12 deletions

File tree

.rive_head

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
cc34cea96329320072dcc92cc4d302db44369dbe
1+
c372d0d9d43f0adaba357ab55d212828b0a156a5

include/rive/file.hpp

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,9 @@ class File : public RefCnt<File>
8181
/// Major version number supported by the runtime.
8282
static const int majorVersion = 7;
8383
/// Minor version number supported by the runtime.
84-
static const int minorVersion = 0;
84+
/// 7.2: images in a layout apply their fit as a separate scale, leaving
85+
/// the user-facing scaleX/scaleY free to be edited/animated on top.
86+
static const int minorVersion = 2;
8587
/// deterministicMode sets a static seed for randomization and uses
8688
/// timestamps for scrolling.
8789
static bool deterministicMode;

include/rive/importers/import_stack.hpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,21 @@ class ImportStack
102102
return false;
103103
}
104104

105+
// The major/minor version of the file being imported. Objects can read
106+
// this during import() to gate behavior on the file format version.
107+
int majorVersion() const { return m_majorVersion; }
108+
int minorVersion() const { return m_minorVersion; }
109+
void version(int major, int minor)
110+
{
111+
m_majorVersion = major;
112+
m_minorVersion = minor;
113+
}
114+
105115
private:
106116
std::unordered_map<uint16_t, std::unique_ptr<ImportStackObject>> m_latests;
107117
std::vector<ImportStackObject*> m_lastAdded;
118+
int m_majorVersion = 0;
119+
int m_minorVersion = 0;
108120
};
109121
} // namespace rive
110122
#endif

include/rive/shapes/image.hpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@ class Image : public ImageBase, public FileAssetReferencer
3535
float m_layoutHeight = NAN;
3636
float m_layoutOffsetX = 0.0f;
3737
float m_layoutOffsetY = 0.0f;
38+
// The layout-driven fit scale is stored separately (rather than written
39+
// into scaleX/scaleY) so the user-facing scale stays free to be
40+
// edited/animated. It is composed with the local transform in
41+
// updateTransform().
42+
float m_layoutScaleX = 1.0f;
43+
float m_layoutScaleY = 1.0f;
44+
// Whether the file applies the layout fit as a separate scale (7.2+).
45+
// Legacy files overwrite scaleX/scaleY with the fit instead. Set from the
46+
// file version at import; defaults to the modern behavior for
47+
// runtime-created images.
48+
bool m_layoutScaleSeparate = true;
3849
void updateImageScale();
3950

4051
public:
@@ -57,6 +68,10 @@ class Image : public ImageBase, public FileAssetReferencer
5768
LayoutDirection direction) override;
5869
float width() const;
5970
float height() const;
71+
// Effective render scale: the user-facing scaleX/scaleY composed with the
72+
// layout fit scale. Equals the user scale when not in a layout.
73+
float renderScaleX() const { return scaleX() * m_layoutScaleX; }
74+
float renderScaleY() const { return scaleY() * m_layoutScaleY; }
6075
void assetUpdated() override;
6176
AABB localBounds() const override;
6277
void updateTransform() override;

src/file.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ rcp<File> File::import(Span<const uint8_t> bytes,
295295
ImportResult File::read(BinaryReader& reader, const RuntimeHeader& header)
296296
{
297297
ImportStack importStack;
298+
importStack.version(header.majorVersion(), header.minorVersion());
298299
#ifdef WITH_RIVE_SCRIPTING
299300
std::vector<InBandContent> inBandContent;
300301
#endif

src/shapes/image.cpp

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ StatusCode Image::import(ImportStack& importStack)
9696
{
9797
return result;
9898
}
99+
// Files exported before 7.2 overwrite scaleX/scaleY with the layout fit, so
100+
// any stored scale on a layout image was ignored. Keep that legacy behavior
101+
// for those files; newer files compose the fit as a separate scale so the
102+
// user scale stays editable/animatable.
103+
int major = importStack.majorVersion();
104+
int minor = importStack.minorVersion();
105+
m_layoutScaleSeparate = major > 7 || (major == 7 && minor >= 2);
99106
return Super::import(importStack);
100107
}
101108

@@ -129,6 +136,7 @@ void Image::assetUpdated()
129136
Core* Image::clone() const
130137
{
131138
Image* twin = ImageBase::clone()->as<Image>();
139+
twin->m_layoutScaleSeparate = m_layoutScaleSeparate;
132140
if (m_fileAsset != nullptr)
133141
{
134142
twin->setAsset(m_fileAsset);
@@ -230,6 +238,10 @@ void Image::controlSize(Vec2D size,
230238
void Image::updateTransform()
231239
{
232240
Super::updateTransform();
241+
// Compose the layout fit scale on top of the user transform (innermost), so
242+
// the user's scaleX/scaleY (built by Super) remain free to be animated:
243+
// M = T(offset) * UserLocal * S(fitScale)
244+
m_Transform.scaleByValues(m_layoutScaleX, m_layoutScaleY);
233245
m_Transform[4] += m_layoutOffsetX;
234246
m_Transform[5] += m_layoutOffsetY;
235247
}
@@ -323,10 +335,26 @@ void Image::updateImageScale()
323335
newOffsetY = -scaledTop + heightRemainder * yAlign;
324336
}
325337

326-
if (newScaleX != scaleX() || newScaleY != scaleY())
338+
if (m_layoutScaleSeparate)
327339
{
328-
scaleX(newScaleX);
329-
scaleY(newScaleY);
340+
if (newScaleX != m_layoutScaleX || newScaleY != m_layoutScaleY)
341+
{
342+
m_layoutScaleX = newScaleX;
343+
m_layoutScaleY = newScaleY;
344+
// Fit scale is composed in updateTransform(), so changing it
345+
// must mark the local transform dirty (not just the world
346+
// transform).
347+
markTransformDirty();
348+
}
349+
}
350+
else
351+
{
352+
// Legacy (pre-7.2): the fit overwrites the user scale.
353+
if (newScaleX != scaleX() || newScaleY != scaleY())
354+
{
355+
scaleX(newScaleX);
356+
scaleY(newScaleY);
357+
}
330358
}
331359
}
332360
if (newOffsetX != m_layoutOffsetX || newOffsetY != m_layoutOffsetY)

src/shapes/slice_mesh.cpp

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,8 @@ std::vector<float> SliceMesh::uvStops(AxisType forAxis)
151151
float imageSize = forAxis == AxisType::X ? m_nslicer->image()->width()
152152
: m_nslicer->image()->height();
153153
float imageScale =
154-
std::abs(forAxis == AxisType::X ? m_nslicer->image()->scaleX()
155-
: m_nslicer->image()->scaleY());
154+
std::abs(forAxis == AxisType::X ? m_nslicer->image()->renderScaleX()
155+
: m_nslicer->image()->renderScaleY());
156156
if (imageSize == 0 || imageScale == 0)
157157
{
158158
return {};
@@ -171,10 +171,10 @@ std::vector<float> SliceMesh::vertexStops(
171171
Image* image = m_nslicer->image();
172172
float imageSize = forAxis == AxisType::X ? image->width() : image->height();
173173

174-
// When doing calcualtions, we assume scale is always non-negative to keep
174+
// When doing calculations, we assume scale is always non-negative to keep
175175
// everything in image space.
176-
float imageScale =
177-
std::abs(forAxis == AxisType::X ? image->scaleX() : image->scaleY());
176+
float imageScale = std::abs(forAxis == AxisType::X ? image->renderScaleX()
177+
: image->renderScaleY());
178178
if (imageSize == 0 || imageScale == 0)
179179
{
180180
return {};
@@ -233,8 +233,8 @@ uint16_t SliceMesh::tileRepeat(std::vector<SliceMeshVertex>& vertices,
233233

234234
// The size of each repeated tile in image space
235235
Image* image = m_nslicer->image();
236-
float scaleX = std::abs(image->scaleX());
237-
float scaleY = std::abs(image->scaleY());
236+
float scaleX = std::abs(image->renderScaleX());
237+
float scaleY = std::abs(image->renderScaleY());
238238

239239
if (scaleX == 0 || scaleY == 0)
240240
{
15.3 KB
Binary file not shown.

tests/unit_tests/runtime/data_binding_images_test.cpp

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#include <rive/shapes/paint/color.hpp>
99
#include <rive/shapes/paint/fill.hpp>
1010
#include <rive/shapes/image.hpp>
11+
#include <rive/layout_component.hpp>
1112
#include <rive/shapes/paint/solid_color.hpp>
1213
#include <rive/text/text_value_run.hpp>
1314
#include <rive/custom_property_number.hpp>
@@ -427,3 +428,152 @@ TEST_CASE("Image fit & alignment images 3", "[silver]")
427428

428429
CHECK(silver.matches("image_fit_alignment_3"));
429430
}
431+
432+
TEST_CASE("Image fit & alignment with image scaling", "[silver]")
433+
{
434+
rive::SerializingFactory silver;
435+
auto file =
436+
ReadRiveFile("assets/image_fit_alignment_updated_test.riv", &silver);
437+
438+
auto artboard = file->artboardNamed("Main");
439+
REQUIRE(artboard != nullptr);
440+
silver.frameSize(artboard->width(), artboard->height());
441+
442+
auto stateMachine = artboard->stateMachineAt(0);
443+
int viewModelId = artboard.get()->viewModelId();
444+
445+
auto vmi = viewModelId == -1
446+
? file->createViewModelInstance(artboard.get())
447+
: file->createViewModelInstance(viewModelId, 0);
448+
449+
stateMachine->bindViewModelInstance(vmi);
450+
stateMachine->advanceAndApply(0.1f);
451+
452+
auto renderer = silver.makeRenderer();
453+
artboard->draw(renderer.get());
454+
455+
int frames = 60;
456+
for (int i = 0; i < frames; i++)
457+
{
458+
silver.addFrame();
459+
stateMachine->advanceAndApply(0.016f);
460+
artboard->draw(renderer.get());
461+
}
462+
463+
CHECK(silver.matches("image_fit_alignment_updated_test"));
464+
}
465+
466+
// The image_fit_alignment asset stores a non-default scaleX/scaleY on its
467+
// layout images. Pre-7.2 files overwrite that with the layout fit (the stored
468+
// scale is ignored). 7.2+ files keep it as the user scale and compose it on top
469+
// of the fit. We patch the file header's minor version to exercise both paths
470+
// from the same asset.
471+
TEST_CASE("Layout image composes user scale on top of fit for 7.2 files",
472+
"[assets]")
473+
{
474+
auto legacyBytes = ReadFile("assets/image_fit_alignment.riv");
475+
REQUIRE(legacyBytes.size() > 6);
476+
REQUIRE(legacyBytes[0] == 'R');
477+
REQUIRE(legacyBytes[1] == 'I');
478+
REQUIRE(legacyBytes[2] == 'V');
479+
REQUIRE(legacyBytes[3] == 'E');
480+
// Major/minor are single-byte varuints here.
481+
REQUIRE(legacyBytes[4] == 7);
482+
REQUIRE(legacyBytes[5] < 2);
483+
484+
auto modernBytes = legacyBytes;
485+
modernBytes[5] = 2; // bump the minor version to 7.2
486+
487+
auto xAxisScale = [](const rive::Mat2D& m) {
488+
return rive::Vec2D(m[0], m[1]).length();
489+
};
490+
491+
// Loads the file, binds its view model, and holds the file/artboard/state
492+
// machine alive plus the layout images in artboard order (identical between
493+
// the two files since they share bytes).
494+
struct Loaded
495+
{
496+
rive::rcp<rive::File> file;
497+
std::unique_ptr<rive::ArtboardInstance> artboard;
498+
std::unique_ptr<rive::StateMachineInstance> stateMachine;
499+
std::vector<rive::Image*> images;
500+
};
501+
auto load = [](std::vector<uint8_t>& bytes,
502+
rive::Factory* factory) -> Loaded {
503+
Loaded loaded;
504+
rive::ImportResult result;
505+
loaded.file = rive::File::import(bytes, factory, &result);
506+
REQUIRE(result == rive::ImportResult::success);
507+
loaded.artboard = loaded.file->artboardNamed("Main");
508+
REQUIRE(loaded.artboard != nullptr);
509+
loaded.stateMachine = loaded.artboard->stateMachineAt(0);
510+
REQUIRE(loaded.stateMachine != nullptr);
511+
int viewModelId = loaded.artboard->viewModelId();
512+
auto vmi =
513+
viewModelId == -1
514+
? loaded.file->createViewModelInstance(loaded.artboard.get())
515+
: loaded.file->createViewModelInstance(viewModelId, 0);
516+
loaded.stateMachine->bindViewModelInstance(vmi);
517+
loaded.stateMachine->advanceAndApply(0.1f);
518+
for (auto image : loaded.artboard->objects<rive::Image>())
519+
{
520+
if (image->parent() != nullptr &&
521+
image->parent()->is<rive::LayoutComponent>())
522+
{
523+
loaded.images.push_back(image);
524+
}
525+
}
526+
return loaded;
527+
};
528+
529+
// Use SerializingFactory so the in-band images actually decode (it gives
530+
// real image dimensions, which the fit depends on). One per file, kept
531+
// alive for the file's lifetime.
532+
rive::SerializingFactory legacyFactory;
533+
rive::SerializingFactory modernFactory;
534+
auto legacy = load(legacyBytes, &legacyFactory);
535+
auto modern = load(modernBytes, &modernFactory);
536+
REQUIRE(!legacy.images.empty());
537+
REQUIRE(legacy.images.size() == modern.images.size());
538+
539+
// The layout is animated and may start collapsed (fit ~ 0). Advance both
540+
// files in lockstep (identical state) until a layout image is open, then
541+
// pick that image. This avoids depending on async decode timing landing on
542+
// an open frame.
543+
rive::NoOpRenderer renderer;
544+
size_t pick = legacy.images.size();
545+
for (int frame = 0; frame < 120 && pick == legacy.images.size(); frame++)
546+
{
547+
for (size_t i = 0; i < legacy.images.size(); i++)
548+
{
549+
if (xAxisScale(legacy.images[i]->worldTransform()) > 1.0f)
550+
{
551+
pick = i;
552+
break;
553+
}
554+
}
555+
if (pick != legacy.images.size())
556+
{
557+
break;
558+
}
559+
legacy.stateMachine->advanceAndApply(0.016f);
560+
modern.stateMachine->advanceAndApply(0.016f);
561+
}
562+
REQUIRE(pick != legacy.images.size());
563+
564+
auto legacyImage = legacy.images[pick];
565+
auto modernImage = modern.images[pick];
566+
567+
// 7.2 keeps the stored (non-default) user scale; legacy overwrites it with
568+
// the fit.
569+
float userScaleX = modernImage->scaleX();
570+
REQUIRE(userScaleX != Approx(1.0f));
571+
CHECK(legacyImage->scaleX() != Approx(userScaleX));
572+
573+
// Modern renders at fit * userScale; legacy at fit only (same layout
574+
// state).
575+
float legacyScale = xAxisScale(legacyImage->worldTransform());
576+
float modernScale = xAxisScale(modernImage->worldTransform());
577+
REQUIRE(legacyScale > 0.0f);
578+
CHECK(modernScale == Approx(legacyScale * userScaleX));
579+
}
Binary file not shown.

0 commit comments

Comments
 (0)