Skip to content

Commit 52aac2d

Browse files
committed
Add ClickTargetType::CompoundPath variant for fill-rule-aware compound shape hit testing
1 parent 325e9af commit 52aac2d

6 files changed

Lines changed: 69 additions & 0 deletions

File tree

editor/src/messages/portfolio/document/document_message_handler.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1636,6 +1636,11 @@ impl DocumentMessageHandler {
16361636
subpath.apply_transform(layer_transform);
16371637
subpath.is_inside_subpath(&viewport_polygon, None, None)
16381638
}
1639+
ClickTargetType::CompoundPath(subpaths) => subpaths.iter().all(|subpath| {
1640+
let mut subpath = subpath.clone();
1641+
subpath.apply_transform(layer_transform);
1642+
subpath.is_inside_subpath(&viewport_polygon, None, None)
1643+
}),
16391644
ClickTargetType::FreePoint(point) => {
16401645
let mut point = *point;
16411646
point.apply_transform(layer_transform);

editor/src/messages/portfolio/document/overlays/utility_types_native.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,6 +1035,7 @@ impl OverlayContextInternal {
10351035
self.manipulator_anchor(transform.transform_point2(point.position), false, None);
10361036
}
10371037
ClickTargetType::Subpath(subpath) => subpaths.push(subpath.clone()),
1038+
ClickTargetType::CompoundPath(compound) => subpaths.extend(compound.iter().cloned()),
10381039
}
10391040
}
10401041

editor/src/messages/portfolio/document/overlays/utility_types_web.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,7 @@ impl OverlayContext {
986986
self.manipulator_anchor(transform.transform_point2(point.position), false, None);
987987
}
988988
ClickTargetType::Subpath(subpath) => subpaths.push(subpath.clone()),
989+
ClickTargetType::CompoundPath(compound) => subpaths.extend(compound.iter().cloned()),
989990
});
990991

991992
if !subpaths.is_empty() {

editor/src/messages/portfolio/document/utility_types/document_metadata.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,10 @@ impl DocumentMetadata {
177177
.iter()
178178
.filter_map(|click_target| match click_target.target_type() {
179179
ClickTargetType::Subpath(subpath) => subpath.loose_bounding_box_with_transform(transform),
180+
ClickTargetType::CompoundPath(subpaths) => subpaths
181+
.iter()
182+
.filter_map(|subpath| subpath.loose_bounding_box_with_transform(transform))
183+
.reduce(|[a_min, a_max], [b_min, b_max]| [a_min.min(b_min), a_max.max(b_max)]),
180184
ClickTargetType::FreePoint(_) => click_target.bounding_box_with_transform(transform),
181185
})
182186
.reduce(Quad::combine_bounds)

node-graph/libraries/vector-types/src/vector/click_target.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ impl FreePoint {
3535
pub enum ClickTargetType {
3636
Subpath(Subpath<PointId>),
3737
FreePoint(FreePoint),
38+
/// Multiple subpaths tested as one compound shape using the non-zero fill rule, so holes
39+
/// (e.g. the inside of an "O") correctly count as outside the fill.
40+
CompoundPath(Vec<Subpath<PointId>>),
3841
}
3942

4043
/// Fixed-size ring buffer cache for rotated bounding boxes.
@@ -144,6 +147,19 @@ impl ClickTarget {
144147
}
145148
}
146149

150+
pub fn new_with_compound_path(subpaths: Vec<Subpath<PointId>>, stroke_width: f64) -> Self {
151+
let bounding_box = subpaths
152+
.iter()
153+
.filter_map(|subpath| subpath.loose_bounding_box())
154+
.reduce(|[a_min, a_max], [b_min, b_max]| [a_min.min(b_min), a_max.max(b_max)]);
155+
Self {
156+
target_type: ClickTargetType::CompoundPath(subpaths),
157+
stroke_width,
158+
bounding_box,
159+
bounding_box_cache: Default::default(),
160+
}
161+
}
162+
147163
pub fn new_with_free_point(point: FreePoint) -> Self {
148164
const MAX_LENGTH_FOR_NO_WIDTH_OR_HEIGHT: f64 = 1e-4 / 2.;
149165
let stroke_width = 10.;
@@ -199,6 +215,10 @@ impl ClickTarget {
199215
let mut write_lock = self.bounding_box_cache.write().unwrap();
200216
write_lock.add_to_cache(subpath, rotation, scale, translation, fingerprint)
201217
}
218+
ClickTargetType::CompoundPath(ref subpaths) => subpaths
219+
.iter()
220+
.filter_map(|subpath| subpath.bounding_box_with_transform(transform))
221+
.reduce(|[a_min, a_max], [b_min, b_max]| [a_min.min(b_min), a_max.max(b_max)]),
202222
// TODO: use point for calculation of bbox
203223
ClickTargetType::FreePoint(_) => self.bounding_box.map(|[a, b]| [transform.transform_point2(a), transform.transform_point2(b)]),
204224
}
@@ -209,6 +229,11 @@ impl ClickTarget {
209229
ClickTargetType::Subpath(ref mut subpath) => {
210230
subpath.apply_transform(affine_transform);
211231
}
232+
ClickTargetType::CompoundPath(ref mut subpaths) => {
233+
for subpath in subpaths {
234+
subpath.apply_transform(affine_transform);
235+
}
236+
}
212237
ClickTargetType::FreePoint(ref mut point) => {
213238
point.apply_transform(affine_transform);
214239
}
@@ -221,6 +246,12 @@ impl ClickTarget {
221246
ClickTargetType::Subpath(ref subpath) => {
222247
self.bounding_box = subpath.bounding_box();
223248
}
249+
ClickTargetType::CompoundPath(ref subpaths) => {
250+
self.bounding_box = subpaths
251+
.iter()
252+
.filter_map(|subpath| subpath.bounding_box())
253+
.reduce(|[a_min, a_max], [b_min, b_max]| [a_min.min(b_min), a_max.max(b_max)]);
254+
}
224255
ClickTargetType::FreePoint(ref point) => {
225256
self.bounding_box = Some([point.position - DVec2::splat(self.stroke_width / 2.), point.position + DVec2::splat(self.stroke_width / 2.)]);
226257
}
@@ -256,6 +287,24 @@ impl ClickTarget {
256287
// Check if shape is entirely within selection
257288
bezpath_is_inside_bezpath(&subpath.to_bezpath(), &selection, None, None)
258289
}
290+
ClickTargetType::CompoundPath(subpaths) => {
291+
// Outline intersection (catches strokes and both filled/unfilled shapes)
292+
let outline_intersects = |path_segment: PathSeg| bezier_iter().any(|line| !filtered_segment_intersections(path_segment, line, None, None).is_empty());
293+
if subpaths.iter().flat_map(|subpath| subpath.iter()).any(outline_intersects) {
294+
return true;
295+
}
296+
297+
// Selection point inside compound fill (non-zero rule)
298+
let combined: BezPath = subpaths.iter().flat_map(|subpath| subpath.to_bezpath()).collect();
299+
if bezier_iter().next().is_some_and(|bezier| combined.contains(bezier.start())) {
300+
return true;
301+
}
302+
303+
// Build closed selection path, then check if any subpath is entirely within it
304+
let mut selection = BezPath::from_path_segments(bezier_iter());
305+
selection.close_path();
306+
subpaths.iter().any(|subpath| bezpath_is_inside_bezpath(&subpath.to_bezpath(), &selection, None, None))
307+
}
259308
ClickTargetType::FreePoint(point) => bezier_iter().map(|bezier: PathSeg| bezier.winding(dvec2_to_point(point.position))).sum::<i32>() != 0,
260309
}
261310
}
@@ -288,6 +337,10 @@ impl ClickTarget {
288337
// Check if the point is within the shape
289338
match self.target_type() {
290339
ClickTargetType::Subpath(subpath) => subpath.closed() && subpath.contains_point(point),
340+
ClickTargetType::CompoundPath(subpaths) => {
341+
let combined: BezPath = subpaths.iter().flat_map(|subpath| subpath.to_bezpath()).collect();
342+
combined.contains(dvec2_to_point(point))
343+
}
291344
ClickTargetType::FreePoint(free_point) => free_point.position == point,
292345
}
293346
} else {

node-graph/libraries/vector-types/src/vector/vector_types.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,11 @@ impl Vector {
158158
match target_type.borrow() {
159159
ClickTargetType::Subpath(subpath) => vector.append_subpath(subpath, preserve_id),
160160
ClickTargetType::FreePoint(point) => vector.append_free_point(point, preserve_id),
161+
ClickTargetType::CompoundPath(subpaths) => {
162+
for subpath in subpaths {
163+
vector.append_subpath(subpath, preserve_id);
164+
}
165+
}
161166
}
162167
}
163168

0 commit comments

Comments
 (0)