diff --git a/include/pagx/PAGComposition.h b/include/pagx/PAGComposition.h index 0afaeec0ec..82c3256213 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,34 @@ 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. + // 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. 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 + // when a timeline node changes. The root composition has no owner layer and spawns no timelines. + void spawnTimelines(const std::shared_ptr& scene); + + // 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 // sealed external composition this is the layer's externalDoc; otherwise the scene's document. PAGXDocument* document = nullptr; 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/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/PAGTimeline.h b/include/pagx/PAGTimeline.h index e673b8983a..297828cbbb 100644 --- a/include/pagx/PAGTimeline.h +++ b/include/pagx/PAGTimeline.h @@ -136,28 +136,27 @@ 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 // 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 // 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 ec34e89885..0e91bae911 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). @@ -175,14 +176,40 @@ 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 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. + * + * Editing an external composition: call notifyChange on the document that owns the edited nodes. + * 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 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. 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); + + /** + * 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; @@ -215,6 +242,7 @@ class PAGXDocument : public Node { friend class PAGXExporter; friend class TextLayoutContext; friend class PAGScene; + friend class PAGComposition; }; } // namespace pagx diff --git a/include/pagx/PAGXNodeChannel.h b/include/pagx/PAGXNodeChannel.h new file mode 100644 index 0000000000..103596dd57 --- /dev/null +++ b/include/pagx/PAGXNodeChannel.h @@ -0,0 +1,119 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * 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 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 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). + */ + +/** + * 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, 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. 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. + * @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); + +/** + * 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. + */ +bool ResetNodeChannel(Node* node, const std::string& channel); + +/** + * 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. + */ +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 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/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/include/pagx/nodes/LayoutNode.h b/include/pagx/nodes/LayoutNode.h index 3df198f69d..d8ea654c40 100644 --- a/include/pagx/nodes/LayoutNode.h +++ b/include/pagx/nodes/LayoutNode.h @@ -104,6 +104,17 @@ class LayoutNode { /** Returns true if any constraint attribute is set. */ bool hasConstraints() const; + /** + * 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. + * + * 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(); + /** * 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/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/src/pagx/PAGScene.cpp b/src/pagx/PAGScene.cpp index 48b15a7398..3eea3b5e66 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() { @@ -105,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; } @@ -225,8 +238,51 @@ 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) { + // 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) { + // 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 + // than patched in place; a removed animation node then simply produces no timeline. Edits that do + // not touch a timeline node leave playback untouched. + 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(); + } } RuntimeBinding* PAGScene::mutableBinding() { 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/src/pagx/PAGXChannelTable.h b/src/pagx/PAGXChannelTable.h new file mode 100644 index 0000000000..67c5fe0b87 --- /dev/null +++ b/src/pagx/PAGXChannelTable.h @@ -0,0 +1,70 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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, 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. +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..fd9c691a71 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,23 +316,75 @@ 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. + // 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) { + 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); - // 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(ownedDirty); + } + } +} + +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/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/PAGXNodeChannel.cpp b/src/pagx/PAGXNodeChannel.cpp new file mode 100644 index 0000000000..1e9213a4f0 --- /dev/null +++ b/src/pagx/PAGXNodeChannel.cpp @@ -0,0 +1,995 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 +#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" +#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 { + +// 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 ChannelAccessor. A read copies the field into *getOut; a write validates the KeyValue +// 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; + } + 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 && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } + 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 && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } + 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 && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } + 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 && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } + 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 && setIn == nullptr) { + const auto& def = Default().*Field; + component = XAxis ? def.x : def.y; + return true; + } + 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 && setIn == nullptr) { + const auto& def = Default().*Field; + component = WidthAxis ? def.width : def.height; + return true; + } + 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 && 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; + } + 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 && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } + 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 && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } + 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 && setIn == nullptr) { + self->*Field = Default().*Field; + return true; + } + 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 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 } +#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, 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, 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, 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, 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, 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, Layout), + FIELD_POINT_Y(Path, "position.y", position, Layout), + FIELD_BOOL(Path, "reversed", reversed, NoFlags), + }; + AppendLayoutNodeFields(table); + return table; +} + +static std::vector BuildTextFields() { + std::vector table = { + FIELD_STRING(Text, "text", text, Layout), + 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), + 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() { + return { + 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() { + return { + 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() { + return { + 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() { + return { + FIELD_FLOAT(RoundCorner, "radius", radius, Anim), + }; +} + +static std::vector BuildMergePathFields() { + return { + FIELD_ENUM(MergePath, "mode", mode, NoFlags, MergePathMode), + }; +} + +static std::vector BuildTextModifierFields() { + return { + 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; +} + +// 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, 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 = {}; + AppendGroupCommonFields(table); + return table; +} + +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() { + return { + 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, 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() { + return { + FIELD_COLOR(SolidColor, "color", color, Anim), + }; +} + +static std::vector BuildLinearGradientFields() { + return { + 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() { + return { + 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() { + return { + 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() { + return { + 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() { + return { + FIELD_FLOAT(ColorStop, "offset", offset, Anim), + FIELD_COLOR(ColorStop, "color", color, Anim), + }; +} + +static std::vector BuildImagePatternFields() { + return { + 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() { + return { + 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() { + return { + 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() { + return { + 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() { + return { + FIELD_FLOAT(BlurFilter, "blurX", blurX, Anim), + FIELD_FLOAT(BlurFilter, "blurY", blurY, Anim), + FIELD_ENUM(BlurFilter, "tileMode", tileMode, NoFlags, TileMode), + }; +} + +static std::vector BuildDropShadowFilterFields() { + return { + 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() { + return { + 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() { + return { + FIELD_COLOR(BlendFilter, "color", color, Anim), + FIELD_ENUM(BlendFilter, "blendMode", blendMode, NoFlags, BlendMode), + }; +} + +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(); + 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; + } +} + +// 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) { + 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 *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) { + if (node == nullptr || out == nullptr) { + return false; + } + 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. + 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) { + if (node == nullptr) { + return false; + } + 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())); + return false; + } + 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); +} + +bool RequiresLayout(NodeType type, const std::string& channel) { + const auto* field = FindChannel(type, 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/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..b9dda407ff 100644 --- a/src/pagx/runtime/KeyframeEvaluator.h +++ b/src/pagx/runtime/KeyframeEvaluator.h @@ -19,10 +19,13 @@ #pragma once #include +#include #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" namespace pagx { @@ -65,6 +68,19 @@ inline ImageRef LerpKeyframeValue(const ImageRef& a, const ImageRef& / return a; } +template <> +inline Matrix LerpKeyframeValue(const Matrix& a, const Matrix& b, double t) { + // 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. // Defined as a named function template because the project forbids lambdas. template diff --git a/src/pagx/runtime/MatrixDecompose.h b/src/pagx/runtime/MatrixDecompose.h new file mode 100644 index 0000000000..a7632387b7 --- /dev/null +++ b/src/pagx/runtime/MatrixDecompose.h @@ -0,0 +1,118 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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]. 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; + 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; + return out; +} + +} // namespace pagx diff --git a/src/pagx/runtime/MixUtils.h b/src/pagx/runtime/MixUtils.h index f3b1b88509..fc76acc930 100644 --- a/src/pagx/runtime/MixUtils.h +++ b/src/pagx/runtime/MixUtils.h @@ -18,7 +18,10 @@ #pragma once +#include +#include "pagx/runtime/MatrixDecompose.h" #include "tgfx/core/Color.h" +#include "tgfx/core/Matrix.h" namespace pagx { @@ -37,4 +40,33 @@ 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 (see MatrixDecompose.h for the shared component math). +// +// 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]. 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 diff --git a/src/pagx/runtime/PAGComposition.cpp b/src/pagx/runtime/PAGComposition.cpp index a2a37150c5..b95edef71f 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" @@ -69,30 +71,58 @@ 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. - 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, @@ -121,6 +151,163 @@ void PAGComposition::buildChildren(const std::vector& layers, } } +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 + // 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, visited); + } + + // 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()); + } + } + // 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 && child->layerType() == LayerType::Layer) { + auto refreshed = binding->get(child->node); + if (refreshed != nullptr && refreshed != child->runtimeLayer) { + child->runtimeLayer = refreshed; + } + } + } + } + // 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) { + 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, + std::unordered_set& visited) { + 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) { + // 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; + } + 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. 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; + } + auto slot = binding->get(child->node); + if (slot != nullptr) { + slot->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 8850c6044f..ee17a60608 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" @@ -118,6 +119,77 @@ 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); + } + + 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) { + 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); @@ -161,6 +233,367 @@ 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); + // 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 + // 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()); + // 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); + // 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. 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. + // 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()) { + 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 = {}; + contents.reserve(node->contents.size()); + for (const auto& element : node->contents) { + auto tgfxElement = convertVectorElement(element); + if (tgfxElement) { + contents.push_back(tgfxElement); + } + } + vectorLayer->setContents(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 (!isElementInContents(element, node->contents)) { + removed.push_back(element); + } + } + if (!removed.empty()) { + unbindContentElements(removed); + } + } + 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 + // 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(); + } + // 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; + } + + // 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 + // 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. 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()) { + unbindSubtree(child.get()); + } + } + + // 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. 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) { + continue; + } + auto type = element->nodeType(); + if (type == NodeType::Group || type == NodeType::TextBox) { + unbindContentElements(static_cast(element)->elements); + } else if (type == NodeType::Fill) { + auto* fill = static_cast(element); + _result.binding.untrackColorSource(fill->color, element); + unbindColorSourceIfUnreferenced(fill->color, element); + } else if (type == NodeType::Stroke) { + auto* stroke = static_cast(element); + _result.binding.untrackColorSource(stroke->color, element); + unbindColorSourceIfUnreferenced(stroke->color, element); + } + _result.binding.remove(element); + } + } + + // 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. 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; + } + auto type = element->nodeType(); + if (type == NodeType::Fill) { + auto* fill = static_cast(element); + untrackColorSourceAndImage(fill->color, element); + } else if (type == NodeType::Stroke) { + auto* stroke = static_cast(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); + } + } + } + + // 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 + // 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; + } + if (_result.binding.isColorSourceShared(color, excludedOwner)) { + return; + } + if (color->nodeType() == NodeType::ImagePattern) { + auto* pattern = static_cast(color); + if (pattern->image) { + _result.binding.untrackImage(pattern->image, pattern); + } + unbindImageIfUnreferenced(pattern); + } else if (color->nodeType() != NodeType::SolidColor) { + for (const auto* stop : static_cast(color)->colorStops) { + _result.binding.remove(stop); + } + } + _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; + } + if (_result.binding.isImageShared(image, pattern)) { + 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. + 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(); @@ -208,11 +641,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 +700,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 +838,76 @@ 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 +915,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 +938,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 +969,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; } @@ -533,6 +1075,9 @@ class LayerBuilderContext { auto fill = tgfx::FillStyle::Make(colorSource); if (fill) { _result.binding.set(node, fill); + if (node->color) { + _result.binding.trackColorSource(node->color, node); + } fill->setAlpha(node->alpha); if (node->blendMode != BlendMode::Normal) { fill->setBlendMode(ToTGFX(node->blendMode)); @@ -543,6 +1088,9 @@ class LayerBuilderContext { if (node->placement != LayerPlacement::Background) { fill->setPlacement(ToTGFX(node->placement)); } + _result.binding.setWriter( + node, "alpha", + WriteMixedFloat); } return fill; } @@ -561,6 +1109,9 @@ class LayerBuilderContext { return nullptr; } _result.binding.set(node, stroke); + if (node->color) { + _result.binding.trackColorSource(node->color, node); + } stroke->setStrokeWidth(node->width); stroke->setAlpha(node->alpha); stroke->setLineCap(ToTGFX(node->cap)); @@ -580,6 +1131,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; } @@ -712,37 +1275,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) { @@ -774,6 +1400,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.trackImage(imageNode, node); + } pattern->setScaleMode(ToTGFX(node->scaleMode)); if (!node->matrix.isIdentity()) { pattern->setMatrix(ToTGFX(node->matrix)); @@ -791,6 +1420,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; } @@ -817,6 +1456,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; } @@ -839,9 +1482,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(); @@ -865,6 +1576,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()); @@ -883,6 +1629,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); } } @@ -938,6 +1704,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; } @@ -1705,4 +2505,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 ce13943458..bd5b3b54ef 100644 --- a/src/renderer/LayerBuilder.h +++ b/src/renderer/LayerBuilder.h @@ -18,10 +18,12 @@ #pragma once +#include #include #include #include #include +#include #include "pagx/PAGXDocument.h" #include "pagx/nodes/Channel.h" #include "tgfx/layers/Layer.h" @@ -32,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. */ @@ -48,6 +53,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 +64,27 @@ 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 { + // 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) { auto it = writers.find(channel); if (it == writers.end() || object == nullptr) { return false; @@ -72,7 +93,7 @@ struct RuntimeTarget { return true; } - private: + protected: std::shared_ptr object = nullptr; std::unordered_map writers = {}; }; @@ -83,15 +104,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 +120,110 @@ struct RuntimeBinding { if (it == targets.end()) { return nullptr; } - return it->second.getObject(); + 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; + } + + // Returns true if the node currently has a binding entry. + bool contains(const Node* node) const { + return targets.find(node) != targets.end(); + } + + // 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(element); + } + + // 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 trackImage(const Node* image, const Node* pattern) { + if (image == nullptr || pattern == nullptr) { + return; + } + imageUsers[image].push_back(pattern); + } + + // Untrack an element from its color source's reverse index. Called when a Fill/Stroke is + // about to be removed from the binding. + 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(), element), vec.end()); + if (vec.empty()) { + colorSourceUsers.erase(it); + } + } + } + + // Untrack an ImagePattern from its image's reverse index. Called when an ImagePattern is + // about to be removed from the binding. + void untrackImage(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 element other than excludedOwner references the given color source. O(1) + // via the reverse index maintained during build. + bool isColorSourceShared(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 isImageShared(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 { @@ -108,11 +231,62 @@ struct RuntimeBinding { if (it == targets.end()) { return false; } - return it->second.apply(channel, value, mix); + 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) { + if (node == nullptr || target == nullptr) { + return nullptr; + } + auto* raw = target.get(); + targets[node] = std::move(target); + 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: - 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 = {}; + + // 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 = {}; }; /** @@ -178,6 +352,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 2c5fb92458..bcddfdf131 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -32,9 +32,12 @@ #include "pagx/PAGScene.h" #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" +#include "pagx/PAGXNodeChannel.h" #include "pagx/PAGXOptimizer.h" #include "pagx/SVGExporter.h" #include "pagx/SVGImporter.h" @@ -43,11 +46,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" @@ -57,6 +63,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" @@ -65,14 +72,18 @@ #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" +#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" @@ -91,19 +102,24 @@ #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" #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" #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" #include "utils/Baseline.h" @@ -5836,13 +5852,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 } /** @@ -6739,6 +6753,303 @@ 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 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 = + "\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* childLayerNode = childDoc->findNode("childLayer"); + ASSERT_TRUE(childLayerNode != nullptr); + { + auto& slotTree = + *static_cast(file->rootComposition()->children[0].get())->binding; + 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. + childLayerNode->alpha = 0.4f; + childDoc->notifyChange({childLayerNode}, /*layoutChanged=*/false); + + auto& rebuiltTree = + *static_cast(file->rootComposition()->children[0].get())->binding; + 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: 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 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 = + "\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); + + // 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. */ @@ -6994,7 +7305,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}; @@ -7013,6 +7324,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()); @@ -8122,5 +8435,1493 @@ PAGX_TEST(PAGXTest, ExportNoiseFilterAnimation) { 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}, /*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()); + 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}, /*layoutChanged=*/true); + + // 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 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}, /*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). + 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: 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}, /*layoutChanged=*/true); + + // 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). + */ +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}, /*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()); + auto hitsAfter = scene->getLayersUnderPoint(10, 10); + ASSERT_FALSE(hitsAfter.empty()); + EXPECT_FLOAT_EQ(hitsAfter[0]->getBounds().width, 120); +} + +/** + * 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, NotifyChangeKeepsTimelineWhenNoTimelineNodeDirty) { + 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); + + // A plain layer edit is not a timeline node, so the timeline is left untouched (cache preserved). + doc->notifyChange({layer}, /*layoutChanged=*/true); + EXPECT_TRUE(timeline->resolved); + + // Playback 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: 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}, /*layoutChanged=*/true); + + // 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: 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}, /*layoutChanged=*/true); + 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 + * 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}, /*layoutChanged=*/true); + + 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. + */ +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}, /*layoutChanged=*/true); + + // 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}, /*layoutChanged=*/true); + + // 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}, /*layoutChanged=*/true); + + // 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}, /*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()); +} + +/** + * 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}, /*layoutChanged=*/true); + + // 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}, /*layoutChanged=*/true); + + // 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. + */ +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: 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: 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. + */ +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")); + 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 (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. + */ +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); +} + +/** + * 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. + */ +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 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. 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)) { + continue; + } + EXPECT_TRUE(binding->hasWriter(node, channel.channel)) + << "node type " << static_cast(node->nodeType()) << " channel '" << channel.channel + << "' is Animatable but has no runtime writer"; + } + } +} + +/** + * 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)); +} + +/** + * 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