Skip to content

Commit

Permalink
Fix bounds for rotated paths. (#2780)
Browse files Browse the repository at this point in the history
  • Loading branch information
Jeffrey Heer committed Aug 14, 2020
1 parent 8be0fa8 commit 1e33358
Show file tree
Hide file tree
Showing 5 changed files with 182 additions and 144 deletions.
12 changes: 6 additions & 6 deletions packages/vega-scenegraph/src/Bounds.js
Expand Up @@ -105,14 +105,14 @@ Bounds.prototype = {
var {x1, y1, x2, y2} = this,
cos = Math.cos(angle),
sin = Math.sin(angle),
cx = x - x*cos + y*sin,
cy = y - x*sin - y*cos;
cx = x - x * cos + y * sin,
cy = y - x * sin - y * cos;

return [
cos*x1 - sin*y1 + cx, sin*x1 + cos*y1 + cy,
cos*x1 - sin*y2 + cx, sin*x1 + cos*y2 + cy,
cos*x2 - sin*y1 + cx, sin*x2 + cos*y1 + cy,
cos*x2 - sin*y2 + cx, sin*x2 + cos*y2 + cy
cos * x1 - sin * y1 + cx, sin * x1 + cos * y1 + cy,
cos * x1 - sin * y2 + cx, sin * x1 + cos * y2 + cy,
cos * x2 - sin * y1 + cx, sin * x2 + cos * y1 + cy,
cos * x2 - sin * y2 + cx, sin * x2 + cos * y2 + cy
];
},

Expand Down
181 changes: 105 additions & 76 deletions packages/vega-scenegraph/src/bound/boundContext.js
@@ -1,53 +1,121 @@
import {Epsilon, HalfPi, Tau} from '../util/constants';
import {DegToRad, Epsilon, HalfPi, Tau} from '../util/constants';

var bounds, lx, ly,
circleThreshold = Tau - 1e-8;
const circleThreshold = Tau - 1e-8;
let bounds, lx, ly, rot, ma, mb, mc, md;

export default function context(_) {
const add = (x, y) => bounds.add(x, y);
const addL = (x, y) => add(lx = x, ly = y);
const addX = x => add(x, bounds.y1);
const addY = y => add(bounds.x1, y);

const px = (x, y) => ma * x + mc * y;
const py = (x, y) => mb * x + md * y;
const addp = (x, y) => add(px(x, y), py(x, y));
const addpL = (x, y) => addL(px(x, y), py(x, y));

export default function(_, deg) {
bounds = _;
if (deg) {
rot = deg * DegToRad;
ma = md = Math.cos(rot);
mb = Math.sin(rot);
mc = -mb;
} else {
ma = md = 1;
rot = mb = mc = 0;
}
return context;
}

function noop() {}

function add(x, y) { bounds.add(x, y); }

function addL(x, y) { add(lx = x, ly = y); }

function addX(x) { add(x, bounds.y1); }

function addY(y) { add(bounds.x1, y); }

context.beginPath = noop;

context.closePath = noop;

context.moveTo = addL;

context.lineTo = addL;

context.rect = function(x, y, w, h) {
add(x + w, y + h);
addL(x, y);
};

context.quadraticCurveTo = function(x1, y1, x2, y2) {
quadExtrema(lx, x1, x2, addX);
quadExtrema(ly, y1, y2, addY);
addL(x2, y2);
const context = {
beginPath() {},
closePath() {},

moveTo: addpL,
lineTo: addpL,

rect(x, y, w, h) {
if (rot) {
addp(x + w, y);
addp(x + w, y + h);
addp(x, y + h);
addpL(x, y);
} else {
add(x + w, y + h);
addL(x, y);
}
},

quadraticCurveTo(x1, y1, x2, y2) {
const px1 = px(x1, y1),
py1 = py(x1, y1),
px2 = px(x2, y2),
py2 = py(x2, y2);
quadExtrema(lx, px1, px2, addX);
quadExtrema(ly, py1, py2, addY);
addL(px2, py2);
},

bezierCurveTo(x1, y1, x2, y2, x3, y3) {
const px1 = px(x1, y1),
py1 = py(x1, y1),
px2 = px(x2, y2),
py2 = py(x2, y2),
px3 = px(x3, y3),
py3 = py(x3, y3);
cubicExtrema(lx, px1, px2, px3, addX);
cubicExtrema(ly, py1, py2, py3, addY);
addL(px3, py3);
},

arc(cx, cy, r, sa, ea, ccw) {
sa += rot;
ea += rot;

// store last point on path
lx = r * Math.cos(ea) + cx;
ly = r * Math.sin(ea) + cy;

if (Math.abs(ea - sa) > circleThreshold) {
// treat as full circle
add(cx - r, cy - r);
add(cx + r, cy + r);
} else {
const update = a => add(r * Math.cos(a) + cx, r * Math.sin(a) + cy);
let s, i;

// sample end points
update(sa);
update(ea);

// sample interior points aligned with 90 degrees
if (ea !== sa) {
sa = sa % Tau; if (sa < 0) sa += Tau;
ea = ea % Tau; if (ea < 0) ea += Tau;

if (ea < sa) {
ccw = !ccw; // flip direction
s = sa; sa = ea; ea = s; // swap end-points
}

if (ccw) {
ea -= Tau;
s = sa - (sa % HalfPi);
for (i=0; i<4 && s>ea; ++i, s-=HalfPi) update(s);
} else {
s = sa - (sa % HalfPi) + HalfPi;
for (i=0; i<4 && s<ea; ++i, s=s+HalfPi) update(s);
}
}
}
}
};

function quadExtrema(x0, x1, x2, cb) {
const t = (x0 - x1) / (x0 + x2 - 2 * x1);
if (0 < t && t < 1) cb(x0 + (x1 - x0) * t);
}

context.bezierCurveTo = function(x1, y1, x2, y2, x3, y3) {
cubicExtrema(lx, x1, x2, x3, addX);
cubicExtrema(ly, y1, y2, y3, addY);
addL(x3, y3);
};

function cubicExtrema(x0, x1, x2, x3, cb) {
const a = x3 - x0 + 3 * x1 - 3 * x2,
b = x0 + x2 - 2 * x1,
Expand Down Expand Up @@ -78,42 +146,3 @@ function cubic(t, x0, x1, x2, x3) {
const s = 1 - t, s2 = s * s, t2 = t * t;
return (s2 * s * x0) + (3 * s2 * t * x1) + (3 * s * t2 * x2) + (t2 * t * x3);
}

context.arc = function(cx, cy, r, sa, ea, ccw) {
// store last point on path
lx = r * Math.cos(ea) + cx;
ly = r * Math.sin(ea) + cy;

if (Math.abs(ea - sa) > circleThreshold) {
// treat as full circle
add(cx - r, cy - r);
add(cx + r, cy + r);
} else {
const update = a => add(r * Math.cos(a) + cx, r * Math.sin(a) + cy);
let s, i;

// sample end points
update(sa);
update(ea);

// sample interior points aligned with 90 degrees
if (ea !== sa) {
sa = sa % Tau; if (sa < 0) sa += Tau;
ea = ea % Tau; if (ea < 0) ea += Tau;

if (ea < sa) {
ccw = !ccw; // flip direction
s = sa; sa = ea; ea = s; // swap end-points
}

if (ccw) {
ea -= Tau;
s = sa - (sa % HalfPi);
for (i=0; i<4 && s>ea; ++i, s-=HalfPi) update(s);
} else {
s = sa - (sa % HalfPi) + HalfPi;
for (i=0; i<4 && s<ea; ++i, s=s+HalfPi) update(s);
}
}
}
};
12 changes: 2 additions & 10 deletions packages/vega-scenegraph/src/marks/markItemPath.js
Expand Up @@ -14,16 +14,8 @@ export default function(type, shape, isect) {
}

function bound(bounds, item) {
var x = item.x || 0,
y = item.y || 0;

shape(context(bounds), item);
boundStroke(bounds, item).translate(x, y);
if (item.angle) {
bounds.rotate(item.angle * DegToRad, x, y);
}

return bounds;
shape(context(bounds, item.angle), item);
return boundStroke(bounds, item).translate(item.x || 0, item.y || 0);
}

function draw(context, item) {
Expand Down
8 changes: 1 addition & 7 deletions packages/vega-scenegraph/src/marks/path.js
Expand Up @@ -45,15 +45,9 @@ function path(context, item) {
}

function bound(bounds, item) {
path(context(bounds), item)
return path(context(bounds, item.angle), item)
? bounds.set(0, 0, 0, 0)
: boundStroke(bounds, item, true);

if (item.angle) {
bounds.rotate(item.angle * DegToRad, item.x || 0, item.y || 0);
}

return bounds;
}

export default {
Expand Down
113 changes: 68 additions & 45 deletions packages/vega-scenegraph/test/bound-context-test.js
@@ -1,8 +1,42 @@
var tape = require('tape'),
vega = require('../'),
Bounds = vega.Bounds,
boundContext = vega.boundContext,
EPSILON = 1e-10;
const tape = require('tape'),
vega = require('../'),
Bounds = vega.Bounds,
boundContext = vega.boundContext,
EPSILON = 1e-10,
x = 0,
y = 0,
r = 1,
rh = Math.SQRT1_2,
tau = 2 * Math.PI,
rotate = tau / 8,
b = new Bounds(),
angles = [
{
angle: 0,
bounds: [1, 0, 1, 0],
rotate: [rh, rh, rh, rh]
},
{
angle: 0 + 0.25 * tau,
bounds: [0, 0, 1, 1],
rotate: [-rh, rh, rh, 1]
},
{
angle: 0.50 * tau,
bounds: [-1, 0, 1, 1],
rotate: [-1, -rh, rh, 1]
},
{
angle: 0.75 * tau,
bounds: [-1, -1, 1, 1],
rotate: [-1, -1, rh, 1]
},
{
angle: tau,
bounds: [-1, -1, 1, 1],
rotate: [-1, -1, 1, 1]
}
];

function boundEqual(b, array) {
return Math.abs(b.x1 - array[0]) < EPSILON
Expand All @@ -11,56 +45,45 @@ function boundEqual(b, array) {
&& Math.abs(b.y2 - array[3]) < EPSILON;
}

function getContext(bounds, angle) {
return boundContext(bounds.clear(), angle || 0);
}

tape('boundContext should bound arc segments', function(t) {
var x = 0,
y = 0,
r = 1,
rh = Math.SQRT1_2,
tau = 2 * Math.PI,
rotate = tau / 8,
b = new Bounds();
angles.forEach(_ => {
getContext(b).arc(x, y, r, 0, _.angle, false);
t.ok(boundEqual(b, _.bounds), 'bound-cw: ' + _.angle);

getContext(b).arc(x, y, r, _.angle, 0, true);
t.ok(boundEqual(b, _.bounds), 'bound-ccw: ' + _.angle);

getContext(b).arc(x, y, r, rotate, rotate + _.angle, false);
t.ok(boundEqual(b, _.rotate), 'rotate-cw: ' + _.angle);

getContext(b).arc(x, y, r, rotate + _.angle, rotate, true);
t.ok(boundEqual(b, _.rotate), 'rotate-ccw: ' + _.angle);
});

t.end();
});

var angles = [
{
angle: 0,
bounds: [1, 0, 1, 0],
rotate: [rh, rh, rh, rh]
},
{
angle: 0.25 * tau,
bounds: [0, 0, 1, 1],
rotate: [-rh, rh, rh, 1]
},
{
angle: 0.50 * tau,
bounds: [-1, 0, 1, 1],
rotate: [-1, -rh, rh, 1]
},
{
angle: 0.75 * tau,
bounds: [-1, -1, 1, 1],
rotate: [-1, -1, rh, 1]
},
{
angle: tau,
bounds: [-1, -1, 1, 1],
rotate: [-1, -1, 1, 1]
}
];
tape('boundContext should bound rotated arc segments', function(t) {
const deg = 45,
rad = deg * Math.PI / 180;

angles.forEach(function(_) {
boundContext(b.clear()).arc(x, y, r, 0, _.angle, false);
angles.forEach(_ => {
getContext(b, -deg).arc(x, y, r, rad, rad + _.angle, false);
t.ok(boundEqual(b, _.bounds), 'bound-cw: ' + _.angle);

boundContext(b.clear()).arc(x, y, r, _.angle, 0, true);
getContext(b, -deg).arc(x, y, r, rad + _.angle, rad, true);
t.ok(boundEqual(b, _.bounds), 'bound-ccw: ' + _.angle);

boundContext(b.clear()).arc(x, y, r, rotate, rotate + _.angle, false);
getContext(b, -deg).arc(x, y, r, rad + rotate, rad + rotate + _.angle, false);
t.ok(boundEqual(b, _.rotate), 'rotate-cw: ' + _.angle);

boundContext(b.clear()).arc(x, y, r, rotate + _.angle, rotate, true);
getContext(b, -deg).arc(x, y, r, rad + rotate + _.angle, rad + rotate, true);
t.ok(boundEqual(b, _.rotate), 'rotate-ccw: ' + _.angle);
});

t.end();
});
});

0 comments on commit 1e33358

Please sign in to comment.