From bad26407a6ac62f98eab14506642ade870c96620 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Thu, 11 Jun 2026 17:00:13 +0800 Subject: [PATCH 01/39] Add a registry-driven reflection API to read and write PAGX node channels by name. (cherry picked from commit 67ab9e4d89bf7daf3ea061c9c21368bb9d5ab688) --- src/pagx/PAGXNodeChannel.cpp | 864 +++++++++++++++++++++++++++++++++++ src/pagx/PAGXNodeChannel.h | 82 ++++ test/src/PAGXTest.cpp | 118 +++++ 3 files changed, 1064 insertions(+) create mode 100644 src/pagx/PAGXNodeChannel.cpp create mode 100644 src/pagx/PAGXNodeChannel.h diff --git a/src/pagx/PAGXNodeChannel.cpp b/src/pagx/PAGXNodeChannel.cpp new file mode 100644 index 0000000000..7ccd80d631 --- /dev/null +++ b/src/pagx/PAGXNodeChannel.cpp @@ -0,0 +1,864 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#include "pagx/PAGXNodeChannel.h" +#include +#include +#include "base/utils/Log.h" +#include "pagx/nodes/BackgroundBlurStyle.h" +#include "pagx/nodes/BlendFilter.h" +#include "pagx/nodes/BlurFilter.h" +#include "pagx/nodes/ColorStop.h" +#include "pagx/nodes/ConicGradient.h" +#include "pagx/nodes/DiamondGradient.h" +#include "pagx/nodes/DropShadowFilter.h" +#include "pagx/nodes/DropShadowStyle.h" +#include "pagx/nodes/Ellipse.h" +#include "pagx/nodes/Fill.h" +#include "pagx/nodes/Gradient.h" +#include "pagx/nodes/Group.h" +#include "pagx/nodes/ImagePattern.h" +#include "pagx/nodes/InnerShadowFilter.h" +#include "pagx/nodes/InnerShadowStyle.h" +#include "pagx/nodes/Layer.h" +#include "pagx/nodes/LayerStyle.h" +#include "pagx/nodes/LayoutNode.h" +#include "pagx/nodes/LinearGradient.h" +#include "pagx/nodes/MergePath.h" +#include "pagx/nodes/Path.h" +#include "pagx/nodes/Polystar.h" +#include "pagx/nodes/RadialGradient.h" +#include "pagx/nodes/RangeSelector.h" +#include "pagx/nodes/Rectangle.h" +#include "pagx/nodes/Repeater.h" +#include "pagx/nodes/RoundCorner.h" +#include "pagx/nodes/SolidColor.h" +#include "pagx/nodes/Stroke.h" +#include "pagx/nodes/Text.h" +#include "pagx/nodes/TextBox.h" +#include "pagx/nodes/TextModifier.h" +#include "pagx/nodes/TextPath.h" +#include "pagx/nodes/TrimPath.h" +#include "pagx/utils/StringParser.h" + +namespace pagx { + +// The access generators below turn a member pointer (and, for enums, the StringParser converters) +// into a uniform NodeAccessFn. A read copies the field into *getOut; a write validates the KeyValue +// alternative (or enum string) and copies into the field. Routing both directions through one +// generated function keeps reads and writes symmetric and removes the per-field if/else boilerplate. + +template +static bool AccessFloat(Node* node, KeyValue* getOut, const KeyValue* setIn) { + auto* self = static_cast(node); + if (getOut != nullptr) { + *getOut = self->*Field; + return true; + } + const auto* value = std::get_if(setIn); + if (value == nullptr) { + return false; + } + self->*Field = *value; + return true; +} + +template +static bool AccessBool(Node* node, KeyValue* getOut, const KeyValue* setIn) { + auto* self = static_cast(node); + if (getOut != nullptr) { + *getOut = self->*Field; + return true; + } + const auto* value = std::get_if(setIn); + if (value == nullptr) { + return false; + } + self->*Field = *value; + return true; +} + +template +static bool AccessInt(Node* node, KeyValue* getOut, const KeyValue* setIn) { + auto* self = static_cast(node); + if (getOut != nullptr) { + *getOut = self->*Field; + return true; + } + const auto* value = std::get_if(setIn); + if (value == nullptr) { + return false; + } + self->*Field = *value; + return true; +} + +template +static bool AccessString(Node* node, KeyValue* getOut, const KeyValue* setIn) { + auto* self = static_cast(node); + if (getOut != nullptr) { + *getOut = self->*Field; + return true; + } + const auto* value = std::get_if(setIn); + if (value == nullptr) { + return false; + } + self->*Field = *value; + return true; +} + +template +static bool AccessColor(Node* node, KeyValue* getOut, const KeyValue* setIn) { + auto* self = static_cast(node); + if (getOut != nullptr) { + *getOut = self->*Field; + return true; + } + const auto* value = std::get_if(setIn); + if (value == nullptr) { + return false; + } + self->*Field = *value; + return true; +} + +// Point sub-component access: addresses position.x / position.y etc. XAxis selects x vs y. +template +static bool AccessPointAxis(Node* node, KeyValue* getOut, const KeyValue* setIn) { + auto* self = static_cast(node); + float& component = XAxis ? (self->*Field).x : (self->*Field).y; + if (getOut != nullptr) { + *getOut = component; + return true; + } + const auto* value = std::get_if(setIn); + if (value == nullptr) { + return false; + } + component = *value; + return true; +} + +// Size sub-component access: addresses size.width / size.height. WidthAxis selects width vs height. +template +static bool AccessSizeAxis(Node* node, KeyValue* getOut, const KeyValue* setIn) { + auto* self = static_cast(node); + float& component = WidthAxis ? (self->*Field).width : (self->*Field).height; + if (getOut != nullptr) { + *getOut = component; + return true; + } + const auto* value = std::get_if(setIn); + if (value == nullptr) { + return false; + } + component = *value; + return true; +} + +// Padding sub-component access. Which: 0=left, 1=top, 2=right, 3=bottom. +template +static bool AccessPaddingComp(Node* node, KeyValue* getOut, const KeyValue* setIn) { + auto* self = static_cast(node); + Padding& padding = self->*Field; + float* component = + Which == 0 ? &padding.left + : (Which == 1 ? &padding.top : (Which == 2 ? &padding.right : &padding.bottom)); + if (getOut != nullptr) { + *getOut = *component; + return true; + } + const auto* value = std::get_if(setIn); + if (value == nullptr) { + return false; + } + *component = *value; + return true; +} + +template +static bool AccessOptionalFloat(Node* node, KeyValue* getOut, const KeyValue* setIn) { + auto* self = static_cast(node); + if (getOut != nullptr) { + if (!(self->*Field).has_value()) { + return false; + } + *getOut = *(self->*Field); + return true; + } + const auto* value = std::get_if(setIn); + if (value == nullptr) { + return false; + } + self->*Field = *value; + return true; +} + +template +static bool AccessOptionalColor(Node* node, KeyValue* getOut, const KeyValue* setIn) { + auto* self = static_cast(node); + if (getOut != nullptr) { + if (!(self->*Field).has_value()) { + return false; + } + *getOut = *(self->*Field); + return true; + } + const auto* value = std::get_if(setIn); + if (value == nullptr) { + return false; + } + self->*Field = *value; + return true; +} + +// Enum access: enums are carried as their string name (matching the PAGX XML attribute values). +// The to/from/isValid converters are the same StringParser functions used by importer and exporter. +template +static bool AccessEnum(Node* node, KeyValue* getOut, const KeyValue* setIn) { + auto* self = static_cast(node); + if (getOut != nullptr) { + *getOut = ToString(self->*Field); + return true; + } + const auto* value = std::get_if(setIn); + if (value == nullptr || !IsValid(*value)) { + return false; + } + self->*Field = FromString(*value); + return true; +} + +// Convenience macros that turn a (channel, member) pair into a NodeFieldDef row. They only build the +// table entries; all access logic lives in the templated generators above. +#define FIELD_FLOAT(T, name, member, cls) \ + { name, cls, &AccessFloat } +#define FIELD_BOOL(T, name, member, cls) \ + { name, cls, &AccessBool } +#define FIELD_INT(T, name, member, cls) \ + { name, cls, &AccessInt } +#define FIELD_STRING(T, name, member, cls) \ + { name, cls, &AccessString } +#define FIELD_COLOR(T, name, member, cls) \ + { name, cls, &AccessColor } +#define FIELD_POINT_X(T, name, member, cls) \ + { name, cls, &AccessPointAxis } +#define FIELD_POINT_Y(T, name, member, cls) \ + { name, cls, &AccessPointAxis } +#define FIELD_SIZE_W(T, name, member, cls) \ + { name, cls, &AccessSizeAxis } +#define FIELD_SIZE_H(T, name, member, cls) \ + { name, cls, &AccessSizeAxis } +#define FIELD_PADDING_L(T, name, member, cls) \ + { name, cls, &AccessPaddingComp } +#define FIELD_PADDING_T(T, name, member, cls) \ + { name, cls, &AccessPaddingComp } +#define FIELD_PADDING_R(T, name, member, cls) \ + { name, cls, &AccessPaddingComp } +#define FIELD_PADDING_B(T, name, member, cls) \ + { name, cls, &AccessPaddingComp } +#define FIELD_OPT_FLOAT(T, name, member, cls) \ + { name, cls, &AccessOptionalFloat } +#define FIELD_OPT_COLOR(T, name, member, cls) \ + { name, cls, &AccessOptionalColor } +#define FIELD_ENUM(T, name, member, cls, E) \ + { name, cls, &AccessEnum } + +// The shared LayoutNode constraint fields (layout inputs) appended to every LayoutNode-derived type. +// T must derive from LayoutNode so the member pointers resolve through the base subobject. +template +static void AppendLayoutNodeFields(std::vector& table) { + std::vector shared = { + FIELD_FLOAT(T, "width", width, AnimClass::LayoutInput), + FIELD_FLOAT(T, "height", height, AnimClass::LayoutInput), + FIELD_FLOAT(T, "percentWidth", percentWidth, AnimClass::LayoutInput), + FIELD_FLOAT(T, "percentHeight", percentHeight, AnimClass::LayoutInput), + FIELD_FLOAT(T, "left", left, AnimClass::LayoutInput), + FIELD_FLOAT(T, "right", right, AnimClass::LayoutInput), + FIELD_FLOAT(T, "top", top, AnimClass::LayoutInput), + FIELD_FLOAT(T, "bottom", bottom, AnimClass::LayoutInput), + FIELD_FLOAT(T, "centerX", centerX, AnimClass::LayoutInput), + FIELD_FLOAT(T, "centerY", centerY, AnimClass::LayoutInput), + }; + table.insert(table.end(), shared.begin(), shared.end()); +} + +static std::vector BuildLayerFields() { + std::vector table = { + FIELD_STRING(Layer, "name", name, AnimClass::LayoutInput), + FIELD_BOOL(Layer, "visible", visible, AnimClass::Animatable), + FIELD_FLOAT(Layer, "alpha", alpha, AnimClass::Animatable), + FIELD_ENUM(Layer, "blendMode", blendMode, AnimClass::Animatable, BlendMode), + FIELD_FLOAT(Layer, "x", x, AnimClass::Animatable), + FIELD_FLOAT(Layer, "y", y, AnimClass::Animatable), + FIELD_BOOL(Layer, "preserve3D", preserve3D, AnimClass::Static), + FIELD_BOOL(Layer, "antiAlias", antiAlias, AnimClass::Static), + FIELD_BOOL(Layer, "groupOpacity", groupOpacity, AnimClass::Static), + FIELD_BOOL(Layer, "passThroughBackground", passThroughBackground, AnimClass::Static), + FIELD_BOOL(Layer, "clipToBounds", clipToBounds, AnimClass::LayoutInput), + FIELD_ENUM(Layer, "maskType", maskType, AnimClass::Static, MaskType), + FIELD_ENUM(Layer, "layout", layout, AnimClass::LayoutInput, LayoutMode), + FIELD_FLOAT(Layer, "gap", gap, AnimClass::LayoutInput), + FIELD_FLOAT(Layer, "flex", flex, AnimClass::LayoutInput), + FIELD_ENUM(Layer, "alignment", alignment, AnimClass::LayoutInput, Alignment), + FIELD_ENUM(Layer, "arrangement", arrangement, AnimClass::LayoutInput, Arrangement), + FIELD_BOOL(Layer, "includeInLayout", includeInLayout, AnimClass::LayoutInput), + FIELD_PADDING_L(Layer, "padding.left", padding, AnimClass::LayoutInput), + FIELD_PADDING_T(Layer, "padding.top", padding, AnimClass::LayoutInput), + FIELD_PADDING_R(Layer, "padding.right", padding, AnimClass::LayoutInput), + FIELD_PADDING_B(Layer, "padding.bottom", padding, AnimClass::LayoutInput), + }; + AppendLayoutNodeFields(table); + return table; +} + +static std::vector BuildRectangleFields() { + std::vector table = { + FIELD_POINT_X(Rectangle, "position.x", position, AnimClass::Animatable), + FIELD_POINT_Y(Rectangle, "position.y", position, AnimClass::Animatable), + FIELD_SIZE_W(Rectangle, "size.width", size, AnimClass::Animatable), + FIELD_SIZE_H(Rectangle, "size.height", size, AnimClass::Animatable), + FIELD_FLOAT(Rectangle, "roundness", roundness, AnimClass::Animatable), + FIELD_BOOL(Rectangle, "reversed", reversed, AnimClass::Static), + }; + AppendLayoutNodeFields(table); + return table; +} + +static std::vector BuildEllipseFields() { + std::vector table = { + FIELD_POINT_X(Ellipse, "position.x", position, AnimClass::Animatable), + FIELD_POINT_Y(Ellipse, "position.y", position, AnimClass::Animatable), + FIELD_SIZE_W(Ellipse, "size.width", size, AnimClass::Animatable), + FIELD_SIZE_H(Ellipse, "size.height", size, AnimClass::Animatable), + FIELD_BOOL(Ellipse, "reversed", reversed, AnimClass::Static), + }; + AppendLayoutNodeFields(table); + return table; +} + +static std::vector BuildPolystarFields() { + std::vector table = { + FIELD_POINT_X(Polystar, "position.x", position, AnimClass::Animatable), + FIELD_POINT_Y(Polystar, "position.y", position, AnimClass::Animatable), + FIELD_ENUM(Polystar, "type", type, AnimClass::Static, PolystarType), + FIELD_FLOAT(Polystar, "pointCount", pointCount, AnimClass::Animatable), + FIELD_FLOAT(Polystar, "outerRadius", outerRadius, AnimClass::Animatable), + FIELD_FLOAT(Polystar, "innerRadius", innerRadius, AnimClass::Animatable), + FIELD_FLOAT(Polystar, "rotation", rotation, AnimClass::Animatable), + FIELD_FLOAT(Polystar, "outerRoundness", outerRoundness, AnimClass::Animatable), + FIELD_FLOAT(Polystar, "innerRoundness", innerRoundness, AnimClass::Animatable), + FIELD_BOOL(Polystar, "reversed", reversed, AnimClass::Static), + }; + AppendLayoutNodeFields(table); + return table; +} + +static std::vector BuildPathFields() { + std::vector table = { + FIELD_POINT_X(Path, "position.x", position, AnimClass::Animatable), + FIELD_POINT_Y(Path, "position.y", position, AnimClass::Animatable), + FIELD_BOOL(Path, "reversed", reversed, AnimClass::Static), + }; + AppendLayoutNodeFields(table); + return table; +} + +static std::vector BuildTextFields() { + std::vector table = { + FIELD_STRING(Text, "text", text, AnimClass::LayoutInput), + FIELD_POINT_X(Text, "position.x", position, AnimClass::Animatable), + FIELD_POINT_Y(Text, "position.y", position, AnimClass::Animatable), + FIELD_STRING(Text, "fontFamily", fontFamily, AnimClass::LayoutInput), + FIELD_STRING(Text, "fontStyle", fontStyle, AnimClass::LayoutInput), + FIELD_FLOAT(Text, "fontSize", fontSize, AnimClass::LayoutInput), + FIELD_FLOAT(Text, "letterSpacing", letterSpacing, AnimClass::LayoutInput), + FIELD_BOOL(Text, "fauxBold", fauxBold, AnimClass::LayoutInput), + FIELD_BOOL(Text, "fauxItalic", fauxItalic, AnimClass::LayoutInput), + FIELD_ENUM(Text, "textAnchor", textAnchor, AnimClass::LayoutInput, TextAnchor), + FIELD_ENUM(Text, "baseline", baseline, AnimClass::LayoutInput, TextBaseline), + }; + AppendLayoutNodeFields(table); + return table; +} + +static std::vector BuildFillFields() { + return { + FIELD_FLOAT(Fill, "alpha", alpha, AnimClass::Animatable), + FIELD_ENUM(Fill, "blendMode", blendMode, AnimClass::Static, BlendMode), + FIELD_ENUM(Fill, "fillRule", fillRule, AnimClass::Static, FillRule), + FIELD_ENUM(Fill, "placement", placement, AnimClass::Static, LayerPlacement), + }; +} + +static std::vector BuildStrokeFields() { + return { + FIELD_FLOAT(Stroke, "width", width, AnimClass::Animatable), + FIELD_FLOAT(Stroke, "alpha", alpha, AnimClass::Animatable), + FIELD_ENUM(Stroke, "blendMode", blendMode, AnimClass::Static, BlendMode), + FIELD_ENUM(Stroke, "cap", cap, AnimClass::Static, LineCap), + FIELD_ENUM(Stroke, "join", join, AnimClass::Static, LineJoin), + FIELD_FLOAT(Stroke, "miterLimit", miterLimit, AnimClass::Animatable), + FIELD_FLOAT(Stroke, "dashOffset", dashOffset, AnimClass::Animatable), + FIELD_BOOL(Stroke, "dashAdaptive", dashAdaptive, AnimClass::Static), + FIELD_ENUM(Stroke, "align", align, AnimClass::Static, StrokeAlign), + FIELD_ENUM(Stroke, "placement", placement, AnimClass::Static, LayerPlacement), + }; +} + +static std::vector BuildTrimPathFields() { + return { + FIELD_FLOAT(TrimPath, "start", start, AnimClass::Animatable), + FIELD_FLOAT(TrimPath, "end", end, AnimClass::Animatable), + FIELD_FLOAT(TrimPath, "offset", offset, AnimClass::Animatable), + FIELD_ENUM(TrimPath, "type", type, AnimClass::Static, TrimType), + }; +} + +static std::vector BuildRoundCornerFields() { + return { + FIELD_FLOAT(RoundCorner, "radius", radius, AnimClass::Animatable), + }; +} + +static std::vector BuildMergePathFields() { + return { + FIELD_ENUM(MergePath, "mode", mode, AnimClass::Static, MergePathMode), + }; +} + +static std::vector BuildTextModifierFields() { + return { + FIELD_POINT_X(TextModifier, "anchor.x", anchor, AnimClass::Animatable), + FIELD_POINT_Y(TextModifier, "anchor.y", anchor, AnimClass::Animatable), + FIELD_POINT_X(TextModifier, "position.x", position, AnimClass::Animatable), + FIELD_POINT_Y(TextModifier, "position.y", position, AnimClass::Animatable), + FIELD_FLOAT(TextModifier, "rotation", rotation, AnimClass::Animatable), + FIELD_POINT_X(TextModifier, "scale.x", scale, AnimClass::Animatable), + FIELD_POINT_Y(TextModifier, "scale.y", scale, AnimClass::Animatable), + FIELD_FLOAT(TextModifier, "skew", skew, AnimClass::Animatable), + FIELD_FLOAT(TextModifier, "skewAxis", skewAxis, AnimClass::Animatable), + FIELD_FLOAT(TextModifier, "alpha", alpha, AnimClass::Animatable), + FIELD_OPT_COLOR(TextModifier, "fillColor", fillColor, AnimClass::Animatable), + FIELD_OPT_COLOR(TextModifier, "strokeColor", strokeColor, AnimClass::Animatable), + FIELD_OPT_FLOAT(TextModifier, "strokeWidth", strokeWidth, AnimClass::Animatable), + }; +} + +static std::vector BuildTextPathFields() { + std::vector table = { + FIELD_POINT_X(TextPath, "baselineOrigin.x", baselineOrigin, AnimClass::LayoutInput), + FIELD_POINT_Y(TextPath, "baselineOrigin.y", baselineOrigin, AnimClass::LayoutInput), + FIELD_FLOAT(TextPath, "baselineAngle", baselineAngle, AnimClass::LayoutInput), + FIELD_FLOAT(TextPath, "firstMargin", firstMargin, AnimClass::LayoutInput), + FIELD_FLOAT(TextPath, "lastMargin", lastMargin, AnimClass::LayoutInput), + FIELD_BOOL(TextPath, "perpendicular", perpendicular, AnimClass::LayoutInput), + FIELD_BOOL(TextPath, "reversed", reversed, AnimClass::Static), + FIELD_BOOL(TextPath, "forceAlignment", forceAlignment, AnimClass::LayoutInput), + }; + AppendLayoutNodeFields(table); + return table; +} + +// Shared Group transform/layout fields, also used by TextBox which derives from Group. +template +static void AppendGroupCommonFields(std::vector& table) { + std::vector shared = { + FIELD_POINT_X(T, "anchor.x", anchor, AnimClass::Animatable), + FIELD_POINT_Y(T, "anchor.y", anchor, AnimClass::Animatable), + FIELD_POINT_X(T, "position.x", position, AnimClass::Animatable), + FIELD_POINT_Y(T, "position.y", position, AnimClass::Animatable), + FIELD_FLOAT(T, "rotation", rotation, AnimClass::Animatable), + FIELD_POINT_X(T, "scale.x", scale, AnimClass::Animatable), + FIELD_POINT_Y(T, "scale.y", scale, AnimClass::Animatable), + FIELD_FLOAT(T, "skew", skew, AnimClass::Animatable), + FIELD_FLOAT(T, "skewAxis", skewAxis, AnimClass::Animatable), + FIELD_FLOAT(T, "alpha", alpha, AnimClass::Animatable), + FIELD_PADDING_L(T, "padding.left", padding, AnimClass::LayoutInput), + FIELD_PADDING_T(T, "padding.top", padding, AnimClass::LayoutInput), + FIELD_PADDING_R(T, "padding.right", padding, AnimClass::LayoutInput), + FIELD_PADDING_B(T, "padding.bottom", padding, AnimClass::LayoutInput), + }; + table.insert(table.end(), shared.begin(), shared.end()); + AppendLayoutNodeFields(table); +} + +static std::vector BuildGroupFields() { + std::vector table = {}; + AppendGroupCommonFields(table); + return table; +} + +static std::vector BuildTextBoxFields() { + std::vector table = { + FIELD_ENUM(TextBox, "textAlign", textAlign, AnimClass::LayoutInput, TextAlign), + FIELD_ENUM(TextBox, "paragraphAlign", paragraphAlign, AnimClass::LayoutInput, ParagraphAlign), + FIELD_ENUM(TextBox, "writingMode", writingMode, AnimClass::LayoutInput, WritingMode), + FIELD_FLOAT(TextBox, "lineHeight", lineHeight, AnimClass::LayoutInput), + FIELD_BOOL(TextBox, "wordWrap", wordWrap, AnimClass::LayoutInput), + FIELD_ENUM(TextBox, "overflow", overflow, AnimClass::LayoutInput, Overflow), + }; + AppendGroupCommonFields(table); + return table; +} + +static std::vector BuildRepeaterFields() { + return { + FIELD_FLOAT(Repeater, "copies", copies, AnimClass::Animatable), + FIELD_FLOAT(Repeater, "offset", offset, AnimClass::Animatable), + FIELD_ENUM(Repeater, "order", order, AnimClass::Static, RepeaterOrder), + FIELD_POINT_X(Repeater, "anchor.x", anchor, AnimClass::Animatable), + FIELD_POINT_Y(Repeater, "anchor.y", anchor, AnimClass::Animatable), + FIELD_POINT_X(Repeater, "position.x", position, AnimClass::Animatable), + FIELD_POINT_Y(Repeater, "position.y", position, AnimClass::Animatable), + FIELD_FLOAT(Repeater, "rotation", rotation, AnimClass::Animatable), + FIELD_POINT_X(Repeater, "scale.x", scale, AnimClass::Animatable), + FIELD_POINT_Y(Repeater, "scale.y", scale, AnimClass::Animatable), + FIELD_FLOAT(Repeater, "startAlpha", startAlpha, AnimClass::Animatable), + FIELD_FLOAT(Repeater, "endAlpha", endAlpha, AnimClass::Animatable), + }; +} + +static std::vector BuildRangeSelectorFields() { + return { + FIELD_FLOAT(RangeSelector, "start", start, AnimClass::Animatable), + FIELD_FLOAT(RangeSelector, "end", end, AnimClass::Animatable), + FIELD_FLOAT(RangeSelector, "offset", offset, AnimClass::Animatable), + FIELD_ENUM(RangeSelector, "unit", unit, AnimClass::Static, SelectorUnit), + FIELD_ENUM(RangeSelector, "shape", shape, AnimClass::Static, SelectorShape), + FIELD_FLOAT(RangeSelector, "easeIn", easeIn, AnimClass::Animatable), + FIELD_FLOAT(RangeSelector, "easeOut", easeOut, AnimClass::Animatable), + FIELD_ENUM(RangeSelector, "mode", mode, AnimClass::Static, SelectorMode), + FIELD_FLOAT(RangeSelector, "weight", weight, AnimClass::Animatable), + FIELD_BOOL(RangeSelector, "randomOrder", randomOrder, AnimClass::Static), + FIELD_INT(RangeSelector, "randomSeed", randomSeed, AnimClass::Static), + }; +} + +static std::vector BuildSolidColorFields() { + return { + FIELD_COLOR(SolidColor, "color", color, AnimClass::Animatable), + }; +} + +static std::vector BuildLinearGradientFields() { + return { + FIELD_POINT_X(LinearGradient, "startPoint.x", startPoint, AnimClass::Animatable), + FIELD_POINT_Y(LinearGradient, "startPoint.y", startPoint, AnimClass::Animatable), + FIELD_POINT_X(LinearGradient, "endPoint.x", endPoint, AnimClass::Animatable), + FIELD_POINT_Y(LinearGradient, "endPoint.y", endPoint, AnimClass::Animatable), + FIELD_BOOL(LinearGradient, "fitsToGeometry", fitsToGeometry, AnimClass::Static), + }; +} + +static std::vector BuildRadialGradientFields() { + return { + FIELD_POINT_X(RadialGradient, "center.x", center, AnimClass::Animatable), + FIELD_POINT_Y(RadialGradient, "center.y", center, AnimClass::Animatable), + FIELD_FLOAT(RadialGradient, "radius", radius, AnimClass::Animatable), + FIELD_BOOL(RadialGradient, "fitsToGeometry", fitsToGeometry, AnimClass::Static), + }; +} + +static std::vector BuildConicGradientFields() { + return { + FIELD_POINT_X(ConicGradient, "center.x", center, AnimClass::Animatable), + FIELD_POINT_Y(ConicGradient, "center.y", center, AnimClass::Animatable), + FIELD_FLOAT(ConicGradient, "startAngle", startAngle, AnimClass::Animatable), + FIELD_FLOAT(ConicGradient, "endAngle", endAngle, AnimClass::Animatable), + FIELD_BOOL(ConicGradient, "fitsToGeometry", fitsToGeometry, AnimClass::Static), + }; +} + +static std::vector BuildDiamondGradientFields() { + return { + FIELD_POINT_X(DiamondGradient, "center.x", center, AnimClass::Animatable), + FIELD_POINT_Y(DiamondGradient, "center.y", center, AnimClass::Animatable), + FIELD_FLOAT(DiamondGradient, "radius", radius, AnimClass::Animatable), + FIELD_BOOL(DiamondGradient, "fitsToGeometry", fitsToGeometry, AnimClass::Static), + }; +} + +static std::vector BuildColorStopFields() { + return { + FIELD_FLOAT(ColorStop, "offset", offset, AnimClass::Animatable), + FIELD_COLOR(ColorStop, "color", color, AnimClass::Animatable), + }; +} + +static std::vector BuildImagePatternFields() { + return { + FIELD_ENUM(ImagePattern, "tileModeX", tileModeX, AnimClass::Static, TileMode), + FIELD_ENUM(ImagePattern, "tileModeY", tileModeY, AnimClass::Static, TileMode), + FIELD_ENUM(ImagePattern, "filterMode", filterMode, AnimClass::Static, FilterMode), + FIELD_ENUM(ImagePattern, "mipmapMode", mipmapMode, AnimClass::Static, MipmapMode), + FIELD_ENUM(ImagePattern, "scaleMode", scaleMode, AnimClass::Static, ScaleMode), + }; +} + +static std::vector BuildDropShadowStyleFields() { + return { + FIELD_ENUM(DropShadowStyle, "blendMode", blendMode, AnimClass::Static, BlendMode), + FIELD_BOOL(DropShadowStyle, "excludeChildEffects", excludeChildEffects, AnimClass::Static), + FIELD_FLOAT(DropShadowStyle, "offsetX", offsetX, AnimClass::Animatable), + FIELD_FLOAT(DropShadowStyle, "offsetY", offsetY, AnimClass::Animatable), + FIELD_FLOAT(DropShadowStyle, "blurX", blurX, AnimClass::Animatable), + FIELD_FLOAT(DropShadowStyle, "blurY", blurY, AnimClass::Animatable), + FIELD_COLOR(DropShadowStyle, "color", color, AnimClass::Animatable), + FIELD_BOOL(DropShadowStyle, "showBehindLayer", showBehindLayer, AnimClass::Animatable), + }; +} + +static std::vector BuildInnerShadowStyleFields() { + return { + FIELD_ENUM(InnerShadowStyle, "blendMode", blendMode, AnimClass::Static, BlendMode), + FIELD_BOOL(InnerShadowStyle, "excludeChildEffects", excludeChildEffects, AnimClass::Static), + FIELD_FLOAT(InnerShadowStyle, "offsetX", offsetX, AnimClass::Animatable), + FIELD_FLOAT(InnerShadowStyle, "offsetY", offsetY, AnimClass::Animatable), + FIELD_FLOAT(InnerShadowStyle, "blurX", blurX, AnimClass::Animatable), + FIELD_FLOAT(InnerShadowStyle, "blurY", blurY, AnimClass::Animatable), + FIELD_COLOR(InnerShadowStyle, "color", color, AnimClass::Animatable), + }; +} + +static std::vector BuildBackgroundBlurStyleFields() { + return { + FIELD_ENUM(BackgroundBlurStyle, "blendMode", blendMode, AnimClass::Static, BlendMode), + FIELD_BOOL(BackgroundBlurStyle, "excludeChildEffects", excludeChildEffects, + AnimClass::Static), + FIELD_FLOAT(BackgroundBlurStyle, "blurX", blurX, AnimClass::Animatable), + FIELD_FLOAT(BackgroundBlurStyle, "blurY", blurY, AnimClass::Animatable), + FIELD_ENUM(BackgroundBlurStyle, "tileMode", tileMode, AnimClass::Static, TileMode), + }; +} + +static std::vector BuildBlurFilterFields() { + return { + FIELD_FLOAT(BlurFilter, "blurX", blurX, AnimClass::Animatable), + FIELD_FLOAT(BlurFilter, "blurY", blurY, AnimClass::Animatable), + FIELD_ENUM(BlurFilter, "tileMode", tileMode, AnimClass::Static, TileMode), + }; +} + +static std::vector BuildDropShadowFilterFields() { + return { + FIELD_FLOAT(DropShadowFilter, "offsetX", offsetX, AnimClass::Animatable), + FIELD_FLOAT(DropShadowFilter, "offsetY", offsetY, AnimClass::Animatable), + FIELD_FLOAT(DropShadowFilter, "blurX", blurX, AnimClass::Animatable), + FIELD_FLOAT(DropShadowFilter, "blurY", blurY, AnimClass::Animatable), + FIELD_COLOR(DropShadowFilter, "color", color, AnimClass::Animatable), + FIELD_BOOL(DropShadowFilter, "shadowOnly", shadowOnly, AnimClass::Animatable), + }; +} + +static std::vector BuildInnerShadowFilterFields() { + return { + FIELD_FLOAT(InnerShadowFilter, "offsetX", offsetX, AnimClass::Animatable), + FIELD_FLOAT(InnerShadowFilter, "offsetY", offsetY, AnimClass::Animatable), + FIELD_FLOAT(InnerShadowFilter, "blurX", blurX, AnimClass::Animatable), + FIELD_FLOAT(InnerShadowFilter, "blurY", blurY, AnimClass::Animatable), + FIELD_COLOR(InnerShadowFilter, "color", color, AnimClass::Animatable), + FIELD_BOOL(InnerShadowFilter, "shadowOnly", shadowOnly, AnimClass::Animatable), + }; +} + +static std::vector BuildBlendFilterFields() { + return { + FIELD_COLOR(BlendFilter, "color", color, AnimClass::Animatable), + FIELD_ENUM(BlendFilter, "blendMode", blendMode, AnimClass::Static, BlendMode), + }; +} + +const std::vector& NodeFieldsFor(NodeType type) { + static const std::vector empty = {}; + // Each table is built once on first use. Static locals keep the member-pointer-generated access + // functions and channel rows alive for the process lifetime. + switch (type) { + case NodeType::Layer: { + static const std::vector table = BuildLayerFields(); + return table; + } + case NodeType::Rectangle: { + static const std::vector table = BuildRectangleFields(); + return table; + } + case NodeType::Ellipse: { + static const std::vector table = BuildEllipseFields(); + return table; + } + case NodeType::Polystar: { + static const std::vector table = BuildPolystarFields(); + return table; + } + case NodeType::Path: { + static const std::vector table = BuildPathFields(); + return table; + } + case NodeType::Text: { + static const std::vector table = BuildTextFields(); + return table; + } + case NodeType::Fill: { + static const std::vector table = BuildFillFields(); + return table; + } + case NodeType::Stroke: { + static const std::vector table = BuildStrokeFields(); + return table; + } + case NodeType::TrimPath: { + static const std::vector table = BuildTrimPathFields(); + return table; + } + case NodeType::RoundCorner: { + static const std::vector table = BuildRoundCornerFields(); + return table; + } + case NodeType::MergePath: { + static const std::vector table = BuildMergePathFields(); + return table; + } + case NodeType::TextModifier: { + static const std::vector table = BuildTextModifierFields(); + return table; + } + case NodeType::TextPath: { + static const std::vector table = BuildTextPathFields(); + return table; + } + case NodeType::TextBox: { + static const std::vector table = BuildTextBoxFields(); + return table; + } + case NodeType::Group: { + static const std::vector table = BuildGroupFields(); + return table; + } + case NodeType::Repeater: { + static const std::vector table = BuildRepeaterFields(); + return table; + } + case NodeType::RangeSelector: { + static const std::vector table = BuildRangeSelectorFields(); + return table; + } + case NodeType::SolidColor: { + static const std::vector table = BuildSolidColorFields(); + return table; + } + case NodeType::LinearGradient: { + static const std::vector table = BuildLinearGradientFields(); + return table; + } + case NodeType::RadialGradient: { + static const std::vector table = BuildRadialGradientFields(); + return table; + } + case NodeType::ConicGradient: { + static const std::vector table = BuildConicGradientFields(); + return table; + } + case NodeType::DiamondGradient: { + static const std::vector table = BuildDiamondGradientFields(); + return table; + } + case NodeType::ColorStop: { + static const std::vector table = BuildColorStopFields(); + return table; + } + case NodeType::ImagePattern: { + static const std::vector table = BuildImagePatternFields(); + return table; + } + case NodeType::DropShadowStyle: { + static const std::vector table = BuildDropShadowStyleFields(); + return table; + } + case NodeType::InnerShadowStyle: { + static const std::vector table = BuildInnerShadowStyleFields(); + return table; + } + case NodeType::BackgroundBlurStyle: { + static const std::vector table = BuildBackgroundBlurStyleFields(); + return table; + } + case NodeType::BlurFilter: { + static const std::vector table = BuildBlurFilterFields(); + return table; + } + case NodeType::DropShadowFilter: { + static const std::vector table = BuildDropShadowFilterFields(); + return table; + } + case NodeType::InnerShadowFilter: { + static const std::vector table = BuildInnerShadowFilterFields(); + return table; + } + case NodeType::BlendFilter: { + static const std::vector table = BuildBlendFilterFields(); + return table; + } + default: + return empty; + } +} + +static const NodeFieldDef* FindField(NodeType type, const std::string& channel) { + const auto& table = NodeFieldsFor(type); + for (const auto& field : table) { + if (channel == field.channel) { + return &field; + } + } + return nullptr; +} + +bool GetNodeChannel(const Node* node, const std::string& channel, KeyValue* out) { + if (node == nullptr || out == nullptr) { + return false; + } + const auto* field = FindField(node->nodeType(), channel); + if (field == nullptr) { + return false; + } + // The read path never mutates the node; const_cast is safe because access only writes when setIn + // is non-null, which it is not here. + return field->access(const_cast(node), out, nullptr); +} + +bool SetNodeChannel(Node* node, const std::string& channel, const KeyValue& value) { + if (node == nullptr) { + return false; + } + const auto* field = FindField(node->nodeType(), channel); + if (field == nullptr || !field->access(node, nullptr, &value)) { + LOGE("SetNodeChannel: unhandled channel '%s' or value type mismatch for node type %d.", + channel.c_str(), static_cast(node->nodeType())); + return false; + } + return true; +} + +bool IsAnimatableChannel(NodeType type, const std::string& channel) { + const auto* field = FindField(type, channel); + return field != nullptr && field->animClass == AnimClass::Animatable; +} + +} // namespace pagx diff --git a/src/pagx/PAGXNodeChannel.h b/src/pagx/PAGXNodeChannel.h new file mode 100644 index 0000000000..35615d224a --- /dev/null +++ b/src/pagx/PAGXNodeChannel.h @@ -0,0 +1,82 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include "pagx/nodes/Channel.h" +#include "pagx/nodes/Node.h" + +namespace pagx { + +/** + * Animation class of a field channel, controlling whether the channel can be driven by animation. + * - Animatable: a layout-output or pure render property that has a lightweight runtime writer; it + * can be both reflected (read/write on the document node) and animated. + * - LayoutInput: an auto-layout constraint (padding, alignment, container width/height, ...). It + * can be reflected but must NOT be animated, since changing it requires re-running layout. + * - Static: reflectable but neither a layout input nor animatable. + */ +enum class AnimClass { Animatable, LayoutInput, Static }; + +// Reads or writes one node field. Exactly one of getOut / setIn is non-null: getOut for a read +// (copy field into *getOut), setIn for a write (validate and copy into the field). Returns false on +// a type mismatch or invalid enum string. +using NodeAccessFn = bool (*)(Node* node, KeyValue* getOut, const KeyValue* setIn); + +/** + * One reflective field of a node type, addressed by channel name (the same attribute name used in + * PAGX XML, e.g. "alpha", "position.x", "blendMode"). + */ +struct NodeFieldDef { + const char* channel; + AnimClass animClass; + NodeAccessFn access; +}; + +/** + * Returns the reflective field table for the given node type, or an empty table if the type has no + * reflectable scalar fields. The table is the single source of truth for channel names and their + * animation class, consumed by node reflection (GetNodeChannel/SetNodeChannel), animation validity + * checks (IsAnimatableChannel), and — indirectly, via a consistency test — the renderer writer + * table. + */ +const std::vector& NodeFieldsFor(NodeType type); + +/** + * Reads the value of the given channel on the node into out. Returns true on success, false if the + * channel is unknown for the node type, the field type cannot be represented as a KeyValue, or an + * optional field is unset. + */ +bool GetNodeChannel(const Node* node, const std::string& channel, KeyValue* out); + +/** + * Writes value into the node field identified by channel. Returns true on success, false if the + * channel is unknown for the node type, the KeyValue type does not match the field, or an enum + * string is invalid. The node is the source of truth; callers refresh any live scene separately. + */ +bool SetNodeChannel(Node* node, const std::string& channel, const KeyValue& value); + +/** + * Returns true if the given channel exists on the node type and is classified as Animatable, i.e. + * it is valid for an animation channel to drive it. + */ +bool IsAnimatableChannel(NodeType type, const std::string& channel); + +} // namespace pagx diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index 2c5fb92458..af3c71a56b 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -35,6 +35,7 @@ #include "pagx/PAGXDocument.h" #include "pagx/PAGXExporter.h" #include "pagx/PAGXImporter.h" +#include "pagx/PAGXNodeChannel.h" #include "pagx/PAGXOptimizer.h" #include "pagx/SVGExporter.h" #include "pagx/SVGImporter.h" @@ -8121,6 +8122,123 @@ PAGX_TEST(PAGXTest, ExportNoiseFilterAnimation) { auto key = "PAGXTest/NoiseFilterAnimation/frame_" + std::to_string(i); EXPECT_TRUE(Baseline::Compare(surface, key)); } +/** + * Test case: GetNodeChannel/SetNodeChannel round-trip scalar fields across node types. + */ +PAGX_TEST(PAGXTest, NodeChannelScalarRoundTrip) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + doc->layers.push_back(layer); + + EXPECT_TRUE(pagx::SetNodeChannel(layer, "alpha", pagx::KeyValue(0.4f))); + EXPECT_FLOAT_EQ(layer->alpha, 0.4f); + pagx::KeyValue out; + EXPECT_TRUE(pagx::GetNodeChannel(layer, "alpha", &out)); + EXPECT_FLOAT_EQ(std::get(out), 0.4f); + + EXPECT_TRUE(pagx::SetNodeChannel(layer, "visible", pagx::KeyValue(false))); + EXPECT_FALSE(layer->visible); + + // LayoutNode shared field via the derived type. + EXPECT_TRUE(pagx::SetNodeChannel(layer, "width", pagx::KeyValue(120.0f))); + EXPECT_FLOAT_EQ(layer->width, 120.0f); + + auto rect = doc->makeNode(); + EXPECT_TRUE(pagx::SetNodeChannel(rect, "roundness", pagx::KeyValue(8.0f))); + EXPECT_FLOAT_EQ(rect->roundness, 8.0f); + + auto selector = doc->makeNode(); + EXPECT_TRUE(pagx::SetNodeChannel(selector, "randomSeed", pagx::KeyValue(7))); + EXPECT_EQ(selector->randomSeed, 7); +} + +/** + * Test case: enum channels use the string enum name; invalid strings and wrong types are rejected. + */ +PAGX_TEST(PAGXTest, NodeChannelEnumRoundTrip) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + + EXPECT_TRUE(pagx::SetNodeChannel(layer, "blendMode", pagx::KeyValue(std::string("multiply")))); + EXPECT_EQ(layer->blendMode, pagx::BlendMode::Multiply); + pagx::KeyValue out; + EXPECT_TRUE(pagx::GetNodeChannel(layer, "blendMode", &out)); + EXPECT_EQ(std::get(out), "multiply"); + + EXPECT_FALSE(pagx::SetNodeChannel(layer, "blendMode", pagx::KeyValue(std::string("notamode")))); + EXPECT_EQ(layer->blendMode, pagx::BlendMode::Multiply); + EXPECT_FALSE(pagx::SetNodeChannel(layer, "blendMode", pagx::KeyValue(1.0f))); +} + +/** + * Test case: Point/Size/Padding fields are addressed by suffixed channels. + */ +PAGX_TEST(PAGXTest, NodeChannelCompositeSuffixes) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto rect = doc->makeNode(); + EXPECT_TRUE(pagx::SetNodeChannel(rect, "position.x", pagx::KeyValue(10.0f))); + EXPECT_TRUE(pagx::SetNodeChannel(rect, "position.y", pagx::KeyValue(20.0f))); + EXPECT_TRUE(pagx::SetNodeChannel(rect, "size.width", pagx::KeyValue(30.0f))); + EXPECT_TRUE(pagx::SetNodeChannel(rect, "size.height", pagx::KeyValue(40.0f))); + EXPECT_FLOAT_EQ(rect->position.x, 10.0f); + EXPECT_FLOAT_EQ(rect->position.y, 20.0f); + EXPECT_FLOAT_EQ(rect->size.width, 30.0f); + EXPECT_FLOAT_EQ(rect->size.height, 40.0f); + + auto group = doc->makeNode(); + EXPECT_TRUE(pagx::SetNodeChannel(group, "padding.left", pagx::KeyValue(5.0f))); + EXPECT_TRUE(pagx::SetNodeChannel(group, "padding.bottom", pagx::KeyValue(6.0f))); + EXPECT_FLOAT_EQ(group->padding.left, 5.0f); + EXPECT_FLOAT_EQ(group->padding.bottom, 6.0f); +} + +/** + * Test case: Color channels round-trip; id-based lookup feeds SetNodeChannel. + */ +PAGX_TEST(PAGXTest, NodeChannelColorAndIdLookup) { + auto doc = pagx::PAGXDocument::Make(100, 100); + doc->makeNode("fillColor"); + + auto* solid = doc->findNode("fillColor"); + ASSERT_TRUE(solid != nullptr); + pagx::Color green = {0.0f, 1.0f, 0.0f, 1.0f}; + EXPECT_TRUE(pagx::SetNodeChannel(solid, "color", pagx::KeyValue(green))); + EXPECT_EQ(solid->color, green); + pagx::KeyValue out; + EXPECT_TRUE(pagx::GetNodeChannel(solid, "color", &out)); + EXPECT_EQ(std::get(out), green); +} + +/** + * Test case: unknown channel, type mismatch, and null node return false. + */ +PAGX_TEST(PAGXTest, NodeChannelRejectsUnsupported) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + + EXPECT_FALSE(pagx::SetNodeChannel(layer, "nosuchchannel", pagx::KeyValue(1.0f))); + pagx::KeyValue out; + EXPECT_FALSE(pagx::GetNodeChannel(layer, "nosuchchannel", &out)); + EXPECT_FALSE(pagx::SetNodeChannel(layer, "alpha", pagx::KeyValue(std::string("x")))); + EXPECT_FALSE(pagx::SetNodeChannel(nullptr, "alpha", pagx::KeyValue(1.0f))); +} + +/** + * Test case: IsAnimatableChannel reflects the field's animation class. Render outputs are + * animatable; auto-layout inputs (width, padding) and the layer name are not. + */ +PAGX_TEST(PAGXTest, NodeChannelAnimatableClass) { + EXPECT_TRUE(pagx::IsAnimatableChannel(pagx::NodeType::Layer, "alpha")); + EXPECT_TRUE(pagx::IsAnimatableChannel(pagx::NodeType::Layer, "x")); + EXPECT_FALSE(pagx::IsAnimatableChannel(pagx::NodeType::Layer, "width")); + EXPECT_FALSE(pagx::IsAnimatableChannel(pagx::NodeType::Layer, "padding.left")); + EXPECT_FALSE(pagx::IsAnimatableChannel(pagx::NodeType::Layer, "name")); + + // Geometry outputs are animatable. + EXPECT_TRUE(pagx::IsAnimatableChannel(pagx::NodeType::Rectangle, "size.width")); + EXPECT_TRUE(pagx::IsAnimatableChannel(pagx::NodeType::Polystar, "outerRadius")); + // Unknown channel is not animatable. + EXPECT_FALSE(pagx::IsAnimatableChannel(pagx::NodeType::Rectangle, "nope")); } } // namespace pag From 7679bfa15b4f51591f0bccf1c0a6078bd8ac5225 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Thu, 11 Jun 2026 18:22:48 +0800 Subject: [PATCH 02/39] Add matrix keyframe animation with TRS interpolation and geometry size channels via a subclassed runtime target. (cherry picked from commit b2f0fe1f4b1565bc818548fa6184fcc2f3de7d81) --- include/pagx/nodes/Channel.h | 4 +- src/pagx/PAGXExporter.cpp | 8 + src/pagx/PAGXImporter.cpp | 11 ++ src/pagx/animation/Channel.cpp | 6 + src/pagx/runtime/KeyframeEvaluator.h | 72 +++++++++ src/pagx/runtime/MixUtils.h | 58 +++++++ src/renderer/LayerBuilder.cpp | 228 ++++++++++++++++++++++++--- src/renderer/LayerBuilder.h | 47 +++++- test/src/PAGXTest.cpp | 92 ++++++++++- 9 files changed, 486 insertions(+), 40 deletions(-) diff --git a/include/pagx/nodes/Channel.h b/include/pagx/nodes/Channel.h index 91f3a7461f..ad0e65a8c3 100644 --- a/include/pagx/nodes/Channel.h +++ b/include/pagx/nodes/Channel.h @@ -24,6 +24,7 @@ #include #include "pagx/nodes/Keyframe.h" #include "pagx/nodes/Node.h" +#include "pagx/types/Matrix.h" namespace pagx { @@ -32,7 +33,7 @@ namespace pagx { * ordered to match ChannelValueType, so the active alternative index equals the corresponding * ChannelValueType value. */ -using KeyValue = std::variant; +using KeyValue = std::variant; /** * Discriminator for the value type carried by a Channel's keyframes. Aligned with the order of @@ -45,6 +46,7 @@ enum class ChannelValueType : uint8_t { String = 3, ImageRef = 4, Color = 5, + Matrix = 6, }; /** diff --git a/src/pagx/PAGXExporter.cpp b/src/pagx/PAGXExporter.cpp index f39ade30f9..13fbdc2c28 100644 --- a/src/pagx/PAGXExporter.cpp +++ b/src/pagx/PAGXExporter.cpp @@ -200,6 +200,11 @@ std::string KeyframeValueToString(const Color& value) { return ColorToHexString(value, value.alpha < 1.0f); } +template <> +std::string KeyframeValueToString(const Matrix& value) { + return MatrixToString(value); +} + template static void WriteTypedChannel(XMLBuilder& xml, const TypedChannel* channel, const char* typeName) { @@ -245,6 +250,9 @@ static void WriteChannel(XMLBuilder& xml, const Channel* channel) { case ChannelValueType::Color: WriteTypedChannel(xml, static_cast*>(channel), "color"); break; + case ChannelValueType::Matrix: + WriteTypedChannel(xml, static_cast*>(channel), "matrix"); + break; } } diff --git a/src/pagx/PAGXImporter.cpp b/src/pagx/PAGXImporter.cpp index 512dcc02aa..0ea268de7e 100644 --- a/src/pagx/PAGXImporter.cpp +++ b/src/pagx/PAGXImporter.cpp @@ -1620,6 +1620,13 @@ Color ParseTypedValue(const std::string& value, PAGXDocument* doc, const return color; } +template <> +Matrix ParseTypedValue(const std::string& value, PAGXDocument*, const DOMNode*) { + // MatrixFromString accepts the same "a,b,c,d,tx,ty" form used for the matrix attribute; an + // unparseable value yields the identity matrix. + return MatrixFromString(value); +} + template static void ParseKeyframes(const DOMNode* channelNode, TypedChannel* channel, PAGXDocument* doc) { @@ -1720,6 +1727,10 @@ static Channel* ParseChannel(const DOMNode* node, PAGXDocument* doc) { auto ch = makeNodeFromXML>(node, doc); ParseKeyframes(node, ch, doc); result = ch; + } else if (type == "matrix") { + auto ch = makeNodeFromXML>(node, doc); + ParseKeyframes(node, ch, doc); + result = ch; } else { ReportError(doc, node, "Invalid Channel type '" + type + "'."); } diff --git a/src/pagx/animation/Channel.cpp b/src/pagx/animation/Channel.cpp index 98a67bee7c..e863a7a8ba 100644 --- a/src/pagx/animation/Channel.cpp +++ b/src/pagx/animation/Channel.cpp @@ -65,6 +65,11 @@ ChannelValueType TypedChannel::valueType() const { return ChannelValueType::Color; } +template <> +ChannelValueType TypedChannel::valueType() const { + return ChannelValueType::Matrix; +} + // Explicit instantiations: must cover every alternative of KeyValue. template class TypedChannel; template class TypedChannel; @@ -72,5 +77,6 @@ template class TypedChannel; template class TypedChannel; template class TypedChannel; template class TypedChannel; +template class TypedChannel; } // namespace pagx diff --git a/src/pagx/runtime/KeyframeEvaluator.h b/src/pagx/runtime/KeyframeEvaluator.h index 3c0ec4a2f8..4da2b48eb3 100644 --- a/src/pagx/runtime/KeyframeEvaluator.h +++ b/src/pagx/runtime/KeyframeEvaluator.h @@ -19,10 +19,12 @@ #pragma once #include +#include #include #include "pagx/nodes/Keyframe.h" #include "pagx/runtime/BezierEasing.h" #include "pagx/types/Color.h" +#include "pagx/types/Matrix.h" #include "pagx/utils/ColorSpaceUtils.h" namespace pagx { @@ -65,6 +67,76 @@ inline ImageRef LerpKeyframeValue(const ImageRef& a, const ImageRef& / return a; } +// Decomposed form of a 2D affine matrix used for interpolation. Interpolating these components +// independently (rather than the raw matrix entries) keeps rotation angular and scale uniform so a +// matrix tween follows the natural translate/rotate/scale/skew path instead of shearing through +// intermediate non-orthogonal states. +struct DecomposedMatrix { + float translateX = 0; + float translateY = 0; + float rotation = 0; // radians + float scaleX = 1; + float scaleY = 1; + float skew = 0; // radians, horizontal shear after rotation +}; + +// Decomposes a 2D affine matrix into translate / rotation / scale / skew. Uses the standard QR-like +// decomposition of the [a c; b d] linear part: the first basis column gives rotation and scaleX, +// the shear of the second column gives skew, and the remaining magnitude gives scaleY. +inline DecomposedMatrix DecomposeMatrix(const Matrix& m) { + DecomposedMatrix out = {}; + out.translateX = m.tx; + out.translateY = m.ty; + float scaleX = std::sqrt(m.a * m.a + m.b * m.b); + float rotation = std::atan2(m.b, m.a); + // Remove rotation from the second column to expose shear and scaleY. + float cosR = std::cos(rotation); + float sinR = std::sin(rotation); + float shearedC = cosR * m.c + sinR * m.d; + float shearedD = -sinR * m.c + cosR * m.d; + float scaleY = shearedD; + float skew = scaleY != 0.0f ? (shearedC / scaleY) : 0.0f; + out.rotation = rotation; + out.scaleX = scaleX; + out.scaleY = scaleY; + out.skew = std::atan(skew); + return out; +} + +// Recomposes a 2D affine matrix from decomposed components, inverting DecomposeMatrix. +inline Matrix RecomposeMatrix(const DecomposedMatrix& d) { + float cosR = std::cos(d.rotation); + float sinR = std::sin(d.rotation); + float tanSkew = std::tan(d.skew); + // Linear part = Rotation * Skew * Scale, matching the decomposition order. + float a = cosR * d.scaleX; + float b = sinR * d.scaleX; + float c = (cosR * tanSkew - sinR) * d.scaleY; + float dd = (sinR * tanSkew + cosR) * d.scaleY; + Matrix m = {}; + m.a = a; + m.b = b; + m.c = c; + m.d = dd; + m.tx = d.translateX; + m.ty = d.translateY; + return m; +} + +template <> +inline Matrix LerpKeyframeValue(const Matrix& a, const Matrix& b, double t) { + auto da = DecomposeMatrix(a); + auto db = DecomposeMatrix(b); + DecomposedMatrix mixed = {}; + mixed.translateX = static_cast(da.translateX + (db.translateX - da.translateX) * t); + mixed.translateY = static_cast(da.translateY + (db.translateY - da.translateY) * t); + mixed.rotation = static_cast(da.rotation + (db.rotation - da.rotation) * t); + mixed.scaleX = static_cast(da.scaleX + (db.scaleX - da.scaleX) * t); + mixed.scaleY = static_cast(da.scaleY + (db.scaleY - da.scaleY) * t); + mixed.skew = static_cast(da.skew + (db.skew - da.skew) * t); + return RecomposeMatrix(mixed); +} + // Comparator for std::upper_bound: returns true when framePosition precedes the keyframe's time. // Defined as a named function template because the project forbids lambdas. template diff --git a/src/pagx/runtime/MixUtils.h b/src/pagx/runtime/MixUtils.h index f3b1b88509..59a4a1fd3d 100644 --- a/src/pagx/runtime/MixUtils.h +++ b/src/pagx/runtime/MixUtils.h @@ -18,7 +18,9 @@ #pragma once +#include #include "tgfx/core/Color.h" +#include "tgfx/core/Matrix.h" namespace pagx { @@ -37,4 +39,60 @@ inline tgfx::Color MixTGFXColor(const tgfx::Color& current, const tgfx::Color& t return result; } +// Interpolates two 2D affine matrices by decomposing each into translate / rotation / scale / skew, +// mixing the components, and recomposing. This keeps rotation angular and scale uniform so a matrix +// tween follows the natural transform path instead of shearing through non-orthogonal states. +inline tgfx::Matrix MixTGFXMatrix(const tgfx::Matrix& current, const tgfx::Matrix& target, + float mix) { + float c[9]; + float t[9]; + current.get9(c); + target.get9(t); + // tgfx Matrix get9 layout: [scaleX skewX transX; skewY scaleY transY; 0 0 1]. + float ca = c[0]; // scaleX + float cc = c[1]; // skewX + float ctx = c[2]; + float cb = c[3]; // skewY + float cd = c[4]; // scaleY + float cty = c[5]; + float ta = t[0]; + float tc = t[1]; + float ttx = t[2]; + float tb = t[3]; + float td = t[4]; + float tty = t[5]; + + float cScaleX = std::sqrt(ca * ca + cb * cb); + float cRot = std::atan2(cb, ca); + float cCos = std::cos(cRot); + float cSin = std::sin(cRot); + float cShearC = cCos * cc + cSin * cd; + float cScaleY = -cSin * cc + cCos * cd; + float cSkew = cScaleY != 0.0f ? std::atan(cShearC / cScaleY) : 0.0f; + + float tScaleX = std::sqrt(ta * ta + tb * tb); + float tRot = std::atan2(tb, ta); + float tCos = std::cos(tRot); + float tSin = std::sin(tRot); + float tShearC = tCos * tc + tSin * td; + float tScaleY = -tSin * tc + tCos * td; + float tSkew = tScaleY != 0.0f ? std::atan(tShearC / tScaleY) : 0.0f; + + float mScaleX = MixFloat(cScaleX, tScaleX, mix); + float mScaleY = MixFloat(cScaleY, tScaleY, mix); + float mRot = MixFloat(cRot, tRot, mix); + float mSkew = MixFloat(cSkew, tSkew, mix); + float mTx = MixFloat(ctx, ttx, mix); + float mTy = MixFloat(cty, tty, mix); + + float mCos = std::cos(mRot); + float mSin = std::sin(mRot); + float mTan = std::tan(mSkew); + float ra = mCos * mScaleX; + float rb = mSin * mScaleX; + float rc = (mCos * mTan - mSin) * mScaleY; + float rd = (mSin * mTan + mCos) * mScaleY; + return tgfx::Matrix::MakeAll(ra, rc, mTx, rb, rd, mTy); +} + } // namespace pagx diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index 8850c6044f..f1b85a060c 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -118,6 +118,70 @@ namespace pagx { +// Runtime target for a Layer's tgfx::Layer that keeps the layer transform decomposed into its +// animatable sources (x, y translation and the 2D matrix). The final tgfx matrix is recomposed as +// Translate(x, y) * matrix, mirroring applyLayerAttributes so x/y and matrix animations stack +// correctly instead of overwriting each other's setMatrix. Channels other than the transform fall +// through to the base RuntimeTarget writer table. +struct LayerRuntimeTarget : RuntimeTarget { + float animX = 0; + float animY = 0; + tgfx::Matrix animMatrix = tgfx::Matrix::I(); + + bool apply(const std::string& channel, const KeyValue& value, float mix) override { + if (channel == "x") { + const auto* v = std::get_if(&value); + if (v == nullptr) { + return false; + } + animX = MixFloat(animX, *v, mix); + recompose(); + return true; + } + if (channel == "y") { + const auto* v = std::get_if(&value); + if (v == nullptr) { + return false; + } + animY = MixFloat(animY, *v, mix); + recompose(); + return true; + } + if (channel == "matrix") { + const auto* v = std::get_if(&value); + if (v == nullptr) { + return false; + } + auto target = ToTGFX(*v); + animMatrix = MixTGFXMatrix(animMatrix, target, mix); + recompose(); + return true; + } + return RuntimeTarget::apply(channel, value, mix); + } + + // Seeds the decomposed transform from the node's authored values so the first animated frame + // mixes against the static baseline rather than an identity. + void initTransform(float x, float y, const tgfx::Matrix& matrix) { + animX = x; + animY = y; + animMatrix = matrix; + } + + private: + void recompose() { + auto* layer = static_cast(const_cast(rawObject())); + if (layer == nullptr) { + return; + } + auto result = animMatrix; + if (animX != 0 || animY != 0) { + result = tgfx::Matrix::MakeTrans(animX, animY) * animMatrix; + } + layer->setMatrix(result); + } +}; + // Decode a data URI (e.g., "data:image/png;base64,...") to an Image. static std::shared_ptr ImageFromDataURI(const std::string& dataURI) { auto data = DecodeBase64DataURI(dataURI); @@ -208,11 +272,22 @@ class LayerBuilderContext { } if (layer) { + // Install a transform-aware target so the Layer's x / y / matrix channels share one + // recomposed tgfx matrix. set() preserves any existing object, so install before set(). + auto target = std::unique_ptr(new LayerRuntimeTarget()); + auto* layerTarget = + static_cast(_result.binding.setTarget(node, std::move(target))); // Register layer for mask lookups and animation writers. _result.binding.set(node, layer); bindLayerChannels(node); applyLayerAttributes(node, layer.get()); + // Seed the decomposed transform from the authored values so the first animated frame mixes + // against the static baseline. applyLayerAttributes has already composed them into the layer. + if (layerTarget != nullptr) { + auto layerPos = node->renderPosition(); + layerTarget->initTransform(layerPos.x, layerPos.y, ToTGFX(node->matrix)); + } // Queue mask to be applied in second pass. if (node->mask != nullptr) { @@ -256,36 +331,12 @@ class LayerBuilderContext { static_cast(object)->setBlendMode(ToTGFX(mode)); } - static void WriteLayerX(void* object, const KeyValue& value, float mix) { - auto* v = std::get_if(&value); - if (v == nullptr) { - return; - } - auto* layer = static_cast(object); - auto matrix = layer->matrix(); - auto mixed = MixFloat(matrix.getTranslateX(), *v, mix); - matrix.setTranslateX(mixed); - layer->setMatrix(matrix); - } - - static void WriteLayerY(void* object, const KeyValue& value, float mix) { - auto* v = std::get_if(&value); - if (v == nullptr) { - return; - } - auto* layer = static_cast(object); - auto matrix = layer->matrix(); - auto mixed = MixFloat(matrix.getTranslateY(), *v, mix); - matrix.setTranslateY(mixed); - layer->setMatrix(matrix); - } - + // x / y / matrix are handled by LayerRuntimeTarget::apply (they share one recomposed matrix), so + // they are not registered as plain writers here. void bindLayerChannels(const Layer* node) { _result.binding.setWriter(node, "alpha", WriteLayerAlpha); _result.binding.setWriter(node, "visible", WriteLayerVisible); _result.binding.setWriter(node, "blendMode", WriteLayerBlendMode); - _result.binding.setWriter(node, "x", WriteLayerX); - _result.binding.setWriter(node, "y", WriteLayerY); } std::shared_ptr convertComposition(const Composition* comp) { @@ -418,6 +469,79 @@ class LayerBuilderContext { return result; } + // Templated writer for the common "scalar float channel = setter(Mix(getter, value, mix))" + // pattern, eliminating one near-identical writer per animatable float property. + template + static void WriteMixedFloat(void* object, const KeyValue& value, float mix) { + const auto* v = std::get_if(&value); + if (v == nullptr) { + return; + } + auto* obj = static_cast(object); + (obj->*Setter)(MixFloat((obj->*Getter)(), *v, mix)); + } + + // Templated writer for tgfx Color channels (setter takes const Color&). + template + static void WriteMixedColor(void* object, const KeyValue& value, float mix) { + const auto* v = std::get_if(&value); + if (v == nullptr) { + return; + } + auto* obj = static_cast(object); + (obj->*Setter)(MixTGFXColor((obj->*Getter)(), ToTGFX(*v), mix)); + } + + // Templated writer for a width/height component of a tgfx Size-valued property. WidthAxis selects + // which component the channel drives; the other component is preserved. + template + static void WriteSizeAxis(void* object, const KeyValue& value, float mix) { + const auto* v = std::get_if(&value); + if (v == nullptr) { + return; + } + auto* obj = static_cast(object); + auto size = (obj->*Getter)(); + if (WidthAxis) { + size.width = MixFloat(size.width, *v, mix); + } else { + size.height = MixFloat(size.height, *v, mix); + } + (obj->*Setter)(size); + } + + // Templated writer for an x/y component of a tgfx Point-valued property. + template + static void WritePointAxis(void* object, const KeyValue& value, float mix) { + const auto* v = std::get_if(&value); + if (v == nullptr) { + return; + } + auto* obj = static_cast(object); + auto point = (obj->*Getter)(); + if (XAxis) { + point.x = MixFloat(point.x, *v, mix); + } else { + point.y = MixFloat(point.y, *v, mix); + } + (obj->*Setter)(point); + } + + // Rectangle roundness: the PAGX node carries a single float but tgfx exposes a 4-corner array, so + // this cannot use WriteMixedFloat. Mix against the first corner and apply uniformly. + static void WriteRectangleRoundness(void* object, const KeyValue& value, float mix) { + const auto* v = std::get_if(&value); + if (v == nullptr) { + return; + } + auto* rect = static_cast(object); + float current = rect->roundness()[0]; + rect->setRoundness(MixFloat(current, *v, mix)); + } + std::shared_ptr convertRectangle(const Rectangle* node) { auto rect = tgfx::Rectangle::Make(); rect->setPosition(ToTGFX(node->renderPosition())); @@ -425,6 +549,20 @@ class LayerBuilderContext { rect->setSize({size.width, size.height}); rect->setRoundness(node->roundness); rect->setReversed(node->reversed); + _result.binding.set(node, rect); + _result.binding.setWriter( + node, "size.width", + WriteSizeAxis); + _result.binding.setWriter( + node, "size.height", + WriteSizeAxis); + _result.binding.setWriter(node, "position.x", + WritePointAxis); + _result.binding.setWriter(node, "position.y", + WritePointAxis); + _result.binding.setWriter(node, "roundness", WriteRectangleRoundness); return rect; } @@ -434,6 +572,19 @@ class LayerBuilderContext { auto size = node->renderSize(); ellipse->setSize({size.width, size.height}); ellipse->setReversed(node->reversed); + _result.binding.set(node, ellipse); + _result.binding.setWriter( + node, "size.width", + WriteSizeAxis); + _result.binding.setWriter( + node, "size.height", + WriteSizeAxis); + _result.binding.setWriter( + node, "position.x", + WritePointAxis); + _result.binding.setWriter(node, "position.y", + WritePointAxis); return ellipse; } @@ -452,6 +603,31 @@ class LayerBuilderContext { } else { polystar->setPolystarType(tgfx::PolystarType::Star); } + _result.binding.set(node, polystar); + _result.binding.setWriter(node, "pointCount", + WriteMixedFloat); + _result.binding.setWriter(node, "outerRadius", + WriteMixedFloat); + _result.binding.setWriter(node, "innerRadius", + WriteMixedFloat); + _result.binding.setWriter(node, "outerRoundness", + WriteMixedFloat); + _result.binding.setWriter(node, "innerRoundness", + WriteMixedFloat); + _result.binding.setWriter( + node, "rotation", + WriteMixedFloat); + _result.binding.setWriter(node, "position.x", + WritePointAxis); + _result.binding.setWriter(node, "position.y", + WritePointAxis); return polystar; } diff --git a/src/renderer/LayerBuilder.h b/src/renderer/LayerBuilder.h index ce13943458..8fe725ca81 100644 --- a/src/renderer/LayerBuilder.h +++ b/src/renderer/LayerBuilder.h @@ -48,6 +48,8 @@ struct RuntimeColorStop { using RuntimeWriter = void (*)(void* object, const KeyValue& value, float mix); struct RuntimeTarget { + virtual ~RuntimeTarget() = default; + void setObject(std::shared_ptr object) { this->object = std::move(object); } @@ -57,13 +59,20 @@ struct RuntimeTarget { return std::static_pointer_cast(object); } + // Raw bound object pointer for reverse lookup, without changing ownership. + const void* rawObject() const { + return object.get(); + } + void setWriter(const std::string& channel, RuntimeWriter writer) { if (!channel.empty() && writer != nullptr) { writers[channel] = writer; } } - bool apply(const std::string& channel, const KeyValue& value, float mix) const { + // Applies an evaluated channel value. Virtual so a subclass (LayerRuntimeTarget) can intercept + // channels that need shared state across writers (the Layer transform: x / y / matrix). + virtual bool apply(const std::string& channel, const KeyValue& value, float mix) { auto it = writers.find(channel); if (it == writers.end() || object == nullptr) { return false; @@ -72,7 +81,7 @@ struct RuntimeTarget { return true; } - private: + protected: std::shared_ptr object = nullptr; std::unordered_map writers = {}; }; @@ -83,15 +92,14 @@ struct RuntimeBinding { if (node == nullptr || object == nullptr) { return; } - auto& target = targets[node]; - target.setObject(std::move(object)); + ensureTarget(node)->setObject(std::move(object)); } void setWriter(const Node* node, const std::string& channel, RuntimeWriter writer) { if (node == nullptr) { return; } - targets[node].setWriter(channel, std::move(writer)); + ensureTarget(node)->setWriter(channel, std::move(writer)); } template @@ -100,7 +108,7 @@ struct RuntimeBinding { if (it == targets.end()) { return nullptr; } - return it->second.getObject(); + return it->second->getObject(); } bool apply(const Node* node, const std::string& channel, const KeyValue& value, float mix) const { @@ -108,11 +116,34 @@ struct RuntimeBinding { if (it == targets.end()) { return false; } - return it->second.apply(channel, value, mix); + return it->second->apply(channel, value, mix); + } + + // Installs a specific RuntimeTarget subclass for a node (e.g. LayerRuntimeTarget). Replaces any + // existing target for the node. Returns the installed target for further setup. + RuntimeTarget* setTarget(const Node* node, std::unique_ptr target) { + if (node == nullptr || target == nullptr) { + return nullptr; + } + auto* raw = target.get(); + targets[node] = std::move(target); + return raw; } private: - std::unordered_map targets = {}; + // Returns the existing target for the node, creating a plain RuntimeTarget if none exists yet. + RuntimeTarget* ensureTarget(const Node* node) { + auto it = targets.find(node); + if (it != targets.end()) { + return it->second.get(); + } + auto target = std::unique_ptr(new RuntimeTarget()); + auto* raw = target.get(); + targets[node] = std::move(target); + return raw; + } + + std::unordered_map> targets = {}; }; /** diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index af3c71a56b..e04d1f72ae 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -105,6 +105,7 @@ #include "tgfx/layers/layerstyles/DropShadowStyle.h" #include "tgfx/layers/layerstyles/NoiseStyle.h" #include "tgfx/layers/vectors/Gradient.h" +#include "tgfx/layers/vectors/Rectangle.h" #include "tgfx/layers/vectors/SolidColor.h" #include "tgfx/layers/vectors/Text.h" #include "utils/Baseline.h" @@ -5837,13 +5838,11 @@ PAGX_TEST(PAGXTest, ChannelLayerX) { auto& tree = *file->mutableBinding(); auto tgfxLayer = tree.get(layer); - auto matrix = tgfxLayer->matrix(); - matrix.setTranslateX(20.0f); - tgfxLayer->setMatrix(matrix); - + // The layer transform is held as decomposed state on the runtime target, seeded from the node's + // authored x (0 here). apply(0.5) mixes that baseline toward the keyframe value 100. auto timeline = file->getDefaultTimeline(); timeline->apply(0.5f); - EXPECT_FLOAT_EQ(tgfxLayer->matrix().getTranslateX(), 60.0f); // 20 + (100-20)*0.5 + EXPECT_FLOAT_EQ(tgfxLayer->matrix().getTranslateX(), 50.0f); // 0 + (100-0)*0.5 } /** @@ -8241,4 +8240,87 @@ PAGX_TEST(PAGXTest, NodeChannelAnimatableClass) { EXPECT_FALSE(pagx::IsAnimatableChannel(pagx::NodeType::Rectangle, "nope")); } +/** + * Test case: a Rectangle's size.width channel drives the tgfx Rectangle size in place. + */ +PAGX_TEST(PAGXTest, ChannelRectangleSize) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode("L"); + doc->layers.push_back(layer); + auto rect = doc->makeNode("R"); + rect->position = {0, 0}; + rect->size = {40, 40}; + layer->contents.push_back(rect); + auto fill = doc->makeNode(); + auto solid = doc->makeNode(); + solid->color = {1, 0, 0, 1}; + fill->color = solid; + layer->contents.push_back(fill); + + auto anim = doc->makeNode("anim"); + anim->duration = 60; + anim->frameRate = 60; + doc->animations.push_back(anim); + auto* object = doc->makeNode(); + object->target = "R"; + anim->objects.push_back(object); + auto* widthProp = doc->makeNode>(); + widthProp->name = "size.width"; + widthProp->keyframes.push_back({0, 100.0f, pagx::KeyframeInterpolationType::Hold, {}, {}}); + object->channels.push_back(widthProp); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto tgfxRect = scene->mutableBinding()->get(rect); + ASSERT_TRUE(tgfxRect != nullptr); + EXPECT_FLOAT_EQ(tgfxRect->size().width, 40.0f); + + auto timeline = scene->getDefaultTimeline(); + ASSERT_TRUE(timeline != nullptr); + // mix=0.5 from 40 toward 100 = 70; height untouched. + timeline->apply(0.5f); + EXPECT_FLOAT_EQ(tgfxRect->size().width, 70.0f); + EXPECT_FLOAT_EQ(tgfxRect->size().height, 40.0f); +} + +/** + * Test case: a Layer's matrix channel drives the tgfx layer transform via TRS-decomposed mixing, + * and stacks with the layer's authored x/y translation. + */ +PAGX_TEST(PAGXTest, ChannelLayerMatrix) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode("L"); + layer->x = 10; + layer->y = 0; + doc->layers.push_back(layer); + + auto anim = doc->makeNode("anim"); + anim->duration = 60; + anim->frameRate = 60; + doc->animations.push_back(anim); + auto* object = doc->makeNode(); + object->target = "L"; + anim->objects.push_back(object); + auto* matrixProp = doc->makeNode>(); + matrixProp->name = "matrix"; + // Target matrix is a pure 2x scale; baseline is identity. + pagx::Matrix scaled = pagx::Matrix::Scale(2.0f, 2.0f); + matrixProp->keyframes.push_back({0, scaled, pagx::KeyframeInterpolationType::Hold, {}, {}}); + object->channels.push_back(matrixProp); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto tgfxLayer = scene->mutableBinding()->get(layer); + ASSERT_TRUE(tgfxLayer != nullptr); + + auto timeline = scene->getDefaultTimeline(); + ASSERT_TRUE(timeline != nullptr); + // mix=1: matrix becomes 2x scale, composed with the authored x translation (10). + timeline->apply(1.0f); + auto m = tgfxLayer->matrix(); + EXPECT_FLOAT_EQ(m.getScaleX(), 2.0f); + EXPECT_FLOAT_EQ(m.getScaleY(), 2.0f); + EXPECT_FLOAT_EQ(m.getTranslateX(), 10.0f); +} + } // namespace pag From 40d0a7986afbd055028d0f0799755144d51a3c61 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Thu, 11 Jun 2026 18:37:41 +0800 Subject: [PATCH 03/39] Register runtime writers for all animatable render channels and add a registry consistency test. (cherry picked from commit c453d7ee78f6278ef060322ec97eccf73eaa154c) --- src/pagx/PAGXNodeChannel.cpp | 4 +- src/renderer/LayerBuilder.cpp | 279 ++++++++++++++++++++++++++++++++-- src/renderer/LayerBuilder.h | 17 +++ test/src/PAGXTest.cpp | 64 ++++++++ 4 files changed, 349 insertions(+), 15 deletions(-) diff --git a/src/pagx/PAGXNodeChannel.cpp b/src/pagx/PAGXNodeChannel.cpp index 7ccd80d631..92464ca2b2 100644 --- a/src/pagx/PAGXNodeChannel.cpp +++ b/src/pagx/PAGXNodeChannel.cpp @@ -384,8 +384,8 @@ static std::vector BuildPathFields() { static std::vector BuildTextFields() { std::vector table = { FIELD_STRING(Text, "text", text, AnimClass::LayoutInput), - FIELD_POINT_X(Text, "position.x", position, AnimClass::Animatable), - FIELD_POINT_Y(Text, "position.y", position, AnimClass::Animatable), + FIELD_POINT_X(Text, "x", position, AnimClass::Animatable), + FIELD_POINT_Y(Text, "y", position, AnimClass::Animatable), FIELD_STRING(Text, "fontFamily", fontFamily, AnimClass::LayoutInput), FIELD_STRING(Text, "fontStyle", fontStyle, AnimClass::LayoutInput), FIELD_FLOAT(Text, "fontSize", fontSize, AnimClass::LayoutInput), diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index f1b85a060c..2c0b768b2e 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -160,6 +160,13 @@ struct LayerRuntimeTarget : RuntimeTarget { return RuntimeTarget::apply(channel, value, mix); } + bool hasWriter(const std::string& channel) const override { + if (channel == "x" || channel == "y" || channel == "matrix") { + return true; + } + return RuntimeTarget::hasWriter(channel); + } + // Seeds the decomposed transform from the node's authored values so the first animated frame // mixes against the static baseline rather than an identity. void initTransform(float x, float y, const tgfx::Matrix& matrix) { @@ -471,7 +478,7 @@ class LayerBuilderContext { // Templated writer for the common "scalar float channel = setter(Mix(getter, value, mix))" // pattern, eliminating one near-identical writer per animatable float property. - template + template static void WriteMixedFloat(void* object, const KeyValue& value, float mix) { const auto* v = std::get_if(&value); if (v == nullptr) { @@ -482,8 +489,7 @@ class LayerBuilderContext { } // Templated writer for tgfx Color channels (setter takes const Color&). - template + template static void WriteMixedColor(void* object, const KeyValue& value, float mix) { const auto* v = std::get_if(&value); if (v == nullptr) { @@ -495,8 +501,7 @@ class LayerBuilderContext { // Templated writer for a width/height component of a tgfx Size-valued property. WidthAxis selects // which component the channel drives; the other component is preserved. - template + template static void WriteSizeAxis(void* object, const KeyValue& value, float mix) { const auto* v = std::get_if(&value); if (v == nullptr) { @@ -513,8 +518,7 @@ class LayerBuilderContext { } // Templated writer for an x/y component of a tgfx Point-valued property. - template + template static void WritePointAxis(void* object, const KeyValue& value, float mix) { const auto* v = std::get_if(&value); if (v == nullptr) { @@ -719,6 +723,9 @@ class LayerBuilderContext { if (node->placement != LayerPlacement::Background) { fill->setPlacement(ToTGFX(node->placement)); } + _result.binding.setWriter( + node, "alpha", + WriteMixedFloat); } return fill; } @@ -756,6 +763,18 @@ class LayerBuilderContext { if (node->placement != LayerPlacement::Background) { stroke->setPlacement(ToTGFX(node->placement)); } + _result.binding.setWriter(node, "width", + WriteMixedFloat); + _result.binding.setWriter(node, "alpha", + WriteMixedFloat); + _result.binding.setWriter(node, "miterLimit", + WriteMixedFloat); + _result.binding.setWriter(node, "dashOffset", + WriteMixedFloat); return stroke; } @@ -888,37 +907,100 @@ class LayerBuilderContext { std::vector colors; std::vector positions; extractGradientStops(node->colorStops, &colors, &positions); - return applyGradientProperties( + auto result = applyGradientProperties( tgfx::Gradient::MakeLinear(ToTGFX(node->startPoint), ToTGFX(node->endPoint), colors, positions), node, node->colorStops); + if (result != nullptr) { + _result.binding.setWriter( + node, "startPoint.x", + WritePointAxis); + _result.binding.setWriter( + node, "startPoint.y", + WritePointAxis); + _result.binding.setWriter( + node, "endPoint.x", + WritePointAxis); + _result.binding.setWriter( + node, "endPoint.y", + WritePointAxis); + } + return result; } std::shared_ptr convertRadialGradient(const RadialGradient* node) { std::vector colors; std::vector positions; extractGradientStops(node->colorStops, &colors, &positions); - return applyGradientProperties( + auto result = applyGradientProperties( tgfx::Gradient::MakeRadial(ToTGFX(node->center), node->radius, colors, positions), node, node->colorStops); + if (result != nullptr) { + _result.binding.setWriter(node, "center.x", + WritePointAxis); + _result.binding.setWriter(node, "center.y", + WritePointAxis); + _result.binding.setWriter(node, "radius", + WriteMixedFloat); + } + return result; } std::shared_ptr convertConicGradient(const ConicGradient* node) { std::vector colors; std::vector positions; extractGradientStops(node->colorStops, &colors, &positions); - return applyGradientProperties(tgfx::Gradient::MakeConic(ToTGFX(node->center), node->startAngle, - node->endAngle, colors, positions), - node, node->colorStops); + auto result = + applyGradientProperties(tgfx::Gradient::MakeConic(ToTGFX(node->center), node->startAngle, + node->endAngle, colors, positions), + node, node->colorStops); + if (result != nullptr) { + _result.binding.setWriter(node, "center.x", + WritePointAxis); + _result.binding.setWriter(node, "center.y", + WritePointAxis); + _result.binding.setWriter( + node, "startAngle", + WriteMixedFloat); + _result.binding.setWriter(node, "endAngle", + WriteMixedFloat); + } + return result; } std::shared_ptr convertDiamondGradient(const DiamondGradient* node) { std::vector colors; std::vector positions; extractGradientStops(node->colorStops, &colors, &positions); - return applyGradientProperties( + auto result = applyGradientProperties( tgfx::Gradient::MakeDiamond(ToTGFX(node->center), node->radius, colors, positions), node, node->colorStops); + if (result != nullptr) { + _result.binding.setWriter( + node, "center.x", + WritePointAxis); + _result.binding.setWriter( + node, "center.y", + WritePointAxis); + _result.binding.setWriter( + node, "radius", + WriteMixedFloat); + } + return result; } std::shared_ptr convertImagePattern(const ImagePattern* node) { @@ -967,6 +1049,16 @@ class LayerBuilderContext { if (node->type == TrimType::Continuous) { trim->setTrimType(tgfx::TrimPathType::Continuous); } + _result.binding.set(node, trim); + _result.binding.setWriter( + node, "start", + WriteMixedFloat); + _result.binding.setWriter( + node, "end", + WriteMixedFloat); + _result.binding.setWriter( + node, "offset", + WriteMixedFloat); return trim; } @@ -993,6 +1085,10 @@ class LayerBuilderContext { std::shared_ptr convertRoundCorner(const RoundCorner* node) { auto round = tgfx::RoundCorner::Make(); round->setRadius(node->radius); + _result.binding.set(node, round); + _result.binding.setWriter(node, "radius", + WriteMixedFloat); return round; } @@ -1015,9 +1111,77 @@ class LayerBuilderContext { repeater->setScale(ToTGFX(node->scale)); repeater->setStartAlpha(node->startAlpha); repeater->setEndAlpha(node->endAlpha); + _result.binding.set(node, repeater); + _result.binding.setWriter( + node, "copies", + WriteMixedFloat); + _result.binding.setWriter( + node, "offset", + WriteMixedFloat); + _result.binding.setWriter( + node, "rotation", + WriteMixedFloat); + _result.binding.setWriter(node, "startAlpha", + WriteMixedFloat); + _result.binding.setWriter( + node, "endAlpha", + WriteMixedFloat); + _result.binding.setWriter( + node, "anchor.x", + WritePointAxis); + _result.binding.setWriter( + node, "anchor.y", + WritePointAxis); + _result.binding.setWriter(node, "position.x", + WritePointAxis); + _result.binding.setWriter(node, "position.y", + WritePointAxis); + _result.binding.setWriter( + node, "scale.x", + WritePointAxis); + _result.binding.setWriter( + node, "scale.y", + WritePointAxis); return repeater; } + // TextModifier optional paint channels: animating sets the optional to the mixed value, blending + // against the current value when present and against the target alone when unset. + static void WriteTextModifierFillColor(void* object, const KeyValue& value, float mix) { + const auto* v = std::get_if(&value); + if (v == nullptr) { + return; + } + auto* modifier = static_cast(object); + auto target = ToTGFX(*v); + auto current = modifier->fillColor(); + modifier->setFillColor(current.has_value() ? MixTGFXColor(*current, target, mix) : target); + } + + static void WriteTextModifierStrokeColor(void* object, const KeyValue& value, float mix) { + const auto* v = std::get_if(&value); + if (v == nullptr) { + return; + } + auto* modifier = static_cast(object); + auto target = ToTGFX(*v); + auto current = modifier->strokeColor(); + modifier->setStrokeColor(current.has_value() ? MixTGFXColor(*current, target, mix) : target); + } + + static void WriteTextModifierStrokeWidth(void* object, const KeyValue& value, float mix) { + const auto* v = std::get_if(&value); + if (v == nullptr) { + return; + } + auto* modifier = static_cast(object); + auto current = modifier->strokeWidth(); + modifier->setStrokeWidth(current.has_value() ? MixFloat(*current, *v, mix) : *v); + } + std::shared_ptr convertTextModifier(const TextModifier* node) { auto modifier = tgfx::TextModifier::Make(); @@ -1041,6 +1205,41 @@ class LayerBuilderContext { modifier->setStrokeWidth(node->strokeWidth.value()); } + _result.binding.set(node, modifier); + _result.binding.setWriter(node, "rotation", + WriteMixedFloat); + _result.binding.setWriter(node, "skew", + WriteMixedFloat); + _result.binding.setWriter(node, "skewAxis", + WriteMixedFloat); + _result.binding.setWriter(node, "alpha", + WriteMixedFloat); + _result.binding.setWriter(node, "anchor.x", + WritePointAxis); + _result.binding.setWriter(node, "anchor.y", + WritePointAxis); + _result.binding.setWriter(node, "position.x", + WritePointAxis); + _result.binding.setWriter(node, "position.y", + WritePointAxis); + _result.binding.setWriter(node, "scale.x", + WritePointAxis); + _result.binding.setWriter(node, "scale.y", + WritePointAxis); + _result.binding.setWriter(node, "fillColor", WriteTextModifierFillColor); + _result.binding.setWriter(node, "strokeColor", WriteTextModifierStrokeColor); + _result.binding.setWriter(node, "strokeWidth", WriteTextModifierStrokeWidth); + // Convert selectors std::vector> tgfxSelectors; tgfxSelectors.reserve(node->selectors.size()); @@ -1059,6 +1258,26 @@ class LayerBuilderContext { tgfxSelector->setWeight(rangeSelector->weight); tgfxSelector->setRandomOrder(rangeSelector->randomOrder); tgfxSelector->setRandomSeed(static_cast(rangeSelector->randomSeed)); + _result.binding.set(rangeSelector, tgfxSelector); + _result.binding.setWriter(rangeSelector, "start", + WriteMixedFloat); + _result.binding.setWriter(rangeSelector, "end", + WriteMixedFloat); + _result.binding.setWriter(rangeSelector, "offset", + WriteMixedFloat); + _result.binding.setWriter(rangeSelector, "easeIn", + WriteMixedFloat); + _result.binding.setWriter( + rangeSelector, "easeOut", + WriteMixedFloat); + _result.binding.setWriter(rangeSelector, "weight", + WriteMixedFloat); tgfxSelectors.push_back(tgfxSelector); } } @@ -1114,6 +1333,40 @@ class LayerBuilderContext { group->setSkewAxis(node->skewAxis); } + // Register transform channels. Group and TextBox share these; the node pointer is the source + // node passed in (TextBox passes itself), so animation targets resolve to the right binding. + _result.binding.set(node, group); + _result.binding.setWriter(node, "rotation", + WriteMixedFloat); + _result.binding.setWriter(node, "alpha", + WriteMixedFloat); + _result.binding.setWriter( + node, "skew", + WriteMixedFloat); + _result.binding.setWriter(node, "skewAxis", + WriteMixedFloat); + _result.binding.setWriter(node, "anchor.x", + WritePointAxis); + _result.binding.setWriter(node, "anchor.y", + WritePointAxis); + _result.binding.setWriter(node, "position.x", + WritePointAxis); + _result.binding.setWriter(node, "position.y", + WritePointAxis); + _result.binding.setWriter(node, "scale.x", + WritePointAxis); + _result.binding.setWriter(node, "scale.y", + WritePointAxis); + return group; } diff --git a/src/renderer/LayerBuilder.h b/src/renderer/LayerBuilder.h index 8fe725ca81..269404c894 100644 --- a/src/renderer/LayerBuilder.h +++ b/src/renderer/LayerBuilder.h @@ -70,6 +70,13 @@ struct RuntimeTarget { } } + // Returns true if this target can apply the given channel. Virtual so a subclass that intercepts + // channels in apply() (LayerRuntimeTarget's x / y / matrix) reports them as handled even though + // they are not in the writers map. + virtual bool hasWriter(const std::string& channel) const { + return writers.find(channel) != writers.end(); + } + // Applies an evaluated channel value. Virtual so a subclass (LayerRuntimeTarget) can intercept // channels that need shared state across writers (the Layer transform: x / y / matrix). virtual bool apply(const std::string& channel, const KeyValue& value, float mix) { @@ -119,6 +126,16 @@ struct RuntimeBinding { return it->second->apply(channel, value, mix); } + // Returns true if the node has a target that can apply the given channel. Used by tests to verify + // every Animatable channel in the reflection registry has a matching runtime writer. + bool hasWriter(const Node* node, const std::string& channel) const { + auto it = targets.find(node); + if (it == targets.end()) { + return false; + } + return it->second->hasWriter(channel); + } + // Installs a specific RuntimeTarget subclass for a node (e.g. LayerRuntimeTarget). Replaces any // existing target for the node. Returns the installed target for further setup. RuntimeTarget* setTarget(const Node* node, std::unique_ptr target) { diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index e04d1f72ae..276e5eb7ea 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -68,12 +68,15 @@ #include "pagx/nodes/Polystar.h" #include "pagx/nodes/RangeSelector.h" #include "pagx/nodes/Rectangle.h" +#include "pagx/nodes/Repeater.h" +#include "pagx/nodes/RoundCorner.h" #include "pagx/nodes/SolidColor.h" #include "pagx/nodes/Stroke.h" #include "pagx/nodes/Text.h" #include "pagx/nodes/TextBox.h" #include "pagx/nodes/TextModifier.h" #include "pagx/nodes/TextPath.h" +#include "pagx/nodes/TrimPath.h" #include "pagx/svg/SVGPathParser.h" #include "pagx/types/Alignment.h" #include "pagx/types/Arrangement.h" @@ -8323,4 +8326,65 @@ PAGX_TEST(PAGXTest, ChannelLayerMatrix) { EXPECT_FLOAT_EQ(m.getTranslateX(), 10.0f); } +/** + * Test case: every channel the reflection registry marks Animatable for a built node type has a + * matching runtime writer, so animations cannot target a channel that silently does nothing. + */ +PAGX_TEST(PAGXTest, AnimatableChannelsHaveWriters) { + auto doc = pagx::PAGXDocument::Make(200, 200); + + // A vector layer carrying one of each geometry / paint / modifier element. + auto layer = doc->makeNode("L"); + doc->layers.push_back(layer); + + auto rect = doc->makeNode(); + rect->size = {40, 40}; + layer->contents.push_back(rect); + auto ellipse = doc->makeNode(); + ellipse->size = {40, 40}; + layer->contents.push_back(ellipse); + auto polystar = doc->makeNode(); + layer->contents.push_back(polystar); + auto trim = doc->makeNode(); + layer->contents.push_back(trim); + auto roundCorner = doc->makeNode(); + layer->contents.push_back(roundCorner); + auto repeater = doc->makeNode(); + layer->contents.push_back(repeater); + auto fill = doc->makeNode(); + auto solid = doc->makeNode(); + solid->color = {1, 0, 0, 1}; + fill->color = solid; + layer->contents.push_back(fill); + auto stroke = doc->makeNode(); + auto strokeColor = doc->makeNode(); + stroke->color = strokeColor; + layer->contents.push_back(stroke); + + // A drop shadow style and a blur filter on the layer. + auto dropStyle = doc->makeNode(); + layer->styles.push_back(dropStyle); + auto blurFilter = doc->makeNode(); + layer->filters.push_back(blurFilter); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto* binding = scene->mutableBinding(); + ASSERT_TRUE(binding != nullptr); + + // For each built node, every Animatable channel in the registry must have a runtime writer. + pagx::Node* nodes[] = {layer, rect, ellipse, polystar, trim, roundCorner, + repeater, fill, stroke, solid, dropStyle, blurFilter}; + for (auto* node : nodes) { + for (const auto& field : pagx::NodeFieldsFor(node->nodeType())) { + if (field.animClass != pagx::AnimClass::Animatable) { + continue; + } + EXPECT_TRUE(binding->hasWriter(node, field.channel)) + << "node type " << static_cast(node->nodeType()) << " channel '" << field.channel + << "' is Animatable but has no runtime writer"; + } + } +} + } // namespace pag From b0c24dd74ab7a008e63e73f8e88462d5ba899250 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 09:51:06 +0800 Subject: [PATCH 04/39] Split node channel reflection into animatable and layout-requiring queries and refresh scenes via notifyChange. --- include/pagx/PAGComposition.h | 14 + include/pagx/PAGXDocument.h | 23 +- include/pagx/PAGXNodeChannel.h | 88 ++++ include/pagx/nodes/LayoutNode.h | 8 + src/pagx/LayoutNode.cpp | 14 + src/pagx/PAGScene.cpp | 14 +- src/pagx/PAGXChannelTable.h | 68 +++ src/pagx/PAGXDocument.cpp | 50 ++- src/pagx/PAGXNodeChannel.cpp | 626 ++++++++++++++-------------- src/pagx/PAGXNodeChannel.h | 82 ---- src/pagx/runtime/PAGComposition.cpp | 127 ++++++ src/renderer/LayerBuilder.cpp | 134 ++++++ src/renderer/LayerBuilder.h | 43 ++ test/src/PAGXTest.cpp | 435 ++++++++++++++++++- 14 files changed, 1314 insertions(+), 412 deletions(-) create mode 100644 include/pagx/PAGXNodeChannel.h create mode 100644 src/pagx/PAGXChannelTable.h delete mode 100644 src/pagx/PAGXNodeChannel.h diff --git a/include/pagx/PAGComposition.h b/include/pagx/PAGComposition.h index 0afaeec0ec..02947b29e4 100644 --- a/include/pagx/PAGComposition.h +++ b/include/pagx/PAGComposition.h @@ -30,6 +30,7 @@ class PAGTimeline; class PAGScene; class PAGXDocument; class Composition; +class Node; struct RuntimeBinding; /** @@ -100,6 +101,19 @@ class PAGComposition : public PAGLayer { // Returns nullptr if no persistent node owns the layer (internal sub-layer). std::shared_ptr findChildForLayer(const tgfx::Layer* hitLayer); + // Refreshes this composition after edits: reconciles its child layer list (adding, removing, and + // reordering children to match the source layers when the container node is dirty), refreshes the + // content of any dirty leaf layers in place, resets timeline target caches, then recurses into + // child compositions so each refreshes only the nodes in its own binding. Called by + // PAGScene::onNodesChanged. + void refreshNodes(const std::vector& dirtyNodes); + + // Reconciles this composition's runtime children with the given source layer list: reuses + // children whose source layer still maps to a tgfx layer (handles stay valid), builds newly added + // layers into the binding, removes runtime children whose source layer is gone, and reorders the + // parent's tgfx children to match the document order. + void syncChildren(const std::vector& sourceLayers); + // Document used to resolve channel target IDs for timelines spawned by this composition. For a // sealed external composition this is the layer's externalDoc; otherwise the scene's document. PAGXDocument* document = nullptr; diff --git a/include/pagx/PAGXDocument.h b/include/pagx/PAGXDocument.h index ec34e89885..da883dd9df 100644 --- a/include/pagx/PAGXDocument.h +++ b/include/pagx/PAGXDocument.h @@ -175,14 +175,21 @@ class PAGXDocument : public Node { void clearEmbed(); /** - * Performs internal bookkeeping for live PAGScene instances created from this document after the - * given nodes have been mutated. Currently this only prunes expired live-scene references; it - * does not yet rebuild or refresh any rendered content. Runtime rebuild dispatch to live scenes - * is not implemented yet. - * @param dirtyNodes the nodes whose fields were mutated. Pointers must reference nodes still - * owned by this document. Null entries are ignored. Passing an empty list is a no-op. - */ - void notifyChange(const std::vector& dirtyNodes); + * Reflects post-build edits to the given nodes in every live PAGScene created from this document. + * Refreshes each affected node's runtime content in place, preserving existing layer handles. + * Render-only edits (alpha, color, blendMode, transform) are reflected without re-running layout. + * Adding or removing child layers of a node is supported by passing the parent (container) node: + * its child list is reconciled, building newly added layers and removing deleted ones while + * reusing unchanged children. + * @param dirtyNodes the nodes whose fields (or child lists) were mutated. Pointers must reference + * nodes still owned by this document. Null entries are ignored. Passing an empty list is a no-op. + * @param layoutChanged whether any mutated field affects layout (size, constraints, padding, + * fonts, text, geometry) or a child list changed. When true, the full layout pass is re-run before + * refreshing so the edited values take effect; when false, layout is skipped for a cheaper + * render-only refresh. Defaults to true. Callers that mutate via SetNodeChannel can derive this + * from RequiresLayout; structural add/remove must pass true. + */ + void notifyChange(const std::vector& dirtyNodes, bool layoutChanged = true); NodeType nodeType() const override { return NodeType::Document; diff --git a/include/pagx/PAGXNodeChannel.h b/include/pagx/PAGXNodeChannel.h new file mode 100644 index 0000000000..94d0e343c9 --- /dev/null +++ b/include/pagx/PAGXNodeChannel.h @@ -0,0 +1,88 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include "pagx/nodes/Channel.h" +#include "pagx/nodes/Node.h" + +namespace pagx { + +/** + * Reflective, by-name read/write access to a PAGX node's scalar properties. Channels use the same + * attribute names as PAGX XML (e.g. "alpha", "position.x", "blendMode"). This is a document-level + * facility: it reads and writes fields on PAGXDocument nodes and does not touch any live PAGScene. + * After mutating, refresh live scenes via PAGXDocument::notifyChange (use RequiresLayout to decide + * the layoutChanged flag). Typical use: editor property panels and data-driven CLI edits, where the + * caller holds a string channel name rather than a compile-time field reference. + * + * Value encoding (KeyValue alternatives): scalars map directly (float/bool/int/string/Color); + * enums are passed as their string name (e.g. blendMode = "multiply"); Point/Size fields are + * addressed component-wise via suffixed channels ("position.x", "size.width"); Matrix uses the + * Matrix alternative. Multi-component fields without a component channel are not exposed. The set of + * channels available for each node type is documented in the PAGX schema reference rather than + * enumerated through this API. + */ + +/** + * Reads the value of the given channel on the node into out. + * @param node the node to read from; must not be null. + * @param channel the channel name (see the encoding notes above). + * @param out receives the value on success; must not be null. + * @return true on success; false if node/out is null, the channel is unknown for the node type, the + * field type cannot be represented as a KeyValue, or an optional field is unset. + */ +bool GetNodeChannel(const Node* node, const std::string& channel, KeyValue* out); + +/** + * Writes value into the node field identified by channel. The node is the source of truth; callers + * refresh any live scene separately via PAGXDocument::notifyChange. + * @param node the node to write to; must not be null. + * @param channel the channel name (see the encoding notes above). + * @param value the value to write; its KeyValue alternative must match the field's type. + * @return true on success; false if node is null, the channel is unknown for the node type, the + * KeyValue type does not match the field, or an enum string is invalid. + */ +bool SetNodeChannel(Node* node, const std::string& channel, const KeyValue& value); + +/** + * Returns true if the given channel exists on the node type and can be driven by an animation + * channel, i.e. it has a lightweight runtime writer that updates the live layer in place. Returns + * false for channels that only take effect through a layout/content rebuild and for unknown + * channels. Note this is independent of RequiresLayout: a geometry channel such as a shape's + * "size.width" is both animatable (in place, during playback) and layout-affecting (when edited on + * the document). + * @param type the node type. + * @param channel the channel name. + */ +bool IsAnimatableChannel(NodeType type, const std::string& channel); + +/** + * Returns true if editing the given channel on the document requires a layout pass before the + * change is visible in a live scene, i.e. its value reaches the rendered layer through a + * layout-derived quantity (size/position/scale) or it is an auto-layout input (constraints, + * padding, fonts, text, container layout). Callers that mutate a channel via SetNodeChannel should + * pass this as the layoutChanged flag to PAGXDocument::notifyChange. Returns false for channels + * that refresh without layout and for unknown channels. + * @param type the node type. + * @param channel the channel name. + */ +bool RequiresLayout(NodeType type, const std::string& channel); + +} // namespace pagx diff --git a/include/pagx/nodes/LayoutNode.h b/include/pagx/nodes/LayoutNode.h index 3df198f69d..bdeb0bb213 100644 --- a/include/pagx/nodes/LayoutNode.h +++ b/include/pagx/nodes/LayoutNode.h @@ -104,6 +104,14 @@ class LayoutNode { /** Returns true if any constraint attribute is set. */ bool hasConstraints() const; + /** + * Clears the layout-computed outputs (preferred and resolved position/size) so that a subsequent + * updateSize() / PerformConstraintLayout() pass re-measures and re-resolves from the current + * authored fields. Authored inputs (width/height, constraints, percent sizes) are not touched. + * Used when re-running layout on an already-laid-out document after edits. + */ + void resetLayout(); + /** * Returns the layout-resolved bounds of this node in its parent's coordinate space. * Only valid after applyLayout() has been called. Before layout, returns an empty Rect. diff --git a/src/pagx/LayoutNode.cpp b/src/pagx/LayoutNode.cpp index 9bf03e8b5e..48e89006a8 100644 --- a/src/pagx/LayoutNode.cpp +++ b/src/pagx/LayoutNode.cpp @@ -38,6 +38,20 @@ bool LayoutNode::hasConstraints() const { !std::isnan(percentHeight); } +void LayoutNode::resetLayout() { + // Reset only the layout-computed outputs so a subsequent updateSize() re-measures and re-resolves + // from the current authored fields. The authored inputs (width/height, constraints, percent*) are + // left untouched. + preferredX = 0; + preferredY = 0; + preferredWidth = NAN; + preferredHeight = NAN; + layoutX = NAN; + layoutY = NAN; + layoutWidth = NAN; + layoutHeight = NAN; +} + Rect LayoutNode::layoutBounds() const { float x = std::isnan(layoutX) ? preferredX : layoutX; float y = std::isnan(layoutY) ? preferredY : layoutY; diff --git a/src/pagx/PAGScene.cpp b/src/pagx/PAGScene.cpp index 48b15a7398..eba5e96adf 100644 --- a/src/pagx/PAGScene.cpp +++ b/src/pagx/PAGScene.cpp @@ -225,8 +225,18 @@ bool PAGScene::rootToSurfaceMatrix(Matrix* out) const { return true; } -void PAGScene::onNodesChanged(const std::vector& /*dirtyNodes*/) { - // TODO(PR11): rebuild affected runtime sub-trees and reset relevant timelines. +void PAGScene::onNodesChanged(const std::vector& dirtyNodes) { + if (_rootComposition != nullptr) { + _rootComposition->refreshNodes(dirtyNodes); + } + // Top-level timelines are owned here, not by the root composition, so reset their cached target + // resolution as well: a mutated node may change which targets they resolve to. + for (auto& entry : timelinesByAnimation) { + if (entry.second != nullptr) { + entry.second->resolved = false; + entry.second->resolvedTargets.clear(); + } + } } RuntimeBinding* PAGScene::mutableBinding() { diff --git a/src/pagx/PAGXChannelTable.h b/src/pagx/PAGXChannelTable.h new file mode 100644 index 0000000000..a62305b2a9 --- /dev/null +++ b/src/pagx/PAGXChannelTable.h @@ -0,0 +1,68 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include "pagx/nodes/Channel.h" +#include "pagx/nodes/Node.h" + +namespace pagx { + +// Independent properties of a node channel, combined as a bitmask. The two axes are orthogonal: a +// channel may be neither, either, or both (e.g. a shape's size.width is animated in place AND, when +// edited on the document, must re-run layout before the edit shows up). +// - Animatable: the channel has a lightweight runtime writer, so an animation channel can drive +// it in place without rebuilding the layer. +// - RequiresLayout: editing the channel on the document only takes effect after a layout pass, +// because its value reaches the rendered layer through a layout-derived accessor (renderSize / +// renderPosition / renderScale) or because it is an auto-layout input (size, constraints, +// padding, fonts, text, container layout). +enum class ChannelFlags : uint32_t { + None = 0, + Animatable = 1u << 0, + RequiresLayout = 1u << 1, +}; + +inline constexpr ChannelFlags operator|(ChannelFlags a, ChannelFlags b) { + return static_cast(static_cast(a) | static_cast(b)); +} + +inline constexpr bool HasFlag(ChannelFlags value, ChannelFlags flag) { + return (static_cast(value) & static_cast(flag)) != 0; +} + +// Function that reads or writes one node channel. Exactly one of getOut / setIn is non-null: getOut +// for a read (copies the field into *getOut), setIn for a write (validates and copies into the +// field). Returns false on a type mismatch or an invalid enum string. +using ChannelAccessor = bool (*)(Node* node, KeyValue* getOut, const KeyValue* setIn); + +// One reflective channel of a node type, addressed by channel name. +struct ChannelDef { + const char* channel; + ChannelFlags flags; + ChannelAccessor access; +}; + +// Returns the channel table for the given node type, or an empty table if the type has no +// reflectable scalar channels. The table is the single source of truth for channel names and their +// flags, backing the public read/write/query API in PAGXNodeChannel.h. +const std::vector& ChannelsFor(NodeType type); + +} // namespace pagx diff --git a/src/pagx/PAGXDocument.cpp b/src/pagx/PAGXDocument.cpp index 98fea32ef0..a1392b4080 100644 --- a/src/pagx/PAGXDocument.cpp +++ b/src/pagx/PAGXDocument.cpp @@ -24,6 +24,7 @@ #include "pagx/PAGScene.h" #include "pagx/PAGXImporter.h" #include "pagx/nodes/Composition.h" +#include "pagx/nodes/Element.h" #include "pagx/nodes/Font.h" #include "pagx/nodes/Image.h" #include "pagx/nodes/LayoutNode.h" @@ -153,6 +154,34 @@ void PAGXDocument::applyLayout(const FontConfig* config, if (config != nullptr) { fontConfig = *config; } + // Re-running layout on an already-laid-out document (e.g. from notifyChange after an edit) must + // discard the cached layout outputs first; updateSize() skips re-measuring a node whose preferred + // size is already set, so without this a size/constraint edit would keep the stale geometry. + if (layoutApplied) { + for (auto& node : nodes) { + switch (node->nodeType()) { + case NodeType::Layer: + static_cast(node.get())->resetLayout(); + break; + case NodeType::Rectangle: + case NodeType::Ellipse: + case NodeType::Path: + case NodeType::Polystar: + case NodeType::Text: + case NodeType::TextPath: + case NodeType::Group: + case NodeType::TextBox: { + auto* layoutNode = LayoutNode::AsLayoutNode(static_cast(node.get())); + if (layoutNode != nullptr) { + layoutNode->resetLayout(); + } + break; + } + default: + break; + } + } + } LayoutContext context(&fontConfig); // Composition layers are laid out first since they may be referenced by document layers. for (auto& node : nodes) { @@ -287,17 +316,24 @@ void PAGXDocument::clearEmbed() { } } -void PAGXDocument::notifyChange(const std::vector& dirtyNodes) { +void PAGXDocument::notifyChange(const std::vector& dirtyNodes, bool layoutChanged) { if (dirtyNodes.empty()) { return; } - // Prune expired weak_ptr entries to keep liveScenes bounded. PruneExpiredScenes(&liveScenes); - // Dispatch to PAGScene::onNodesChanged by iterating liveScenes; each scene decides which dirty - // nodes are relevant using its own runtime binding. Implementation lives in PAGScene.cpp to avoid - // pulling PAGScene.h into the document header. - // TODO(PR11): wire to PAGScene::onNodesChanged once that method is implemented. - (void)dirtyNodes; + // Layout-affecting edits (size, constraints, padding, fonts, text, geometry) and structural child + // list changes require a full re-layout, since layout is resolved top-down and a single node + // cannot be re-measured in isolation. applyLayout() discards the cached layout outputs first when + // the document is already laid out (see its reset branch). Pure render edits skip this entirely. + if (layoutChanged) { + applyLayout(); + } + for (auto& weakScene : liveScenes) { + auto scene = weakScene.lock(); + if (scene != nullptr) { + scene->onNodesChanged(dirtyNodes); + } + } } void PAGXDocument::registerLiveScene(const std::shared_ptr& scene) { diff --git a/src/pagx/PAGXNodeChannel.cpp b/src/pagx/PAGXNodeChannel.cpp index 92464ca2b2..aa33d0e8fc 100644 --- a/src/pagx/PAGXNodeChannel.cpp +++ b/src/pagx/PAGXNodeChannel.cpp @@ -20,6 +20,7 @@ #include #include #include "base/utils/Log.h" +#include "pagx/PAGXChannelTable.h" #include "pagx/nodes/BackgroundBlurStyle.h" #include "pagx/nodes/BlendFilter.h" #include "pagx/nodes/BlurFilter.h" @@ -58,8 +59,17 @@ namespace pagx { +// Short aliases for the channel flag combinations used by the tables below. Anim: has a runtime +// writer (animatable in place). Layout: editing it on the document needs a layout pass to show up. +// AnimLayout: both (e.g. a shape's size, which is animated in place but, when edited, must re-run +// layout because the rendered size is read through a layout-derived accessor). NoFlags: neither. +static constexpr ChannelFlags Anim = ChannelFlags::Animatable; +static constexpr ChannelFlags Layout = ChannelFlags::RequiresLayout; +static constexpr ChannelFlags AnimLayout = ChannelFlags::Animatable | ChannelFlags::RequiresLayout; +static constexpr ChannelFlags NoFlags = ChannelFlags::None; + // The access generators below turn a member pointer (and, for enums, the StringParser converters) -// into a uniform NodeAccessFn. A read copies the field into *getOut; a write validates the KeyValue +// into a uniform ChannelAccessor. A read copies the field into *getOut; a write validates the KeyValue // alternative (or enum string) and copies into the field. Routing both directions through one // generated function keeps reads and writes symmetric and removes the per-field if/else boilerplate. @@ -246,7 +256,7 @@ static bool AccessEnum(Node* node, KeyValue* getOut, const KeyValue* setIn) { return true; } -// Convenience macros that turn a (channel, member) pair into a NodeFieldDef row. They only build the +// Convenience macros that turn a (channel, member) pair into a ChannelDef row. They only build the // table entries; all access logic lives in the templated generators above. #define FIELD_FLOAT(T, name, member, cls) \ { name, cls, &AccessFloat } @@ -284,194 +294,194 @@ static bool AccessEnum(Node* node, KeyValue* getOut, const KeyValue* setIn) { // The shared LayoutNode constraint fields (layout inputs) appended to every LayoutNode-derived type. // T must derive from LayoutNode so the member pointers resolve through the base subobject. template -static void AppendLayoutNodeFields(std::vector& table) { - std::vector shared = { - FIELD_FLOAT(T, "width", width, AnimClass::LayoutInput), - FIELD_FLOAT(T, "height", height, AnimClass::LayoutInput), - FIELD_FLOAT(T, "percentWidth", percentWidth, AnimClass::LayoutInput), - FIELD_FLOAT(T, "percentHeight", percentHeight, AnimClass::LayoutInput), - FIELD_FLOAT(T, "left", left, AnimClass::LayoutInput), - FIELD_FLOAT(T, "right", right, AnimClass::LayoutInput), - FIELD_FLOAT(T, "top", top, AnimClass::LayoutInput), - FIELD_FLOAT(T, "bottom", bottom, AnimClass::LayoutInput), - FIELD_FLOAT(T, "centerX", centerX, AnimClass::LayoutInput), - FIELD_FLOAT(T, "centerY", centerY, AnimClass::LayoutInput), +static void AppendLayoutNodeFields(std::vector& table) { + std::vector shared = { + FIELD_FLOAT(T, "width", width, Layout), + FIELD_FLOAT(T, "height", height, Layout), + FIELD_FLOAT(T, "percentWidth", percentWidth, Layout), + FIELD_FLOAT(T, "percentHeight", percentHeight, Layout), + FIELD_FLOAT(T, "left", left, Layout), + FIELD_FLOAT(T, "right", right, Layout), + FIELD_FLOAT(T, "top", top, Layout), + FIELD_FLOAT(T, "bottom", bottom, Layout), + FIELD_FLOAT(T, "centerX", centerX, Layout), + FIELD_FLOAT(T, "centerY", centerY, Layout), }; table.insert(table.end(), shared.begin(), shared.end()); } -static std::vector BuildLayerFields() { - std::vector table = { - FIELD_STRING(Layer, "name", name, AnimClass::LayoutInput), - FIELD_BOOL(Layer, "visible", visible, AnimClass::Animatable), - FIELD_FLOAT(Layer, "alpha", alpha, AnimClass::Animatable), - FIELD_ENUM(Layer, "blendMode", blendMode, AnimClass::Animatable, BlendMode), - FIELD_FLOAT(Layer, "x", x, AnimClass::Animatable), - FIELD_FLOAT(Layer, "y", y, AnimClass::Animatable), - FIELD_BOOL(Layer, "preserve3D", preserve3D, AnimClass::Static), - FIELD_BOOL(Layer, "antiAlias", antiAlias, AnimClass::Static), - FIELD_BOOL(Layer, "groupOpacity", groupOpacity, AnimClass::Static), - FIELD_BOOL(Layer, "passThroughBackground", passThroughBackground, AnimClass::Static), - FIELD_BOOL(Layer, "clipToBounds", clipToBounds, AnimClass::LayoutInput), - FIELD_ENUM(Layer, "maskType", maskType, AnimClass::Static, MaskType), - FIELD_ENUM(Layer, "layout", layout, AnimClass::LayoutInput, LayoutMode), - FIELD_FLOAT(Layer, "gap", gap, AnimClass::LayoutInput), - FIELD_FLOAT(Layer, "flex", flex, AnimClass::LayoutInput), - FIELD_ENUM(Layer, "alignment", alignment, AnimClass::LayoutInput, Alignment), - FIELD_ENUM(Layer, "arrangement", arrangement, AnimClass::LayoutInput, Arrangement), - FIELD_BOOL(Layer, "includeInLayout", includeInLayout, AnimClass::LayoutInput), - FIELD_PADDING_L(Layer, "padding.left", padding, AnimClass::LayoutInput), - FIELD_PADDING_T(Layer, "padding.top", padding, AnimClass::LayoutInput), - FIELD_PADDING_R(Layer, "padding.right", padding, AnimClass::LayoutInput), - FIELD_PADDING_B(Layer, "padding.bottom", padding, AnimClass::LayoutInput), +static std::vector BuildLayerFields() { + std::vector table = { + FIELD_STRING(Layer, "name", name, NoFlags), + FIELD_BOOL(Layer, "visible", visible, Anim), + FIELD_FLOAT(Layer, "alpha", alpha, Anim), + FIELD_ENUM(Layer, "blendMode", blendMode, Anim, BlendMode), + FIELD_FLOAT(Layer, "x", x, AnimLayout), + FIELD_FLOAT(Layer, "y", y, AnimLayout), + FIELD_BOOL(Layer, "preserve3D", preserve3D, NoFlags), + FIELD_BOOL(Layer, "antiAlias", antiAlias, NoFlags), + FIELD_BOOL(Layer, "groupOpacity", groupOpacity, NoFlags), + FIELD_BOOL(Layer, "passThroughBackground", passThroughBackground, NoFlags), + FIELD_BOOL(Layer, "clipToBounds", clipToBounds, Layout), + FIELD_ENUM(Layer, "maskType", maskType, NoFlags, MaskType), + FIELD_ENUM(Layer, "layout", layout, Layout, LayoutMode), + FIELD_FLOAT(Layer, "gap", gap, Layout), + FIELD_FLOAT(Layer, "flex", flex, Layout), + FIELD_ENUM(Layer, "alignment", alignment, Layout, Alignment), + FIELD_ENUM(Layer, "arrangement", arrangement, Layout, Arrangement), + FIELD_BOOL(Layer, "includeInLayout", includeInLayout, Layout), + FIELD_PADDING_L(Layer, "padding.left", padding, Layout), + FIELD_PADDING_T(Layer, "padding.top", padding, Layout), + FIELD_PADDING_R(Layer, "padding.right", padding, Layout), + FIELD_PADDING_B(Layer, "padding.bottom", padding, Layout), }; AppendLayoutNodeFields(table); return table; } -static std::vector BuildRectangleFields() { - std::vector table = { - FIELD_POINT_X(Rectangle, "position.x", position, AnimClass::Animatable), - FIELD_POINT_Y(Rectangle, "position.y", position, AnimClass::Animatable), - FIELD_SIZE_W(Rectangle, "size.width", size, AnimClass::Animatable), - FIELD_SIZE_H(Rectangle, "size.height", size, AnimClass::Animatable), - FIELD_FLOAT(Rectangle, "roundness", roundness, AnimClass::Animatable), - FIELD_BOOL(Rectangle, "reversed", reversed, AnimClass::Static), +static std::vector BuildRectangleFields() { + std::vector table = { + FIELD_POINT_X(Rectangle, "position.x", position, AnimLayout), + FIELD_POINT_Y(Rectangle, "position.y", position, AnimLayout), + FIELD_SIZE_W(Rectangle, "size.width", size, AnimLayout), + FIELD_SIZE_H(Rectangle, "size.height", size, AnimLayout), + FIELD_FLOAT(Rectangle, "roundness", roundness, Anim), + FIELD_BOOL(Rectangle, "reversed", reversed, NoFlags), }; AppendLayoutNodeFields(table); return table; } -static std::vector BuildEllipseFields() { - std::vector table = { - FIELD_POINT_X(Ellipse, "position.x", position, AnimClass::Animatable), - FIELD_POINT_Y(Ellipse, "position.y", position, AnimClass::Animatable), - FIELD_SIZE_W(Ellipse, "size.width", size, AnimClass::Animatable), - FIELD_SIZE_H(Ellipse, "size.height", size, AnimClass::Animatable), - FIELD_BOOL(Ellipse, "reversed", reversed, AnimClass::Static), +static std::vector BuildEllipseFields() { + std::vector table = { + FIELD_POINT_X(Ellipse, "position.x", position, AnimLayout), + FIELD_POINT_Y(Ellipse, "position.y", position, AnimLayout), + FIELD_SIZE_W(Ellipse, "size.width", size, AnimLayout), + FIELD_SIZE_H(Ellipse, "size.height", size, AnimLayout), + FIELD_BOOL(Ellipse, "reversed", reversed, NoFlags), }; AppendLayoutNodeFields(table); return table; } -static std::vector BuildPolystarFields() { - std::vector table = { - FIELD_POINT_X(Polystar, "position.x", position, AnimClass::Animatable), - FIELD_POINT_Y(Polystar, "position.y", position, AnimClass::Animatable), - FIELD_ENUM(Polystar, "type", type, AnimClass::Static, PolystarType), - FIELD_FLOAT(Polystar, "pointCount", pointCount, AnimClass::Animatable), - FIELD_FLOAT(Polystar, "outerRadius", outerRadius, AnimClass::Animatable), - FIELD_FLOAT(Polystar, "innerRadius", innerRadius, AnimClass::Animatable), - FIELD_FLOAT(Polystar, "rotation", rotation, AnimClass::Animatable), - FIELD_FLOAT(Polystar, "outerRoundness", outerRoundness, AnimClass::Animatable), - FIELD_FLOAT(Polystar, "innerRoundness", innerRoundness, AnimClass::Animatable), - FIELD_BOOL(Polystar, "reversed", reversed, AnimClass::Static), +static std::vector BuildPolystarFields() { + std::vector table = { + FIELD_POINT_X(Polystar, "position.x", position, AnimLayout), + FIELD_POINT_Y(Polystar, "position.y", position, AnimLayout), + FIELD_ENUM(Polystar, "type", type, Layout, PolystarType), + FIELD_FLOAT(Polystar, "pointCount", pointCount, AnimLayout), + FIELD_FLOAT(Polystar, "outerRadius", outerRadius, AnimLayout), + FIELD_FLOAT(Polystar, "innerRadius", innerRadius, AnimLayout), + FIELD_FLOAT(Polystar, "rotation", rotation, AnimLayout), + FIELD_FLOAT(Polystar, "outerRoundness", outerRoundness, Anim), + FIELD_FLOAT(Polystar, "innerRoundness", innerRoundness, Anim), + FIELD_BOOL(Polystar, "reversed", reversed, NoFlags), }; AppendLayoutNodeFields(table); return table; } -static std::vector BuildPathFields() { - std::vector table = { - FIELD_POINT_X(Path, "position.x", position, AnimClass::Animatable), - FIELD_POINT_Y(Path, "position.y", position, AnimClass::Animatable), - FIELD_BOOL(Path, "reversed", reversed, AnimClass::Static), +static std::vector BuildPathFields() { + std::vector table = { + FIELD_POINT_X(Path, "position.x", position, Anim), + FIELD_POINT_Y(Path, "position.y", position, Anim), + FIELD_BOOL(Path, "reversed", reversed, NoFlags), }; AppendLayoutNodeFields(table); return table; } -static std::vector BuildTextFields() { - std::vector table = { - FIELD_STRING(Text, "text", text, AnimClass::LayoutInput), - FIELD_POINT_X(Text, "x", position, AnimClass::Animatable), - FIELD_POINT_Y(Text, "y", position, AnimClass::Animatable), - FIELD_STRING(Text, "fontFamily", fontFamily, AnimClass::LayoutInput), - FIELD_STRING(Text, "fontStyle", fontStyle, AnimClass::LayoutInput), - FIELD_FLOAT(Text, "fontSize", fontSize, AnimClass::LayoutInput), - FIELD_FLOAT(Text, "letterSpacing", letterSpacing, AnimClass::LayoutInput), - FIELD_BOOL(Text, "fauxBold", fauxBold, AnimClass::LayoutInput), - FIELD_BOOL(Text, "fauxItalic", fauxItalic, AnimClass::LayoutInput), - FIELD_ENUM(Text, "textAnchor", textAnchor, AnimClass::LayoutInput, TextAnchor), - FIELD_ENUM(Text, "baseline", baseline, AnimClass::LayoutInput, TextBaseline), +static std::vector BuildTextFields() { + std::vector table = { + FIELD_STRING(Text, "text", text, Layout), + FIELD_POINT_X(Text, "x", position, Anim), + FIELD_POINT_Y(Text, "y", position, Anim), + FIELD_STRING(Text, "fontFamily", fontFamily, Layout), + FIELD_STRING(Text, "fontStyle", fontStyle, Layout), + FIELD_FLOAT(Text, "fontSize", fontSize, Layout), + FIELD_FLOAT(Text, "letterSpacing", letterSpacing, Layout), + FIELD_BOOL(Text, "fauxBold", fauxBold, Layout), + FIELD_BOOL(Text, "fauxItalic", fauxItalic, Layout), + FIELD_ENUM(Text, "textAnchor", textAnchor, Layout, TextAnchor), + FIELD_ENUM(Text, "baseline", baseline, Layout, TextBaseline), }; AppendLayoutNodeFields(table); return table; } -static std::vector BuildFillFields() { +static std::vector BuildFillFields() { return { - FIELD_FLOAT(Fill, "alpha", alpha, AnimClass::Animatable), - FIELD_ENUM(Fill, "blendMode", blendMode, AnimClass::Static, BlendMode), - FIELD_ENUM(Fill, "fillRule", fillRule, AnimClass::Static, FillRule), - FIELD_ENUM(Fill, "placement", placement, AnimClass::Static, LayerPlacement), + FIELD_FLOAT(Fill, "alpha", alpha, Anim), + FIELD_ENUM(Fill, "blendMode", blendMode, NoFlags, BlendMode), + FIELD_ENUM(Fill, "fillRule", fillRule, NoFlags, FillRule), + FIELD_ENUM(Fill, "placement", placement, NoFlags, LayerPlacement), }; } -static std::vector BuildStrokeFields() { +static std::vector BuildStrokeFields() { return { - FIELD_FLOAT(Stroke, "width", width, AnimClass::Animatable), - FIELD_FLOAT(Stroke, "alpha", alpha, AnimClass::Animatable), - FIELD_ENUM(Stroke, "blendMode", blendMode, AnimClass::Static, BlendMode), - FIELD_ENUM(Stroke, "cap", cap, AnimClass::Static, LineCap), - FIELD_ENUM(Stroke, "join", join, AnimClass::Static, LineJoin), - FIELD_FLOAT(Stroke, "miterLimit", miterLimit, AnimClass::Animatable), - FIELD_FLOAT(Stroke, "dashOffset", dashOffset, AnimClass::Animatable), - FIELD_BOOL(Stroke, "dashAdaptive", dashAdaptive, AnimClass::Static), - FIELD_ENUM(Stroke, "align", align, AnimClass::Static, StrokeAlign), - FIELD_ENUM(Stroke, "placement", placement, AnimClass::Static, LayerPlacement), + FIELD_FLOAT(Stroke, "width", width, Anim), + FIELD_FLOAT(Stroke, "alpha", alpha, Anim), + FIELD_ENUM(Stroke, "blendMode", blendMode, NoFlags, BlendMode), + FIELD_ENUM(Stroke, "cap", cap, NoFlags, LineCap), + FIELD_ENUM(Stroke, "join", join, NoFlags, LineJoin), + FIELD_FLOAT(Stroke, "miterLimit", miterLimit, Anim), + FIELD_FLOAT(Stroke, "dashOffset", dashOffset, Anim), + FIELD_BOOL(Stroke, "dashAdaptive", dashAdaptive, NoFlags), + FIELD_ENUM(Stroke, "align", align, NoFlags, StrokeAlign), + FIELD_ENUM(Stroke, "placement", placement, NoFlags, LayerPlacement), }; } -static std::vector BuildTrimPathFields() { +static std::vector BuildTrimPathFields() { return { - FIELD_FLOAT(TrimPath, "start", start, AnimClass::Animatable), - FIELD_FLOAT(TrimPath, "end", end, AnimClass::Animatable), - FIELD_FLOAT(TrimPath, "offset", offset, AnimClass::Animatable), - FIELD_ENUM(TrimPath, "type", type, AnimClass::Static, TrimType), + FIELD_FLOAT(TrimPath, "start", start, Anim), + FIELD_FLOAT(TrimPath, "end", end, Anim), + FIELD_FLOAT(TrimPath, "offset", offset, Anim), + FIELD_ENUM(TrimPath, "type", type, NoFlags, TrimType), }; } -static std::vector BuildRoundCornerFields() { +static std::vector BuildRoundCornerFields() { return { - FIELD_FLOAT(RoundCorner, "radius", radius, AnimClass::Animatable), + FIELD_FLOAT(RoundCorner, "radius", radius, Anim), }; } -static std::vector BuildMergePathFields() { +static std::vector BuildMergePathFields() { return { - FIELD_ENUM(MergePath, "mode", mode, AnimClass::Static, MergePathMode), + FIELD_ENUM(MergePath, "mode", mode, NoFlags, MergePathMode), }; } -static std::vector BuildTextModifierFields() { +static std::vector BuildTextModifierFields() { return { - FIELD_POINT_X(TextModifier, "anchor.x", anchor, AnimClass::Animatable), - FIELD_POINT_Y(TextModifier, "anchor.y", anchor, AnimClass::Animatable), - FIELD_POINT_X(TextModifier, "position.x", position, AnimClass::Animatable), - FIELD_POINT_Y(TextModifier, "position.y", position, AnimClass::Animatable), - FIELD_FLOAT(TextModifier, "rotation", rotation, AnimClass::Animatable), - FIELD_POINT_X(TextModifier, "scale.x", scale, AnimClass::Animatable), - FIELD_POINT_Y(TextModifier, "scale.y", scale, AnimClass::Animatable), - FIELD_FLOAT(TextModifier, "skew", skew, AnimClass::Animatable), - FIELD_FLOAT(TextModifier, "skewAxis", skewAxis, AnimClass::Animatable), - FIELD_FLOAT(TextModifier, "alpha", alpha, AnimClass::Animatable), - FIELD_OPT_COLOR(TextModifier, "fillColor", fillColor, AnimClass::Animatable), - FIELD_OPT_COLOR(TextModifier, "strokeColor", strokeColor, AnimClass::Animatable), - FIELD_OPT_FLOAT(TextModifier, "strokeWidth", strokeWidth, AnimClass::Animatable), - }; -} - -static std::vector BuildTextPathFields() { - std::vector table = { - FIELD_POINT_X(TextPath, "baselineOrigin.x", baselineOrigin, AnimClass::LayoutInput), - FIELD_POINT_Y(TextPath, "baselineOrigin.y", baselineOrigin, AnimClass::LayoutInput), - FIELD_FLOAT(TextPath, "baselineAngle", baselineAngle, AnimClass::LayoutInput), - FIELD_FLOAT(TextPath, "firstMargin", firstMargin, AnimClass::LayoutInput), - FIELD_FLOAT(TextPath, "lastMargin", lastMargin, AnimClass::LayoutInput), - FIELD_BOOL(TextPath, "perpendicular", perpendicular, AnimClass::LayoutInput), - FIELD_BOOL(TextPath, "reversed", reversed, AnimClass::Static), - FIELD_BOOL(TextPath, "forceAlignment", forceAlignment, AnimClass::LayoutInput), + FIELD_POINT_X(TextModifier, "anchor.x", anchor, Anim), + FIELD_POINT_Y(TextModifier, "anchor.y", anchor, Anim), + FIELD_POINT_X(TextModifier, "position.x", position, Anim), + FIELD_POINT_Y(TextModifier, "position.y", position, Anim), + FIELD_FLOAT(TextModifier, "rotation", rotation, Anim), + FIELD_POINT_X(TextModifier, "scale.x", scale, Anim), + FIELD_POINT_Y(TextModifier, "scale.y", scale, Anim), + FIELD_FLOAT(TextModifier, "skew", skew, Anim), + FIELD_FLOAT(TextModifier, "skewAxis", skewAxis, Anim), + FIELD_FLOAT(TextModifier, "alpha", alpha, Anim), + FIELD_OPT_COLOR(TextModifier, "fillColor", fillColor, Anim), + FIELD_OPT_COLOR(TextModifier, "strokeColor", strokeColor, Anim), + FIELD_OPT_FLOAT(TextModifier, "strokeWidth", strokeWidth, Anim), + }; +} + +static std::vector BuildTextPathFields() { + std::vector table = { + FIELD_POINT_X(TextPath, "baselineOrigin.x", baselineOrigin, Layout), + FIELD_POINT_Y(TextPath, "baselineOrigin.y", baselineOrigin, Layout), + FIELD_FLOAT(TextPath, "baselineAngle", baselineAngle, Layout), + FIELD_FLOAT(TextPath, "firstMargin", firstMargin, Layout), + FIELD_FLOAT(TextPath, "lastMargin", lastMargin, Layout), + FIELD_BOOL(TextPath, "perpendicular", perpendicular, Layout), + FIELD_BOOL(TextPath, "reversed", reversed, NoFlags), + FIELD_BOOL(TextPath, "forceAlignment", forceAlignment, Layout), }; AppendLayoutNodeFields(table); return table; @@ -479,340 +489,339 @@ static std::vector BuildTextPathFields() { // Shared Group transform/layout fields, also used by TextBox which derives from Group. template -static void AppendGroupCommonFields(std::vector& table) { - std::vector shared = { - FIELD_POINT_X(T, "anchor.x", anchor, AnimClass::Animatable), - FIELD_POINT_Y(T, "anchor.y", anchor, AnimClass::Animatable), - FIELD_POINT_X(T, "position.x", position, AnimClass::Animatable), - FIELD_POINT_Y(T, "position.y", position, AnimClass::Animatable), - FIELD_FLOAT(T, "rotation", rotation, AnimClass::Animatable), - FIELD_POINT_X(T, "scale.x", scale, AnimClass::Animatable), - FIELD_POINT_Y(T, "scale.y", scale, AnimClass::Animatable), - FIELD_FLOAT(T, "skew", skew, AnimClass::Animatable), - FIELD_FLOAT(T, "skewAxis", skewAxis, AnimClass::Animatable), - FIELD_FLOAT(T, "alpha", alpha, AnimClass::Animatable), - FIELD_PADDING_L(T, "padding.left", padding, AnimClass::LayoutInput), - FIELD_PADDING_T(T, "padding.top", padding, AnimClass::LayoutInput), - FIELD_PADDING_R(T, "padding.right", padding, AnimClass::LayoutInput), - FIELD_PADDING_B(T, "padding.bottom", padding, AnimClass::LayoutInput), +static void AppendGroupCommonFields(std::vector& table) { + std::vector shared = { + FIELD_POINT_X(T, "anchor.x", anchor, Anim), + FIELD_POINT_Y(T, "anchor.y", anchor, Anim), + FIELD_POINT_X(T, "position.x", position, AnimLayout), + FIELD_POINT_Y(T, "position.y", position, AnimLayout), + FIELD_FLOAT(T, "rotation", rotation, Anim), + FIELD_POINT_X(T, "scale.x", scale, Anim), + FIELD_POINT_Y(T, "scale.y", scale, Anim), + FIELD_FLOAT(T, "skew", skew, Anim), + FIELD_FLOAT(T, "skewAxis", skewAxis, Anim), + FIELD_FLOAT(T, "alpha", alpha, Anim), + FIELD_PADDING_L(T, "padding.left", padding, Layout), + FIELD_PADDING_T(T, "padding.top", padding, Layout), + FIELD_PADDING_R(T, "padding.right", padding, Layout), + FIELD_PADDING_B(T, "padding.bottom", padding, Layout), }; table.insert(table.end(), shared.begin(), shared.end()); AppendLayoutNodeFields(table); } -static std::vector BuildGroupFields() { - std::vector table = {}; +static std::vector BuildGroupFields() { + std::vector table = {}; AppendGroupCommonFields(table); return table; } -static std::vector BuildTextBoxFields() { - std::vector table = { - FIELD_ENUM(TextBox, "textAlign", textAlign, AnimClass::LayoutInput, TextAlign), - FIELD_ENUM(TextBox, "paragraphAlign", paragraphAlign, AnimClass::LayoutInput, ParagraphAlign), - FIELD_ENUM(TextBox, "writingMode", writingMode, AnimClass::LayoutInput, WritingMode), - FIELD_FLOAT(TextBox, "lineHeight", lineHeight, AnimClass::LayoutInput), - FIELD_BOOL(TextBox, "wordWrap", wordWrap, AnimClass::LayoutInput), - FIELD_ENUM(TextBox, "overflow", overflow, AnimClass::LayoutInput, Overflow), +static std::vector BuildTextBoxFields() { + std::vector table = { + FIELD_ENUM(TextBox, "textAlign", textAlign, Layout, TextAlign), + FIELD_ENUM(TextBox, "paragraphAlign", paragraphAlign, Layout, ParagraphAlign), + FIELD_ENUM(TextBox, "writingMode", writingMode, Layout, WritingMode), + FIELD_FLOAT(TextBox, "lineHeight", lineHeight, Layout), + FIELD_BOOL(TextBox, "wordWrap", wordWrap, Layout), + FIELD_ENUM(TextBox, "overflow", overflow, Layout, Overflow), }; AppendGroupCommonFields(table); return table; } -static std::vector BuildRepeaterFields() { +static std::vector BuildRepeaterFields() { return { - FIELD_FLOAT(Repeater, "copies", copies, AnimClass::Animatable), - FIELD_FLOAT(Repeater, "offset", offset, AnimClass::Animatable), - FIELD_ENUM(Repeater, "order", order, AnimClass::Static, RepeaterOrder), - FIELD_POINT_X(Repeater, "anchor.x", anchor, AnimClass::Animatable), - FIELD_POINT_Y(Repeater, "anchor.y", anchor, AnimClass::Animatable), - FIELD_POINT_X(Repeater, "position.x", position, AnimClass::Animatable), - FIELD_POINT_Y(Repeater, "position.y", position, AnimClass::Animatable), - FIELD_FLOAT(Repeater, "rotation", rotation, AnimClass::Animatable), - FIELD_POINT_X(Repeater, "scale.x", scale, AnimClass::Animatable), - FIELD_POINT_Y(Repeater, "scale.y", scale, AnimClass::Animatable), - FIELD_FLOAT(Repeater, "startAlpha", startAlpha, AnimClass::Animatable), - FIELD_FLOAT(Repeater, "endAlpha", endAlpha, AnimClass::Animatable), - }; -} - -static std::vector BuildRangeSelectorFields() { + FIELD_FLOAT(Repeater, "copies", copies, Anim), + FIELD_FLOAT(Repeater, "offset", offset, Anim), + FIELD_ENUM(Repeater, "order", order, NoFlags, RepeaterOrder), + FIELD_POINT_X(Repeater, "anchor.x", anchor, Anim), + FIELD_POINT_Y(Repeater, "anchor.y", anchor, Anim), + FIELD_POINT_X(Repeater, "position.x", position, Anim), + FIELD_POINT_Y(Repeater, "position.y", position, Anim), + FIELD_FLOAT(Repeater, "rotation", rotation, Anim), + FIELD_POINT_X(Repeater, "scale.x", scale, Anim), + FIELD_POINT_Y(Repeater, "scale.y", scale, Anim), + FIELD_FLOAT(Repeater, "startAlpha", startAlpha, Anim), + FIELD_FLOAT(Repeater, "endAlpha", endAlpha, Anim), + }; +} + +static std::vector BuildRangeSelectorFields() { return { - FIELD_FLOAT(RangeSelector, "start", start, AnimClass::Animatable), - FIELD_FLOAT(RangeSelector, "end", end, AnimClass::Animatable), - FIELD_FLOAT(RangeSelector, "offset", offset, AnimClass::Animatable), - FIELD_ENUM(RangeSelector, "unit", unit, AnimClass::Static, SelectorUnit), - FIELD_ENUM(RangeSelector, "shape", shape, AnimClass::Static, SelectorShape), - FIELD_FLOAT(RangeSelector, "easeIn", easeIn, AnimClass::Animatable), - FIELD_FLOAT(RangeSelector, "easeOut", easeOut, AnimClass::Animatable), - FIELD_ENUM(RangeSelector, "mode", mode, AnimClass::Static, SelectorMode), - FIELD_FLOAT(RangeSelector, "weight", weight, AnimClass::Animatable), - FIELD_BOOL(RangeSelector, "randomOrder", randomOrder, AnimClass::Static), - FIELD_INT(RangeSelector, "randomSeed", randomSeed, AnimClass::Static), + FIELD_FLOAT(RangeSelector, "start", start, Anim), + FIELD_FLOAT(RangeSelector, "end", end, Anim), + FIELD_FLOAT(RangeSelector, "offset", offset, Anim), + FIELD_ENUM(RangeSelector, "unit", unit, NoFlags, SelectorUnit), + FIELD_ENUM(RangeSelector, "shape", shape, NoFlags, SelectorShape), + FIELD_FLOAT(RangeSelector, "easeIn", easeIn, Anim), + FIELD_FLOAT(RangeSelector, "easeOut", easeOut, Anim), + FIELD_ENUM(RangeSelector, "mode", mode, NoFlags, SelectorMode), + FIELD_FLOAT(RangeSelector, "weight", weight, Anim), + FIELD_BOOL(RangeSelector, "randomOrder", randomOrder, NoFlags), + FIELD_INT(RangeSelector, "randomSeed", randomSeed, NoFlags), }; } -static std::vector BuildSolidColorFields() { +static std::vector BuildSolidColorFields() { return { - FIELD_COLOR(SolidColor, "color", color, AnimClass::Animatable), + FIELD_COLOR(SolidColor, "color", color, Anim), }; } -static std::vector BuildLinearGradientFields() { +static std::vector BuildLinearGradientFields() { return { - FIELD_POINT_X(LinearGradient, "startPoint.x", startPoint, AnimClass::Animatable), - FIELD_POINT_Y(LinearGradient, "startPoint.y", startPoint, AnimClass::Animatable), - FIELD_POINT_X(LinearGradient, "endPoint.x", endPoint, AnimClass::Animatable), - FIELD_POINT_Y(LinearGradient, "endPoint.y", endPoint, AnimClass::Animatable), - FIELD_BOOL(LinearGradient, "fitsToGeometry", fitsToGeometry, AnimClass::Static), + FIELD_POINT_X(LinearGradient, "startPoint.x", startPoint, Anim), + FIELD_POINT_Y(LinearGradient, "startPoint.y", startPoint, Anim), + FIELD_POINT_X(LinearGradient, "endPoint.x", endPoint, Anim), + FIELD_POINT_Y(LinearGradient, "endPoint.y", endPoint, Anim), + FIELD_BOOL(LinearGradient, "fitsToGeometry", fitsToGeometry, NoFlags), }; } -static std::vector BuildRadialGradientFields() { +static std::vector BuildRadialGradientFields() { return { - FIELD_POINT_X(RadialGradient, "center.x", center, AnimClass::Animatable), - FIELD_POINT_Y(RadialGradient, "center.y", center, AnimClass::Animatable), - FIELD_FLOAT(RadialGradient, "radius", radius, AnimClass::Animatable), - FIELD_BOOL(RadialGradient, "fitsToGeometry", fitsToGeometry, AnimClass::Static), + FIELD_POINT_X(RadialGradient, "center.x", center, Anim), + FIELD_POINT_Y(RadialGradient, "center.y", center, Anim), + FIELD_FLOAT(RadialGradient, "radius", radius, Anim), + FIELD_BOOL(RadialGradient, "fitsToGeometry", fitsToGeometry, NoFlags), }; } -static std::vector BuildConicGradientFields() { +static std::vector BuildConicGradientFields() { return { - FIELD_POINT_X(ConicGradient, "center.x", center, AnimClass::Animatable), - FIELD_POINT_Y(ConicGradient, "center.y", center, AnimClass::Animatable), - FIELD_FLOAT(ConicGradient, "startAngle", startAngle, AnimClass::Animatable), - FIELD_FLOAT(ConicGradient, "endAngle", endAngle, AnimClass::Animatable), - FIELD_BOOL(ConicGradient, "fitsToGeometry", fitsToGeometry, AnimClass::Static), + FIELD_POINT_X(ConicGradient, "center.x", center, Anim), + FIELD_POINT_Y(ConicGradient, "center.y", center, Anim), + FIELD_FLOAT(ConicGradient, "startAngle", startAngle, Anim), + FIELD_FLOAT(ConicGradient, "endAngle", endAngle, Anim), + FIELD_BOOL(ConicGradient, "fitsToGeometry", fitsToGeometry, NoFlags), }; } -static std::vector BuildDiamondGradientFields() { +static std::vector BuildDiamondGradientFields() { return { - FIELD_POINT_X(DiamondGradient, "center.x", center, AnimClass::Animatable), - FIELD_POINT_Y(DiamondGradient, "center.y", center, AnimClass::Animatable), - FIELD_FLOAT(DiamondGradient, "radius", radius, AnimClass::Animatable), - FIELD_BOOL(DiamondGradient, "fitsToGeometry", fitsToGeometry, AnimClass::Static), + FIELD_POINT_X(DiamondGradient, "center.x", center, Anim), + FIELD_POINT_Y(DiamondGradient, "center.y", center, Anim), + FIELD_FLOAT(DiamondGradient, "radius", radius, Anim), + FIELD_BOOL(DiamondGradient, "fitsToGeometry", fitsToGeometry, NoFlags), }; } -static std::vector BuildColorStopFields() { +static std::vector BuildColorStopFields() { return { - FIELD_FLOAT(ColorStop, "offset", offset, AnimClass::Animatable), - FIELD_COLOR(ColorStop, "color", color, AnimClass::Animatable), + FIELD_FLOAT(ColorStop, "offset", offset, Anim), + FIELD_COLOR(ColorStop, "color", color, Anim), }; } -static std::vector BuildImagePatternFields() { +static std::vector BuildImagePatternFields() { return { - FIELD_ENUM(ImagePattern, "tileModeX", tileModeX, AnimClass::Static, TileMode), - FIELD_ENUM(ImagePattern, "tileModeY", tileModeY, AnimClass::Static, TileMode), - FIELD_ENUM(ImagePattern, "filterMode", filterMode, AnimClass::Static, FilterMode), - FIELD_ENUM(ImagePattern, "mipmapMode", mipmapMode, AnimClass::Static, MipmapMode), - FIELD_ENUM(ImagePattern, "scaleMode", scaleMode, AnimClass::Static, ScaleMode), + FIELD_ENUM(ImagePattern, "tileModeX", tileModeX, NoFlags, TileMode), + FIELD_ENUM(ImagePattern, "tileModeY", tileModeY, NoFlags, TileMode), + FIELD_ENUM(ImagePattern, "filterMode", filterMode, NoFlags, FilterMode), + FIELD_ENUM(ImagePattern, "mipmapMode", mipmapMode, NoFlags, MipmapMode), + FIELD_ENUM(ImagePattern, "scaleMode", scaleMode, NoFlags, ScaleMode), }; } -static std::vector BuildDropShadowStyleFields() { +static std::vector BuildDropShadowStyleFields() { return { - FIELD_ENUM(DropShadowStyle, "blendMode", blendMode, AnimClass::Static, BlendMode), - FIELD_BOOL(DropShadowStyle, "excludeChildEffects", excludeChildEffects, AnimClass::Static), - FIELD_FLOAT(DropShadowStyle, "offsetX", offsetX, AnimClass::Animatable), - FIELD_FLOAT(DropShadowStyle, "offsetY", offsetY, AnimClass::Animatable), - FIELD_FLOAT(DropShadowStyle, "blurX", blurX, AnimClass::Animatable), - FIELD_FLOAT(DropShadowStyle, "blurY", blurY, AnimClass::Animatable), - FIELD_COLOR(DropShadowStyle, "color", color, AnimClass::Animatable), - FIELD_BOOL(DropShadowStyle, "showBehindLayer", showBehindLayer, AnimClass::Animatable), + FIELD_ENUM(DropShadowStyle, "blendMode", blendMode, NoFlags, BlendMode), + FIELD_BOOL(DropShadowStyle, "excludeChildEffects", excludeChildEffects, NoFlags), + FIELD_FLOAT(DropShadowStyle, "offsetX", offsetX, Anim), + FIELD_FLOAT(DropShadowStyle, "offsetY", offsetY, Anim), + FIELD_FLOAT(DropShadowStyle, "blurX", blurX, Anim), + FIELD_FLOAT(DropShadowStyle, "blurY", blurY, Anim), + FIELD_COLOR(DropShadowStyle, "color", color, Anim), + FIELD_BOOL(DropShadowStyle, "showBehindLayer", showBehindLayer, Anim), }; } -static std::vector BuildInnerShadowStyleFields() { +static std::vector BuildInnerShadowStyleFields() { return { - FIELD_ENUM(InnerShadowStyle, "blendMode", blendMode, AnimClass::Static, BlendMode), - FIELD_BOOL(InnerShadowStyle, "excludeChildEffects", excludeChildEffects, AnimClass::Static), - FIELD_FLOAT(InnerShadowStyle, "offsetX", offsetX, AnimClass::Animatable), - FIELD_FLOAT(InnerShadowStyle, "offsetY", offsetY, AnimClass::Animatable), - FIELD_FLOAT(InnerShadowStyle, "blurX", blurX, AnimClass::Animatable), - FIELD_FLOAT(InnerShadowStyle, "blurY", blurY, AnimClass::Animatable), - FIELD_COLOR(InnerShadowStyle, "color", color, AnimClass::Animatable), + FIELD_ENUM(InnerShadowStyle, "blendMode", blendMode, NoFlags, BlendMode), + FIELD_BOOL(InnerShadowStyle, "excludeChildEffects", excludeChildEffects, NoFlags), + FIELD_FLOAT(InnerShadowStyle, "offsetX", offsetX, Anim), + FIELD_FLOAT(InnerShadowStyle, "offsetY", offsetY, Anim), + FIELD_FLOAT(InnerShadowStyle, "blurX", blurX, Anim), + FIELD_FLOAT(InnerShadowStyle, "blurY", blurY, Anim), + FIELD_COLOR(InnerShadowStyle, "color", color, Anim), }; } -static std::vector BuildBackgroundBlurStyleFields() { +static std::vector BuildBackgroundBlurStyleFields() { return { - FIELD_ENUM(BackgroundBlurStyle, "blendMode", blendMode, AnimClass::Static, BlendMode), - FIELD_BOOL(BackgroundBlurStyle, "excludeChildEffects", excludeChildEffects, - AnimClass::Static), - FIELD_FLOAT(BackgroundBlurStyle, "blurX", blurX, AnimClass::Animatable), - FIELD_FLOAT(BackgroundBlurStyle, "blurY", blurY, AnimClass::Animatable), - FIELD_ENUM(BackgroundBlurStyle, "tileMode", tileMode, AnimClass::Static, TileMode), + FIELD_ENUM(BackgroundBlurStyle, "blendMode", blendMode, NoFlags, BlendMode), + FIELD_BOOL(BackgroundBlurStyle, "excludeChildEffects", excludeChildEffects, NoFlags), + FIELD_FLOAT(BackgroundBlurStyle, "blurX", blurX, Anim), + FIELD_FLOAT(BackgroundBlurStyle, "blurY", blurY, Anim), + FIELD_ENUM(BackgroundBlurStyle, "tileMode", tileMode, NoFlags, TileMode), }; } -static std::vector BuildBlurFilterFields() { +static std::vector BuildBlurFilterFields() { return { - FIELD_FLOAT(BlurFilter, "blurX", blurX, AnimClass::Animatable), - FIELD_FLOAT(BlurFilter, "blurY", blurY, AnimClass::Animatable), - FIELD_ENUM(BlurFilter, "tileMode", tileMode, AnimClass::Static, TileMode), + FIELD_FLOAT(BlurFilter, "blurX", blurX, Anim), + FIELD_FLOAT(BlurFilter, "blurY", blurY, Anim), + FIELD_ENUM(BlurFilter, "tileMode", tileMode, NoFlags, TileMode), }; } -static std::vector BuildDropShadowFilterFields() { +static std::vector BuildDropShadowFilterFields() { return { - FIELD_FLOAT(DropShadowFilter, "offsetX", offsetX, AnimClass::Animatable), - FIELD_FLOAT(DropShadowFilter, "offsetY", offsetY, AnimClass::Animatable), - FIELD_FLOAT(DropShadowFilter, "blurX", blurX, AnimClass::Animatable), - FIELD_FLOAT(DropShadowFilter, "blurY", blurY, AnimClass::Animatable), - FIELD_COLOR(DropShadowFilter, "color", color, AnimClass::Animatable), - FIELD_BOOL(DropShadowFilter, "shadowOnly", shadowOnly, AnimClass::Animatable), + FIELD_FLOAT(DropShadowFilter, "offsetX", offsetX, Anim), + FIELD_FLOAT(DropShadowFilter, "offsetY", offsetY, Anim), + FIELD_FLOAT(DropShadowFilter, "blurX", blurX, Anim), + FIELD_FLOAT(DropShadowFilter, "blurY", blurY, Anim), + FIELD_COLOR(DropShadowFilter, "color", color, Anim), + FIELD_BOOL(DropShadowFilter, "shadowOnly", shadowOnly, Anim), }; } -static std::vector BuildInnerShadowFilterFields() { +static std::vector BuildInnerShadowFilterFields() { return { - FIELD_FLOAT(InnerShadowFilter, "offsetX", offsetX, AnimClass::Animatable), - FIELD_FLOAT(InnerShadowFilter, "offsetY", offsetY, AnimClass::Animatable), - FIELD_FLOAT(InnerShadowFilter, "blurX", blurX, AnimClass::Animatable), - FIELD_FLOAT(InnerShadowFilter, "blurY", blurY, AnimClass::Animatable), - FIELD_COLOR(InnerShadowFilter, "color", color, AnimClass::Animatable), - FIELD_BOOL(InnerShadowFilter, "shadowOnly", shadowOnly, AnimClass::Animatable), + FIELD_FLOAT(InnerShadowFilter, "offsetX", offsetX, Anim), + FIELD_FLOAT(InnerShadowFilter, "offsetY", offsetY, Anim), + FIELD_FLOAT(InnerShadowFilter, "blurX", blurX, Anim), + FIELD_FLOAT(InnerShadowFilter, "blurY", blurY, Anim), + FIELD_COLOR(InnerShadowFilter, "color", color, Anim), + FIELD_BOOL(InnerShadowFilter, "shadowOnly", shadowOnly, Anim), }; } -static std::vector BuildBlendFilterFields() { +static std::vector BuildBlendFilterFields() { return { - FIELD_COLOR(BlendFilter, "color", color, AnimClass::Animatable), - FIELD_ENUM(BlendFilter, "blendMode", blendMode, AnimClass::Static, BlendMode), + FIELD_COLOR(BlendFilter, "color", color, Anim), + FIELD_ENUM(BlendFilter, "blendMode", blendMode, NoFlags, BlendMode), }; } -const std::vector& NodeFieldsFor(NodeType type) { - static const std::vector empty = {}; +const std::vector& ChannelsFor(NodeType type) { + static const std::vector empty = {}; // Each table is built once on first use. Static locals keep the member-pointer-generated access // functions and channel rows alive for the process lifetime. switch (type) { case NodeType::Layer: { - static const std::vector table = BuildLayerFields(); + static const std::vector table = BuildLayerFields(); return table; } case NodeType::Rectangle: { - static const std::vector table = BuildRectangleFields(); + static const std::vector table = BuildRectangleFields(); return table; } case NodeType::Ellipse: { - static const std::vector table = BuildEllipseFields(); + static const std::vector table = BuildEllipseFields(); return table; } case NodeType::Polystar: { - static const std::vector table = BuildPolystarFields(); + static const std::vector table = BuildPolystarFields(); return table; } case NodeType::Path: { - static const std::vector table = BuildPathFields(); + static const std::vector table = BuildPathFields(); return table; } case NodeType::Text: { - static const std::vector table = BuildTextFields(); + static const std::vector table = BuildTextFields(); return table; } case NodeType::Fill: { - static const std::vector table = BuildFillFields(); + static const std::vector table = BuildFillFields(); return table; } case NodeType::Stroke: { - static const std::vector table = BuildStrokeFields(); + static const std::vector table = BuildStrokeFields(); return table; } case NodeType::TrimPath: { - static const std::vector table = BuildTrimPathFields(); + static const std::vector table = BuildTrimPathFields(); return table; } case NodeType::RoundCorner: { - static const std::vector table = BuildRoundCornerFields(); + static const std::vector table = BuildRoundCornerFields(); return table; } case NodeType::MergePath: { - static const std::vector table = BuildMergePathFields(); + static const std::vector table = BuildMergePathFields(); return table; } case NodeType::TextModifier: { - static const std::vector table = BuildTextModifierFields(); + static const std::vector table = BuildTextModifierFields(); return table; } case NodeType::TextPath: { - static const std::vector table = BuildTextPathFields(); + static const std::vector table = BuildTextPathFields(); return table; } case NodeType::TextBox: { - static const std::vector table = BuildTextBoxFields(); + static const std::vector table = BuildTextBoxFields(); return table; } case NodeType::Group: { - static const std::vector table = BuildGroupFields(); + static const std::vector table = BuildGroupFields(); return table; } case NodeType::Repeater: { - static const std::vector table = BuildRepeaterFields(); + static const std::vector table = BuildRepeaterFields(); return table; } case NodeType::RangeSelector: { - static const std::vector table = BuildRangeSelectorFields(); + static const std::vector table = BuildRangeSelectorFields(); return table; } case NodeType::SolidColor: { - static const std::vector table = BuildSolidColorFields(); + static const std::vector table = BuildSolidColorFields(); return table; } case NodeType::LinearGradient: { - static const std::vector table = BuildLinearGradientFields(); + static const std::vector table = BuildLinearGradientFields(); return table; } case NodeType::RadialGradient: { - static const std::vector table = BuildRadialGradientFields(); + static const std::vector table = BuildRadialGradientFields(); return table; } case NodeType::ConicGradient: { - static const std::vector table = BuildConicGradientFields(); + static const std::vector table = BuildConicGradientFields(); return table; } case NodeType::DiamondGradient: { - static const std::vector table = BuildDiamondGradientFields(); + static const std::vector table = BuildDiamondGradientFields(); return table; } case NodeType::ColorStop: { - static const std::vector table = BuildColorStopFields(); + static const std::vector table = BuildColorStopFields(); return table; } case NodeType::ImagePattern: { - static const std::vector table = BuildImagePatternFields(); + static const std::vector table = BuildImagePatternFields(); return table; } case NodeType::DropShadowStyle: { - static const std::vector table = BuildDropShadowStyleFields(); + static const std::vector table = BuildDropShadowStyleFields(); return table; } case NodeType::InnerShadowStyle: { - static const std::vector table = BuildInnerShadowStyleFields(); + static const std::vector table = BuildInnerShadowStyleFields(); return table; } case NodeType::BackgroundBlurStyle: { - static const std::vector table = BuildBackgroundBlurStyleFields(); + static const std::vector table = BuildBackgroundBlurStyleFields(); return table; } case NodeType::BlurFilter: { - static const std::vector table = BuildBlurFilterFields(); + static const std::vector table = BuildBlurFilterFields(); return table; } case NodeType::DropShadowFilter: { - static const std::vector table = BuildDropShadowFilterFields(); + static const std::vector table = BuildDropShadowFilterFields(); return table; } case NodeType::InnerShadowFilter: { - static const std::vector table = BuildInnerShadowFilterFields(); + static const std::vector table = BuildInnerShadowFilterFields(); return table; } case NodeType::BlendFilter: { - static const std::vector table = BuildBlendFilterFields(); + static const std::vector table = BuildBlendFilterFields(); return table; } default: @@ -820,8 +829,8 @@ const std::vector& NodeFieldsFor(NodeType type) { } } -static const NodeFieldDef* FindField(NodeType type, const std::string& channel) { - const auto& table = NodeFieldsFor(type); +static const ChannelDef* FindChannel(NodeType type, const std::string& channel) { + const auto& table = ChannelsFor(type); for (const auto& field : table) { if (channel == field.channel) { return &field; @@ -834,7 +843,7 @@ bool GetNodeChannel(const Node* node, const std::string& channel, KeyValue* out) if (node == nullptr || out == nullptr) { return false; } - const auto* field = FindField(node->nodeType(), channel); + const auto* field = FindChannel(node->nodeType(), channel); if (field == nullptr) { return false; } @@ -847,7 +856,7 @@ bool SetNodeChannel(Node* node, const std::string& channel, const KeyValue& valu if (node == nullptr) { return false; } - const auto* field = FindField(node->nodeType(), channel); + const auto* field = FindChannel(node->nodeType(), channel); if (field == nullptr || !field->access(node, nullptr, &value)) { LOGE("SetNodeChannel: unhandled channel '%s' or value type mismatch for node type %d.", channel.c_str(), static_cast(node->nodeType())); @@ -857,8 +866,13 @@ bool SetNodeChannel(Node* node, const std::string& channel, const KeyValue& valu } bool IsAnimatableChannel(NodeType type, const std::string& channel) { - const auto* field = FindField(type, channel); - return field != nullptr && field->animClass == AnimClass::Animatable; + const auto* field = FindChannel(type, channel); + return field != nullptr && HasFlag(field->flags, ChannelFlags::Animatable); +} + +bool RequiresLayout(NodeType type, const std::string& channel) { + const auto* field = FindChannel(type, channel); + return field != nullptr && HasFlag(field->flags, ChannelFlags::RequiresLayout); } } // namespace pagx diff --git a/src/pagx/PAGXNodeChannel.h b/src/pagx/PAGXNodeChannel.h deleted file mode 100644 index 35615d224a..0000000000 --- a/src/pagx/PAGXNodeChannel.h +++ /dev/null @@ -1,82 +0,0 @@ -///////////////////////////////////////////////////////////////////////////////////////////////// -// -// Tencent is pleased to support the open source community by making libpag available. -// -// Copyright (C) 2026 Tencent. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file -// except in compliance with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// unless required by applicable law or agreed to in writing, software distributed under the -// license is distributed on an "as is" basis, without warranties or conditions of any kind, -// either express or implied. see the license for the specific language governing permissions -// and limitations under the license. -// -///////////////////////////////////////////////////////////////////////////////////////////////// - -#pragma once - -#include -#include -#include "pagx/nodes/Channel.h" -#include "pagx/nodes/Node.h" - -namespace pagx { - -/** - * Animation class of a field channel, controlling whether the channel can be driven by animation. - * - Animatable: a layout-output or pure render property that has a lightweight runtime writer; it - * can be both reflected (read/write on the document node) and animated. - * - LayoutInput: an auto-layout constraint (padding, alignment, container width/height, ...). It - * can be reflected but must NOT be animated, since changing it requires re-running layout. - * - Static: reflectable but neither a layout input nor animatable. - */ -enum class AnimClass { Animatable, LayoutInput, Static }; - -// Reads or writes one node field. Exactly one of getOut / setIn is non-null: getOut for a read -// (copy field into *getOut), setIn for a write (validate and copy into the field). Returns false on -// a type mismatch or invalid enum string. -using NodeAccessFn = bool (*)(Node* node, KeyValue* getOut, const KeyValue* setIn); - -/** - * One reflective field of a node type, addressed by channel name (the same attribute name used in - * PAGX XML, e.g. "alpha", "position.x", "blendMode"). - */ -struct NodeFieldDef { - const char* channel; - AnimClass animClass; - NodeAccessFn access; -}; - -/** - * Returns the reflective field table for the given node type, or an empty table if the type has no - * reflectable scalar fields. The table is the single source of truth for channel names and their - * animation class, consumed by node reflection (GetNodeChannel/SetNodeChannel), animation validity - * checks (IsAnimatableChannel), and — indirectly, via a consistency test — the renderer writer - * table. - */ -const std::vector& NodeFieldsFor(NodeType type); - -/** - * Reads the value of the given channel on the node into out. Returns true on success, false if the - * channel is unknown for the node type, the field type cannot be represented as a KeyValue, or an - * optional field is unset. - */ -bool GetNodeChannel(const Node* node, const std::string& channel, KeyValue* out); - -/** - * Writes value into the node field identified by channel. Returns true on success, false if the - * channel is unknown for the node type, the KeyValue type does not match the field, or an enum - * string is invalid. The node is the source of truth; callers refresh any live scene separately. - */ -bool SetNodeChannel(Node* node, const std::string& channel, const KeyValue& value); - -/** - * Returns true if the given channel exists on the node type and is classified as Animatable, i.e. - * it is valid for an animation channel to drive it. - */ -bool IsAnimatableChannel(NodeType type, const std::string& channel); - -} // namespace pagx diff --git a/src/pagx/runtime/PAGComposition.cpp b/src/pagx/runtime/PAGComposition.cpp index a2a37150c5..795cf66a54 100644 --- a/src/pagx/runtime/PAGComposition.cpp +++ b/src/pagx/runtime/PAGComposition.cpp @@ -17,6 +17,7 @@ ///////////////////////////////////////////////////////////////////////////////////////////////// #include "pagx/PAGComposition.h" +#include #include #include "pagx/PAGLayer.h" #include "pagx/PAGScene.h" @@ -26,6 +27,7 @@ #include "pagx/nodes/AnimationTimeline.h" #include "pagx/nodes/Composition.h" #include "pagx/nodes/Layer.h" +#include "pagx/nodes/Node.h" #include "renderer/LayerBuilder.h" #include "tgfx/layers/Layer.h" @@ -121,6 +123,131 @@ void PAGComposition::buildChildren(const std::vector& layers, } } +void PAGComposition::refreshNodes(const std::vector& dirtyNodes) { + std::unordered_set dirtySet(dirtyNodes.begin(), dirtyNodes.end()); + // Reconcile the child layer list first. A dirty container node means its child list may have + // gained or lost layers. The root composition (node == nullptr) reconciles against the document's + // top-level layers; a child composition reconciles against its source composition's layers. + const std::vector* sourceLayers = nullptr; + if (node == nullptr) { + auto scene = rootScene.lock(); + if (scene != nullptr && scene->document != nullptr) { + sourceLayers = &scene->document->layers; + } + } else if (node->composition != nullptr) { + sourceLayers = &node->composition->layers; + } + bool containerDirty = node == nullptr || dirtySet.find(node) != dirtySet.end(); + if (sourceLayers != nullptr && containerDirty) { + syncChildren(*sourceLayers); + } + + // Refresh the attributes/contents of any dirty layer nodes owned by this composition's binding. + // Iterate the dirty set rather than the top-level children so nested child layers (Layer.children + // built into the same binding) are refreshed too. RefreshLayerInPlace also reconciles each dirty + // layer's own child list, so adding or removing nested children takes effect when the parent layer + // is marked dirty. + if (binding != nullptr) { + for (auto* dirty : dirtyNodes) { + if (dirty == nullptr || dirty->nodeType() != NodeType::Layer) { + continue; + } + auto* dirtyLayer = static_cast(dirty); + if (binding->get(dirtyLayer) != nullptr) { + LayerBuilder::RefreshLayerInPlace(dirtyLayer, binding.get()); + } + } + } + // A mutated node may change which targets a timeline resolves to, so drop the cached resolution. + for (auto& timeline : timelines) { + if (timeline != nullptr) { + timeline->resolved = false; + timeline->resolvedTargets.clear(); + } + } + for (auto& child : children) { + if (child != nullptr && child->layerType() != LayerType::Layer) { + static_cast(child.get())->refreshNodes(dirtyNodes); + } + } +} + +void PAGComposition::syncChildren(const std::vector& sourceLayers) { + auto scene = rootScene.lock(); + if (!scene || binding == nullptr || runtimeLayer == nullptr) { + return; + } + // Index existing runtime children by their source layer node so layers that still exist are + // reused unchanged (their tgfx layers and handles remain valid). + std::unordered_map> existing = {}; + for (auto& child : children) { + if (child != nullptr && child->node != nullptr) { + existing.emplace(child->node, child); + } + } + std::unordered_set kept = {}; + std::vector> newChildren = {}; + newChildren.reserve(sourceLayers.size()); + for (auto* layer : sourceLayers) { + if (layer == nullptr) { + continue; + } + auto it = existing.find(layer); + if (it != existing.end()) { + newChildren.push_back(it->second); + kept.insert(layer); + continue; + } + // Newly added layer: build its tgfx subtree into this binding and wrap it in a runtime node. + if (layer->composition != nullptr) { + std::unordered_set visited = {}; + auto childComposition = PAGComposition::MakeChild(layer, scene, visited); + if (childComposition == nullptr) { + continue; + } + auto slot = binding->get(layer); + if (slot == nullptr) { + slot = LayerBuilder::BuildLayerInto(layer, binding.get()); + } + if (slot != nullptr && childComposition->runtimeLayer != nullptr) { + slot->addChild(childComposition->runtimeLayer); + } + newChildren.push_back(std::move(childComposition)); + } else { + auto layerRuntime = LayerBuilder::BuildLayerInto(layer, binding.get()); + if (layerRuntime == nullptr) { + continue; + } + newChildren.push_back(std::shared_ptr(new PAGLayer(layer, layerRuntime, scene))); + } + } + // Remove runtime children whose source layer is gone: detach their tgfx layer from this parent + // and drop their binding entries so no stale mapping survives. + for (auto& child : children) { + if (child == nullptr || child->node == nullptr || kept.find(child->node) != kept.end()) { + continue; + } + if (child->runtimeLayer != nullptr) { + child->runtimeLayer->removeFromParent(); + } + binding->remove(child->node); + } + children = std::move(newChildren); + // Reorder this parent's direct child tgfx layers to match the document order. The direct child of + // this composition's runtimeLayer is the layer's slot (binding entry), not child->runtimeLayer: a + // composition child's runtimeLayer is the subtree root nested under its slot. addChild on a layer + // already parented here moves it to the top, so appending in source order yields document order. + for (auto& child : children) { + if (child == nullptr || child->node == nullptr) { + continue; + } + auto slot = binding->get(child->node); + if (slot != nullptr) { + runtimeLayer->addChild(slot); + } + } +} + void PAGComposition::advance(int64_t deltaMicroseconds) { for (auto& timeline : timelines) { if (timeline != nullptr) { diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index 2c0b768b2e..36f4f280ea 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -232,6 +232,121 @@ class LayerBuilderContext { return std::move(_result); } + // Rewrites the current state of a single Layer node onto its existing tgfx::Layer, preserving the + // tgfx::Layer object identity so handles holding it stay valid. Reuses the supplied binding both + // to look up the existing layer and to refresh masks. Vector contents, layer attributes, styles + // and filters are regenerated from the node's current fields. Returns false if the node has no + // tgfx::Layer in the binding (e.g. it was never built or belongs to another composition slot). + bool refreshLayerInPlace(const Layer* node, RuntimeBinding* binding) { + if (node == nullptr || binding == nullptr) { + return false; + } + auto layer = binding->get(node); + if (layer == nullptr) { + return false; + } + _result.binding = std::move(*binding); + // applyLayerAttributes only assigns the mutable attributes when they differ from the default, + // so reset them first; otherwise an edit that cleared a matrix / blendMode / scrollRect / style + // / filter would keep the previously built value instead of reverting to the default. + layer->setMatrix(tgfx::Matrix::I()); + layer->setMatrix3D(tgfx::Matrix3D::I()); + layer->setPreserve3D(false); + layer->setBlendMode(ToTGFX(BlendMode::Normal)); + layer->setAllowsEdgeAntialiasing(true); + layer->setPassThroughBackground(true); + layer->setScrollRect(tgfx::Rect::MakeEmpty()); + layer->setLayerStyles({}); + layer->setFilters({}); + // Regenerate vector contents in place; composition slot layers carry no contents and keep their + // runtime-populated children untouched. + if (node->composition == nullptr && !node->contents.empty()) { + auto* vectorLayer = static_cast(layer.get()); + std::vector> contents = {}; + contents.reserve(node->contents.size()); + for (const auto& element : node->contents) { + auto tgfxElement = convertVectorElement(element); + if (tgfxElement) { + contents.push_back(tgfxElement); + } + } + vectorLayer->setContents(contents); + } + applyLayerAttributes(node, layer.get()); + if (node->mask != nullptr) { + _pendingMasks.emplace_back(layer, node->mask, ToTGFXMaskType(node->maskType)); + resolvePendingMasks(); + } + // Reconcile nested child layers (Layer.children) so additions and removals are reflected. + // Composition slot layers are skipped: their children come from the runtime composition slot, + // not from convertLayer's child recursion. + if (node->composition == nullptr) { + reconcileChildLayers(node, layer.get()); + } + *binding = std::move(_result.binding); + return true; + } + + // Reconciles the direct child tgfx layers of a plain (non-composition) Layer node against its + // current node->children list. Child nodes that still map to a tgfx layer are reused (handles stay + // valid); newly added child nodes are built into the binding; removed children are detached and + // unbound (recursively, including their descendants). Surviving children are reordered to match + // the document order. + void reconcileChildLayers(const Layer* node, tgfx::Layer* parentLayer) { + std::unordered_set kept = {}; + for (const auto& child : node->children) { + if (child == nullptr) { + continue; + } + auto childLayer = _result.binding.get(child); + if (childLayer == nullptr) { + // Newly added: build its full subtree (and bindings) via the normal convertLayer path. + childLayer = convertLayer(child); + } + if (childLayer != nullptr) { + // addChild moves an existing child to the top, so appending in document order yields the + // correct final z-order. + parentLayer->addChild(childLayer); + kept.insert(childLayer.get()); + } + } + // Detach and unbind children whose source node was removed from node->children. Iterate a copy + // since removeFromParent mutates the parent's child list. + auto tgfxChildren = parentLayer->children(); + for (const auto& tgfxChild : tgfxChildren) { + if (kept.find(tgfxChild.get()) == kept.end()) { + unbindSubtree(tgfxChild.get()); + tgfxChild->removeFromParent(); + } + } + } + + // Recursively drops binding entries for a detached tgfx layer subtree so removed nodes leave no + // stale node->object mapping behind. + void unbindSubtree(const tgfx::Layer* layer) { + if (layer == nullptr) { + return; + } + const Node* owner = _result.binding.findNode(layer); + if (owner != nullptr) { + _result.binding.remove(owner); + } + for (const auto& child : layer->children()) { + unbindSubtree(child.get()); + } + } + + // Builds a single Layer node into the supplied binding and returns its new tgfx::Layer. Mirrors + // the runtime convertLayer path (composition slots stay empty containers) so the produced layer + // matches one created during the initial build, then hands the populated binding back. + std::shared_ptr buildLayerInto(const Layer* node, RuntimeBinding* binding) { + _result.binding = std::move(*binding); + auto layer = convertLayer(node); + resolvePendingMasks(); + *binding = std::move(_result.binding); + return layer; + } + std::shared_ptr build(const PAGXDocument& document) { // Build layer tree. auto rootLayer = tgfx::Layer::Make(); @@ -2134,4 +2249,23 @@ LayerBuildResult LayerBuilder::BuildCompositionSubtree(const Composition* compos return context.buildSubtree(composition); } +bool LayerBuilder::RefreshLayerInPlace(const Layer* node, RuntimeBinding* binding) { + if (node == nullptr || binding == nullptr) { + return false; + } + LayerBuilderContext context; + context.setNeedsRuntimeData(true); + return context.refreshLayerInPlace(node, binding); +} + +std::shared_ptr LayerBuilder::BuildLayerInto(const Layer* node, + RuntimeBinding* binding) { + if (node == nullptr || binding == nullptr) { + return nullptr; + } + LayerBuilderContext context; + context.setNeedsRuntimeData(true); + return context.buildLayerInto(node, binding); +} + } // namespace pagx diff --git a/src/renderer/LayerBuilder.h b/src/renderer/LayerBuilder.h index 269404c894..41e00976b6 100644 --- a/src/renderer/LayerBuilder.h +++ b/src/renderer/LayerBuilder.h @@ -118,6 +118,24 @@ struct RuntimeBinding { return it->second->getObject(); } + // Drops the mapping for the given node, including its tgfx object and channel writers. Used when + // a node is removed from the document so the binding does not keep a stale entry alive. + void remove(const Node* node) { + targets.erase(node); + } + + // Returns the node whose bound tgfx object is the given pointer, or nullptr if none. Linear scan; + // used by in-place refresh to map a tgfx child layer back to its source node when reconciling + // child lists. + const Node* findNode(const void* object) const { + for (const auto& entry : targets) { + if (entry.second->rawObject() == object) { + return entry.first; + } + } + return nullptr; + } + bool apply(const Node* node, const std::string& channel, const KeyValue& value, float mix) const { auto it = targets.find(node); if (it == targets.end()) { @@ -226,6 +244,31 @@ class LayerBuilder { * had applyLayout() called. */ static LayerBuildResult BuildCompositionSubtree(const Composition* composition); + + /** + * Re-applies the current state of a single Layer node onto its existing tgfx::Layer in place, + * reusing the supplied binding. The tgfx::Layer object identity is preserved so handles that + * hold it stay valid; only its vector contents, transform/render attributes, styles and filters + * are regenerated from the node's current fields. The document must have had applyLayout() called + * (re-run it first when layout-affecting fields changed). Used by PAGScene to reflect post-build + * edits without rebuilding the layer tree. + * @param node The Layer node to refresh. + * @param binding The runtime binding that maps the node to its tgfx::Layer. + * @return true if the node had a tgfx::Layer in the binding and was refreshed, false otherwise. + */ + static bool RefreshLayerInPlace(const Layer* node, RuntimeBinding* binding); + + /** + * Builds a single Layer node (and its vector contents and recursive sub-layers) into the supplied + * existing binding, returning the new tgfx::Layer. Layers referencing a composition produce an + * empty container layer (the runtime PAGComposition slot is populated separately), matching the + * runtime build path. Used to add a newly inserted child layer to a live scene without rebuilding + * the whole tree. + * @param node The Layer node to build. + * @param binding The runtime binding to populate with the node's mapping. + * @return The new tgfx::Layer for the node, or nullptr if node or binding is null. + */ + static std::shared_ptr BuildLayerInto(const Layer* node, RuntimeBinding* binding); }; } // namespace pagx diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index 276e5eb7ea..f733b78498 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -32,6 +32,7 @@ #include "pagx/PAGScene.h" #include "pagx/PAGSurface.h" #include "pagx/PAGTimeline.h" +#include "pagx/PAGXChannelTable.h" #include "pagx/PAGXDocument.h" #include "pagx/PAGXExporter.h" #include "pagx/PAGXImporter.h" @@ -8124,6 +8125,245 @@ PAGX_TEST(PAGXTest, ExportNoiseFilterAnimation) { auto key = "PAGXTest/NoiseFilterAnimation/frame_" + std::to_string(i); EXPECT_TRUE(Baseline::Compare(surface, key)); } +/** + * Test case: notifyChange reflects a render-attribute edit (alpha) on the live tgfx layer in place, + * preserving the existing tgfx::Layer instance so handles stay valid. + */ +PAGX_TEST(PAGXTest, NotifyChangeRenderAttributeInPlace) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + layer->width = 50; + layer->height = 50; + layer->alpha = 1.0f; + doc->layers.push_back(layer); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto tgfxLayer = scene->mutableBinding()->get(layer); + ASSERT_TRUE(tgfxLayer != nullptr); + EXPECT_FLOAT_EQ(tgfxLayer->alpha(), 1.0f); + + layer->alpha = 0.3f; + doc->notifyChange({layer}); + + // Same tgfx::Layer instance is reused (in place), and the new alpha is reflected. + EXPECT_EQ(scene->mutableBinding()->get(layer).get(), tgfxLayer.get()); + EXPECT_FLOAT_EQ(tgfxLayer->alpha(), 0.3f); +} + +/** + * Test case: notifyChange regenerates vector contents so a SolidColor edit is reflected after the + * refresh. + */ +PAGX_TEST(PAGXTest, NotifyChangeVectorContentColor) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + layer->width = 50; + layer->height = 50; + doc->layers.push_back(layer); + auto rect = doc->makeNode(); + rect->size.width = 50; + rect->size.height = 50; + layer->contents.push_back(rect); + auto fill = doc->makeNode(); + auto solid = doc->makeNode(); + solid->color = {1.0f, 0.0f, 0.0f, 1.0f}; + fill->color = solid; + layer->contents.push_back(fill); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto tgfxSolid = scene->mutableBinding()->get(solid); + ASSERT_TRUE(tgfxSolid != nullptr); + EXPECT_EQ(tgfxSolid->color(), tgfx::Color({1.0f, 0.0f, 0.0f, 1.0f})); + + solid->color = {0.0f, 1.0f, 0.0f, 1.0f}; + doc->notifyChange({layer}); + + // Contents are regenerated, so the binding now points at a fresh tgfx SolidColor with the edit. + auto refreshedSolid = scene->mutableBinding()->get(solid); + ASSERT_TRUE(refreshedSolid != nullptr); + EXPECT_EQ(refreshedSolid->color(), tgfx::Color({0.0f, 1.0f, 0.0f, 1.0f})); +} + +/** + * Test case: notifyChange re-runs layout so a geometry edit is reflected in the layer's content + * bounds, while keeping the layer handle valid (the tgfx::Layer instance is preserved). + */ +PAGX_TEST(PAGXTest, NotifyChangeLayoutWidth) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode("L"); + doc->layers.push_back(layer); + auto rect = doc->makeNode(); + rect->position = {0, 0}; + rect->size = {50, 50}; + layer->contents.push_back(rect); + auto fill = doc->makeNode(); + auto solid = doc->makeNode(); + solid->color = {1, 0, 0, 1}; + fill->color = solid; + layer->contents.push_back(fill); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto tgfxLayer = scene->mutableBinding()->get(layer); + ASSERT_TRUE(tgfxLayer != nullptr); + + auto hits = scene->getLayersUnderPoint(10, 10); + ASSERT_FALSE(hits.empty()); + EXPECT_FLOAT_EQ(hits[0]->getBounds().width, 50); + + rect->size = {120, 50}; + doc->notifyChange({layer}); + + // The same tgfx::Layer instance is kept and the regenerated content reflects the new width. + EXPECT_EQ(scene->mutableBinding()->get(layer).get(), tgfxLayer.get()); + auto hitsAfter = scene->getLayersUnderPoint(10, 10); + ASSERT_FALSE(hitsAfter.empty()); + EXPECT_FLOAT_EQ(hitsAfter[0]->getBounds().width, 120); +} + +/** + * Test case: notifyChange resets a timeline's resolved-target cache so a subsequent apply re-binds. + */ +PAGX_TEST(PAGXTest, NotifyChangeResetsTimelineCache) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + layer->width = 50; + layer->height = 50; + doc->layers.push_back(layer); + auto anim = doc->makeNode("anim"); + anim->duration = 60; + anim->frameRate = 60; + doc->animations.push_back(anim); + auto* object = doc->makeNode(); + object->target = "L"; + anim->objects.push_back(object); + auto* alphaChannel = doc->makeNode>(); + alphaChannel->name = "alpha"; + alphaChannel->keyframes.push_back({0, 0.5f, pagx::KeyframeInterpolationType::Hold, {}, {}}); + object->channels.push_back(alphaChannel); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto timeline = scene->getDefaultTimeline(); + ASSERT_TRUE(timeline != nullptr); + timeline->apply(1.0f); + EXPECT_TRUE(timeline->resolved); + + doc->notifyChange({layer}); + EXPECT_FALSE(timeline->resolved); + + // Re-resolving on the next apply still drives the channel correctly. + auto tgfxLayer = scene->mutableBinding()->get(layer); + ASSERT_TRUE(tgfxLayer != nullptr); + tgfxLayer->setAlpha(1.0f); + timeline->apply(1.0f); + EXPECT_FLOAT_EQ(tgfxLayer->alpha(), 0.5f); +} + +/** + * Test case: notifyChange adds a newly inserted top-level layer to the live scene while keeping the + * existing sibling's tgfx layer (and handle) unchanged. + */ +PAGX_TEST(PAGXTest, NotifyChangeAddLayer) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto first = doc->makeNode("A"); + first->width = 50; + first->height = 50; + doc->layers.push_back(first); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto firstTgfx = scene->mutableBinding()->get(first); + ASSERT_TRUE(firstTgfx != nullptr); + + auto second = doc->makeNode("B"); + second->width = 30; + second->height = 30; + doc->layers.push_back(second); + doc->notifyChange({second}); + + // The new layer now has a tgfx mapping, and the existing one is untouched (same instance). + auto secondTgfx = scene->mutableBinding()->get(second); + ASSERT_TRUE(secondTgfx != nullptr); + EXPECT_EQ(scene->mutableBinding()->get(first).get(), firstTgfx.get()); + EXPECT_NE(secondTgfx.get(), firstTgfx.get()); +} + +/** + * Test case: notifyChange removes a deleted layer from the live scene and drops its binding, while + * the surviving sibling's tgfx layer (and handle) stays valid. + */ +PAGX_TEST(PAGXTest, NotifyChangeRemoveLayer) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto first = doc->makeNode("A"); + first->width = 50; + first->height = 50; + doc->layers.push_back(first); + auto second = doc->makeNode("B"); + second->width = 30; + second->height = 30; + doc->layers.push_back(second); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto firstTgfx = scene->mutableBinding()->get(first); + ASSERT_TRUE(firstTgfx != nullptr); + ASSERT_TRUE(scene->mutableBinding()->get(second) != nullptr); + + // Remove the second layer from the document and notify. + doc->layers.pop_back(); + doc->notifyChange({second}); + + // The removed layer's binding is dropped; the surviving layer keeps its instance. + EXPECT_EQ(scene->mutableBinding()->get(second), nullptr); + EXPECT_EQ(scene->mutableBinding()->get(first).get(), firstTgfx.get()); +} + +/** + * Test case: notifyChange reconciles a plain layer's nested children (Layer.children): adding and + * removing a nested child is reflected, while the parent and surviving children keep their handles. + */ +PAGX_TEST(PAGXTest, NotifyChangeNestedChildAddRemove) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto parent = doc->makeNode("P"); + parent->width = 100; + parent->height = 100; + doc->layers.push_back(parent); + auto childA = doc->makeNode("A"); + childA->width = 40; + childA->height = 40; + parent->children.push_back(childA); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto parentTgfx = scene->mutableBinding()->get(parent); + auto childATgfx = scene->mutableBinding()->get(childA); + ASSERT_TRUE(parentTgfx != nullptr); + ASSERT_TRUE(childATgfx != nullptr); + + // Add a nested child B under the parent and notify with the parent (container) node. + auto childB = doc->makeNode("B"); + childB->width = 20; + childB->height = 20; + parent->children.push_back(childB); + doc->notifyChange({parent}); + + // B is built and bound; A and the parent keep their original tgfx instances. + auto childBTgfx = scene->mutableBinding()->get(childB); + ASSERT_TRUE(childBTgfx != nullptr); + EXPECT_EQ(scene->mutableBinding()->get(parent).get(), parentTgfx.get()); + EXPECT_EQ(scene->mutableBinding()->get(childA).get(), childATgfx.get()); + + // Remove the nested child A and notify; its binding is dropped, B and parent stay valid. + parent->children.erase(parent->children.begin()); + doc->notifyChange({parent}); + EXPECT_EQ(scene->mutableBinding()->get(childA), nullptr); + EXPECT_EQ(scene->mutableBinding()->get(childB).get(), childBTgfx.get()); + EXPECT_EQ(scene->mutableBinding()->get(parent).get(), parentTgfx.get()); +} + /** * Test case: GetNodeChannel/SetNodeChannel round-trip scalar fields across node types. */ @@ -8226,8 +8466,78 @@ PAGX_TEST(PAGXTest, NodeChannelRejectsUnsupported) { } /** - * Test case: IsAnimatableChannel reflects the field's animation class. Render outputs are - * animatable; auto-layout inputs (width, padding) and the layer name are not. + * Test case: optional fields read false while unset, then round-trip after a write. + */ +PAGX_TEST(PAGXTest, NodeChannelOptionalFields) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto modifier = doc->makeNode(); + + // Unset optionals report no value on read. + pagx::KeyValue out; + EXPECT_FALSE(pagx::GetNodeChannel(modifier, "strokeWidth", &out)); + EXPECT_FALSE(pagx::GetNodeChannel(modifier, "fillColor", &out)); + + // Writing populates the optional, and the value reads back. + EXPECT_TRUE(pagx::SetNodeChannel(modifier, "strokeWidth", pagx::KeyValue(3.0f))); + EXPECT_TRUE(modifier->strokeWidth.has_value()); + EXPECT_FLOAT_EQ(*modifier->strokeWidth, 3.0f); + EXPECT_TRUE(pagx::GetNodeChannel(modifier, "strokeWidth", &out)); + EXPECT_FLOAT_EQ(std::get(out), 3.0f); + + pagx::Color red = {1.0f, 0.0f, 0.0f, 1.0f}; + EXPECT_TRUE(pagx::SetNodeChannel(modifier, "fillColor", pagx::KeyValue(red))); + EXPECT_TRUE(modifier->fillColor.has_value()); + EXPECT_TRUE(pagx::GetNodeChannel(modifier, "fillColor", &out)); + EXPECT_EQ(std::get(out), red); + + // Wrong value type on an optional is still rejected. + EXPECT_FALSE(pagx::SetNodeChannel(modifier, "strokeWidth", pagx::KeyValue(std::string("x")))); +} + +/** + * Test case: ChannelsFor exposes every channel of a node type, each with a working accessor and + * flags that match the IsAnimatableChannel/RequiresLayout queries. + */ +PAGX_TEST(PAGXTest, NodeChannelTableConsistency) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + + const auto& channels = pagx::ChannelsFor(pagx::NodeType::Layer); + ASSERT_FALSE(channels.empty()); + bool sawAnimatable = false; + bool sawRequiresLayout = false; + bool sawNoFlags = false; + for (const auto& channel : channels) { + // The flags reported by the table must agree with the by-name query helpers. + EXPECT_EQ(pagx::HasFlag(channel.flags, pagx::ChannelFlags::Animatable), + pagx::IsAnimatableChannel(pagx::NodeType::Layer, channel.channel)); + EXPECT_EQ(pagx::HasFlag(channel.flags, pagx::ChannelFlags::RequiresLayout), + pagx::RequiresLayout(pagx::NodeType::Layer, channel.channel)); + // Every listed channel must be readable on a freshly built node. + pagx::KeyValue out; + EXPECT_TRUE(pagx::GetNodeChannel(layer, channel.channel, &out)) + << "channel '" << channel.channel << "' is listed but not readable"; + if (pagx::HasFlag(channel.flags, pagx::ChannelFlags::Animatable)) { + sawAnimatable = true; + } + if (pagx::HasFlag(channel.flags, pagx::ChannelFlags::RequiresLayout)) { + sawRequiresLayout = true; + } + if (channel.flags == pagx::ChannelFlags::None) { + sawNoFlags = true; + } + } + EXPECT_TRUE(sawAnimatable); + EXPECT_TRUE(sawRequiresLayout); + EXPECT_TRUE(sawNoFlags); + + // A node type with no reflectable channels yields an empty table. + EXPECT_TRUE(pagx::ChannelsFor(pagx::NodeType::Document).empty()); +} + +/** + * Test case: IsAnimatableChannel marks channels that have a runtime writer. Pure render outputs and + * in-place geometry are animatable; auto-layout inputs (width, padding) and the layer name are not. */ PAGX_TEST(PAGXTest, NodeChannelAnimatableClass) { EXPECT_TRUE(pagx::IsAnimatableChannel(pagx::NodeType::Layer, "alpha")); @@ -8236,13 +8546,124 @@ PAGX_TEST(PAGXTest, NodeChannelAnimatableClass) { EXPECT_FALSE(pagx::IsAnimatableChannel(pagx::NodeType::Layer, "padding.left")); EXPECT_FALSE(pagx::IsAnimatableChannel(pagx::NodeType::Layer, "name")); - // Geometry outputs are animatable. + // Geometry outputs are animatable (driven in place during playback). EXPECT_TRUE(pagx::IsAnimatableChannel(pagx::NodeType::Rectangle, "size.width")); EXPECT_TRUE(pagx::IsAnimatableChannel(pagx::NodeType::Polystar, "outerRadius")); // Unknown channel is not animatable. EXPECT_FALSE(pagx::IsAnimatableChannel(pagx::NodeType::Rectangle, "nope")); } +/** + * Test case: RequiresLayout marks channels whose document edit only takes effect after a layout + * pass. This covers both auto-layout inputs and layout-derived geometry, so a channel can be both + * animatable and layout-requiring (e.g. a shape's size / position, or a layer's x / y). + */ +PAGX_TEST(PAGXTest, NodeChannelRequiresLayout) { + // Auto-layout inputs require layout. + EXPECT_TRUE(pagx::RequiresLayout(pagx::NodeType::Layer, "width")); + EXPECT_TRUE(pagx::RequiresLayout(pagx::NodeType::Layer, "padding.left")); + EXPECT_TRUE(pagx::RequiresLayout(pagx::NodeType::Text, "fontSize")); + // Layout-derived geometry requires layout even though it is also animatable. + EXPECT_TRUE(pagx::RequiresLayout(pagx::NodeType::Rectangle, "size.width")); + EXPECT_TRUE(pagx::RequiresLayout(pagx::NodeType::Rectangle, "position.x")); + EXPECT_TRUE(pagx::RequiresLayout(pagx::NodeType::Polystar, "outerRadius")); + EXPECT_TRUE(pagx::RequiresLayout(pagx::NodeType::Layer, "x")); + // Pure render channels refresh without layout. + EXPECT_FALSE(pagx::RequiresLayout(pagx::NodeType::Layer, "alpha")); + EXPECT_FALSE(pagx::RequiresLayout(pagx::NodeType::Rectangle, "roundness")); + EXPECT_FALSE(pagx::RequiresLayout(pagx::NodeType::Fill, "alpha")); + // Unknown channel does not require layout. + EXPECT_FALSE(pagx::RequiresLayout(pagx::NodeType::Layer, "nope")); +} + +/** + * Test case: notifyChange with layoutChanged=false skips the layout pass — a render edit still + * takes effect, while a layout edit made in the same call is intentionally NOT reflected (proving + * layout was skipped). + */ +PAGX_TEST(PAGXTest, NotifyChangeRenderOnlySkipsLayout) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode("L"); + layer->alpha = 1.0f; + doc->layers.push_back(layer); + auto rect = doc->makeNode("R"); + rect->position = {0, 0}; + rect->size = {40, 40}; + layer->contents.push_back(rect); + auto fill = doc->makeNode(); + auto solid = doc->makeNode(); + solid->color = {1, 0, 0, 1}; + fill->color = solid; + layer->contents.push_back(fill); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto tgfxLayer = scene->mutableBinding()->get(layer); + ASSERT_TRUE(tgfxLayer != nullptr); + ASSERT_TRUE(scene->mutableBinding()->get(rect) != nullptr); + + // Edit a render field (alpha) and a layout field (rect size) together, but notify as render-only. + // dirty is the host layer: geometry size is refreshed via the layer's RefreshLayerInPlace, which + // rebuilds vector contents from renderSize(). With layout skipped, renderSize() keeps the stale + // value, so the size edit is intentionally not reflected. + layer->alpha = 0.3f; + rect->size = {120, 40}; + doc->notifyChange({layer}, /*layoutChanged=*/false); + + // Render edit reflected; layout edit NOT reflected because layout was skipped (size stays 40). + // Re-fetch the tgfx Rectangle since RefreshLayerInPlace rebuilds vector contents. + EXPECT_FLOAT_EQ(tgfxLayer->alpha(), 0.3f); + EXPECT_FLOAT_EQ(scene->mutableBinding()->get(rect)->size().width, 40.0f); + + // Now notify with layoutChanged=true: re-layout updates renderSize and the edit takes effect. + doc->notifyChange({layer}, /*layoutChanged=*/true); + EXPECT_FLOAT_EQ(scene->mutableBinding()->get(rect)->size().width, 120.0f); +} + +/** + * Test case: the documented edit workflow end to end — mutate a channel via SetNodeChannel, derive + * the layoutChanged flag from RequiresLayout, then call notifyChange. A render channel (alpha) + * refreshes with layout skipped; a layout-affecting channel (rect width via size.width) only takes + * effect once RequiresLayout routes it through a layout pass. + */ +PAGX_TEST(PAGXTest, NotifyChangeFromSetNodeChannel) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode("L"); + layer->alpha = 1.0f; + doc->layers.push_back(layer); + auto rect = doc->makeNode("R"); + rect->position = {0, 0}; + rect->size = {40, 40}; + layer->contents.push_back(rect); + auto fill = doc->makeNode(); + auto solid = doc->makeNode(); + solid->color = {1, 0, 0, 1}; + fill->color = solid; + layer->contents.push_back(fill); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto tgfxLayer = scene->mutableBinding()->get(layer); + ASSERT_TRUE(tgfxLayer != nullptr); + ASSERT_TRUE(scene->mutableBinding()->get(rect) != nullptr); + + // Render channel: alpha does not require layout, so the caller-derived flag skips layout. The + // edit still reaches the live layer. + EXPECT_TRUE(pagx::SetNodeChannel(layer, "alpha", pagx::KeyValue(0.3f))); + bool alphaLayout = pagx::RequiresLayout(layer->nodeType(), "alpha"); + EXPECT_FALSE(alphaLayout); + doc->notifyChange({layer}, alphaLayout); + EXPECT_FLOAT_EQ(tgfxLayer->alpha(), 0.3f); + + // Layout-affecting channel: size.width requires layout, so the derived flag re-runs layout and + // the new width is reflected. Re-fetch the tgfx Rectangle since refresh rebuilds vector contents. + EXPECT_TRUE(pagx::SetNodeChannel(rect, "size.width", pagx::KeyValue(120.0f))); + bool widthLayout = pagx::RequiresLayout(rect->nodeType(), "size.width"); + EXPECT_TRUE(widthLayout); + doc->notifyChange({layer}, widthLayout); + EXPECT_FLOAT_EQ(scene->mutableBinding()->get(rect)->size().width, 120.0f); +} + /** * Test case: a Rectangle's size.width channel drives the tgfx Rectangle size in place. */ @@ -8376,12 +8797,12 @@ PAGX_TEST(PAGXTest, AnimatableChannelsHaveWriters) { pagx::Node* nodes[] = {layer, rect, ellipse, polystar, trim, roundCorner, repeater, fill, stroke, solid, dropStyle, blurFilter}; for (auto* node : nodes) { - for (const auto& field : pagx::NodeFieldsFor(node->nodeType())) { - if (field.animClass != pagx::AnimClass::Animatable) { + for (const auto& channel : pagx::ChannelsFor(node->nodeType())) { + if (!pagx::HasFlag(channel.flags, pagx::ChannelFlags::Animatable)) { continue; } - EXPECT_TRUE(binding->hasWriter(node, field.channel)) - << "node type " << static_cast(node->nodeType()) << " channel '" << field.channel + EXPECT_TRUE(binding->hasWriter(node, channel.channel)) + << "node type " << static_cast(node->nodeType()) << " channel '" << channel.channel << "' is Animatable but has no runtime writer"; } } From 4372aa374aa0a6d51f640a2d32a5ea0959be8ded Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 10:32:13 +0800 Subject: [PATCH 05/39] Mark Text x and y channels as layout-affecting to match other shape positions. --- src/pagx/PAGXNodeChannel.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pagx/PAGXNodeChannel.cpp b/src/pagx/PAGXNodeChannel.cpp index aa33d0e8fc..47190a42fa 100644 --- a/src/pagx/PAGXNodeChannel.cpp +++ b/src/pagx/PAGXNodeChannel.cpp @@ -394,8 +394,8 @@ static std::vector BuildPathFields() { static std::vector BuildTextFields() { std::vector table = { FIELD_STRING(Text, "text", text, Layout), - FIELD_POINT_X(Text, "x", position, Anim), - FIELD_POINT_Y(Text, "y", position, Anim), + FIELD_POINT_X(Text, "x", position, AnimLayout), + FIELD_POINT_Y(Text, "y", position, AnimLayout), FIELD_STRING(Text, "fontFamily", fontFamily, Layout), FIELD_STRING(Text, "fontStyle", fontStyle, Layout), FIELD_FLOAT(Text, "fontSize", fontSize, Layout), From f82d828183209f7be23c4818a1f5efc29e0eaef7 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 10:32:32 +0800 Subject: [PATCH 06/39] Drop value-encoding note for Matrix channels that the reflection API does not expose. --- include/pagx/PAGXNodeChannel.h | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/include/pagx/PAGXNodeChannel.h b/include/pagx/PAGXNodeChannel.h index 94d0e343c9..50b8eea9a8 100644 --- a/include/pagx/PAGXNodeChannel.h +++ b/include/pagx/PAGXNodeChannel.h @@ -34,10 +34,9 @@ namespace pagx { * * Value encoding (KeyValue alternatives): scalars map directly (float/bool/int/string/Color); * enums are passed as their string name (e.g. blendMode = "multiply"); Point/Size fields are - * addressed component-wise via suffixed channels ("position.x", "size.width"); Matrix uses the - * Matrix alternative. Multi-component fields without a component channel are not exposed. The set of - * channels available for each node type is documented in the PAGX schema reference rather than - * enumerated through this API. + * addressed component-wise via suffixed channels ("position.x", "size.width"). Multi-component + * fields without a component channel are not exposed. The set of channels available for each node + * type is documented in the PAGX schema reference rather than enumerated through this API. */ /** From e555f6c8489b99a18b2df54f90787a96e16251ef Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 10:33:14 +0800 Subject: [PATCH 07/39] Reset layer mask during in-place refresh so a removed mask no longer persists. --- src/renderer/LayerBuilder.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index 36f4f280ea..475385e1ff 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -247,8 +247,9 @@ class LayerBuilderContext { } _result.binding = std::move(*binding); // applyLayerAttributes only assigns the mutable attributes when they differ from the default, - // so reset them first; otherwise an edit that cleared a matrix / blendMode / scrollRect / style - // / filter would keep the previously built value instead of reverting to the default. + // and the mask is only re-applied when node->mask is set, so reset them first; otherwise an + // edit that cleared a matrix / blendMode / scrollRect / style / filter / mask would keep the + // previously built value instead of reverting to the default. layer->setMatrix(tgfx::Matrix::I()); layer->setMatrix3D(tgfx::Matrix3D::I()); layer->setPreserve3D(false); @@ -258,6 +259,7 @@ class LayerBuilderContext { layer->setScrollRect(tgfx::Rect::MakeEmpty()); layer->setLayerStyles({}); layer->setFilters({}); + layer->setMask(nullptr); // Regenerate vector contents in place; composition slot layers carry no contents and keep their // runtime-populated children untouched. if (node->composition == nullptr && !node->contents.empty()) { From 9b0808a81f8e75573e9f0cb31fb1f6c7ccb25c2d Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 10:33:25 +0800 Subject: [PATCH 08/39] Add direct unordered_set include used by child-layer reconciliation. --- src/renderer/LayerBuilder.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index 475385e1ff..f76df92fc6 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -19,6 +19,7 @@ #include "LayerBuilder.h" #include #include +#include #include "ToTGFX.h" #include "base/utils/Log.h" #include "pagx/PAGXDocument.h" From d107612f8ed790ec94292c6a39016dcf9c8d16a5 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 10:35:03 +0800 Subject: [PATCH 09/39] Correct Path position channel flags to layout-only since no runtime writer exists. --- src/pagx/PAGXNodeChannel.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pagx/PAGXNodeChannel.cpp b/src/pagx/PAGXNodeChannel.cpp index 47190a42fa..25d8cdb68b 100644 --- a/src/pagx/PAGXNodeChannel.cpp +++ b/src/pagx/PAGXNodeChannel.cpp @@ -383,8 +383,8 @@ static std::vector BuildPolystarFields() { static std::vector BuildPathFields() { std::vector table = { - FIELD_POINT_X(Path, "position.x", position, Anim), - FIELD_POINT_Y(Path, "position.y", position, Anim), + FIELD_POINT_X(Path, "position.x", position, Layout), + FIELD_POINT_Y(Path, "position.y", position, Layout), FIELD_BOOL(Path, "reversed", reversed, NoFlags), }; AppendLayoutNodeFields(table); From a94dbefcbcf70e3bd5dab4c337327ef30603c2c1 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 10:37:19 +0800 Subject: [PATCH 10/39] Detach composition child slot on removal so empty slot containers no longer orphan. --- src/pagx/runtime/PAGComposition.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pagx/runtime/PAGComposition.cpp b/src/pagx/runtime/PAGComposition.cpp index 795cf66a54..4466f431b7 100644 --- a/src/pagx/runtime/PAGComposition.cpp +++ b/src/pagx/runtime/PAGComposition.cpp @@ -222,13 +222,18 @@ void PAGComposition::syncChildren(const std::vector& sourceLayers) { } } // Remove runtime children whose source layer is gone: detach their tgfx layer from this parent - // and drop their binding entries so no stale mapping survives. + // and drop their binding entries so no stale mapping survives. Detach the slot (the binding + // entry, which is this composition's direct child), not child->runtimeLayer: for a composition + // child the latter is the subtree root nested under the slot, so detaching it would orphan the + // empty slot container. Removing the slot detaches it together with its nested subtree. The slot + // must be looked up before binding->remove() erases the mapping. for (auto& child : children) { if (child == nullptr || child->node == nullptr || kept.find(child->node) != kept.end()) { continue; } - if (child->runtimeLayer != nullptr) { - child->runtimeLayer->removeFromParent(); + auto slot = binding->get(child->node); + if (slot != nullptr) { + slot->removeFromParent(); } binding->remove(child->node); } From 3cd2103fdf72a318fdc30aa277ef38b36e2b50e5 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 10:37:38 +0800 Subject: [PATCH 11/39] Update applyLayout doc to reflect that notifyChange safely re-runs layout via the reset branch. --- include/pagx/PAGXDocument.h | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/include/pagx/PAGXDocument.h b/include/pagx/PAGXDocument.h index da883dd9df..30bf8d6074 100644 --- a/include/pagx/PAGXDocument.h +++ b/include/pagx/PAGXDocument.h @@ -143,9 +143,10 @@ class PAGXDocument : public Node { /** * Executes auto layout on the document, positioning layers according to their layout - * constraints. Must be called before rendering or font embedding. This method should only - * be called once per document — repeated calls may produce incorrect results because - * measurement data is cached and some layout operations permanently modify source geometry. + * constraints. Must be called before rendering or font embedding. Re-running layout on an + * already-laid-out document is supported (notifyChange relies on this to reflect edits): the + * reset branch discards the cached layout outputs first so nodes are re-measured from their + * current fields. * @param fontConfig Optional font config for text measurement and rendering. When provided, * updates the internal config before layout. Pass nullptr to use the * previously set config (or no config). From e632a80e0c18079b26b48f41f6239ac458382882 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 10:37:59 +0800 Subject: [PATCH 12/39] Drop transform from notifyChange render-only example since layer position requires layout. --- include/pagx/PAGXDocument.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/pagx/PAGXDocument.h b/include/pagx/PAGXDocument.h index 30bf8d6074..8dd4c9332e 100644 --- a/include/pagx/PAGXDocument.h +++ b/include/pagx/PAGXDocument.h @@ -178,7 +178,7 @@ class PAGXDocument : public Node { /** * Reflects post-build edits to the given nodes in every live PAGScene created from this document. * Refreshes each affected node's runtime content in place, preserving existing layer handles. - * Render-only edits (alpha, color, blendMode, transform) are reflected without re-running layout. + * Render-only edits (alpha, color, blendMode) are reflected without re-running layout. * Adding or removing child layers of a node is supported by passing the parent (container) node: * its child list is reconciled, building newly added layers and removing deleted ones while * reusing unchanged children. From 92fa54571a55a51d178a7cd5e8978030aa1bc473 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 10:53:11 +0800 Subject: [PATCH 13/39] Guard in-place content refresh against layer-type mismatch to avoid invalid VectorLayer cast. --- src/renderer/LayerBuilder.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index f76df92fc6..47db9a5c6a 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -262,8 +262,14 @@ class LayerBuilderContext { layer->setFilters({}); layer->setMask(nullptr); // Regenerate vector contents in place; composition slot layers carry no contents and keep their - // runtime-populated children untouched. - if (node->composition == nullptr && !node->contents.empty()) { + // runtime-populated children untouched. Only a tgfx::VectorLayer can hold contents: convertLayer + // builds a plain tgfx::Layer when the node had no contents at build time and a VectorLayer + // otherwise. Guard the cast on the live layer's concrete type so a static_cast to VectorLayer* is + // never performed on a plain layer, and so a layer whose contents were cleared (now empty, but + // still a VectorLayer) drops its stale elements via setContents({}). The empty -> vector case + // (an edit adds contents to a layer built empty) cannot be handled here: it needs a new + // VectorLayer instance, which would break the tgfx::Layer identity that handles depend on. + if (node->composition == nullptr && layer->type() == tgfx::LayerType::Vector) { auto* vectorLayer = static_cast(layer.get()); std::vector> contents = {}; contents.reserve(node->contents.size()); From 96e6b4b54b0302d07b8fe6f850b377ce1dcb67ee Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 10:55:48 +0800 Subject: [PATCH 14/39] Unbind vector content-element nodes when removing a layer subtree to prevent dangling bindings. --- src/renderer/LayerBuilder.cpp | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index 47db9a5c6a..101764bcaa 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -331,13 +331,19 @@ class LayerBuilderContext { } // Recursively drops binding entries for a detached tgfx layer subtree so removed nodes leave no - // stale node->object mapping behind. + // stale node->object mapping behind. A Layer's vector content elements are bound as their own + // entries keyed by the Element node (not as tgfx layer children), so they are unbound explicitly + // when the owning Layer node is found; otherwise removing a layer subtree would leak those + // element bindings and leave dangling Node* keys. void unbindSubtree(const tgfx::Layer* layer) { if (layer == nullptr) { return; } const Node* owner = _result.binding.findNode(layer); if (owner != nullptr) { + if (owner->nodeType() == NodeType::Layer) { + unbindContentElements(static_cast(owner)->contents); + } _result.binding.remove(owner); } for (const auto& child : layer->children()) { @@ -345,6 +351,21 @@ class LayerBuilderContext { } } + // Drops binding entries for a list of vector content-element nodes, recursing into nested element + // containers (Group / TextBox) so the whole element subtree of a removed layer is unbound. + void unbindContentElements(const std::vector& elements) { + for (auto* element : elements) { + if (element == nullptr) { + continue; + } + auto type = element->nodeType(); + if (type == NodeType::Group || type == NodeType::TextBox) { + unbindContentElements(static_cast(element)->elements); + } + _result.binding.remove(element); + } + } + // Builds a single Layer node into the supplied binding and returns its new tgfx::Layer. Mirrors // the runtime convertLayer path (composition slots stay empty containers) so the produced layer // matches one created during the initial build, then hands the populated binding back. From 1e85c1ca0d1f8e514af37d0448e1bcdc69b932c4 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 11:27:45 +0800 Subject: [PATCH 15/39] Document that matrix interpolation loses rotation winding; use a scalar rotation channel for precise turns. --- src/pagx/runtime/KeyframeEvaluator.h | 6 ++++++ src/pagx/runtime/MixUtils.h | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/src/pagx/runtime/KeyframeEvaluator.h b/src/pagx/runtime/KeyframeEvaluator.h index 4da2b48eb3..14a43cdaed 100644 --- a/src/pagx/runtime/KeyframeEvaluator.h +++ b/src/pagx/runtime/KeyframeEvaluator.h @@ -125,6 +125,12 @@ inline Matrix RecomposeMatrix(const DecomposedMatrix& d) { template <> inline Matrix LerpKeyframeValue(const Matrix& a, const Matrix& b, double t) { + // Limitation: rotation is recovered via atan2 in DecomposeMatrix, so it is confined to (-pi, pi] + // and winding (full turns) is lost. The lerp takes the literal path between the two recovered + // angles rather than the shortest arc, so a keyframe pair crossing the +/-pi boundary spins the + // long way. This is inherent to interpolating baked matrices: the authored angle cannot be + // reconstructed from the matrix alone. Keyframes needing precise multi-turn or boundary-crossing + // rotation should target a scalar rotation channel (e.g. Group::rotation) instead of a Matrix. auto da = DecomposeMatrix(a); auto db = DecomposeMatrix(b); DecomposedMatrix mixed = {}; diff --git a/src/pagx/runtime/MixUtils.h b/src/pagx/runtime/MixUtils.h index 59a4a1fd3d..0fdb54445d 100644 --- a/src/pagx/runtime/MixUtils.h +++ b/src/pagx/runtime/MixUtils.h @@ -42,6 +42,14 @@ inline tgfx::Color MixTGFXColor(const tgfx::Color& current, const tgfx::Color& t // Interpolates two 2D affine matrices by decomposing each into translate / rotation / scale / skew, // mixing the components, and recomposing. This keeps rotation angular and scale uniform so a matrix // tween follows the natural transform path instead of shearing through non-orthogonal states. +// +// Limitation: the rotation recovered via atan2 is confined to (-pi, pi], so winding (full turns) is +// lost and the mix takes the literal path between the two recovered angles rather than the shortest +// arc. A tween that should sweep across the +/-pi boundary (e.g. 170 deg to 190 deg) instead spins +// the long way through 0. This is inherent to interpolating baked matrices: the source angle cannot +// be reconstructed from the matrix alone. Animations that need precise multi-turn or boundary- +// crossing rotation should drive a scalar rotation channel (e.g. Group::rotation) rather than a +// Layer matrix channel. inline tgfx::Matrix MixTGFXMatrix(const tgfx::Matrix& current, const tgfx::Matrix& target, float mix) { float c[9]; From 31b33551ca5674215996d4034c85b1c7626b1014 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 11:56:12 +0800 Subject: [PATCH 16/39] Extract shared matrix decompose/mix/recompose into MatrixDecompose.h to de-duplicate the two interpolators. --- src/pagx/runtime/KeyframeEvaluator.h | 82 +++------------------ src/pagx/runtime/MatrixDecompose.h | 106 +++++++++++++++++++++++++++ src/pagx/runtime/MixUtils.h | 74 +++++-------------- 3 files changed, 136 insertions(+), 126 deletions(-) create mode 100644 src/pagx/runtime/MatrixDecompose.h diff --git a/src/pagx/runtime/KeyframeEvaluator.h b/src/pagx/runtime/KeyframeEvaluator.h index 14a43cdaed..b9dda407ff 100644 --- a/src/pagx/runtime/KeyframeEvaluator.h +++ b/src/pagx/runtime/KeyframeEvaluator.h @@ -23,6 +23,7 @@ #include #include "pagx/nodes/Keyframe.h" #include "pagx/runtime/BezierEasing.h" +#include "pagx/runtime/MatrixDecompose.h" #include "pagx/types/Color.h" #include "pagx/types/Matrix.h" #include "pagx/utils/ColorSpaceUtils.h" @@ -67,80 +68,17 @@ inline ImageRef LerpKeyframeValue(const ImageRef& a, const ImageRef& / return a; } -// Decomposed form of a 2D affine matrix used for interpolation. Interpolating these components -// independently (rather than the raw matrix entries) keeps rotation angular and scale uniform so a -// matrix tween follows the natural translate/rotate/scale/skew path instead of shearing through -// intermediate non-orthogonal states. -struct DecomposedMatrix { - float translateX = 0; - float translateY = 0; - float rotation = 0; // radians - float scaleX = 1; - float scaleY = 1; - float skew = 0; // radians, horizontal shear after rotation -}; - -// Decomposes a 2D affine matrix into translate / rotation / scale / skew. Uses the standard QR-like -// decomposition of the [a c; b d] linear part: the first basis column gives rotation and scaleX, -// the shear of the second column gives skew, and the remaining magnitude gives scaleY. -inline DecomposedMatrix DecomposeMatrix(const Matrix& m) { - DecomposedMatrix out = {}; - out.translateX = m.tx; - out.translateY = m.ty; - float scaleX = std::sqrt(m.a * m.a + m.b * m.b); - float rotation = std::atan2(m.b, m.a); - // Remove rotation from the second column to expose shear and scaleY. - float cosR = std::cos(rotation); - float sinR = std::sin(rotation); - float shearedC = cosR * m.c + sinR * m.d; - float shearedD = -sinR * m.c + cosR * m.d; - float scaleY = shearedD; - float skew = scaleY != 0.0f ? (shearedC / scaleY) : 0.0f; - out.rotation = rotation; - out.scaleX = scaleX; - out.scaleY = scaleY; - out.skew = std::atan(skew); - return out; -} - -// Recomposes a 2D affine matrix from decomposed components, inverting DecomposeMatrix. -inline Matrix RecomposeMatrix(const DecomposedMatrix& d) { - float cosR = std::cos(d.rotation); - float sinR = std::sin(d.rotation); - float tanSkew = std::tan(d.skew); - // Linear part = Rotation * Skew * Scale, matching the decomposition order. - float a = cosR * d.scaleX; - float b = sinR * d.scaleX; - float c = (cosR * tanSkew - sinR) * d.scaleY; - float dd = (sinR * tanSkew + cosR) * d.scaleY; - Matrix m = {}; - m.a = a; - m.b = b; - m.c = c; - m.d = dd; - m.tx = d.translateX; - m.ty = d.translateY; - return m; -} - template <> inline Matrix LerpKeyframeValue(const Matrix& a, const Matrix& b, double t) { - // Limitation: rotation is recovered via atan2 in DecomposeMatrix, so it is confined to (-pi, pi] - // and winding (full turns) is lost. The lerp takes the literal path between the two recovered - // angles rather than the shortest arc, so a keyframe pair crossing the +/-pi boundary spins the - // long way. This is inherent to interpolating baked matrices: the authored angle cannot be - // reconstructed from the matrix alone. Keyframes needing precise multi-turn or boundary-crossing - // rotation should target a scalar rotation channel (e.g. Group::rotation) instead of a Matrix. - auto da = DecomposeMatrix(a); - auto db = DecomposeMatrix(b); - DecomposedMatrix mixed = {}; - mixed.translateX = static_cast(da.translateX + (db.translateX - da.translateX) * t); - mixed.translateY = static_cast(da.translateY + (db.translateY - da.translateY) * t); - mixed.rotation = static_cast(da.rotation + (db.rotation - da.rotation) * t); - mixed.scaleX = static_cast(da.scaleX + (db.scaleX - da.scaleX) * t); - mixed.scaleY = static_cast(da.scaleY + (db.scaleY - da.scaleY) * t); - mixed.skew = static_cast(da.skew + (db.skew - da.skew) * t); - return RecomposeMatrix(mixed); + // See MatrixDecompose.h for the winding limitation: matrix rotation interpolation cannot recover + // full turns, so keyframes needing precise multi-turn or +/-pi-boundary rotation should target a + // scalar rotation channel (e.g. Group::rotation) instead of a Matrix. + auto da = DecomposeAffine(a.a, a.b, a.c, a.d, a.tx, a.ty); + auto db = DecomposeAffine(b.a, b.b, b.c, b.d, b.tx, b.ty); + auto mixed = MixDecomposed(da, db, static_cast(t)); + Matrix result = {}; + RecomposeAffine(mixed, &result.a, &result.b, &result.c, &result.d, &result.tx, &result.ty); + return result; } // Comparator for std::upper_bound: returns true when framePosition precedes the keyframe's time. diff --git a/src/pagx/runtime/MatrixDecompose.h b/src/pagx/runtime/MatrixDecompose.h new file mode 100644 index 0000000000..4bf3d51a1d --- /dev/null +++ b/src/pagx/runtime/MatrixDecompose.h @@ -0,0 +1,106 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include + +namespace pagx { + +// Decomposed form of a 2D affine matrix used for interpolation. Interpolating these components +// independently (rather than the raw matrix entries) keeps rotation angular and scale uniform so a +// matrix tween follows the natural translate/rotate/scale/skew path instead of shearing through +// intermediate non-orthogonal states. +struct DecomposedMatrix { + float translateX = 0; + float translateY = 0; + float rotation = 0; // radians + float scaleX = 1; + float scaleY = 1; + float skew = 0; // radians, horizontal shear after rotation +}; + +// Decomposes the linear part [a c; b d] plus translation (tx, ty) of a 2D affine matrix into +// translate / rotation / scale / skew via a QR-like decomposition: the first basis column gives +// rotation and scaleX, the shear of the second column gives skew, and the remaining magnitude gives +// scaleY. Element naming follows the row-major affine [a c tx; b d ty]. This is the single source of +// truth for matrix decomposition; callers adapt their own matrix type into these six elements. +// +// Limitation: rotation is recovered via atan2 and confined to (-pi, pi], so winding (full turns) is +// lost. Interpolating two decomposed matrices takes the literal path between the recovered angles +// rather than the shortest arc, so a tween crossing the +/-pi boundary spins the long way. This is +// inherent to baked matrices: the authored angle cannot be reconstructed from the matrix alone. +// Animations needing precise multi-turn or boundary-crossing rotation should drive a scalar rotation +// channel (e.g. Group::rotation) instead of a matrix channel. +inline DecomposedMatrix DecomposeAffine(float a, float b, float c, float d, float tx, float ty) { + DecomposedMatrix out = {}; + out.translateX = tx; + out.translateY = ty; + out.scaleX = std::sqrt(a * a + b * b); + out.rotation = std::atan2(b, a); + float cosR = std::cos(out.rotation); + float sinR = std::sin(out.rotation); + float shearedC = cosR * c + sinR * d; + float scaleY = -sinR * c + cosR * d; + out.scaleY = scaleY; + out.skew = scaleY != 0.0f ? std::atan(shearedC / scaleY) : 0.0f; + return out; +} + +// Recomposes the six affine elements [a c tx; b d ty] from decomposed components, inverting +// DecomposeAffine. Outputs are written through the provided pointers (any may be null to skip). +inline void RecomposeAffine(const DecomposedMatrix& m, float* a, float* b, float* c, float* d, + float* tx, float* ty) { + float cosR = std::cos(m.rotation); + float sinR = std::sin(m.rotation); + float tanSkew = std::tan(m.skew); + // Linear part = Rotation * Skew * Scale, matching the decomposition order. + if (a != nullptr) { + *a = cosR * m.scaleX; + } + if (b != nullptr) { + *b = sinR * m.scaleX; + } + if (c != nullptr) { + *c = (cosR * tanSkew - sinR) * m.scaleY; + } + if (d != nullptr) { + *d = (sinR * tanSkew + cosR) * m.scaleY; + } + if (tx != nullptr) { + *tx = m.translateX; + } + if (ty != nullptr) { + *ty = m.translateY; + } +} + +// Interpolates two decomposed matrices component-wise at fraction t in [0, 1]. +inline DecomposedMatrix MixDecomposed(const DecomposedMatrix& a, const DecomposedMatrix& b, + float t) { + DecomposedMatrix out = {}; + out.translateX = a.translateX + (b.translateX - a.translateX) * t; + out.translateY = a.translateY + (b.translateY - a.translateY) * t; + out.rotation = a.rotation + (b.rotation - a.rotation) * t; + out.scaleX = a.scaleX + (b.scaleX - a.scaleX) * t; + out.scaleY = a.scaleY + (b.scaleY - a.scaleY) * t; + out.skew = a.skew + (b.skew - a.skew) * t; + return out; +} + +} // namespace pagx diff --git a/src/pagx/runtime/MixUtils.h b/src/pagx/runtime/MixUtils.h index 0fdb54445d..fc76acc930 100644 --- a/src/pagx/runtime/MixUtils.h +++ b/src/pagx/runtime/MixUtils.h @@ -19,6 +19,7 @@ #pragma once #include +#include "pagx/runtime/MatrixDecompose.h" #include "tgfx/core/Color.h" #include "tgfx/core/Matrix.h" @@ -40,67 +41,32 @@ inline tgfx::Color MixTGFXColor(const tgfx::Color& current, const tgfx::Color& t } // Interpolates two 2D affine matrices by decomposing each into translate / rotation / scale / skew, -// mixing the components, and recomposing. This keeps rotation angular and scale uniform so a matrix -// tween follows the natural transform path instead of shearing through non-orthogonal states. +// mixing the components, and recomposing (see MatrixDecompose.h for the shared component math). // -// Limitation: the rotation recovered via atan2 is confined to (-pi, pi], so winding (full turns) is -// lost and the mix takes the literal path between the two recovered angles rather than the shortest -// arc. A tween that should sweep across the +/-pi boundary (e.g. 170 deg to 190 deg) instead spins -// the long way through 0. This is inherent to interpolating baked matrices: the source angle cannot -// be reconstructed from the matrix alone. Animations that need precise multi-turn or boundary- -// crossing rotation should drive a scalar rotation channel (e.g. Group::rotation) rather than a -// Layer matrix channel. +// Limitation: rotation is recovered via atan2, so winding (full turns) is lost and the mix takes +// the literal path between the recovered angles rather than the shortest arc. A tween crossing the +// +/-pi boundary (e.g. 170 deg to 190 deg) spins the long way through 0. Animations that need +// precise multi-turn or boundary-crossing rotation should drive a scalar rotation channel (e.g. +// Group::rotation) rather than a Layer matrix channel. inline tgfx::Matrix MixTGFXMatrix(const tgfx::Matrix& current, const tgfx::Matrix& target, float mix) { float c[9]; float t[9]; current.get9(c); target.get9(t); - // tgfx Matrix get9 layout: [scaleX skewX transX; skewY scaleY transY; 0 0 1]. - float ca = c[0]; // scaleX - float cc = c[1]; // skewX - float ctx = c[2]; - float cb = c[3]; // skewY - float cd = c[4]; // scaleY - float cty = c[5]; - float ta = t[0]; - float tc = t[1]; - float ttx = t[2]; - float tb = t[3]; - float td = t[4]; - float tty = t[5]; - - float cScaleX = std::sqrt(ca * ca + cb * cb); - float cRot = std::atan2(cb, ca); - float cCos = std::cos(cRot); - float cSin = std::sin(cRot); - float cShearC = cCos * cc + cSin * cd; - float cScaleY = -cSin * cc + cCos * cd; - float cSkew = cScaleY != 0.0f ? std::atan(cShearC / cScaleY) : 0.0f; - - float tScaleX = std::sqrt(ta * ta + tb * tb); - float tRot = std::atan2(tb, ta); - float tCos = std::cos(tRot); - float tSin = std::sin(tRot); - float tShearC = tCos * tc + tSin * td; - float tScaleY = -tSin * tc + tCos * td; - float tSkew = tScaleY != 0.0f ? std::atan(tShearC / tScaleY) : 0.0f; - - float mScaleX = MixFloat(cScaleX, tScaleX, mix); - float mScaleY = MixFloat(cScaleY, tScaleY, mix); - float mRot = MixFloat(cRot, tRot, mix); - float mSkew = MixFloat(cSkew, tSkew, mix); - float mTx = MixFloat(ctx, ttx, mix); - float mTy = MixFloat(cty, tty, mix); - - float mCos = std::cos(mRot); - float mSin = std::sin(mRot); - float mTan = std::tan(mSkew); - float ra = mCos * mScaleX; - float rb = mSin * mScaleX; - float rc = (mCos * mTan - mSin) * mScaleY; - float rd = (mSin * mTan + mCos) * mScaleY; - return tgfx::Matrix::MakeAll(ra, rc, mTx, rb, rd, mTy); + // tgfx Matrix get9 layout: [scaleX skewX transX; skewY scaleY transY; 0 0 1]. DecomposeAffine + // expects the row-major affine [a c tx; b d ty], i.e. a=scaleX, b=skewY, c=skewX, d=scaleY. + auto dCurrent = DecomposeAffine(c[0], c[3], c[1], c[4], c[2], c[5]); + auto dTarget = DecomposeAffine(t[0], t[3], t[1], t[4], t[2], t[5]); + auto mixed = MixDecomposed(dCurrent, dTarget, mix); + float ra = 0; + float rb = 0; + float rc = 0; + float rd = 0; + float rtx = 0; + float rty = 0; + RecomposeAffine(mixed, &ra, &rb, &rc, &rd, &rtx, &rty); + return tgfx::Matrix::MakeAll(ra, rc, rtx, rb, rd, rty); } } // namespace pagx From ed3929f860b32bb1e97b5444302d9ce7caee514a Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 12:37:32 +0800 Subject: [PATCH 17/39] Unbind a removed painter's color source and gradient stops unless another painter still references them. --- src/renderer/LayerBuilder.cpp | 39 ++++++++++++++++++++++++++++++++++- src/renderer/LayerBuilder.h | 18 ++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index 101764bcaa..e3621cffb7 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -352,7 +352,9 @@ class LayerBuilderContext { } // Drops binding entries for a list of vector content-element nodes, recursing into nested element - // containers (Group / TextBox) so the whole element subtree of a removed layer is unbound. + // containers (Group / TextBox) so the whole element subtree of a removed layer is unbound. A + // Fill/Stroke's color source (and a gradient's ColorStops) is bound separately and may be shared + // via an "@id" reference, so it is unbound only when no surviving Fill/Stroke still references it. void unbindContentElements(const std::vector& elements) { for (auto* element : elements) { if (element == nullptr) { @@ -361,11 +363,46 @@ class LayerBuilderContext { auto type = element->nodeType(); if (type == NodeType::Group || type == NodeType::TextBox) { unbindContentElements(static_cast(element)->elements); + } else if (type == NodeType::Fill) { + unbindColorSourceIfUnreferenced(static_cast(element)->color, element); + } else if (type == NodeType::Stroke) { + unbindColorSourceIfUnreferenced(static_cast(element)->color, element); } _result.binding.remove(element); } } + // Unbinds a Fill/Stroke color source, but only if no Fill/Stroke other than excludedOwner still + // points at it. Shared color sources (referenced by several painters via "@id") stay bound as long + // as any referencing painter survives. A gradient's ColorStop bindings are dropped together with + // the gradient. excludedOwner is the painter currently being removed; its own binding is dropped by + // the caller, so it must not count as a surviving reference. + void unbindColorSourceIfUnreferenced(const ColorSource* color, const Element* excludedOwner) { + if (color == nullptr || !_result.binding.contains(color)) { + return; + } + for (const auto* node : _result.binding.boundNodes()) { + if (node == excludedOwner) { + continue; + } + const ColorSource* other = nullptr; + if (node->nodeType() == NodeType::Fill) { + other = static_cast(node)->color; + } else if (node->nodeType() == NodeType::Stroke) { + other = static_cast(node)->color; + } + if (other == color) { + return; + } + } + if (color->nodeType() != NodeType::SolidColor && color->nodeType() != NodeType::ImagePattern) { + for (const auto* stop : static_cast(color)->colorStops) { + _result.binding.remove(stop); + } + } + _result.binding.remove(color); + } + // Builds a single Layer node into the supplied binding and returns its new tgfx::Layer. Mirrors // the runtime convertLayer path (composition slots stay empty containers) so the produced layer // matches one created during the initial build, then hands the populated binding back. diff --git a/src/renderer/LayerBuilder.h b/src/renderer/LayerBuilder.h index 41e00976b6..90eb25e86e 100644 --- a/src/renderer/LayerBuilder.h +++ b/src/renderer/LayerBuilder.h @@ -22,6 +22,7 @@ #include #include #include +#include #include "pagx/PAGXDocument.h" #include "pagx/nodes/Channel.h" #include "tgfx/layers/Layer.h" @@ -136,6 +137,23 @@ struct RuntimeBinding { return nullptr; } + // Returns true if the node currently has a binding entry. + bool contains(const Node* node) const { + return targets.find(node) != targets.end(); + } + + // Collects every node that currently has a binding entry. Used by removal to scan for surviving + // references to a shared resource (e.g. a color source referenced by multiple fills) before + // unbinding it. + std::vector boundNodes() const { + std::vector nodes = {}; + nodes.reserve(targets.size()); + for (const auto& entry : targets) { + nodes.push_back(entry.first); + } + return nodes; + } + bool apply(const Node* node, const std::string& channel, const KeyValue& value, float mix) const { auto it = targets.find(node); if (it == targets.end()) { From 988e2074543ed9dc74254faa6d347d340df7345c Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 12:59:52 +0800 Subject: [PATCH 18/39] Promote an empty layer to a VectorLayer when it gains contents during in-place refresh. --- src/pagx/runtime/PAGComposition.cpp | 11 +++++++ src/renderer/LayerBuilder.cpp | 36 ++++++++++++++++----- test/src/PAGXTest.cpp | 49 +++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/pagx/runtime/PAGComposition.cpp b/src/pagx/runtime/PAGComposition.cpp index 4466f431b7..898623d565 100644 --- a/src/pagx/runtime/PAGComposition.cpp +++ b/src/pagx/runtime/PAGComposition.cpp @@ -157,6 +157,17 @@ void PAGComposition::refreshNodes(const std::vector& dirtyNodes) { LayerBuilder::RefreshLayerInPlace(dirtyLayer, binding.get()); } } + // RefreshLayerInPlace may swap a child's tgfx layer instance (a plain layer promoted to a + // VectorLayer once it gains contents). Re-sync each top-level child's cached runtimeLayer from + // the binding so handles and hit-testing keep pointing at the live layer. + for (auto& child : children) { + if (child != nullptr && child->node != nullptr) { + auto refreshed = binding->get(child->node); + if (refreshed != nullptr && refreshed != child->runtimeLayer) { + child->runtimeLayer = refreshed; + } + } + } } // A mutated node may change which targets a timeline resolves to, so drop the cached resolution. for (auto& timeline : timelines) { diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index e3621cffb7..eb76220251 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -247,6 +247,14 @@ class LayerBuilderContext { return false; } _result.binding = std::move(*binding); + // A plain (non-composition) node that gained contents since it was built as a plain tgfx::Layer + // must be promoted to a VectorLayer, the only layer kind that can hold contents. Swap in a new + // VectorLayer in the same parent slot, move the existing child layers over, and rebind the node; + // child layers and their own bindings are preserved (only this node's tgfx instance changes). + if (node->composition == nullptr && !node->contents.empty() && + layer->type() != tgfx::LayerType::Vector) { + layer = promoteToVectorLayer(node, layer); + } // applyLayerAttributes only assigns the mutable attributes when they differ from the default, // and the mask is only re-applied when node->mask is set, so reset them first; otherwise an // edit that cleared a matrix / blendMode / scrollRect / style / filter / mask would keep the @@ -262,13 +270,10 @@ class LayerBuilderContext { layer->setFilters({}); layer->setMask(nullptr); // Regenerate vector contents in place; composition slot layers carry no contents and keep their - // runtime-populated children untouched. Only a tgfx::VectorLayer can hold contents: convertLayer - // builds a plain tgfx::Layer when the node had no contents at build time and a VectorLayer - // otherwise. Guard the cast on the live layer's concrete type so a static_cast to VectorLayer* is - // never performed on a plain layer, and so a layer whose contents were cleared (now empty, but - // still a VectorLayer) drops its stale elements via setContents({}). The empty -> vector case - // (an edit adds contents to a layer built empty) cannot be handled here: it needs a new - // VectorLayer instance, which would break the tgfx::Layer identity that handles depend on. + // runtime-populated children untouched. Only a tgfx::VectorLayer can hold contents. A node that + // gained contents was promoted above, and one whose contents were cleared (now empty, but still + // a VectorLayer) drops its stale elements via setContents({}). The type guard keeps the cast + // safe for plain layers that never had contents. if (node->composition == nullptr && layer->type() == tgfx::LayerType::Vector) { auto* vectorLayer = static_cast(layer.get()); std::vector> contents = {}; @@ -296,6 +301,23 @@ class LayerBuilderContext { return true; } + // Replaces a plain tgfx::Layer that has gained contents with a new VectorLayer in the same parent + // slot, and rebinds the node to the new instance. The node's child layers are not moved here: the + // subsequent reconcileChildLayers re-attaches each still-bound child onto the new layer. set() + // keeps the node's existing LayerRuntimeTarget and channel writers, only swapping its object + // pointer. Returns the new layer (or the original if it has no parent to swap within). + std::shared_ptr promoteToVectorLayer(const Layer* node, + const std::shared_ptr& oldLayer) { + auto* parent = oldLayer->parent(); + if (parent == nullptr) { + return oldLayer; + } + auto vectorLayer = tgfx::VectorLayer::Make(); + parent->replaceChild(oldLayer, vectorLayer); + _result.binding.set(node, vectorLayer); + return vectorLayer; + } + // Reconciles the direct child tgfx layers of a plain (non-composition) Layer node against its // current node->children list. Child nodes that still map to a tgfx layer are reused (handles stay // valid); newly added child nodes are built into the binding; removed children are detached and diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index f733b78498..825f65f51c 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -102,6 +102,7 @@ #include "tgfx/core/Typeface.h" #include "tgfx/layers/DisplayList.h" #include "tgfx/layers/Layer.h" +#include "tgfx/layers/VectorLayer.h" #include "tgfx/layers/filters/BlendFilter.h" #include "tgfx/layers/filters/BlurFilter.h" #include "tgfx/layers/filters/DropShadowFilter.h" @@ -8186,6 +8187,54 @@ PAGX_TEST(PAGXTest, NotifyChangeVectorContentColor) { EXPECT_EQ(refreshedSolid->color(), tgfx::Color({0.0f, 1.0f, 0.0f, 1.0f})); } +/** + * Test case: notifyChange promotes a layer that was built empty (a plain tgfx::Layer) to a + * VectorLayer once it gains contents, so the added content renders. The owning PAGLayer's + * runtimeLayer is re-synced to the new instance and existing nested children are preserved. + */ +PAGX_TEST(PAGXTest, NotifyChangeEmptyLayerGainsContents) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + layer->width = 50; + layer->height = 50; + doc->layers.push_back(layer); + // A nested child layer so we can verify it survives the promotion. + auto child = doc->makeNode("C"); + child->width = 10; + child->height = 10; + layer->children.push_back(child); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto* binding = scene->mutableBinding(); + // Built with empty contents: the live layer is a plain tgfx::Layer, not a VectorLayer. + auto built = binding->get(layer); + ASSERT_TRUE(built != nullptr); + EXPECT_NE(built->type(), tgfx::LayerType::Vector); + auto childBuilt = binding->get(child); + ASSERT_TRUE(childBuilt != nullptr); + + // Add a rectangle + fill, then notify. The layer must be promoted to a VectorLayer. + auto rect = doc->makeNode(); + rect->size.width = 50; + rect->size.height = 50; + layer->contents.push_back(rect); + auto fill = doc->makeNode(); + auto solid = doc->makeNode(); + solid->color = {1.0f, 0.0f, 0.0f, 1.0f}; + fill->color = solid; + layer->contents.push_back(fill); + doc->notifyChange({layer}); + + // The node is now bound to a VectorLayer, the added content is bound, and the nested child layer + // instance is preserved (its handle stays valid). + auto promoted = binding->get(layer); + ASSERT_TRUE(promoted != nullptr); + EXPECT_EQ(promoted->type(), tgfx::LayerType::Vector); + EXPECT_TRUE(binding->get(solid) != nullptr); + EXPECT_EQ(binding->get(child).get(), childBuilt.get()); +} + /** * Test case: notifyChange re-runs layout so a geometry edit is reflected in the layer's content * bounds, while keeping the layer handle valid (the tgfx::Layer instance is preserved). From 7a894371726a766d8cb9e0af63aedc56de264079 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 13:04:46 +0800 Subject: [PATCH 19/39] Expose the source node id on PAGLayer so callers can identify the originating document node. --- include/pagx/PAGLayer.h | 7 +++++++ src/pagx/PAGLayer.cpp | 4 ++++ test/src/PAGXTest.cpp | 4 +++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/include/pagx/PAGLayer.h b/include/pagx/PAGLayer.h index fd71135816..c8d452b315 100644 --- a/include/pagx/PAGLayer.h +++ b/include/pagx/PAGLayer.h @@ -62,6 +62,13 @@ class PAGLayer { */ std::string name() const; + /** + * Returns the unique identifier of the source node this layer was built from, matching the node's + * "id" attribute in the PAGX document. Returns an empty string if the source node has no id (for + * example, the root composition or a node created without one). + */ + std::string id() const; + /** * Returns the matrix that maps this layer's local coordinate space to the surface coordinate * space, reflecting the layer's current on-screen position (including any animation applied so diff --git a/src/pagx/PAGLayer.cpp b/src/pagx/PAGLayer.cpp index b9d10d8414..5339bcfdb3 100644 --- a/src/pagx/PAGLayer.cpp +++ b/src/pagx/PAGLayer.cpp @@ -40,6 +40,10 @@ std::string PAGLayer::name() const { return node != nullptr ? node->name : std::string(); } +std::string PAGLayer::id() const { + return node != nullptr ? node->id : std::string(); +} + Matrix PAGLayer::getGlobalMatrix() const { auto scene = rootScene.lock(); if (runtimeLayer == nullptr || scene == nullptr) { diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index 825f65f51c..156595c208 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -6999,7 +6999,7 @@ PAGX_TEST(PAGXTest, HitTestSingleLayer) { auto doc = pagx::PAGXDocument::Make(200, 200); ASSERT_TRUE(doc != nullptr); - auto layer = doc->makeNode(); + auto layer = doc->makeNode("hitLayerId"); layer->name = "HitLayer"; auto rect = doc->makeNode(); rect->position = {50, 40}; @@ -7018,6 +7018,8 @@ PAGX_TEST(PAGXTest, HitTestSingleLayer) { auto hits = file->getLayersUnderPoint(50, 40); ASSERT_FALSE(hits.empty()); EXPECT_EQ(hits[0]->name(), "HitLayer"); + // The handle also exposes the source node's id (the "@id" used for document references). + EXPECT_EQ(hits[0]->id(), "hitLayerId"); auto miss = file->getLayersUnderPoint(180, 180); EXPECT_TRUE(miss.empty()); From a1f74951e766f8d6ad29aee1ec708ff9b1fe1b58 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 13:40:58 +0800 Subject: [PATCH 20/39] Re-sync runtimeLayer only for plain child layers so composition children keep their subtree root. --- src/pagx/runtime/PAGComposition.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pagx/runtime/PAGComposition.cpp b/src/pagx/runtime/PAGComposition.cpp index 898623d565..656750491f 100644 --- a/src/pagx/runtime/PAGComposition.cpp +++ b/src/pagx/runtime/PAGComposition.cpp @@ -157,11 +157,13 @@ void PAGComposition::refreshNodes(const std::vector& dirtyNodes) { LayerBuilder::RefreshLayerInPlace(dirtyLayer, binding.get()); } } - // RefreshLayerInPlace may swap a child's tgfx layer instance (a plain layer promoted to a - // VectorLayer once it gains contents). Re-sync each top-level child's cached runtimeLayer from - // the binding so handles and hit-testing keep pointing at the live layer. + // RefreshLayerInPlace may swap a plain child's tgfx layer instance (a plain layer promoted to a + // VectorLayer once it gains contents). Re-sync only plain children: for them the binding entry + // is the runtimeLayer itself. A composition child is skipped because its binding entry is the + // empty slot while its runtimeLayer is the nested subtree root, so re-syncing from the binding + // would overwrite runtimeLayer with the slot and break hit-testing, bounds and nested re-attach. for (auto& child : children) { - if (child != nullptr && child->node != nullptr) { + if (child != nullptr && child->node != nullptr && child->layerType() == LayerType::Layer) { auto refreshed = binding->get(child->node); if (refreshed != nullptr && refreshed != child->runtimeLayer) { child->runtimeLayer = refreshed; From dfe1280de45e687e69ccb56cf4394f0f70d92344 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 13:41:58 +0800 Subject: [PATCH 21/39] Add a composition-child hit-test-after-notifyChange test covering the runtimeLayer re-sync path. --- test/src/PAGXTest.cpp | 48 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index 156595c208..fb542ed64c 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -8237,6 +8237,54 @@ PAGX_TEST(PAGXTest, NotifyChangeEmptyLayerGainsContents) { EXPECT_EQ(binding->get(child).get(), childBuilt.get()); } +/** + * Test case: a composition child's PAGLayer keeps its subtree-root runtimeLayer across a + * notifyChange and stays hit-testable. refreshNodes re-syncs the cached runtimeLayer only for plain + * children; a composition child's binding entry is the empty slot, not its subtree root, so it must + * be skipped or the hit-test would resolve to the slot instead of the nested child. + */ +PAGX_TEST(PAGXTest, NotifyChangeKeepsCompositionChildHitTestable) { + auto doc = pagx::PAGXDocument::Make(200, 200); + ASSERT_TRUE(doc != nullptr); + + auto comp = doc->makeNode("comp"); + comp->width = 100; + comp->height = 100; + auto childLayer = doc->makeNode(); + childLayer->name = "NestedChild"; + auto childRect = doc->makeNode(); + childRect->position = {50, 50}; + childRect->size = {100, 100}; + auto childFill = doc->makeNode(); + auto childSolid = doc->makeNode(); + childSolid->color = {0, 0, 1, 1}; + childFill->color = childSolid; + childLayer->contents.push_back(childRect); + childLayer->contents.push_back(childFill); + comp->layers.push_back(childLayer); + + auto compLayer = doc->makeNode(); + compLayer->name = "CompLayer"; + compLayer->composition = comp; + doc->layers.push_back(compLayer); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + + auto hits = scene->getLayersUnderPoint(50, 50); + ASSERT_FALSE(hits.empty()); + EXPECT_EQ(hits[0]->name(), "NestedChild"); + + // Mark the top-level composition child dirty so refreshNodes runs its runtimeLayer re-sync loop. + doc->notifyChange({compLayer}); + + // The composition child's runtimeLayer was not overwritten with its slot, so the nested child is + // still resolved by the hit-test. + auto hitsAfter = scene->getLayersUnderPoint(50, 50); + ASSERT_FALSE(hitsAfter.empty()); + EXPECT_EQ(hitsAfter[0]->name(), "NestedChild"); +} + /** * Test case: notifyChange re-runs layout so a geometry edit is reflected in the layer's content * bounds, while keeping the layer handle valid (the tgfx::Layer instance is preserved). From bb59eb0112f3da514448da5c00feefc125ebd4c3 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 14:54:38 +0800 Subject: [PATCH 22/39] Unbind an ImagePattern's image only when no surviving pattern still references it. --- src/renderer/LayerBuilder.cpp | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index eb76220251..5222f4442d 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -396,9 +396,11 @@ class LayerBuilderContext { // Unbinds a Fill/Stroke color source, but only if no Fill/Stroke other than excludedOwner still // points at it. Shared color sources (referenced by several painters via "@id") stay bound as long - // as any referencing painter survives. A gradient's ColorStop bindings are dropped together with - // the gradient. excludedOwner is the painter currently being removed; its own binding is dropped by - // the caller, so it must not count as a surviving reference. + // as any referencing painter survives. A gradient's ColorStop bindings are owned children of the + // gradient and are dropped together with it; an ImagePattern's Image is an independently shareable + // resource and is dropped only when no surviving ImagePattern still references it. excludedOwner is + // the painter currently being removed; its own binding is dropped by the caller, so it must not + // count as a surviving reference. void unbindColorSourceIfUnreferenced(const ColorSource* color, const Element* excludedOwner) { if (color == nullptr || !_result.binding.contains(color)) { return; @@ -417,7 +419,9 @@ class LayerBuilderContext { return; } } - if (color->nodeType() != NodeType::SolidColor && color->nodeType() != NodeType::ImagePattern) { + if (color->nodeType() == NodeType::ImagePattern) { + unbindImageIfUnreferenced(static_cast(color)); + } else if (color->nodeType() != NodeType::SolidColor) { for (const auto* stop : static_cast(color)->colorStops) { _result.binding.remove(stop); } @@ -425,6 +429,25 @@ class LayerBuilderContext { _result.binding.remove(color); } + // Unbinds the Image bound for an ImagePattern that is about to be removed, but only if no surviving + // ImagePattern other than pattern still references the same Image node. An Image is a shareable + // "@id" resource, so dropping it unconditionally would break other patterns still using it. + void unbindImageIfUnreferenced(const ImagePattern* pattern) { + const Image* image = pattern->image; + if (image == nullptr || !_result.binding.contains(image)) { + return; + } + for (const auto* node : _result.binding.boundNodes()) { + if (node == pattern || node->nodeType() != NodeType::ImagePattern) { + continue; + } + if (static_cast(node)->image == image) { + return; + } + } + _result.binding.remove(image); + } + // Builds a single Layer node into the supplied binding and returns its new tgfx::Layer. Mirrors // the runtime convertLayer path (composition slots stay empty containers) so the produced layer // matches one created during the initial build, then hands the populated binding back. From a7d1467340678c2a4fabed44bd30c04252e35923 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 14:54:48 +0800 Subject: [PATCH 23/39] Rebuild scene timelines when a timeline node changes so animation add/remove/edit takes effect. --- include/pagx/PAGComposition.h | 19 ++++- include/pagx/PAGXDocument.h | 10 +++ src/pagx/PAGScene.cpp | 29 ++++++-- src/pagx/runtime/PAGComposition.cpp | 54 +++++++++----- test/src/PAGXTest.cpp | 106 ++++++++++++++++++++++++++-- 5 files changed, 187 insertions(+), 31 deletions(-) diff --git a/include/pagx/PAGComposition.h b/include/pagx/PAGComposition.h index 02947b29e4..68847ddb85 100644 --- a/include/pagx/PAGComposition.h +++ b/include/pagx/PAGComposition.h @@ -103,9 +103,9 @@ class PAGComposition : public PAGLayer { // Refreshes this composition after edits: reconciles its child layer list (adding, removing, and // reordering children to match the source layers when the container node is dirty), refreshes the - // content of any dirty leaf layers in place, resets timeline target caches, then recurses into - // child compositions so each refreshes only the nodes in its own binding. Called by - // PAGScene::onNodesChanged. + // content of any dirty leaf layers in place, then recurses into child compositions so each + // refreshes only the nodes in its own binding. Timelines are not touched here; PAGScene resets the + // whole timeline tree separately when a timeline node changed. Called by PAGScene::onNodesChanged. void refreshNodes(const std::vector& dirtyNodes); // Reconciles this composition's runtime children with the given source layer list: reuses @@ -114,6 +114,19 @@ class PAGComposition : public PAGLayer { // parent's tgfx children to match the document order. void syncChildren(const std::vector& sourceLayers); + // Rebuilds this composition's timelines from the owner layer's AnimationTimeline drivers, + // resolving each driver's animation id against the document. Discards any existing timelines + // first, so a removed driver or animation simply produces no timeline and a removed animation node + // (findNode returns null) leaves nothing to drive. Used at build time and to reset timelines when + // a timeline node changes. The root composition has no owner layer and spawns no timelines. + void spawnTimelines(const std::shared_ptr& scene); + + // Recursively resets the timelines of this composition and all descendant compositions. Called + // when an edit touches a timeline node (Animation / AnimationObject / Channel), since timelines + // may share targets and cross-reference, so the whole timeline tree is rebuilt rather than patched + // in place. + void resetTimelines(); + // Document used to resolve channel target IDs for timelines spawned by this composition. For a // sealed external composition this is the layer's externalDoc; otherwise the scene's document. PAGXDocument* document = nullptr; diff --git a/include/pagx/PAGXDocument.h b/include/pagx/PAGXDocument.h index 8dd4c9332e..608af60b08 100644 --- a/include/pagx/PAGXDocument.h +++ b/include/pagx/PAGXDocument.h @@ -182,6 +182,16 @@ class PAGXDocument : public Node { * Adding or removing child layers of a node is supported by passing the parent (container) node: * its child list is reconciled, building newly added layers and removing deleted ones while * reusing unchanged children. + * + * Timelines: when any dirty node is a timeline node (Animation, AnimationObject, or Channel), all + * timelines are rebuilt from the document, so adding, removing, or editing an animation takes + * effect (a removed animation simply stops driving). Edits that do not touch a timeline node leave + * in-progress playback undisturbed. + * + * Not supported by this incremental path (require rebuilding the scene with PAGScene::Make): + * cross-node structural changes such as deleting a node that an animation targets, or repointing + * an AnimationObject.target / a "@id" reference to a different node. notifyChange refreshes the + * passed dirty nodes in place; it does not re-resolve every reference across the document. * @param dirtyNodes the nodes whose fields (or child lists) were mutated. Pointers must reference * nodes still owned by this document. Null entries are ignored. Passing an empty list is a no-op. * @param layoutChanged whether any mutated field affects layout (size, constraints, padding, diff --git a/src/pagx/PAGScene.cpp b/src/pagx/PAGScene.cpp index eba5e96adf..fbba2ae45f 100644 --- a/src/pagx/PAGScene.cpp +++ b/src/pagx/PAGScene.cpp @@ -229,13 +229,30 @@ void PAGScene::onNodesChanged(const std::vector& dirtyNodes) { if (_rootComposition != nullptr) { _rootComposition->refreshNodes(dirtyNodes); } - // Top-level timelines are owned here, not by the root composition, so reset their cached target - // resolution as well: a mutated node may change which targets they resolve to. - for (auto& entry : timelinesByAnimation) { - if (entry.second != nullptr) { - entry.second->resolved = false; - entry.second->resolvedTargets.clear(); + // Reset every timeline only when a timeline node (Animation / AnimationObject / Channel) changed. + // Timelines can share targets and cross-reference, so the whole timeline tree is rebuilt rather + // than patched in place; a removed animation node then simply produces no timeline. Edits that do + // not touch a timeline node leave playback untouched. NOTE: editing a node that an animation + // *targets* (e.g. deleting the target, or changing an id an AnimationObject.target points at) is a + // cross-node structural change that is NOT handled incrementally here — it requires rebuilding the + // scene from the document. + bool timelineDirty = false; + for (auto* node : dirtyNodes) { + if (node == nullptr) { + continue; } + auto type = node->nodeType(); + if (type == NodeType::Animation || type == NodeType::AnimationObject || + type == NodeType::Channel) { + timelineDirty = true; + break; + } + } + if (timelineDirty) { + if (_rootComposition != nullptr) { + _rootComposition->resetTimelines(); + } + timelinesByAnimation.clear(); } } diff --git a/src/pagx/runtime/PAGComposition.cpp b/src/pagx/runtime/PAGComposition.cpp index 656750491f..f082f9c452 100644 --- a/src/pagx/runtime/PAGComposition.cpp +++ b/src/pagx/runtime/PAGComposition.cpp @@ -73,28 +73,50 @@ std::shared_ptr PAGComposition::MakeChild( composition->document = externalDoc != nullptr ? externalDoc : parentScene->document.get(); // Spawn the timelines declared on the owner layer, targeting this composition's own binding and // document, then build the persistent per-layer runtime node tree for the composition content. - for (const auto& driver : ownerLayer->timelines) { + composition->spawnTimelines(parentScene); + visited.insert(sourceComposition); + composition->buildChildren(ownerLayer->composition->layers, visited); + visited.erase(sourceComposition); + return composition; +} + +void PAGComposition::spawnTimelines(const std::shared_ptr& scene) { + // Discard any existing timelines first so a removed driver or animation produces no timeline and a + // removed animation node (findNode returns null below) simply leaves nothing to drive. + timelines.clear(); + // The root composition (node == nullptr) has no owner layer and therefore no drivers. + if (node == nullptr) { + return; + } + for (const auto& driver : node->timelines) { if (driver == nullptr || driver->timelineType() != TimelineType::Animation) { continue; } auto* animationDriver = static_cast(driver.get()); - auto* animation = composition->document != nullptr - ? composition->document->findNode(animationDriver->animationId) - : nullptr; + auto* animation = + document != nullptr ? document->findNode(animationDriver->animationId) : nullptr; if (animation == nullptr) { continue; } - auto timeline = std::shared_ptr( - new PAGTimeline(animation, composition->binding.get(), composition->document, parentScene)); + auto timeline = + std::shared_ptr(new PAGTimeline(animation, binding.get(), document, scene)); if (!animationDriver->playing) { timeline->pause(); } - composition->timelines.push_back(std::move(timeline)); + timelines.push_back(std::move(timeline)); + } +} + +void PAGComposition::resetTimelines() { + auto scene = rootScene.lock(); + if (scene != nullptr) { + spawnTimelines(scene); + } + for (auto& child : children) { + if (child != nullptr && child->layerType() != LayerType::Layer) { + static_cast(child.get())->resetTimelines(); + } } - visited.insert(sourceComposition); - composition->buildChildren(ownerLayer->composition->layers, visited); - visited.erase(sourceComposition); - return composition; } void PAGComposition::buildChildren(const std::vector& layers, @@ -171,13 +193,9 @@ void PAGComposition::refreshNodes(const std::vector& dirtyNodes) { } } } - // A mutated node may change which targets a timeline resolves to, so drop the cached resolution. - for (auto& timeline : timelines) { - if (timeline != nullptr) { - timeline->resolved = false; - timeline->resolvedTargets.clear(); - } - } + // Timelines are intentionally left untouched here: they are reset as a whole tree by PAGScene + // (resetTimelines) only when a timeline node changed, so a plain attribute or structural edit does + // not disturb in-progress playback. for (auto& child : children) { if (child != nullptr && child->layerType() != LayerType::Layer) { static_cast(child.get())->refreshNodes(dirtyNodes); diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index fb542ed64c..37eaa7303e 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -8323,9 +8323,11 @@ PAGX_TEST(PAGXTest, NotifyChangeLayoutWidth) { } /** - * Test case: notifyChange resets a timeline's resolved-target cache so a subsequent apply re-binds. + * Test case: a non-timeline edit (a plain layer attribute) does NOT disturb timelines — the + * timeline keeps its resolved cache and in-progress playback. Only timeline-node edits reset + * timelines. */ -PAGX_TEST(PAGXTest, NotifyChangeResetsTimelineCache) { +PAGX_TEST(PAGXTest, NotifyChangeKeepsTimelineWhenNoTimelineNodeDirty) { auto doc = pagx::PAGXDocument::Make(100, 100); auto layer = doc->makeNode("L"); layer->width = 50; @@ -8350,10 +8352,11 @@ PAGX_TEST(PAGXTest, NotifyChangeResetsTimelineCache) { timeline->apply(1.0f); EXPECT_TRUE(timeline->resolved); + // A plain layer edit is not a timeline node, so the timeline is left untouched (cache preserved). doc->notifyChange({layer}); - EXPECT_FALSE(timeline->resolved); + EXPECT_TRUE(timeline->resolved); - // Re-resolving on the next apply still drives the channel correctly. + // Playback still drives the channel correctly. auto tgfxLayer = scene->mutableBinding()->get(layer); ASSERT_TRUE(tgfxLayer != nullptr); tgfxLayer->setAlpha(1.0f); @@ -8361,6 +8364,101 @@ PAGX_TEST(PAGXTest, NotifyChangeResetsTimelineCache) { EXPECT_FLOAT_EQ(tgfxLayer->alpha(), 0.5f); } +/** + * Test case: editing a timeline node (a Channel keyframe) resets the scene's timelines so the new + * keyframe value takes effect on the next apply. + */ +PAGX_TEST(PAGXTest, NotifyChangeResetsTimelinesOnChannelEdit) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + layer->width = 50; + layer->height = 50; + doc->layers.push_back(layer); + auto anim = doc->makeNode("anim"); + anim->duration = 60; + anim->frameRate = 60; + doc->animations.push_back(anim); + auto* object = doc->makeNode(); + object->target = "L"; + anim->objects.push_back(object); + auto* alphaChannel = doc->makeNode>(); + alphaChannel->name = "alpha"; + alphaChannel->keyframes.push_back({0, 0.5f, pagx::KeyframeInterpolationType::Hold, {}, {}}); + object->channels.push_back(alphaChannel); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto tgfxLayer = scene->mutableBinding()->get(layer); + ASSERT_TRUE(tgfxLayer != nullptr); + scene->getDefaultTimeline()->apply(1.0f); + EXPECT_FLOAT_EQ(tgfxLayer->alpha(), 0.5f); + + // Edit the keyframe value and mark the Channel node dirty: timelines are rebuilt. + alphaChannel->keyframes[0].value = 0.25f; + doc->notifyChange({alphaChannel}); + + // A freshly rebuilt timeline applies the new value. + auto rebuilt = scene->getDefaultTimeline(); + ASSERT_TRUE(rebuilt != nullptr); + tgfxLayer->setAlpha(1.0f); + rebuilt->apply(1.0f); + EXPECT_FLOAT_EQ(tgfxLayer->alpha(), 0.25f); +} + +/** + * Test case: removing the animation driver from a layer and notifying with the animation node dirty + * stops it driving. The composition timeline is rebuilt from the owner layer's now-empty driver + * list, so no timeline drives the target and advancing no longer changes it. + */ +PAGX_TEST(PAGXTest, NotifyChangeRemovedAnimationStopsDriving) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto comp = doc->makeNode("comp"); + comp->width = 50; + comp->height = 50; + auto child = doc->makeNode("child"); + child->width = 50; + child->height = 50; + comp->layers.push_back(child); + + auto anim = doc->makeNode("anim"); + anim->duration = 60; + anim->frameRate = 60; + doc->animations.push_back(anim); + auto* object = doc->makeNode(); + object->target = "child"; + anim->objects.push_back(object); + auto* alphaChannel = doc->makeNode>(); + alphaChannel->name = "alpha"; + alphaChannel->keyframes.push_back({0, 0.5f, pagx::KeyframeInterpolationType::Hold, {}, {}}); + object->channels.push_back(alphaChannel); + + auto compLayer = doc->makeNode("compLayer"); + compLayer->composition = comp; + auto driver = std::make_unique(); + driver->animationId = "anim"; + driver->playing = true; + compLayer->timelines.push_back(std::move(driver)); + doc->layers.push_back(compLayer); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto* binding = static_cast(scene->rootComposition()->children[0].get()) + ->binding.get(); + auto tgfxChild = binding->get(child); + ASSERT_TRUE(tgfxChild != nullptr); + scene->advanceAndApply(0); + EXPECT_FLOAT_EQ(tgfxChild->alpha(), 0.5f); + + // Remove the driver from the layer and notify with the animation node dirty: timelines are rebuilt + // from the now-empty driver list, so nothing drives the child. + compLayer->timelines.clear(); + doc->notifyChange({anim}); + + tgfxChild->setAlpha(1.0f); + scene->advanceAndApply(500'000); + EXPECT_FLOAT_EQ(tgfxChild->alpha(), 1.0f); +} + /** * Test case: notifyChange adds a newly inserted top-level layer to the live scene while keeping the * existing sibling's tgfx layer (and handle) unchanged. From 41787489d22170be2fb4279b41cae9693b216229 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 15:07:14 +0800 Subject: [PATCH 24/39] Document the mark-the-reference-chain-dirty rule and unsupported cross-PAGX edits in notifyChange. --- include/pagx/PAGXDocument.h | 16 +++++++++---- test/src/PAGXTest.cpp | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/include/pagx/PAGXDocument.h b/include/pagx/PAGXDocument.h index 608af60b08..4f92aef428 100644 --- a/include/pagx/PAGXDocument.h +++ b/include/pagx/PAGXDocument.h @@ -188,10 +188,18 @@ class PAGXDocument : public Node { * effect (a removed animation simply stops driving). Edits that do not touch a timeline node leave * in-progress playback undisturbed. * - * Not supported by this incremental path (require rebuilding the scene with PAGScene::Make): - * cross-node structural changes such as deleting a node that an animation targets, or repointing - * an AnimationObject.target / a "@id" reference to a different node. notifyChange refreshes the - * passed dirty nodes in place; it does not re-resolve every reference across the document. + * Reference edits: nodes reference each other by "@id" (e.g. AnimationObject.target, Fill.color, + * Layer.mask). When an edit changes such a relationship, the caller must mark every node on the + * affected reference chain dirty, not only the node it directly mutated, so the runtime re-resolves + * the reference. For example, renaming a node's id, or deleting a node that an animation targets, + * must also pass the referencing AnimationObject so its timeline is rebuilt; repointing a + * Fill.color must pass the owning layer so its contents are regenerated. notifyChange only + * refreshes the dirty nodes it is given; it does not scan the document for other referrers. + * + * Not supported: cross-PAGX edits. A layer that references an external composition document (via + * externalDoc / a "@id" composition file reference) is built into the runtime tree once at scene + * creation. Editing nodes inside an external document, or changing which external document a layer + * references, is not reflected by notifyChange — rebuild the scene with PAGScene::Make instead. * @param dirtyNodes the nodes whose fields (or child lists) were mutated. Pointers must reference * nodes still owned by this document. Null entries are ignored. Passing an empty list is a no-op. * @param layoutChanged whether any mutated field affects layout (size, constraints, padding, diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index 37eaa7303e..8271599d9d 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -8405,6 +8405,54 @@ PAGX_TEST(PAGXTest, NotifyChangeResetsTimelinesOnChannelEdit) { EXPECT_FLOAT_EQ(tgfxLayer->alpha(), 0.25f); } +/** + * Test case: a reference edit (repointing AnimationObject.target to a different node) takes effect + * when the caller marks the referencing timeline node dirty, since the timeline is rebuilt and + * re-resolves its targets. Demonstrates the "mark every node on the reference chain dirty" rule. + */ +PAGX_TEST(PAGXTest, NotifyChangeRetargetAnimationObject) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layerA = doc->makeNode("A"); + layerA->width = 50; + layerA->height = 50; + doc->layers.push_back(layerA); + auto layerB = doc->makeNode("B"); + layerB->width = 50; + layerB->height = 50; + doc->layers.push_back(layerB); + auto anim = doc->makeNode("anim"); + anim->duration = 60; + anim->frameRate = 60; + doc->animations.push_back(anim); + auto* object = doc->makeNode(); + object->target = "A"; + anim->objects.push_back(object); + auto* alphaChannel = doc->makeNode>(); + alphaChannel->name = "alpha"; + alphaChannel->keyframes.push_back({0, 0.5f, pagx::KeyframeInterpolationType::Hold, {}, {}}); + object->channels.push_back(alphaChannel); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto* binding = scene->mutableBinding(); + auto tgfxA = binding->get(layerA); + auto tgfxB = binding->get(layerB); + ASSERT_TRUE(tgfxA != nullptr); + ASSERT_TRUE(tgfxB != nullptr); + scene->getDefaultTimeline()->apply(1.0f); + EXPECT_FLOAT_EQ(tgfxA->alpha(), 0.5f); + + // Repoint the target from A to B and mark the referencing AnimationObject dirty: the timeline is + // rebuilt and re-resolves to B. + object->target = "B"; + tgfxA->setAlpha(1.0f); + tgfxB->setAlpha(1.0f); + doc->notifyChange({object}); + scene->getDefaultTimeline()->apply(1.0f); + EXPECT_FLOAT_EQ(tgfxB->alpha(), 0.5f); + EXPECT_FLOAT_EQ(tgfxA->alpha(), 1.0f); +} + /** * Test case: removing the animation driver from a layer and notifying with the animation node dirty * stops it driving. The composition timeline is rebuilt from the owner layer's now-empty driver From 11dfcd8df17a360b5777002371ee589d2bf94818 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 15:47:40 +0800 Subject: [PATCH 25/39] Sync external composition edits to embedding scenes and reject notifying a foreign document's nodes. --- include/pagx/PAGComposition.h | 21 +++------ include/pagx/PAGXDocument.h | 34 +++++--------- include/pagx/nodes/LayoutNode.h | 7 ++- src/pagx/PAGXDocument.cpp | 22 +++++++++ src/pagx/runtime/PAGComposition.cpp | 6 +++ test/src/PAGXTest.cpp | 72 +++++++++++++++++++++++++++++ 6 files changed, 122 insertions(+), 40 deletions(-) diff --git a/include/pagx/PAGComposition.h b/include/pagx/PAGComposition.h index 68847ddb85..d0ed4adda4 100644 --- a/include/pagx/PAGComposition.h +++ b/include/pagx/PAGComposition.h @@ -101,11 +101,8 @@ class PAGComposition : public PAGLayer { // Returns nullptr if no persistent node owns the layer (internal sub-layer). std::shared_ptr findChildForLayer(const tgfx::Layer* hitLayer); - // Refreshes this composition after edits: reconciles its child layer list (adding, removing, and - // reordering children to match the source layers when the container node is dirty), refreshes the - // content of any dirty leaf layers in place, then recurses into child compositions so each - // refreshes only the nodes in its own binding. Timelines are not touched here; PAGScene resets the - // whole timeline tree separately when a timeline node changed. Called by PAGScene::onNodesChanged. + // Refreshes this composition after edits: reconciles its child layer list and refreshes any dirty + // leaf layers in place, then recurses into child compositions. Called by PAGScene::onNodesChanged. void refreshNodes(const std::vector& dirtyNodes); // Reconciles this composition's runtime children with the given source layer list: reuses @@ -114,17 +111,13 @@ class PAGComposition : public PAGLayer { // parent's tgfx children to match the document order. void syncChildren(const std::vector& sourceLayers); - // Rebuilds this composition's timelines from the owner layer's AnimationTimeline drivers, - // resolving each driver's animation id against the document. Discards any existing timelines - // first, so a removed driver or animation simply produces no timeline and a removed animation node - // (findNode returns null) leaves nothing to drive. Used at build time and to reset timelines when - // a timeline node changes. The root composition has no owner layer and spawns no timelines. + // Rebuilds this composition's timelines from the owner layer's animation drivers, discarding any + // existing ones first so removed drivers or animations simply stop driving. Used at build time and + // when a timeline node changes. The root composition has no owner layer and spawns no timelines. void spawnTimelines(const std::shared_ptr& scene); - // Recursively resets the timelines of this composition and all descendant compositions. Called - // when an edit touches a timeline node (Animation / AnimationObject / Channel), since timelines - // may share targets and cross-reference, so the whole timeline tree is rebuilt rather than patched - // in place. + // Resets the timelines of this composition and all descendant compositions. Called when an edit + // touches a timeline node, rebuilding the whole timeline tree rather than patching it in place. void resetTimelines(); // Document used to resolve channel target IDs for timelines spawned by this composition. For a diff --git a/include/pagx/PAGXDocument.h b/include/pagx/PAGXDocument.h index 4f92aef428..618632edec 100644 --- a/include/pagx/PAGXDocument.h +++ b/include/pagx/PAGXDocument.h @@ -176,30 +176,19 @@ class PAGXDocument : public Node { void clearEmbed(); /** - * Reflects post-build edits to the given nodes in every live PAGScene created from this document. - * Refreshes each affected node's runtime content in place, preserving existing layer handles. - * Render-only edits (alpha, color, blendMode) are reflected without re-running layout. - * Adding or removing child layers of a node is supported by passing the parent (container) node: - * its child list is reconciled, building newly added layers and removing deleted ones while - * reusing unchanged children. + * Reflects post-build edits to the given nodes in every live PAGScene created from this document, + * refreshing each affected node's runtime content in place while preserving existing layer + * handles. Pass a container node to reconcile its child layer list; editing a timeline node + * (Animation, AnimationObject, or Channel) rebuilds all timelines. * - * Timelines: when any dirty node is a timeline node (Animation, AnimationObject, or Channel), all - * timelines are rebuilt from the document, so adding, removing, or editing an animation takes - * effect (a removed animation simply stops driving). Edits that do not touch a timeline node leave - * in-progress playback undisturbed. + * When an edit changes a node's "@id" reference (e.g. AnimationObject.target, Fill.color), mark + * every node on the affected reference chain dirty, not just the mutated one — notifyChange only + * refreshes the nodes it is given and does not re-resolve references elsewhere. * - * Reference edits: nodes reference each other by "@id" (e.g. AnimationObject.target, Fill.color, - * Layer.mask). When an edit changes such a relationship, the caller must mark every node on the - * affected reference chain dirty, not only the node it directly mutated, so the runtime re-resolves - * the reference. For example, renaming a node's id, or deleting a node that an animation targets, - * must also pass the referencing AnimationObject so its timeline is rebuilt; repointing a - * Fill.color must pass the owning layer so its contents are regenerated. notifyChange only - * refreshes the dirty nodes it is given; it does not scan the document for other referrers. - * - * Not supported: cross-PAGX edits. A layer that references an external composition document (via - * externalDoc / a "@id" composition file reference) is built into the runtime tree once at scene - * creation. Editing nodes inside an external document, or changing which external document a layer - * references, is not reflected by notifyChange — rebuild the scene with PAGScene::Make instead. + * Editing an external composition: call notifyChange on the document that owns the edited nodes. + * Every scene that embeds this document as an external composition is refreshed too. A node may + * only be notified through its owning document; passing a node owned by a different (e.g. parent) + * document is rejected with no effect. * @param dirtyNodes the nodes whose fields (or child lists) were mutated. Pointers must reference * nodes still owned by this document. Null entries are ignored. Passing an empty list is a no-op. * @param layoutChanged whether any mutated field affects layout (size, constraints, padding, @@ -241,6 +230,7 @@ class PAGXDocument : public Node { friend class PAGXExporter; friend class TextLayoutContext; friend class PAGScene; + friend class PAGComposition; }; } // namespace pagx diff --git a/include/pagx/nodes/LayoutNode.h b/include/pagx/nodes/LayoutNode.h index bdeb0bb213..28b62640f9 100644 --- a/include/pagx/nodes/LayoutNode.h +++ b/include/pagx/nodes/LayoutNode.h @@ -105,10 +105,9 @@ class LayoutNode { bool hasConstraints() const; /** - * Clears the layout-computed outputs (preferred and resolved position/size) so that a subsequent - * updateSize() / PerformConstraintLayout() pass re-measures and re-resolves from the current - * authored fields. Authored inputs (width/height, constraints, percent sizes) are not touched. - * Used when re-running layout on an already-laid-out document after edits. + * Clears the layout-computed outputs (preferred and resolved position/size) so the node is + * re-measured on the next layout pass. Authored inputs (width/height, constraints, percent sizes) + * are left unchanged. Used when re-running layout on an already-laid-out document after edits. */ void resetLayout(); diff --git a/src/pagx/PAGXDocument.cpp b/src/pagx/PAGXDocument.cpp index a1392b4080..e3d247a708 100644 --- a/src/pagx/PAGXDocument.cpp +++ b/src/pagx/PAGXDocument.cpp @@ -320,6 +320,28 @@ void PAGXDocument::notifyChange(const std::vector& dirtyNodes, bool layou if (dirtyNodes.empty()) { return; } + // A node referenced by an external (child) document is owned by that child document, not this one, + // so it must be notified through its own document. Reject foreign nodes: a parent must not notify + // a child document's nodes. Child document nodes are simply not in this document's node list. + for (auto* node : dirtyNodes) { + if (node == nullptr) { + continue; + } + bool owned = false; + for (auto& ownedNode : nodes) { + if (ownedNode.get() == node) { + owned = true; + break; + } + } + if (!owned) { + LOGE( + "PAGXDocument::notifyChange: node not found in this document; a node owned by an " + "external " + "child document must be notified through that document."); + return; + } + } PruneExpiredScenes(&liveScenes); // Layout-affecting edits (size, constraints, padding, fonts, text, geometry) and structural child // list changes require a full re-layout, since layout is resolved top-down and a single node diff --git a/src/pagx/runtime/PAGComposition.cpp b/src/pagx/runtime/PAGComposition.cpp index f082f9c452..da6d64854a 100644 --- a/src/pagx/runtime/PAGComposition.cpp +++ b/src/pagx/runtime/PAGComposition.cpp @@ -71,6 +71,12 @@ std::shared_ptr PAGComposition::MakeChild( *composition->binding = std::move(buildResult.binding); auto* externalDoc = ownerLayer->externalDoc.get(); composition->document = externalDoc != nullptr ? externalDoc : parentScene->document.get(); + // Register this host scene with the external document so that editing the external (child) + // document and calling its own notifyChange refreshes this scene's embedded subtree. The child + // document keeps only a weak reference, so it never keeps the scene alive. + if (externalDoc != nullptr) { + externalDoc->registerLiveScene(parentScene); + } // Spawn the timelines declared on the owner layer, targeting this composition's own binding and // document, then build the persistent per-layer runtime node tree for the composition content. composition->spawnTimelines(parentScene); diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index 8271599d9d..4fddd35cad 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -6744,6 +6744,78 @@ PAGX_TEST(PAGXTest, ExternalPAGXCompositionLoadFileData) { EXPECT_NEAR(tgfxChild->alpha(), 0.5f, 1.0e-3f); } +/** + * Test case: editing a child (external) document and calling its own notifyChange refreshes the + * embedded subtree inside a parent scene that references it. The parent's tgfx layer instances are + * preserved (same handle), proving the host scene was reverse-registered with the child document. + */ +PAGX_TEST(PAGXTest, ExternalPAGXChildEditSyncsToParentScene) { + std::string mainXML = + "\n" + " \n" + "\n"; + auto doc = pagx::PAGXImporter::FromXML(mainXML); + ASSERT_TRUE(doc != nullptr); + EXPECT_TRUE(doc->loadFileData("child.pagx", + MakePAGXData(MakeExternalCompositionXML("childLayer", "fade")))); + auto* slotLayer = doc->findNode("slot"); + ASSERT_TRUE(slotLayer != nullptr); + ASSERT_TRUE(slotLayer->externalDoc != nullptr); + + auto file = pagx::PAGScene::Make(doc); + ASSERT_TRUE(file != nullptr); + auto* childDoc = slotLayer->externalDoc.get(); + auto* childSolid = childDoc->findNode("childLayer"); + ASSERT_TRUE(childSolid != nullptr); + auto& slotTree = + *static_cast(file->rootComposition()->children[0].get())->binding; + auto childTgfx = slotTree.get(childSolid); + ASSERT_TRUE(childTgfx != nullptr); + EXPECT_FLOAT_EQ(childTgfx->alpha(), 1.0f); + + // Edit a node owned by the CHILD document and notify through the CHILD document. The parent scene + // is reverse-registered, so its embedded subtree refreshes; the tgfx layer instance is preserved. + childSolid->alpha = 0.4f; + childDoc->notifyChange({childSolid}, /*layoutChanged=*/false); + + EXPECT_EQ(slotTree.get(childSolid).get(), childTgfx.get()); + EXPECT_FLOAT_EQ(childTgfx->alpha(), 0.4f); +} + +/** + * Test case: a parent document must not notify a node owned by a child (external) document. Such a + * node is not in the parent's node list, so notifyChange rejects the call and changes nothing. + */ +PAGX_TEST(PAGXTest, ExternalPAGXParentCannotNotifyChildNode) { + std::string mainXML = + "\n" + " \n" + "\n"; + auto doc = pagx::PAGXImporter::FromXML(mainXML); + ASSERT_TRUE(doc != nullptr); + EXPECT_TRUE(doc->loadFileData("child.pagx", + MakePAGXData(MakeExternalCompositionXML("childLayer", "fade")))); + auto* slotLayer = doc->findNode("slot"); + ASSERT_TRUE(slotLayer != nullptr); + ASSERT_TRUE(slotLayer->externalDoc != nullptr); + auto file = pagx::PAGScene::Make(doc); + ASSERT_TRUE(file != nullptr); + auto* childDoc = slotLayer->externalDoc.get(); + auto* childLayer = childDoc->findNode("childLayer"); + ASSERT_TRUE(childLayer != nullptr); + auto& slotTree = + *static_cast(file->rootComposition()->children[0].get())->binding; + auto childTgfx = slotTree.get(childLayer); + ASSERT_TRUE(childTgfx != nullptr); + EXPECT_FLOAT_EQ(childTgfx->alpha(), 1.0f); + + // The parent document does not own the child layer, so notifying it through the parent is rejected + // (logs an error) and the embedded value stays unchanged. + childLayer->alpha = 0.2f; + doc->notifyChange({childLayer}, /*layoutChanged=*/false); + EXPECT_FLOAT_EQ(childTgfx->alpha(), 1.0f); +} + /** * Test case: external file enumeration continues through loaded external PAGX documents. */ From dc6fe497cb76bc1b73f3ab948437ea18f578c47f Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 16:02:14 +0800 Subject: [PATCH 26/39] Rebuild a scene's runtime tree when an embedded external document is edited. --- include/pagx/PAGScene.h | 9 +++++++- include/pagx/PAGXDocument.h | 10 ++++++--- src/pagx/PAGScene.cpp | 42 ++++++++++++++++++++++++++++--------- src/pagx/PAGXDocument.cpp | 38 ++++++++++++++++++--------------- test/src/PAGXTest.cpp | 24 ++++++++++++--------- 5 files changed, 82 insertions(+), 41 deletions(-) diff --git a/include/pagx/PAGScene.h b/include/pagx/PAGScene.h index 093b0dc6b1..94b0a7d829 100644 --- a/include/pagx/PAGScene.h +++ b/include/pagx/PAGScene.h @@ -151,9 +151,16 @@ class PAGScene : public std::enable_shared_from_this { private: PAGScene(); - // Intended dispatch target for PAGXDocument::notifyChange; wiring is not yet implemented. + // Dispatch target for PAGXDocument::notifyChange. Refreshes the runtime tree in place for edits to + // this document's own nodes; rebuilds the whole tree when the edit comes from an embedded external + // document (its nodes are not owned by this scene's document). void onNodesChanged(const std::vector& dirtyNodes); + // Builds or rebuilds the runtime layer tree and binding from the document, detaching any previous + // tree first. Used at creation and when an embedded external document changes (an external + // composition is built into the tree once and cannot be patched in place). + void buildRuntimeTree(); + RuntimeBinding* mutableBinding(); tgfx::DisplayList* getDisplayListForOptions() const; diff --git a/include/pagx/PAGXDocument.h b/include/pagx/PAGXDocument.h index 618632edec..91ff73d498 100644 --- a/include/pagx/PAGXDocument.h +++ b/include/pagx/PAGXDocument.h @@ -186,9 +186,9 @@ class PAGXDocument : public Node { * refreshes the nodes it is given and does not re-resolve references elsewhere. * * Editing an external composition: call notifyChange on the document that owns the edited nodes. - * Every scene that embeds this document as an external composition is refreshed too. A node may - * only be notified through its owning document; passing a node owned by a different (e.g. parent) - * document is rejected with no effect. + * Every scene that embeds this document as an external composition is refreshed too (it rebuilds + * its runtime tree). A node may only be notified through its owning document; passing a node owned + * by a different (e.g. parent) document is rejected with no effect. * @param dirtyNodes the nodes whose fields (or child lists) were mutated. Pointers must reference * nodes still owned by this document. Null entries are ignored. Passing an empty list is a no-op. * @param layoutChanged whether any mutated field affects layout (size, constraints, padding, @@ -214,6 +214,10 @@ class PAGXDocument : public Node { void registerNode(Node* node, const std::string& id); + // Returns true if the node is owned by this document (present in its node list). Used to reject + // notifyChange calls for nodes that belong to a different (e.g. external child) document. + bool ownsNode(const Node* node) const; + // PAGScene lifecycle hooks (called from PAGScene::Make / ~PAGScene). void registerLiveScene(const std::shared_ptr& scene); void unregisterLiveScene(PAGScene* scene); diff --git a/src/pagx/PAGScene.cpp b/src/pagx/PAGScene.cpp index fbba2ae45f..3839ed2761 100644 --- a/src/pagx/PAGScene.cpp +++ b/src/pagx/PAGScene.cpp @@ -54,17 +54,27 @@ std::shared_ptr PAGScene::Make(std::shared_ptr document) scene->document = document; scene->displayList = std::make_unique(); scene->displayOptions = std::unique_ptr(new PAGDisplayOptions(scene.get())); + scene->buildRuntimeTree(); + document->registerLiveScene(scene); + return scene; +} + +void PAGScene::buildRuntimeTree() { + // Detach any previously built tree (a rebuild after an external-document edit) so the display list + // and timeline cache do not retain stale runtime layers. + if (_rootComposition != nullptr && _rootComposition->runtimeLayer != nullptr) { + _rootComposition->runtimeLayer->removeFromParent(); + } + timelinesByAnimation.clear(); auto buildResult = LayerBuilder::BuildForRuntime(document.get()); auto rootComp = std::shared_ptr( - new PAGComposition(nullptr, std::move(buildResult.root), scene)); + new PAGComposition(nullptr, std::move(buildResult.root), shared_from_this())); *rootComp->binding = std::move(buildResult.binding); rootComp->document = document.get(); - scene->_rootComposition = rootComp; + _rootComposition = rootComp; std::unordered_set visited = {}; - scene->_rootComposition->buildChildren(document->layers, visited); - scene->displayList->root()->addChild(rootComp->runtimeLayer); - document->registerLiveScene(scene); - return scene; + _rootComposition->buildChildren(document->layers, visited); + displayList->root()->addChild(rootComp->runtimeLayer); } PAGScene::~PAGScene() { @@ -226,16 +236,28 @@ bool PAGScene::rootToSurfaceMatrix(Matrix* out) const { } void PAGScene::onNodesChanged(const std::vector& dirtyNodes) { + // The dirty nodes belong either to this scene's own document or to an external (child) document + // this scene embeds. A child document dispatches to this scene through its own notifyChange; in + // that case the nodes are not owned here, and an external composition is built into the runtime + // tree once, so it cannot be patched in place — rebuild the whole runtime tree from the document. + bool foreign = false; + for (auto* node : dirtyNodes) { + if (node != nullptr && (document == nullptr || !document->ownsNode(node))) { + foreign = true; + break; + } + } + if (foreign) { + buildRuntimeTree(); + return; + } if (_rootComposition != nullptr) { _rootComposition->refreshNodes(dirtyNodes); } // Reset every timeline only when a timeline node (Animation / AnimationObject / Channel) changed. // Timelines can share targets and cross-reference, so the whole timeline tree is rebuilt rather // than patched in place; a removed animation node then simply produces no timeline. Edits that do - // not touch a timeline node leave playback untouched. NOTE: editing a node that an animation - // *targets* (e.g. deleting the target, or changing an id an AnimationObject.target points at) is a - // cross-node structural change that is NOT handled incrementally here — it requires rebuilding the - // scene from the document. + // not touch a timeline node leave playback untouched. bool timelineDirty = false; for (auto* node : dirtyNodes) { if (node == nullptr) { diff --git a/src/pagx/PAGXDocument.cpp b/src/pagx/PAGXDocument.cpp index e3d247a708..9b87ef1a5e 100644 --- a/src/pagx/PAGXDocument.cpp +++ b/src/pagx/PAGXDocument.cpp @@ -320,25 +320,13 @@ void PAGXDocument::notifyChange(const std::vector& dirtyNodes, bool layou if (dirtyNodes.empty()) { return; } - // A node referenced by an external (child) document is owned by that child document, not this one, - // so it must be notified through its own document. Reject foreign nodes: a parent must not notify - // a child document's nodes. Child document nodes are simply not in this document's node list. + // A node owned by an external (child) document must be notified through that document, not a + // parent. Reject foreign nodes: they are simply not in this document's node list. for (auto* node : dirtyNodes) { - if (node == nullptr) { - continue; - } - bool owned = false; - for (auto& ownedNode : nodes) { - if (ownedNode.get() == node) { - owned = true; - break; - } - } - if (!owned) { + if (node != nullptr && !ownsNode(node)) { LOGE( - "PAGXDocument::notifyChange: node not found in this document; a node owned by an " - "external " - "child document must be notified through that document."); + "PAGXDocument::notifyChange: node not owned by this document; notify it through its own " + "document."); return; } } @@ -358,10 +346,26 @@ void PAGXDocument::notifyChange(const std::vector& dirtyNodes, bool layou } } +bool PAGXDocument::ownsNode(const Node* node) const { + for (auto& ownedNode : nodes) { + if (ownedNode.get() == node) { + return true; + } + } + return false; +} + void PAGXDocument::registerLiveScene(const std::shared_ptr& scene) { if (scene == nullptr) { return; } + // Avoid duplicate entries: a scene re-registers with its external documents on every rebuild. + PruneExpiredScenes(&liveScenes); + for (auto& weakScene : liveScenes) { + if (weakScene.lock() == scene) { + return; + } + } liveScenes.emplace_back(scene); } diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index 4fddd35cad..c402717a80 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -6746,8 +6746,8 @@ PAGX_TEST(PAGXTest, ExternalPAGXCompositionLoadFileData) { /** * Test case: editing a child (external) document and calling its own notifyChange refreshes the - * embedded subtree inside a parent scene that references it. The parent's tgfx layer instances are - * preserved (same handle), proving the host scene was reverse-registered with the child document. + * embedded subtree inside a parent scene that references it. The parent scene reverse-registered + * with the child document, so the edit is reflected (the scene rebuilds its runtime tree). */ PAGX_TEST(PAGXTest, ExternalPAGXChildEditSyncsToParentScene) { std::string mainXML = @@ -6767,19 +6767,23 @@ PAGX_TEST(PAGXTest, ExternalPAGXChildEditSyncsToParentScene) { auto* childDoc = slotLayer->externalDoc.get(); auto* childSolid = childDoc->findNode("childLayer"); ASSERT_TRUE(childSolid != nullptr); - auto& slotTree = - *static_cast(file->rootComposition()->children[0].get())->binding; - auto childTgfx = slotTree.get(childSolid); - ASSERT_TRUE(childTgfx != nullptr); - EXPECT_FLOAT_EQ(childTgfx->alpha(), 1.0f); + { + auto& slotTree = + *static_cast(file->rootComposition()->children[0].get())->binding; + ASSERT_TRUE(slotTree.get(childSolid) != nullptr); + EXPECT_FLOAT_EQ(slotTree.get(childSolid)->alpha(), 1.0f); + } // Edit a node owned by the CHILD document and notify through the CHILD document. The parent scene - // is reverse-registered, so its embedded subtree refreshes; the tgfx layer instance is preserved. + // is reverse-registered, so it rebuilds its runtime tree and reflects the new value. childSolid->alpha = 0.4f; childDoc->notifyChange({childSolid}, /*layoutChanged=*/false); - EXPECT_EQ(slotTree.get(childSolid).get(), childTgfx.get()); - EXPECT_FLOAT_EQ(childTgfx->alpha(), 0.4f); + auto& rebuiltTree = + *static_cast(file->rootComposition()->children[0].get())->binding; + auto refreshed = rebuiltTree.get(childSolid); + ASSERT_TRUE(refreshed != nullptr); + EXPECT_FLOAT_EQ(refreshed->alpha(), 0.4f); } /** From 17d15c3ba839490b5d7518cdfae336a3e2c49c45 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 16:41:09 +0800 Subject: [PATCH 27/39] Resolve top-level timeline binding lazily so a cached handle survives a scene runtime-tree rebuild. --- include/pagx/PAGTimeline.h | 6 ++- src/pagx/PAGScene.cpp | 5 ++- src/pagx/PAGTimeline.cpp | 14 ++++++- test/src/PAGXTest.cpp | 77 ++++++++++++++++++++++++++++++++++---- 4 files changed, 91 insertions(+), 11 deletions(-) diff --git a/include/pagx/PAGTimeline.h b/include/pagx/PAGTimeline.h index e673b8983a..9fef5fc257 100644 --- a/include/pagx/PAGTimeline.h +++ b/include/pagx/PAGTimeline.h @@ -147,8 +147,10 @@ class PAGTimeline { // advance() and apply() bail out once the scene is gone to avoid dereferencing freed memory. std::weak_ptr owner; Animation* animation = nullptr; - // Runtime binding the channel writers should target. Top-level timelines use the owning - // PAGScene's binding; composition timelines use the binding built for that composition. + // Runtime binding the channel writers should target. A null binding marks a top-level timeline: + // the owning scene's current root binding is resolved lazily at apply time, so the timeline keeps + // working after the scene rebuilds its runtime tree (which frees and replaces that binding). A + // non-null binding is a fixed composition binding co-owned with that composition. RuntimeBinding* binding = nullptr; // Document used to resolve channel target IDs at apply time. Top-level timelines use the scene's // primary document; timelines spawned by external composition layers use the layer's externalDoc diff --git a/src/pagx/PAGScene.cpp b/src/pagx/PAGScene.cpp index 3839ed2761..9b42e1c4e2 100644 --- a/src/pagx/PAGScene.cpp +++ b/src/pagx/PAGScene.cpp @@ -115,8 +115,11 @@ std::shared_ptr PAGScene::getTimeline(const std::string& id) { if (it != timelinesByAnimation.end()) { return it->second; } + // Construct with a null binding so the timeline resolves the scene's current root binding lazily + // at apply time. A user-cached handle then survives a runtime-tree rebuild (foreign external-doc + // edit) that replaces _rootComposition and frees the old binding, instead of dangling. auto timeline = std::shared_ptr( - new PAGTimeline(matched, _rootComposition->binding.get(), document.get(), weak_from_this())); + new PAGTimeline(matched, nullptr, document.get(), weak_from_this())); timelinesByAnimation.emplace(matched, timeline); return timeline; } diff --git a/src/pagx/PAGTimeline.cpp b/src/pagx/PAGTimeline.cpp index bc618941ee..ded2fa8494 100644 --- a/src/pagx/PAGTimeline.cpp +++ b/src/pagx/PAGTimeline.cpp @@ -19,6 +19,7 @@ #include "pagx/PAGTimeline.h" #include #include +#include "pagx/PAGScene.h" #include "pagx/PAGXDocument.h" #include "pagx/nodes/Animation.h" #include "pagx/nodes/AnimationObject.h" @@ -182,10 +183,21 @@ void PAGTimeline::apply(float mix) { if (clamped <= 0.0f) { return; } + // A null binding marks a top-level timeline: resolve the owning scene's current root binding now, + // so it tracks a runtime-tree rebuild that replaced the scene's binding. A non-null binding is a + // fixed composition binding co-owned with that composition. + RuntimeBinding* effectiveBinding = binding; + if (effectiveBinding == nullptr) { + auto scene = owner.lock(); + effectiveBinding = scene != nullptr ? scene->mutableBinding() : nullptr; + } + if (effectiveBinding == nullptr) { + return; + } if (!resolved) { resolveTargets(); } - ApplyResolved(resolvedTargets, animation, binding, currentTimeUs, clamped); + ApplyResolved(resolvedTargets, animation, effectiveBinding, currentTimeUs, clamped); } bool PAGTimeline::advanceAndApply(int64_t deltaMicroseconds, float mix) { diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index c402717a80..39f6a665d5 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -6765,27 +6765,90 @@ PAGX_TEST(PAGXTest, ExternalPAGXChildEditSyncsToParentScene) { auto file = pagx::PAGScene::Make(doc); ASSERT_TRUE(file != nullptr); auto* childDoc = slotLayer->externalDoc.get(); - auto* childSolid = childDoc->findNode("childLayer"); - ASSERT_TRUE(childSolid != nullptr); + auto* childLayerNode = childDoc->findNode("childLayer"); + ASSERT_TRUE(childLayerNode != nullptr); { auto& slotTree = *static_cast(file->rootComposition()->children[0].get())->binding; - ASSERT_TRUE(slotTree.get(childSolid) != nullptr); - EXPECT_FLOAT_EQ(slotTree.get(childSolid)->alpha(), 1.0f); + ASSERT_TRUE(slotTree.get(childLayerNode) != nullptr); + EXPECT_FLOAT_EQ(slotTree.get(childLayerNode)->alpha(), 1.0f); } // Edit a node owned by the CHILD document and notify through the CHILD document. The parent scene // is reverse-registered, so it rebuilds its runtime tree and reflects the new value. - childSolid->alpha = 0.4f; - childDoc->notifyChange({childSolid}, /*layoutChanged=*/false); + childLayerNode->alpha = 0.4f; + childDoc->notifyChange({childLayerNode}, /*layoutChanged=*/false); auto& rebuiltTree = *static_cast(file->rootComposition()->children[0].get())->binding; - auto refreshed = rebuiltTree.get(childSolid); + auto refreshed = rebuiltTree.get(childLayerNode); ASSERT_TRUE(refreshed != nullptr); EXPECT_FLOAT_EQ(refreshed->alpha(), 0.4f); } +/** + * Test case: a top-level timeline cached by the caller keeps driving correctly after the scene + * rebuilds its runtime tree (triggered by editing an embedded external child document). The + * timeline resolves the scene's current root binding lazily at apply time, so the cached handle + * does not dangle when the old binding is freed by the rebuild. + */ +PAGX_TEST(PAGXTest, TopLevelTimelineSurvivesExternalChildRebuild) { + std::string mainXML = + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "\n"; + auto doc = pagx::PAGXImporter::FromXML(mainXML); + ASSERT_TRUE(doc != nullptr); + EXPECT_TRUE(doc->loadFileData("child.pagx", + MakePAGXData(MakeExternalCompositionXML("childLayer", "fade")))); + auto* slotLayer = doc->findNode("slot"); + ASSERT_TRUE(slotLayer != nullptr); + ASSERT_TRUE(slotLayer->externalDoc != nullptr); + auto* mainLayer = doc->findNode("mainLayer"); + ASSERT_TRUE(mainLayer != nullptr); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + + // Cache the top-level timeline handle BEFORE the rebuild, like a caller that keeps it per frame. + // Drive to a mid value (0.5) distinct from the layer's default alpha (1.0) so the assertion proves + // the timeline actually wrote through the binding, not that the value happened to match. + auto cachedTimeline = scene->getDefaultTimeline(); + ASSERT_TRUE(cachedTimeline != nullptr); + cachedTimeline->setCurrentTime(500'000); // frame 30 of a 60-frame, 60fps animation + cachedTimeline->apply(1.0f); + EXPECT_FLOAT_EQ(scene->mutableBinding()->get(mainLayer)->alpha(), 0.5f); + + // Edit the CHILD document and notify through it: the parent scene rebuilds its runtime tree, which + // frees the old root binding the cached timeline was originally built against. + auto* childDoc = slotLayer->externalDoc.get(); + auto* childLayerNode = childDoc->findNode("childLayer"); + ASSERT_TRUE(childLayerNode != nullptr); + childLayerNode->alpha = 0.3f; + childDoc->notifyChange({childLayerNode}, /*layoutChanged=*/false); + + // Applying the CACHED handle must re-resolve the new binding and drive the value, not crash. + cachedTimeline->setCurrentTime(500'000); + cachedTimeline->apply(1.0f); + EXPECT_FLOAT_EQ(scene->mutableBinding()->get(mainLayer)->alpha(), 0.5f); +} + /** * Test case: a parent document must not notify a node owned by a child (external) document. Such a * node is not in the parent's node list, so notifyChange rejects the call and changes nothing. From 23d0adce516ca284c0ce8b27cbdb6625d46e42f7 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 16:46:08 +0800 Subject: [PATCH 28/39] Add tests for display-option persistence and deep A-B-C reverse-registration across rebuild. --- test/src/PAGXTest.cpp | 107 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index 39f6a665d5..a505db72a8 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -6849,6 +6849,113 @@ PAGX_TEST(PAGXTest, TopLevelTimelineSurvivesExternalChildRebuild) { EXPECT_FLOAT_EQ(scene->mutableBinding()->get(mainLayer)->alpha(), 0.5f); } +/** + * Test case: the scene's display options (zoom scale and content offset) persist across a runtime + * tree rebuild triggered by an external-child edit. buildRuntimeTree only swaps the root layer on + * the persistent displayList, so the zoom/offset stored on that displayList must survive untouched. + */ +PAGX_TEST(PAGXTest, DisplayOptionsSurviveExternalChildRebuild) { + std::string mainXML = + "\n" + " \n" + "\n"; + auto doc = pagx::PAGXImporter::FromXML(mainXML); + ASSERT_TRUE(doc != nullptr); + EXPECT_TRUE(doc->loadFileData("child.pagx", + MakePAGXData(MakeExternalCompositionXML("childLayer", "fade")))); + auto* slotLayer = doc->findNode("slot"); + ASSERT_TRUE(slotLayer != nullptr); + ASSERT_TRUE(slotLayer->externalDoc != nullptr); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto* options = scene->getDisplayOptions(); + ASSERT_TRUE(options != nullptr); + options->setZoomScale(2.0f); + options->setContentOffset(30.0f, 40.0f); + + // Edit the CHILD document and notify through it to force the parent scene to rebuild its tree. + auto* childDoc = slotLayer->externalDoc.get(); + auto* childLayerNode = childDoc->findNode("childLayer"); + ASSERT_TRUE(childLayerNode != nullptr); + childLayerNode->alpha = 0.5f; + childDoc->notifyChange({childLayerNode}, /*layoutChanged=*/false); + + // Zoom and offset live on the persistent displayList, so the rebuild must leave them unchanged. + EXPECT_FLOAT_EQ(options->getZoomScale(), 2.0f); + EXPECT_FLOAT_EQ(options->getContentOffset().x, 30.0f); + EXPECT_FLOAT_EQ(options->getContentOffset().y, 40.0f); +} + +/** + * Test case: deep (A->B->C) reverse-registration. Document A embeds child B.pagx, which embeds + * grandchild C.pagx. Editing a node in the grandchild document C and notifying through C must + * refresh A's embedded subtree, proving the root scene reverse-registered all the way down (C's + * liveScenes contains A's scene), not just one level. + */ +PAGX_TEST(PAGXTest, ExternalPAGXGrandchildEditSyncsToRootScene) { + std::string mainXML = + "\n" + " \n" + "\n"; + // B embeds C through a layer whose composition points at c.pagx. + std::string childBXML = + "\n" + " \n" + "\n"; + auto doc = pagx::PAGXImporter::FromXML(mainXML); + ASSERT_TRUE(doc != nullptr); + EXPECT_TRUE(doc->loadFileData("b.pagx", MakePAGXData(childBXML))); + // After B is loaded its own external file (c.pagx) becomes enumerable; load it too. + EXPECT_TRUE( + doc->loadFileData("c.pagx", MakePAGXData(MakeExternalCompositionXML("grandLayer", "fade")))); + EXPECT_TRUE(doc->getExternalFilePaths().empty()); + + auto* slotB = doc->findNode("slotB"); + ASSERT_TRUE(slotB != nullptr); + ASSERT_TRUE(slotB->externalDoc != nullptr); + auto* bDoc = slotB->externalDoc.get(); + auto* slotC = bDoc->findNode("slotC"); + ASSERT_TRUE(slotC != nullptr); + ASSERT_TRUE(slotC->externalDoc != nullptr); + auto* cDoc = slotC->externalDoc.get(); + auto* grandLayer = cDoc->findNode("grandLayer"); + ASSERT_TRUE(grandLayer != nullptr); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + + // The root scene must have reverse-registered into the GRANDCHILD document, otherwise notifying + // through cDoc would be a silent no-op (a missing deep-registration bug). + ASSERT_FALSE(cDoc->liveScenes.empty()); + bool rootRegistered = false; + for (auto& weakScene : cDoc->liveScenes) { + if (weakScene.lock() == scene) { + rootRegistered = true; + break; + } + } + EXPECT_TRUE(rootRegistered); + + // Locate the grandchild's runtime layer through A's tree: root -> B composition -> C composition. + auto* bComposition = + static_cast(scene->rootComposition()->children[0].get()); + auto* cComposition = static_cast(bComposition->children[0].get()); + ASSERT_TRUE(cComposition->binding->get(grandLayer) != nullptr); + EXPECT_FLOAT_EQ(cComposition->binding->get(grandLayer)->alpha(), 1.0f); + + // Edit the grandchild node and notify through the grandchild document: A's scene rebuilds and the + // deeply embedded subtree reflects the new value. + grandLayer->alpha = 0.25f; + cDoc->notifyChange({grandLayer}, /*layoutChanged=*/false); + + auto* rebuiltB = static_cast(scene->rootComposition()->children[0].get()); + auto* rebuiltC = static_cast(rebuiltB->children[0].get()); + auto refreshed = rebuiltC->binding->get(grandLayer); + ASSERT_TRUE(refreshed != nullptr); + EXPECT_FLOAT_EQ(refreshed->alpha(), 0.25f); +} + /** * Test case: a parent document must not notify a node owned by a child (external) document. Such a * node is not in the parent's node list, so notifyChange rejects the call and changes nothing. From 953fd96cd7b8e363153e6e9632bf3029bb2a3b84 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 17:18:56 +0800 Subject: [PATCH 29/39] Re-seed layer transform baseline on in-place refresh and broaden the animatable-writer registry test. --- src/renderer/LayerBuilder.cpp | 10 ++++++ src/renderer/LayerBuilder.h | 7 ++++ test/src/PAGXTest.cpp | 66 ++++++++++++++++++++++++++++++++--- 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index 5222f4442d..9a85f4ac07 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -287,6 +287,16 @@ class LayerBuilderContext { vectorLayer->setContents(contents); } applyLayerAttributes(node, layer.get()); + // Re-seed the decomposed transform baseline from the node's current authored values, mirroring + // the initial build (see convertLayer). The Layer's x / y / matrix channels are AnimLayout, so a + // document edit to them must update the LayerRuntimeTarget baseline; otherwise a concurrent + // transform animation would mix against the stale baseline. The target installed for a Layer + // node is always a LayerRuntimeTarget (convertLayer), so the static_cast is safe. + auto* layerTarget = static_cast(_result.binding.getTarget(node)); + if (layerTarget != nullptr) { + auto layerPos = node->renderPosition(); + layerTarget->initTransform(layerPos.x, layerPos.y, ToTGFX(node->matrix)); + } if (node->mask != nullptr) { _pendingMasks.emplace_back(layer, node->mask, ToTGFXMaskType(node->maskType)); resolvePendingMasks(); diff --git a/src/renderer/LayerBuilder.h b/src/renderer/LayerBuilder.h index 90eb25e86e..df50ae8257 100644 --- a/src/renderer/LayerBuilder.h +++ b/src/renderer/LayerBuilder.h @@ -183,6 +183,13 @@ struct RuntimeBinding { return raw; } + // Returns the node's installed RuntimeTarget, or nullptr if none. Used to re-seed a layer's + // transform baseline on an in-place refresh. + RuntimeTarget* getTarget(const Node* node) const { + auto it = targets.find(node); + return it != targets.end() ? it->second.get() : nullptr; + } + private: // Returns the existing target for the node, creating a plain RuntimeTarget if none exists yet. RuntimeTarget* ensureTarget(const Node* node) { diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index a505db72a8..8b4cdcce33 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -45,11 +45,14 @@ #include "pagx/nodes/Animation.h" #include "pagx/nodes/AnimationObject.h" #include "pagx/nodes/AnimationTimeline.h" +#include "pagx/nodes/BackgroundBlurStyle.h" #include "pagx/nodes/BlendFilter.h" #include "pagx/nodes/BlurFilter.h" #include "pagx/nodes/Channel.h" #include "pagx/nodes/ColorStop.h" #include "pagx/nodes/Composition.h" +#include "pagx/nodes/ConicGradient.h" +#include "pagx/nodes/DiamondGradient.h" #include "pagx/nodes/DropShadowFilter.h" #include "pagx/nodes/DropShadowStyle.h" #include "pagx/nodes/Ellipse.h" @@ -59,6 +62,7 @@ #include "pagx/nodes/Group.h" #include "pagx/nodes/Image.h" #include "pagx/nodes/ImagePattern.h" +#include "pagx/nodes/InnerShadowFilter.h" #include "pagx/nodes/InnerShadowStyle.h" #include "pagx/nodes/Layer.h" #include "pagx/nodes/LinearGradient.h" @@ -67,6 +71,7 @@ #include "pagx/nodes/Path.h" #include "pagx/nodes/PathData.h" #include "pagx/nodes/Polystar.h" +#include "pagx/nodes/RadialGradient.h" #include "pagx/nodes/RangeSelector.h" #include "pagx/nodes/Rectangle.h" #include "pagx/nodes/Repeater.h" @@ -9273,20 +9278,73 @@ PAGX_TEST(PAGXTest, AnimatableChannelsHaveWriters) { stroke->color = strokeColor; layer->contents.push_back(stroke); - // A drop shadow style and a blur filter on the layer. + // A group with transform channels, plus a fill backed by each gradient kind so the gradient point + // writers and their ColorStop writers are exercised. + auto group = doc->makeNode(); + auto groupRect = doc->makeNode(); + groupRect->size = {20, 20}; + group->elements.push_back(groupRect); + layer->contents.push_back(group); + + auto linear = doc->makeNode(); + auto linearStop = doc->makeNode(); + linearStop->color = {1, 0, 0, 1}; + linear->colorStops.push_back(linearStop); + auto linearFill = doc->makeNode(); + linearFill->color = linear; + layer->contents.push_back(linearFill); + + auto radial = doc->makeNode(); + auto radialFill = doc->makeNode(); + radialFill->color = radial; + layer->contents.push_back(radialFill); + + auto conic = doc->makeNode(); + auto conicFill = doc->makeNode(); + conicFill->color = conic; + layer->contents.push_back(conicFill); + + auto diamond = doc->makeNode(); + auto diamondFill = doc->makeNode(); + diamondFill->color = diamond; + layer->contents.push_back(diamondFill); + + // A text modifier carrying a range selector exercises the modifier transform/color writers and + // the selector writers. + auto modifier = doc->makeNode(); + auto selector = doc->makeNode(); + modifier->selectors.push_back(selector); + layer->contents.push_back(modifier); + + // One of each layer style and filter kind so their animatable writers are covered. auto dropStyle = doc->makeNode(); layer->styles.push_back(dropStyle); + auto innerStyle = doc->makeNode(); + layer->styles.push_back(innerStyle); + auto backgroundBlurStyle = doc->makeNode(); + layer->styles.push_back(backgroundBlurStyle); auto blurFilter = doc->makeNode(); layer->filters.push_back(blurFilter); + auto dropFilter = doc->makeNode(); + layer->filters.push_back(dropFilter); + auto innerFilter = doc->makeNode(); + layer->filters.push_back(innerFilter); + auto blendFilter = doc->makeNode(); + layer->filters.push_back(blendFilter); auto scene = pagx::PAGScene::Make(doc); ASSERT_TRUE(scene != nullptr); auto* binding = scene->mutableBinding(); ASSERT_TRUE(binding != nullptr); - // For each built node, every Animatable channel in the registry must have a runtime writer. - pagx::Node* nodes[] = {layer, rect, ellipse, polystar, trim, roundCorner, - repeater, fill, stroke, solid, dropStyle, blurFilter}; + // For each built node, every Animatable channel in the registry must have a runtime writer. Text + // and TextBox are intentionally omitted: they require a registered font to build, while every + // other node type with animatable channels is covered here. + pagx::Node* nodes[] = { + layer, rect, ellipse, polystar, trim, roundCorner, repeater, + fill, stroke, solid, group, linear, linearStop, radial, + conic, diamond, modifier, selector, dropStyle, innerStyle, backgroundBlurStyle, + blurFilter, dropFilter, innerFilter, blendFilter}; for (auto* node : nodes) { for (const auto& channel : pagx::ChannelsFor(node->nodeType())) { if (!pagx::HasFlag(channel.flags, pagx::ChannelFlags::Animatable)) { From e9b1e6e53f02cc8e8a92a57324c4dbe163959607 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 18:02:55 +0800 Subject: [PATCH 30/39] Add ResetNodeChannel to restore a node channel to its type default value. --- include/pagx/PAGXNodeChannel.h | 13 +++++++ src/pagx/PAGXChannelTable.h | 8 ++-- src/pagx/PAGXNodeChannel.cpp | 68 +++++++++++++++++++++++++++++++++- test/src/PAGXTest.cpp | 44 ++++++++++++++++++++++ 4 files changed, 128 insertions(+), 5 deletions(-) diff --git a/include/pagx/PAGXNodeChannel.h b/include/pagx/PAGXNodeChannel.h index 50b8eea9a8..1f6f3014ba 100644 --- a/include/pagx/PAGXNodeChannel.h +++ b/include/pagx/PAGXNodeChannel.h @@ -60,6 +60,19 @@ bool GetNodeChannel(const Node* node, const std::string& channel, KeyValue* out) */ bool SetNodeChannel(Node* node, const std::string& channel, const KeyValue& value); +/** + * Resets the node field identified by channel to the default value of its node type, i.e. the value + * a freshly created node of that type carries. This is the way to "clear" a previously edited + * channel: for optional fields (e.g. a TextModifier's strokeWidth) the default is the unset state, + * so resetting removes the value. Only the addressed component is reset for component-wise channels + * ("position.x" resets x but leaves y). The node is the source of truth; callers refresh any live + * scene separately via PAGXDocument::notifyChange. + * @param node the node to reset; must not be null. + * @param channel the channel name (see the encoding notes above). + * @return true on success; false if node is null or the channel is unknown for the node type. + */ +bool ResetNodeChannel(Node* node, const std::string& channel); + /** * Returns true if the given channel exists on the node type and can be driven by an animation * channel, i.e. it has a lightweight runtime writer that updates the live layer in place. Returns diff --git a/src/pagx/PAGXChannelTable.h b/src/pagx/PAGXChannelTable.h index a62305b2a9..67c5fe0b87 100644 --- a/src/pagx/PAGXChannelTable.h +++ b/src/pagx/PAGXChannelTable.h @@ -48,9 +48,11 @@ inline constexpr bool HasFlag(ChannelFlags value, ChannelFlags flag) { return (static_cast(value) & static_cast(flag)) != 0; } -// Function that reads or writes one node channel. Exactly one of getOut / setIn is non-null: getOut -// for a read (copies the field into *getOut), setIn for a write (validates and copies into the -// field). Returns false on a type mismatch or an invalid enum string. +// Function that reads, writes, or resets one node channel. At most one of getOut / setIn is +// non-null: getOut for a read (copies the field into *getOut), setIn for a write (validates and +// copies into the field), and both null for a reset (copies the corresponding field of a +// default-constructed node back into the field). Returns false on a type mismatch or an invalid +// enum string. using ChannelAccessor = bool (*)(Node* node, KeyValue* getOut, const KeyValue* setIn); // One reflective channel of a node type, addressed by channel name. diff --git a/src/pagx/PAGXNodeChannel.cpp b/src/pagx/PAGXNodeChannel.cpp index 25d8cdb68b..ae78a30b7b 100644 --- a/src/pagx/PAGXNodeChannel.cpp +++ b/src/pagx/PAGXNodeChannel.cpp @@ -21,6 +21,7 @@ #include #include "base/utils/Log.h" #include "pagx/PAGXChannelTable.h" +#include "pagx/PAGXDefaults.h" #include "pagx/nodes/BackgroundBlurStyle.h" #include "pagx/nodes/BlendFilter.h" #include "pagx/nodes/BlurFilter.h" @@ -70,12 +71,18 @@ static constexpr ChannelFlags NoFlags = ChannelFlags::None; // The access generators below turn a member pointer (and, for enums, the StringParser converters) // into a uniform ChannelAccessor. A read copies the field into *getOut; a write validates the KeyValue -// alternative (or enum string) and copies into the field. Routing both directions through one -// generated function keeps reads and writes symmetric and removes the per-field if/else boilerplate. +// alternative (or enum string) and copies into the field; a reset (both getOut and setIn null) copies +// the corresponding field of a default-constructed node of type T back into the field. Routing all +// three directions through one generated function keeps them symmetric and removes the per-field +// if/else boilerplate. template static bool AccessFloat(Node* node, KeyValue* getOut, const KeyValue* setIn) { auto* self = static_cast(node); + if (getOut == nullptr && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } if (getOut != nullptr) { *getOut = self->*Field; return true; @@ -91,6 +98,10 @@ static bool AccessFloat(Node* node, KeyValue* getOut, const KeyValue* setIn) { template static bool AccessBool(Node* node, KeyValue* getOut, const KeyValue* setIn) { auto* self = static_cast(node); + if (getOut == nullptr && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } if (getOut != nullptr) { *getOut = self->*Field; return true; @@ -106,6 +117,10 @@ static bool AccessBool(Node* node, KeyValue* getOut, const KeyValue* setIn) { template static bool AccessInt(Node* node, KeyValue* getOut, const KeyValue* setIn) { auto* self = static_cast(node); + if (getOut == nullptr && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } if (getOut != nullptr) { *getOut = self->*Field; return true; @@ -121,6 +136,10 @@ static bool AccessInt(Node* node, KeyValue* getOut, const KeyValue* setIn) { template static bool AccessString(Node* node, KeyValue* getOut, const KeyValue* setIn) { auto* self = static_cast(node); + if (getOut == nullptr && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } if (getOut != nullptr) { *getOut = self->*Field; return true; @@ -136,6 +155,10 @@ static bool AccessString(Node* node, KeyValue* getOut, const KeyValue* setIn) { template static bool AccessColor(Node* node, KeyValue* getOut, const KeyValue* setIn) { auto* self = static_cast(node); + if (getOut == nullptr && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } if (getOut != nullptr) { *getOut = self->*Field; return true; @@ -153,6 +176,11 @@ template static bool AccessPointAxis(Node* node, KeyValue* getOut, const KeyValue* setIn) { auto* self = static_cast(node); float& component = XAxis ? (self->*Field).x : (self->*Field).y; + if (getOut == nullptr && setIn == nullptr) { + const auto& def = Default().*Field; + component = XAxis ? def.x : def.y; + return true; + } if (getOut != nullptr) { *getOut = component; return true; @@ -170,6 +198,11 @@ template static bool AccessSizeAxis(Node* node, KeyValue* getOut, const KeyValue* setIn) { auto* self = static_cast(node); float& component = WidthAxis ? (self->*Field).width : (self->*Field).height; + if (getOut == nullptr && setIn == nullptr) { + const auto& def = Default().*Field; + component = WidthAxis ? def.width : def.height; + return true; + } if (getOut != nullptr) { *getOut = component; return true; @@ -190,6 +223,12 @@ static bool AccessPaddingComp(Node* node, KeyValue* getOut, const KeyValue* setI float* component = Which == 0 ? &padding.left : (Which == 1 ? &padding.top : (Which == 2 ? &padding.right : &padding.bottom)); + if (getOut == nullptr && setIn == nullptr) { + const Padding& def = Default().*Field; + *component = + Which == 0 ? def.left : (Which == 1 ? def.top : (Which == 2 ? def.right : def.bottom)); + return true; + } if (getOut != nullptr) { *getOut = *component; return true; @@ -205,6 +244,10 @@ static bool AccessPaddingComp(Node* node, KeyValue* getOut, const KeyValue* setI template static bool AccessOptionalFloat(Node* node, KeyValue* getOut, const KeyValue* setIn) { auto* self = static_cast(node); + if (getOut == nullptr && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } if (getOut != nullptr) { if (!(self->*Field).has_value()) { return false; @@ -223,6 +266,10 @@ static bool AccessOptionalFloat(Node* node, KeyValue* getOut, const KeyValue* se template static bool AccessOptionalColor(Node* node, KeyValue* getOut, const KeyValue* setIn) { auto* self = static_cast(node); + if (getOut == nullptr && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } if (getOut != nullptr) { if (!(self->*Field).has_value()) { return false; @@ -244,6 +291,10 @@ template static bool AccessEnum(Node* node, KeyValue* getOut, const KeyValue* setIn) { auto* self = static_cast(node); + if (getOut == nullptr && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } if (getOut != nullptr) { *getOut = ToString(self->*Field); return true; @@ -865,6 +916,19 @@ bool SetNodeChannel(Node* node, const std::string& channel, const KeyValue& valu return true; } +bool ResetNodeChannel(Node* node, const std::string& channel) { + if (node == nullptr) { + return false; + } + const auto* field = FindChannel(node->nodeType(), channel); + if (field == nullptr || !field->access(node, nullptr, nullptr)) { + LOGE("ResetNodeChannel: unhandled channel '%s' for node type %d.", channel.c_str(), + static_cast(node->nodeType())); + return false; + } + return true; +} + bool IsAnimatableChannel(NodeType type, const std::string& channel) { const auto* field = FindChannel(type, channel); return field != nullptr && HasFlag(field->flags, ChannelFlags::Animatable); diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index 8b4cdcce33..818bce6cee 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -33,6 +33,7 @@ #include "pagx/PAGSurface.h" #include "pagx/PAGTimeline.h" #include "pagx/PAGXChannelTable.h" +#include "pagx/PAGXDefaults.h" #include "pagx/PAGXDocument.h" #include "pagx/PAGXExporter.h" #include "pagx/PAGXImporter.h" @@ -8990,6 +8991,49 @@ PAGX_TEST(PAGXTest, NodeChannelOptionalFields) { EXPECT_FALSE(pagx::SetNodeChannel(modifier, "strokeWidth", pagx::KeyValue(std::string("x")))); } +/** + * Test case: ResetNodeChannel restores a channel to its node type's default value. Covers scalars, + * enums, component-wise channels (only the addressed axis is reset), and optionals (reset clears the + * value), plus rejection of null nodes and unknown channels. + */ +PAGX_TEST(PAGXTest, NodeChannelReset) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode(); + auto rect = doc->makeNode(); + auto modifier = doc->makeNode(); + + // Scalar: edit then reset returns to the default carried by a fresh node. + float defaultAlpha = pagx::Default().alpha; + EXPECT_TRUE(pagx::SetNodeChannel(layer, "alpha", pagx::KeyValue(0.3f))); + EXPECT_TRUE(pagx::ResetNodeChannel(layer, "alpha")); + EXPECT_FLOAT_EQ(layer->alpha, defaultAlpha); + + // Enum: reset returns to the default blend mode. + pagx::BlendMode defaultBlend = pagx::Default().blendMode; + EXPECT_TRUE(pagx::SetNodeChannel(layer, "blendMode", pagx::KeyValue(std::string("multiply")))); + EXPECT_TRUE(pagx::ResetNodeChannel(layer, "blendMode")); + EXPECT_EQ(layer->blendMode, defaultBlend); + + // Component-wise: resetting position.x leaves position.y untouched. Rectangle's default position + // is NaN (the auto-layout sentinel), so the reset is checked via isnan rather than equality. + EXPECT_TRUE(pagx::SetNodeChannel(rect, "position.x", pagx::KeyValue(10.0f))); + EXPECT_TRUE(pagx::SetNodeChannel(rect, "position.y", pagx::KeyValue(20.0f))); + EXPECT_TRUE(pagx::ResetNodeChannel(rect, "position.x")); + EXPECT_TRUE(std::isnan(rect->position.x)); + EXPECT_TRUE(std::isnan(pagx::Default().position.x)); + EXPECT_FLOAT_EQ(rect->position.y, 20.0f); + + // Optional: reset clears a previously written value. + EXPECT_TRUE(pagx::SetNodeChannel(modifier, "strokeWidth", pagx::KeyValue(3.0f))); + EXPECT_TRUE(modifier->strokeWidth.has_value()); + EXPECT_TRUE(pagx::ResetNodeChannel(modifier, "strokeWidth")); + EXPECT_FALSE(modifier->strokeWidth.has_value()); + + // Rejection: null node and unknown channel. + EXPECT_FALSE(pagx::ResetNodeChannel(nullptr, "alpha")); + EXPECT_FALSE(pagx::ResetNodeChannel(layer, "nosuchchannel")); +} + /** * Test case: ChannelsFor exposes every channel of a node type, each with a working accessor and * flags that match the IsAnimatableChannel/RequiresLayout queries. From c74c6cf9f241d2d7bea5fcb444e2574169c70588 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Fri, 12 Jun 2026 18:05:06 +0800 Subject: [PATCH 31/39] Add runtime add and remove composition tests covering the syncChildren MakeChild path. --- test/src/PAGXTest.cpp | 108 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index 818bce6cee..6b4d60a9c8 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -8861,6 +8861,114 @@ PAGX_TEST(PAGXTest, NotifyChangeNestedChildAddRemove) { EXPECT_EQ(scene->mutableBinding()->get(parent).get(), parentTgfx.get()); } +/** + * Test case: notifyChange adds a newly inserted composition slot layer at runtime. syncChildren has + * no pre-built slot for the new layer, so it builds one via BuildLayerInto and attaches the + * MakeChild subtree under it. The nested composition content is built into the scene and stays + * hit-testable, while the existing sibling layer keeps its handle. + */ +PAGX_TEST(PAGXTest, NotifyChangeAddComposition) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto existing = doc->makeNode("E"); + existing->width = 50; + existing->height = 50; + doc->layers.push_back(existing); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto existingTgfx = scene->mutableBinding()->get(existing); + ASSERT_TRUE(existingTgfx != nullptr); + + // Build a composition with one filled child layer, then a slot layer that references it. + auto comp = doc->makeNode("comp"); + comp->width = 100; + comp->height = 100; + auto inner = doc->makeNode(); + inner->name = "NestedChild"; + auto innerRect = doc->makeNode(); + innerRect->position = {50, 50}; + innerRect->size = {100, 100}; + auto innerFill = doc->makeNode(); + auto innerSolid = doc->makeNode(); + innerSolid->color = {0, 0, 1, 1}; + innerFill->color = innerSolid; + inner->contents.push_back(innerRect); + inner->contents.push_back(innerFill); + comp->layers.push_back(inner); + + auto slot = doc->makeNode(); + slot->name = "Slot"; + slot->composition = comp; + doc->layers.push_back(slot); + + // Notify with the newly inserted slot layer; syncChildren builds its composition subtree. + doc->notifyChange({slot}); + + // The slot is bound, the nested composition content is hit-testable, and the existing sibling + // layer keeps its original tgfx instance. + ASSERT_TRUE(scene->mutableBinding()->get(slot) != nullptr); + EXPECT_EQ(scene->mutableBinding()->get(existing).get(), existingTgfx.get()); + auto hits = scene->getLayersUnderPoint(50, 50); + ASSERT_FALSE(hits.empty()); + EXPECT_EQ(hits[0]->name(), "NestedChild"); +} + +/** + * Test case: notifyChange removes a composition slot layer at runtime. syncChildren detaches the + * slot (the binding entry, which carries the nested subtree) and drops the bindings of the slot and + * its composition content, while the surviving sibling layer keeps its handle. + */ +PAGX_TEST(PAGXTest, NotifyChangeRemoveComposition) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto existing = doc->makeNode("E"); + existing->width = 50; + existing->height = 50; + doc->layers.push_back(existing); + + auto comp = doc->makeNode("comp"); + comp->width = 100; + comp->height = 100; + auto inner = doc->makeNode(); + inner->name = "NestedChild"; + auto innerRect = doc->makeNode(); + innerRect->position = {50, 50}; + innerRect->size = {100, 100}; + auto innerFill = doc->makeNode(); + auto innerSolid = doc->makeNode(); + innerSolid->color = {0, 0, 1, 1}; + innerFill->color = innerSolid; + inner->contents.push_back(innerRect); + inner->contents.push_back(innerFill); + comp->layers.push_back(inner); + + auto slot = doc->makeNode(); + slot->name = "Slot"; + slot->composition = comp; + doc->layers.push_back(slot); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto existingTgfx = scene->mutableBinding()->get(existing); + ASSERT_TRUE(existingTgfx != nullptr); + ASSERT_TRUE(scene->mutableBinding()->get(slot) != nullptr); + auto hits = scene->getLayersUnderPoint(50, 50); + ASSERT_FALSE(hits.empty()); + EXPECT_EQ(hits[0]->name(), "NestedChild"); + + // Remove the slot layer from the document and notify. + doc->layers.pop_back(); + doc->notifyChange({slot}); + + // The slot's binding is dropped, the surviving sibling keeps its instance, and the composition + // content is no longer hit-testable. + EXPECT_EQ(scene->mutableBinding()->get(slot), nullptr); + EXPECT_EQ(scene->mutableBinding()->get(existing).get(), existingTgfx.get()); + auto hitsAfter = scene->getLayersUnderPoint(50, 50); + for (const auto& hit : hitsAfter) { + EXPECT_NE(hit->name(), "NestedChild"); + } +} + /** * Test case: GetNodeChannel/SetNodeChannel round-trip scalar fields across node types. */ From b508a453e35b671fec257be3f67b544cb8a7bea0 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Mon, 15 Jun 2026 17:01:52 +0800 Subject: [PATCH 32/39] Apply PAGX review feedback: filter foreign nodes in notifyChange expose ownsNode add ChannelExists ListChannels and fix matrix rotation shortest arc. --- include/pagx/PAGComposition.h | 15 ++- include/pagx/PAGTimeline.h | 13 +-- include/pagx/PAGXDocument.h | 44 +++++---- include/pagx/PAGXNodeChannel.h | 73 ++++++++------ include/pagx/nodes/LayoutNode.h | 6 +- src/pagx/PAGScene.cpp | 6 +- src/pagx/PAGXDocument.cpp | 35 +++++-- src/pagx/PAGXNodeChannel.cpp | 67 +++++++++++-- src/pagx/runtime/MatrixDecompose.h | 16 +++- src/pagx/runtime/PAGComposition.cpp | 28 +++++- src/renderer/LayerBuilder.cpp | 24 +++-- src/renderer/LayerBuilder.h | 17 ++-- test/src/PAGXTest.cpp | 144 ++++++++++++++++++++++++---- 13 files changed, 376 insertions(+), 112 deletions(-) diff --git a/include/pagx/PAGComposition.h b/include/pagx/PAGComposition.h index d0ed4adda4..82c3256213 100644 --- a/include/pagx/PAGComposition.h +++ b/include/pagx/PAGComposition.h @@ -101,15 +101,24 @@ class PAGComposition : public PAGLayer { // Returns nullptr if no persistent node owns the layer (internal sub-layer). std::shared_ptr findChildForLayer(const tgfx::Layer* hitLayer); + private: // Refreshes this composition after edits: reconciles its child layer list and refreshes any dirty // leaf layers in place, then recurses into child compositions. Called by PAGScene::onNodesChanged. - void refreshNodes(const std::vector& dirtyNodes); + // visited carries the source compositions on the current ancestor path: when this method recurses + // into a child composition, the child's own source is inserted before recursing and erased on + // return, so any newly added layer that references an ancestor composition is detected at the top + // of MakeChild rather than one frame deeper. + void refreshNodes(const std::vector& dirtyNodes, + std::unordered_set& visited); // Reconciles this composition's runtime children with the given source layer list: reuses // children whose source layer still maps to a tgfx layer (handles stay valid), builds newly added // layers into the binding, removes runtime children whose source layer is gone, and reorders the - // parent's tgfx children to match the document order. - void syncChildren(const std::vector& sourceLayers); + // parent's tgfx children to match the document order. visited is the ancestor path of source + // compositions; the caller must include this composition's own source so MakeChild rejects a + // newly added layer that points back to this composition or any ancestor at the top of the call. + void syncChildren(const std::vector& sourceLayers, + std::unordered_set& visited); // Rebuilds this composition's timelines from the owner layer's animation drivers, discarding any // existing ones first so removed drivers or animations simply stop driving. Used at build time and diff --git a/include/pagx/PAGTimeline.h b/include/pagx/PAGTimeline.h index 9fef5fc257..297828cbbb 100644 --- a/include/pagx/PAGTimeline.h +++ b/include/pagx/PAGTimeline.h @@ -136,11 +136,9 @@ class PAGTimeline { std::weak_ptr owner); // Resolves each animation object's target node against contextDoc once and caches the - // (node, channels) pairs, so apply() avoids a per-frame findNode() hash lookup for every - // object. Built lazily on the first apply(). The cache is valid only as long as the animation's - // objects and contextDoc's nodeMap are not mutated after the owning PAGScene is built. - // Cache invalidation will be wired through PAGXDocument::notifyChange → PAGScene::onNodesChanged - // which resets resolved to false on affected timelines whenever nodeMap entries change. + // (node, channels) pairs, so apply() avoids a per-frame findNode() hash lookup. Built lazily on + // the first apply(). Stale caches are replaced by rebuilding the PAGTimeline (driven by + // PAGXDocument::notifyChange when a timeline node is dirty); this cache is never reset in place. void resolveTargets(); // Owning scene. animation / binding / contextDoc point into content this scene keeps alive, so @@ -157,9 +155,8 @@ class PAGTimeline { // so internal IDs of the external file stay self-contained. PAGXDocument* contextDoc = nullptr; // Cached target resolution: each entry pairs a resolved target node with the channels driving - // it. Populated by resolveTargets() on first apply(); resolved stays false until then. Once - // resolved is set it is never reset, so this cache assumes animation->objects and - // contextDoc->nodeMap stay stable after the owning PAGScene is built (see resolveTargets()). + // it. Populated by resolveTargets() on first apply(); never reset in place — stale caches are + // replaced by rebuilding the PAGTimeline (see resolveTargets()). std::vector>> resolvedTargets = {}; bool resolved = false; int64_t currentTimeUs = 0; diff --git a/include/pagx/PAGXDocument.h b/include/pagx/PAGXDocument.h index 91ff73d498..0e91bae911 100644 --- a/include/pagx/PAGXDocument.h +++ b/include/pagx/PAGXDocument.h @@ -176,28 +176,40 @@ class PAGXDocument : public Node { void clearEmbed(); /** - * Reflects post-build edits to the given nodes in every live PAGScene created from this document, - * refreshing each affected node's runtime content in place while preserving existing layer - * handles. Pass a container node to reconcile its child layer list; editing a timeline node - * (Animation, AnimationObject, or Channel) rebuilds all timelines. + * Reflects post-build edits to the given nodes in every scene created from this document, while + * preserving existing layer handles wherever possible. Pass a container node to reflect changes + * to its child list; editing an animation, animation object, or channel applies the new timeline + * data to subsequent playback. * * When an edit changes a node's "@id" reference (e.g. AnimationObject.target, Fill.color), mark * every node on the affected reference chain dirty, not just the mutated one — notifyChange only - * refreshes the nodes it is given and does not re-resolve references elsewhere. + * refreshes the nodes it is given. * * Editing an external composition: call notifyChange on the document that owns the edited nodes. - * Every scene that embeds this document as an external composition is refreshed too (it rebuilds - * its runtime tree). A node may only be notified through its owning document; passing a node owned - * by a different (e.g. parent) document is rejected with no effect. + * Scenes that embed this document as an external composition are refreshed automatically. A node + * may only be notified through its owning document; foreign nodes (e.g. a node owned by a child + * externalDoc when notifyChange is called on the parent) are skipped, leaving the rest of the + * dirty list to refresh as usual. Use ownsNode() if the caller does not statically know which + * document owns a given node. * @param dirtyNodes the nodes whose fields (or child lists) were mutated. Pointers must reference - * nodes still owned by this document. Null entries are ignored. Passing an empty list is a no-op. + * nodes still owned by this document. Null entries and foreign nodes are skipped. Passing an + * empty list (or one whose entries are all skipped) is a no-op. * @param layoutChanged whether any mutated field affects layout (size, constraints, padding, - * fonts, text, geometry) or a child list changed. When true, the full layout pass is re-run before - * refreshing so the edited values take effect; when false, layout is skipped for a cheaper - * render-only refresh. Defaults to true. Callers that mutate via SetNodeChannel can derive this - * from RequiresLayout; structural add/remove must pass true. + * fonts, text, geometry) or a child list changed. Pass true to re-run layout before refreshing; + * pass false for a cheaper render-only refresh, only safe for edits that do not affect layout + * (e.g. alpha, color). Callers that mutate via SetNodeChannel can derive the right value from + * RequiresLayout(NodeType, channel); structural add/remove must pass true. */ - void notifyChange(const std::vector& dirtyNodes, bool layoutChanged = true); + void notifyChange(const std::vector& dirtyNodes, bool layoutChanged); + + /** + * Returns true if the node belongs to this document. A node belongs to exactly one document; + * passing a node owned by a different document to notifyChange has no effect on this document's + * scenes. Use this to dispatch a multi-document edit to the right owners, or to validate a node's + * origin before notifying. + * @param node the node to check; null returns false. + */ + bool ownsNode(const Node* node) const; NodeType nodeType() const override { return NodeType::Document; @@ -214,10 +226,6 @@ class PAGXDocument : public Node { void registerNode(Node* node, const std::string& id); - // Returns true if the node is owned by this document (present in its node list). Used to reject - // notifyChange calls for nodes that belong to a different (e.g. external child) document. - bool ownsNode(const Node* node) const; - // PAGScene lifecycle hooks (called from PAGScene::Make / ~PAGScene). void registerLiveScene(const std::shared_ptr& scene); void unregisterLiveScene(PAGScene* scene); diff --git a/include/pagx/PAGXNodeChannel.h b/include/pagx/PAGXNodeChannel.h index 1f6f3014ba..103596dd57 100644 --- a/include/pagx/PAGXNodeChannel.h +++ b/include/pagx/PAGXNodeChannel.h @@ -19,6 +19,7 @@ #pragma once #include +#include #include "pagx/nodes/Channel.h" #include "pagx/nodes/Node.h" @@ -26,17 +27,19 @@ namespace pagx { /** * Reflective, by-name read/write access to a PAGX node's scalar properties. Channels use the same - * attribute names as PAGX XML (e.g. "alpha", "position.x", "blendMode"). This is a document-level - * facility: it reads and writes fields on PAGXDocument nodes and does not touch any live PAGScene. - * After mutating, refresh live scenes via PAGXDocument::notifyChange (use RequiresLayout to decide - * the layoutChanged flag). Typical use: editor property panels and data-driven CLI edits, where the - * caller holds a string channel name rather than a compile-time field reference. + * attribute names as PAGX XML (e.g. "alpha", "position.x", "blendMode"). This API edits a + * PAGXDocument; refresh any associated scene afterwards via PAGXDocument::notifyChange (use + * RequiresLayout to decide the layoutChanged flag). Typical use: editor property panels and + * data-driven CLI edits, where the caller holds a string channel name rather than a compile-time + * field reference. * * Value encoding (KeyValue alternatives): scalars map directly (float/bool/int/string/Color); * enums are passed as their string name (e.g. blendMode = "multiply"); Point/Size fields are * addressed component-wise via suffixed channels ("position.x", "size.width"). Multi-component * fields without a component channel are not exposed. The set of channels available for each node - * type is documented in the PAGX schema reference rather than enumerated through this API. + * type can be enumerated at runtime via ListChannels(); ChannelExists() is the corresponding + * existence predicate, useful to distinguish a typo from a known-but-not-animatable channel + * (IsAnimatableChannel and RequiresLayout both return false for unknown channels). */ /** @@ -44,14 +47,14 @@ namespace pagx { * @param node the node to read from; must not be null. * @param channel the channel name (see the encoding notes above). * @param out receives the value on success; must not be null. - * @return true on success; false if node/out is null, the channel is unknown for the node type, the - * field type cannot be represented as a KeyValue, or an optional field is unset. + * @return true on success; false if node/out is null, the channel is unknown for the node type, or + * an optional field is unset. */ bool GetNodeChannel(const Node* node, const std::string& channel, KeyValue* out); /** - * Writes value into the node field identified by channel. The node is the source of truth; callers - * refresh any live scene separately via PAGXDocument::notifyChange. + * Writes value into the node field identified by channel. Edits are applied to the document; + * refresh any associated scene separately via PAGXDocument::notifyChange. * @param node the node to write to; must not be null. * @param channel the channel name (see the encoding notes above). * @param value the value to write; its KeyValue alternative must match the field's type. @@ -61,12 +64,11 @@ bool GetNodeChannel(const Node* node, const std::string& channel, KeyValue* out) bool SetNodeChannel(Node* node, const std::string& channel, const KeyValue& value); /** - * Resets the node field identified by channel to the default value of its node type, i.e. the value - * a freshly created node of that type carries. This is the way to "clear" a previously edited - * channel: for optional fields (e.g. a TextModifier's strokeWidth) the default is the unset state, - * so resetting removes the value. Only the addressed component is reset for component-wise channels - * ("position.x" resets x but leaves y). The node is the source of truth; callers refresh any live - * scene separately via PAGXDocument::notifyChange. + * Resets the node field identified by channel to its default — the value a freshly created node of + * the same type carries. Use this to "clear" a previously edited channel: for optional fields + * (e.g. a TextModifier's strokeWidth) the default is the unset state, so resetting removes the + * value. Only the addressed component is reset for component-wise channels ("position.x" resets x + * but leaves y). Refresh any associated scene separately via PAGXDocument::notifyChange. * @param node the node to reset; must not be null. * @param channel the channel name (see the encoding notes above). * @return true on success; false if node is null or the channel is unknown for the node type. @@ -74,12 +76,11 @@ bool SetNodeChannel(Node* node, const std::string& channel, const KeyValue& valu bool ResetNodeChannel(Node* node, const std::string& channel); /** - * Returns true if the given channel exists on the node type and can be driven by an animation - * channel, i.e. it has a lightweight runtime writer that updates the live layer in place. Returns - * false for channels that only take effect through a layout/content rebuild and for unknown - * channels. Note this is independent of RequiresLayout: a geometry channel such as a shape's - * "size.width" is both animatable (in place, during playback) and layout-affecting (when edited on - * the document). + * Returns true if the given channel can be driven by an animation channel (i.e. an animation may + * target it during playback). Returns false for channels that only take effect through a + * layout/content rebuild and for unknown channels. Independent of RequiresLayout: a geometry + * channel such as a shape's "size.width" is both animatable during playback and layout-affecting + * when edited on the document. * @param type the node type. * @param channel the channel name. */ @@ -87,14 +88,32 @@ bool IsAnimatableChannel(NodeType type, const std::string& channel); /** * Returns true if editing the given channel on the document requires a layout pass before the - * change is visible in a live scene, i.e. its value reaches the rendered layer through a - * layout-derived quantity (size/position/scale) or it is an auto-layout input (constraints, - * padding, fonts, text, container layout). Callers that mutate a channel via SetNodeChannel should - * pass this as the layoutChanged flag to PAGXDocument::notifyChange. Returns false for channels - * that refresh without layout and for unknown channels. + * change is visible in a scene. Callers that mutate a channel via SetNodeChannel should pass this + * as the layoutChanged flag to PAGXDocument::notifyChange. Returns false for channels that refresh + * without layout and for unknown channels. * @param type the node type. * @param channel the channel name. */ bool RequiresLayout(NodeType type, const std::string& channel); +/** + * Returns true if the given channel name is defined for the node type, regardless of its flags. + * Use this to distinguish a typo (channel does not exist) from a channel that exists but is not + * animatable / does not require layout — IsAnimatableChannel and RequiresLayout collapse both into + * false. Editor scenarios that pass user-provided channel names through Get/SetNodeChannel should + * validate with this first to surface typos as a distinct error. + * @param type the node type. + * @param channel the channel name. + */ +bool ChannelExists(NodeType type, const std::string& channel); + +/** + * Returns the list of channel names defined for the given node type. The returned names match the + * strings accepted by Get/SetNodeChannel/ResetNodeChannel and the queries above. Order is stable + * within a release but is not guaranteed across releases. Returns an empty vector for node types + * that expose no reflectable scalar channels. + * @param type the node type. + */ +std::vector ListChannels(NodeType type); + } // namespace pagx diff --git a/include/pagx/nodes/LayoutNode.h b/include/pagx/nodes/LayoutNode.h index 28b62640f9..d8ea654c40 100644 --- a/include/pagx/nodes/LayoutNode.h +++ b/include/pagx/nodes/LayoutNode.h @@ -107,7 +107,11 @@ class LayoutNode { /** * Clears the layout-computed outputs (preferred and resolved position/size) so the node is * re-measured on the next layout pass. Authored inputs (width/height, constraints, percent sizes) - * are left unchanged. Used when re-running layout on an already-laid-out document after edits. + * are left unchanged. + * + * Called automatically by `PAGXDocument::applyLayout` before re-running layout on an + * already-laid-out document, so callers normally do not invoke this directly. Calling it without + * a subsequent layout pass leaves the node without resolved geometry. */ void resetLayout(); diff --git a/src/pagx/PAGScene.cpp b/src/pagx/PAGScene.cpp index 9b42e1c4e2..3eea3b5e66 100644 --- a/src/pagx/PAGScene.cpp +++ b/src/pagx/PAGScene.cpp @@ -255,7 +255,11 @@ void PAGScene::onNodesChanged(const std::vector& dirtyNodes) { return; } if (_rootComposition != nullptr) { - _rootComposition->refreshNodes(dirtyNodes); + // The root composition has no source Composition node (it represents the document body), so the + // ancestor path begins empty. refreshNodes pushes each child composition's source as it + // descends. + std::unordered_set visited = {}; + _rootComposition->refreshNodes(dirtyNodes, visited); } // Reset every timeline only when a timeline node (Animation / AnimationObject / Channel) changed. // Timelines can share targets and cross-reference, so the whole timeline tree is rebuilt rather diff --git a/src/pagx/PAGXDocument.cpp b/src/pagx/PAGXDocument.cpp index 9b87ef1a5e..fd9c691a71 100644 --- a/src/pagx/PAGXDocument.cpp +++ b/src/pagx/PAGXDocument.cpp @@ -320,16 +320,35 @@ void PAGXDocument::notifyChange(const std::vector& dirtyNodes, bool layou if (dirtyNodes.empty()) { return; } - // A node owned by an external (child) document must be notified through that document, not a - // parent. Reject foreign nodes: they are simply not in this document's node list. + // Partition the input into nodes this document owns (forwarded to live scenes) and foreign nodes + // (dropped, with one aggregate LOGE). A node belongs to exactly one document, so a single + // mis-routed entry must not stop the rest of the batch from refreshing — the editor scenario where + // a multi-select dirty list mixes nodes from a parent and an embedded child document is common, + // and silent batch rejection would translate every such typo into "nothing updates". Foreign-node + // edits still need to be re-issued through the owning document by the caller (use ownsNode() to + // predicate the call). + std::vector ownedDirty = {}; + ownedDirty.reserve(dirtyNodes.size()); + size_t foreignCount = 0; for (auto* node : dirtyNodes) { - if (node != nullptr && !ownsNode(node)) { - LOGE( - "PAGXDocument::notifyChange: node not owned by this document; notify it through its own " - "document."); - return; + if (node == nullptr) { + continue; + } + if (ownsNode(node)) { + ownedDirty.push_back(node); + } else { + ++foreignCount; } } + if (foreignCount > 0) { + LOGE( + "PAGXDocument::notifyChange: %zu node(s) not owned by this document were dropped; notify " + "them through their own document.", + foreignCount); + } + if (ownedDirty.empty()) { + return; + } PruneExpiredScenes(&liveScenes); // Layout-affecting edits (size, constraints, padding, fonts, text, geometry) and structural child // list changes require a full re-layout, since layout is resolved top-down and a single node @@ -341,7 +360,7 @@ void PAGXDocument::notifyChange(const std::vector& dirtyNodes, bool layou for (auto& weakScene : liveScenes) { auto scene = weakScene.lock(); if (scene != nullptr) { - scene->onNodesChanged(dirtyNodes); + scene->onNodesChanged(ownedDirty); } } } diff --git a/src/pagx/PAGXNodeChannel.cpp b/src/pagx/PAGXNodeChannel.cpp index ae78a30b7b..1e9213a4f0 100644 --- a/src/pagx/PAGXNodeChannel.cpp +++ b/src/pagx/PAGXNodeChannel.cpp @@ -18,6 +18,7 @@ #include "pagx/PAGXNodeChannel.h" #include +#include #include #include "base/utils/Log.h" #include "pagx/PAGXChannelTable.h" @@ -880,14 +881,45 @@ const std::vector& ChannelsFor(NodeType type) { } } -static const ChannelDef* FindChannel(NodeType type, const std::string& channel) { - const auto& table = ChannelsFor(type); +// Builds the per-type channel lookup map from the vector, keyed by string_view referencing the +// ChannelDef's channel const char*. The literal strings have static storage duration so the +// string_view keys remain valid for the program lifetime. +static std::unordered_map BuildChannelIndex( + const std::vector& table) { + std::unordered_map index = {}; + index.reserve(table.size()); for (const auto& field : table) { - if (channel == field.channel) { - return &field; - } + index.emplace(std::string_view(field.channel), &field); + } + return index; +} + +// Returns the per-type O(1) lookup index over ChannelsFor(type). The index is a process-wide cache +// keyed by &table: each type's vector address maps to its built-once +// unordered_map. The cache is not synchronized: PAGX reflection +// APIs are document-level edit operations and are expected to run on a single thread (matching the +// rest of the PAGXDocument editing surface), so the first-call insert is not racing with reads in +// any supported scenario. Underlying ChannelDef::channel pointers are static const char* literals, +// so string_view keys outlive any caller; the unique_ptr keeps each inner map at a stable address +// across cache rehashes. +static const std::unordered_map& ChannelIndexFor( + NodeType type) { + using ChannelIndex = std::unordered_map; + const auto& table = ChannelsFor(type); + static std::unordered_map*, std::unique_ptr> cache = + {}; + auto it = cache.find(&table); + if (it == cache.end()) { + auto index = std::unique_ptr(new ChannelIndex(BuildChannelIndex(table))); + it = cache.emplace(&table, std::move(index)).first; } - return nullptr; + return *it->second; +} + +static const ChannelDef* FindChannel(NodeType type, const std::string& channel) { + const auto& index = ChannelIndexFor(type); + auto found = index.find(std::string_view(channel)); + return found != index.end() ? found->second : nullptr; } bool GetNodeChannel(const Node* node, const std::string& channel, KeyValue* out) { @@ -896,11 +928,18 @@ bool GetNodeChannel(const Node* node, const std::string& channel, KeyValue* out) } const auto* field = FindChannel(node->nodeType(), channel); if (field == nullptr) { + LOGE("GetNodeChannel: unhandled channel '%s' for node type %d.", channel.c_str(), + static_cast(node->nodeType())); return false; } // The read path never mutates the node; const_cast is safe because access only writes when setIn // is non-null, which it is not here. - return field->access(const_cast(node), out, nullptr); + if (!field->access(const_cast(node), out, nullptr)) { + LOGE("GetNodeChannel: failed to read channel '%s' for node type %d.", channel.c_str(), + static_cast(node->nodeType())); + return false; + } + return true; } bool SetNodeChannel(Node* node, const std::string& channel, const KeyValue& value) { @@ -939,4 +978,18 @@ bool RequiresLayout(NodeType type, const std::string& channel) { return field != nullptr && HasFlag(field->flags, ChannelFlags::RequiresLayout); } +bool ChannelExists(NodeType type, const std::string& channel) { + return FindChannel(type, channel) != nullptr; +} + +std::vector ListChannels(NodeType type) { + const auto& table = ChannelsFor(type); + std::vector names = {}; + names.reserve(table.size()); + for (const auto& field : table) { + names.emplace_back(field.channel); + } + return names; +} + } // namespace pagx diff --git a/src/pagx/runtime/MatrixDecompose.h b/src/pagx/runtime/MatrixDecompose.h index 4bf3d51a1d..a7632387b7 100644 --- a/src/pagx/runtime/MatrixDecompose.h +++ b/src/pagx/runtime/MatrixDecompose.h @@ -90,13 +90,25 @@ inline void RecomposeAffine(const DecomposedMatrix& m, float* a, float* b, float } } -// Interpolates two decomposed matrices component-wise at fraction t in [0, 1]. +// Interpolates two decomposed matrices component-wise at fraction t in [0, 1]. Rotation is +// interpolated along the shortest arc by wrapping the difference into [-pi, pi]; this keeps tweens +// like 170deg -> 190deg from spinning the long way 340deg around. Multi-turn winding cannot be +// recovered from a baked matrix (see DecomposeAffine), so animations needing precise multi-turn or +// boundary-crossing rotation should drive a scalar rotation channel. inline DecomposedMatrix MixDecomposed(const DecomposedMatrix& a, const DecomposedMatrix& b, float t) { + static constexpr float Pi = 3.14159265358979323846f; + static constexpr float TwoPi = 2.0f * Pi; DecomposedMatrix out = {}; out.translateX = a.translateX + (b.translateX - a.translateX) * t; out.translateY = a.translateY + (b.translateY - a.translateY) * t; - out.rotation = a.rotation + (b.rotation - a.rotation) * t; + float rotDiff = b.rotation - a.rotation; + if (rotDiff > Pi) { + rotDiff -= TwoPi; + } else if (rotDiff < -Pi) { + rotDiff += TwoPi; + } + out.rotation = a.rotation + rotDiff * t; out.scaleX = a.scaleX + (b.scaleX - a.scaleX) * t; out.scaleY = a.scaleY + (b.scaleY - a.scaleY) * t; out.skew = a.skew + (b.skew - a.skew) * t; diff --git a/src/pagx/runtime/PAGComposition.cpp b/src/pagx/runtime/PAGComposition.cpp index da6d64854a..b95edef71f 100644 --- a/src/pagx/runtime/PAGComposition.cpp +++ b/src/pagx/runtime/PAGComposition.cpp @@ -151,7 +151,8 @@ void PAGComposition::buildChildren(const std::vector& layers, } } -void PAGComposition::refreshNodes(const std::vector& dirtyNodes) { +void PAGComposition::refreshNodes(const std::vector& dirtyNodes, + std::unordered_set& visited) { std::unordered_set dirtySet(dirtyNodes.begin(), dirtyNodes.end()); // Reconcile the child layer list first. A dirty container node means its child list may have // gained or lost layers. The root composition (node == nullptr) reconciles against the document's @@ -167,7 +168,7 @@ void PAGComposition::refreshNodes(const std::vector& dirtyNodes) { } bool containerDirty = node == nullptr || dirtySet.find(node) != dirtySet.end(); if (sourceLayers != nullptr && containerDirty) { - syncChildren(*sourceLayers); + syncChildren(*sourceLayers, visited); } // Refresh the attributes/contents of any dirty layer nodes owned by this composition's binding. @@ -204,12 +205,27 @@ void PAGComposition::refreshNodes(const std::vector& dirtyNodes) { // not disturb in-progress playback. for (auto& child : children) { if (child != nullptr && child->layerType() != LayerType::Layer) { - static_cast(child.get())->refreshNodes(dirtyNodes); + auto* childComposition = static_cast(child.get()); + // Push the child composition's source onto the ancestor path before recursing, so any layer + // newly added inside it that references an ancestor (including this composition) is detected + // at the top of MakeChild. Only erase what this frame inserted: a sibling subtree that + // legitimately shares the same downstream composition would otherwise drop the marker. + const Composition* childSource = + childComposition->node != nullptr ? childComposition->node->composition : nullptr; + bool inserted = false; + if (childSource != nullptr) { + inserted = visited.insert(childSource).second; + } + childComposition->refreshNodes(dirtyNodes, visited); + if (inserted) { + visited.erase(childSource); + } } } } -void PAGComposition::syncChildren(const std::vector& sourceLayers) { +void PAGComposition::syncChildren(const std::vector& sourceLayers, + std::unordered_set& visited) { auto scene = rootScene.lock(); if (!scene || binding == nullptr || runtimeLayer == nullptr) { return; @@ -237,7 +253,9 @@ void PAGComposition::syncChildren(const std::vector& sourceLayers) { } // Newly added layer: build its tgfx subtree into this binding and wrap it in a runtime node. if (layer->composition != nullptr) { - std::unordered_set visited = {}; + // Reuse the ancestor path threaded in by refreshNodes so a newly added layer that references + // an ancestor composition is rejected at the top of MakeChild rather than only after one + // wasted PAGComposition allocation deeper in the recursion. auto childComposition = PAGComposition::MakeChild(layer, scene, visited); if (childComposition == nullptr) { continue; diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index 9a85f4ac07..76c3bdc492 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -415,9 +415,10 @@ class LayerBuilderContext { if (color == nullptr || !_result.binding.contains(color)) { return; } - for (const auto* node : _result.binding.boundNodes()) { + bool stillReferenced = false; + _result.binding.forEachBoundNode([&](const Node* node) { if (node == excludedOwner) { - continue; + return true; } const ColorSource* other = nullptr; if (node->nodeType() == NodeType::Fill) { @@ -426,8 +427,13 @@ class LayerBuilderContext { other = static_cast(node)->color; } if (other == color) { - return; + stillReferenced = true; + return false; } + return true; + }); + if (stillReferenced) { + return; } if (color->nodeType() == NodeType::ImagePattern) { unbindImageIfUnreferenced(static_cast(color)); @@ -447,13 +453,19 @@ class LayerBuilderContext { if (image == nullptr || !_result.binding.contains(image)) { return; } - for (const auto* node : _result.binding.boundNodes()) { + bool stillReferenced = false; + _result.binding.forEachBoundNode([&](const Node* node) { if (node == pattern || node->nodeType() != NodeType::ImagePattern) { - continue; + return true; } if (static_cast(node)->image == image) { - return; + stillReferenced = true; + return false; } + return true; + }); + if (stillReferenced) { + return; } _result.binding.remove(image); } diff --git a/src/renderer/LayerBuilder.h b/src/renderer/LayerBuilder.h index df50ae8257..51dc980d34 100644 --- a/src/renderer/LayerBuilder.h +++ b/src/renderer/LayerBuilder.h @@ -142,16 +142,17 @@ struct RuntimeBinding { return targets.find(node) != targets.end(); } - // Collects every node that currently has a binding entry. Used by removal to scan for surviving - // references to a shared resource (e.g. a color source referenced by multiple fills) before - // unbinding it. - std::vector boundNodes() const { - std::vector nodes = {}; - nodes.reserve(targets.size()); + // Iterates over every node that currently has a binding entry, calling fn(node) on each. Used by + // removal to scan for surviving references to a shared resource (e.g. a color source referenced + // by multiple fills) before unbinding it. fn returns true to continue iteration, false to stop + // early. Iterating directly avoids the per-call vector allocation a snapshot would require. + template + void forEachBoundNode(Fn&& fn) const { for (const auto& entry : targets) { - nodes.push_back(entry.first); + if (!fn(entry.first)) { + return; + } } - return nodes; } bool apply(const Node* node, const std::string& channel, const KeyValue& value, float mix) const { diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index 6b4d60a9c8..3849ee8fbd 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -6964,7 +6964,8 @@ PAGX_TEST(PAGXTest, ExternalPAGXGrandchildEditSyncsToRootScene) { /** * Test case: a parent document must not notify a node owned by a child (external) document. Such a - * node is not in the parent's node list, so notifyChange rejects the call and changes nothing. + * node is not in the parent's node list, so notifyChange filters it out and the embedded value + * stays unchanged. ownsNode() returns false for the foreign node so callers can predicate the call. */ PAGX_TEST(PAGXTest, ExternalPAGXParentCannotNotifyChildNode) { std::string mainXML = @@ -6989,13 +6990,63 @@ PAGX_TEST(PAGXTest, ExternalPAGXParentCannotNotifyChildNode) { ASSERT_TRUE(childTgfx != nullptr); EXPECT_FLOAT_EQ(childTgfx->alpha(), 1.0f); - // The parent document does not own the child layer, so notifying it through the parent is rejected - // (logs an error) and the embedded value stays unchanged. + // ownsNode lets callers detect cross-document mis-routing before calling notifyChange. + EXPECT_FALSE(doc->ownsNode(childLayer)); + EXPECT_TRUE(childDoc->ownsNode(childLayer)); + EXPECT_TRUE(doc->ownsNode(slotLayer)); + + // The parent document does not own the child layer, so it is filtered out of the dirty list and + // the embedded value stays unchanged. The call still logs an error reporting the dropped node. childLayer->alpha = 0.2f; doc->notifyChange({childLayer}, /*layoutChanged=*/false); EXPECT_FLOAT_EQ(childTgfx->alpha(), 1.0f); } +/** + * Test case: a mixed dirty list with both an owned node and a foreign (child-document) node has + * the foreign node filtered out while the owned node still refreshes; one mis-routed entry must + * not block the rest of the batch. + */ +PAGX_TEST(PAGXTest, ExternalPAGXMixedDirtyListFiltersForeignNode) { + std::string mainXML = + "\n" + " \n" + " \n" + "\n"; + auto doc = pagx::PAGXImporter::FromXML(mainXML); + ASSERT_TRUE(doc != nullptr); + EXPECT_TRUE(doc->loadFileData("child.pagx", + MakePAGXData(MakeExternalCompositionXML("childLayer", "fade")))); + auto* localLayer = doc->findNode("local"); + auto* slotLayer = doc->findNode("slot"); + ASSERT_TRUE(localLayer != nullptr); + ASSERT_TRUE(slotLayer != nullptr); + ASSERT_TRUE(slotLayer->externalDoc != nullptr); + auto file = pagx::PAGScene::Make(doc); + ASSERT_TRUE(file != nullptr); + auto* childDoc = slotLayer->externalDoc.get(); + auto* childLayer = childDoc->findNode("childLayer"); + ASSERT_TRUE(childLayer != nullptr); + + auto localTgfx = file->mutableBinding()->get(localLayer); + auto& slotTree = + *static_cast(file->rootComposition()->children[1].get())->binding; + auto childTgfx = slotTree.get(childLayer); + ASSERT_TRUE(localTgfx != nullptr); + ASSERT_TRUE(childTgfx != nullptr); + EXPECT_FLOAT_EQ(localTgfx->alpha(), 1.0f); + EXPECT_FLOAT_EQ(childTgfx->alpha(), 1.0f); + + // Edit the owned local node and pass both the owned node and a foreign one in the same call. + // The foreign node is filtered out (the parent does not own it) but the local one still + // refreshes — the batch is not rejected. + localLayer->alpha = 0.4f; + childLayer->alpha = 0.2f; + doc->notifyChange({localLayer, childLayer}, /*layoutChanged=*/false); + EXPECT_FLOAT_EQ(localTgfx->alpha(), 0.4f); + EXPECT_FLOAT_EQ(childTgfx->alpha(), 1.0f); +} + /** * Test case: external file enumeration continues through loaded external PAGX documents. */ @@ -8399,7 +8450,7 @@ PAGX_TEST(PAGXTest, NotifyChangeRenderAttributeInPlace) { EXPECT_FLOAT_EQ(tgfxLayer->alpha(), 1.0f); layer->alpha = 0.3f; - doc->notifyChange({layer}); + doc->notifyChange({layer}, /*layoutChanged=*/true); // Same tgfx::Layer instance is reused (in place), and the new alpha is reflected. EXPECT_EQ(scene->mutableBinding()->get(layer).get(), tgfxLayer.get()); @@ -8433,7 +8484,7 @@ PAGX_TEST(PAGXTest, NotifyChangeVectorContentColor) { EXPECT_EQ(tgfxSolid->color(), tgfx::Color({1.0f, 0.0f, 0.0f, 1.0f})); solid->color = {0.0f, 1.0f, 0.0f, 1.0f}; - doc->notifyChange({layer}); + doc->notifyChange({layer}, /*layoutChanged=*/true); // Contents are regenerated, so the binding now points at a fresh tgfx SolidColor with the edit. auto refreshedSolid = scene->mutableBinding()->get(solid); @@ -8478,7 +8529,7 @@ PAGX_TEST(PAGXTest, NotifyChangeEmptyLayerGainsContents) { solid->color = {1.0f, 0.0f, 0.0f, 1.0f}; fill->color = solid; layer->contents.push_back(fill); - doc->notifyChange({layer}); + doc->notifyChange({layer}, /*layoutChanged=*/true); // The node is now bound to a VectorLayer, the added content is bound, and the nested child layer // instance is preserved (its handle stays valid). @@ -8528,7 +8579,7 @@ PAGX_TEST(PAGXTest, NotifyChangeKeepsCompositionChildHitTestable) { EXPECT_EQ(hits[0]->name(), "NestedChild"); // Mark the top-level composition child dirty so refreshNodes runs its runtimeLayer re-sync loop. - doc->notifyChange({compLayer}); + doc->notifyChange({compLayer}, /*layoutChanged=*/true); // The composition child's runtimeLayer was not overwritten with its slot, so the nested child is // still resolved by the hit-test. @@ -8565,7 +8616,7 @@ PAGX_TEST(PAGXTest, NotifyChangeLayoutWidth) { EXPECT_FLOAT_EQ(hits[0]->getBounds().width, 50); rect->size = {120, 50}; - doc->notifyChange({layer}); + doc->notifyChange({layer}, /*layoutChanged=*/true); // The same tgfx::Layer instance is kept and the regenerated content reflects the new width. EXPECT_EQ(scene->mutableBinding()->get(layer).get(), tgfxLayer.get()); @@ -8605,7 +8656,7 @@ PAGX_TEST(PAGXTest, NotifyChangeKeepsTimelineWhenNoTimelineNodeDirty) { EXPECT_TRUE(timeline->resolved); // A plain layer edit is not a timeline node, so the timeline is left untouched (cache preserved). - doc->notifyChange({layer}); + doc->notifyChange({layer}, /*layoutChanged=*/true); EXPECT_TRUE(timeline->resolved); // Playback still drives the channel correctly. @@ -8647,7 +8698,7 @@ PAGX_TEST(PAGXTest, NotifyChangeResetsTimelinesOnChannelEdit) { // Edit the keyframe value and mark the Channel node dirty: timelines are rebuilt. alphaChannel->keyframes[0].value = 0.25f; - doc->notifyChange({alphaChannel}); + doc->notifyChange({alphaChannel}, /*layoutChanged=*/true); // A freshly rebuilt timeline applies the new value. auto rebuilt = scene->getDefaultTimeline(); @@ -8699,7 +8750,7 @@ PAGX_TEST(PAGXTest, NotifyChangeRetargetAnimationObject) { object->target = "B"; tgfxA->setAlpha(1.0f); tgfxB->setAlpha(1.0f); - doc->notifyChange({object}); + doc->notifyChange({object}, /*layoutChanged=*/true); scene->getDefaultTimeline()->apply(1.0f); EXPECT_FLOAT_EQ(tgfxB->alpha(), 0.5f); EXPECT_FLOAT_EQ(tgfxA->alpha(), 1.0f); @@ -8752,7 +8803,7 @@ PAGX_TEST(PAGXTest, NotifyChangeRemovedAnimationStopsDriving) { // Remove the driver from the layer and notify with the animation node dirty: timelines are rebuilt // from the now-empty driver list, so nothing drives the child. compLayer->timelines.clear(); - doc->notifyChange({anim}); + doc->notifyChange({anim}, /*layoutChanged=*/true); tgfxChild->setAlpha(1.0f); scene->advanceAndApply(500'000); @@ -8779,7 +8830,7 @@ PAGX_TEST(PAGXTest, NotifyChangeAddLayer) { second->width = 30; second->height = 30; doc->layers.push_back(second); - doc->notifyChange({second}); + doc->notifyChange({second}, /*layoutChanged=*/true); // The new layer now has a tgfx mapping, and the existing one is untouched (same instance). auto secondTgfx = scene->mutableBinding()->get(second); @@ -8811,7 +8862,7 @@ PAGX_TEST(PAGXTest, NotifyChangeRemoveLayer) { // Remove the second layer from the document and notify. doc->layers.pop_back(); - doc->notifyChange({second}); + doc->notifyChange({second}, /*layoutChanged=*/true); // The removed layer's binding is dropped; the surviving layer keeps its instance. EXPECT_EQ(scene->mutableBinding()->get(second), nullptr); @@ -8845,7 +8896,7 @@ PAGX_TEST(PAGXTest, NotifyChangeNestedChildAddRemove) { childB->width = 20; childB->height = 20; parent->children.push_back(childB); - doc->notifyChange({parent}); + doc->notifyChange({parent}, /*layoutChanged=*/true); // B is built and bound; A and the parent keep their original tgfx instances. auto childBTgfx = scene->mutableBinding()->get(childB); @@ -8855,7 +8906,7 @@ PAGX_TEST(PAGXTest, NotifyChangeNestedChildAddRemove) { // Remove the nested child A and notify; its binding is dropped, B and parent stay valid. parent->children.erase(parent->children.begin()); - doc->notifyChange({parent}); + doc->notifyChange({parent}, /*layoutChanged=*/true); EXPECT_EQ(scene->mutableBinding()->get(childA), nullptr); EXPECT_EQ(scene->mutableBinding()->get(childB).get(), childBTgfx.get()); EXPECT_EQ(scene->mutableBinding()->get(parent).get(), parentTgfx.get()); @@ -8902,7 +8953,7 @@ PAGX_TEST(PAGXTest, NotifyChangeAddComposition) { doc->layers.push_back(slot); // Notify with the newly inserted slot layer; syncChildren builds its composition subtree. - doc->notifyChange({slot}); + doc->notifyChange({slot}, /*layoutChanged=*/true); // The slot is bound, the nested composition content is hit-testable, and the existing sibling // layer keeps its original tgfx instance. @@ -8957,7 +9008,7 @@ PAGX_TEST(PAGXTest, NotifyChangeRemoveComposition) { // Remove the slot layer from the document and notify. doc->layers.pop_back(); - doc->notifyChange({slot}); + doc->notifyChange({slot}, /*layoutChanged=*/true); // The slot's binding is dropped, the surviving sibling keeps its instance, and the composition // content is no longer hit-testable. @@ -9395,6 +9446,63 @@ PAGX_TEST(PAGXTest, ChannelLayerMatrix) { EXPECT_FLOAT_EQ(m.getTranslateX(), 10.0f); } +/** + * Test case: a matrix tween between two rotations on opposite sides of +/-pi (170deg and 190deg, + * which atan2 recovers as +2.967 rad and -2.967 rad) takes the shortest arc (a continuous 20deg + * sweep through 180deg) instead of the long way (340deg in the opposite direction). Multi-turn + * winding is still unrecoverable from a baked matrix and remains documented on the scalar rotation + * channel; this test only fixes the +/-pi-boundary case. + */ +PAGX_TEST(PAGXTest, ChannelLayerMatrixRotationShortestArc) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode("L"); + doc->layers.push_back(layer); + + auto anim = doc->makeNode("anim"); + anim->duration = 60; + anim->frameRate = 60; + doc->animations.push_back(anim); + auto* object = doc->makeNode(); + object->target = "L"; + anim->objects.push_back(object); + auto* matrixProp = doc->makeNode>(); + matrixProp->name = "matrix"; + // 170deg and 190deg encode as +2.967 / -2.967 rad after atan2 recovery; without the wrap fix the + // tween would interpolate -5.934 rad (the long way around). + pagx::Matrix m170 = pagx::Matrix::Rotate(170.0f); + pagx::Matrix m190 = pagx::Matrix::Rotate(190.0f); + matrixProp->keyframes.push_back({0, m170, pagx::KeyframeInterpolationType::Linear, {}, {}}); + matrixProp->keyframes.push_back({60, m190, pagx::KeyframeInterpolationType::Linear, {}, {}}); + object->channels.push_back(matrixProp); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto tgfxLayer = scene->mutableBinding()->get(layer); + ASSERT_TRUE(tgfxLayer != nullptr); + + auto timeline = scene->getDefaultTimeline(); + ASSERT_TRUE(timeline != nullptr); + // Sweep at 25%, 50%, 75% of the segment. The shortest-arc rotation should rise monotonically from + // 170 -> 175 -> 180 -> 185 -> 190 deg; sin and cos act as a sufficient continuity check. + // Without the wrap fix, the long-way path (-5.934 * t) would dip below sin(170deg) before + // climbing back, breaking monotonicity around midway. + auto sample = [&](int64_t timeUs) { + timeline->setCurrentTime(timeUs); + timeline->apply(1.0f); + return tgfxLayer->matrix(); + }; + auto m25 = sample(250000); + auto m50 = sample(500000); + auto m75 = sample(750000); + // At 50%, rotation should be exactly 180deg: cos = -1, sin = 0. + EXPECT_NEAR(m50.getScaleX(), -1.0f, 1e-3f); + EXPECT_NEAR(m50.getSkewY(), 0.0f, 1e-3f); + // sin(angle) = m.b for a pure rotation. Monotonic decrease 170 -> 180 -> 190 deg means + // sin is positive (+0.087), then 0, then negative (-0.087) — the long way would flip signs. + EXPECT_GT(m25.getSkewY(), 0.0f); + EXPECT_LT(m75.getSkewY(), 0.0f); +} + /** * Test case: every channel the reflection registry marks Animatable for a built node type has a * matching runtime writer, so animations cannot target a channel that silently does nothing. From 24e56fdd2308690a95ccea55531f231e61643bed Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Tue, 16 Jun 2026 15:16:28 +0800 Subject: [PATCH 33/39] Fix lambda violation in LayerBuilder by adding reverse index and lookup methods to RuntimeBinding. --- src/renderer/LayerBuilder.cpp | 55 ++++++++----------- src/renderer/LayerBuilder.h | 100 +++++++++++++++++++++++++++++++--- 2 files changed, 113 insertions(+), 42 deletions(-) diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index 76c3bdc492..325dd0169b 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -396,9 +396,13 @@ class LayerBuilderContext { if (type == NodeType::Group || type == NodeType::TextBox) { unbindContentElements(static_cast(element)->elements); } else if (type == NodeType::Fill) { - unbindColorSourceIfUnreferenced(static_cast(element)->color, element); + auto* fill = static_cast(element); + _result.binding.unregisterColorSourceUser(fill->color, element); + unbindColorSourceIfUnreferenced(fill->color, element); } else if (type == NodeType::Stroke) { - unbindColorSourceIfUnreferenced(static_cast(element)->color, element); + auto* stroke = static_cast(element); + _result.binding.unregisterColorSourceUser(stroke->color, element); + unbindColorSourceIfUnreferenced(stroke->color, element); } _result.binding.remove(element); } @@ -415,28 +419,15 @@ class LayerBuilderContext { if (color == nullptr || !_result.binding.contains(color)) { return; } - bool stillReferenced = false; - _result.binding.forEachBoundNode([&](const Node* node) { - if (node == excludedOwner) { - return true; - } - const ColorSource* other = nullptr; - if (node->nodeType() == NodeType::Fill) { - other = static_cast(node)->color; - } else if (node->nodeType() == NodeType::Stroke) { - other = static_cast(node)->color; - } - if (other == color) { - stillReferenced = true; - return false; - } - return true; - }); - if (stillReferenced) { + if (_result.binding.isColorSourceReferencedExceptBy(color, excludedOwner)) { return; } if (color->nodeType() == NodeType::ImagePattern) { - unbindImageIfUnreferenced(static_cast(color)); + auto* pattern = static_cast(color); + if (pattern->image) { + _result.binding.unregisterImageUser(pattern->image, pattern); + } + unbindImageIfUnreferenced(pattern); } else if (color->nodeType() != NodeType::SolidColor) { for (const auto* stop : static_cast(color)->colorStops) { _result.binding.remove(stop); @@ -453,18 +444,7 @@ class LayerBuilderContext { if (image == nullptr || !_result.binding.contains(image)) { return; } - bool stillReferenced = false; - _result.binding.forEachBoundNode([&](const Node* node) { - if (node == pattern || node->nodeType() != NodeType::ImagePattern) { - return true; - } - if (static_cast(node)->image == image) { - stillReferenced = true; - return false; - } - return true; - }); - if (stillReferenced) { + if (_result.binding.isImageReferencedExceptBy(image, pattern)) { return; } _result.binding.remove(image); @@ -962,6 +942,9 @@ class LayerBuilderContext { auto fill = tgfx::FillStyle::Make(colorSource); if (fill) { _result.binding.set(node, fill); + if (node->color) { + _result.binding.registerColorSourceUser(node->color, node); + } fill->setAlpha(node->alpha); if (node->blendMode != BlendMode::Normal) { fill->setBlendMode(ToTGFX(node->blendMode)); @@ -993,6 +976,9 @@ class LayerBuilderContext { return nullptr; } _result.binding.set(node, stroke); + if (node->color) { + _result.binding.registerColorSourceUser(node->color, node); + } stroke->setStrokeWidth(node->width); stroke->setAlpha(node->alpha); stroke->setLineCap(ToTGFX(node->cap)); @@ -1281,6 +1267,9 @@ class LayerBuilderContext { tgfx::ImagePattern::Make(image, ToTGFX(node->tileModeX), ToTGFX(node->tileModeY), sampling); if (pattern) { _result.binding.set(node, pattern); + if (imageNode) { + _result.binding.registerImageUser(imageNode, node); + } pattern->setScaleMode(ToTGFX(node->scaleMode)); if (!node->matrix.isIdentity()) { pattern->setMatrix(ToTGFX(node->matrix)); diff --git a/src/renderer/LayerBuilder.h b/src/renderer/LayerBuilder.h index 51dc980d34..93fc0c600e 100644 --- a/src/renderer/LayerBuilder.h +++ b/src/renderer/LayerBuilder.h @@ -18,6 +18,7 @@ #pragma once +#include #include #include #include @@ -33,6 +34,9 @@ class Gradient; namespace pagx { +class ColorSource; +class ImagePattern; + /** * Runtime color stop binding keeps the parent gradient and stop index for a ColorStop node. */ @@ -142,17 +146,84 @@ struct RuntimeBinding { return targets.find(node) != targets.end(); } - // Iterates over every node that currently has a binding entry, calling fn(node) on each. Used by - // removal to scan for surviving references to a shared resource (e.g. a color source referenced - // by multiple fills) before unbinding it. fn returns true to continue iteration, false to stop - // early. Iterating directly avoids the per-call vector allocation a snapshot would require. - template - void forEachBoundNode(Fn&& fn) const { - for (const auto& entry : targets) { - if (!fn(entry.first)) { - return; + // Register a Fill/Stroke in the reverse index for its color source. Called by LayerBuilder after + // a painter (Fill or Stroke) is bound to a tgfx object during tree construction. + void registerColorSourceUser(const Node* colorSource, const Node* painter) { + if (colorSource == nullptr || painter == nullptr) { + return; + } + colorSourceUsers[colorSource].push_back(painter); + } + + // Register an ImagePattern in the reverse index for its image. Called by LayerBuilder after an + // ImagePattern is bound to a tgfx object during tree construction. + void registerImageUser(const Node* image, const Node* pattern) { + if (image == nullptr || pattern == nullptr) { + return; + } + imageUsers[image].push_back(pattern); + } + + // Unregister a painter from its color source's reverse index. Called when a Fill/Stroke is + // about to be removed from the binding. + void unregisterColorSourceUser(const Node* colorSource, const Node* painter) { + if (colorSource == nullptr || painter == nullptr) { + return; + } + auto it = colorSourceUsers.find(colorSource); + if (it != colorSourceUsers.end()) { + auto& vec = it->second; + vec.erase(std::remove(vec.begin(), vec.end(), painter), vec.end()); + if (vec.empty()) { + colorSourceUsers.erase(it); + } + } + } + + // Unregister an ImagePattern from its image's reverse index. Called when an ImagePattern is + // about to be removed from the binding. + void unregisterImageUser(const Node* image, const Node* pattern) { + if (image == nullptr || pattern == nullptr) { + return; + } + auto it = imageUsers.find(image); + if (it != imageUsers.end()) { + auto& vec = it->second; + vec.erase(std::remove(vec.begin(), vec.end(), pattern), vec.end()); + if (vec.empty()) { + imageUsers.erase(it); + } + } + } + + // Returns true if any painter other than excludedOwner references the given color source. O(1) + // via the reverse index maintained during build. + bool isColorSourceReferencedExceptBy(const Node* colorSource, const Node* excludedOwner) const { + auto it = colorSourceUsers.find(colorSource); + if (it == colorSourceUsers.end()) { + return false; + } + for (const auto* painter : it->second) { + if (painter != excludedOwner && targets.find(painter) != targets.end()) { + return true; } } + return false; + } + + // Returns true if any ImagePattern other than excludedPattern references the given image. O(1) + // via the reverse index maintained during build. + bool isImageReferencedExceptBy(const Node* image, const Node* excludedPattern) const { + auto it = imageUsers.find(image); + if (it == imageUsers.end()) { + return false; + } + for (const auto* pattern : it->second) { + if (pattern != excludedPattern && targets.find(pattern) != targets.end()) { + return true; + } + } + return false; } bool apply(const Node* node, const std::string& channel, const KeyValue& value, float mix) const { @@ -205,6 +276,17 @@ struct RuntimeBinding { } std::unordered_map> targets = {}; + + // Reverse index: for each ColorSource bound to any Fill/Stroke, the set of Elements + // (Fill/Stroke) that reference it. Maintained incrementally by set/remove so + // unbindColorSourceIfUnreferenced can check for surviving references in O(1) instead of + // scanning all bound nodes. + std::unordered_map> colorSourceUsers = {}; + + // Reverse index: for each Image bound to any ImagePattern, the set of ImagePattern nodes that + // reference it. Maintained incrementally so unbindImageIfUnreferenced can check for surviving + // references in O(1). + std::unordered_map> imageUsers = {}; }; /** From 1a92f368d457d077e23cd2102e846a84b7fe3c03 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Tue, 16 Jun 2026 15:47:20 +0800 Subject: [PATCH 34/39] Rename RuntimeBinding reverse-index methods to track/untrack + shared --- src/renderer/LayerBuilder.cpp | 16 ++++++++-------- src/renderer/LayerBuilder.h | 32 ++++++++++++++++---------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index 325dd0169b..d5f13b7685 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -397,11 +397,11 @@ class LayerBuilderContext { unbindContentElements(static_cast(element)->elements); } else if (type == NodeType::Fill) { auto* fill = static_cast(element); - _result.binding.unregisterColorSourceUser(fill->color, element); + _result.binding.untrackColorSource(fill->color, element); unbindColorSourceIfUnreferenced(fill->color, element); } else if (type == NodeType::Stroke) { auto* stroke = static_cast(element); - _result.binding.unregisterColorSourceUser(stroke->color, element); + _result.binding.untrackColorSource(stroke->color, element); unbindColorSourceIfUnreferenced(stroke->color, element); } _result.binding.remove(element); @@ -419,13 +419,13 @@ class LayerBuilderContext { if (color == nullptr || !_result.binding.contains(color)) { return; } - if (_result.binding.isColorSourceReferencedExceptBy(color, excludedOwner)) { + if (_result.binding.isColorSourceShared(color, excludedOwner)) { return; } if (color->nodeType() == NodeType::ImagePattern) { auto* pattern = static_cast(color); if (pattern->image) { - _result.binding.unregisterImageUser(pattern->image, pattern); + _result.binding.untrackImage(pattern->image, pattern); } unbindImageIfUnreferenced(pattern); } else if (color->nodeType() != NodeType::SolidColor) { @@ -444,7 +444,7 @@ class LayerBuilderContext { if (image == nullptr || !_result.binding.contains(image)) { return; } - if (_result.binding.isImageReferencedExceptBy(image, pattern)) { + if (_result.binding.isImageShared(image, pattern)) { return; } _result.binding.remove(image); @@ -943,7 +943,7 @@ class LayerBuilderContext { if (fill) { _result.binding.set(node, fill); if (node->color) { - _result.binding.registerColorSourceUser(node->color, node); + _result.binding.trackColorSource(node->color, node); } fill->setAlpha(node->alpha); if (node->blendMode != BlendMode::Normal) { @@ -977,7 +977,7 @@ class LayerBuilderContext { } _result.binding.set(node, stroke); if (node->color) { - _result.binding.registerColorSourceUser(node->color, node); + _result.binding.trackColorSource(node->color, node); } stroke->setStrokeWidth(node->width); stroke->setAlpha(node->alpha); @@ -1268,7 +1268,7 @@ class LayerBuilderContext { if (pattern) { _result.binding.set(node, pattern); if (imageNode) { - _result.binding.registerImageUser(imageNode, node); + _result.binding.trackImage(imageNode, node); } pattern->setScaleMode(ToTGFX(node->scaleMode)); if (!node->matrix.isIdentity()) { diff --git a/src/renderer/LayerBuilder.h b/src/renderer/LayerBuilder.h index 93fc0c600e..bd5b3b54ef 100644 --- a/src/renderer/LayerBuilder.h +++ b/src/renderer/LayerBuilder.h @@ -146,43 +146,43 @@ struct RuntimeBinding { return targets.find(node) != targets.end(); } - // Register a Fill/Stroke in the reverse index for its color source. Called by LayerBuilder after - // a painter (Fill or Stroke) is bound to a tgfx object during tree construction. - void registerColorSourceUser(const Node* colorSource, const Node* painter) { - if (colorSource == nullptr || painter == nullptr) { + // Tracks a Fill/Stroke in the reverse index for its color source. Called by LayerBuilder after + // an element (Fill or Stroke) is bound to a tgfx object during tree construction. + void trackColorSource(const Node* colorSource, const Node* element) { + if (colorSource == nullptr || element == nullptr) { return; } - colorSourceUsers[colorSource].push_back(painter); + colorSourceUsers[colorSource].push_back(element); } - // Register an ImagePattern in the reverse index for its image. Called by LayerBuilder after an + // Tracks an ImagePattern in the reverse index for its image. Called by LayerBuilder after an // ImagePattern is bound to a tgfx object during tree construction. - void registerImageUser(const Node* image, const Node* pattern) { + void trackImage(const Node* image, const Node* pattern) { if (image == nullptr || pattern == nullptr) { return; } imageUsers[image].push_back(pattern); } - // Unregister a painter from its color source's reverse index. Called when a Fill/Stroke is + // Untrack an element from its color source's reverse index. Called when a Fill/Stroke is // about to be removed from the binding. - void unregisterColorSourceUser(const Node* colorSource, const Node* painter) { - if (colorSource == nullptr || painter == nullptr) { + void untrackColorSource(const Node* colorSource, const Node* element) { + if (colorSource == nullptr || element == nullptr) { return; } auto it = colorSourceUsers.find(colorSource); if (it != colorSourceUsers.end()) { auto& vec = it->second; - vec.erase(std::remove(vec.begin(), vec.end(), painter), vec.end()); + vec.erase(std::remove(vec.begin(), vec.end(), element), vec.end()); if (vec.empty()) { colorSourceUsers.erase(it); } } } - // Unregister an ImagePattern from its image's reverse index. Called when an ImagePattern is + // Untrack an ImagePattern from its image's reverse index. Called when an ImagePattern is // about to be removed from the binding. - void unregisterImageUser(const Node* image, const Node* pattern) { + void untrackImage(const Node* image, const Node* pattern) { if (image == nullptr || pattern == nullptr) { return; } @@ -196,9 +196,9 @@ struct RuntimeBinding { } } - // Returns true if any painter other than excludedOwner references the given color source. O(1) + // Returns true if any element other than excludedOwner references the given color source. O(1) // via the reverse index maintained during build. - bool isColorSourceReferencedExceptBy(const Node* colorSource, const Node* excludedOwner) const { + bool isColorSourceShared(const Node* colorSource, const Node* excludedOwner) const { auto it = colorSourceUsers.find(colorSource); if (it == colorSourceUsers.end()) { return false; @@ -213,7 +213,7 @@ struct RuntimeBinding { // Returns true if any ImagePattern other than excludedPattern references the given image. O(1) // via the reverse index maintained during build. - bool isImageReferencedExceptBy(const Node* image, const Node* excludedPattern) const { + bool isImageShared(const Node* image, const Node* excludedPattern) const { auto it = imageUsers.find(image); if (it == imageUsers.end()) { return false; From a0447470e85ba3e088021bc6fb42093a79786974 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Tue, 16 Jun 2026 16:21:00 +0800 Subject: [PATCH 35/39] Fix refreshLayerInPlace to unbind elements removed from Layer contents. --- src/renderer/LayerBuilder.cpp | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index d5f13b7685..f8ef7e1656 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -274,6 +274,20 @@ class LayerBuilderContext { // gained contents was promoted above, and one whose contents were cleared (now empty, but still // a VectorLayer) drops its stale elements via setContents({}). The type guard keeps the cast // safe for plain layers that never had contents. + // Collect old content elements that are currently bound so we can unbind those no longer present + // in node->contents after the rebuild. Without this, stale binding entries for removed elements + // (Fill/Stroke/Elements) remain in the RuntimeBinding, and shared ColorSource/Image unbind logic + // never triggers for them. + std::vector oldElements; + if (layer->type() == tgfx::LayerType::Vector) { + auto* vecLayer = static_cast(layer.get()); + for (const auto& content : vecLayer->contents()) { + const auto* nodeFromContent = _result.binding.findNode(content.get()); + if (nodeFromContent != nullptr && nodeFromContent->nodeType() != NodeType::Layer) { + oldElements.push_back(const_cast(static_cast(nodeFromContent))); + } + } + } if (node->composition == nullptr && layer->type() == tgfx::LayerType::Vector) { auto* vectorLayer = static_cast(layer.get()); std::vector> contents = {}; @@ -286,6 +300,19 @@ class LayerBuilderContext { } vectorLayer->setContents(contents); } + // Unbind content elements that were removed from node->contents. + if (!oldElements.empty()) { + std::vector removed; + for (auto* element : oldElements) { + if (std::find(node->contents.begin(), node->contents.end(), element) == + node->contents.end()) { + removed.push_back(element); + } + } + if (!removed.empty()) { + unbindContentElements(removed); + } + } applyLayerAttributes(node, layer.get()); // Re-seed the decomposed transform baseline from the node's current authored values, mirroring // the initial build (see convertLayer). The Layer's x / y / matrix channels are AnimLayout, so a From 6f8ccf7f370d2eef722c0ba9e4cd9e2c13438670 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Tue, 16 Jun 2026 16:29:37 +0800 Subject: [PATCH 36/39] Fix old style/filter binding entries not cleaned up in refreshLayerInPlace. --- src/renderer/LayerBuilder.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index f8ef7e1656..52816fae69 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -266,6 +266,23 @@ class LayerBuilderContext { layer->setAllowsEdgeAntialiasing(true); layer->setPassThroughBackground(true); layer->setScrollRect(tgfx::Rect::MakeEmpty()); + // Collect style/filter nodes currently bound to this layer's styles and filters before + // resetting them, so we can clean up entries for any that were removed from node->styles or + // node->filters after the rebuild. + std::vector oldStyles; + for (const auto& tgfxStyle : layer->layerStyles()) { + const auto* styleNode = _result.binding.findNode(tgfxStyle.get()); + if (styleNode != nullptr) { + oldStyles.push_back(styleNode); + } + } + std::vector oldFilters; + for (const auto& tgfxFilter : layer->filters()) { + const auto* filterNode = _result.binding.findNode(tgfxFilter.get()); + if (filterNode != nullptr) { + oldFilters.push_back(filterNode); + } + } layer->setLayerStyles({}); layer->setFilters({}); layer->setMask(nullptr); @@ -314,6 +331,18 @@ class LayerBuilderContext { } } applyLayerAttributes(node, layer.get()); + // Unbind style/filter nodes that were removed from node->styles or node->filters. + for (const auto* styleNode : oldStyles) { + if (std::find(node->styles.begin(), node->styles.end(), styleNode) == node->styles.end()) { + _result.binding.remove(styleNode); + } + } + for (const auto* filterNode : oldFilters) { + if (std::find(node->filters.begin(), node->filters.end(), filterNode) == + node->filters.end()) { + _result.binding.remove(filterNode); + } + } // Re-seed the decomposed transform baseline from the node's current authored values, mirroring // the initial build (see convertLayer). The Layer's x / y / matrix channels are AnimLayout, so a // document edit to them must update the LayerRuntimeTarget baseline; otherwise a concurrent From d8705dd878b6adc13fa5a94ea3c441b1e6a39bae Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Tue, 16 Jun 2026 16:39:58 +0800 Subject: [PATCH 37/39] Add tests for shared ColorSource/Image/Gradient unbind via notifyChange layer removal. --- test/src/PAGXTest.cpp | 226 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 226 insertions(+) diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index 3849ee8fbd..08c874fe95 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -102,6 +102,7 @@ #endif #include "tgfx/core/Data.h" #include "tgfx/core/Font.h" +#include "tgfx/core/Image.h" #include "tgfx/core/Stream.h" #include "tgfx/core/Surface.h" #include "tgfx/core/TextBlob.h" @@ -115,7 +116,9 @@ #include "tgfx/layers/filters/NoiseFilter.h" #include "tgfx/layers/layerstyles/DropShadowStyle.h" #include "tgfx/layers/layerstyles/NoiseStyle.h" +#include "tgfx/layers/vectors/FillStyle.h" #include "tgfx/layers/vectors/Gradient.h" +#include "tgfx/layers/vectors/ImagePattern.h" #include "tgfx/layers/vectors/Rectangle.h" #include "tgfx/layers/vectors/SolidColor.h" #include "tgfx/layers/vectors/Text.h" @@ -8431,6 +8434,7 @@ PAGX_TEST(PAGXTest, ExportNoiseFilterAnimation) { auto key = "PAGXTest/NoiseFilterAnimation/frame_" + std::to_string(i); EXPECT_TRUE(Baseline::Compare(surface, key)); } +} /** * Test case: notifyChange reflects a render-attribute edit (alpha) on the live tgfx layer in place, * preserving the existing tgfx::Layer instance so handles stay valid. @@ -9617,4 +9621,226 @@ PAGX_TEST(PAGXTest, AnimatableChannelsHaveWriters) { } } +/** + * Test: a SolidColor shared by two Fill painters stays bound when only one Fill is removed. + */ +PAGX_TEST(PAGXTest, SharedSolidColorSurvivesAfterOnePainterRemoved) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + layer->width = 50; + layer->height = 50; + doc->layers.push_back(layer); + + auto solid = doc->makeNode("sharedSolid"); + solid->color = {1.0f, 0.0f, 0.0f, 1.0f}; + + auto rect1 = doc->makeNode(); + rect1->size = {20, 20}; + layer->contents.push_back(rect1); + auto fill1 = doc->makeNode(); + fill1->color = solid; + layer->contents.push_back(fill1); + + auto rect2 = doc->makeNode(); + rect2->size = {20, 20}; + layer->contents.push_back(rect2); + auto fill2 = doc->makeNode(); + fill2->color = solid; + layer->contents.push_back(fill2); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto* binding = scene->mutableBinding(); + ASSERT_TRUE(binding->get(solid) != nullptr); + ASSERT_TRUE(binding->get(fill1) != nullptr); + ASSERT_TRUE(binding->get(fill2) != nullptr); + + layer->contents.erase(std::remove(layer->contents.begin(), layer->contents.end(), fill2), + layer->contents.end()); + doc->notifyChange({layer}, /*layoutChanged=*/true); + + EXPECT_TRUE(binding->get(fill2) == nullptr); + EXPECT_TRUE(binding->get(fill1) != nullptr); + EXPECT_TRUE(binding->get(solid) != nullptr); +} + +/** + * Test: a SolidColor shared by two Fill painters is unbound when all Fills are removed. + */ +PAGX_TEST(PAGXTest, BothPaintersRemovedUnbindsSharedSolidColor) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + layer->width = 50; + layer->height = 50; + doc->layers.push_back(layer); + + auto solid = doc->makeNode("sharedSolid"); + solid->color = {1.0f, 0.0f, 0.0f, 1.0f}; + + auto fill1 = doc->makeNode(); + fill1->color = solid; + layer->contents.push_back(fill1); + auto fill2 = doc->makeNode(); + fill2->color = solid; + layer->contents.push_back(fill2); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto* binding = scene->mutableBinding(); + ASSERT_TRUE(binding->get(solid) != nullptr); + + layer->contents.clear(); + doc->notifyChange({layer}, /*layoutChanged=*/true); + + EXPECT_TRUE(binding->get(fill1) == nullptr); + EXPECT_TRUE(binding->get(fill2) == nullptr); + EXPECT_TRUE(binding->get(solid) == nullptr); +} + +/** + * Test: an Image shared by two ImagePattern painters stays bound when one pattern is removed. + */ +PAGX_TEST(PAGXTest, SharedImageSurvivesAfterOnePatternRemoved) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + layer->width = 50; + layer->height = 50; + doc->layers.push_back(layer); + + auto imageNode = doc->makeNode("sharedImage"); + imageNode->filePath = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/" + "5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; + + auto rect1 = doc->makeNode(); + rect1->size = {20, 20}; + layer->contents.push_back(rect1); + auto pattern1 = doc->makeNode(); + pattern1->image = imageNode; + auto fill1 = doc->makeNode(); + fill1->color = pattern1; + layer->contents.push_back(fill1); + + auto rect2 = doc->makeNode(); + rect2->size = {20, 20}; + layer->contents.push_back(rect2); + auto pattern2 = doc->makeNode(); + pattern2->image = imageNode; + auto fill2 = doc->makeNode(); + fill2->color = pattern2; + layer->contents.push_back(fill2); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto* binding = scene->mutableBinding(); + ASSERT_TRUE(binding->get(imageNode) != nullptr); + ASSERT_TRUE(binding->contains(pattern1)); + ASSERT_TRUE(binding->contains(pattern2)); + + layer->contents.erase(std::remove(layer->contents.begin(), layer->contents.end(), fill2), + layer->contents.end()); + doc->notifyChange({layer}, /*layoutChanged=*/true); + + EXPECT_FALSE(binding->contains(pattern2)); + EXPECT_TRUE(binding->contains(pattern1)); + EXPECT_TRUE(binding->get(imageNode) != nullptr); +} + +/** + * Test: an Image shared by two ImagePattern painters is unbound when all patterns are removed. + */ +PAGX_TEST(PAGXTest, SharedImageUnboundAfterAllPatternsRemoved) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + layer->width = 50; + layer->height = 50; + doc->layers.push_back(layer); + + auto imageNode = doc->makeNode("sharedImage"); + imageNode->filePath = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/" + "5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; + + auto pattern1 = doc->makeNode(); + pattern1->image = imageNode; + auto fill1 = doc->makeNode(); + fill1->color = pattern1; + layer->contents.push_back(fill1); + + auto pattern2 = doc->makeNode(); + pattern2->image = imageNode; + auto fill2 = doc->makeNode(); + fill2->color = pattern2; + layer->contents.push_back(fill2); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto* binding = scene->mutableBinding(); + ASSERT_TRUE(binding->get(imageNode) != nullptr); + + layer->contents.clear(); + doc->notifyChange({layer}, /*layoutChanged=*/true); + + EXPECT_FALSE(binding->contains(fill1)); + EXPECT_FALSE(binding->contains(pattern1)); + EXPECT_FALSE(binding->contains(fill2)); + EXPECT_FALSE(binding->contains(pattern2)); + EXPECT_TRUE(binding->get(imageNode) == nullptr); +} + +/** + * Test: a LinearGradient shared by two Fill painters stays bound (with its ColorStops) when one + * Fill is removed. + */ +PAGX_TEST(PAGXTest, SharedGradientSurvivesAfterOnePainterRemoved) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + layer->width = 50; + layer->height = 50; + doc->layers.push_back(layer); + + auto gradient = doc->makeNode("grad"); + gradient->startPoint = {0, 0}; + gradient->endPoint = {1, 1}; + auto stop1 = doc->makeNode(); + stop1->offset = 0; + stop1->color = {1, 0, 0, 1}; + gradient->colorStops.push_back(stop1); + auto stop2 = doc->makeNode(); + stop2->offset = 1; + stop2->color = {0, 0, 1, 1}; + gradient->colorStops.push_back(stop2); + + auto rect1 = doc->makeNode(); + rect1->size = {20, 20}; + layer->contents.push_back(rect1); + auto fill1 = doc->makeNode(); + fill1->color = gradient; + layer->contents.push_back(fill1); + + auto rect2 = doc->makeNode(); + rect2->size = {20, 20}; + layer->contents.push_back(rect2); + auto fill2 = doc->makeNode(); + fill2->color = gradient; + layer->contents.push_back(fill2); + + auto scene = pagx::PAGScene::Make(doc); + ASSERT_TRUE(scene != nullptr); + auto* binding = scene->mutableBinding(); + ASSERT_TRUE(binding->get(fill1) != nullptr); + ASSERT_TRUE(binding->get(fill2) != nullptr); + ASSERT_TRUE(binding->get(gradient) != nullptr); + + layer->contents.erase(std::remove(layer->contents.begin(), layer->contents.end(), fill2), + layer->contents.end()); + doc->notifyChange({layer}, /*layoutChanged=*/true); + + EXPECT_TRUE(binding->get(fill2) == nullptr); + EXPECT_TRUE(binding->get(fill1) != nullptr); + EXPECT_TRUE(binding->get(gradient) != nullptr); + EXPECT_TRUE(binding->contains(stop1)); + EXPECT_TRUE(binding->contains(stop2)); +} + } // namespace pag From 7924fa078cdab65856f51122daa25e3bf7c0f927 Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Tue, 16 Jun 2026 17:05:00 +0800 Subject: [PATCH 38/39] Fix duplicate reverse index entries and nested element unbind in content refresh. --- src/renderer/LayerBuilder.cpp | 77 ++++++++++++++++++++++++++++++--- test/src/PAGXTest.cpp | 81 +++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 7 deletions(-) diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index 52816fae69..62ae51ed3a 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -299,12 +299,17 @@ class LayerBuilderContext { if (layer->type() == tgfx::LayerType::Vector) { auto* vecLayer = static_cast(layer.get()); for (const auto& content : vecLayer->contents()) { - const auto* nodeFromContent = _result.binding.findNode(content.get()); - if (nodeFromContent != nullptr && nodeFromContent->nodeType() != NodeType::Layer) { - oldElements.push_back(const_cast(static_cast(nodeFromContent))); - } + collectElementTree(content.get(), &oldElements); } } + // Untrack all old elements from the reverse index before rebuilding, so that convertXxx (which + // calls trackColorSource/trackImage) starts from a clean slate. Surviving elements will be + // re-tracked during convertVectorElement; removed elements stay un-tracked and are cleaned up + // below. Without this, every refreshLayerInPlace call would append duplicate entries to the + // colorSourceUsers / imageUsers maps. + for (auto* element : oldElements) { + untrackElementColorSource(element); + } if (node->composition == nullptr && layer->type() == tgfx::LayerType::Vector) { auto* vectorLayer = static_cast(layer.get()); std::vector> contents = {}; @@ -317,12 +322,12 @@ class LayerBuilderContext { } vectorLayer->setContents(contents); } - // Unbind content elements that were removed from node->contents. + // Unbind content elements that were removed from node->contents. A Group/TextBox subtree + // element is considered removed only when it cannot be reached from any top-level content. if (!oldElements.empty()) { std::vector removed; for (auto* element : oldElements) { - if (std::find(node->contents.begin(), node->contents.end(), element) == - node->contents.end()) { + if (!isElementInContents(element, node->contents)) { removed.push_back(element); } } @@ -464,6 +469,64 @@ class LayerBuilderContext { } } + // Recursively collects all descendant elements from a Group/TextBox tree by walking the tgfx + // VectorElement hierarchy rather than the node tree. The node tree may have already been + // modified (elements erased from Group->elements), but the tgfx tree still holds the full + // pre-rebuild structure at the time oldElements are collected in refreshLayerInPlace. This + // ensures removed nested elements (e.g., Fill inside a Group) are captured and later unbound. + void collectElementTree(const tgfx::VectorElement* tgfxElement, std::vector* out) { + if (tgfxElement == nullptr) { + return; + } + const auto* nodeFromTgfx = _result.binding.findNode(tgfxElement); + if (nodeFromTgfx == nullptr || nodeFromTgfx->nodeType() == NodeType::Layer) { + return; + } + auto* element = const_cast(static_cast(nodeFromTgfx)); + out->push_back(element); + auto nodeType = element->nodeType(); + if (nodeType == NodeType::Group || nodeType == NodeType::TextBox) { + auto* tgfxGroup = static_cast(tgfxElement); + for (const auto& child : tgfxGroup->elements()) { + collectElementTree(child.get(), out); + } + } + } + + // Untracks the color source reverse-index entry for Fill/Stroke elements, or the image reverse + // index for ImagePattern. Called before rebuilding content during refreshLayerInPlace so that + // surviving elements are re-tracked by convertXxx without accumulating duplicate entries. + void untrackElementColorSource(Element* element) { + if (element == nullptr) { + return; + } + auto type = element->nodeType(); + if (type == NodeType::Fill) { + auto* fill = static_cast(element); + _result.binding.untrackColorSource(fill->color, element); + } else if (type == NodeType::Stroke) { + auto* stroke = static_cast(element); + _result.binding.untrackColorSource(stroke->color, element); + } + } + + // Returns true if the element is reachable from any of the top-level contents, recursing into + // Group/TextBox containers. + static bool isElementInContents(Element* target, const std::vector& contents) { + for (auto* element : contents) { + if (element == target) { + return true; + } + auto type = element->nodeType(); + if (type == NodeType::Group || type == NodeType::TextBox) { + if (isElementInContents(target, static_cast(element)->elements)) { + return true; + } + } + } + return false; + } + // Unbinds a Fill/Stroke color source, but only if no Fill/Stroke other than excludedOwner still // points at it. Shared color sources (referenced by several painters via "@id") stay bound as long // as any referencing painter survives. A gradient's ColorStop bindings are owned children of the diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index 08c874fe95..bcddfdf131 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -9843,4 +9843,85 @@ PAGX_TEST(PAGXTest, SharedGradientSurvivesAfterOnePainterRemoved) { EXPECT_TRUE(binding->contains(stop2)); } +/** + * Test case: multiple refreshLayerInPlace calls do not cause duplicate entries in the + * colorSourceUsers reverse index. Each refresh rebuilds vector contents, and convertFill + * calls trackColorSource again. Without the pre-refresh untrack step, the colorSourceUsers + * vector would grow by one duplicate entry per refresh. + */ +PAGX_TEST(PAGXTest, ReverseIndexNoDuplicatesAfterRepeatedRefresh) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + layer->width = 50; + layer->height = 50; + doc->layers.push_back(layer); + + auto solid = doc->makeNode("solid"); + solid->color = {1, 0, 0, 1}; + + auto rect = doc->makeNode(); + rect->size = {20, 20}; + layer->contents.push_back(rect); + auto fill = doc->makeNode(); + fill->color = solid; + layer->contents.push_back(fill); + + auto scene = pagx::PAGScene::Make(doc); + auto* binding = scene->mutableBinding(); + + size_t countBefore = binding->colorSourceUsers.at(solid).size(); + EXPECT_EQ(countBefore, 1u); + + // Refresh the layer 3 times without any content change. + for (int i = 0; i < 3; i++) { + doc->notifyChange({layer}, /*layoutChanged=*/false); + } + + // After repeated refreshes the reverse index must still have exactly one entry. + size_t countAfter = binding->colorSourceUsers.at(solid).size(); + EXPECT_EQ(countAfter, 1u); +} + +/** + * Test case: removing a Fill inside a Group and calling notifyChange properly untracks the + * old Fill from the reverse index and unbinds its shared color source when no other Fill + * references it. Before the fix, collectElementTree only collected top-level content + * elements, missing nested Group children. + */ +PAGX_TEST(PAGXTest, GroupInnerFillRemovedUnbindsSharedColor) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode("L"); + layer->width = 50; + layer->height = 50; + doc->layers.push_back(layer); + + auto solid = doc->makeNode("solid"); + solid->color = {0, 1, 0, 1}; + + auto group = doc->makeNode(); + auto ell = doc->makeNode(); + ell->size = {20, 20}; + group->elements.push_back(ell); + auto fill = doc->makeNode(); + fill->color = solid; + group->elements.push_back(fill); + + layer->contents.push_back(group); + + auto scene = pagx::PAGScene::Make(doc); + auto* binding = scene->mutableBinding(); + + EXPECT_TRUE(binding->get(fill) != nullptr); + EXPECT_TRUE(binding->contains(solid)); + + // Remove the Fill from inside the Group. + group->elements.erase(std::remove(group->elements.begin(), group->elements.end(), fill), + group->elements.end()); + doc->notifyChange({layer}, /*layoutChanged=*/true); + + EXPECT_TRUE(binding->get(fill) == nullptr); + EXPECT_FALSE(binding->contains(solid)); + EXPECT_EQ(binding->colorSourceUsers.find(solid), binding->colorSourceUsers.end()); +} + } // namespace pag From d2c1484b91db61df609ed7d22cfc15414ca8c9cb Mon Sep 17 00:00:00 2001 From: Hparty <420024556@qq.com> Date: Tue, 16 Jun 2026 17:11:15 +0800 Subject: [PATCH 39/39] Fix ImagePattern imageUsers repeat duplicate accumulation on refresh. --- src/renderer/LayerBuilder.cpp | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/renderer/LayerBuilder.cpp b/src/renderer/LayerBuilder.cpp index 62ae51ed3a..ee17a60608 100644 --- a/src/renderer/LayerBuilder.cpp +++ b/src/renderer/LayerBuilder.cpp @@ -493,9 +493,10 @@ class LayerBuilderContext { } } - // Untracks the color source reverse-index entry for Fill/Stroke elements, or the image reverse - // index for ImagePattern. Called before rebuilding content during refreshLayerInPlace so that - // surviving elements are re-tracked by convertXxx without accumulating duplicate entries. + // Untracks the color source reverse-index entry for Fill/Stroke elements. If the color source + // is an ImagePattern, also untracks its imageUsers entry. Called before rebuilding content during + // refreshLayerInPlace so that surviving elements are re-tracked by convertXxx without accumulating + // duplicate entries. void untrackElementColorSource(Element* element) { if (element == nullptr) { return; @@ -503,10 +504,23 @@ class LayerBuilderContext { auto type = element->nodeType(); if (type == NodeType::Fill) { auto* fill = static_cast(element); - _result.binding.untrackColorSource(fill->color, element); + untrackColorSourceAndImage(fill->color, element); } else if (type == NodeType::Stroke) { auto* stroke = static_cast(element); - _result.binding.untrackColorSource(stroke->color, element); + untrackColorSourceAndImage(stroke->color, element); + } + } + + void untrackColorSourceAndImage(const ColorSource* colorSource, const Node* owner) { + if (colorSource == nullptr) { + return; + } + _result.binding.untrackColorSource(colorSource, owner); + if (colorSource->nodeType() == NodeType::ImagePattern) { + auto* pattern = static_cast(colorSource); + if (pattern->image) { + _result.binding.untrackImage(pattern->image, pattern); + } } }