mirror of
https://github.com/scratchfoundation/paper.js.git
synced 2025-08-01 08:38:54 -04:00
Merge remote-tracking branch 'origin/master' into boolean-operations
Conflicts: src/path/PathItem.Boolean.js
This commit is contained in:
commit
d522e4aec2
8 changed files with 198 additions and 66 deletions
src
test
|
@ -166,7 +166,7 @@ var Line = Base.extend(/** @lends Line# */{
|
|||
vy -= py;
|
||||
}
|
||||
return Numerical.isZero(vx)
|
||||
? vy >= 0 ? py - x : x - px
|
||||
? vy >= 0 ? px - x : x - px
|
||||
: Numerical.isZero(vy)
|
||||
? vx >= 0 ? y - py : py - y
|
||||
: -(vy * x - vx * y - px * (py + vy) + py * (px + vx)) /
|
||||
|
|
|
@ -1073,7 +1073,6 @@ new function() { // Scope for methods that require numerical integration
|
|||
// Let P be the first curve and Q be the second
|
||||
var q0x = v2[0], q0y = v2[1], q3x = v2[6], q3y = v2[7],
|
||||
tolerance = /*#=*/Numerical.TOLERANCE,
|
||||
epsilon = 1e-10, // /*#=*/Numerical.EPSILON,
|
||||
// Calculate the fat-line L for Q is the baseline l and two
|
||||
// offsets which completely encloses the curve P.
|
||||
d1 = getSignedDistance(q0x, q0y, q3x, q3y, v2[2], v2[3]) || 0,
|
||||
|
@ -1089,9 +1088,7 @@ new function() { // Scope for methods that require numerical integration
|
|||
dp2 = getSignedDistance(q0x, q0y, q3x, q3y, v1[4], v1[5]),
|
||||
dp3 = getSignedDistance(q0x, q0y, q3x, q3y, v1[6], v1[7]),
|
||||
tMinNew, tMaxNew, tDiff;
|
||||
// NOTE: the recursion threshold of 4 is needed to prevent issue #571
|
||||
// from occurring: https://github.com/paperjs/paper.js/issues/571
|
||||
if (q0x === q3x && uMax - uMin <= epsilon && recursion > 4) {
|
||||
if (q0x === q3x && uMax - uMin <= tolerance && recursion > 3) {
|
||||
// The fatline of Q has converged to a point, the clipping is not
|
||||
// reliable. Return the value we have even though we will miss the
|
||||
// precision.
|
||||
|
@ -1154,7 +1151,7 @@ new function() { // Scope for methods that require numerical integration
|
|||
curve1, t1, Curve.evaluate(v1, t1, 0),
|
||||
curve2, t2, Curve.evaluate(v2, t2, 0));
|
||||
}
|
||||
} else { // Iterate
|
||||
} else if (tDiff > 0) { // Iterate
|
||||
addCurveIntersections(v2, v1, curve2, curve1, locations, include,
|
||||
uMin, uMax, tMinNew, tMaxNew, tDiff, !reverse, ++recursion);
|
||||
}
|
||||
|
@ -1192,7 +1189,7 @@ new function() { // Scope for methods that require numerical integration
|
|||
var vx = l2x - l1x,
|
||||
vy = l2y - l1y;
|
||||
if (Numerical.isZero(vx))
|
||||
return vy >= 0 ? l1y - x : x - l1x;
|
||||
return vy >= 0 ? l1x - x : x - l1x;
|
||||
var m = vy / vx, // slope
|
||||
b = l1y - m * l1x; // y offset
|
||||
// Distance to the linear equation
|
||||
|
@ -1411,11 +1408,11 @@ new function() { // Scope for methods that require numerical integration
|
|||
},
|
||||
|
||||
filterIntersections: function(locations, _expand) {
|
||||
var max = locations.length - 1,
|
||||
var last = locations.length - 1,
|
||||
tMax = 1 - /*#=*/Numerical.TOLERANCE;
|
||||
// Merge intersections very close to the end of a curve to the
|
||||
// beginning of the next curve.
|
||||
for (var i = max; i >= 0; i--) {
|
||||
for (var i = last; i >= 0; i--) {
|
||||
var loc = locations[i],
|
||||
next = loc._curve.getNext(),
|
||||
next2 = loc._curve2.getNext();
|
||||
|
@ -1442,18 +1439,18 @@ new function() { // Scope for methods that require numerical integration
|
|||
: path1._id - path2._id;
|
||||
}
|
||||
|
||||
if (max > 0) {
|
||||
if (last > 0) {
|
||||
locations.sort(compare);
|
||||
// Filter out duplicate locations
|
||||
for (var i = max; i >= 1; i--) {
|
||||
if (locations[i].equals(locations[i === 0 ? max : i - 1])) {
|
||||
// Filter out duplicate locations.
|
||||
for (var i = last; i > 0; i--) {
|
||||
if (locations[i].equals(locations[i - 1])) {
|
||||
locations.splice(i, 1);
|
||||
max--;
|
||||
last--;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_expand) {
|
||||
for (var i = max; i >= 0; i--)
|
||||
for (var i = last; i >= 0; i--)
|
||||
locations.push(locations[i].getIntersection());
|
||||
locations.sort(compare);
|
||||
}
|
||||
|
|
|
@ -32,30 +32,49 @@
|
|||
*/
|
||||
|
||||
PathItem.inject(new function() {
|
||||
var operators = {
|
||||
unite: function(w) {
|
||||
return w === 1 || w === 0;
|
||||
},
|
||||
|
||||
intersect: function(w) {
|
||||
return w === 2;
|
||||
},
|
||||
|
||||
subtract: function(w) {
|
||||
return w === 1;
|
||||
},
|
||||
|
||||
exclude: function(w) {
|
||||
return w === 1;
|
||||
}
|
||||
};
|
||||
|
||||
// Boolean operators return true if a curve with the given winding
|
||||
// contribution contributes to the final result or not. They are called
|
||||
// for each curve in the graph after curves in the operands are
|
||||
// split at intersections.
|
||||
function computeBoolean(path1, path2, operator, subtract) {
|
||||
function computeBoolean(path1, path2, operation) {
|
||||
var operator = operators[operation];
|
||||
// Creates a cloned version of the path that we can modify freely, with
|
||||
// its matrix applied to its geometry. Calls #reduce() to simplify
|
||||
// compound paths and remove empty curves, and #reorient() to make sure
|
||||
// all paths have correct winding direction.
|
||||
function preparePath(path) {
|
||||
return path.clone(false).reduce().reorient().transform(null, true);
|
||||
return path.clone(false).reduce().reorient().transform(null, true,
|
||||
true);
|
||||
}
|
||||
|
||||
// We do not modify the operands themselves
|
||||
// The result might not belong to the same type
|
||||
// We do not modify the operands themselves, but create copies instead,
|
||||
// fas produced by the calls to preparePath().
|
||||
// Note that the result paths might not belong to the same type
|
||||
// i.e. subtraction(A:Path, B:Path):CompoundPath etc.
|
||||
var _path1 = preparePath(path1),
|
||||
_path2 = path2 && path1 !== path2 && preparePath(path2);
|
||||
// Do operator specific calculations before we begin
|
||||
// Make both paths at clockwise orientation, except when subtract = true
|
||||
// We need both paths at opposite orientation for subtraction.
|
||||
if (!_path1.isClockwise())
|
||||
_path1.reverse();
|
||||
if (_path2 && !(subtract ^ _path2.isClockwise()))
|
||||
// Give both paths the same orientation except for subtraction
|
||||
// and exclusion, where we need them at opposite orientation.
|
||||
if (_path2 && /^(subtract|exclude)$/.test(operation)
|
||||
^ (_path2.isClockwise() !== _path1.isClockwise()))
|
||||
_path2.reverse();
|
||||
// Split curves at intersections on both paths. Note that for self
|
||||
// intersection, _path2 will be null and getIntersections() handles it.
|
||||
|
@ -134,7 +153,7 @@ PathItem.inject(new function() {
|
|||
// While subtracting, we need to omit this curve if this
|
||||
// curve is contributing to the second operand and is
|
||||
// outside the first operand.
|
||||
windingSum += subtract && _path2
|
||||
windingSum += operation === 'subtract' && _path2
|
||||
&& (path === _path1 && _path2._getWinding(pt, hor)
|
||||
|| path === _path2 && !_path1._getWinding(pt, hor))
|
||||
? 0
|
||||
|
@ -172,7 +191,8 @@ PathItem.inject(new function() {
|
|||
* @param {CurveLocation[]} intersections Array of CurveLocation objects
|
||||
*/
|
||||
function splitPath(intersections) {
|
||||
var tolerance = /*#=*/Numerical.TOLERANCE,
|
||||
var tMin = /*#=*/Numerical.TOLERANCE,
|
||||
tMax = 1 - tMin,
|
||||
linearHandles;
|
||||
|
||||
function resetLinear() {
|
||||
|
@ -182,23 +202,22 @@ PathItem.inject(new function() {
|
|||
linearHandles[i].set(0, 0);
|
||||
}
|
||||
|
||||
for (var i = intersections.length - 1, curve, prevLoc; i >= 0; i--) {
|
||||
for (var i = intersections.length - 1, curve, prev; i >= 0; i--) {
|
||||
var loc = intersections[i],
|
||||
t = loc._parameter;
|
||||
// Check if we are splitting same curve multiple times
|
||||
if (prevLoc && prevLoc._curve === loc._curve
|
||||
// Avoid dividing with zero
|
||||
&& prevLoc._parameter > 0) {
|
||||
// Check if we are splitting same curve multiple times, but avoid
|
||||
// dividing with zero.
|
||||
if (prev && prev._curve === loc._curve && prev._parameter > 0) {
|
||||
// Scale parameter after previous split.
|
||||
t /= prevLoc._parameter;
|
||||
t /= prev._parameter;
|
||||
} else {
|
||||
curve = loc._curve;
|
||||
if (linearHandles)
|
||||
resetLinear();
|
||||
curve = loc._curve;
|
||||
linearHandles = curve.isLinear() && [];
|
||||
if (linearHandles)
|
||||
linearHandles.push(curve._segment1._handleOut,
|
||||
curve._segment2._handleIn);
|
||||
linearHandles = curve.isLinear() ? [
|
||||
curve._segment1._handleOut,
|
||||
curve._segment2._handleIn
|
||||
] : null;
|
||||
}
|
||||
var newCurve,
|
||||
segment;
|
||||
|
@ -209,9 +228,9 @@ PathItem.inject(new function() {
|
|||
if (linearHandles)
|
||||
linearHandles.push(segment._handleOut, segment._handleIn);
|
||||
} else {
|
||||
segment = t < tolerance
|
||||
segment = t < tMin
|
||||
? curve._segment1
|
||||
: t > 1 - tolerance
|
||||
: t > tMax
|
||||
? curve._segment2
|
||||
: curve.getPartLength(0, t) < curve.getPartLength(t, 1)
|
||||
? curve._segment1
|
||||
|
@ -220,7 +239,7 @@ PathItem.inject(new function() {
|
|||
// Link the new segment with the intersection on the other curve
|
||||
segment._intersection = loc.getIntersection();
|
||||
loc._segment = segment;
|
||||
prevLoc = loc;
|
||||
prev = loc;
|
||||
}
|
||||
if (linearHandles)
|
||||
resetLinear();
|
||||
|
@ -231,14 +250,7 @@ PathItem.inject(new function() {
|
|||
* with respect to a given set of monotone curves.
|
||||
*/
|
||||
function getWinding(point, curves, horizontal, testContains) {
|
||||
// We need to use a smaller tolerance here than in the rest of the
|
||||
// library when dealing with curve time parameters and coordinates, in
|
||||
// order to get really precise values for winding tests. 1e-7 was
|
||||
// determined through a lot of trial and error, and boolean-test suites.
|
||||
// Further decreasing it produces new errors.
|
||||
// The value of 1e-7 also solves issue #559:
|
||||
// https://github.com/paperjs/paper.js/issues/559
|
||||
var tolerance = 1e-7,
|
||||
var tolerance = /*#=*/Numerical.TOLERANCE,
|
||||
tMin = tolerance,
|
||||
tMax = 1 - tMin,
|
||||
x = point.x,
|
||||
|
@ -344,10 +356,6 @@ PathItem.inject(new function() {
|
|||
* @return {Path[]} the contours traced
|
||||
*/
|
||||
function tracePaths(segments, operator, selfOp) {
|
||||
// Choose a default operator which will return all contours
|
||||
operator = operator || function() {
|
||||
return true;
|
||||
};
|
||||
var paths = [],
|
||||
// Values for getTangentAt() that are almost 0 and 1.
|
||||
// TODO: Correctly support getTangentAt(0) / (1)?
|
||||
|
@ -485,9 +493,7 @@ PathItem.inject(new function() {
|
|||
* @return {PathItem} the resulting path item
|
||||
*/
|
||||
unite: function(path) {
|
||||
return computeBoolean(this, path, function(w) {
|
||||
return w === 1 || w === 0;
|
||||
}, false);
|
||||
return computeBoolean(this, path, 'unite');
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -498,9 +504,7 @@ PathItem.inject(new function() {
|
|||
* @return {PathItem} the resulting path item
|
||||
*/
|
||||
intersect: function(path) {
|
||||
return computeBoolean(this, path, function(w) {
|
||||
return w === 2;
|
||||
}, false);
|
||||
return computeBoolean(this, path, 'intersect');
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -511,9 +515,7 @@ PathItem.inject(new function() {
|
|||
* @return {PathItem} the resulting path item
|
||||
*/
|
||||
subtract: function(path) {
|
||||
return computeBoolean(this, path, function(w) {
|
||||
return w === 1;
|
||||
}, true);
|
||||
return computeBoolean(this, path, 'subtract');
|
||||
},
|
||||
|
||||
// Compound boolean operators combine the basic boolean operations such
|
||||
|
@ -526,7 +528,7 @@ PathItem.inject(new function() {
|
|||
* @return {Group} the resulting group item
|
||||
*/
|
||||
exclude: function(path) {
|
||||
return new Group([this.subtract(path), path.subtract(this)]);
|
||||
return computeBoolean(this, path, 'exclude');
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
@ -63,7 +63,7 @@ var Numerical = new function() {
|
|||
cos = Math.cos,
|
||||
PI = Math.PI,
|
||||
isFinite = Number.isFinite,
|
||||
TOLERANCE = 1e-5,
|
||||
TOLERANCE = 1e-6,
|
||||
EPSILON = 1e-13,
|
||||
MACHINE_EPSILON = 1.12e-16;
|
||||
|
||||
|
|
|
@ -72,9 +72,9 @@ var comparators = {
|
|||
},
|
||||
|
||||
Number: function(actual, expected, message, options) {
|
||||
// Compare with a default tolerance of Numerical.TOLERANCE:
|
||||
// Compare with a default tolerance of 1e-5:
|
||||
var ok = Math.abs(actual - expected)
|
||||
<= Base.pick(options && options.tolerance, Numerical.TOLERANCE);
|
||||
<= Base.pick(options && options.tolerance, 1e-5);
|
||||
QUnit.push(ok, ok ? expected : actual, expected, message);
|
||||
},
|
||||
|
||||
|
|
31
test/tests/Path_Boolean.js
Normal file
31
test/tests/Path_Boolean.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Paper.js - The Swiss Army Knife of Vector Graphics Scripting.
|
||||
* http://paperjs.org/
|
||||
*
|
||||
* Copyright (c) 2011 - 2014, Juerg Lehni & Jonathan Puckey
|
||||
* http://scratchdisk.com/ & http://jonathanpuckey.com/
|
||||
*
|
||||
* Distributed under the MIT license. See LICENSE file for details.
|
||||
*
|
||||
* All rights reserved.
|
||||
*/
|
||||
|
||||
module('Path Boolean Operations');
|
||||
|
||||
test('path.unite(); #609', function() {
|
||||
// https://github.com/paperjs/paper.js/issues/609
|
||||
// path1 and path2 are half circles, applying unite should result in a circle
|
||||
|
||||
var path1 = new Path();
|
||||
path1.moveTo(new Point(100, 100));
|
||||
path1.arcTo(new Point(100, 200));
|
||||
path1.closePath();
|
||||
|
||||
var path2 = new Path();
|
||||
path2.moveTo(new Point(100, 200));
|
||||
path2.arcTo(new Point(100, 100));
|
||||
path2.closePath();
|
||||
|
||||
var path3 = path1.unite(path2);
|
||||
equals(path3.pathData, 'M100,100c27.61424,0 50,22.38576 50,50c0,27.61424 -22.38576,50 -50,50z M100,200c-27.61424,0 -50,-22.38576 -50,-50c0,-27.61424 22.38576,-50 50,-50z', 'path3.pathData');
|
||||
});
|
100
test/tests/Path_Intersections.js
Normal file
100
test/tests/Path_Intersections.js
Normal file
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* Paper.js - The Swiss Army Knife of Vector Graphics Scripting.
|
||||
* http://paperjs.org/
|
||||
*
|
||||
* Copyright (c) 2011 - 2014, Juerg Lehni & Jonathan Puckey
|
||||
* http://scratchdisk.com/ & http://jonathanpuckey.com/
|
||||
*
|
||||
* Distributed under the MIT license. See LICENSE file for details.
|
||||
*
|
||||
* All rights reserved.
|
||||
*/
|
||||
|
||||
module('Path Intersections');
|
||||
|
||||
function testIntersection(intersections, results) {
|
||||
equals(intersections.length, results.length, 'intersections.length');
|
||||
for (var i = 0; i < results.length; i++) {
|
||||
var inter = intersections[i];
|
||||
var values = results[i];
|
||||
var name = 'intersections[' + i + ']';
|
||||
equals(inter.point, new Point(values.point), name + '.point');
|
||||
equals(inter.index, values.index, name + '.index');
|
||||
equals(inter.parameter, values.parameter || 0, name + '.parameter');
|
||||
}
|
||||
}
|
||||
|
||||
test('path.getIntersections(); #565', function() {
|
||||
// https://github.com/paperjs/paper.js/issues/565
|
||||
var crv1 = new Curve(new Point(421.75945, 416.40481), new Point(-181.49299, -224.94946), new Point(44.52004, -194.13319), new Point(397.47615, 331.34712));
|
||||
var crv2 = new Curve(new Point(360.09446, 350.97254), new Point(-58.58867, -218.45806), new Point(-109.55091, -220.99561), new Point(527.83582, 416.79948));
|
||||
var pth1 = new Path([crv1.segment1, crv1.segment2]);
|
||||
var pth2 = new Path([crv2.segment1, crv2.segment2]);
|
||||
testIntersection(crv1.getIntersections(crv2), [
|
||||
{ point: { x: 354.13635, y: 220.81369 }, index: 0, parameter: 0.46725 },
|
||||
{ point: { x: 390.24772, y: 224.27351 }, index: 0, parameter: 0.71605 }
|
||||
]);
|
||||
|
||||
// Alternative pair of curves that has the same issue
|
||||
var crv1 = new Curve(new Point(484.9026237381622, 404.11001967731863), new Point(-265.1185871567577, -204.00749347172678), new Point(-176.7118886578828, 111.96015905588865), new Point(438.8191690435633, 429.0297837462276));
|
||||
var crv2 = new Curve(new Point(388.25280445162207, 490.95032326877117), new Point(-194.0586572047323, -50.77360603027046), new Point(-184.71034923568368, -260.5346686206758), new Point(498.41401199810207, 455.55853731930256)); var pth1 = new Path([crv1.segment1, crv1.segment2]);
|
||||
var pth1 = new Path([crv1.segment1, crv1.segment2]);
|
||||
var pth2 = new Path([crv2.segment1, crv2.segment2]);
|
||||
testIntersection(crv1.getIntersections(crv2), [
|
||||
{ point: { x: 335.62744, y: 338.15939 }, index: 0, parameter: 0.26516 }
|
||||
]);
|
||||
});
|
||||
|
||||
test('path.getIntersections(); #568', function() {
|
||||
// https://github.com/paperjs/paper.js/issues/568
|
||||
var crv1 = new Curve(new Point(509.05465863179415, 440.1211663847789), new Point(233.6728838738054, -245.8216403145343), new Point(-270.755685120821, 53.14275110140443), new Point(514.079892472364, 481.95262297522277));
|
||||
var crv2 = new Curve(new Point(542.1666181180626, 451.06309361290187), new Point(179.91238399408758, 148.68241581134498), new Point(193.42650789767504, -47.97609066590667), new Point(423.66228222381324, 386.3876062911004));
|
||||
var pth1 = new Path([crv1.segment1, crv1.segment2]);
|
||||
var pth2 = new Path([crv2.segment1, crv2.segment2]);
|
||||
testIntersection(crv1.getIntersections(crv2), [
|
||||
{ point: { x: 547.96568, y: 396.66339 }, index: 0, parameter: 0.07024 },
|
||||
{ point: { x: 504.79973, y: 383.37886 }, index: 0, parameter: 0.48077 }
|
||||
]);
|
||||
|
||||
var crv1 = new Curve(new Point(0, 0), new Point(20, 40) , new Point (-30, -50), new Point(50, 50));
|
||||
var crv2 = new Curve(new Point(50, 50), new Point(20, 100), new Point (-30, -120), new Point(250, 250));
|
||||
var pth1 = new Path([crv1.segment1, crv1.segment2]);
|
||||
var pth2 = new Path([crv2.segment1, crv2.segment2]);
|
||||
testIntersection(crv1.getIntersections(crv2), [
|
||||
{ point: { x: 50, y: 50 }, index: 0, parameter: 1 }
|
||||
]);
|
||||
});
|
||||
|
||||
test('path.getIntersections(); #570', function() {
|
||||
// https://github.com/paperjs/paper.js/issues/570
|
||||
var crv1 = new Curve(new Point(171, 359), new Point(65.26926656546078, 62.85188632229557), new Point(-37.43795644844329, 7.813022000754188), new Point(311.16034791674826, 406.2985255840872));
|
||||
var crv2 = new Curve(new Point(311.16034791674826, 406.2985255840872), new Point(39.997020018940304, -8.347079462067768), new Point(-73.86292504547487, -77.47859270504358), new Point(465, 467));
|
||||
var pth1 = new Path([crv1.segment1, crv1.segment2]);
|
||||
var pth2 = new Path([crv2.segment1, crv2.segment2]);
|
||||
var ints = crv1.getIntersections(crv2);
|
||||
testIntersection(crv1.getIntersections(crv2), [
|
||||
{ point: { x: 311.16035, y: 406.29853 }, index: 0, parameter: 1 }
|
||||
]);
|
||||
});
|
||||
|
||||
test('path.getIntersections(); #571', function() {
|
||||
// https://github.com/paperjs/paper.js/issues/571
|
||||
var crv1 = new Curve(new Point(171, 359), new Point(205.3908899553486, -14.994581100305595), new Point(5.767644819815757, 28.49094950835297), new Point(420.1235851920127, 275.8351912321666));
|
||||
var crv2 = new Curve(new Point(420.1235851920127, 275.8351912321666), new Point(-10.77224553077383, -53.21262197949682), new Point(-259.2129470250785, -258.56165821345775), new Point(465, 467));
|
||||
var pth1 = new Path([crv1.segment1, crv1.segment2]);
|
||||
var pth2 = new Path([crv2.segment1, crv2.segment2]);
|
||||
testIntersection(crv1.getIntersections(crv2), [
|
||||
{ point: { x: 352.39945, y: 330.44135 }, index: 0, parameter: 0.41159 },
|
||||
{ point: { x: 420.12359, y: 275.83519 }, index: 0, parameter: 1 }
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
test('path.getIntersections(); overlapping circles', function() {
|
||||
var c1 = new Path.Circle(new paper.Point(50, 50), 50);
|
||||
var c2 = new Path.Circle(new paper.Point(100, 100), 50);
|
||||
testIntersection(c1.getIntersections(c2), [
|
||||
{ point: { x: 100, y: 50 }, index: 2 },
|
||||
{ point: { x: 50, y: 100 }, index: 3 }
|
||||
]);
|
||||
});
|
|
@ -35,6 +35,8 @@
|
|||
/*#*/ include('Path_Curves.js');
|
||||
/*#*/ include('Path_Bounds.js');
|
||||
/*#*/ include('Path_Length.js');
|
||||
/*#*/ include('Path_Intersections.js');
|
||||
/*#*/ include('Path_Boolean.js');
|
||||
|
||||
/*#*/ include('Curve.js');
|
||||
/*#*/ include('CurveLocation.js');
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue