A32 ·
Reuleaux Polygons
I have always been fascinated by the Reuleaux triangle. It is a shape formed by the intersection of three circles. Sort of like a ballooned up equilateral triangle.
Reuleaux polygons are a generalization of the Reuleaux triangle. They are a curvilinear polygons i.e., made up of circular arcs and have an odd number of sides.
Construction
The Reuleaux triangle is constructed by drawing circles from each vertex of an equilateral triangle. Where the radius of these circles equals the side length of the triangle.
<div id="app"></div> .input-range-rv {
vertical-align: middle;
background-color: transparent;
padding-top: 8px;
padding-top: 0.5rem;
padding-bottom: 8px;
padding-bottom: 0.5rem;
color: inherit;
background-color: transparent;
-webkit-appearance: none;
}
.input-range-rv::-webkit-slider-thumb {
position: relative;
width: 8px;
width: 0.5rem;
height: 20px;
height: 1.25rem;
cursor: pointer;
margin-top: -8px;
margin-top: -0.5rem;
border-radius: 3px;
background-color: #333;
-webkit-appearance: none;
}
/* Touch screen friendly pseudo element */
.input-range-rv::-webkit-slider-thumb:before {
content: '';
display: block;
position: absolute;
top: -8px;
top: -0.5rem;
left: -14px;
left: -0.875rem;
width: 36px;
width: 2.25rem;
height: 36px;
height: 2.25rem;
opacity: 0;
}
.input-range-rv::-moz-range-thumb {
width: 8px;
width: 0.5rem;
height: 20px;
height: 1.25rem;
cursor: pointer;
border-radius: 3px;
border-color: transparent;
border-width: 0;
background-color: #333;
}
.input-range-rv::-webkit-slider-runnable-track {
height: 4px;
height: 0.25rem;
cursor: pointer;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.25);
}
.input-range-rv::-moz-range-track {
height: 4px;
height: 0.25rem;
cursor: pointer;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.25);
}
.input-range-rv:focus {
outline: none;
} const { render } = ReactDOM;
const RangeControl = ({
name,
label,
value,
update,
className = '',
...props
}) => (
<div className={`mb4 ${className}`}>
<label htmlFor={name} className="f5 b db mb2 dark-gray">
{label} <span className="gray">({value})</span>
</label>
<input
name={name}
type="range"
className="input-range-rv flex-auto dark-gray w-100"
value={value}
onChange={e => update(name, e.target.value)}
{...props}
/>
</div>
);
const Controls = ({ className, controls, values, update }) => (
<div className={className}>
{controls.map(({ type, ...props }) =>
React.createElement(type, {
value: values[props.name],
key: props.name,
update,
...props,
}),
)}
</div>
);
const ReuleauxPolygon = ({ reuleauxPolygonD, polygonD, className }) => (
<g>
<defs>
<pattern
id="diagonal-stripe"
patternUnits="userSpaceOnUse"
width="10"
height="10"
>
<rect width="10" height="10" stroke="none" fill="#fff" />
<path
d="M-1,1 l2,-2 M0,10 l10,-10 M9,11 l2,-2"
stroke="#333"
strokeWidth="1"
/>
</pattern>
</defs>
<path fill="url(#diagonal-stripe)" d={reuleauxPolygonD} />
<path stroke="#0f0" strokeWidth="4" d={polygonD} />
<path fill="none" strokeWidth="4" d={reuleauxPolygonD} />
</g>
);
const ReuleauxPolygonConstruction = ({ sideCount = 3, className }) => {
const { r, pts, polygon, reuleaux } = reuleauxPolygonConstruction(
[270, 270],
90,
sideCount,
);
return (
<div className={className}>
<svg
viewBox="0 0 540 540"
className="w-100"
fill="none"
stroke="#333"
strokeLinejoin="round"
strokeLinecap="round"
strokeWidth="2"
>
{pts.map(p => (
<circle key={p.join(',')} stroke="#ccc" cx={p[0]} cy={p[1]} r={r} />
))}
<ReuleauxPolygon reuleauxPolygonD={reuleaux} polygonD={polygon} />
{pts.map(p => (
<circle key={p.join(',')} stroke="none" fill="#f0f" cx={p[0]} cy={p[1]} r={4} />
))}
</svg>
</div>
);
};
class App extends React.Component {
state = {
values: {
'side-count': 3,
},
controls: [
{
name: 'side-count',
label: 'Side Count',
type: RangeControl,
min: 3,
max: 11,
step: 2,
},
],
};
update = (name, value) => {
this.setState(state => ({
values: {
...state.values,
[name]: +value,
},
}));
};
render() {
const { controls, values } = this.state;
return (
<div className="code dark-gray mw6 center ph3 vh-100 flex items-center justify-center">
<div className="w-100">
<ReuleauxPolygonConstruction
className="mh3 mb3"
sideCount={values['side-count']}
/>
<Controls
controls={controls}
values={values}
update={this.update}
className=""
/>
</div>
</div>
);
}
}
render(<App />, document.querySelector('#app'));
/**
* Polygon Math
*/
function pts(sideCount, radius) {
const angle = 360 / sideCount;
const vertexIndices = range(sideCount);
const offsetDeg = 90 - (180 - angle) / 2;
const offset = degreesToRadians(offsetDeg);
return vertexIndices.map(index => {
return {
theta: offset + degreesToRadians(angle * index),
r: radius,
};
});
}
function range(count) {
return Array.from(Array(count).keys());
}
function degreesToRadians(angleInDegrees) {
return Math.PI * angleInDegrees / 180;
}
function polygon([cx, cy], sideCount, radius) {
return pts(sideCount, radius).map(({ r, theta }) => [
cx + r * Math.cos(theta),
cy + r * Math.sin(theta),
]);
}
function dist([x1, y1], [x2, y2]) {
return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5;
}
function reuleauxPolygonConstruction([cx, cy], radius, sideCount) {
const pts = polygon([cx, cy], radius, sideCount);
const l = dist(pts[0], pts[2]);
const [o, ...rest] = pts;
return {
r: l,
pts,
polygon: ['M', o[0], o[1], ...rest.map(p => `L ${p[0]} ${p[1]}`), 'Z'].join(
' ',
),
reuleaux: [
'M',
o[0],
o[1],
...rest.map(p => `A ${l} ${l} 0 0 1 ${p[0]} ${p[1]}`),
`A ${l} ${l} 0 0 1 ${o[0]} ${o[1]}`,
'Z',
].join(' '),
};
} Reuleaux polygons are constructed using a similar process. Group three consecutive vertices to form a triangle. Then draw a circle from each vertex. The radius of this circle is the distance between the first and the third vertex. Then move onto the next three vertices and so on. Try changing the side count slider, in the demo above, to see this visualized.
SVG Arc Command
To draw these circular arcs in SVG we can use the arc command.
A rx ry x-axis-rotation large-arc-flag sweep-flag x y
Where rx is the x-radius of the ellipse and ry is the y-radius. Keep rx equal to ry to create circular arcs.
Given these radii, there are two ellipses that can connect any two points. On those ellipses there are two possible paths to connect the points. In total, there are four possible arcs connecting any two points.
The large-arc-flag allows you to pick an arc greater than or less than 180 degrees. The sweep-flag controls if the arc should move clockwise or anti-clockwise.
<div id="app"></div> .input-range-rv {
vertical-align: middle;
background-color: transparent;
padding-top: 8px;
padding-top: 0.5rem;
padding-bottom: 8px;
padding-bottom: 0.5rem;
color: inherit;
background-color: transparent;
-webkit-appearance: none;
}
.input-range-rv::-webkit-slider-thumb {
position: relative;
width: 8px;
width: 0.5rem;
height: 20px;
height: 1.25rem;
cursor: pointer;
margin-top: -8px;
margin-top: -0.5rem;
border-radius: 3px;
background-color: #333;
-webkit-appearance: none;
}
/* Touch screen friendly pseudo element */
.input-range-rv::-webkit-slider-thumb:before {
content: '';
display: block;
position: absolute;
top: -8px;
top: -0.5rem;
left: -14px;
left: -0.875rem;
width: 36px;
width: 2.25rem;
height: 36px;
height: 2.25rem;
opacity: 0;
}
.input-range-rv::-moz-range-thumb {
width: 8px;
width: 0.5rem;
height: 20px;
height: 1.25rem;
cursor: pointer;
border-radius: 3px;
border-color: transparent;
border-width: 0;
background-color: #333;
}
.input-range-rv::-webkit-slider-runnable-track {
height: 4px;
height: 0.25rem;
cursor: pointer;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.25);
}
.input-range-rv::-moz-range-track {
height: 4px;
height: 0.25rem;
cursor: pointer;
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.25);
}
.input-range-rv:focus {
outline: none;
} const { render } = ReactDOM;
const RangeControl = ({
name,
label,
value,
update,
className = '',
...props
}) => (
<div className={`mb4 ${className}`}>
<label htmlFor={name} className="f5 b db mb2 dark-gray">
{label} <span className="gray">({value})</span>
</label>
<input
name={name}
type="range"
className="input-range-rv flex-auto dark-gray w-100"
value={value}
onChange={e => update(name, e.target.value)}
{...props}
/>
</div>
);
const BooleanControl = ({
name,
label,
value,
update,
className = '',
...props
}) => (
<div className={`mb4 ${className}`}>
<p className="f5 dark-gray b mt0 mb2">
{label}
</p>
<div>
<label htmlFor={name} className="f5 dark-gray mr3">
<input
type="radio"
name={name}
value={1}
checked={value === 0}
onChange={e => update(name, 0)}
/>{' '}
0
</label>
<label htmlFor={name} className="f5 dark-gray">
<input
type="radio"
name={name}
value={1}
checked={value === 1}
onChange={e => update(name, 1)}
/>{' '}
1
</label>
</div>
</div>
);
const Controls = ({ className, controls, values, update }) => (
<div className={className}>
{controls.map(({ type, ...props }) =>
React.createElement(type, {
value: values[props.name],
key: props.name,
update,
...props,
}),
)}
</div>
);
const Arc = ({
'start-x': x1,
'start-y': y1,
rx,
ry,
'x-axis-rotation': xRot,
'large-arc-flag': largeArc,
'sweep-flag': sweep,
x: x2,
y: y2,
className,
}) => (
<div className={className}>
<svg viewBox="0 0 320 320" className="db w-100">
<defs>
<pattern id="grid" patternUnits="userSpaceOnUse" width="10" height="10">
<path
d="M 10 0 V 10 H -10"
stroke="#0f0" strokeWidth="1" fill="none" />
</pattern>
</defs>
<rect width="320" height="320" fill="url('#grid')"></rect>
<path d="M 320 0 H 0 V 320" fill="none" stroke="#0f0" strokeWidth="1" />
<g stroke="#333333"
fill="none"
strokeLinejoin="round"
strokeWidth="2">
<g stroke="#ccc">
<path
d={`M ${x1} ${y1} A ${rx} ${ry} ${xRot} 0 0 ${x2} ${y2}`}
/>
<path
d={`M ${x1} ${y1} A ${rx} ${ry} ${xRot} 1 1 ${x2} ${y2}`}
/>
<path
d={`M ${x1} ${y1} A ${rx} ${ry} ${xRot} 1 0 ${x2} ${y2}`}
/>
<path
d={`M ${x1} ${y1} A ${rx} ${ry} ${xRot} 0 1 ${x2} ${y2}`}
/>
</g>
<path
d={`M ${x1} ${y1} A ${rx} ${ry} ${xRot} ${largeArc} ${sweep} ${x2} ${y2}`}
/>
<circle cx={x1} cy={y1} r={2} fill="#fff" />
<circle cx={x2} cy={y2} r={2} fill="#fff" />
</g>
</svg>
<p className="mid-gray mt3 mb0 tc pa3 bg-light-gray purple">
{`M ${x1} ${y1} A ${rx} ${ry} ${xRot} ${largeArc} ${sweep} ${x2} ${y2}`}
</p>
</div>
);
class App extends React.Component {
state = {
values: {
'start-x': 120,
'start-y': 200,
rx: 75,
ry: 75,
'x-axis-rotation': -15,
'large-arc-flag': 0,
'sweep-flag': 1,
x: 200,
y: 120,
},
controls: [
{
name: 'large-arc-flag',
label: 'Large Arc Flag',
type: BooleanControl,
},
{
name: 'sweep-flag',
label: 'Sweep Flag',
type: BooleanControl,
},
{
name: 'rx',
label: 'rx',
type: RangeControl,
min: 0,
max: 320,
step: 1,
},
{
name: 'ry',
label: 'ry',
type: RangeControl,
min: 0,
max: 320,
step: 1,
},
{
name: 'x-axis-rotation',
label: 'x-Axis Rotation',
type: RangeControl,
min: 0,
max: 360,
step: 1,
},
{
name: 'start-x',
label: 'Start x',
type: RangeControl,
min: 0,
max: 320,
step: 1,
},
{
name: 'start-y',
label: 'Start y',
type: RangeControl,
min: 0,
max: 320,
step: 1,
},
{
name: 'x',
label: 'End x',
type: RangeControl,
min: 0,
max: 320,
step: 1,
},
{
name: 'y',
label: 'End y',
type: RangeControl,
min: 0,
max: 400,
step: 1,
},
],
};
update = (name, value) => {
this.setState(state => ({
values: {
...state.values,
[name]: value,
},
}));
};
render() {
const { controls, values } = this.state;
return (
<div className="code dark-gray mw8 center mt4 ph3">
<div className="flex flex-wrap mb5">
<Arc className="flex-auto mr0 mr5-ns mb5 mb0-ns" {...values} />
<Controls
controls={controls}
values={values}
update={this.update}
className="w-40-ns"
/>
</div>
</div>
);
}
}
render(<App />, document.querySelector('#app')); Reuleaux Polygons With SVG
The first step is to get the location of each vertex of the polygon. In a previous blog post, I explained how you can use polar coordinates to generate the vertices of a regular polygon. You can follow the same process to get the vertices of a Reuleaux polygon. The only difference is that instead of connecting those vertices with a line you need to connect them with arcs.
Compute the vertices of the polygon using the polygon generator.
const pts = polygon([cx, cy], sideCount, radius);The radius of the arcs will be the distance between the first and the third vertex.
const r = dist(pts[0], pts[2]);Draw the path. For Reuleaux polygons we need the arc to be less than 180 degrees and the
sweep-flagdirection should match the direction you define your points in (clockwise or anti-clockwise).
function reuleauxPolygonConstruction([cx, cy], radius, sideCount) {
const pts = polygon([cx, cy], sideCount, radius);
const r = dist(pts[0], pts[2]);
const [head, ...tail] = pts;
return [
// move to the first vertex
'M', head[0], head[1],
// Connect adjacent vertices with an arc
...tail.map(p => `A ${r} ${r} 0 0 1 ${p[0]} ${p[1]}`),
// Connect the last and the first vertices with an arc
`A ${r} ${r} 0 0 1 ${head[0]} ${head[1]}`,
// Close the shape
'Z',
].join(' ');
Curve Of Constant Width
Although it is possible to construct curvilinear polygons with even number of sides. Reuleaux polygons always have an odd number of sides because they are constant-width shapes. Therefore, every vertex must be able to associate an opposite arc. This is possible only if we have an odd number of arcs.
A curve of constant width is a convex planar shape whose width (defined as the perpendicular distance between two distinct parallel lines each having at least one point in common with the shape’s boundary but none with the shape’s interior) is the same regardless of the orientation of the curve.
Any curve of constant width can rotate within a square while staying within it at all times. Notice, how as it rotates, its axis does not stay fixed at a single point. It follows a curve around the center of the square.
<div class="flex flex-wrap justify-center items-center vh-100">
<div>
<svg
viewBox="0 0 400 400"
class="db w5 h5 center"
fill="none"
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="4">
<g transform="translate(200 200)">
<path
id="square"
stroke="#0f0"
d="M -155.885 -155.885 h 311.77 v 311.77 h -311.77 z"
/>
</g>
<circle cx="200" cy="200" r="4" stroke="#ccc" stroke-width="4" />
<g id="container">
<circle cx="0" cy="0" r="4" stroke="#f0f" stroke-width="4" />
<path
id="reuleaux-triangle"
stroke="#333"
d="M 90 155.88457268119896
A 311.77 311.77 0 0 1 -180 0
A 311.77 311.77 0 0 1 90 -155.885
A 311.77 311.77 0 0 1 90 155.885
Z"
/>
</g>
</svg>
<div class="mt4 flex items-center">
<button
class="bg-animate hover-bg-light-gray lh-solid f7 ttu tracked bg-none bw1 bg-white ba br-0 b--dark-gray tc pv2 ph2"
id="play">
play
</button>
<button
class="bg-animate hover-bg-light-gray lh-solid f7 ttu tracked bg-none bw1 bg-white ba b--dark-gray tc pv2 ph2 mr3"
id="pause">
pause
</button>
<input class="input-range-rv"
id="progress"
type="range" step=".001" type="range" min="0" max="100" value="0" />
</div>
</div>
</div> .input-range-rv {
vertical-align: middle;
background-color: transparent;
padding-top: 8px;
padding-top: 0.5rem;
padding-bottom: 8px;
padding-bottom: 0.5rem;
color: inherit;
background-color: transparent;
-webkit-appearance: none;
}
.input-range-rv::-webkit-slider-thumb {
position: relative;
width: 8px;
width: 0.5rem;
height: 20px;
height: 1.25rem;
cursor: pointer;
margin-top: -8px;
margin-top: -0.5rem;
border-radius: 3px;
background-color: #333;
-webkit-appearance: none;
}
/* Touch screen friendly pseudo element */
.input-range-rv::-webkit-slider-thumb:before {
content: '';
display: block;
position: absolute;
top: -8px;
top: -0.5rem;
left: -14px;
left: -0.875rem;
width: 36px;
width: 2.25rem;
height: 36px;
height: 2.25rem;
opacity: 0;
}
.input-range-rv::-moz-range-thumb {
width: 8px;
width: 0.5rem;
height: 20px;
height: 1.25rem;
cursor: pointer;
border-radius: 3px;
border-color: transparent;
border-width: 0;
background-color: #333;
}
.input-range-rv::-webkit-slider-runnable-track {
height: 4px;
height: 0.25rem;
cursor: pointer;
border-radius: 3px;
background-color: #ccc;
}
.input-range-rv::-moz-range-track {
height: 4px;
height: 0.25rem;
cursor: pointer;
border-radius: 3px;
background-color: #ccc;
}
.input-range-rv:focus {
outline: none;
} const triangle = document.querySelector('#reuleaux-triangle');
const container = document.querySelector('#container');
triangle.setAttribute('transform', `rotate(0)`);
container.setAttribute('transform', `translate(${200 + 24.115} 200)`);
const tl = anime({
targets: { beta: 0, theta: 0 },
theta: [0, 6 * Math.PI],
beta: [0, -360],
easing: 'linear',
duration: 8000,
loop: true,
// autoplay: false,
update: (anim) => {
const t = anim.animations[0].animatable.target.theta;
const b = anim.animations[0].animatable.target.beta;
const a = 24.115;
const n = 2.36185;
const x = Math.pow( Math.abs( Math.cos( t ) ), 2 / n ) * a * Math.sign( Math.cos( t ) );
const y = Math.pow( Math.abs( Math.sin( t ) ), 2 / n ) * a * Math.sign( Math.sin( t ) );
triangle.setAttribute('transform', `rotate(${b})`);
container.setAttribute('transform', `translate(${200 + x} ${200 + y})`);
progressEl.value = anim.progress;
}
});
var progressEl = document.querySelector('#progress');
document.querySelector('#play').onclick = tl.play;
document.querySelector('#pause').onclick = tl.pause;
progressEl.addEventListener('input', function() {
tl.seek(tl.duration * (progressEl.value / 100));
});
['input','change'].forEach(function(evt) {
progressEl.addEventListener(evt, function() {
tl.seek(tl.duration * (progressEl.value / 100));
});
}); This property of being able to rotate within a square is leveraged to create bits that drill a nearly square hole. The drill bit itself is a Reuleaux triangle and is mounted in a chuck that allows for the bit to rotate without having a fixed centre of rotation.
Morphing
Lastly, just for fun, here are some polygons morphing between their regular form and Reuleaux form using flubber.
<figure class="ma0 pa0 vh-100 flex flex-wrap justify-center items-center"
style="background-color: #A9D7FD;">
<svg
viewBox="0 0 400 400"
class="db w5 h5 center"
fill="none"
stroke="#2F3030"
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="8">
<path
id="reuleaux-fill"
transform="scale(1.05) translate(-6 -10)"
stroke="none"
fill="#ffffff"
d=""
/>
<path
id="reuleaux-polygon"
d=""
/>
</svg>
<figcaption class="clip">Morphing Reuleaux polygons</figcaption>
</figure> const { interpolate } = flubber;
const polygonStroke = document.querySelector('#reuleaux-polygon');
const polygonFill = document.querySelector('#reuleaux-fill');
const polygons = [3, 5, 7, 9]
.map(count => [
regularPolygon([200, 200], 180, count),
reuleauxPolygon([200, 200], 180, count),
])
.reduce((acc, v) => acc.concat(v), []);
const colors = ['#fff', '#fff', '#ED705C', '#ED705C', '#337EED', '#337EED', '#ffb700', '#ffb700'];
let idx = 1;
const next = () => {
idx = idx < polygons.length - 1 ? idx + 1 : 0;
}
const randomColor = (not) => {
const p = colors[Math.floor(Math.random() * colors.length)];
return p === not ? randomColor(not) : p;
};
let fromColor = '#fff';
let toColor = randomColor(fromColor);
let interpolator = flubber.interpolate(polygons[0], polygons[idx]);
const time = { t: 0 };
const animate = () =>
anime({
targets: [polygonFill, time],
t: [0, 1],
fill: colors[idx],
duration: 900,
easing: 'easeOutQuint',
delay: 600,
update: anim => {
const d = interpolator(time.t);
polygonStroke.setAttribute('d', d);
polygonFill.setAttribute('d', d);
},
complete: (anim) => {
next();
interpolator = flubber
.interpolate(
polygons[idx === 0 ? polygons.length - 1 : idx - 1],
polygons[idx],
);
fromColor = toColor;
toColor = randomColor(fromColor);
animate();
}
});
animate();
/**
* Polygon Math
*/
function pts(sideCount, radius) {
const angle = 360 / sideCount;
const vertexIndices = range(sideCount);
const offsetDeg = 90 - (180 - angle) / 2;
const offset = degreesToRadians(offsetDeg);
return vertexIndices.map(index => {
return {
theta: offset + degreesToRadians(angle * index),
r: radius,
};
});
}
function range(count) {
return Array.from(Array(count).keys());
}
function degreesToRadians(angleInDegrees) {
return Math.PI * angleInDegrees / 180;
}
function polygon([cx, cy], radius, sideCount) {
return pts(sideCount, radius).map(({ r, theta }) => [
cx + r * Math.cos(theta),
cy + r * Math.sin(theta),
]);
}
function dist([x1, y1], [x2, y2]) {
return ((x1 - x2) ** 2 + (y1 - y2) ** 2) ** 0.5;
}
function reuleauxPolygon([cx, cy], radius, sideCount) {
const pts = polygon([cx, cy], radius, sideCount);
const l = dist(pts[0], pts[2]);
const [o, ...rest] = pts;
return [
'M',
o[0],
o[1],
...rest.map(p => `A ${l} ${l} 0 0 1 ${p[0]} ${p[1]}`),
`A ${l} ${l} 0 0 1 ${o[0]} ${o[1]}`,
'Z',
].join(' ');
}
function regularPolygon([cx, cy], sideCount, radius) {
const [o, ...rest] = polygon([cx, cy], sideCount, radius);
return [
'M',
o[0],
o[1],
...rest.map(p => `L ${p[0]} ${p[1]}`),
'Z',
].join(' ');
}