@@ -35,6 +35,9 @@ impl FreePoint {
3535pub 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 {
0 commit comments