From 41685779feddfc31d210e62a5d741262120e78dc Mon Sep 17 00:00:00 2001 From: Ink Open Source Date: Fri, 26 Jun 2026 11:19:34 -0700 Subject: [PATCH] Implement functionality to spatially segment a stroke. Additionally, update `partialErase` to always return a single stroke (even when a stroke's mesh has been entirely erased). PiperOrigin-RevId: 938685070 --- ink/strokes/BUILD.bazel | 1 + ink/strokes/internal/jni/BUILD.bazel | 2 + ink/strokes/internal/jni/stroke_jni.cc | 30 +++++++++----- ink/strokes/internal/jni/stroke_native.cc | 32 ++++++++++----- ink/strokes/internal/jni/stroke_native.h | 21 ++++++---- ink/strokes/internal/stroke_segmentation.cc | 1 + .../internal/stroke_segmentation_test.cc | 9 +++++ ink/strokes/stroke.cc | 35 ++++++++++------- ink/strokes/stroke.h | 29 +++++++++----- ink/strokes/stroke_test.cc | 39 ++++++++++++------- 10 files changed, 133 insertions(+), 66 deletions(-) diff --git a/ink/strokes/BUILD.bazel b/ink/strokes/BUILD.bazel index f4d7e02f..5fba8150 100644 --- a/ink/strokes/BUILD.bazel +++ b/ink/strokes/BUILD.bazel @@ -33,6 +33,7 @@ cc_library( "//ink/geometry:partitioned_mesh", "//ink/strokes/input:stroke_input_batch", "//ink/strokes/internal:stroke_input_modeler", + "//ink/strokes/internal:stroke_segmentation", "//ink/strokes/internal:stroke_shape_builder", "//ink/strokes/internal:stroke_subtraction", "//ink/strokes/internal:stroke_vertex", diff --git a/ink/strokes/internal/jni/BUILD.bazel b/ink/strokes/internal/jni/BUILD.bazel index 34188636..24349966 100644 --- a/ink/strokes/internal/jni/BUILD.bazel +++ b/ink/strokes/internal/jni/BUILD.bazel @@ -63,6 +63,7 @@ cc_library( "//ink/strokes/input:stroke_input_batch", "@abseil-cpp//absl/log:absl_check", "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", ] + select({ "@platforms//os:android": [], "//conditions:default": [ @@ -79,6 +80,7 @@ cc_library( ":stroke_input_jni_helper", ":stroke_native", "//ink/jni/internal:jni_defines", + "//ink/jni/internal:status_jni_helper", ] + select({ "@platforms//os:android": [], "//conditions:default": [ diff --git a/ink/strokes/internal/jni/stroke_jni.cc b/ink/strokes/internal/jni/stroke_jni.cc index 45f4a21b..a5694776 100644 --- a/ink/strokes/internal/jni/stroke_jni.cc +++ b/ink/strokes/internal/jni/stroke_jni.cc @@ -15,10 +15,13 @@ #include #include "ink/jni/internal/jni_defines.h" +#include "ink/jni/internal/status_jni_helper.h" #include "ink/strokes/internal/jni/stroke_native.h" extern "C" { +using ::ink::jni::ThrowExceptionFromStatusCallback; + JNI_METHOD(strokes, StrokeNative, jlong, createWithBrushAndInputs) (JNIEnv* env, jobject object, jlong brush_native_pointer, jlong inputs_native_pointer) { @@ -60,15 +63,24 @@ JNI_METHOD(strokes, StrokeNative, void, free) StrokeNative_free(native_pointer_to_stroke); } -JNI_METHOD(strokes, MultipleStrokesNative, jlong, createWithPartialErase) -(JNIEnv* env, jobject object, jlong target_stroke_ptr, jlong eraser_shape_ptr, - jfloat eraser_a, jfloat eraser_b, jfloat eraser_c, jfloat eraser_d, - jfloat eraser_e, jfloat eraser_f, jfloat stroke_a, jfloat stroke_b, - jfloat stroke_c, jfloat stroke_d, jfloat stroke_e, jfloat stroke_f) { - return MultipleStrokesNative_createWithPartialErase( - target_stroke_ptr, eraser_shape_ptr, eraser_a, eraser_b, eraser_c, - eraser_d, eraser_e, eraser_f, stroke_a, stroke_b, stroke_c, stroke_d, - stroke_e, stroke_f); +JNI_METHOD(strokes, StrokeNative, jlong, createWithSubtract) +(JNIEnv* env, jobject object, jlong target_stroke_ptr, jlong mask_shape_ptr, + jfloat mask_a, jfloat mask_b, jfloat mask_c, jfloat mask_d, jfloat mask_e, + jfloat mask_f, jfloat stroke_a, jfloat stroke_b, jfloat stroke_c, + jfloat stroke_d, jfloat stroke_e, jfloat stroke_f) { + return StrokeNative_createWithSubtract( + target_stroke_ptr, mask_shape_ptr, mask_a, mask_b, mask_c, mask_d, mask_e, + mask_f, stroke_a, stroke_b, stroke_c, stroke_d, stroke_e, stroke_f); +} + +JNI_METHOD(strokes, MultipleStrokesNative, jlong, createWithSplit) +(JNIEnv* env, jobject object, jlong target_stroke_ptr, jfloat transform_a, + jfloat transform_b, jfloat transform_c, jfloat transform_d, jfloat transform_e, + jfloat transform_f, jfloat tolerance) { + return MultipleStrokesNative_createWithSplit( + env, target_stroke_ptr, transform_a, transform_b, transform_c, + transform_d, transform_e, transform_f, tolerance, + &ThrowExceptionFromStatusCallback); } JNI_METHOD(strokes, MultipleStrokesNative, jint, getStrokeCount) diff --git a/ink/strokes/internal/jni/stroke_native.cc b/ink/strokes/internal/jni/stroke_native.cc index b9dd335c..0f7b89d1 100644 --- a/ink/strokes/internal/jni/stroke_native.cc +++ b/ink/strokes/internal/jni/stroke_native.cc @@ -89,20 +89,32 @@ void StrokeNative_free(int64_t native_pointer) { DeleteNativeStroke(native_pointer); } -int64_t MultipleStrokesNative_createWithPartialErase( - int64_t target_stroke_ptr, int64_t eraser_shape_ptr, float eraser_a, - float eraser_b, float eraser_c, float eraser_d, float eraser_e, - float eraser_f, float stroke_a, float stroke_b, float stroke_c, - float stroke_d, float stroke_e, float stroke_f) { - AffineTransform eraser_transform(eraser_a, eraser_b, eraser_c, eraser_d, - eraser_e, eraser_f); +int64_t StrokeNative_createWithSubtract( + int64_t target_stroke_ptr, int64_t mask_shape_ptr, float mask_a, + float mask_b, float mask_c, float mask_d, float mask_e, float mask_f, + float stroke_a, float stroke_b, float stroke_c, float stroke_d, + float stroke_e, float stroke_f) { + AffineTransform mask_transform(mask_a, mask_b, mask_c, mask_d, mask_e, + mask_f); AffineTransform stroke_transform(stroke_a, stroke_b, stroke_c, stroke_d, stroke_e, stroke_f); + return NewNativeStroke(CastToStroke(target_stroke_ptr) + .Subtract(CastToPartitionedMesh(mask_shape_ptr), + mask_transform, stroke_transform)); +} + +int64_t MultipleStrokesNative_createWithSplit( + void* jni_env_pass_through, int64_t target_stroke_ptr, float transform_a, + float transform_b, float transform_c, float transform_d, float transform_e, + float transform_f, float tolerance, + void (*throw_from_status_callback)(void* jni_env, int status_code, + const char* status_str)) { + AffineTransform transform(transform_a, transform_b, transform_c, transform_d, + transform_e, transform_f); + std::vector fragments = - CastToStroke(target_stroke_ptr) - .PartialErase(CastToPartitionedMesh(eraser_shape_ptr), - eraser_transform, stroke_transform); + CastToStroke(target_stroke_ptr).Split(transform, tolerance); MultipleStrokes result; result.reserve(fragments.size()); diff --git a/ink/strokes/internal/jni/stroke_native.h b/ink/strokes/internal/jni/stroke_native.h index 1597140c..b0e74e51 100644 --- a/ink/strokes/internal/jni/stroke_native.h +++ b/ink/strokes/internal/jni/stroke_native.h @@ -44,14 +44,19 @@ int64_t StrokeNative_newShallowCopyOfShape(int64_t native_pointer_to_stroke); void StrokeNative_free(int64_t native_pointer_to_stroke); -// Returns a pointer to hold the result of a partial erase before hand-off -// of the individual strokes. Uses `unique_ptr` for the handoff to minimize -// copying. -int64_t MultipleStrokesNative_createWithPartialErase( - int64_t target_stroke_ptr, int64_t eraser_shape_ptr, float eraser_a, - float eraser_b, float eraser_c, float eraser_d, float eraser_e, - float eraser_f, float stroke_a, float stroke_b, float stroke_c, - float stroke_d, float stroke_e, float stroke_f); +// Returns a pointer to hold the result of a geometric subtraction. +int64_t StrokeNative_createWithSubtract( + int64_t target_stroke_ptr, int64_t mask_shape_ptr, float mask_a, + float mask_b, float mask_c, float mask_d, float mask_e, float mask_f, + float stroke_a, float stroke_b, float stroke_c, float stroke_d, + float stroke_e, float stroke_f); + +int64_t MultipleStrokesNative_createWithSplit( + void* jni_env_pass_through, int64_t target_stroke_ptr, float transform_a, + float transform_b, float transform_c, float transform_d, float transform_e, + float transform_f, float tolerance, + void (*throw_from_status_callback)(void* jni_env, int status_code, + const char* status_str)); int32_t MultipleStrokesNative_getStrokeCount(int64_t native_pointer); diff --git a/ink/strokes/internal/stroke_segmentation.cc b/ink/strokes/internal/stroke_segmentation.cc index 82ee1ba6..f9730b6f 100644 --- a/ink/strokes/internal/stroke_segmentation.cc +++ b/ink/strokes/internal/stroke_segmentation.cc @@ -272,6 +272,7 @@ absl::StatusOr> SegmentSpatially( // own component, then merge vertices that share a triangle, then merge // vertices that are within `tolerance` of each other. std::vector vertices = GetAllVertices(shape, transform); + if (vertices.empty()) return std::vector{}; ConnectedComponents components(vertices.size()); ConnectByTriangles(shape, components); ConnectBySpatialProximity(vertices, tolerance, components); diff --git a/ink/strokes/internal/stroke_segmentation_test.cc b/ink/strokes/internal/stroke_segmentation_test.cc index ed1e63f9..ec40c888 100644 --- a/ink/strokes/internal/stroke_segmentation_test.cc +++ b/ink/strokes/internal/stroke_segmentation_test.cc @@ -31,9 +31,18 @@ namespace ink::strokes_internal { namespace { using ::absl_testing::IsOk; +using ::absl_testing::IsOkAndHolds; using ::testing::ElementsAre; +using ::testing::IsEmpty; using ::testing::SizeIs; +TEST(StrokeSegmentationTest, SegmentSpatiallyEmptyMesh) { + PartitionedMesh empty_mesh; + EXPECT_THAT(SegmentSpatially(empty_mesh, AffineTransform::Identity(), + /*tolerance=*/1.0f), + IsOkAndHolds(IsEmpty())); +} + TEST(StrokeSegmentationTest, SegmentSpatially) { // C F // |\ |\ diff --git a/ink/strokes/stroke.cc b/ink/strokes/stroke.cc index 52283125..37a9c91c 100644 --- a/ink/strokes/stroke.cc +++ b/ink/strokes/stroke.cc @@ -34,6 +34,7 @@ #include "ink/geometry/partitioned_mesh.h" #include "ink/strokes/input/stroke_input_batch.h" #include "ink/strokes/internal/stroke_input_modeler.h" +#include "ink/strokes/internal/stroke_segmentation.h" #include "ink/strokes/internal/stroke_shape_builder.h" #include "ink/strokes/internal/stroke_subtraction.h" #include "ink/strokes/internal/stroke_vertex.h" @@ -208,22 +209,28 @@ void Stroke::RegenerateShape() { ABSL_DCHECK_EQ(shape_.RenderGroupCount(), brush_.CoatCount()); } -std::vector Stroke::PartialErase( - const PartitionedMesh& eraser_shape, - const AffineTransform& eraser_transform, - const AffineTransform& stroke_transform) const { +Stroke Stroke::Subtract(const PartitionedMesh& mask_shape, + const AffineTransform& mask_transform, + const AffineTransform& stroke_transform) const { absl::StatusOr remaining_mesh = strokes_internal::Subtract( - shape_, stroke_transform, eraser_shape, eraser_transform); - if (!remaining_mesh.ok()) return {*this}; - - // If the entire stroke is erased, return an empty list. - if (absl::c_none_of(remaining_mesh->Meshes(), [](const Mesh& mesh) { - return mesh.TriangleCount() > 0; - })) { - return {}; - } + shape_, stroke_transform, mask_shape, mask_transform); + if (!remaining_mesh.ok()) return *this; + + return Stroke(brush_, inputs_, *remaining_mesh); +} - return {Stroke(brush_, inputs_, *remaining_mesh)}; +std::vector Stroke::Split(const AffineTransform& stroke_transform, + float tolerance) const { + absl::StatusOr> partitioned_meshes = + strokes_internal::SegmentSpatially(shape_, stroke_transform, tolerance); + if (!partitioned_meshes.ok()) return {*this}; + + std::vector results; + results.reserve(partitioned_meshes->size()); + for (PartitionedMesh& mesh : *partitioned_meshes) { + results.emplace_back(brush_, inputs_, std::move(mesh)); + } + return results; } } // namespace ink diff --git a/ink/strokes/stroke.h b/ink/strokes/stroke.h index 82b2753b..58458cd1 100644 --- a/ink/strokes/stroke.h +++ b/ink/strokes/stroke.h @@ -15,6 +15,8 @@ #ifndef INK_STROKES_STROKE_H_ #define INK_STROKES_STROKE_H_ +#include + #include "absl/status/status.h" #include "ink/brush/brush.h" #include "ink/brush/brush_family.h" @@ -109,21 +111,28 @@ class Stroke { // shape if `inputs` is empty. void SetInputs(const StrokeInputBatch& inputs); - // Erases the `eraser_shape` from this stroke geometry using the given - // `eraser_transform` and `stroke_transform` that map the eraser and stroke + // Subtracts the `mask_shape` from this stroke geometry using the given + // `mask_transform` and `stroke_transform` that map the mask and stroke // to common coordinates. // // The coordinate transformations are expected to be non-degenerate; otherwise // the stroke is returned as is. + Stroke Subtract(const PartitionedMesh& mask_shape, + const AffineTransform& mask_transform, + const AffineTransform& stroke_transform) const; + + // Splits this stroke into a set of spatially disconnected `Stroke`s. + // + // Two regions of the stroke are considered disconnected if they are further + // than `tolerance` distance apart, after applying `stroke_transform`. + // + // Each resulting stroke has a new shape representing its portion, but retains + // the original inputs and brush. // - // Each resulting fragment retains the original brush and inputs, but has a - // newly computed shape representing the portion remaining after erasure. The - // order of the fragments in the returned vector is arbitrary and carries no - // guarantee. - std::vector PartialErase( - const PartitionedMesh& eraser_shape, - const AffineTransform& eraser_transform, - const AffineTransform& stroke_transform) const; + // The order of the fragments in the returned vector is arbitrary and carries + // no guarantee. + std::vector Split(const AffineTransform& stroke_transform, + float tolerance) const; private: // Regenerates the PartitionedMesh. diff --git a/ink/strokes/stroke_test.cc b/ink/strokes/stroke_test.cc index 07334386..6a411a06 100644 --- a/ink/strokes/stroke_test.cc +++ b/ink/strokes/stroke_test.cc @@ -607,32 +607,28 @@ void CanConstructStrokeFromAnyInputBatch(const Brush& brush, FUZZ_TEST(DISABLED_StrokeTest, CanConstructStrokeFromAnyInputBatch) .WithDomains(ValidBrush(), ArbitraryStrokeInputBatch()); -TEST(StrokeTest, PartialEraseWithEmptyEraserShapeReturnsStroke) { +TEST(StrokeTest, SubtractWithEmptyMaskShapeReturnsStroke) { Stroke stroke(CreateBrush(), CreateFilledInputs()); - PartitionedMesh empty_eraser_shape; + PartitionedMesh empty_mask_shape; AffineTransform identity = AffineTransform::Identity(); - std::vector result = - stroke.PartialErase(empty_eraser_shape, identity, identity); + Stroke result = stroke.Subtract(empty_mask_shape, identity, identity); - ASSERT_THAT(result, SizeIs(1)); - EXPECT_THAT(result[0].GetBrush(), BrushEq(stroke.GetBrush())); - EXPECT_THAT(result[0].GetInputs(), StrokeInputBatchEq(stroke.GetInputs())); + EXPECT_THAT(result.GetBrush(), BrushEq(stroke.GetBrush())); + EXPECT_THAT(result.GetInputs(), StrokeInputBatchEq(stroke.GetInputs())); } -TEST(StrokeTest, PartialEraseWithDegenerateTransformReturnsStroke) { +TEST(StrokeTest, SubtractWithDegenerateTransformReturnsStroke) { Stroke stroke(CreateBrush(), CreateFilledInputs()); - PartitionedMesh eraser_shape = CreateFilledShape(); + PartitionedMesh mask_shape = CreateFilledShape(); AffineTransform identity = AffineTransform::Identity(); AffineTransform degenerate = AffineTransform::Scale(1, 0); - std::vector result = - stroke.PartialErase(eraser_shape, identity, degenerate); + Stroke result = stroke.Subtract(mask_shape, identity, degenerate); - ASSERT_THAT(result, SizeIs(1)); - EXPECT_THAT(result[0].GetBrush(), BrushEq(stroke.GetBrush())); - EXPECT_THAT(result[0].GetInputs(), StrokeInputBatchEq(stroke.GetInputs())); - EXPECT_THAT(result[0].GetShape(), PartitionedMeshDeepEq(stroke.GetShape())); + EXPECT_THAT(result.GetBrush(), BrushEq(stroke.GetBrush())); + EXPECT_THAT(result.GetInputs(), StrokeInputBatchEq(stroke.GetInputs())); + EXPECT_THAT(result.GetShape(), PartitionedMeshDeepEq(stroke.GetShape())); } TEST(StrokeTest, ParticleBrushWithOneDimensionZero) { @@ -659,5 +655,18 @@ TEST(StrokeTest, ParticleBrushWithOneDimensionZero) { EXPECT_THAT(stroke.GetShape().Meshes(), SizeIs(1)); } +TEST(StrokeTest, Split) { + Stroke stroke(CreateBrush(), CreateFilledInputs()); + ASSERT_FALSE(stroke.GetShape().Meshes().empty()); + + std::vector segments = + stroke.Split(AffineTransform::Identity(), /*tolerance=*/1.0f); + EXPECT_THAT(segments, Not(IsEmpty())); + for (const auto& segment : segments) { + EXPECT_THAT(segment.GetBrush(), BrushEq(stroke.GetBrush())); + EXPECT_THAT(segment.GetInputs(), StrokeInputBatchEq(stroke.GetInputs())); + } +} + } // namespace } // namespace ink