Skip to content

Commit f7bfa3e

Browse files
authored
Merge pull request #803 from jonobr1/467-smooth
[Enhancement] Two.Path.smooth method added
2 parents c8c4e03 + e5c04da commit f7bfa3e

13 files changed

Lines changed: 4623 additions & 1115 deletions

File tree

build/two.js

Lines changed: 612 additions & 142 deletions
Large diffs are not rendered by default.

build/two.min.js

Lines changed: 2 additions & 2 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: 612 additions & 142 deletions
Large diffs are not rendered by default.

src/group.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ export class Group extends Shape {
379379
}
380380
}
381381

382-
static IsVisible = function (element, visibleOnly) {
382+
static IsVisible(element, visibleOnly) {
383383
if (!visibleOnly) {
384384
return true;
385385
}
@@ -396,9 +396,9 @@ export class Group extends Shape {
396396
}
397397

398398
return true;
399-
};
399+
}
400400

401-
static VisitForHitTest = function (
401+
static VisitForHitTest(
402402
group,
403403
context,
404404
includeGroups,
@@ -482,7 +482,7 @@ export class Group extends Shape {
482482
}
483483

484484
return false;
485-
};
485+
}
486486

487487
/**
488488
* @name Two.Group#copy
@@ -609,6 +609,25 @@ export class Group extends Shape {
609609
return this;
610610
}
611611

612+
/**
613+
* @name Two.Group#getShapesAtPoint
614+
* @function
615+
* @param {Number} x - X coordinate in world space.
616+
* @param {Number} y - Y coordinate in world space.
617+
* @param {Object} [options] - Hit test configuration.
618+
* @param {Boolean} [options.visibleOnly=true] - Limit results to visible shapes.
619+
* @param {Boolean} [options.includeGroups=false] - Include groups in the hit results.
620+
* @param {('all'|'deepest')} [options.mode='all'] - Whether to return all intersecting shapes or only the top-most.
621+
* @param {Boolean} [options.deepest] - Alias for `mode: 'deepest'`.
622+
* @param {Number} [options.precision] - Segmentation precision for curved geometry.
623+
* @param {Number} [options.tolerance=0] - Pixel tolerance applied to hit testing.
624+
* @param {Boolean} [options.fill] - Override fill testing behaviour.
625+
* @param {Boolean} [options.stroke] - Override stroke testing behaviour.
626+
* @param {Function} [options.filter] - Predicate to filter shapes from the result set.
627+
* @returns {Shape[]} Ordered list of intersecting shapes, front to back.
628+
* @description Traverse the group hierarchy and return shapes that contain the specified point.
629+
* @nota-bene Expects *world-space coordinates* – the same pixel-space you get from the renderer (e.g., mouse `clientX`/`clientY` adjusted for the canvas’s offset and pixel ratio).
630+
*/
612631
getShapesAtPoint(x, y, options) {
613632
const opts = options || {};
614633
const { results, hitOptions, context, single, empty } =

src/path.js

Lines changed: 211 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ import {
3232
hasVisibleFill,
3333
hasVisibleStroke,
3434
} from './utils/hit-test.js';
35+
import {
36+
clearHandleComponent,
37+
setHandleComponent,
38+
inheritRelative,
39+
isSegmentCurved,
40+
splitSubdivisionSegment,
41+
applyGlobalSmooth,
42+
applyLocalSmooth,
43+
} from './utils/path.js';
3544

3645
// Constants
3746

@@ -824,6 +833,18 @@ export class Path extends Shape {
824833
};
825834
}
826835

836+
/**
837+
* @name Two.Path#contains
838+
* @function
839+
* @param {Number} x - x coordinate to hit test against
840+
* @param {Number} y - y coordinate to hit test against
841+
* @param {Object} [options] - Optional options object
842+
* @param {Boolean} [options.ignoreVisibility] - If `true`, hit test against `path.visible = false` shapes
843+
* @param {Number} [options.tolerance] - Padding to hit test against in pixels
844+
* @returns {Boolean}
845+
* @description Check to see if coordinates are within a {@link Two.Path}'s bounding rectangle
846+
* @nota-bene Expects *world-space coordinates* – the same pixel-space you get from the renderer (e.g., mouse `clientX`/`clientY` adjusted for the canvas’s offset and pixel ratio).
847+
*/
827848
contains(x, y, options) {
828849
const opts = options || {};
829850
const ignoreVisibility = opts.ignoreVisibility === true;
@@ -832,12 +853,15 @@ export class Path extends Shape {
832853
return false;
833854
}
834855

835-
if (!ignoreVisibility && typeof this.opacity === 'number' && this.opacity <= 0) {
856+
if (
857+
!ignoreVisibility &&
858+
typeof this.opacity === 'number' &&
859+
this.opacity <= 0
860+
) {
836861
return false;
837862
}
838863

839-
const tolerance =
840-
typeof opts.tolerance === 'number' ? opts.tolerance : 0;
864+
const tolerance = typeof opts.tolerance === 'number' ? opts.tolerance : 0;
841865

842866
this._update(true);
843867

@@ -878,8 +902,7 @@ export class Path extends Shape {
878902
}
879903

880904
if (strokeTest && segments.length > 0) {
881-
const linewidth =
882-
typeof this.linewidth === 'number' ? this.linewidth : 0;
905+
const linewidth = typeof this.linewidth === 'number' ? this.linewidth : 0;
883906
if (linewidth > 0) {
884907
const distance = distanceToSegments(segments, localX, localY);
885908
if (distance <= linewidth / 2 + tolerance) {
@@ -1052,80 +1075,208 @@ export class Path extends Shape {
10521075
return this;
10531076
}
10541077

1078+
/**
1079+
* @name Two.Path#smooth
1080+
* @function
1081+
* @param {Object} [options] - Configuration for smoothing.
1082+
* @param {String} [options.type='continuous'] - Type of smoothing algorithm.
1083+
* @param {Number} [options.from=0] - Index of vertices to start smoothing
1084+
* @param {Number} [options.to=1] - Index of vertices to terminate smoothing
1085+
* @description Adjust vertex handles to generate smooth curves without toggling `automatic`.
1086+
*/
1087+
smooth(options) {
1088+
const opts = options || {};
1089+
const type = opts.type || 'continuous';
1090+
const vertices = this._collection;
1091+
const length = vertices.length;
1092+
1093+
if (length < 2) {
1094+
return this;
1095+
}
1096+
1097+
const closed =
1098+
this._closed ||
1099+
(length > 0 &&
1100+
vertices[length - 1] &&
1101+
vertices[length - 1].command === Commands.close);
1102+
1103+
const resolveIndex = (value, defaultIndex) => {
1104+
if (value === undefined || value === null) {
1105+
return defaultIndex;
1106+
}
1107+
1108+
if (typeof value === 'number') {
1109+
if (closed) {
1110+
return mod(value, length);
1111+
}
1112+
let index = value;
1113+
if (index < 0) {
1114+
index += length;
1115+
}
1116+
return Math.min(Math.max(index, 0), length - 1);
1117+
}
1118+
1119+
const idx = vertices.indexOf(value);
1120+
return idx !== -1 ? idx : defaultIndex;
1121+
};
1122+
1123+
const loop = closed && opts.from === undefined && opts.to === undefined;
1124+
let from = resolveIndex(opts.from, 0);
1125+
let to = resolveIndex(opts.to, length - 1);
1126+
1127+
if (from > to) {
1128+
if (closed) {
1129+
from -= length;
1130+
} else {
1131+
const temp = from;
1132+
from = to;
1133+
to = temp;
1134+
}
1135+
}
1136+
1137+
const rangeLength = to - from + 1;
1138+
for (let i = 0; i < rangeLength; i += 1) {
1139+
const index = mod(from + i, length);
1140+
const anchor = vertices[index];
1141+
const isOpenStart = !closed && index === 0;
1142+
if (anchor.command === Commands.move && !isOpenStart) {
1143+
anchor.command = Commands.line;
1144+
}
1145+
}
1146+
1147+
if (type === 'continuous' || type === 'asymmetric') {
1148+
applyGlobalSmooth(
1149+
vertices,
1150+
from,
1151+
to,
1152+
closed,
1153+
loop,
1154+
type === 'asymmetric'
1155+
);
1156+
} else if (type === 'catmull-rom' || type === 'geometric') {
1157+
const range = {
1158+
type,
1159+
factor: opts.factor,
1160+
};
1161+
applyLocalSmooth(vertices, from, to, closed, loop, range);
1162+
} else {
1163+
throw new Error(
1164+
`Path.smooth does not support type "${type}". Try 'continuous', 'asymmetric', 'catmull-rom', or 'geometric'.`
1165+
);
1166+
}
1167+
1168+
this._automatic = false;
1169+
this._flagVertices = true;
1170+
this._flagLength = true;
1171+
1172+
return this;
1173+
}
1174+
10551175
/**
10561176
* @name Two.Path#subdivide
10571177
* @function
10581178
* @param {Number} limit - How many times to recurse subdivisions.
10591179
* @description Insert a {@link Two.Anchor} at the midpoint between every item in {@link Two.Path#vertices}.
10601180
*/
10611181
subdivide(limit) {
1062-
// TODO: DRYness (function below)
10631182
this._update();
10641183

1065-
const last = this.vertices.length - 1;
1066-
const closed =
1067-
this._closed || this.vertices[last]._command === Commands.close;
1068-
let b = this.vertices[last];
1069-
let points = [],
1070-
verts;
1071-
1072-
_.each(
1073-
this.vertices,
1074-
function (a, i) {
1075-
if (i <= 0 && !closed) {
1076-
b = a;
1077-
return;
1078-
}
1184+
const vertices = this.vertices;
1185+
const length = vertices.length;
1186+
if (length < 2) {
1187+
return this;
1188+
}
10791189

1080-
if (a.command === Commands.move) {
1081-
points.push(new Anchor(b.x, b.y));
1082-
if (i > 0) {
1083-
points[points.length - 1].command = Commands.line;
1084-
}
1085-
b = a;
1086-
return;
1087-
}
1190+
const points = [];
1191+
let prevOriginal = null;
1192+
let subpathStartOriginal = null;
10881193

1089-
verts = getSubdivisions(a, b, limit);
1090-
points = points.concat(verts);
1194+
for (let i = 0; i < length; i += 1) {
1195+
const currentOriginal = vertices[i];
10911196

1092-
// Assign commands to all the verts
1093-
_.each(verts, function (v, i) {
1094-
if (i <= 0 && b.command === Commands.move) {
1095-
v.command = Commands.move;
1096-
} else {
1097-
v.command = Commands.line;
1098-
}
1099-
});
1197+
if (!prevOriginal || currentOriginal.command === Commands.move) {
1198+
const clone = currentOriginal.clone();
1199+
points.push(clone);
1200+
prevOriginal = currentOriginal;
1201+
subpathStartOriginal = currentOriginal;
1202+
continue;
1203+
}
11001204

1101-
if (i >= last) {
1102-
// TODO: Add check if the two vectors in question are the same values.
1103-
if (this._closed && this._automatic) {
1104-
b = a;
1205+
const isCurve = isSegmentCurved(currentOriginal, prevOriginal);
11051206

1106-
verts = getSubdivisions(a, b, limit);
1107-
points = points.concat(verts);
1207+
if (isCurve) {
1208+
const subdivided = getSubdivisions(currentOriginal, prevOriginal, limit);
1209+
const steps = subdivided.length;
1210+
const prevClone = points[points.length - 1];
1211+
let startSegment = prevClone.clone();
1212+
let endSegment = currentOriginal.clone();
1213+
let prevCloneRef = prevClone;
1214+
let prevT = 0;
11081215

1109-
// Assign commands to all the verts
1110-
_.each(verts, function (v, i) {
1111-
if (i <= 0 && b.command === Commands.move) {
1112-
v.command = Commands.move;
1113-
} else {
1114-
v.command = Commands.line;
1115-
}
1116-
});
1216+
if (steps <= 1) {
1217+
const currentClone = currentOriginal.clone();
1218+
points.push(currentClone);
1219+
} else {
1220+
for (let j = 1; j < steps; j += 1) {
1221+
const globalT = j / steps;
1222+
const denom = 1 - prevT;
1223+
const localT =
1224+
denom <= Number.EPSILON ? globalT : (globalT - prevT) / denom;
1225+
1226+
const split = splitSubdivisionSegment(
1227+
startSegment,
1228+
endSegment,
1229+
localT
1230+
);
1231+
1232+
setHandleComponent(
1233+
prevCloneRef,
1234+
'right',
1235+
split.startOut.x - prevCloneRef.x,
1236+
split.startOut.y - prevCloneRef.y
1237+
);
1238+
1239+
const newAnchor = split.anchor;
1240+
points.push(newAnchor);
1241+
1242+
prevCloneRef = newAnchor;
1243+
startSegment = newAnchor.clone();
1244+
prevT = globalT;
1245+
1246+
setHandleComponent(
1247+
endSegment,
1248+
'left',
1249+
split.endIn.x - endSegment.x,
1250+
split.endIn.y - endSegment.y
1251+
);
11171252
}
11181253

1119-
points.push(new Anchor(a.x, a.y));
1120-
points[points.length - 1].command = closed
1121-
? Commands.close
1122-
: Commands.line;
1254+
const currentClone = currentOriginal.clone();
1255+
currentClone.controls.left.copy(endSegment.controls.left);
1256+
points.push(currentClone);
1257+
}
1258+
} else {
1259+
const subdivided = getSubdivisions(currentOriginal, prevOriginal, limit);
1260+
1261+
for (let j = 1; j < subdivided.length; j += 1) {
1262+
const anchor = subdivided[j];
1263+
inheritRelative(anchor, prevOriginal);
1264+
clearHandleComponent(anchor, 'left');
1265+
clearHandleComponent(anchor, 'right');
1266+
anchor.command = Commands.line;
1267+
points.push(anchor);
11231268
}
11241269

1125-
b = a;
1126-
},
1127-
this
1128-
);
1270+
const currentClone = currentOriginal.clone();
1271+
points.push(currentClone);
1272+
}
1273+
1274+
prevOriginal = currentOriginal;
1275+
1276+
if (currentOriginal.command === Commands.close) {
1277+
prevOriginal = subpathStartOriginal;
1278+
}
1279+
}
11291280

11301281
this._automatic = false;
11311282
this._curved = false;

src/shape.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,9 @@ export class Shape extends Element {
221221
* @param {Object} [options] - Optional options object
222222
* @param {Boolean} [options.ignoreVisibility] - If `true`, hit test against `shape.visible = false` shapes
223223
* @param {Number} [options.tolerance] - Padding to hit test against in pixels
224-
* @description Remove self from the scene / parent.
224+
* @returns {Boolean}
225+
* @description Check to see if coordinates are within a {@link Two.Shape}'s bounding rectangle
226+
* @nota-bene Expects *world-space coordinates* – the same pixel-space you get from the renderer (e.g., mouse `clientX`/`clientY` adjusted for the canvas’s offset and pixel ratio).
225227
*/
226228
contains(x, y, options) {
227229
const opts = options || {};

0 commit comments

Comments
 (0)