feat(editor): stroke position (#14437) fa1cd145d3 * feat(stroke): expose inside/outside stroke position in the editor The Rive renderer learned inside and outside strokes in #14117, through RenderPaint::strokePosition, but nothing in the scene graph set it. This wires it from a new core property to a Position dropdown in the stroke options popout. Core: Stroke.position, a uint8 (key 470) whose values match rive::StrokePosition -- inside 0, center 1, outside 2. It defaults to center and is only exported when it differs, so existing files are unchanged and older runtimes skip it and draw centered. Runtime: Stroke pushes the position alongside cap and join in initRenderPaint, applyTo and update, and positionChanged marks paint dirt. An out of range value draws centered, since the renderer asserts on positions it doesn't know. rive_native: a StrokePosition enum and RenderPaint.strokePosition, serialized as one byte right after blendMode under a new PaintDirt bit. Being a leading field it rides the first chunk of a large gradient, like join and cap. renderer_binding reads it back and ignores unknown values. Editor: the dropdown sits under the cap/join toggles, behind ENABLE_STROKE_POSITION (on in dev, UAT and early access, off in prod). UI only: a stroke authored with a position keeps it with the flag off. Only the Rive renderer draws inside/outside so far; the other backends draw centered. Shift+X remembers the position across a fill round trip. Silvers: a strokePosition op (43) records the value when a paint leaves center, so every existing silver is byte for byte unchanged. The C++ replayer rejects an unknown value, and the golden comparison checks it. Viewer: reads strokePosition, and also applyLayerMask (42), which #14369 added to the C++ writer but not the Dart reader. The instanced mesh ops (#14381) had landed after the enum's closing semicolon, so the reader didn't compile; that is fixed too, and the op parity test passes again. Renderer behavior this exposes, all intended: inside/outside close open paths, so the closing segment is stroked too; joins only exist on the outer side of a turn, so inside strokes show them at concave corners and outside strokes at convex ones; and which side is inside follows the stroke path's non-zero rule, so same-direction holes and opposite-wound contours resolve the way that rule says. * test(renderer): pin stroke position on open, concave and overlapping paths The stroke_position GMs only draw a circle: closed, convex and without corners, so none of the cases that decide how inside and outside strokes look were covered. These three GMs pin the intended behavior. stroke_position_shapes: an open zigzag, a square with one concave notch, and an even-odd square with a hole, for each join (rows) and each position (columns). Open paths come out closed, and joins show only on the outer side of a turn -- concave corners for inside, convex for outside. stroke_position_shapes_feathered: the same matrix with a feather, which takes the renderer's other branch (clip by the path, then draw the thickened stroke). stroke_position_winding: what the editor sends, a non-zero stroke path. A same-direction hole that an even-odd Fill shows, and two squares wound in opposite directions, resolved the way non-zero says. Each cell draws the stroke, the path's fill region in translucent purple over it, and the path itself as a thin red line. Goldens come from this PR's CI run via scripts/rebaseline_pr_goldens.py, since every device renders its own. * test(renderer): goldens for the stroke position GMs, skipped on canvas2d Adds the golden images for stroke_position_shapes, _shapes_feathered and stroke_position_winding from this PR's CI candidates, pulled with scripts/rebaseline_lfs_gms_and_goldens.py. Every device job reported 0 failing, so no existing golden changed; they were only missing these. browserstack_gms gets the pixel6/gl images, as check_browserstack_gm_images.py asks. canvas2d already skips stroke_position and stroke_position_clipped because it has no stroke position support, and draws every column centered. The new GMs join that skip list, so canvas2d and svg get no goldens for them, the same as the existing ones. galaxy_s22/glmsaa is still waiting on a device and comes in a follow-up. * feat(stroke): make stroke position animatable and bindable Stroke.position gets "animates" and "bindable" in its def, regenerated into rive_core and rml. The C++ runtime and editor_native output is unchanged: keyframes (KeyFrameUint) and the number/enum data bind contexts already set it through CoreRegistry::setUint, and positionChanged already pushes it onto the paint. Keys hold. Uints only tween with "interpolates", which an enum has no use for, so a partial mix in a blend state or transition switches the value rather than landing on a neighbor. In animate mode the Position dropdown shows its key button, and the timeline row reads "Position". Binding gets a "Stroke Position" system enum (Inside, Center, Outside), the way text and layout enums have one, so a view model can bind by name instead of a number or a hand-built enum in the right order. It is appended to SystemEnum so every existing enumType keeps its index, and takes reserved ids [83..86, 0]. The editor maps it to the property with the enumToUint converter; the runtime needs nothing, since it never reads enumType. Tests: a keyframe and a registry write (the path binds take) both reach the render paint, and a half mix holds instead of interpolating. In the editor, setting position in animate mode keys it and drives the paint without touching the design value, the paint follows the design value again back in design mode, the dropdown offers a key button, and the system enum's names, maps and reserved ids line up with StrokePosition. * test(renderer): galaxy_s22/glmsaa goldens for the stroke position GMs The last device from this PR's CI: its job was still waiting on a device when the other goldens went in. It reported 0 failing, so only the three new GMs were missing. * fix(stroke): read a negative position as center Stroke.position is a plain int in Dart, so a bound number can arrive negative, and strokePositionFromCore only checked the upper bound: StrokePosition.values[-1] threw from positionChanged instead of drawing centered like any other unknown value. It checks both bounds now. The C++ side reads a uint8, so it never sees a negative value. * fix(stroke): size outside strokes by their full reach paintBoundsOutset estimated every stroke as reaching half its thickness past the path, and shapePaintOutset deliberately skipped stroke position because Stroke::applyTo never set it. It does now. The renderer draws an outside stroke as a centered stroke twice as thick, clipped to the outside, so it reaches the full thickness: twice the estimate, and four times the thickness with a miter join. That outset sizes the offscreen surfaces for layer masks and cache-as-bitmap and drives culling, so an outside stroke in a masked or cached layer had its outer edge cut off, in the runtime and, through the Dart mirror, on the editor stage. An outside stroke now counts its full thickness before the miter and cap factors, on both sides. An inside stroke reaches nothing past the path, but keeps the centered half as a safe over-estimate. The renderer's own call (calculateBoundsOutset) only ever passes centered strokes, so it is unchanged. Both paint_outset tests get the same outside-position row, and each side checks that shapePaintOutset reads a real stroke's position; the C++ one fails without the pass-through. fix(runtime): draw COLRv1 gradients in color emoji instead of solid black (#14552) 55f76f1773 * fix(runtime): draw COLRv1 gradients in color emoji instead of solid black * fix(runtime): keep elliptical radial emoji gradients, refresh foreground stops, clamp stop offsets fix(rive_native): windows hang on exit from GPU teardown in static destructors (#14535) 1f180ed07b * fix(rive_native): windows hang on exit from GPU teardown in static destructors The process ore context and session consumers map were file statics, so their destructors ran at DLL unload under the loader lock, where ~ContextD3D11 releasing the device hangs or crashes in the driver. The hang keeps Rive.exe alive and its single instance mutex blocks relaunch; the crash is RIVE_NATIVE-SN. Both now outlive the process, like DeferredRenderThread and g_contextSession, and a static_assert on each keeps them that way. * fix(rive_native): release GPU and audio statics before exit on windows instead of leaking them Co-authored-by: Guido Rosso <guido@rive.app> Co-authored-by: Luigi Rosso <luigi-rosso@users.noreply.github.com> Co-authored-by: Luigi Rosso <luigi.rosso@gmail.com> Co-authored-by: hernan <hernan@rive.app>
diff --git a/.rive_head b/.rive_head index dc1d34d..a5e22d6 100644 --- a/.rive_head +++ b/.rive_head
@@ -1 +1 @@ -ac3546564e218186245ce3ca4ea4e3dde7300153 +fa1cd145d3726e266ff9b9e9a2ff6fd4d6703883
diff --git a/include/rive/audio/audio_engine.hpp b/include/rive/audio/audio_engine.hpp index a790ff5..5d07884 100644 --- a/include/rive/audio/audio_engine.hpp +++ b/include/rive/audio/audio_engine.hpp
@@ -57,6 +57,7 @@ Artboard* artboard = nullptr); static rcp<AudioEngine> RuntimeEngine(bool makeWhenNecessary = true); + static void ReleaseRuntimeEngine(); #ifdef EXTERNAL_RIVE_AUDIO_ENGINE bool readAudioFrames(float* frames,
diff --git a/include/rive/generated/core_registry.hpp b/include/rive/generated/core_registry.hpp index be03434..36d6a3f 100644 --- a/include/rive/generated/core_registry.hpp +++ b/include/rive/generated/core_registry.hpp
@@ -658,10 +658,10 @@ return new ListenerInputTypeEvent(); case ListenerInputTypeGamepadBase::typeKey: return new ListenerInputTypeGamepad(); - case ListenerInputTypeKeyboardBase::typeKey: - return new ListenerInputTypeKeyboard(); case ListenerInputTypePointerButtonBase::typeKey: return new ListenerInputTypePointerButton(); + case ListenerInputTypeKeyboardBase::typeKey: + return new ListenerInputTypeKeyboard(); case ListenerInputTypeTextBase::typeKey: return new ListenerInputTypeText(); case ListenerInputTypeSemanticBase::typeKey: @@ -1744,6 +1744,9 @@ case StrokeBase::joinPropertyKey: object->as<StrokeBase>()->join(value); break; + case StrokeBase::positionPropertyKey: + object->as<StrokeBase>()->position(value); + break; case PaintImageBase::imageSamplerFilterPropertyKey: object->as<PaintImageBase>()->imageSamplerFilter(value); break; @@ -3905,6 +3908,8 @@ return object->as<StrokeBase>()->cap(); case StrokeBase::joinPropertyKey: return object->as<StrokeBase>()->join(); + case StrokeBase::positionPropertyKey: + return object->as<StrokeBase>()->position(); case PaintImageBase::imageSamplerFilterPropertyKey: return object->as<PaintImageBase>()->imageSamplerFilter(); case PaintImageBase::imageSamplerWrapXPropertyKey: @@ -5241,6 +5246,7 @@ case ColorChannelsBase::colorAlphaPropertyKey: case StrokeBase::capPropertyKey: case StrokeBase::joinPropertyKey: + case StrokeBase::positionPropertyKey: case PaintImageBase::imageSamplerFilterPropertyKey: case PaintImageBase::imageSamplerWrapXPropertyKey: case PaintImageBase::imageSamplerWrapYPropertyKey: @@ -6190,6 +6196,8 @@ return object->is<StrokeBase>(); case StrokeBase::joinPropertyKey: return object->is<StrokeBase>(); + case StrokeBase::positionPropertyKey: + return object->is<StrokeBase>(); case PaintImageBase::imageSamplerFilterPropertyKey: return object->is<PaintImageBase>(); case PaintImageBase::imageSamplerWrapXPropertyKey:
diff --git a/include/rive/generated/shapes/paint/stroke_base.hpp b/include/rive/generated/shapes/paint/stroke_base.hpp index bb7ec1f..4c301e9 100644 --- a/include/rive/generated/shapes/paint/stroke_base.hpp +++ b/include/rive/generated/shapes/paint/stroke_base.hpp
@@ -36,12 +36,14 @@ static const uint16_t capPropertyKey = 48; static const uint16_t joinPropertyKey = 49; static const uint16_t transformAffectsStrokePropertyKey = 50; + static const uint16_t positionPropertyKey = 470; protected: float m_Thickness = 1.0f; uint8_t m_Cap = 0; uint8_t m_Join = 0; bool m_TransformAffectsStroke = true; + uint8_t m_Position = 1; public: inline float thickness() const { return m_Thickness; } @@ -101,6 +103,19 @@ notifyPropertyChanged(transformAffectsStrokePropertyKey); } + inline uint8_t position() const { return m_Position; } + void position(uint8_t value) + { + if (m_Position == value) + { + return; + } + RIVE_EDITOR_CHANGING(positionPropertyKey, &m_Position, &value); + m_Position = value; + RIVE_EDITOR_CHANGED(positionChanged()); + notifyPropertyChanged(positionPropertyKey); + } + Core* clone() const override; void copy(const StrokeBase& object) { @@ -108,6 +123,7 @@ m_Cap = object.m_Cap; m_Join = object.m_Join; m_TransformAffectsStroke = object.m_TransformAffectsStroke; + m_Position = object.m_Position; ShapePaint::copy(object); } @@ -127,6 +143,9 @@ case transformAffectsStrokePropertyKey: m_TransformAffectsStroke = CoreBoolType::deserialize(reader); return true; + case positionPropertyKey: + m_Position = CoreUintType::deserialize(reader); + return true; } return ShapePaint::deserialize(propertyKey, reader); } @@ -136,6 +155,7 @@ virtual void capChanged() {} virtual void joinChanged() {} virtual void transformAffectsStrokeChanged() {} + virtual void positionChanged() {} #ifdef WITH_RIVE_EDITOR #include "editor_native/generated/shapes/paint/stroke_ext.inl" #endif
diff --git a/include/rive/shapes/paint/stroke.hpp b/include/rive/shapes/paint/stroke.hpp index 010556a..0e07194 100644 --- a/include/rive/shapes/paint/stroke.hpp +++ b/include/rive/shapes/paint/stroke.hpp
@@ -2,6 +2,7 @@ #define _RIVE_STROKE_HPP_ #include "rive/generated/shapes/paint/stroke_base.hpp" #include "rive/shapes/path_flags.hpp" +#include "rive/shapes/paint/stroke_position.hpp" namespace rive { class StrokeEffect; @@ -19,11 +20,13 @@ void update(ComponentDirt value) override; void invalidateRendering() override; ShapePaintType paintType() const override { return ShapePaintType::stroke; } + StrokePosition strokePosition() const; protected: void thicknessChanged() override; void capChanged() override; void joinChanged() override; + void positionChanged() override; }; } // namespace rive
diff --git a/include/rive/text/text.hpp b/include/rive/text/text.hpp index 8bf4ff1..6bbc452 100644 --- a/include/rive/text/text.hpp +++ b/include/rive/text/text.hpp
@@ -165,6 +165,12 @@ } }; +// Sweep gradients have no Rive shader and fall back to their first stop. +void drawColorGlyphLayer(Renderer* renderer, + Factory* factory, + Font::ColorGlyphLayer& layer, + float opacity); + class Text : public TextBase, public TextInterface, public DataBindListItemConsumer
diff --git a/include/rive/text_engine.hpp b/include/rive/text_engine.hpp index b00d25d..6e8e46b 100644 --- a/include/rive/text_engine.hpp +++ b/include/rive/text_engine.hpp
@@ -302,6 +302,7 @@ { float offset; ColorInt color; + bool isForeground = false; }; enum class ColorGlyphPaintType : uint8_t @@ -328,6 +329,9 @@ float x0 = 0, y0 = 0, x1 = 0, y1 = 0; float r0 = 0, r1 = 0; // radial float startAngle = 0, endAngle = 0; // sweep + // Maps the unit circle onto the radial's outer circle in glyph space, + // keeping its ellipse under uneven or skewed font transforms. + Mat2D radialTransform; // Image data (only valid when paintType == image). // Raw encoded image bytes (e.g. PNG from SBIX/CBDT).
diff --git a/include/utils/serialize_ops.hpp b/include/utils/serialize_ops.hpp index cb944ce..6bbf7f1 100644 --- a/include/utils/serialize_ops.hpp +++ b/include/utils/serialize_ops.hpp
@@ -84,6 +84,11 @@ // named image. Recorded inside the masked layer's canvas bracket, as the // last op before it closes. applyLayerMask = 42, // imageId, mode + + // RenderPaint::strokePosition. Its own op for the same reason as + // additiveness: only written once a paint leaves center, so a stream with + // no inside or outside strokes is byte for byte what it was before. + strokePosition = 43, // paint id, value }; inline void serializeRawPath(BinaryWriter* writer, const RawPath& path)
diff --git a/src/audio/audio_engine.cpp b/src/audio/audio_engine.cpp index 76f038a..118cec1 100644 --- a/src/audio/audio_engine.cpp +++ b/src/audio/audio_engine.cpp
@@ -549,6 +549,8 @@ return m_runtimeAudioEngine; } +void AudioEngine::ReleaseRuntimeEngine() { m_runtimeAudioEngine = nullptr; } + #ifdef EXTERNAL_RIVE_AUDIO_ENGINE bool AudioEngine::readAudioFrames(float* frames, uint64_t numFrames,
diff --git a/src/shapes/paint/paint_outset.cpp b/src/shapes/paint/paint_outset.cpp index b79a4b8..a4dec30 100644 --- a/src/shapes/paint/paint_outset.cpp +++ b/src/shapes/paint/paint_outset.cpp
@@ -19,7 +19,13 @@ float outset = 0.0f; if (stroke.has_value()) { - outset = stroke->thickness * .5f; + // A centered stroke straddles the path, reaching half its thickness. + // The renderer draws an outside stroke as a centered stroke twice as + // thick, clipped to the outside, so it reaches the full thickness (and + // its miter twice as far). An inside one is clipped to the path and + // reaches nothing past it; half is a safe over-estimate. + outset = stroke->thickness * + (stroke->position == StrokePosition::outside ? 1.0f : .5f); if (stroke->join == StrokeJoin::miter) { // Miter joins may be longer than the stroke radius. @@ -53,14 +59,13 @@ if (paint->is<Stroke>()) { const Stroke* s = paint->as<Stroke>(); - // Mirrors Stroke::applyTo, which is what the renderer actually sees. - // StrokePosition is deliberately not read: applyTo never sets it, so - // the stroke really does straddle the path and thickness/2 is the - // reach. + // Mirrors Stroke::applyTo, which is what the renderer actually sees, + // position included: an outside stroke reaches twice as far. StrokeParams params; params.thickness = s->thickness(); params.join = static_cast<StrokeJoin>(s->join()); params.cap = static_cast<StrokeCap>(s->cap()); + params.position = s->strokePosition(); stroke = params; // Stroke::pickPath: the local path when the transform affects the // stroke, the already-world path otherwise.
diff --git a/src/shapes/paint/stroke.cpp b/src/shapes/paint/stroke.cpp index 7a37fb3..e15cc77 100644 --- a/src/shapes/paint/stroke.cpp +++ b/src/shapes/paint/stroke.cpp
@@ -2,6 +2,7 @@ #include "rive/shapes/paint/stroke.hpp" #include "rive/shapes/paint/stroke_cap.hpp" #include "rive/shapes/paint/stroke_join.hpp" +#include "rive/shapes/paint/stroke_position.hpp" using namespace rive; @@ -16,6 +17,7 @@ renderPaint->thickness(thickness()); renderPaint->cap((StrokeCap)cap()); renderPaint->join((StrokeJoin)join()); + renderPaint->strokePosition(strokePosition()); return renderPaint; } @@ -25,6 +27,7 @@ renderPaint->thickness(thickness()); renderPaint->cap((StrokeCap)cap()); renderPaint->join((StrokeJoin)join()); + renderPaint->strokePosition(strokePosition()); renderPaint->shader(nullptr); m_PaintMutator->applyTo(renderPaint, opacityModifier); } @@ -40,6 +43,19 @@ void Stroke::joinChanged() { addDirt(ComponentDirt::Paint); } +void Stroke::positionChanged() { addDirt(ComponentDirt::Paint); } + +StrokePosition Stroke::strokePosition() const +{ + // An unknown value (e.g. from a newer file) draws centered, which is also + // what renderers without stroke position support do. + if (position() > (uint8_t)StrokePosition::outside) + { + return StrokePosition::center; + } + return (StrokePosition)position(); +} + void Stroke::update(ComponentDirt value) { Super::update(value); @@ -66,6 +82,7 @@ m_RenderPaint->thickness(thickness()); m_RenderPaint->cap((StrokeCap)cap()); m_RenderPaint->join((StrokeJoin)join()); + m_RenderPaint->strokePosition(strokePosition()); } }
diff --git a/src/text/font_hb.cpp b/src/text/font_hb.cpp index 73ed67d..047b122 100644 --- a/src/text/font_hb.cpp +++ b/src/text/font_hb.cpp
@@ -13,6 +13,7 @@ #include "hb.h" #include "hb-ot.h" +#include <algorithm> #include <unordered_set> // harfbuzz's own file loader is compiled out (HB_NO_OPEN/HB_NO_MMAP keep it @@ -237,13 +238,11 @@ hb_font_t* font; hb_draw_funcs_t* drawFuncs; std::vector<rive::Font::ColorGlyphLayer>* layers; - rive::GlyphID clipGlyph = 0; + rive::RawPath clipPath; bool hasClip = false; rive::ColorInt foreground = 0xFF000000; - // Transform stack for gradient coordinates. - // HarfBuzz gives gradient coords in the local space of the paint; - // transforms map them to glyph space. + // HarfBuzz transforms conjugated into Rive glyph space (scaled, Y down). std::vector<rive::Mat2D> transformStack; void pushTransform(float xx, @@ -253,21 +252,9 @@ float dx, float dy) { - rive::Mat2D m; - m[0] = xx; - m[1] = yx; - m[2] = xy; - m[3] = yy; - m[4] = dx * gInvScale; - m[5] = -dy * gInvScale; - if (transformStack.empty()) - { - transformStack.push_back(m); - } - else - { - transformStack.push_back(transformStack.back() * m); - } + rive::Mat2D m(xx, -yx, -xy, yy, dx * gInvScale, -dy * gInvScale); + transformStack.push_back( + transformStack.empty() ? m : transformStack.back() * m); } void popTransform() @@ -278,33 +265,37 @@ } } + rive::Mat2D glyphTransform() const + { + rive::Mat2D scale = rive::Mat2D::fromScale(gInvScale, -gInvScale); + return transformStack.empty() ? scale : transformStack.back() * scale; + } + rive::Vec2D mapPoint(float x, float y) const { - // Scale from HB font units to Rive glyph units, and flip Y. - float rx = x * gInvScale; - float ry = -y * gInvScale; - if (!transformStack.empty()) - { - // The transform stack already incorporates gInvScale + Y flip - // in pushTransform, so just apply raw coords. - const auto& m = transformStack.back(); - return rive::Vec2D(m[0] * x + m[2] * y + m[4], - m[1] * x + m[3] * y + m[5]); - } - return rive::Vec2D(rx, ry); + return glyphTransform() * rive::Vec2D(x, y); } float mapRadius(float r) const { float scaled = r * gInvScale; + if (transformStack.empty()) + { + return scaled; + } + const auto& m = transformStack.back(); + return scaled * std::sqrt(m[0] * m[0] + m[1] * m[1]); + } + + void pushClipGlyph(hb_codepoint_t glyph) + { + clipPath.rewind(); + hb_font_draw_glyph(font, glyph, drawFuncs, &clipPath); if (!transformStack.empty()) { - // Use the geometric mean of the scale factors. - const auto& m = transformStack.back(); - float sx = std::sqrt(m[0] * m[0] + m[1] * m[1]); - return r * sx; // Don't double-apply gInvScale; it's in the matrix. + clipPath.transformInPlace(transformStack.back()); } - return scaled; + hasClip = true; } // Helper: extract color stops from hb_color_line_t. @@ -312,8 +303,8 @@ hb_color_line_t* colorLine, rive::ColorInt foreground) { - unsigned int count = 0; - hb_color_line_get_color_stops(colorLine, 0, &count, nullptr); + unsigned int count = + hb_color_line_get_color_stops(colorLine, 0, nullptr, nullptr); std::vector<hb_color_stop_t> hbStops(count); hb_color_line_get_color_stops(colorLine, 0, &count, hbStops.data()); std::vector<rive::Font::GradientStop> stops; @@ -322,16 +313,21 @@ { rive::ColorInt c = s.is_foreground ? foreground : hbColorToColorInt(s.color); - stops.push_back({s.offset, c}); + stops.push_back( + {std::clamp(s.offset, 0.0f, 1.0f), c, (bool)s.is_foreground}); } + // Variations can reorder stops and renderers expect them ascending. + std::stable_sort( + stops.begin(), + stops.end(), + [](const auto& a, const auto& b) { return a.offset < b.offset; }); return stops; } - // Helper: emit a layer with the current clip glyph path. rive::Font::ColorGlyphLayer makeClipLayer() const { rive::Font::ColorGlyphLayer layer; - hb_font_draw_glyph(font, clipGlyph, drawFuncs, &layer.path); + layer.path = clipPath; return layer; } }; @@ -360,9 +356,7 @@ hb_font_t*, void*) { - auto* state = (PaintState*)paint_data; - state->clipGlyph = (rive::GlyphID)glyph; - state->hasClip = true; + ((PaintState*)paint_data)->pushClipGlyph(glyph); } static void paint_push_clip_rectangle(hb_paint_funcs_t*, @@ -464,6 +458,9 @@ layer.y1 = sp1.y; layer.r0 = state->mapRadius(radius0); layer.r1 = state->mapRadius(radius1); + layer.radialTransform = + state->glyphTransform() * + rive::Mat2D::fromScaleAndTranslation(radius1, radius1, x1, y1); state->layers->push_back(std::move(layer)); } @@ -958,31 +955,19 @@ if (cacheIt != m_colorLayerCache.end()) { // Copy from cache, but update foreground colors. - for (const auto& cached : cacheIt->second) + for (ColorGlyphLayer layer : cacheIt->second) { - ColorGlyphLayer layer; - layer.paintType = cached.paintType; - layer.path = cached.path; - layer.useForeground = cached.useForeground; - layer.color = cached.useForeground ? foreground : cached.color; - // Copy gradient data if present. - layer.stops = cached.stops; - layer.x0 = cached.x0; - layer.y0 = cached.y0; - layer.x1 = cached.x1; - layer.y1 = cached.y1; - layer.r0 = cached.r0; - layer.r1 = cached.r1; - layer.startAngle = cached.startAngle; - layer.endAngle = cached.endAngle; - // Copy image data if present. - layer.imageBytes = cached.imageBytes; - layer.imageWidth = cached.imageWidth; - layer.imageHeight = cached.imageHeight; - layer.imageBearingX = cached.imageBearingX; - layer.imageBearingY = cached.imageBearingY; - layer.imageExtentX = cached.imageExtentX; - layer.imageExtentY = cached.imageExtentY; + if (layer.useForeground) + { + layer.color = foreground; + } + for (auto& stop : layer.stops) + { + if (stop.isForeground) + { + stop.color = foreground; + } + } out.push_back(std::move(layer)); } return cacheIt->second.size();
diff --git a/src/text/raw_text.cpp b/src/text/raw_text.cpp index 72f52c2..64ce932 100644 --- a/src/text/raw_text.cpp +++ b/src/text/raw_text.cpp
@@ -568,12 +568,7 @@ renderer->transform(transform); for (auto& layer : layers) { - auto renderPath = - m_factory->makeRenderPath(layer.path, FillRule::nonZero); - auto layerPaint = m_factory->makeRenderPaint(); - layerPaint->style(RenderPaintStyle::fill); - layerPaint->color(layer.color); - renderer->drawPath(renderPath.get(), layerPaint.get()); + drawColorGlyphLayer(renderer, m_factory, layer, 1.0f); } renderer->restore(); }
diff --git a/src/text/text.cpp b/src/text/text.cpp index a10ca06..c8728b7 100644 --- a/src/text/text.cpp +++ b/src/text/text.cpp
@@ -991,6 +991,71 @@ } } +void rive::drawColorGlyphLayer(Renderer* renderer, + Factory* factory, + Font::ColorGlyphLayer& layer, + float opacity) +{ + using PaintType = Font::ColorGlyphPaintType; + auto paint = factory->makeRenderPaint(); + paint->style(RenderPaintStyle::fill); + Mat2D toUnitCircle; + bool isLinear = layer.paintType == PaintType::linearGradient; + bool isRadial = layer.paintType == PaintType::radialGradient && + layer.radialTransform.invert(&toUnitCircle); + if (layer.stops.size() < 2 || (!isLinear && !isRadial)) + { + ColorInt color = + layer.paintType == PaintType::solid || layer.stops.empty() + ? layer.color + : layer.stops.front().color; + paint->color(colorModulateOpacity(color, opacity)); + renderer->drawPath( + factory->makeRenderPath(layer.path, FillRule::nonZero).get(), + paint.get()); + return; + } + + std::vector<ColorInt> colors; + std::vector<float> stops; + colors.reserve(layer.stops.size()); + stops.reserve(layer.stops.size()); + for (const auto& stop : layer.stops) + { + colors.push_back(colorModulateOpacity(stop.color, opacity)); + stops.push_back(stop.offset); + } + if (isLinear) + { + paint->shader(factory->makeLinearGradient(layer.x0, + layer.y0, + layer.x1, + layer.y1, + colors.data(), + stops.data(), + colors.size())); + renderer->drawPath( + factory->makeRenderPath(layer.path, FillRule::nonZero).get(), + paint.get()); + return; + } + + // Radial shaders are circles, so draw in the gradient's own space. + paint->shader(factory->makeRadialGradient(0, + 0, + 1, + colors.data(), + stops.data(), + colors.size())); + layer.path.transformInPlace(toUnitCircle); + renderer->save(); + renderer->transform(layer.radialTransform); + renderer->drawPath( + factory->makeRenderPath(layer.path, FillRule::nonZero).get(), + paint.get()); + renderer->restore(); +} + void Text::drawColorGlyph(Renderer* renderer, const TextDrawCommand::ColorGlyphInfo& info, const Mat2D& worldTransform) @@ -1043,12 +1108,7 @@ } else { - auto renderPath = - factory->makeRenderPath(layer.path, FillRule::nonZero); - auto renderPaint = factory->makeRenderPaint(); - renderPaint->style(RenderPaintStyle::fill); - renderPaint->color(colorModulateOpacity(layer.color, info.opacity)); - renderer->drawPath(renderPath.get(), renderPaint.get()); + drawColorGlyphLayer(renderer, factory, layer, info.opacity); } } renderer->restore();
diff --git a/tests/gm/gmmain.cpp b/tests/gm/gmmain.cpp index 6ebd0df..d278e10 100644 --- a/tests/gm/gmmain.cpp +++ b/tests/gm/gmmain.cpp
@@ -164,6 +164,9 @@ MAKE_GM(strokedlines) MAKE_GM(strokefill) MAKE_GM(stroke_position) + MAKE_GM(stroke_position_shapes) + MAKE_GM(stroke_position_shapes_feathered) + MAKE_GM(stroke_position_winding) MAKE_GM(stroke_position_clipped) MAKE_GM(bug339297) MAKE_GM(bug339297_as_clip)
diff --git a/tests/gm/stroke_position_shapes.cpp b/tests/gm/stroke_position_shapes.cpp new file mode 100644 index 0000000..97ef3f8 --- /dev/null +++ b/tests/gm/stroke_position_shapes.cpp
@@ -0,0 +1,186 @@ +/* + * Copyright 2026 Rive + */ + +// Inside/center/outside strokes on the shapes the circle-only stroke_position +// GMs don't reach. Each cell draws the stroke, then the path's fill region in +// translucent purple, then the path itself as a thin red line. +// +// Pinned behavior, all intended: +// - Inside/outside close open paths, so the closing segment is stroked too. +// - Joins only exist on the outer side of a turn: inside strokes show them +// at concave corners, outside strokes at convex ones. +// - Which side is "inside" follows the stroke path's own fill rule, so holes +// and contours wound in opposite directions resolve as that rule says. + +#include "gm.hpp" +#include "gmutils.hpp" +#include "rive/renderer.hpp" + +using namespace rivegm; +using namespace rive; + +namespace +{ +constexpr float kCell = 160.0f; +constexpr float kThickness = 18.0f; + +Path openZigzag() +{ + return PathBuilder() + .moveTo(24, 50) + .lineTo(60, 120) + .lineTo(96, 50) + .lineTo(136, 120) + .detach(); +} + +// Four convex corners and one concave one, at the notch. +Path notchedSquare() +{ + return PathBuilder() + .moveTo(30, 30) + .lineTo(130, 30) + .lineTo(130, 130) + .lineTo(80, 80) + .lineTo(30, 130) + .close() + .detach(); +} + +// Both squares wound the same way, so only even-odd sees a hole. +Path squareWithHole(FillRule fillRule) +{ + Path path = PathBuilder() + .moveTo(30, 30) + .lineTo(130, 30) + .lineTo(130, 130) + .lineTo(30, 130) + .close() + .moveTo(60, 60) + .lineTo(100, 60) + .lineTo(100, 100) + .lineTo(60, 100) + .close() + .detach(); + path->fillRule(fillRule); + return path; +} + +// A clockwise square beside a counter-clockwise one, as two pen-drawn paths +// in one shape can be. +Path oppositeSquares() +{ + return PathBuilder() + .moveTo(20, 40) + .lineTo(90, 40) + .lineTo(90, 110) + .lineTo(20, 110) + .close() + .moveTo(110, 70) + .lineTo(110, 120) + .lineTo(140, 120) + .lineTo(140, 70) + .close() + .detach(); +} + +void drawBackground(Renderer* renderer, float width, float height) +{ + Paint bg; + bg->color(0xff000000); + renderer->drawPath(PathBuilder::Rect({0, 0, width, height}), bg); +} + +// [strokePath] is what gets stroked; [fillPath] only shows the region the +// stroke is measured against, and is usually the same path. +void drawCell(Renderer* renderer, + const Path& strokePath, + const Path& fillPath, + StrokeParams params, + float feather) +{ + Paint stroke; + stroke->color(0xFFEFEFEF); + stroke->stroke(params); + stroke->feather(feather); + renderer->drawPath(strokePath, stroke); + + Paint fill; + fill->color(0x5f881177); + renderer->drawPath(fillPath, fill); + + Paint line; + line->color(0xFFFF3030); + line->stroke({.thickness = 1.5f}); + renderer->drawPath(strokePath, line); +} + +// Rows: shape x join. Columns: inside, center, outside. +void drawShapesByJoin(Renderer* renderer, float feather) +{ + drawBackground(renderer, 3 * kCell, 9 * kCell); + + Path shapes[] = {openZigzag(), + notchedSquare(), + squareWithHole(FillRule::evenOdd)}; + StrokeJoin joins[] = {StrokeJoin::miter, + StrokeJoin::round, + StrokeJoin::bevel}; + for (int s = 0; s < 3; ++s) + { + for (int j = 0; j < 3; ++j) + { + for (int p = 0; p < 3; ++p) + { + renderer->save(); + renderer->translate(p * kCell, (s * 3 + j) * kCell); + drawCell(renderer, + shapes[s], + shapes[s], + {.thickness = kThickness, + .join = joins[j], + .position = StrokePosition(p)}, + feather); + renderer->restore(); + } + } + } +} +} // namespace + +DEF_SIMPLE_GM(stroke_position_shapes, 480, 1440, renderer) +{ + drawShapesByJoin(renderer, 0.0f); +} + +// Feathered inside/outside strokes take a different branch in the renderer: +// clip by the path, then draw the thickened stroke. +DEF_SIMPLE_GM(stroke_position_shapes_feathered, 480, 1440, renderer) +{ + drawShapesByJoin(renderer, 6.0f); +} + +// What the editor sends: stroke paths are always non-zero, whatever the Fill's +// rule. Row 1 is a same-direction hole that an even-odd Fill shows, but the +// non-zero stroke path counts as inside. Row 2 is two opposite-wound squares. +// Columns: inside, center, outside. +DEF_SIMPLE_GM(stroke_position_winding, 480, 320, renderer) +{ + drawBackground(renderer, 3 * kCell, 2 * kCell); + + Path hole = squareWithHole(FillRule::nonZero); + Path holeFill = squareWithHole(FillRule::evenOdd); + Path pair = oppositeSquares(); + for (int p = 0; p < 3; ++p) + { + StrokeParams params = {.thickness = 12.0f, + .position = StrokePosition(p)}; + renderer->save(); + renderer->translate(p * kCell, 0); + drawCell(renderer, hole, holeFill, params, 0.0f); + renderer->translate(0, kCell); + drawCell(renderer, pair, pair, params, 0.0f); + renderer->restore(); + } +}
diff --git a/tests/unit_tests/assets/NotoColorEmojiCOLRv1.subset.ttf b/tests/unit_tests/assets/NotoColorEmojiCOLRv1.subset.ttf new file mode 100644 index 0000000..8fce7a0 --- /dev/null +++ b/tests/unit_tests/assets/NotoColorEmojiCOLRv1.subset.ttf Binary files differ
diff --git a/tests/unit_tests/runtime/color_glyph_test.cpp b/tests/unit_tests/runtime/color_glyph_test.cpp index bf9f56b..5a4fad5 100644 --- a/tests/unit_tests/runtime/color_glyph_test.cpp +++ b/tests/unit_tests/runtime/color_glyph_test.cpp
@@ -487,3 +487,115 @@ NoOpRenderer renderer; rawText.render(&renderer); } + +// U+1F389, U+1F600 and U+1F605 are glyphs 1 to 3, subset from Android's COLRv1 +// Noto. +static const char* colrV1Font = "assets/NotoColorEmojiCOLRv1.subset.ttf"; + +TEST_CASE("COLRv1 radial gradient maps to glyph space", "[color_glyph]") +{ + auto font = loadFont(colrV1Font); + REQUIRE(font != nullptr); + + std::vector<Font::ColorGlyphLayer> layers; + REQUIRE(font->getColorLayers(2, layers) == 10); + + // The face is a radial gradient centered at (630, 360) of a 1024 upem. + const auto& face = layers[0]; + REQUIRE(face.paintType == Font::ColorGlyphPaintType::radialGradient); + REQUIRE(face.stops.size() == 3); + CHECK(face.stops[0].color == 0xFFFDE030); + CHECK(face.x1 == Approx(630.0f / 1024.0f)); + CHECK(face.y1 == Approx(-360.0f / 1024.0f)); + CHECK(face.r1 == Approx(534.0f / 1024.0f)); + AABB faceBounds = face.path.bounds(); + CHECK(faceBounds.contains(Vec2D(face.x1, face.y1))); +} + +TEST_CASE("COLRv1 transformed clip glyphs keep their transform", + "[color_glyph]") +{ + auto font = loadFont(colrV1Font); + REQUIRE(font != nullptr); + + std::vector<Font::ColorGlyphLayer> layers; + REQUIRE(font->getColorLayers(2, layers) == 10); + + // Both eye highlights reuse one glyph, translated by -41.906 and 317.625. + AABB left = layers[2].path.bounds(); + AABB right = layers[4].path.bounds(); + CHECK(right.minX - left.minX == Approx((317.625f + 41.906f) / 1024.0f)); + CHECK(right.minY == Approx(left.minY)); +} + +TEST_CASE("COLRv1 radial gradients keep their ellipse", "[color_glyph]") +{ + auto font = loadFont(colrV1Font); + REQUIRE(font != nullptr); + + // The sweat drop on U+1F605 is a radial gradient under an uneven scale. + std::vector<Font::ColorGlyphLayer> layers; + REQUIRE(font->getColorLayers(3, layers) > 0); + bool sawEllipse = false; + for (const auto& layer : layers) + { + if (layer.paintType != Font::ColorGlyphPaintType::radialGradient) + { + continue; + } + const Mat2D& m = layer.radialTransform; + Vec2D center = m * Vec2D(0, 0); + CHECK(center.x == Approx(layer.x1)); + CHECK(center.y == Approx(layer.y1)); + float sx = Vec2D(m[0], m[1]).length(); + float sy = Vec2D(m[2], m[3]).length(); + sawEllipse |= std::abs(sx - sy) > 0.2f * std::max(sx, sy); + } + CHECK(sawEllipse); +} + +class GradientCountingFactory : public NoOpFactory +{ +public: + int linear = 0; + int radial = 0; + + rcp<RenderShader> makeLinearGradient(float, + float, + float, + float, + const ColorInt[], + const float[], + size_t) override + { + linear++; + return nullptr; + } + + rcp<RenderShader> makeRadialGradient(float, + float, + float, + const ColorInt[], + const float[], + size_t) override + { + radial++; + return nullptr; + } +}; + +TEST_CASE("RawText draws COLRv1 gradient layers with gradient shaders", + "[color_glyph]") +{ + auto font = loadFont(colrV1Font); + REQUIRE(font != nullptr); + + GradientCountingFactory factory; + RawText rawText(&factory); + rawText.append("\xF0\x9F\x8E\x89\xF0\x9F\x98\x80", nullptr, font, 32.0f); + + NoOpRenderer renderer; + rawText.render(&renderer); + CHECK(factory.linear == 1); + CHECK(factory.radial == 1); +}
diff --git a/tests/unit_tests/runtime/paint_outset_test.cpp b/tests/unit_tests/runtime/paint_outset_test.cpp index 4204059..15e07df 100644 --- a/tests/unit_tests/runtime/paint_outset_test.cpp +++ b/tests/unit_tests/runtime/paint_outset_test.cpp
@@ -12,8 +12,12 @@ * preview will crop where playback does not. */ +#include <rive/artboard.hpp> +#include <rive/file.hpp> #include <rive/shapes/paint/paint_outset.hpp> +#include <rive/shapes/paint/stroke.hpp> +#include "rive_file_reader.hpp" #include <catch.hpp> using namespace rive; @@ -72,6 +76,31 @@ CHECK(paintBoundsOutset(s, 0.0f) == Approx(5.0f * kMiterLimit)); } +TEST_CASE("an outside stroke reaches its full thickness", "[paint-outset]") +{ + // Drawn as a centered stroke twice as thick, clipped to the outside. + StrokeParams s; + s.thickness = 10.0f; + s.join = StrokeJoin::round; + s.position = StrokePosition::outside; + CHECK(paintBoundsOutset(s, 0.0f) == Approx(10.0f)); + + s.join = StrokeJoin::miter; + CHECK(paintBoundsOutset(s, 0.0f) == Approx(10.0f * kMiterLimit)); + CHECK(paintBoundsOutset(s, 0.0f) == Approx(40.0f)); + + s.join = StrokeJoin::bevel; + s.cap = StrokeCap::square; + CHECK(paintBoundsOutset(s, 0.0f) == Approx(10.0f * math::SQRT2)); + + // Clipped to the path, an inside stroke reaches nothing past it. Half its + // thickness, like a centered one, is kept as a safe over-estimate. + s.position = StrokePosition::inside; + s.join = StrokeJoin::round; + s.cap = StrokeCap::butt; + CHECK(paintBoundsOutset(s, 0.0f) == Approx(5.0f)); +} + TEST_CASE("a feather reaches 1.5x its authored strength", "[paint-outset]") { // Design tools quote a blur as the width of two standard deviations, so an @@ -125,3 +154,29 @@ } } } + +TEST_CASE("shapePaintOutset reads a stroke's position", "[paint-outset]") +{ + auto file = ReadRiveFile("assets/stroke_name_test.riv"); + auto artboard = file->artboardDefault(); + REQUIRE(artboard != nullptr); + artboard->advance(0.0f); + auto stroke = artboard->find<Stroke>("white_stroke"); + REQUIRE(stroke != nullptr); + REQUIRE(stroke->shouldDraw()); + + StrokeParams params; + params.thickness = stroke->thickness(); + params.join = static_cast<StrokeJoin>(stroke->join()); + params.cap = static_cast<StrokeCap>(stroke->cap()); + const float centeredStroke = paintBoundsOutset(params, 0.0f); + params.position = StrokePosition::outside; + const float outsideStroke = paintBoundsOutset(params, 0.0f); + REQUIRE(outsideStroke > centeredStroke); + + // Compared as a difference so any feather on the stroke cancels out. + const float centered = shapePaintOutset(stroke).outset; + stroke->position((uint8_t)StrokePosition::outside); + CHECK(shapePaintOutset(stroke).outset - centered == + Approx(outsideStroke - centeredStroke)); +}
diff --git a/tests/unit_tests/runtime/serialized_replay_test.cpp b/tests/unit_tests/runtime/serialized_replay_test.cpp index 139b216..98e2c17 100644 --- a/tests/unit_tests/runtime/serialized_replay_test.cpp +++ b/tests/unit_tests/runtime/serialized_replay_test.cpp
@@ -16,6 +16,7 @@ #include <catch.hpp> #include <cstring> +#include <vector> using namespace rive; @@ -37,6 +38,7 @@ paint->blendMode(BlendMode::multiply); paint->feather(2.0f); paint->additiveness(0.375f); + paint->strokePosition(StrokePosition::outside); RawPath rp; rp.move({0, 0}); @@ -297,6 +299,69 @@ CHECK(sink.got[0].uvScale.y == 1.0f); } +TEST_CASE("a centered stroke records no stroke position op", + "[serialize][replay]") +{ + // Every stroke in an existing file is centered, so recording one has to + // leave the stream byte for byte what it was before the op existed, or + // every silver with a stroke in it would need regenerating. + SerializingFactory plain; + auto plainPaint = plain.makeRenderPaint(); + plainPaint->style(RenderPaintStyle::stroke); + + SerializingFactory centered; + auto centeredPaint = centered.makeRenderPaint(); + centeredPaint->style(RenderPaintStyle::stroke); + centeredPaint->strokePosition(StrokePosition::center); + + auto sa = plain.bytes(); + auto sb = centered.bytes(); + REQUIRE(sa.size() == sb.size()); + CHECK(std::memcmp(sa.data(), sb.data(), sa.size()) == 0); +} + +TEST_CASE("a replayed paint keeps its stroke position", "[serialize][replay]") +{ + SerializingFactory a; + auto paint = a.makeRenderPaint(); + paint->style(RenderPaintStyle::stroke); + paint->strokePosition(StrokePosition::inside); + auto recorded = a.bytes().size(); + // Setting the same value again is deduped like every other paint op. + paint->strokePosition(StrokePosition::inside); + CHECK(a.bytes().size() == recorded); + paint->strokePosition(StrokePosition::outside); + CHECK(a.bytes().size() > recorded); + + SerializingFactory b; + auto r = b.makeRenderer(); + REQUIRE(replaySerializedCommands(a.bytes(), &b, r.get())); + auto sa = a.bytes(); + auto sb = b.bytes(); + REQUIRE(sa.size() == sb.size()); + CHECK(std::memcmp(sa.data(), sb.data(), sa.size()) == 0); +} + +TEST_CASE("an unknown stroke position fails replay", "[serialize][replay]") +{ + // Renderers assert on a position they don't know, so replay has to stop + // at it rather than pass it through. + SerializingFactory a; + auto paint = a.makeRenderPaint(); + paint->strokePosition(StrokePosition::outside); + std::vector<uint8_t> stream(a.bytes().begin(), a.bytes().end()); + // The op ends the stream: [strokePosition][paint id][value]. + REQUIRE(stream.back() == (uint8_t)StrokePosition::outside); + stream.back() = 7; + + SerializingFactory b; + auto r = b.makeRenderer(); + CHECK_FALSE(replaySerializedCommands( + Span<const uint8_t>(stream.data(), stream.size()), + &b, + r.get())); +} + TEST_CASE("serialized replay rejects a bad header", "[serialize][replay]") { const uint8_t garbage[8] = {'X', 'X', 'X', 'X', 1, 0, 0, 0};
diff --git a/tests/unit_tests/runtime/stroke_test.cpp b/tests/unit_tests/runtime/stroke_test.cpp index 23eddd1..712c11f 100644 --- a/tests/unit_tests/runtime/stroke_test.cpp +++ b/tests/unit_tests/runtime/stroke_test.cpp
@@ -1,10 +1,13 @@ +#include <rive/animation/keyframe_uint.hpp> #include <rive/file.hpp> +#include <rive/generated/core_registry.hpp> #include <rive/node.hpp> #include <rive/shapes/rectangle.hpp> #include <rive/shapes/shape.hpp> #include <rive/shapes/paint/stroke.hpp> #include <rive/shapes/paint/solid_color.hpp> #include <rive/shapes/paint/color.hpp> +#include <utils/no_op_factory.hpp> #include <utils/no_op_renderer.hpp> #include "rive_file_reader.hpp" #include <catch.hpp> @@ -21,3 +24,141 @@ stroke->paint()->as<rive::SolidColor>()->colorValue( rive::colorARGB(255, 0, 255, 255)); } + +namespace +{ +// ReadRiveFile builds paints from a NoOpFactory, which discards everything. +// This records the stroke position Stroke pushes onto its RenderPaint. +class StrokePositionPaint : public rive::RenderPaint +{ +public: + rive::StrokePosition position = rive::StrokePosition::center; + int positionWrites = 0; + + void style(rive::RenderPaintStyle) override {} + void color(rive::ColorInt) override {} + void thickness(float) override {} + void join(rive::StrokeJoin) override {} + void cap(rive::StrokeCap) override {} + void strokePosition(rive::StrokePosition value) override + { + position = value; + positionWrites++; + } + void blendMode(rive::BlendMode) override {} + void shader(rive::rcp<rive::RenderShader>) override {} + void invalidateStroke() override {} +}; + +class StrokePositionFactory : public rive::NoOpFactory +{ +public: + rive::rcp<rive::RenderPaint> makeRenderPaint() override + { + return rive::make_rcp<StrokePositionPaint>(); + } +}; +} // namespace + +TEST_CASE("stroke position defaults to center", "[stroke]") +{ + StrokePositionFactory factory; + auto file = ReadRiveFile("assets/stroke_name_test.riv", &factory); + auto artboard = file->artboard()->instance(); + auto stroke = artboard->find<rive::Stroke>("white_stroke"); + REQUIRE(stroke != nullptr); + + // Files written before the property existed never carry it. + CHECK(stroke->position() == (uint8_t)rive::StrokePosition::center); + CHECK(stroke->strokePosition() == rive::StrokePosition::center); + auto* paint = static_cast<StrokePositionPaint*>(stroke->renderPaint()); + REQUIRE(paint != nullptr); + CHECK(paint->positionWrites > 0); + CHECK(paint->position == rive::StrokePosition::center); +} + +TEST_CASE("changing stroke position reaches the render paint", "[stroke]") +{ + StrokePositionFactory factory; + auto file = ReadRiveFile("assets/stroke_name_test.riv", &factory); + auto artboard = file->artboard()->instance(); + auto stroke = artboard->find<rive::Stroke>("white_stroke"); + REQUIRE(stroke != nullptr); + auto* paint = static_cast<StrokePositionPaint*>(stroke->renderPaint()); + REQUIRE(paint != nullptr); + + stroke->position((uint8_t)rive::StrokePosition::inside); + artboard->advance(0.0f); + CHECK(paint->position == rive::StrokePosition::inside); + + stroke->position((uint8_t)rive::StrokePosition::outside); + artboard->advance(0.0f); + CHECK(paint->position == rive::StrokePosition::outside); + + // A value from a newer file this runtime doesn't know draws centered. + stroke->position(7); + artboard->advance(0.0f); + CHECK(stroke->strokePosition() == rive::StrokePosition::center); + CHECK(paint->position == rive::StrokePosition::center); +} + +TEST_CASE("stroke applyTo carries its position", "[stroke]") +{ + StrokePositionFactory factory; + auto file = ReadRiveFile("assets/stroke_name_test.riv", &factory); + auto artboard = file->artboard()->instance(); + auto stroke = artboard->find<rive::Stroke>("white_stroke"); + REQUIRE(stroke != nullptr); + + // Text and layout paints get their stroke params through applyTo. + stroke->position((uint8_t)rive::StrokePosition::outside); + StrokePositionPaint target; + stroke->applyTo(&target, 1.0f); + CHECK(target.position == rive::StrokePosition::outside); +} + +TEST_CASE("keyframes and binds drive stroke position", "[stroke]") +{ + // KeyFrameUint and the enum/number data bind contexts all land in + // CoreRegistry::setUint, so both reach the paint through positionChanged. + StrokePositionFactory factory; + auto file = ReadRiveFile("assets/stroke_name_test.riv", &factory); + auto artboard = file->artboard()->instance(); + auto stroke = artboard->find<rive::Stroke>("white_stroke"); + REQUIRE(stroke != nullptr); + auto* paint = static_cast<StrokePositionPaint*>(stroke->renderPaint()); + REQUIRE(paint != nullptr); + + rive::KeyFrameUint keyframe; + keyframe.value((uint32_t)rive::StrokePosition::outside); + keyframe.apply(stroke, rive::StrokeBase::positionPropertyKey, 1.0f); + artboard->advance(0.0f); + CHECK(paint->position == rive::StrokePosition::outside); + + rive::CoreRegistry::setUint(stroke, + rive::StrokeBase::positionPropertyKey, + (uint32_t)rive::StrokePosition::inside); + artboard->advance(0.0f); + CHECK(paint->position == rive::StrokePosition::inside); +} + +TEST_CASE("stroke position holds between keyframes", "[stroke]") +{ + // An enum has nothing between its values, so a partial mix (a blend + // state, or a transition) must switch rather than land on a neighbor. + CHECK_FALSE(rive::CoreRegistry::isInterpolatableUint( + rive::StrokeBase::positionPropertyKey)); + + StrokePositionFactory factory; + auto file = ReadRiveFile("assets/stroke_name_test.riv", &factory); + auto artboard = file->artboard()->instance(); + auto stroke = artboard->find<rive::Stroke>("white_stroke"); + REQUIRE(stroke != nullptr); + + rive::KeyFrameUint keyframe; + keyframe.value((uint32_t)rive::StrokePosition::inside); + // Center (1) mixed halfway toward inside (0) would round to either + // neighbor if it interpolated. + keyframe.apply(stroke, rive::StrokeBase::positionPropertyKey, 0.5f); + CHECK(stroke->position() == (uint8_t)rive::StrokePosition::inside); +}
diff --git a/utils/serialized_replay.cpp b/utils/serialized_replay.cpp index b77ca25..01966fe 100644 --- a/utils/serialized_replay.cpp +++ b/utils/serialized_replay.cpp
@@ -198,6 +198,18 @@ paint->additiveness(reader.readFloat32()); break; } + case SerializeOp::strokePosition: + { + uint64_t id = reader.readVarUint64(); + uint64_t position = reader.readVarUint64(); + RenderPaint* paint = find(paints, id); + // Renderers assert on a position they don't know. + if (paint == nullptr || + position > (uint64_t)StrokePosition::outside) + return false; + paint->strokePosition(static_cast<StrokePosition>(position)); + break; + } case SerializeOp::shader: { uint64_t id = reader.readVarUint64();
diff --git a/utils/serializing_factory.cpp b/utils/serializing_factory.cpp index 1c53baa..8f797fd 100644 --- a/utils/serializing_factory.cpp +++ b/utils/serializing_factory.cpp
@@ -104,6 +104,8 @@ return "blendMode"; case SerializeOp::additiveness: return "additiveness"; + case SerializeOp::strokePosition: + return "strokePosition"; case SerializeOp::shader: return "shader"; case SerializeOp::paintModulatedImage: @@ -229,6 +231,18 @@ m_writer->writeVarUint((uint32_t)m_cap); } + void strokePosition(StrokePosition value) override + { + if (m_strokePosition == value) + { + return; + } + m_strokePosition = value; + m_writer->writeVarUint((uint32_t)SerializeOp::strokePosition); + m_writer->writeVarUint(m_id); + m_writer->writeVarUint((uint32_t)m_strokePosition); + } + void blendMode(BlendMode value) override { if (m_blendMode == value) @@ -312,6 +326,7 @@ float m_thickness = 1; StrokeJoin m_join = StrokeJoin::miter; StrokeCap m_cap = StrokeCap::butt; + StrokePosition m_strokePosition = StrokePosition::center; float m_feather = 0; float m_additiveness = 0; BlendMode m_blendMode = BlendMode::srcOver; @@ -1666,6 +1681,22 @@ return false; } break; + case SerializeOp::strokePosition: + if (!varUintMatches(opA, + "strokeposition_paint_id", + readerA, + readerB)) + { + return false; + } + if (!varUintMatches(opA, + "strokeposition_value", + readerA, + readerB)) + { + return false; + } + break; case SerializeOp::feather: if (!varUintMatches(opA, "feather_paint_id", readerA, readerB)) {