Public
Edited
Feb 8
4 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
gear_canvas_1 = make_gear_canvas(canvas_default_width)
Insert cell
settings_1 = ({...settings_base, ...settings_extra, ...settings_flourish});
Insert cell
// this separate updater is important for the UI, it makes sure that the display
// doesn't get reinitialized (thus resetting its size)
gear_canvas_1_updater = {
update_gear_canvas(gear_canvas_1, settings_1);
}
Insert cell
animation_period_updater = {
function gcd(a,b) {
if (b > a) [a, b] = [b, a];
while (true) {
if (b == 0) return a;
a %= b;
if (a == 0) return b;
b %= a;
}
}
function lcm(a, b) { return a * b / gcd(a, b); }
var p = lcm(settings_base.tooth_count_1, settings_base.tooth_count_2);
animate_slider.period = p;
return p;
}
Insert cell
// this separate updater is important for the UI, it makes sure that the display
// doesn't get reinitialized (thus resetting its size)
gear_canvas_1_rotator = {
gear_canvas_1_updater; // must update first
draw_gears_on_canvas(gear_canvas_1, settings_1, rotate_1);
}
Insert cell
function get_involute_extra_dist(settings) {
// check for different tooth shape, undefined or NaN.
return settings.tooth_shape == "involute" ?
(settings.modify_involute || 0) + (settings.move_involute || 0) :
0;
}
Insert cell
function make_gear_canvas(chart_size) {
chart_size = Math.min(width, chart_size);
const dpr = window.devicePixelRatio;
const ctx = DOM.context2d(chart_size, chart_size, dpr);
return {ctx: ctx, node: ctx.canvas, chart_size: chart_size, dpr: dpr};
}
Insert cell
function make_gear_svg_canvas(chart_size) {
var ctx = new C2S(chart_size, chart_size);
return {ctx:ctx, dpr:1, chart_size: chart_size};
}
Insert cell
function update_gear_canvas(gear_canvas, settings) {

const {zoom_to, tooth_count_1, tooth_count_2, tooth_shape} = settings;
const {chart_size} = gear_canvas;

const tooth_count_1_corrected = tooth_count_1 + 2 * get_involute_extra_dist(settings);
var x, y, zoom;
if (zoom_to == "gear_both") {
zoom = 1 / (tooth_count_1_corrected + tooth_count_2 + 4);
x = 0.5 * (tooth_count_1_corrected + 4) * zoom;
}
else if (zoom_to == "gear_1") {
zoom = .995 / (tooth_count_1_corrected + 4);
x = 0.5;
}
else if (zoom_to == "gear_2") {
zoom = .995 / (tooth_count_2 + 4);
x = 0.5 * (5 - tooth_count_1_corrected) * zoom;
}
else if (zoom_to == "teeth_1"){
zoom = 0.040;
x = 0.5 - zoom * 0.5 * tooth_count_1_corrected;
}
else {
var extra_zoom = 1 + 2 / Math.min(tooth_count_1, tooth_count_2)
+ 1 / (6 + Math.max(tooth_count_1, tooth_count_2))
zoom = 0.10 * extra_zoom;
x = 0.5 - zoom * 0.5 * tooth_count_1_corrected;
}

gear_canvas.tooth_shape = tooth_shape;
gear_canvas.gear_1 = undefined;
gear_canvas.gear_2 = undefined;
gear_canvas.zoom = zoom;
gear_canvas.x1 = x;

try {
gear_canvas.gear_1 = new Gear(
{...settings,
tooth_shape: opposite_gear_shape(tooth_shape),
color: "#04f",
tooth_count: tooth_count_1, opposite_tooth_count: tooth_count_2});
}
catch {}

try {
gear_canvas.gear_2 = new Gear({...settings,
color: "#f62",
modify_involute: 0,
tooth_count: tooth_count_2, opposite_tooth_count: tooth_count_1});
}
catch {}
}
Insert cell
pixel_ratio = window.devicePixelRatio
Insert cell
function draw_gears_on_canvas(gear_canvas, settings, rotate) {
const ctx = gear_canvas.ctx;
var {x1, zoom, chart_size, gear_1, gear_2} = gear_canvas;
const {tooth_count_1, tooth_count_2, tooth_shape} = settings;
x1 *= chart_size;
const y = 0.5 * chart_size;
zoom *= chart_size;

var show = settings.show;
if (tooth_shape == "involute") {
show = show.filter(x => x != "loa")
}

// erase
ctx.fillStyle = "white";
ctx.clearRect(0, 0, ctx.canvas.width / gear_canvas.dpr, ctx.canvas.height / gear_canvas.dpr);
if (gear_1) {
var rot_rad = rotate * 2 * Math.PI / tooth_count_1;
gear_1.draw(ctx, x1, y, zoom, show, rot_rad, 0);
}

var between_axes = 0.5 * (tooth_count_1 + tooth_count_2) + get_involute_extra_dist(settings);
const x2 = x1 + zoom * between_axes;
var rot_extra = 0;

// calculate geometry quircks for involute meshing
var rot_extra = 0, sinPA, cosPA;
if (gear_1 && gear_2 && tooth_shape == "involute") {
({rot_extra, sinPA, cosPA} =
get_involute_extra_rotation(gear_1, gear_2, between_axes));
}
if (gear_2) {
var rotate_2 = tooth_count_2 / 2 - rotate - (gear_1.tooth_width + gear_2.tooth_width) / 2;
var rot_rad = rotate_2 * 2 * Math.PI / tooth_count_2 - rot_extra;
gear_2.draw(ctx, x2, y, zoom, show, rot_rad, Math.PI);
}

if (tooth_shape == "involute" && gear_1 && gear_2 &&
settings.show.includes("loa")) {
var p1 = {x: x1 + zoom * cosPA * gear_1.base,
y: y - zoom * sinPA * gear_1.base};
var p2 = {x: x2 - zoom * cosPA * gear_2.base,
y: y + zoom * sinPA * gear_2.base};
const line_width = 2 / ((1 + pixel_ratio));
ctx.lineWidth = 0.7 * line_width;
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = "#095";
ctx.lineWidth = line_width;
ctx.setLineDash([]);
ctx.stroke();

}
}
Insert cell
function get_involute_extra_rotation(gear_1, gear_2, between_axes)
{
if (gear_1.tooth_shape != "involute") return undefined;
// loa depends on distance between axes, it is the line which touches both base circles
// right triangle math
var total_base = gear_1.base + gear_2.base;
var cosPA = total_base / between_axes;
var sinPA = Math.sqrt(1 - cosPA * cosPA);
var modified_r = 1 / cosPA;

// rotation from involute root to touch poing on LOA:
// rotatet forward by actual pressure angle, rotate backwards by
// angle between involute root and pitch point
var actual_pa = Math.acos(cosPA);
var rot_to_modified_r = actual_pa - Math.sqrt(modified_r * modified_r - 1);

// to actually touch, the gears need slightly more rotation as they move further apart
var rot_1_extra = -gear_2.involute_root_angle + rot_to_modified_r;
var rot_2_extra = -gear_1.involute_root_angle + rot_to_modified_r;

return {rot_extra: rot_1_extra + gear_1.tooth_count / gear_2.tooth_count * rot_2_extra,
cosPA: cosPA,
sinPA: sinPA};
}
Insert cell
Gear = {

// This creates a gear, the module of the gear is one unit.
function _Gear(settings) {
const {tooth_shape = "involute",
hole_shape = "",
tooth_count, pressure_angle = 20} = settings;
const tw = .5;
const tooth_angle = 2 * Math.PI / tooth_count;
const radius = tooth_count * 0.5;
Object.assign(this,
settings,
{pitch: tooth_angle, tooth_count:tooth_count, tooth_width: tw});
if (tooth_shape == "lantern") {
// lantern gear, this is a simple series of circles
if (tooth_count < 4) throw RangeError("must have at least 4 teeth");
const l_radius = 0.25 * Math.PI; // 1/4 of the tooth pitch
const l_c = r_th_to_p(radius, -tooth_angle / 4);
const dd = 2.2 * l_radius;
const N = 50;
this.tooth_profile = [];
// we draw a solid circle inside the lantern bars with lines
// connecting them.
{
const N2 = 5;
const r = radius - dd;
const gap = Math.PI / N * l_radius / r;
const th_max = -tooth_angle / 4 + gap;
for (var i = -N2; i <= N2; ++i) {
this.tooth_profile.push(r_th_to_p(r, i / N2 * th_max));
}
}
{
for (var i = 0.5; i < N/2; ++i) {
var p1 = r_th_to_p(l_radius, i / N * 2*Math.PI + tooth_angle / 4);
this.tooth_profile.push({x: l_c.x - p1.x, y: l_c.y + p1.y});
}
}
Object.assign(this, {ad:l_radius, dd:dd,
undercut_radius: radius - l_radius});
}
else if (tooth_shape == "lantern-cycloid") {
if (tooth_count < 4) throw RangeError("must have at least 4 teeth");
const l_radius = 0.25 * Math.PI; // 1/4 of the tooth pitch
const dd = (settings.cycloid_round_bottom ? 1.05 : 1.2) * l_radius;
this.tooth_profile = [];
// undercut
{
const N = 30;
const r1 = radius - dd + l_radius;
const l_c = r_th_to_p(r1, tooth_angle / 4);
const th_max = Math.acos(0.5 * l_radius / r1);
if (settings.cycloid_round_bottom) {
for (var i = .5; i < N; ++i) {
var p1 = r_th_to_p(l_radius, -i/N * th_max - tooth_angle / 4);
this.tooth_profile.push({x: l_c.x - p1.x, y: l_c.y + p1.y});
}
}
else {
var p1 = r_th_to_p(l_radius / Math.cos(0.5 * th_max), -0.5 * th_max - tooth_angle / 4);
this.tooth_profile.push({x: l_c.x - p1.x, y: l_c.y + p1.y});
}
}
const r_other = settings.opposite_tooth_count / tooth_count * radius;
const lc = make_lantern_cycloid(radius, r_other, l_radius, tooth_angle, -Math.tan(tooth_angle / 4))
lc.points.forEach(p => this.tooth_profile.push(p));
Object.assign(this, {ad:2 * l_radius, dd:dd,
undercut_radius: radius, loa:lc.loa});
}
else if (tooth_shape == "cycloid") {
if (tooth_count < 2) throw RangeError("must have at least 4 teeth");
// these look a bit better with a slightly larger ad/dd.
// in practical gears these aren't fixed ratios.
const dd = Math.min(1.40, radius * .85);
const ad = 1.33;
const param1 = 2 - (pressure_angle - 5) / 15;
var cycloid = make_cycloid_tooth(radius, param1, dd, ad, tooth_count,
settings.opposite_tooth_count, settings.cycloid_round_bottom);
Object.assign(this, {ad:ad, dd:dd,
r_hc: cycloid.r_hc, r_ec: cycloid.r_ec, loa_end_th:cycloid.loa_end_th,
tooth_no_uc:cycloid.points_no_uc, tooth_profile: cycloid.points});
this.loa = [{x:radius, y:0}];
const r_hc = cycloid.r_hc;
const th_max = cycloid.loa_end_th;
for (var i = 0; i <= 20; ++i) {
var th = th_max * (i + 0) / 20;
var p1 = r_th_to_p(r_hc, th);
this.loa.push({x:radius - r_hc + p1.x, y:-p1.y});
}
var p1 = this.loa[this.loa.length - 1];
this.undercut_radius = Math.hypot(p1.x, p1.y);
}
else if (tooth_shape =="involute") {
if (tooth_count < 3) throw RangeError("must have at least 4 teeth");
const modify_involute = settings.modify_involute || 0;
// addendum and dedendum:
// this is apparently a de facto standard:
const ad = 1.00 + modify_involute; // equal to module
const dd_unm = 1.16; // 16% over the addendum
const dd = dd_unm - modify_involute;
const pressure_angle_rad = Math.max(5, pressure_angle) * Math.PI/180;
const involute = make_involute(radius, dd, ad, modify_involute, tooth_angle, pressure_angle_rad);
const undercut = make_undercut_involute(
radius, pressure_angle_rad, tooth_angle, dd, ad, dd_unm, modify_involute);
const combined = min_curve(undercut, involute.points, tooth_angle * 1e-5);
this.undercut_radius = combined.first_2 && Math.hypot(combined.first_2.x, combined.first_2.y);
Object.assign(this, {ad:ad, dd:dd, base: involute.base, involute_root_angle: involute.root_angle,
tooth_profile: combined.points, tooth_no_uc: involute.points,
pressure_angle_rad: pressure_angle_rad})
this.loa = [
{x:radius, y:0},
r_th_to_p(this.base, -this.pressure_angle_rad)]
}
else {
throw new RangeError("Not a known tooth shape: ", tooth_shape);
}
}

_Gear.prototype.iterate_points = function(f, i_start = 0, i_end) {
const {tooth_count} = this;
i_end ??= tooth_count;
const tooth_angle = 2 * Math.PI / tooth_count;
const profile = this.tooth_profile;
for (var i = i_start; i < i_end; ++i) {
var m = make_rot(tooth_angle * (i - this.tooth_width / 2));
profile.forEach(p => {
var p1 = tr(m, {x: p.x, y: -p.y});
f(p1);
});
var m = make_rot(tooth_angle * (i + this.tooth_width / 2));
profile.slice().reverse().forEach(p => {
var p1 = tr(m, p);
f(p1);
});
}
}
_Gear.prototype.draw_teeth = function(ctx, x, y, zoom, range, rot_rad) {
const {tooth_count} = this;
const [i_start, i_end] = range;
const radius = tooth_count * 0.5;

// set up transforms for this gear
ctx.save();
ctx.translate(x, y);
ctx.scale(zoom, zoom);
ctx.rotate(rot_rad);
// draw gear
const angle_start = (i_start - .75) * Math.PI * 2 / tooth_count;
const angle_end = (i_end - .75) * Math.PI * 2 / tooth_count;
ctx.beginPath();
var p = r_th_to_p(radius - this.dd - 2, angle_start);
ctx.moveTo(p.x, p.y);
p = r_th_to_p(radius - this.dd, angle_start);
ctx.lineTo(p.x, p.y);

this.iterate_points(p => ctx.lineTo(p.x, p.y), i_start, i_end);

p = r_th_to_p(radius - this.dd, angle_end);
ctx.lineTo(p.x, p.y);
p = r_th_to_p(radius - this.dd - 2, angle_end);
ctx.lineTo(p.x, p.y);
ctx.closePath();
const line_width = 2 / ((1 + pixel_ratio) * zoom);
ctx.lineWidth = line_width;
ctx.fillStyle = this.color;
ctx.strokeStyle = this.color;
ctx.globalAlpha = 0.2;
ctx.fill();
ctx.globalAlpha = 1;
ctx.stroke();

ctx.restore();
}
_Gear.prototype.draw = function(ctx, x, y, zoom, show, rot_rad, rot_rad_guides) {
const {tooth_count} = this;
const radius = tooth_count * 0.5;
const line_width = 2 / ((1 + pixel_ratio) * zoom);

// set up transforms for this gear
ctx.save();
ctx.translate(x, y);
ctx.scale(zoom, zoom);
// save before rotating
ctx.save();
ctx.rotate(rot_rad);
// draw gear
ctx.beginPath();
this.iterate_points(p => ctx.lineTo(p.x, p.y));
ctx.closePath();

const max_hole_r = (radius - this.dd) * .85;
switch (this.hole_shape)
{
case "": break;
case "round": draw_round_hole(ctx, max_hole_r); break;
case "square": draw_square_hole(ctx, max_hole_r); break;
case "keyed": draw_keyed_hole(ctx, max_hole_r); break;
case "cross": draw_axle_hole(ctx, max_hole_r); break;
}
ctx.lineWidth = line_width;
ctx.fillStyle = this.color;
ctx.strokeStyle = this.color;
ctx.globalAlpha = 0.2;
ctx.fill();
ctx.globalAlpha = 1;
ctx.stroke();
// draw other things
if (show.includes("pitch"))
{
ctx.beginPath();
ctx.arc(0, 0, radius, 0, 2 * Math.PI, false);
ctx.lineWidth = line_width;
ctx.strokeStyle = "#000";
ctx.setLineDash([2 * line_width, 6 * line_width]);
ctx.stroke();
}
if (show.includes("axes"))
{
const x1 = radius + this.ad;
ctx.beginPath();
ctx.moveTo(0, -x1);
ctx.lineTo(0, x1);
ctx.moveTo(-x1, 0);
ctx.lineTo(x1, 0);
ctx.strokeStyle = this.color;
ctx.lineWidth = line_width * .4;
ctx.setLineDash([10 * line_width, 2 * line_width]);
ctx.stroke();
}
// undo rotation
ctx.restore();
ctx.save();

ctx.rotate(rot_rad_guides);
if (this.base && show.includes("base"))
{
ctx.beginPath();
ctx.arc(0, 0, this.base, 0, 2 * Math.PI, false);
ctx.strokeStyle = "#095";
ctx.lineWidth = line_width * .7;
ctx.setLineDash([6 * line_width, 2 * line_width]);
ctx.stroke();
}
if (this.undercut_radius && show.includes("uc"))
{
ctx.beginPath();
ctx.arc(0, 0, this.undercut_radius, 0, 2 * Math.PI, false);
ctx.strokeStyle = "#f0f";
ctx.lineWidth = line_width * .7;
ctx.setLineDash([6 * line_width, 2 * line_width]);
ctx.stroke();
}
if (show.includes("ad_dd"))
{
ctx.beginPath();
ctx.arc(0, 0, radius - this.dd, 0, 2 * Math.PI, false);
ctx.strokeStyle = "#f80";
ctx.lineWidth = line_width * .5;
ctx.setLineDash([16 * line_width, 4 * line_width]);
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, radius + this.ad, 0, 2 * Math.PI, false);
ctx.stroke();
}
if (this.loa && show.includes("loa")) {
const loa = this.loa;
ctx.beginPath();
ctx.moveTo(loa[0].x, loa[0].y);
for (var i = 1; i < loa.length; ++i) {
ctx.lineTo(loa[i].x, loa[i].y);
}
ctx.strokeStyle = "#095";
ctx.lineWidth = line_width;
ctx.setLineDash([]);
ctx.stroke();
}
// undo rotation
ctx.restore();
// undo scale and offset
ctx.restore();
}
return _Gear;
}
Insert cell
function opposite_gear_shape(s) {
if (s == "lantern") return "lantern-cycloid";
if (s == "lantern-cycloid") return "lantern";
return s
}
Insert cell
function make_svg_gears(gear_canvas, settings) {
const {gear_1, gear_2} = gear_canvas;
const size_x = gear_1.tooth_count + gear_2.tooth_count + 4;
const size_y = Math.max(gear_1.tooth_count, gear_2.tooth_count) + 4;
const x1 = 2 + 0.5 * gear_1.tooth_count;
const dx2 = (gear_1.tooth_count + gear_2.tooth_count) / 2 + get_involute_extra_dist(settings);
const y = size_y / 2;

var inv_rot_extra = get_involute_extra_rotation(gear_1, gear_2, dx2);
var rot_extra = inv_rot_extra ? inv_rot_extra.rot_extra : 0;
rot_extra *= 180 / Math.PI;
rot_extra += 360 / gear_2.tooth_count * (gear_1.tooth_width + gear_2.tooth_width) / 2;
const to_fixed = f => Number.parseFloat(f).toFixed(3);
var path1 = "";
gear_canvas.gear_1.iterate_points(p => path1 += `L${to_fixed(p.x)} ${to_fixed(p.y)}`);
path1 = 'M' + path1.substring(1) + "z";

var path2 = "";
gear_canvas.gear_2.iterate_points(p => path2 += `L${to_fixed(p.x)} ${to_fixed(p.y)}`);
path2 = 'M' + path2.substring(1) + "z";


const zoom = Math.floor(Math.min(30, 2000 / size_x));
const stroke_w = 1 / zoom;
const dash_array = `${to_fixed(8 / zoom)} ${to_fixed(2 / zoom)} ${to_fixed(2 / zoom)} ${to_fixed(2 / zoom)}`;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size_x * zoom}" height="${size_y * zoom}" viewBox="${-x1} ${-y} ${size_x} ${size_y}">
<g id="gear-1" fill="#ccf" stroke="blue" stroke-width="${stroke_w}" >
<path d="${path1}" />
<circle cx="0" cy="0" r="${stroke_w}" fill="blue"/>
<line stroke="black" stroke-width="${stroke_w*.7}" stroke-dasharray="${dash_array}" stroke-linecap="butt"
x1="0" y1="${-gear_1.tooth_count/2 - 2}"
x2="0" y2="${+gear_1.tooth_count/2 + 2}"/>
<line stroke="black" stroke-width="${stroke_w*.7}" stroke-dasharray="${dash_array}" stroke-linecap="butt"
x1="${-gear_1.tooth_count/2 - 2}" y1="0"
x2="${gear_1.tooth_count/2 + 2}" y2="0"/>
</g>
<g id="gear-2" fill="#fcc" stroke="red" stroke-width="${stroke_w}" transform="translate(${dx2},0) rotate(${180 - rot_extra})">
<path d="${path2}" />
<circle cx="0" cy="0" r="${stroke_w}" fill="red"/>
<line stroke="black" stroke-width="${stroke_w*.7}" stroke-dasharray="${dash_array}" stroke-linecap="butt"
x1="0" y1="${-gear_2.tooth_count/2 - 2}"
x2="0" y2="${+gear_2.tooth_count/2 + 2}"/>
<line stroke="black" stroke-width="${stroke_w*.7}" stroke-dasharray="${dash_array}" stroke-linecap="butt"
x1="${-gear_2.tooth_count/2 - 2}" y1="0"
x2="${gear_2.tooth_count/2 + 2}" y2="0"/>
</g>
</svg>`;
}
Insert cell
show_parts = ({
pitch : "Pitch c.",
base : "Base c.",
loa : "Line of action",
ad_dd : "add/ded c.",
uc : "Undercut",
})
Insert cell
zoom_choice = ({
gear_1: "Gear 1",
gear_2: "Gear 2",
gear_both: "2 gears",
teeth_1: "Teeth",
teeth_2: "Teeth detail"
})
Insert cell
hole_choice = ({
"": "None",
round: "Round",
square: "Square",
keyed: "Keyed",
cross: "Cross axle"
})
Insert cell
tooth_shape = ({ involute: "Involute", cycloid: "Cycloid", lantern: "Lantern"})
Insert cell
canvas_default_width = 800
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
// make an involute teeth curve. The involute crosses the pitch circle at Y = modify_involute.
// and the "string" comes from the -Y direction.
function make_involute(rp, dedendum, addendum, modify, tooth_dth, pa)
{
// get the angle we have to roll our straight line to arrive at a certain
// radius
function rToTheta(rb, r1) {
// length of our tangent line
var len = Math.sqrt(r1*r1 - rb*rb);
// and apply radians definition
return len / rb;
}
// base radius
const rb = rp * Math.cos(pa);
// start/end radius
var r1 = Math.max(rp - dedendum, rb);
const r2 = rp + addendum;
// theta at pitch radius
const thP = pa;
// theta at start of involute curve
// (may be below r1, should be th <= 0)
const thAbs0 = pa - Math.sin(pa) * (rp + modify) / rb;

if (Math.cos(pa) * rb < rp - dedendum)
{
// we may drop another part of the involute for large gears:
// below the intersection between the pressure line and the addendum
// line of a rack meshing with the gear. (actually, we assume for the
// undercut that the rack addendum equals our dedendum).
// If this intersect lies below the tangent point of the base circle and
// the pressure line, we can drop the involute below this point.
// That part will never touch an involute on any other gear, so instead
// we may follow the the undercut curve.
const intersect_y = dedendum * Math.tan(Math.PI * .5 - pa)
r1 = Math.max(r1, Math.hypot(intersect_y, rp - dedendum));
}
// range of theta we have to cover (relative to thAbs0)
const th1 = rToTheta(rb, r1);
const th2 = rToTheta(rb, r2);
// angle where we intersect with the next tooth
const angle_slope = Math.tan(0.25 * tooth_dth);

var l = [];
const steps = 5 + Math.ceil((th2 - th1) / .03);
const dTh = (th2 - th1) / steps;
for (var i = 0; i <= steps; ++i)
{
const th = th1 + i * dTh;
const thAbs = thAbs0 + th
// tangent point
var p = {
x: Math.cos(thAbs) * rb,
y: -Math.sin(thAbs) * rb };
// arc length
var len = rb * th;
// and follow line segment
p.x += len * Math.sin(thAbs);
p.y += len * Math.cos(thAbs);
// make sure the tooth doesn't intersect with the next tooth
if (p.y < -angle_slope * p.x) {
p = intersect_segment(l[l.length - 1], p, -angle_slope);
l.push(p);
break;
}
else {
l.push(p);
}
}
return {points: l, base: rb, root_angle: thAbs0};
}

Insert cell
Insert cell
Insert cell
// Make a cycloid. If r2 is negative, the result is a hypocycloid.
// Stop if either we roll for a certain angle (th_max) or until we
// intersect with the line with a given slope (limit_slope)
// the cusp is at (r1, 0). If th_max is negative the generating
// circle will roll downwards.
function make_cycloid(r1, r2, th_max, limit_slope, N = 20) {
var l = [];
var has_point = false;
const d_th = th_max / N;
const sign = th_max < 0 ? -1 : 1;
for (var step = 0; step <= N; ++step)
{
var th = step * d_th;
var th_hc = th * r1 / r2;
var p1 = r_th_to_p(r1 + r2, th);
var p2 = r_th_to_p(r2, th + th_hc);
var p = {x: p1.x - p2.x, y: p1.y - p2.y};
if (sign * p.y > sign * limit_slope * p.x) {
if (l.length)
{
has_point = true;
p = intersect_segment(l[l.length - 1], p, limit_slope);
l.push(p);
}
break;
}
else {
l.push(p);
}
}
return {points: l, has_point: has_point};
}
Insert cell
// make a cycloid teeth curve. The curve is a combination of two cycloids (one inside, one
// outside the pitch circle). The curve crosses the pitch circle at Y = zero. The circles roll
// towards -Y.
// The undercut is is always assumed to start on the pitch circle, and it is shaped as a
// circular arc matching the lantern bars of the other gear.
// Strictly speaking, for small gears the undercut should be slightly higher, as there is a small
// amount of interference with the next tooth, but we do not take this into account.
function make_cycloid_tooth(rp, parameter, dedendum, addendum, teeth, other_teeth, round_bottom)
{
if (!teeth || !other_teeth) throw RangeError("must have non-zero amount of teeth");

const module = 2 * rp / teeth;
const rp_other = rp * other_teeth / teeth;
// choose radii for the cycloid circles
function get_r_h(t1, t2, rp_inner) {
// fixed points for parameter = 0 / 2 and 1:
const a = 2 / rp_inner;
const b = parameter < 1 ? (2 * t1) / rp_inner
: 1.0001 / rp_inner;
const t = Math.abs(parameter - 1);
return 1 / ((1 - t) * a + t * b);
}
const r_hc = get_r_h(teeth, other_teeth, rp);
const r_ec = get_r_h(other_teeth, teeth, rp_other);

// Calculate where the addendum of the other gear intersects
// our line of action for the hypocycloid
var x_intersect = intersect_circles(rp_other + r_hc, r_hc, rp_other + addendum);
const hc_loa_end_th = Math.acos(Math.max(-1, x_intersect / r_hc));

// tooth curve
var tooth_angle = Math.PI * 0.5 / teeth;
const angle_slope = Math.tan(tooth_angle);
// if the dedendum has a flat bottom spanning the tooth angle,
// and it touches the dedendum circle, this is the distance from the
// centre to the corner points.
const r_corner_dedendum = (rp - dedendum) / Math.cos(tooth_angle);
var l = [];
var has_undercut = false;
if (Math.abs(parameter - 1) < 1e-4)
{
// parameter == 1 case:
// The two circles we roll over our pitch circle
// are half of the radius of the two gears. This results in straight lines
// for the dedendum.
if (!round_bottom) l.push({x: r_corner_dedendum, y:0});
has_undercut = round_bottom;
}
else {
const dd_cos = rp - r_corner_dedendum;
// we stop when p reaches the addendum circle.
// First calculate the angles in our end state with the law of cosines.
// A: centre of rolling circle, B: centre of gear, C: end point of cycloid
var alpha = triangle_angle(rp - dd_cos, r_hc, rp - r_hc);
// rolling angle is outside this triangle at vertex A
var th_max_ad = r_hc / rp * (Math.PI - alpha) || Math.PI;
var th_max = Math.min(r_hc / rp * hc_loa_end_th, th_max_ad);
if (th_max != th_max_ad || round_bottom) {
has_undercut = true;
}
const hc = make_cycloid(rp, -r_hc, th_max, angle_slope);
if (!hc.has_point && !round_bottom) {
var p_crease = intersect_segment(hc.points[hc.points.length - 2], hc.points[hc.points.length - 1], angle_slope);
if (p_crease && p_crease.x > r_corner_dedendum && p_crease.x < rp) {
hc.points.push(p_crease);
}
else {
hc.points.push({x: r_corner_dedendum,
y: hc.points.length ? hc.points[hc.points.length - 1].y : 0});
}
}
hc.points.slice().reverse().forEach(p => l.push(p));
}

// we stop when p reaches the addendum circle.
// First calculate the angles in our end state with the law of cosines.
var alpha = triangle_angle(rp + addendum, r_ec, rp + r_ec) || Math.PI;
var th_max = r_ec / rp * alpha;
const ec = make_cycloid(rp, r_ec, -th_max, -angle_slope);
ec.points.forEach(p => l.push(p));

const l_no_uc = l;
if (has_undercut) {
// undercut curve
const th_undercut_start = hc_loa_end_th * r_hc / rp;
const undercut_corner = r_th_to_p(r_hc, hc_loa_end_th);
undercut_corner.x += rp - r_hc;
const slop = dedendum - addendum;
const undercut = make_undercut_circle(
rp, rp_other, th_undercut_start, undercut_corner, slop, round_bottom);
if (undercut[0]) {
l = min_curve(l, undercut, 0).points;
}
var i = 0;
while (l[i].y > angle_slope * l[i].x) ++i;
if (i > 0) {
l.splice(0, i, intersect_segment(l[i - 1], l[i], angle_slope));
}
}
return {points: l, points_no_uc: l_no_uc, r_ec: r_ec, r_hc: r_hc, loa_end_th: hc_loa_end_th};
}
Insert cell
Insert cell
// Make sort of, kind of, a cycloid. The curve is traced by
// the edge of a circle instead of a point.
// the curve isn't guaranteed to intersect the pitch circle at Z=0, instead
// it is based on a fixed start position of the lantern bar.
function make_lantern_cycloid(r_pitch, r_pitch_other, r_lantern, tooth_theta, limit_slope, N = 20) {
var l = [];
var loa = []
var has_point = false;
// initial position of lantern bar
const th1_start = tooth_theta / 4;

// we stop when the lantern centre reaches the addendum circle, plus
// one lantern radius on the other pitch circle.
// that is a bit approximate but good enough
// First calculate the angles in our end state with the law of cosines.
const ad = r_lantern * 2;
var alpha = triangle_angle(r_pitch + ad, r_pitch_other, r_pitch + r_pitch_other) || Math.PI;
var th1_end = r_pitch_other / r_pitch * (alpha + r_lantern / r_pitch_other);

const d_th = (th1_end - th1_start) / N;

// We rely here on a property of gears: the normal of the contact point
// goes through the pitch point. For the lantern bar this means the contact
// point is on the line between the lantern bar centre and the pitch point.
var p_dist = r_pitch;
var done = false;
for (var step = 0; step <= N && !done; ++step)
{
var d_th1 = step * d_th;
var th1 = th1_start + d_th1;
var th2 = Math.PI - r_pitch / r_pitch_other * th1;
// position of lantern bar
var l_p = r_th_to_p(r_pitch_other, th2);
l_p.x += r_pitch + r_pitch_other;
// the touch point is on the line to the pitch point (r_pitch, 0)
var loa_touch = {x: r_pitch - l_p.x, y: -l_p.y};
var loa_touch_scale = r_lantern / Math.hypot(loa_touch.x, loa_touch.y);
var p = {x: l_p.x + loa_touch.x * loa_touch_scale,
y: l_p.y + loa_touch.y * loa_touch_scale};
loa.push(p);
p = tr(make_rot(tooth_theta / 4 - th1), p);
const p_dist_prev = p_dist;
p_dist = Math.hypot(p.x, p.y);
if (p_dist > r_pitch + ad) {
var t = (r_pitch + ad - p_dist_prev) / (p_dist - p_dist_prev);
var p1 = l[l.length - 1];
p = {x: p1.x * (1-t) + p.x * t, y: p1.y * (1-t) + p.y * t};
done = true;
}
if (p.y < limit_slope * p.x) {
has_point = true;
p = intersect_segment(l[l.length - 1], p, limit_slope);
done = true;
}
l.push(p);
}
return {points: l, has_point: has_point, loa: loa};
}
Insert cell
Insert cell
Insert cell
Insert cell
function make_undercut_circle(r1, r2, th_1_start, corner_p, slop, everything) {
// make an undercut given a corner point where the undercut starts.
// corner_p must be given relative to gear 1, and must be on the
// line of action.
// corner relative to gear 2
const x2 = r1 + r2;
const p0_c_2 = {x:corner_p.x - x2, y:-corner_p.y};
var undercut = [];
const t_max = everything ? 1.001 : .501;
for (var t = 0; t < t_max; t += 0.02) {
var th_1 = (1 - t) * th_1_start; // gear 1: max to 0
var th_2 = t * th_1_start * r1 / r2; // gear 2: 0 to max
// rotation of gear 2
var p = tr(make_rot(-th_2), p0_c_2);
if (everything && p.y > 0) break;
// make relative to gear 1
p.x += x2;
// undo rotation of gear 1
p = tr(make_rot(th_1), p);
// add slop
p.x -= slop * t;
p.y -= slop * t;
if (!everything && p.y > 0) break;
undercut.push(p);
}
undercut.reverse();
return undercut;
}
Insert cell
Insert cell
function draw_round_hole(ctx, max_radius) {
const r = Math.min(1, max_radius);
ctx.moveTo(r, 0);
ctx.arc(0, 0, r, 2 * Math.PI, 0, true);
}
Insert cell
function draw_axle_hole(ctx, max_radius) {
// cross shaped axle, the size corresponds to that of axles in
// certain construction brick sets, assuming gears with a module
// of 1mm (aka 1/8th stud).
const rHole = Math.min(2.39, max_radius);
const x1 = 0.89; // half width of one arm
const r2 = 0.5; // radius for rounding between arms
const x2 = x1 + r2; // centre of the circle for that rounding
ctx.moveTo(rHole, 0);
if (rHole < x2 + r2)
{
ctx.arc(0, 0, rHole, 2 * Math.PI, 0, true);
}
else {
const a1 = Math.asin(x1 / rHole); // end angle for outer circle arc segments
const a90 = Math.PI / 2;
ctx.arc(0, 0, rHole, 0, -a1, true);
ctx.arc(x2, -x2, r2, a90, 2 * a90, false);
ctx.arc(0, 0, rHole, -a90 + a1, -a90 - a1, true);
ctx.arc(-x2, -x2, r2, 0, a90, false);
ctx.arc(0, 0, rHole, -2 * a90 + a1, -2 * a90 - a1, true);
ctx.arc(-x2, x2, r2, -a90, 0, false);
ctx.arc(0, 0, rHole, -3 * a90 + a1, -3 * a90 - a1, true);
ctx.arc(x2, x2, r2, -2 * a90, -a90, false);
ctx.arc(0, 0, rHole, -4 * a90 + a1, -4 * a90, true);
ctx.closePath();
}
}
Insert cell
function draw_keyed_hole(ctx, max_radius) {
// circular hole with square keys
if (max_radius < 1.1)
{
const r = Math.min(1, max_radius);
ctx.moveTo(r, 0);
ctx.arc(0, 0, r, 2 * Math.PI, 0, true);
}
else {
// x and y coordinates for the intersection fo the arc and the square
const y1 = .2;
const x1 = Math.sqrt(1 - y1*y1);
// x coorinate for the depth of the key
const x2 = Math.min(1.2, Math.sqrt(max_radius * max_radius - y1 * y1));
// start and end angle for the arc
const a1 = Math.asin(y1);
ctx.moveTo(x2, -y1);
ctx.lineTo(x1, -y1);
ctx.arc(0, 0, 1, 2 * Math.PI - a1, Math.PI + a1, true);
ctx.lineTo(-x1, -y1);
ctx.lineTo(-x2, -y1);
ctx.lineTo(-x2, y1);
ctx.lineTo(-x1, y1);
ctx.arc(0, 0, 1, Math.PI - a1, a1, true);
ctx.lineTo(x1, y1);
ctx.lineTo(x2, y1);
ctx.closePath();
}
}
Insert cell
function draw_square_hole(ctx, max_radius) {
// for a hole optically the same size as the circle hole, we need a bit less than 1 times that radius as the side.
const r = Math.min(1.25, max_radius);
ctx.moveTo(0, r);
ctx.lineTo(r, 0);
ctx.lineTo(0, -r);
ctx.lineTo(-r, 0);
ctx.closePath();
}
Insert cell
Insert cell
function r_th_to_p(r, th) {
var ct = Math.cos(th);
var st = Math.sin(th);
return {x: ct * r, y: st * r};
}
Insert cell
function make_rot(th) {
var ct = Math.cos(th);
var st = Math.sin(th);
return {a: ct, c: -st, e: 0,
b: st, d: ct, f: 0};
}
Insert cell
// ⎡a c⎤ ⎡p.x⎤ ⎡e⎤
// ⎣b d⎦ ⎣p.y⎦ + ⎣f⎦
function tr(matrix, point) {
return {
x: matrix.a * point.x + matrix.c * point.y + matrix.e,
y: matrix.b * point.x + matrix.d * point.y + matrix.f,
}
}
Insert cell
/// combines 2 curves: for each X coordinate, have the lowest Y coordinate of the two.
/// (approximately, we don't make up new points here).
/// both curves must be sorted by increasing X.
/// Returns:
/// - the combined curve
/// - the first point of points2, if any previous one was under points1 by more than the given tolerance.
function min_curve(points1, points2, tolerance) {
var i1 = 0;
var i2 = 0;
var p2_first = undefined;
var p2_dropped = false;
var result = [];
// non-overlapping region

for (; i1 < points1.length && points1[i1].x < points2[0].x; ++i1){
result.push(points1[i1]);
}
for (; i2 < points2.length && points2[i2].x < points1[0].x; ++i2){
result.push(points2[i2]);
}

function intersect_y(p1, p2, x)
{
var t = (x - p1.x) / (p2.x - p1.x);
return p1.y + t * (p2.y - p1.y);
}
// overlapping, take lowest Y
while (i1 < points1.length && i2 < points2.length) {
if (points1[i1].x < points2[i2].x) {
var i2_1 = Math.max(0, i2 - 1);
var y2 = intersect_y(points2[i2_1], points2[i2_1 + 1], points1[i1].x);
if (points1[i1].y < y2) {
result.push(points1[i1]);
}
++i1;
}
else {
var i1_1 = Math.max(0, i1 - 1);
var y1 = intersect_y(points1[i1_1], points1[i1_1 + 1], points2[i2].x);
if (points2[i2].y < y1) {
result.push(points2[i2]);
if (p2_dropped && p2_first == undefined) p2_first = points2[i2];
}
else if (points2[i2].y >= y1 + tolerance) {
p2_dropped = true
}
++i2;
}
}

// non-overlapping region
for (; i1 < points1.length; ++i1){
result.push(points1[i1]);
}
for (; i2 < points2.length; ++i2){
result.push(points2[i2]);
}
return {points: result, first_2: p2_first};
}
Insert cell
function intersect_segment(p1, p2, slope) {
var dy1 = slope * p1.x - p1.y;
var dy2 = slope * p2.x - p2.y;
var t = -dy1 / (dy2 - dy1);
var x = p1.x + (p2.x - p1.x) * t;
return {x: x, y: slope * x};
}
Insert cell
// returns the distance to the centre of the r1 circle.
function intersect_circles(d, r1, r2) {
var x_intersect = (d*d + r1*r1 - r2*r2) / (2 * d);
return x_intersect;
}
Insert cell
/// Angle of a triangle with three given sides. The angle of the corner opposite of a is returned.
function triangle_angle(a, b, c) {
return Math.acos((b*b + c*c - a*a) / (2 * b * c));
}
Insert cell
import { range, infiniteRange, animationSpeedSlider } from "@roelandschoukens/inputs"
Insert cell
// The original implementation was made by Gliffy Inc. but is no longer maintained
// The module we're loading here is a TypeScript fork
C2S = (await import("https://cdn.jsdelivr.net/npm/canvas-to-svg@1/+esm")).default
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more