mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Add Drive Ratio calculator (pulleys, gears, sprockets)
A new "Drive Ratio" calculator under /more/calculators for belt, gear, and
chain-sprocket drives, supporting a compound train of 2–6 wheels on a single
shared belt/chain (constant size·rpm across the train).
- Pure, unit-tested math: size·rpm solver, ratio, torque, surface/belt speed,
belt length, sprocket pitch diameter, chain length, gear pitch diameter.
- Mode toggle (Pulley/Gear/Sprocket) with imperial/metric units; torque in
lb·ft or N·m.
- Add/remove wheels (2–6), each with a colorblind-friendly color shared between
its inputs and the schematic SVG diagram (Paul Tol bright palette).
- Dependency-free inline-SVG diagram: colored wheels threaded by one belt/chain,
or meshing gear teeth; auto-fitting viewBox.
- Per-mode details: belt/chain length and center distance (2-wheel only, with an
info popup), per-wheel pitch diameters, and constant train speed.
- Guards against impossible geometry (wheels overlapping) and ideal-torque note.
- Integer teeth enforced in gear/sprocket modes; pulley diameters stay continuous.
Also fix Theme.Header dropping the theme's text color when a caller passes a
`style` prop (now merges {...t.style, ...props.style}).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9edfb0de25
commit
c38f90aae8
4 changed files with 984 additions and 1 deletions
|
|
@ -8,6 +8,7 @@ import {DipoleAntennaCalculator} from "./calculators/HamCalculators";
|
|||
import {Link} from "react-router";
|
||||
import {RatioCalculators} from "./calculators/RatioCalculator";
|
||||
import {QRCodeCalculator} from "./calculators/QRCodeCalculator";
|
||||
import {DriveCalculator} from "./calculators/DriveCalculator";
|
||||
|
||||
export const useCalculators = () => {
|
||||
const [calc, setCalc] = useCalcQuery();
|
||||
|
|
@ -15,6 +16,7 @@ export const useCalculators = () => {
|
|||
const calculators = [
|
||||
{key: 'ratio', icon: 'th large', button: 'Ratio', contents: <RatioCalculators/>},
|
||||
{key: 'electrical', icon: 'lightning', button: 'Electrical', contents: <ElectricalCalculators/>},
|
||||
{key: 'drive', icon: 'cogs', button: 'Drive Ratio', contents: <DriveCalculator/>},
|
||||
{key: 'antenna', icon: 'signal', button: 'Antenna', contents: <DipoleAntennaCalculator/>},
|
||||
{key: 'temperature', icon: 'thermometer', button: 'Temperature', contents: <TemperatureCalculator/>},
|
||||
{key: 'qrCode', icon: 'qrcode', button: 'QR Code', contents: <QRCodeCalculator/>},
|
||||
|
|
|
|||
|
|
@ -285,7 +285,9 @@ export function Divider(props: DividerProps) {
|
|||
|
||||
export function Header(props: HeaderProps) {
|
||||
const {t} = useContext(ThemeContext);
|
||||
return <SHeader {...t} {...props}/>
|
||||
// Merge the caller's style on top of the theme's text style; a bare `{...props}`
|
||||
// would let an incoming `style` prop replace (and drop) the theme's text color.
|
||||
return <SHeader {...t} {...props} style={{...t.style, ...props.style}}/>
|
||||
}
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
|
|
|
|||
660
app/src/components/calculators/DriveCalculator.js
Normal file
660
app/src/components/calculators/DriveCalculator.js
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
import React from "react";
|
||||
import {Form, Header, Segment, Table} from "../Theme";
|
||||
import {Button, GridColumn, GridRow, Input, Label, TableBody, TableCell, TableRow} from "semantic-ui-react";
|
||||
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
|
||||
import {ColoredInput} from "../Apps";
|
||||
import {InfoPopup, roundDigits, Toggle, useLocalStorage} from "../Common";
|
||||
import {Media} from "../../contexts/contexts";
|
||||
|
||||
// Pure calculation functions for the Drive Ratio calculator (pulleys, gears, sprockets).
|
||||
//
|
||||
// All three drive types obey one relation between a driver and a driven element:
|
||||
//
|
||||
// s1 * n1 = s2 * n2
|
||||
//
|
||||
// where `s` is the element "size" (pitch diameter for pulleys, tooth count for gears
|
||||
// and sprockets) and `n` is the rotational speed (RPM). The driven element spins
|
||||
// slower when it is larger, and its torque increases by the same ratio.
|
||||
//
|
||||
// These functions are intentionally unit-agnostic: callers must pass consistent units
|
||||
// (e.g. all lengths in inches, or all in millimeters) and the result is returned in
|
||||
// those same units. The React component is responsible for unit selection/display.
|
||||
|
||||
// Divide `a` by `b`, returning null when the result would not be a finite number.
|
||||
// This keeps empty/zero inputs from producing NaN or Infinity in the UI.
|
||||
const safeDiv = (a, b) => {
|
||||
const result = a / b;
|
||||
return Number.isFinite(result) ? result : null;
|
||||
};
|
||||
|
||||
// Solve for the one drive value the user has not recently edited, using s1*n1 = s2*n2.
|
||||
// `target` is the field to compute ('s1' | 's2' | 'n1' | 'n2'); the remaining three are
|
||||
// read from `values`. Returns the computed value, or null when it cannot be determined.
|
||||
export function computeDriveValue(target, {s1, s2, n1, n2}) {
|
||||
switch (target) {
|
||||
case 's1':
|
||||
return safeDiv(s2 * n2, n1);
|
||||
case 's2':
|
||||
return safeDiv(s1 * n1, n2);
|
||||
case 'n1':
|
||||
return safeDiv(s2 * n2, s1);
|
||||
case 'n2':
|
||||
return safeDiv(s1 * n1, s2);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Speed/torque ratio of the drive: driven size over driver size (s2 / s1).
|
||||
// Equals driverRPM / drivenRPM, and equals the factor by which torque is multiplied.
|
||||
export function driveRatio(s1, s2) {
|
||||
return safeDiv(s2, s1);
|
||||
}
|
||||
|
||||
// Output torque from an input torque, given the driver and driven sizes.
|
||||
// Torque is multiplied by the drive ratio (s2 / s1).
|
||||
export function torqueOut(torqueIn, s1, s2) {
|
||||
const ratio = driveRatio(s1, s2);
|
||||
return ratio === null ? null : torqueIn * ratio;
|
||||
}
|
||||
|
||||
// Surface (pitch-line) velocity of a rotating element: pi * diameter * rpm.
|
||||
// For a belt drive this is the belt speed; for gears/sprockets the pitch-line velocity.
|
||||
// Returns length-units per minute (matching the units of `diameter`).
|
||||
export function surfaceSpeed(diameter, rpm) {
|
||||
return Math.PI * diameter * rpm;
|
||||
}
|
||||
|
||||
// Surface speed in a human-friendly unit: ft/min (imperial) or m/s (metric).
|
||||
// `diameter` is in inches when metric is false, or millimeters when metric is true.
|
||||
// Returns {value, unit}, or null when it cannot be computed.
|
||||
export function surfaceSpeedDisplay(diameter, rpm, metric) {
|
||||
if (!(diameter > 0) || !(rpm > 0)) {
|
||||
return null;
|
||||
}
|
||||
const perMinute = surfaceSpeed(diameter, rpm); // in/min or mm/min
|
||||
return metric
|
||||
? {value: perMinute / 60000, unit: 'm/s'} // mm/min -> m/s
|
||||
: {value: perMinute / 12, unit: 'ft/min'}; // in/min -> ft/min
|
||||
}
|
||||
|
||||
// Length of an open belt around two pulleys of pitch diameters d1 and d2 whose centers
|
||||
// are `centerDistance` apart. Returns the belt length in the same units as the inputs,
|
||||
// or null when the geometry is impossible (centers closer than the pulleys touching).
|
||||
//
|
||||
// This is the standard shop/textbook approximation:
|
||||
// L = 2C + (pi/2)(d1 + d2) + (d2 - d1)^2 / (4C)
|
||||
// It is accurate when C is comfortably larger than (d1 + d2)/2 and degrades as the
|
||||
// centers tighten toward that limit, where the pulleys would touch.
|
||||
export function beltLength(d1, d2, centerDistance) {
|
||||
// Pulleys overlap when the centers are within the sum of their radii — no valid belt.
|
||||
if (!(d1 > 0) || !(d2 > 0) || !Number.isFinite(centerDistance) || centerDistance <= (d1 + d2) / 2) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
2 * centerDistance +
|
||||
(Math.PI / 2) * (d1 + d2) +
|
||||
Math.pow(d2 - d1, 2) / (4 * centerDistance)
|
||||
);
|
||||
}
|
||||
|
||||
// Pitch diameter of a chain sprocket with `teeth` teeth and a given chain `pitch`.
|
||||
// PD = pitch / sin(pi / teeth)
|
||||
export function chainPitchDiameter(teeth, pitch) {
|
||||
if (!Number.isFinite(teeth) || teeth < 1) {
|
||||
return null;
|
||||
}
|
||||
return safeDiv(pitch, Math.sin(Math.PI / teeth));
|
||||
}
|
||||
|
||||
// Exact chain length, expressed in pitches (links), for two sprockets t1/t2 teeth whose
|
||||
// centers are `centerDistance` apart, using a chain of the given `pitch`. Returns null
|
||||
// when the geometry is impossible (centers closer than the sprockets touching).
|
||||
// L = (t1 + t2)/2 + 2*(C/p) + ((t2 - t1)/(2*pi))^2 / (C/p)
|
||||
export function chainLengthPitches(t1, t2, centerDistance, pitch) {
|
||||
const pd1 = chainPitchDiameter(t1, pitch);
|
||||
const pd2 = chainPitchDiameter(t2, pitch);
|
||||
// Sprockets overlap when the centers are within the sum of their pitch radii.
|
||||
if (pd1 === null || pd2 === null || !Number.isFinite(centerDistance)
|
||||
|| centerDistance <= (pd1 + pd2) / 2) {
|
||||
return null;
|
||||
}
|
||||
const centerPitches = safeDiv(centerDistance, pitch);
|
||||
if (centerPitches === null || centerPitches <= 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
(t1 + t2) / 2 +
|
||||
2 * centerPitches +
|
||||
Math.pow((t2 - t1) / (2 * Math.PI), 2) / centerPitches
|
||||
);
|
||||
}
|
||||
|
||||
// Chain length rounded up to the nearest even number of pitches (chains join in pairs).
|
||||
export function chainLengthPitchesEven(t1, t2, centerDistance, pitch) {
|
||||
const exact = chainLengthPitches(t1, t2, centerDistance, pitch);
|
||||
return exact === null ? null : 2 * Math.ceil(exact / 2);
|
||||
}
|
||||
|
||||
// Pitch diameter of a metric gear: teeth * module (module in mm).
|
||||
export function gearPitchDiameterMetric(teeth, module) {
|
||||
return teeth * module;
|
||||
}
|
||||
|
||||
// Pitch diameter of an imperial gear: teeth / diametralPitch (teeth per inch).
|
||||
export function gearPitchDiameterImperial(teeth, diametralPitch) {
|
||||
return safeDiv(teeth, diametralPitch);
|
||||
}
|
||||
|
||||
// Center distance between two meshing gears (or any two tangent pitch circles):
|
||||
// half the sum of their pitch diameters.
|
||||
export function centerDistanceFromPitchDiameters(d1, d2) {
|
||||
return (d1 + d2) / 2;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagram geometry — pure helpers used to draw the schematic SVG. No new
|
||||
// dependencies: the component renders plain inline SVG from these numbers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The two external ("open", non-crossing) tangent lines of two circles.
|
||||
// Returns two segments, each {a: [x,y], b: [x,y]}, touching circle 1 then circle 2.
|
||||
// Used to draw a belt (pulleys) or chain (sprockets) wrapping both wheels.
|
||||
export function externalBeltTangents(x1, y1, r1, x2, y2, r2) {
|
||||
const d = Math.hypot(x2 - x1, y2 - y1);
|
||||
if (d === 0) {
|
||||
return null;
|
||||
}
|
||||
const gamma = Math.atan2(y2 - y1, x2 - x1);
|
||||
// (r1 - r2) / d can drift slightly outside [-1, 1] from rounding; clamp it.
|
||||
const ratio = Math.max(-1, Math.min(1, (r1 - r2) / d));
|
||||
const beta = Math.asin(ratio);
|
||||
const point = (cx, cy, r, angle) => [cx + r * Math.cos(angle), cy + r * Math.sin(angle)];
|
||||
const a1 = gamma + (Math.PI / 2 - beta);
|
||||
const a2 = gamma - (Math.PI / 2 - beta);
|
||||
return [
|
||||
{a: point(x1, y1, r1, a1), b: point(x2, y2, r2, a1)},
|
||||
{a: point(x1, y1, r1, a2), b: point(x2, y2, r2, a2)},
|
||||
];
|
||||
}
|
||||
|
||||
// SVG polygon points for a schematic toothed wheel (gear or sprocket).
|
||||
// Not an involute — `teeth` trapezoidal teeth alternating between rOuter (tip)
|
||||
// and rInner (root). `tipFrac` is the fraction of each tooth period spent at the tip.
|
||||
// Returns a string suitable for an SVG <polygon points={...}/>.
|
||||
export function gearPoints(cx, cy, rOuter, rInner, teeth, tipFrac = 0.5) {
|
||||
const count = Math.max(3, Math.round(teeth));
|
||||
const step = (2 * Math.PI) / count;
|
||||
const gap = (step * (1 - tipFrac)) / 2;
|
||||
const points = [];
|
||||
const push = (r, angle) =>
|
||||
points.push(`${(cx + r * Math.cos(angle)).toFixed(2)},${(cy + r * Math.sin(angle)).toFixed(2)}`);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const a0 = i * step;
|
||||
push(rInner, a0); // root before the tooth
|
||||
push(rOuter, a0 + gap); // rising flank
|
||||
push(rOuter, a0 + gap + step * tipFrac);// tooth tip
|
||||
push(rInner, a0 + step); // falling flank into next root
|
||||
}
|
||||
return points.join(' ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// React component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Per-mode wording. All three drive types share the same math; only the size
|
||||
// label (and the diagram) differ.
|
||||
const MODES = {
|
||||
pulley: {button: 'Pulley', icon: 'circle outline', sizeLabel: 'Diameter', toothed: false},
|
||||
gear: {button: 'Gear', icon: 'cogs', sizeLabel: 'Teeth', toothed: true},
|
||||
sprocket: {button: 'Sprocket', icon: 'cog', sizeLabel: 'Teeth', toothed: true},
|
||||
};
|
||||
|
||||
// Distinct, colorblind-friendly wheel colors (Paul Tol's bright palette):
|
||||
// blue, red, green, yellow, cyan, purple. Grey is reserved as a neutral.
|
||||
const ELEMENT_COLORS = ['#4477aa', '#ee6677', '#228833', '#ccbb44', '#66ccee', '#aa3377'];
|
||||
|
||||
const MIN_ELEMENTS = 2;
|
||||
const MAX_ELEMENTS = ELEMENT_COLORS.length; // 6 — one per palette color
|
||||
|
||||
// Placeholder sizes so the diagram is visible before the user types anything.
|
||||
const DEFAULT_DIAGRAM_SIZES = {
|
||||
pulley: [4, 8, 6, 10, 5, 7],
|
||||
toothed: [12, 24, 18, 30, 15, 21],
|
||||
};
|
||||
|
||||
// Explanation shown in the Center Distance info popup (pulley and sprocket modes).
|
||||
const CENTER_DISTANCE_INFO = 'The straight-line distance between the centers of the two shafts — '
|
||||
+ 'the driver and driven axles. It sets how far apart the wheels are mounted, which '
|
||||
+ 'determines the belt or chain length.';
|
||||
|
||||
// Disclaimer shown in the Output Torque info popup.
|
||||
const TORQUE_INFO = 'Ideal output torque, assuming no losses. Real belt, chain, and gear drives '
|
||||
+ 'lose a few percent to friction and slip, so actual output torque is somewhat lower.';
|
||||
|
||||
// Pick black or white label text for legibility on a given background color.
|
||||
function textColorFor(hex) {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.6 ? '#000000' : '#ffffff';
|
||||
}
|
||||
|
||||
const emptyElement = () => ({size: '', rpm: ''});
|
||||
const driveInitialState = {elements: [emptyElement(), emptyElement()], lastUpdated: []};
|
||||
|
||||
// Solve a single shared belt/chain train: every wheel rides one belt, so size*rpm is the
|
||||
// same constant K for all of them. The most recently edited wheel that has both a size
|
||||
// and an RPM anchors K; every other wheel's RPM follows from its size (or its size from
|
||||
// its RPM). Intermediate wheels set their own speed but do not change the overall
|
||||
// first-to-last ratio (they are idlers).
|
||||
//
|
||||
// Re-anchoring is intentional and supported: if a user later provides both size+ rpm on
|
||||
// a different wheel, that wheel becomes the new anchor and all other wheels (including
|
||||
// ones the user had previously entered values for) are re-solved from the new K.
|
||||
function solveTrain(elements, lastUpdated) {
|
||||
const num = (v) => (v === '' ? null : Number(v));
|
||||
const anchor = lastUpdated.find((i) =>
|
||||
elements[i] && num(elements[i].size) > 0 && num(elements[i].rpm) > 0);
|
||||
console.debug('[DriveCalc] solveTrain', {
|
||||
lastUpdated: [...lastUpdated],
|
||||
anchor,
|
||||
k: anchor !== undefined ? num(elements[anchor].size) * num(elements[anchor].rpm) : null
|
||||
});
|
||||
if (anchor === undefined) {
|
||||
return elements;
|
||||
}
|
||||
const k = num(elements[anchor].size) * num(elements[anchor].rpm);
|
||||
return elements.map((el, i) => {
|
||||
if (i === anchor) {
|
||||
return el;
|
||||
}
|
||||
const size = num(el.size), rpm = num(el.rpm);
|
||||
if (size > 0) {
|
||||
return {...el, rpm: String(roundDigits(k / size, 4))};
|
||||
}
|
||||
if (rpm > 0) {
|
||||
return {...el, size: String(roundDigits(k / rpm, 4))};
|
||||
}
|
||||
return el;
|
||||
});
|
||||
}
|
||||
|
||||
function driveReducer(state, action) {
|
||||
if (action.type === 'add') {
|
||||
if (state.elements.length >= MAX_ELEMENTS) {
|
||||
return state;
|
||||
}
|
||||
return {...state, elements: [...state.elements, emptyElement()]};
|
||||
}
|
||||
if (action.type === 'remove') {
|
||||
if (state.elements.length <= MIN_ELEMENTS) {
|
||||
return state;
|
||||
}
|
||||
const elements = state.elements.filter((_, i) => i !== action.index);
|
||||
// Drop the removed index from the edit history and shift higher indices down.
|
||||
const lastUpdated = state.lastUpdated
|
||||
.filter((i) => i !== action.index)
|
||||
.map((i) => (i > action.index ? i - 1 : i));
|
||||
return {...state, elements: solveTrain(elements, lastUpdated), lastUpdated};
|
||||
}
|
||||
if (action.type === 'field') {
|
||||
const {index, field, value} = action;
|
||||
// Negative sizes/speeds are meaningless here; clamp to a positive magnitude.
|
||||
const clamped = value === '' ? '' : String(Math.abs(Number(value)));
|
||||
const elements = state.elements.map((el, i) =>
|
||||
i === index ? {...el, [field]: clamped} : el);
|
||||
const lastUpdated = [index, ...state.lastUpdated.filter((i) => i !== index)];
|
||||
console.debug('[DriveCalc] field', {index, field, value: clamped, lastUpdated: [...lastUpdated]});
|
||||
// Only update the field the user is actively typing. Solving is debounced in the component.
|
||||
return {...state, elements, lastUpdated};
|
||||
}
|
||||
if (action.type === 'solve') {
|
||||
return {...state, elements: solveTrain(state.elements, state.lastUpdated)};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
// Schematic, dependency-free SVG of the drive train. Draws one colored wheel per
|
||||
// element, connected by a single belt/chain (or meshing teeth for gears). Sizes fall
|
||||
// back to placeholders so a diagram is always visible, even before the user types.
|
||||
function DriveDiagram({mode, sizes}) {
|
||||
const toothed = MODES[mode].toothed;
|
||||
const defaults = toothed ? DEFAULT_DIAGRAM_SIZES.toothed : DEFAULT_DIAGRAM_SIZES.pulley;
|
||||
const nums = sizes.map((s, i) => (Number(s) > 0 ? Number(s) : defaults[i % defaults.length]));
|
||||
const maxSize = Math.max(...nums);
|
||||
|
||||
// Map each size onto a drawn pitch radius, keeping small wheels visible.
|
||||
const MAX_R = 60, MIN_R = 22, TOOTH = 6, PAD = 16;
|
||||
const radii = nums.map((s) => MIN_R + (MAX_R - MIN_R) * (s / maxSize));
|
||||
const extent = (r) => (toothed ? r + TOOTH : r);
|
||||
const maxExtent = Math.max(...radii.map(extent));
|
||||
const cy = PAD + maxExtent;
|
||||
|
||||
// Lay wheels left to right. Gears mesh (pitch circles touch); belts/chains leave a gap.
|
||||
const gap = mode === 'gear' ? 0 : 54;
|
||||
const centers = [PAD + extent(radii[0])];
|
||||
for (let i = 1; i < radii.length; i++) {
|
||||
centers.push(centers[i - 1] + radii[i - 1] + radii[i] + gap);
|
||||
}
|
||||
const width = centers[centers.length - 1] + extent(radii[radii.length - 1]) + PAD;
|
||||
const height = cy + maxExtent + PAD;
|
||||
|
||||
const wheel = (cx, r, size, color) => {
|
||||
if (toothed) {
|
||||
const teeth = Math.max(6, Math.min(40, Math.round(size)));
|
||||
return <g>
|
||||
<polygon points={gearPoints(cx, cy, r + TOOTH, r - TOOTH, teeth)}
|
||||
fill={color} stroke={color} strokeWidth={1} fillOpacity={0.9}/>
|
||||
<circle cx={cx} cy={cy} r={Math.max(6, r * 0.22)} fill='none' stroke='#fff' strokeWidth={2}/>
|
||||
</g>;
|
||||
}
|
||||
return <g>
|
||||
<circle cx={cx} cy={cy} r={r} fill={color} fillOpacity={0.9} stroke={color}/>
|
||||
<circle cx={cx} cy={cy} r={Math.max(5, r * 0.2)} fill='#fff'/>
|
||||
</g>;
|
||||
};
|
||||
|
||||
return <svg viewBox={`0 0 ${width} ${height}`} width='100%'
|
||||
style={{maxWidth: `${width}px`, height: 'auto'}}>
|
||||
{mode !== 'gear' && centers.slice(1).map((_, idx) => {
|
||||
// Belt/chain between each adjacent pair of wheels, drawn behind the wheels.
|
||||
const segs = externalBeltTangents(centers[idx], cy, radii[idx], centers[idx + 1], cy, radii[idx + 1]);
|
||||
return segs && segs.map((seg, j) =>
|
||||
<line key={`${idx}-${j}`} x1={seg.a[0]} y1={seg.a[1]} x2={seg.b[0]} y2={seg.b[1]}
|
||||
stroke='#999' strokeWidth={mode === 'sprocket' ? 5 : 7} strokeLinecap='round'/>);
|
||||
})}
|
||||
{centers.map((cx, i) =>
|
||||
<g key={i}>{wheel(cx, radii[i], nums[i], ELEMENT_COLORS[i % ELEMENT_COLORS.length])}</g>)}
|
||||
</svg>;
|
||||
}
|
||||
|
||||
// Format the drive ratio as "N : 1" (reduction) or "1 : N" (overdrive).
|
||||
function formatRatio(ratio) {
|
||||
if (ratio === null || !Number.isFinite(ratio) || ratio <= 0) {
|
||||
return '—';
|
||||
}
|
||||
return ratio >= 1
|
||||
? `${roundDigits(ratio, 3)} : 1`
|
||||
: `1 : ${roundDigits(1 / ratio, 3)}`;
|
||||
}
|
||||
|
||||
// Format a positive length/number with a unit, or an em-dash when unavailable.
|
||||
function fmt(value, unit) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '—';
|
||||
}
|
||||
return unit ? `${roundDigits(value, 3)} ${unit}` : `${roundDigits(value, 3)}`;
|
||||
}
|
||||
|
||||
// Format a {value, unit} surface-speed result, or an em-dash when unavailable.
|
||||
function fmtSpeed(speed) {
|
||||
return speed ? `${roundDigits(speed.value, 3)} ${speed.unit}` : '—';
|
||||
}
|
||||
|
||||
export function DriveCalculator() {
|
||||
const [mode, setMode] = React.useState('pulley');
|
||||
const [metric, setMetric] = useLocalStorage('drive_calculator_metric', false);
|
||||
const [state, dispatch] = React.useReducer(driveReducer, driveInitialState);
|
||||
const [torqueIn, setTorqueIn] = React.useState('');
|
||||
// Mode-specific auxiliary inputs (do not participate in the size*rpm solver).
|
||||
const [centerDistance, setCenterDistance] = React.useState('');
|
||||
const [gearTooth, setGearTooth] = React.useState(''); // module (metric) or diametral pitch (imperial)
|
||||
const [chainPitch, setChainPitch] = React.useState('');
|
||||
|
||||
// Debounce solving so that typing a multi-digit number doesn't cause constant
|
||||
// re-solving + overwriting of other wheels with partial K values.
|
||||
const solveTimeoutRef = React.useRef(null);
|
||||
const scheduleSolve = React.useCallback(() => {
|
||||
if (solveTimeoutRef.current) clearTimeout(solveTimeoutRef.current);
|
||||
solveTimeoutRef.current = setTimeout(() => {
|
||||
dispatch({ type: 'solve' });
|
||||
}, 280);
|
||||
}, [dispatch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (solveTimeoutRef.current) clearTimeout(solveTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {sizeLabel, button, toothed} = MODES[mode];
|
||||
const {elements} = state;
|
||||
const twoElements = elements.length === 2;
|
||||
console.debug('[DriveCalc] render', elements.map((e, i) => ({i, size: e.size, rpm: e.rpm})), 'lastUpdated:', state.lastUpdated);
|
||||
const lengthUnit = metric ? 'mm' : 'in';
|
||||
const torqueUnit = metric ? 'N·m' : 'lb·ft';
|
||||
// Pulley diameters carry a length unit; gear/sprocket teeth are unitless.
|
||||
const sizeUnit = mode === 'pulley' ? ` (${lengthUnit})` : '';
|
||||
|
||||
// Overall ratio is the first wheel -> the last wheel (intermediate wheels are idlers).
|
||||
const firstSize = Number(elements[0].size);
|
||||
const lastSize = Number(elements[elements.length - 1].size);
|
||||
const ratio = driveRatio(firstSize, lastSize);
|
||||
const outTorque = torqueIn !== '' ? torqueOut(Number(torqueIn), firstSize, lastSize) : null;
|
||||
|
||||
const inputProps = {
|
||||
fluid: true,
|
||||
type: 'number',
|
||||
onSelect: e => e.target.select(),
|
||||
autoComplete: 'off',
|
||||
};
|
||||
// Each wheel's inputs use its diagram color so the form and the picture line up.
|
||||
const coloredField = (hex, labelText, value, onChange, extraProps = {}, name) =>
|
||||
<Input {...inputProps} {...extraProps} name={name} labelPosition='left' value={value} onChange={onChange}
|
||||
label={<Label style={{backgroundColor: hex, color: textColorFor(hex), borderColor: hex}}>
|
||||
{labelText}</Label>}/>;
|
||||
// Auxiliary (non-solver) numeric input.
|
||||
const auxInput = (label, value, setter, name) =>
|
||||
<ColoredInput {...inputProps} name={name} label={label} value={value}
|
||||
onChange={e => setter(e.target.value)}/>;
|
||||
|
||||
// Pitch diameter of a wheel given the mode's extra parameter. Pulley size IS the
|
||||
// pitch diameter; gears need a module/diametral-pitch, sprockets a chain pitch.
|
||||
const pitchDiameter = (size) => {
|
||||
if (mode === 'gear') {
|
||||
return metric ? gearPitchDiameterMetric(size, Number(gearTooth))
|
||||
: gearPitchDiameterImperial(size, Number(gearTooth));
|
||||
}
|
||||
if (mode === 'sprocket') {
|
||||
return chainPitchDiameter(size, Number(chainPitch));
|
||||
}
|
||||
return size;
|
||||
};
|
||||
|
||||
// Belt/chain speed is constant across the train; compute it from any complete wheel.
|
||||
const speedEl = elements.find(el => Number(el.size) > 0 && Number(el.rpm) > 0);
|
||||
const trainSpeed = speedEl
|
||||
? surfaceSpeedDisplay(pitchDiameter(Number(speedEl.size)), Number(speedEl.rpm), metric)
|
||||
: null;
|
||||
|
||||
const modeButtons = <Button.Group style={{marginBottom: '1em', flexWrap: 'wrap'}}>
|
||||
{Object.entries(MODES).map(([key, m]) =>
|
||||
<Button key={key} type='button' icon={m.icon} content={m.button}
|
||||
primary={mode === key} onClick={() => setMode(key)}/>)}
|
||||
</Button.Group>;
|
||||
|
||||
// One row of inputs per wheel: size, RPM, and a remove button (above the minimum).
|
||||
const elementRows = elements.map((el, i) => {
|
||||
const hex = ELEMENT_COLORS[i % ELEMENT_COLORS.length];
|
||||
const onField = (field) => (e) => {
|
||||
dispatch({type: 'field', index: i, field, value: e.target.value});
|
||||
scheduleSolve();
|
||||
};
|
||||
// Teeth are whole numbers; pulley diameters stay continuous.
|
||||
const onSize = toothed
|
||||
? (e) => {
|
||||
dispatch({type: 'field', index: i, field: 'size',
|
||||
value: e.target.value === '' ? '' : String(Math.round(Math.abs(Number(e.target.value))))});
|
||||
scheduleSolve();
|
||||
}
|
||||
: onField('size');
|
||||
return {
|
||||
hex,
|
||||
sizeField: coloredField(hex, `#${i + 1} ${sizeLabel}${sizeUnit}`, el.size, onSize,
|
||||
toothed ? {step: 1, min: 1} : {}, `size-${i}`),
|
||||
rpmField: coloredField(hex, `#${i + 1} RPM`, el.rpm, onField('rpm'), {}, `rpm-${i}`),
|
||||
removeBtn: elements.length > MIN_ELEMENTS
|
||||
? <Button type='button' basic color='red' icon='close' size='small'
|
||||
aria-label={`Remove #${i + 1}`} onClick={() => dispatch({type: 'remove', index: i})}/>
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
const addButton = <Button type='button' icon='plus' primary content={`Add ${button}`}
|
||||
disabled={elements.length >= MAX_ELEMENTS}
|
||||
onClick={() => dispatch({type: 'add'})}
|
||||
style={{marginTop: '0.5em'}}/>;
|
||||
|
||||
// Small colored swatch matching a wheel, used to label its detail rows.
|
||||
const swatch = (hex) => <span style={{
|
||||
display: 'inline-block', width: '0.8em', height: '0.8em', borderRadius: '2px',
|
||||
backgroundColor: hex, marginRight: '0.5em', verticalAlign: 'middle',
|
||||
}}/>;
|
||||
|
||||
// Per-wheel pitch-diameter rows (gear/sprocket only).
|
||||
const pitchDiameterRows = toothed ? elements.map((el, i) =>
|
||||
<TableRow key={i}>
|
||||
<TableCell>{swatch(ELEMENT_COLORS[i % ELEMENT_COLORS.length])}#{i + 1} Pitch Diameter</TableCell>
|
||||
<TableCell>{fmt(pitchDiameter(Number(el.size)), lengthUnit)}</TableCell>
|
||||
</TableRow>) : null;
|
||||
|
||||
// Pairwise belt/chain length and center distance only make sense for a two-wheel
|
||||
// drive; with 3+ wheels they depend on the physical layout, so they are omitted.
|
||||
let detailRows;
|
||||
if (mode === 'pulley') {
|
||||
const belt = twoElements ? beltLength(firstSize, lastSize, Number(centerDistance)) : null;
|
||||
detailRows = <>
|
||||
{twoElements && <>
|
||||
<TableRow>
|
||||
<TableCell width={6}>Center Distance <InfoPopup content={CENTER_DISTANCE_INFO}/></TableCell>
|
||||
<TableCell>{auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Belt Length</TableCell>
|
||||
<TableCell>{fmt(belt, lengthUnit)}</TableCell>
|
||||
</TableRow>
|
||||
</>}
|
||||
<TableRow>
|
||||
<TableCell width={6}>Belt Speed</TableCell>
|
||||
<TableCell>{fmtSpeed(trainSpeed)}</TableCell>
|
||||
</TableRow>
|
||||
</>;
|
||||
} else if (mode === 'gear') {
|
||||
const paramLabel = metric ? 'Module (mm)' : 'Diametral Pitch (1/in)';
|
||||
const pd1 = pitchDiameter(firstSize), pd2 = pitchDiameter(lastSize);
|
||||
const cd = twoElements && pd1 > 0 && pd2 > 0 ? centerDistanceFromPitchDiameters(pd1, pd2) : null;
|
||||
detailRows = <>
|
||||
<TableRow>
|
||||
<TableCell width={6}>{paramLabel}</TableCell>
|
||||
<TableCell>{auxInput(paramLabel, gearTooth, setGearTooth, "gearParam")}</TableCell>
|
||||
</TableRow>
|
||||
{pitchDiameterRows}
|
||||
{twoElements && <TableRow>
|
||||
<TableCell>Center Distance</TableCell>
|
||||
<TableCell>{fmt(cd, lengthUnit)}</TableCell>
|
||||
</TableRow>}
|
||||
<TableRow>
|
||||
<TableCell>Pitch-Line Velocity</TableCell>
|
||||
<TableCell>{fmtSpeed(trainSpeed)}</TableCell>
|
||||
</TableRow>
|
||||
</>;
|
||||
} else { // sprocket
|
||||
const pitch = Number(chainPitch);
|
||||
const links = twoElements && pitch > 0
|
||||
? chainLengthPitchesEven(firstSize, lastSize, Number(centerDistance), pitch)
|
||||
: null;
|
||||
detailRows = <>
|
||||
<TableRow>
|
||||
<TableCell width={6}>Chain Pitch</TableCell>
|
||||
<TableCell>{auxInput(`Chain Pitch (${lengthUnit})`, chainPitch, setChainPitch, "chainPitch")}</TableCell>
|
||||
</TableRow>
|
||||
{twoElements && <TableRow>
|
||||
<TableCell>Center Distance <InfoPopup content={CENTER_DISTANCE_INFO}/></TableCell>
|
||||
<TableCell>{auxInput(`Center Distance (${lengthUnit})`, centerDistance, setCenterDistance, "centerDistance")}</TableCell>
|
||||
</TableRow>}
|
||||
{pitchDiameterRows}
|
||||
{twoElements && <TableRow>
|
||||
<TableCell>Chain Length</TableCell>
|
||||
<TableCell>{links === null ? '—' : `${links} pitches`}</TableCell>
|
||||
</TableRow>}
|
||||
<TableRow>
|
||||
<TableCell>Chain Speed</TableCell>
|
||||
<TableCell>{fmtSpeed(trainSpeed)}</TableCell>
|
||||
</TableRow>
|
||||
</>;
|
||||
}
|
||||
|
||||
return <Form>
|
||||
<Header as='h1'>Drive Ratio</Header>
|
||||
|
||||
<div style={{marginBottom: '1em'}}>{modeButtons}</div>
|
||||
<div style={{marginBottom: '1em'}}>
|
||||
<Toggle label={metric ? 'Metric (mm)' : 'Imperial (in)'}
|
||||
checked={metric} onChange={() => setMetric(!metric)}/>
|
||||
</div>
|
||||
|
||||
<Media at='mobile'>
|
||||
{elementRows.map(({sizeField, rpmField, removeBtn}, i) =>
|
||||
<div key={i} style={{marginBottom: '0.5em'}}>
|
||||
<div style={{marginBottom: '0.25em'}}>{sizeField}</div>
|
||||
<div style={{marginBottom: '0.25em'}}>{rpmField}</div>
|
||||
{removeBtn}
|
||||
{i < elementRows.length - 1 && <hr/>}
|
||||
</div>)}
|
||||
</Media>
|
||||
|
||||
<Media greaterThanOrEqual='tablet'>
|
||||
<Grid verticalAlign='middle'>
|
||||
{elementRows.map(({sizeField, rpmField, removeBtn}, i) =>
|
||||
<GridRow key={i}>
|
||||
<GridColumn width={7}>{sizeField}</GridColumn>
|
||||
<GridColumn width={7}>{rpmField}</GridColumn>
|
||||
<GridColumn width={2}>{removeBtn}</GridColumn>
|
||||
</GridRow>)}
|
||||
</Grid>
|
||||
</Media>
|
||||
|
||||
<div>{addButton}</div>
|
||||
|
||||
<Segment style={{textAlign: 'center', overflowX: 'auto'}}>
|
||||
<DriveDiagram mode={mode} sizes={elements.map(el => el.size)}/>
|
||||
</Segment>
|
||||
|
||||
<Table definition unstackable>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell width={6}>Drive Ratio{' '}
|
||||
<span style={{fontWeight: 'normal', opacity: 0.7}}>(#1 → #{elements.length})</span>
|
||||
</TableCell>
|
||||
<TableCell>{formatRatio(ratio)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Torque Multiplier</TableCell>
|
||||
<TableCell>{ratio === null ? '—' : `${roundDigits(ratio, 3)}×`}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Output Torque <InfoPopup content={TORQUE_INFO}/></TableCell>
|
||||
<TableCell>
|
||||
<ColoredInput {...inputProps} name="torqueIn" label={`Input Torque (${torqueUnit})`}
|
||||
value={torqueIn}
|
||||
onChange={e => setTorqueIn(e.target.value)}/>
|
||||
{outTorque !== null && <span style={{marginLeft: '1em'}}>
|
||||
= <b>{roundDigits(outTorque, 3)} {torqueUnit}</b>
|
||||
</span>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Header as='h3' style={{marginTop: '1em'}}>{button} Details</Header>
|
||||
<Table definition unstackable>
|
||||
<TableBody>{detailRows}</TableBody>
|
||||
</Table>
|
||||
</Form>;
|
||||
}
|
||||
|
||||
// Exported for testing
|
||||
export {driveReducer};
|
||||
319
app/src/components/calculators/DriveCalculator.test.js
Normal file
319
app/src/components/calculators/DriveCalculator.test.js
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
import {
|
||||
beltLength,
|
||||
centerDistanceFromPitchDiameters,
|
||||
chainLengthPitches,
|
||||
chainLengthPitchesEven,
|
||||
chainPitchDiameter,
|
||||
computeDriveValue,
|
||||
driveRatio,
|
||||
driveReducer,
|
||||
externalBeltTangents,
|
||||
gearPitchDiameterImperial,
|
||||
gearPitchDiameterMetric,
|
||||
gearPoints,
|
||||
surfaceSpeed,
|
||||
surfaceSpeedDisplay,
|
||||
torqueOut,
|
||||
} from "./DriveCalculator";
|
||||
|
||||
describe('computeDriveValue (s1*n1 = s2*n2)', () => {
|
||||
// A 2:1 reduction: a 10-tooth driver at 100 RPM driving a 20-tooth gear.
|
||||
const known = {s1: 10, s2: 20, n1: 100, n2: 50};
|
||||
|
||||
test('solves driven RPM', () => {
|
||||
expect(computeDriveValue('n2', {s1: 10, s2: 20, n1: 100})).toBeCloseTo(50, 6);
|
||||
});
|
||||
|
||||
test('solves driver RPM', () => {
|
||||
expect(computeDriveValue('n1', {s1: 10, s2: 20, n2: 50})).toBeCloseTo(100, 6);
|
||||
});
|
||||
|
||||
test('solves driven size', () => {
|
||||
expect(computeDriveValue('s2', {s1: 10, n1: 100, n2: 50})).toBeCloseTo(20, 6);
|
||||
});
|
||||
|
||||
test('solves driver size', () => {
|
||||
expect(computeDriveValue('s1', {s2: 20, n1: 100, n2: 50})).toBeCloseTo(10, 6);
|
||||
});
|
||||
|
||||
test('the solved value is consistent with the others', () => {
|
||||
expect(known.s1 * known.n1).toBeCloseTo(known.s2 * known.n2, 6);
|
||||
});
|
||||
|
||||
test('returns null on divide-by-zero', () => {
|
||||
expect(computeDriveValue('n2', {s1: 10, s2: 0, n1: 100})).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for an unknown target', () => {
|
||||
expect(computeDriveValue('nope', {s1: 10, s2: 20, n1: 100})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('driveRatio and torque', () => {
|
||||
test('2:1 reduction has a ratio of 2', () => {
|
||||
expect(driveRatio(10, 20)).toBeCloseTo(2, 6);
|
||||
});
|
||||
|
||||
test('overdrive has a ratio below 1', () => {
|
||||
expect(driveRatio(20, 10)).toBeCloseTo(0.5, 6);
|
||||
});
|
||||
|
||||
test('output torque is multiplied by the ratio', () => {
|
||||
// 5 units in, 2:1 reduction -> 10 units out.
|
||||
expect(torqueOut(5, 10, 20)).toBeCloseTo(10, 6);
|
||||
});
|
||||
|
||||
test('ratio is null when the driver size is zero', () => {
|
||||
expect(driveRatio(0, 20)).toBeNull();
|
||||
expect(torqueOut(5, 0, 20)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('surfaceSpeed', () => {
|
||||
test('belt speed of a 10" pulley at 100 RPM', () => {
|
||||
// pi * 10 * 100
|
||||
expect(surfaceSpeed(10, 100)).toBeCloseTo(3141.59, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('surfaceSpeedDisplay', () => {
|
||||
test('imperial: 10" pulley at 100 RPM -> ft/min', () => {
|
||||
// pi * 10 * 100 / 12
|
||||
const result = surfaceSpeedDisplay(10, 100, false);
|
||||
expect(result.unit).toBe('ft/min');
|
||||
expect(result.value).toBeCloseTo(261.8, 1);
|
||||
});
|
||||
|
||||
test('metric: 100 mm pulley at 100 RPM -> m/s', () => {
|
||||
// pi * 100 * 100 / 60000
|
||||
const result = surfaceSpeedDisplay(100, 100, true);
|
||||
expect(result.unit).toBe('m/s');
|
||||
expect(result.value).toBeCloseTo(0.524, 3);
|
||||
});
|
||||
|
||||
test('returns null when the diameter or RPM is missing', () => {
|
||||
expect(surfaceSpeedDisplay(0, 100, false)).toBeNull();
|
||||
expect(surfaceSpeedDisplay(10, 0, false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('beltLength (open belt)', () => {
|
||||
test('classic two-pulley belt length', () => {
|
||||
// d1=10, d2=20, C=30 -> 60 + pi*15 + 100/120
|
||||
expect(beltLength(10, 20, 30)).toBeCloseTo(107.96, 2);
|
||||
});
|
||||
|
||||
test('equal pulleys: belt is 2C + pi*d', () => {
|
||||
// d1=d2=10, C=20 -> 40 + pi*10, with no diameter-difference term
|
||||
expect(beltLength(10, 10, 20)).toBeCloseTo(40 + Math.PI * 10, 6);
|
||||
});
|
||||
|
||||
test('returns null for a non-positive center distance', () => {
|
||||
expect(beltLength(10, 20, 0)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when the geometry is impossible (pulleys would overlap)', () => {
|
||||
// Minimum center distance is (d1 + d2)/2 = 15; anything at or below overlaps.
|
||||
expect(beltLength(10, 20, 15)).toBeNull();
|
||||
expect(beltLength(10, 20, 14)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when a diameter is missing', () => {
|
||||
expect(beltLength(0, 20, 30)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chainPitchDiameter', () => {
|
||||
test('17-tooth sprocket with 1/2" chain pitch (#40)', () => {
|
||||
// 0.5 / sin(pi/17) ~= 2.721"
|
||||
expect(chainPitchDiameter(17, 0.5)).toBeCloseTo(2.721, 3);
|
||||
});
|
||||
|
||||
test('returns null for fewer than one tooth', () => {
|
||||
expect(chainPitchDiameter(0, 0.5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chainLengthPitches', () => {
|
||||
test('exact length in pitches', () => {
|
||||
// t1=17, t2=34, C=20", p=0.5" -> ~105.68 pitches
|
||||
expect(chainLengthPitches(17, 34, 20, 0.5)).toBeCloseTo(105.68, 2);
|
||||
});
|
||||
|
||||
test('rounded up to the next even number of pitches', () => {
|
||||
expect(chainLengthPitchesEven(17, 34, 20, 0.5)).toBe(106);
|
||||
});
|
||||
|
||||
test('returns null for a non-positive center distance', () => {
|
||||
expect(chainLengthPitches(17, 34, 0, 0.5)).toBeNull();
|
||||
expect(chainLengthPitchesEven(17, 34, 0, 0.5)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when the geometry is impossible (sprockets would overlap)', () => {
|
||||
// 17T and 34T at 0.5" pitch have pitch diameters ~2.72" and ~5.42",
|
||||
// so the centers must exceed ~4.07"; 2" is impossible.
|
||||
expect(chainLengthPitches(17, 34, 2, 0.5)).toBeNull();
|
||||
expect(chainLengthPitchesEven(17, 34, 2, 0.5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gear pitch diameter and center distance', () => {
|
||||
test('metric: 20 teeth, module 2 mm -> 40 mm', () => {
|
||||
expect(gearPitchDiameterMetric(20, 2)).toBeCloseTo(40, 6);
|
||||
});
|
||||
|
||||
test('imperial: 20 teeth, 10 diametral pitch -> 2"', () => {
|
||||
expect(gearPitchDiameterImperial(20, 10)).toBeCloseTo(2, 6);
|
||||
});
|
||||
|
||||
test('center distance is half the sum of pitch diameters', () => {
|
||||
expect(centerDistanceFromPitchDiameters(40, 60)).toBeCloseTo(50, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('externalBeltTangents (diagram geometry)', () => {
|
||||
test('returns two tangent segments', () => {
|
||||
const segs = externalBeltTangents(0, 0, 10, 100, 0, 20);
|
||||
expect(segs).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('tangent length matches sqrt(d^2 - (r1-r2)^2)', () => {
|
||||
// Horizontal centers 100 apart, radii 10 and 20.
|
||||
const [seg] = externalBeltTangents(0, 0, 10, 100, 0, 20);
|
||||
const length = Math.hypot(seg.b[0] - seg.a[0], seg.b[1] - seg.a[1]);
|
||||
const expected = Math.sqrt(100 * 100 - (10 - 20) ** 2);
|
||||
expect(length).toBeCloseTo(expected, 4);
|
||||
});
|
||||
|
||||
test('equal radii give two parallel tangents of length d', () => {
|
||||
const segs = externalBeltTangents(0, 0, 15, 80, 0, 15);
|
||||
for (const seg of segs) {
|
||||
const length = Math.hypot(seg.b[0] - seg.a[0], seg.b[1] - seg.a[1]);
|
||||
expect(length).toBeCloseTo(80, 4);
|
||||
}
|
||||
});
|
||||
|
||||
test('returns null for coincident centers', () => {
|
||||
expect(externalBeltTangents(0, 0, 10, 0, 0, 20)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gearPoints (diagram geometry)', () => {
|
||||
test('produces four points per tooth', () => {
|
||||
const points = gearPoints(0, 0, 20, 12, 10).split(' ');
|
||||
expect(points).toHaveLength(40);
|
||||
});
|
||||
|
||||
test('clamps to a minimum of three teeth', () => {
|
||||
const points = gearPoints(0, 0, 20, 12, 1).split(' ');
|
||||
expect(points).toHaveLength(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('driveReducer (single-belt train)', () => {
|
||||
const fresh = (count = 2) => ({
|
||||
elements: Array.from({length: count}, () => ({size: '', rpm: ''})),
|
||||
lastUpdated: [],
|
||||
});
|
||||
const field = (state, index, f, value) => {
|
||||
let s = driveReducer(state, {type: 'field', index, field: f, value});
|
||||
return driveReducer(s, {type: 'solve'});
|
||||
};
|
||||
|
||||
test('solves a driven wheel RPM from the driver (2:1)', () => {
|
||||
let state = fresh();
|
||||
state = field(state, 0, 'size', '4');
|
||||
state = field(state, 1, 'size', '8');
|
||||
state = field(state, 0, 'rpm', '100');
|
||||
// Belt speed K = 4*100 = 400; wheel 2 (8") spins at 400/8 = 50.
|
||||
expect(Number(state.elements[1].rpm)).toBeCloseTo(50, 4);
|
||||
});
|
||||
|
||||
test('propagates one input speed across a 3-wheel train', () => {
|
||||
let state = driveReducer(fresh(), {type: 'add'}); // 3 wheels
|
||||
state = field(state, 0, 'size', '2');
|
||||
state = field(state, 1, 'size', '6');
|
||||
state = field(state, 2, 'size', '3');
|
||||
state = field(state, 0, 'rpm', '1000');
|
||||
// K = 2000; wheel 2 = 2000/6, wheel 3 = 2000/3.
|
||||
expect(Number(state.elements[1].rpm)).toBeCloseTo(333.333, 2);
|
||||
expect(Number(state.elements[2].rpm)).toBeCloseTo(666.667, 2);
|
||||
});
|
||||
|
||||
test('editing a wheel RPM re-anchors the belt speed', () => {
|
||||
let state = fresh();
|
||||
state = field(state, 0, 'size', '4');
|
||||
state = field(state, 1, 'size', '8');
|
||||
state = field(state, 0, 'rpm', '100'); // wheel 2 -> 50
|
||||
state = field(state, 1, 'rpm', '25'); // re-anchor: K = 8*25 = 200
|
||||
expect(Number(state.elements[0].rpm)).toBeCloseTo(50, 4); // 200/4
|
||||
});
|
||||
|
||||
test('add appends wheels up to the maximum of 6', () => {
|
||||
let state = fresh();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
state = driveReducer(state, {type: 'add'});
|
||||
}
|
||||
expect(state.elements).toHaveLength(6);
|
||||
});
|
||||
|
||||
test('remove deletes a wheel but never below the minimum of 2', () => {
|
||||
let state = driveReducer(fresh(), {type: 'add'}); // 3
|
||||
state = driveReducer(state, {type: 'remove', index: 1});
|
||||
expect(state.elements).toHaveLength(2);
|
||||
state = driveReducer(state, {type: 'remove', index: 0});
|
||||
expect(state.elements).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('clamps negative input to a positive magnitude', () => {
|
||||
const state = field(fresh(), 0, 'size', '-7');
|
||||
expect(state.elements[0].size).toBe('7');
|
||||
});
|
||||
|
||||
test('ignores unknown actions', () => {
|
||||
const initial = fresh();
|
||||
expect(driveReducer(initial, {type: 'bogus'})).toBe(initial);
|
||||
});
|
||||
|
||||
// --- Edge cases ---
|
||||
test('clearing the anchor falls back to the next most recent complete wheel', () => {
|
||||
let state = fresh(3);
|
||||
state = field(state, 0, 'size', '10');
|
||||
state = field(state, 0, 'rpm', '800'); // K=8000
|
||||
state = field(state, 2, 'size', '20'); // wheel 3 gets 400
|
||||
// Now clear the original anchor's rpm
|
||||
state = field(state, 0, 'rpm', '');
|
||||
// Should still have a complete wheel (index 2), but no anchor that has both → no solving happens for others
|
||||
expect(state.elements[2].rpm).toBe('400'); // previously solved value stays
|
||||
});
|
||||
|
||||
test('completing a middle wheel as first anchor works', () => {
|
||||
let state = fresh(3);
|
||||
state = field(state, 1, 'size', '5');
|
||||
state = field(state, 1, 'rpm', '400'); // K=2000
|
||||
state = field(state, 0, 'size', '4');
|
||||
state = field(state, 2, 'size', '10');
|
||||
// Both outer wheels should now be solved from the middle anchor
|
||||
expect(Number(state.elements[0].rpm)).toBeCloseTo(500, 4);
|
||||
expect(Number(state.elements[2].rpm)).toBeCloseTo(200, 4);
|
||||
});
|
||||
|
||||
test('solving only happens into the missing side (re-anchoring still works)', () => {
|
||||
let state = fresh();
|
||||
state = field(state, 0, 'size', '5');
|
||||
state = field(state, 0, 'rpm', '200'); // K=1000
|
||||
state = field(state, 1, 'size', '20');
|
||||
expect(Number(state.elements[1].rpm)).toBeCloseTo(50, 4);
|
||||
// Re-anchor by giving wheel 1 both values
|
||||
state = field(state, 1, 'rpm', '30'); // new K=600
|
||||
expect(Number(state.elements[0].rpm)).toBeCloseTo(120, 4);
|
||||
});
|
||||
|
||||
test('very small and fractional values are handled without NaN', () => {
|
||||
let state = fresh();
|
||||
state = field(state, 0, 'size', '0.5');
|
||||
state = field(state, 0, 'rpm', '10000');
|
||||
state = field(state, 1, 'size', '0.25');
|
||||
expect(Number(state.elements[1].rpm)).toBeCloseTo(20000, 4);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue