Skip to content

Commit d3b4374

Browse files
authored
Merge pull request #801 from jonobr1/306-hit-testing
Feature Added: Renderer Agnostic Hit Testing
2 parents 6889f3a + 27f40d6 commit d3b4374

File tree

18 files changed

+2169
-201
lines changed

18 files changed

+2169
-201
lines changed

build/two.js

Lines changed: 472 additions & 6 deletions
Large diffs are not rendered by default.

build/two.min.js

Lines changed: 2 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

build/two.module.js

Lines changed: 472 additions & 5 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,4 @@
8585
}
8686
]
8787
}
88-
}
88+
}

src/group.js

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Events } from './events.js';
22
import { _ } from './utils/underscore.js';
33
import { getEffectFromObject } from './utils/shape.js';
4+
import { boundsContains } from './utils/hit-test.js';
45

56
import { Shape } from './shape.js';
67
import { Children } from './children.js';
@@ -23,6 +24,22 @@ import { Sprite } from './effects/sprite.js';
2324
const min = Math.min,
2425
max = Math.max;
2526

27+
const cache = {
28+
getShapesAtPoint: {
29+
results: [],
30+
hitOptions: {},
31+
context: {
32+
x: 0,
33+
y: 0,
34+
visibleOnly: true,
35+
results: null,
36+
},
37+
single: [],
38+
output: [],
39+
empty: [],
40+
},
41+
};
42+
2643
/**
2744
* @name Two.Group
2845
* @class
@@ -362,6 +379,111 @@ export class Group extends Shape {
362379
}
363380
}
364381

382+
static IsVisible = function (element, visibleOnly) {
383+
if (!visibleOnly) {
384+
return true;
385+
}
386+
387+
let current = element;
388+
while (current) {
389+
if (typeof current.visible === 'boolean' && !current.visible) {
390+
return false;
391+
}
392+
if (typeof current.opacity === 'number' && current.opacity <= 0) {
393+
return false;
394+
}
395+
current = current.parent;
396+
}
397+
398+
return true;
399+
};
400+
401+
static VisitForHitTest = function (
402+
group,
403+
context,
404+
includeGroups,
405+
filter,
406+
hitOptions,
407+
tolerance,
408+
stopOnFirst
409+
) {
410+
const children = group && group.children;
411+
if (!children) {
412+
return false;
413+
}
414+
415+
const results = context.results;
416+
for (let i = children.length - 1; i >= 0; i--) {
417+
const child = children[i];
418+
419+
if (!child) {
420+
continue;
421+
}
422+
423+
if (!Group.IsVisible(child, context.visibleOnly)) {
424+
continue;
425+
}
426+
427+
const rect =
428+
typeof child.getBoundingClientRect === 'function'
429+
? child.getBoundingClientRect()
430+
: null;
431+
432+
if (rect && !boundsContains(rect, context.x, context.y, tolerance)) {
433+
continue;
434+
}
435+
436+
if (child instanceof Group) {
437+
if (
438+
includeGroups &&
439+
(!filter || filter(child)) &&
440+
typeof child.contains === 'function' &&
441+
child.contains(context.x, context.y, hitOptions)
442+
) {
443+
results.push(child);
444+
if (stopOnFirst) {
445+
return true;
446+
}
447+
}
448+
if (
449+
Group.VisitForHitTest(
450+
child,
451+
context,
452+
includeGroups,
453+
filter,
454+
hitOptions,
455+
tolerance,
456+
stopOnFirst
457+
)
458+
) {
459+
return true;
460+
}
461+
continue;
462+
}
463+
464+
if (!(child instanceof Shape)) {
465+
continue;
466+
}
467+
468+
if (filter && !filter(child)) {
469+
continue;
470+
}
471+
472+
if (typeof child.contains !== 'function') {
473+
continue;
474+
}
475+
476+
if (child.contains(context.x, context.y, hitOptions)) {
477+
results.push(child);
478+
if (stopOnFirst) {
479+
return true;
480+
}
481+
}
482+
}
483+
484+
return false;
485+
};
486+
365487
/**
366488
* @name Two.Group#copy
367489
* @function
@@ -487,6 +609,70 @@ export class Group extends Shape {
487609
return this;
488610
}
489611

612+
getShapesAtPoint(x, y, options) {
613+
const opts = options || {};
614+
const { results, hitOptions, context, single, empty } =
615+
cache.getShapesAtPoint;
616+
617+
results.length = 0;
618+
619+
const mode = opts.mode === 'deepest' || opts.deepest ? 'deepest' : 'all';
620+
const visibleOnly = opts.visibleOnly !== false;
621+
const includeGroups = !!opts.includeGroups;
622+
const filter = typeof opts.filter === 'function' ? opts.filter : null;
623+
const tolerance = typeof opts.tolerance === 'number' ? opts.tolerance : 0;
624+
625+
if (typeof opts.precision === 'number') {
626+
hitOptions.precision = opts.precision;
627+
} else {
628+
delete hitOptions.precision;
629+
}
630+
if (typeof opts.fill !== 'undefined') {
631+
hitOptions.fill = opts.fill;
632+
} else {
633+
delete hitOptions.fill;
634+
}
635+
if (typeof opts.stroke !== 'undefined') {
636+
hitOptions.stroke = opts.stroke;
637+
} else {
638+
delete hitOptions.stroke;
639+
}
640+
hitOptions.tolerance = tolerance;
641+
hitOptions.ignoreVisibility = !visibleOnly;
642+
643+
const stopOnFirst = mode === 'deepest';
644+
context.x = x;
645+
context.y = y;
646+
context.visibleOnly = visibleOnly;
647+
context.results = results;
648+
649+
Group.VisitForHitTest(
650+
this,
651+
context,
652+
includeGroups,
653+
filter,
654+
hitOptions,
655+
tolerance,
656+
stopOnFirst
657+
);
658+
659+
if (stopOnFirst) {
660+
if (results.length > 0) {
661+
const first = results[0];
662+
results.length = 0;
663+
single[0] = first;
664+
single.length = 1;
665+
return single;
666+
}
667+
empty.length = 0;
668+
return empty;
669+
}
670+
671+
const hits = results.slice();
672+
results.length = 0;
673+
return hits;
674+
}
675+
490676
/**
491677
* @name Two.Group#corner
492678
* @function

src/path.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,19 @@ import { Shape } from './shape.js';
1919
import { Events } from './events.js';
2020
import { Vector } from './vector.js';
2121
import { Anchor } from './anchor.js';
22+
import { Matrix } from './matrix.js';
2223

2324
import { Gradient } from './effects/gradient.js';
2425
import { LinearGradient } from './effects/linear-gradient.js';
2526
import { RadialGradient } from './effects/radial-gradient.js';
2627
import { Texture } from './effects/texture.js';
28+
import {
29+
buildPathHitParts,
30+
pointInPolygons,
31+
distanceToSegments,
32+
hasVisibleFill,
33+
hasVisibleStroke,
34+
} from './utils/hit-test.js';
2735

2836
// Constants
2937

@@ -33,6 +41,7 @@ const min = Math.min,
3341
floor = Math.floor;
3442

3543
const vector = new Vector();
44+
const hitTestMatrix = new Matrix();
3645

3746
/**
3847
* @name Two.Path
@@ -815,6 +824,81 @@ export class Path extends Shape {
815824
};
816825
}
817826

827+
contains(x, y, options) {
828+
const opts = options || {};
829+
const ignoreVisibility = opts.ignoreVisibility === true;
830+
831+
if (!ignoreVisibility && this.visible === false) {
832+
return false;
833+
}
834+
835+
if (!ignoreVisibility && typeof this.opacity === 'number' && this.opacity <= 0) {
836+
return false;
837+
}
838+
839+
const tolerance =
840+
typeof opts.tolerance === 'number' ? opts.tolerance : 0;
841+
842+
this._update(true);
843+
844+
const rect = this.getBoundingClientRect();
845+
846+
if (
847+
!rect ||
848+
x < rect.left - tolerance ||
849+
x > rect.right + tolerance ||
850+
y < rect.top - tolerance ||
851+
y > rect.bottom + tolerance
852+
) {
853+
return false;
854+
}
855+
856+
const matrix = this.worldMatrix;
857+
const inverse = matrix && matrix.inverse(hitTestMatrix);
858+
859+
if (!inverse) {
860+
return super.contains(x, y, opts);
861+
}
862+
863+
const [localX, localY] = inverse.multiply(x, y, 1);
864+
const precision =
865+
typeof opts.precision === 'number' && !Number.isNaN(opts.precision)
866+
? Math.max(1, Math.floor(opts.precision))
867+
: 8;
868+
869+
const fillTest = hasVisibleFill(this, opts.fill);
870+
const strokeTest = hasVisibleStroke(this, opts.stroke);
871+
872+
const { polygons, segments } = buildPathHitParts(this, precision);
873+
874+
if (fillTest && polygons.length > 0) {
875+
if (pointInPolygons(polygons, localX, localY)) {
876+
return true;
877+
}
878+
}
879+
880+
if (strokeTest && segments.length > 0) {
881+
const linewidth =
882+
typeof this.linewidth === 'number' ? this.linewidth : 0;
883+
if (linewidth > 0) {
884+
const distance = distanceToSegments(segments, localX, localY);
885+
if (distance <= linewidth / 2 + tolerance) {
886+
return true;
887+
}
888+
}
889+
}
890+
891+
if (!fillTest && !strokeTest) {
892+
return super.contains(x, y, opts);
893+
}
894+
895+
if (fillTest && polygons.length === 0) {
896+
return super.contains(x, y, opts);
897+
}
898+
899+
return false;
900+
}
901+
818902
/**
819903
* @name Two.Path#getPointAt
820904
* @function

src/shape.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,55 @@ export class Shape extends Element {
213213
return this;
214214
}
215215

216+
/**
217+
* @name Two.Shape#contains
218+
* @function
219+
* @param {Number} x - x coordinate to hit test against
220+
* @param {Number} y - y coordinate to hit test against
221+
* @param {Object} [options] - Optional options object
222+
* @param {Boolean} [options.ignoreVisibility] - If `true`, hit test against `shape.visible = false` shapes
223+
* @param {Number} [options.tolerance] - Padding to hit test against in pixels
224+
* @description Remove self from the scene / parent.
225+
*/
226+
contains(x, y, options) {
227+
const opts = options || {};
228+
const ignoreVisibility = opts.ignoreVisibility === true;
229+
230+
if (!ignoreVisibility && 'visible' in this && this.visible === false) {
231+
return false;
232+
}
233+
234+
if (
235+
!ignoreVisibility &&
236+
'opacity' in this &&
237+
typeof this.opacity === 'number' &&
238+
this.opacity <= 0
239+
) {
240+
return false;
241+
}
242+
243+
if (typeof this.getBoundingClientRect !== 'function') {
244+
return false;
245+
}
246+
247+
const tolerance = typeof opts.tolerance === 'number' ? opts.tolerance : 0;
248+
249+
this._update(true);
250+
251+
const rect = this.getBoundingClientRect();
252+
253+
if (!rect) {
254+
return false;
255+
}
256+
257+
return (
258+
x >= rect.left - tolerance &&
259+
x <= rect.right + tolerance &&
260+
y >= rect.top - tolerance &&
261+
y <= rect.bottom + tolerance
262+
);
263+
}
264+
216265
/**
217266
* @name Two.Shape#copy
218267
* @function

0 commit comments

Comments
 (0)