[sksg] Hit-testing API

Introduce RenderNode::nodeAt(const SkPoint&) as the entry point for the hit-testing API.

This is backed by a onNodeAt() virtual, which gets dispatched throughout the render DAG,
and normally stops at the first leaf Draw node in encounters.

To support the implementation, introduce a GeometryNode::contains(const SkPoint&) API.

This is backed by a onContains() virtual, overridden in each concrete geometry class.

Expose nodeAt() on sksg::Scene, and add some basic unit tests.

Change-Id: I0c8abd9d1e51ecf2d8b4dd699f325cd636e21084
Reviewed-on: https://skia-review.googlesource.com/c/191296
Commit-Queue: Florin Malita <fmalita@chromium.org>
Reviewed-by: Mike Reed <reed@google.com>
diff --git a/modules/skottie/src/SkottieLayer.cpp b/modules/skottie/src/SkottieLayer.cpp
index 3cc4122..24d64c8 100644
--- a/modules/skottie/src/SkottieLayer.cpp
+++ b/modules/skottie/src/SkottieLayer.cpp
@@ -187,6 +187,8 @@
             return SkRect::MakeSize(fAnimation->size());
         }
 
+        const RenderNode* onNodeAt(const SkPoint&) const override { return nullptr; }
+
         void onRender(SkCanvas* canvas, const RenderContext* ctx) const override {
             const auto local_scope =
                 ScopedRenderContext(canvas, ctx).setIsolation(this->bounds(), true);
diff --git a/modules/sksg/include/SkSGClipEffect.h b/modules/sksg/include/SkSGClipEffect.h
index 52ba7c7..309127f 100644
--- a/modules/sksg/include/SkSGClipEffect.h
+++ b/modules/sksg/include/SkSGClipEffect.h
@@ -33,6 +33,7 @@
     ClipEffect(sk_sp<RenderNode>, sk_sp<GeometryNode>, bool aa);
 
     void onRender(SkCanvas*, const RenderContext*) const override;
+    const RenderNode* onNodeAt(const SkPoint&)     const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
 
diff --git a/modules/sksg/include/SkSGColorFilter.h b/modules/sksg/include/SkSGColorFilter.h
index a9e12e7..db1a800 100644
--- a/modules/sksg/include/SkSGColorFilter.h
+++ b/modules/sksg/include/SkSGColorFilter.h
@@ -27,6 +27,7 @@
     explicit ColorFilter(sk_sp<RenderNode>);
 
     void onRender(SkCanvas*, const RenderContext*) const final;
+    const RenderNode* onNodeAt(const SkPoint&)     const final;
 
     sk_sp<SkColorFilter> fColorFilter;
 
diff --git a/modules/sksg/include/SkSGDraw.h b/modules/sksg/include/SkSGDraw.h
index 018dd1a..570ba72 100644
--- a/modules/sksg/include/SkSGDraw.h
+++ b/modules/sksg/include/SkSGDraw.h
@@ -33,6 +33,7 @@
     ~Draw() override;
 
     void onRender(SkCanvas*, const RenderContext*) const override;
+    const RenderNode* onNodeAt(const SkPoint&)     const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
 
diff --git a/modules/sksg/include/SkSGEffectNode.h b/modules/sksg/include/SkSGEffectNode.h
index 9f0d940..1fe9468 100644
--- a/modules/sksg/include/SkSGEffectNode.h
+++ b/modules/sksg/include/SkSGEffectNode.h
@@ -24,6 +24,7 @@
     ~EffectNode() override;
 
     void onRender(SkCanvas*, const RenderContext*) const override;
+    const RenderNode* onNodeAt(const SkPoint&)     const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
 
diff --git a/modules/sksg/include/SkSGGeometryNode.h b/modules/sksg/include/SkSGGeometryNode.h
index 7ce3aa9..66be09d 100644
--- a/modules/sksg/include/SkSGGeometryNode.h
+++ b/modules/sksg/include/SkSGGeometryNode.h
@@ -27,6 +27,8 @@
     void clip(SkCanvas*, bool antiAlias) const;
     void draw(SkCanvas*, const SkPaint&) const;
 
+    bool contains(const SkPoint&) const;
+
     SkPath asPath() const;
 
 protected:
@@ -36,6 +38,8 @@
 
     virtual void onDraw(SkCanvas*, const SkPaint&) const = 0;
 
+    virtual bool onContains(const SkPoint&) const = 0;
+
     virtual SkPath onAsPath() const = 0;
 
 private:
diff --git a/modules/sksg/include/SkSGGeometryTransform.h b/modules/sksg/include/SkSGGeometryTransform.h
index bf297eb..f9907ed 100644
--- a/modules/sksg/include/SkSGGeometryTransform.h
+++ b/modules/sksg/include/SkSGGeometryTransform.h
@@ -37,6 +37,7 @@
 protected:
     void onClip(SkCanvas*, bool antiAlias) const override;
     void onDraw(SkCanvas*, const SkPaint&) const override;
+    bool onContains(const SkPoint&)        const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
     SkPath onAsPath() const override;
diff --git a/modules/sksg/include/SkSGGroup.h b/modules/sksg/include/SkSGGroup.h
index bb53555..ccb9cf8 100644
--- a/modules/sksg/include/SkSGGroup.h
+++ b/modules/sksg/include/SkSGGroup.h
@@ -39,6 +39,8 @@
     ~Group() override;
 
     void onRender(SkCanvas*, const RenderContext*) const override;
+    const RenderNode* onNodeAt(const SkPoint&)     const override;
+
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
 
 private:
diff --git a/modules/sksg/include/SkSGImage.h b/modules/sksg/include/SkSGImage.h
index b6aef47..f3e3151 100644
--- a/modules/sksg/include/SkSGImage.h
+++ b/modules/sksg/include/SkSGImage.h
@@ -34,6 +34,7 @@
     explicit Image(sk_sp<SkImage>);
 
     void onRender(SkCanvas*, const RenderContext*) const override;
+    const RenderNode* onNodeAt(const SkPoint&)     const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
 
diff --git a/modules/sksg/include/SkSGMaskEffect.h b/modules/sksg/include/SkSGMaskEffect.h
index f668777..9837d0e 100644
--- a/modules/sksg/include/SkSGMaskEffect.h
+++ b/modules/sksg/include/SkSGMaskEffect.h
@@ -36,6 +36,7 @@
     MaskEffect(sk_sp<RenderNode>, sk_sp<RenderNode> mask, Mode);
 
     void onRender(SkCanvas*, const RenderContext*) const override;
+    const RenderNode* onNodeAt(const SkPoint&)     const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
 
diff --git a/modules/sksg/include/SkSGMerge.h b/modules/sksg/include/SkSGMerge.h
index c4957f4..b37530a 100644
--- a/modules/sksg/include/SkSGMerge.h
+++ b/modules/sksg/include/SkSGMerge.h
@@ -50,6 +50,7 @@
 protected:
     void onClip(SkCanvas*, bool antiAlias) const override;
     void onDraw(SkCanvas*, const SkPaint&) const override;
+    bool onContains(const SkPoint&)        const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
     SkPath onAsPath() const override;
diff --git a/modules/sksg/include/SkSGOpacityEffect.h b/modules/sksg/include/SkSGOpacityEffect.h
index a982304..8e1105c 100644
--- a/modules/sksg/include/SkSGOpacityEffect.h
+++ b/modules/sksg/include/SkSGOpacityEffect.h
@@ -28,6 +28,7 @@
     OpacityEffect(sk_sp<RenderNode>, float);
 
     void onRender(SkCanvas*, const RenderContext*) const override;
+    const RenderNode* onNodeAt(const SkPoint&)     const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
 
diff --git a/modules/sksg/include/SkSGPath.h b/modules/sksg/include/SkSGPath.h
index 1a87188..55d16fa 100644
--- a/modules/sksg/include/SkSGPath.h
+++ b/modules/sksg/include/SkSGPath.h
@@ -31,6 +31,7 @@
 protected:
     void onClip(SkCanvas*, bool antiAlias) const override;
     void onDraw(SkCanvas*, const SkPaint&) const override;
+    bool onContains(const SkPoint&)        const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
     SkPath onAsPath() const override;
diff --git a/modules/sksg/include/SkSGPlane.h b/modules/sksg/include/SkSGPlane.h
index c0a2637..f8338a1 100644
--- a/modules/sksg/include/SkSGPlane.h
+++ b/modules/sksg/include/SkSGPlane.h
@@ -25,6 +25,7 @@
 protected:
     void onClip(SkCanvas*, bool antiAlias) const override;
     void onDraw(SkCanvas*, const SkPaint&) const override;
+    bool onContains(const SkPoint&)        const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
     SkPath onAsPath() const override;
diff --git a/modules/sksg/include/SkSGRect.h b/modules/sksg/include/SkSGRect.h
index 72133cf..a4666b4 100644
--- a/modules/sksg/include/SkSGRect.h
+++ b/modules/sksg/include/SkSGRect.h
@@ -38,6 +38,7 @@
 protected:
     void onClip(SkCanvas*, bool antiAlias) const override;
     void onDraw(SkCanvas*, const SkPaint&) const override;
+    bool onContains(const SkPoint&)        const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
     SkPath onAsPath() const override;
@@ -80,6 +81,7 @@
 protected:
     void onClip(SkCanvas*, bool antiAlias) const override;
     void onDraw(SkCanvas*, const SkPaint&) const override;
+    bool onContains(const SkPoint&)        const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
     SkPath onAsPath() const override;
diff --git a/modules/sksg/include/SkSGRenderEffect.h b/modules/sksg/include/SkSGRenderEffect.h
index 29b4d1c..08eb87d 100644
--- a/modules/sksg/include/SkSGRenderEffect.h
+++ b/modules/sksg/include/SkSGRenderEffect.h
@@ -64,6 +64,7 @@
 
 protected:
     void onRender(SkCanvas*, const RenderContext*) const override;
+    const RenderNode* onNodeAt(const SkPoint&)     const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
 
diff --git a/modules/sksg/include/SkSGRenderNode.h b/modules/sksg/include/SkSGRenderNode.h
index 13d86fb..6e44b5b 100644
--- a/modules/sksg/include/SkSGRenderNode.h
+++ b/modules/sksg/include/SkSGRenderNode.h
@@ -29,10 +29,15 @@
     // Render the node and its descendants to the canvas.
     void render(SkCanvas*, const RenderContext* = nullptr) const;
 
+    // Perform a front-to-back hit-test, and return the RenderNode located at |point|.
+    // Normally, hit-testing stops at leaf Draw nodes.
+    const RenderNode* nodeAt(const SkPoint& point) const;
+
 protected:
     explicit RenderNode(uint32_t inval_traits = 0);
 
     virtual void onRender(SkCanvas*, const RenderContext*) const = 0;
+    virtual const RenderNode* onNodeAt(const SkPoint& p)   const = 0;
 
     // Paint property overrides.
     // These are deferred until we can determine whether they can be applied to the individual
diff --git a/modules/sksg/include/SkSGRoundEffect.h b/modules/sksg/include/SkSGRoundEffect.h
index 67124ca..4787a8c 100644
--- a/modules/sksg/include/SkSGRoundEffect.h
+++ b/modules/sksg/include/SkSGRoundEffect.h
@@ -30,6 +30,7 @@
 protected:
     void onClip(SkCanvas*, bool antiAlias) const override;
     void onDraw(SkCanvas*, const SkPaint&) const override;
+    bool onContains(const SkPoint&)        const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
     SkPath onAsPath() const override;
diff --git a/modules/sksg/include/SkSGScene.h b/modules/sksg/include/SkSGScene.h
index 95bee30..9261cf4 100644
--- a/modules/sksg/include/SkSGScene.h
+++ b/modules/sksg/include/SkSGScene.h
@@ -15,6 +15,7 @@
 #include <vector>
 
 class SkCanvas;
+struct SkPoint;
 
 namespace sksg {
 
@@ -67,6 +68,7 @@
 
     void render(SkCanvas*) const;
     void animate(float t);
+    const RenderNode* nodeAt(const SkPoint&) const;
 
     void setShowInval(bool show) { fShowInval = show; }
 
diff --git a/modules/sksg/include/SkSGText.h b/modules/sksg/include/SkSGText.h
index 64ec1b2..e2cf788 100644
--- a/modules/sksg/include/SkSGText.h
+++ b/modules/sksg/include/SkSGText.h
@@ -45,6 +45,7 @@
 protected:
     void onClip(SkCanvas*, bool antiAlias) const override;
     void onDraw(SkCanvas*, const SkPaint&) const override;
+    bool onContains(const SkPoint&)        const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
     SkPath onAsPath() const override;
@@ -83,6 +84,7 @@
 protected:
     void onClip(SkCanvas*, bool antiAlias) const override;
     void onDraw(SkCanvas*, const SkPaint&) const override;
+    bool onContains(const SkPoint&)        const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
     SkPath onAsPath() const override;
diff --git a/modules/sksg/include/SkSGTransform.h b/modules/sksg/include/SkSGTransform.h
index 3153a7e..d8408da 100644
--- a/modules/sksg/include/SkSGTransform.h
+++ b/modules/sksg/include/SkSGTransform.h
@@ -97,6 +97,7 @@
 
 protected:
     void onRender(SkCanvas*, const RenderContext*) const override;
+    const RenderNode* onNodeAt(const SkPoint&)     const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
 
diff --git a/modules/sksg/include/SkSGTrimEffect.h b/modules/sksg/include/SkSGTrimEffect.h
index 18f1592..ea2fd253 100644
--- a/modules/sksg/include/SkSGTrimEffect.h
+++ b/modules/sksg/include/SkSGTrimEffect.h
@@ -36,6 +36,7 @@
 protected:
     void onClip(SkCanvas*, bool antiAlias) const override;
     void onDraw(SkCanvas*, const SkPaint&) const override;
+    bool onContains(const SkPoint&)        const override;
 
     SkRect onRevalidate(InvalidationController*, const SkMatrix&) override;
     SkPath onAsPath() const override;
diff --git a/modules/sksg/src/SkSGClipEffect.cpp b/modules/sksg/src/SkSGClipEffect.cpp
index 045605d..11a9be6 100644
--- a/modules/sksg/src/SkSGClipEffect.cpp
+++ b/modules/sksg/src/SkSGClipEffect.cpp
@@ -36,6 +36,10 @@
     this->INHERITED::onRender(canvas, ctx);
 }
 
+const RenderNode* ClipEffect::onNodeAt(const SkPoint& p) const {
+    return fClipNode->contains(p) ? this->INHERITED::onNodeAt(p) : nullptr;
+}
+
 SkRect ClipEffect::onRevalidate(InvalidationController* ic, const SkMatrix& ctm) {
     SkASSERT(this->hasInval());
 
diff --git a/modules/sksg/src/SkSGColorFilter.cpp b/modules/sksg/src/SkSGColorFilter.cpp
index 3ad2287..ae4a01a 100644
--- a/modules/sksg/src/SkSGColorFilter.cpp
+++ b/modules/sksg/src/SkSGColorFilter.cpp
@@ -24,6 +24,11 @@
     this->INHERITED::onRender(canvas, local_ctx);
 }
 
+const RenderNode* ColorFilter::onNodeAt(const SkPoint& p) const {
+    // TODO: we likely need to do something more sophisticated than delegate to descendants here.
+    return this->INHERITED::onNodeAt(p);
+}
+
 ColorModeFilter::ColorModeFilter(sk_sp<RenderNode> child, sk_sp<Color> color, SkBlendMode mode)
     : INHERITED(std::move(child))
     , fColor(std::move(color))
diff --git a/modules/sksg/src/SkSGDraw.cpp b/modules/sksg/src/SkSGDraw.cpp
index 24b358e..10def5b 100644
--- a/modules/sksg/src/SkSGDraw.cpp
+++ b/modules/sksg/src/SkSGDraw.cpp
@@ -7,6 +7,7 @@
 
 #include "SkSGDraw.h"
 
+#include "SkPath.h"
 #include "SkSGGeometryNode.h"
 #include "SkSGInvalidationController.h"
 #include "SkSGPaintNode.h"
@@ -40,6 +41,25 @@
     }
 }
 
+const RenderNode* Draw::onNodeAt(const SkPoint& p) const {
+    const auto paint = fPaint->makePaint();
+
+    if (!paint.getAlpha()) {
+        return nullptr;
+    }
+
+    if (paint.getStyle() == SkPaint::Style::kFill_Style && fGeometry->contains(p)) {
+        return this;
+    }
+
+    SkPath stroke_path;
+    if (!paint.getFillPath(fGeometry->asPath(), &stroke_path)) {
+        return nullptr;
+    }
+
+    return stroke_path.contains(p.x(), p.y()) ? this : nullptr;
+}
+
 SkRect Draw::onRevalidate(InvalidationController* ic, const SkMatrix& ctm) {
     SkASSERT(this->hasInval());
 
diff --git a/modules/sksg/src/SkSGEffectNode.cpp b/modules/sksg/src/SkSGEffectNode.cpp
index d3ce7a6..8ea2eea 100644
--- a/modules/sksg/src/SkSGEffectNode.cpp
+++ b/modules/sksg/src/SkSGEffectNode.cpp
@@ -23,6 +23,10 @@
     fChild->render(canvas, ctx);
 }
 
+const RenderNode* EffectNode::onNodeAt(const SkPoint& p) const {
+    return fChild->nodeAt(p);
+}
+
 SkRect EffectNode::onRevalidate(InvalidationController* ic, const SkMatrix& ctm) {
     SkASSERT(this->hasInval());
 
diff --git a/modules/sksg/src/SkSGGeometryNode.cpp b/modules/sksg/src/SkSGGeometryNode.cpp
index 6b78c48..78ef6e6 100644
--- a/modules/sksg/src/SkSGGeometryNode.cpp
+++ b/modules/sksg/src/SkSGGeometryNode.cpp
@@ -24,6 +24,11 @@
     this->onDraw(canvas, paint);
 }
 
+bool GeometryNode::contains(const SkPoint& p) const {
+    SkASSERT(!this->hasInval());
+    return this->bounds().contains(p.x(), p.y()) ? this->onContains(p) : false;
+}
+
 SkPath GeometryNode::asPath() const {
     SkASSERT(!this->hasInval());
     return this->onAsPath();
diff --git a/modules/sksg/src/SkSGGeometryTransform.cpp b/modules/sksg/src/SkSGGeometryTransform.cpp
index 6e71dc6..224d46b 100644
--- a/modules/sksg/src/SkSGGeometryTransform.cpp
+++ b/modules/sksg/src/SkSGGeometryTransform.cpp
@@ -33,6 +33,10 @@
     canvas->drawPath(fTransformedPath, paint);
 }
 
+bool GeometryTransform::onContains(const SkPoint& p) const {
+    return fTransformedPath.contains(p.x(), p.y());
+}
+
 SkRect GeometryTransform::onRevalidate(InvalidationController* ic, const SkMatrix& ctm) {
     SkASSERT(this->hasInval());
 
diff --git a/modules/sksg/src/SkSGGroup.cpp b/modules/sksg/src/SkSGGroup.cpp
index fff7436..cc44b31 100644
--- a/modules/sksg/src/SkSGGroup.cpp
+++ b/modules/sksg/src/SkSGGroup.cpp
@@ -66,6 +66,16 @@
     }
 }
 
+const RenderNode* Group::onNodeAt(const SkPoint& p) const {
+    for (const auto& child : fChildren) {
+        if (const auto* node = child->nodeAt(p)) {
+            return node;
+        }
+    }
+
+    return nullptr;
+}
+
 SkRect Group::onRevalidate(InvalidationController* ic, const SkMatrix& ctm) {
     SkASSERT(this->hasInval());
 
diff --git a/modules/sksg/src/SkSGImage.cpp b/modules/sksg/src/SkSGImage.cpp
index 59da5d7..64bfd33 100644
--- a/modules/sksg/src/SkSGImage.cpp
+++ b/modules/sksg/src/SkSGImage.cpp
@@ -30,6 +30,11 @@
     canvas->drawImage(fImage, 0, 0, &paint);
 }
 
+const RenderNode* Image::onNodeAt(const SkPoint& p) const {
+    SkASSERT(this->bounds().contains(p.x(), p.y()));
+    return this;
+}
+
 SkRect Image::onRevalidate(InvalidationController*, const SkMatrix& ctm) {
     return fImage ? SkRect::Make(fImage->bounds()) : SkRect::MakeEmpty();
 }
diff --git a/modules/sksg/src/SkSGMaskEffect.cpp b/modules/sksg/src/SkSGMaskEffect.cpp
index ff869fd..2086d3f 100644
--- a/modules/sksg/src/SkSGMaskEffect.cpp
+++ b/modules/sksg/src/SkSGMaskEffect.cpp
@@ -32,7 +32,6 @@
     // Note: the paint overrides in ctx don't apply to the mask.
     fMaskNode->render(canvas);
 
-
     SkPaint p;
     p.setBlendMode(fMaskMode == Mode::kNormal ? SkBlendMode::kSrcIn : SkBlendMode::kSrcOut);
     canvas->saveLayer(this->bounds(), &p);
@@ -40,6 +39,11 @@
     this->INHERITED::onRender(canvas, ctx);
 }
 
+const RenderNode* MaskEffect::onNodeAt(const SkPoint& p) const {
+    const auto mask_hit = (!!fMaskNode->nodeAt(p) == (fMaskMode == Mode::kNormal));
+
+    return mask_hit ? this->INHERITED::onNodeAt(p) : nullptr;
+}
 
 SkRect MaskEffect::onRevalidate(InvalidationController* ic, const SkMatrix& ctm) {
     SkASSERT(this->hasInval());
diff --git a/modules/sksg/src/SkSGMerge.cpp b/modules/sksg/src/SkSGMerge.cpp
index 48e7b9b..a882647 100644
--- a/modules/sksg/src/SkSGMerge.cpp
+++ b/modules/sksg/src/SkSGMerge.cpp
@@ -33,6 +33,10 @@
     canvas->drawPath(fMerged, paint);
 }
 
+bool Merge::onContains(const SkPoint& p) const {
+    return fMerged.contains(p.x(), p.y());
+}
+
 SkPath Merge::onAsPath() const {
     return fMerged;
 }
diff --git a/modules/sksg/src/SkSGOpacityEffect.cpp b/modules/sksg/src/SkSGOpacityEffect.cpp
index cc72c14..d6abfa2 100644
--- a/modules/sksg/src/SkSGOpacityEffect.cpp
+++ b/modules/sksg/src/SkSGOpacityEffect.cpp
@@ -23,6 +23,10 @@
     this->INHERITED::onRender(canvas, local_context);
 }
 
+const RenderNode* OpacityEffect::onNodeAt(const SkPoint& p) const {
+    return (fOpacity > 0) ? this->INHERITED::onNodeAt(p) : nullptr;
+}
+
 SkRect OpacityEffect::onRevalidate(InvalidationController* ic, const SkMatrix& ctm) {
     SkASSERT(this->hasInval());
 
diff --git a/modules/sksg/src/SkSGPath.cpp b/modules/sksg/src/SkSGPath.cpp
index 230442d..3b63182 100644
--- a/modules/sksg/src/SkSGPath.cpp
+++ b/modules/sksg/src/SkSGPath.cpp
@@ -23,6 +23,10 @@
     canvas->drawPath(fPath, paint);
 }
 
+bool Path::onContains(const SkPoint& p) const {
+    return fPath.contains(p.x(), p.y());
+}
+
 SkRect Path::onRevalidate(InvalidationController*, const SkMatrix&) {
     SkASSERT(this->hasInval());
 
diff --git a/modules/sksg/src/SkSGPlane.cpp b/modules/sksg/src/SkSGPlane.cpp
index 806fcc7..989f40c 100644
--- a/modules/sksg/src/SkSGPlane.cpp
+++ b/modules/sksg/src/SkSGPlane.cpp
@@ -20,6 +20,8 @@
     canvas->drawPaint(paint);
 }
 
+bool Plane::onContains(const SkPoint&) const { return true; }
+
 SkRect Plane::onRevalidate(InvalidationController*, const SkMatrix&) {
     SkASSERT(this->hasInval());
 
diff --git a/modules/sksg/src/SkSGRect.cpp b/modules/sksg/src/SkSGRect.cpp
index 64739d9..091751b 100644
--- a/modules/sksg/src/SkSGRect.cpp
+++ b/modules/sksg/src/SkSGRect.cpp
@@ -23,6 +23,10 @@
     canvas->drawRect(fRect, paint);
 }
 
+bool Rect::onContains(const SkPoint& p) const {
+    return fRect.contains(p.x(), p.y());
+}
+
 SkRect Rect::onRevalidate(InvalidationController*, const SkMatrix&) {
     SkASSERT(this->hasInval());
 
@@ -45,6 +49,22 @@
     canvas->drawRRect(fRRect, paint);
 }
 
+bool RRect::onContains(const SkPoint& p) const {
+    if (!fRRect.rect().contains(p.x(), p.y())) {
+        return false;
+    }
+
+    if (fRRect.isRect()) {
+        return true;
+    }
+
+    // TODO: no SkRRect::contains(x, y)
+    return fRRect.contains(SkRect::MakeLTRB(p.x() - SK_ScalarNearlyZero,
+                                            p.y() - SK_ScalarNearlyZero,
+                                            p.x() + SK_ScalarNearlyZero,
+                                            p.y() + SK_ScalarNearlyZero));
+}
+
 SkRect RRect::onRevalidate(InvalidationController*, const SkMatrix&) {
     SkASSERT(this->hasInval());
 
diff --git a/modules/sksg/src/SkSGRenderEffect.cpp b/modules/sksg/src/SkSGRenderEffect.cpp
index fdde23a..dcfdbe4 100644
--- a/modules/sksg/src/SkSGRenderEffect.cpp
+++ b/modules/sksg/src/SkSGRenderEffect.cpp
@@ -39,6 +39,13 @@
     return filter->computeFastBounds(this->INHERITED::onRevalidate(ic, ctm));
 }
 
+const RenderNode* ImageFilterEffect::onNodeAt(const SkPoint& p) const {
+    // TODO: map p through the filter DAG and dispatch to descendants?
+    // For now, image filters occlude hit-testing.
+    SkASSERT(this->bounds().contains(p.x(), p.y()));
+    return this;
+}
+
 void ImageFilterEffect::onRender(SkCanvas* canvas, const RenderContext* ctx) const {
     // TODO: hoist these checks to RenderNode?
     if (this->bounds().isEmpty())
diff --git a/modules/sksg/src/SkSGRenderNode.cpp b/modules/sksg/src/SkSGRenderNode.cpp
index e62ce00..fab3105 100644
--- a/modules/sksg/src/SkSGRenderNode.cpp
+++ b/modules/sksg/src/SkSGRenderNode.cpp
@@ -20,6 +20,10 @@
     this->onRender(canvas, ctx);
 }
 
+const RenderNode* RenderNode::nodeAt(const SkPoint& p) const {
+    return this->bounds().contains(p.x(), p.y()) ? this->onNodeAt(p) : nullptr;
+}
+
 bool RenderNode::RenderContext::modulatePaint(SkPaint* paint) const {
     const auto initial_alpha = paint->getAlpha(),
                        alpha = SkToU8(sk_float_round2int(initial_alpha * fOpacity));
diff --git a/modules/sksg/src/SkSGRoundEffect.cpp b/modules/sksg/src/SkSGRoundEffect.cpp
index e47c361..14b8e1d 100644
--- a/modules/sksg/src/SkSGRoundEffect.cpp
+++ b/modules/sksg/src/SkSGRoundEffect.cpp
@@ -32,6 +32,10 @@
     canvas->drawPath(fRoundedPath, paint);
 }
 
+bool RoundEffect::onContains(const SkPoint& p) const {
+    return fRoundedPath.contains(p.x(), p.y());
+}
+
 SkPath RoundEffect::onAsPath() const {
     return fRoundedPath;
 }
diff --git a/modules/sksg/src/SkSGScene.cpp b/modules/sksg/src/SkSGScene.cpp
index e916540..7d27bb2 100644
--- a/modules/sksg/src/SkSGScene.cpp
+++ b/modules/sksg/src/SkSGScene.cpp
@@ -69,4 +69,8 @@
     }
 }
 
+const RenderNode* Scene::nodeAt(const SkPoint& p) const {
+    return fRoot->nodeAt(p);
+}
+
 } // namespace sksg
diff --git a/modules/sksg/src/SkSGText.cpp b/modules/sksg/src/SkSGText.cpp
index b70b192..9aa7211 100644
--- a/modules/sksg/src/SkSGText.cpp
+++ b/modules/sksg/src/SkSGText.cpp
@@ -73,6 +73,10 @@
     canvas->drawTextBlob(fBlob, aligned_pos.x(), aligned_pos.y(), paint);
 }
 
+bool Text::onContains(const SkPoint& p) const {
+    return this->asPath().contains(p.x(), p.y());
+}
+
 SkPath Text::onAsPath() const {
     // TODO
     return SkPath();
@@ -99,6 +103,10 @@
     canvas->drawTextBlob(fBlob, fPosition.x(), fPosition.y(), paint);
 }
 
+bool TextBlob::onContains(const SkPoint& p) const {
+    return this->asPath().contains(p.x(), p.y());
+}
+
 SkPath TextBlob::onAsPath() const {
     // TODO
     return SkPath();
diff --git a/modules/sksg/src/SkSGTransform.cpp b/modules/sksg/src/SkSGTransform.cpp
index 6834fc9..fff1ba4 100644
--- a/modules/sksg/src/SkSGTransform.cpp
+++ b/modules/sksg/src/SkSGTransform.cpp
@@ -96,6 +96,15 @@
     this->INHERITED::onRender(canvas, ctx);
 }
 
+const RenderNode* TransformEffect::onNodeAt(const SkPoint& p) const {
+    const auto m = TransformPriv::As<SkMatrix>(fTransform);
+
+    SkPoint mapped_p;
+    m.mapPoints(&mapped_p, &p, 1);
+
+    return this->INHERITED::onNodeAt(mapped_p);
+}
+
 SkRect TransformEffect::onRevalidate(InvalidationController* ic, const SkMatrix& ctm) {
     SkASSERT(this->hasInval());
 
diff --git a/modules/sksg/src/SkSGTrimEffect.cpp b/modules/sksg/src/SkSGTrimEffect.cpp
index a2ec3c6..53f8f28 100644
--- a/modules/sksg/src/SkSGTrimEffect.cpp
+++ b/modules/sksg/src/SkSGTrimEffect.cpp
@@ -32,6 +32,10 @@
     canvas->drawPath(fTrimmedPath, paint);
 }
 
+bool TrimEffect::onContains(const SkPoint& p) const {
+    return fTrimmedPath.contains(p.x(), p.y());
+}
+
 SkPath TrimEffect::onAsPath() const {
     return fTrimmedPath;
 }
diff --git a/modules/sksg/tests/SGTest.cpp b/modules/sksg/tests/SGTest.cpp
index 873cd9a..d3509ce 100644
--- a/modules/sksg/tests/SGTest.cpp
+++ b/modules/sksg/tests/SGTest.cpp
@@ -56,6 +56,23 @@
     }
 }
 
+struct HitTest {
+    const SkPoint           pt;
+    sk_sp<sksg::RenderNode> node;
+};
+
+static void check_hittest(skiatest::Reporter* reporter, const sk_sp<sksg::RenderNode>& root,
+                          const std::vector<HitTest>& tests) {
+    for (const auto& tst : tests) {
+        const auto* node = root->nodeAt(tst.pt);
+        if (node != tst.node.get()) {
+            SkDebugf("*** nodeAt(%f, %f) - expected %p, got %p\n",
+                     tst.pt.x(), tst.pt.y(), tst.node.get(), node);
+        }
+        REPORTER_ASSERT(reporter, tst.node.get() == node);
+    }
+}
+
 static void inval_test1(skiatest::Reporter* reporter) {
     auto color  = sksg::Color::Make(0xff000000);
     auto r1     = sksg::Rect::Make(SkRect::MakeWH(100, 100)),
@@ -63,9 +80,11 @@
     auto grp    = sksg::Group::Make();
     auto matrix = sksg::Matrix<SkMatrix>::Make(SkMatrix::I());
     auto root   = sksg::TransformEffect::Make(grp, matrix);
+    auto d1     = sksg::Draw::Make(r1, color),
+         d2     = sksg::Draw::Make(r2, color);
 
-    grp->addChild(sksg::Draw::Make(r1, color));
-    grp->addChild(sksg::Draw::Make(r2, color));
+    grp->addChild(d1);
+    grp->addChild(d2);
 
     {
         // Initial revalidation.
@@ -73,6 +92,15 @@
                     SkRect::MakeWH(100, 100),
                     SkRectPriv::MakeLargeS32(),
                     nullptr);
+
+        check_hittest(reporter, root, {
+                          {{  -1,   0 }, nullptr },
+                          {{   0,  -1 }, nullptr },
+                          {{ 100,   0 }, nullptr },
+                          {{   0, 100 }, nullptr },
+                          {{   0,   0 },      d1 },
+                          {{  99,  99 },      d1 },
+                      });
     }
 
     {
@@ -83,6 +111,22 @@
                     SkRect::MakeWH(300, 200),
                     SkRect::MakeWH(300, 200),
                     &damage);
+
+        check_hittest(reporter, root, {
+                          {{  -1,   0 }, nullptr },
+                          {{   0,  -1 }, nullptr },
+                          {{ 100,   0 }, nullptr },
+                          {{   0, 100 }, nullptr },
+                          {{   0,   0 },      d1 },
+                          {{  99,  99 },      d1 },
+
+                          {{ 199, 100 }, nullptr },
+                          {{ 200,  99 }, nullptr },
+                          {{ 300, 100 }, nullptr },
+                          {{ 200, 200 }, nullptr },
+                          {{ 200, 100 },      d2 },
+                          {{ 299, 199 },      d2 },
+                      });
     }
 
     {
@@ -103,6 +147,22 @@
                     SkRect::MakeWH(300, 200),
                     SkRect::MakeWH(100, 100),
                     &damage);
+
+        check_hittest(reporter, root, {
+                          {{  -1,   0 }, nullptr },
+                          {{   0,  -1 }, nullptr },
+                          {{  50,   0 }, nullptr },
+                          {{   0, 100 }, nullptr },
+                          {{   0,   0 },      d1 },
+                          {{  49,  99 },      d1 },
+
+                          {{ 199, 100 }, nullptr },
+                          {{ 200,  99 }, nullptr },
+                          {{ 300, 100 }, nullptr },
+                          {{ 200, 200 }, nullptr },
+                          {{ 200, 100 },      d2 },
+                          {{ 299, 199 },      d2 },
+                      });
     }
 
     {
@@ -113,6 +173,22 @@
                     SkRect::MakeWH(600, 400),
                     SkRect::MakeWH(600, 400),
                     &damage);
+
+        check_hittest(reporter, root, {
+                          {{  -1,   0 }, nullptr },
+                          {{   0,  -1 }, nullptr },
+                          {{  25,   0 }, nullptr },
+                          {{   0,  50 }, nullptr },
+                          {{   0,   0 },      d1 },
+                          {{  24,  49 },      d1 },
+
+                          {{  99,  50 }, nullptr },
+                          {{ 100,  49 }, nullptr },
+                          {{ 150,  50 }, nullptr },
+                          {{ 100, 100 }, nullptr },
+                          {{ 100,  50 },      d2 },
+                          {{ 149,  99 },      d2 },
+                      });
     }
 
     {
@@ -123,6 +199,22 @@
                     SkRect::MakeWH(500, 400),
                     SkRect::MakeLTRB(400, 200, 600, 400),
                     &damage);
+
+        check_hittest(reporter, root, {
+                          {{  -1,   0 }, nullptr },
+                          {{   0,  -1 }, nullptr },
+                          {{  25,   0 }, nullptr },
+                          {{   0,  50 }, nullptr },
+                          {{   0,   0 },      d1 },
+                          {{  24,  49 },      d1 },
+
+                          {{  99,  50 }, nullptr },
+                          {{ 100,  49 }, nullptr },
+                          {{ 125,  50 }, nullptr },
+                          {{ 100, 100 }, nullptr },
+                          {{ 100,  50 },      d2 },
+                          {{ 124,  99 },      d2 },
+                      });
     }
 }
 
diff --git a/tools/viewer/SlideDir.cpp b/tools/viewer/SlideDir.cpp
index beb2ec2..a6b3b14 100644
--- a/tools/viewer/SlideDir.cpp
+++ b/tools/viewer/SlideDir.cpp
@@ -80,6 +80,8 @@
         fSlide->draw(canvas);
     }
 
+    const RenderNode* onNodeAt(const SkPoint&) const override { return nullptr; }
+
 private:
     void tick(SkMSec t) {
         fSlide->animate(SkAnimTimer(t * 1e6));