世界可以写成数学。代码让它运行起来。 The world can be described in mathematics. Code makes that description run.
一辆自行车的车架是长度和角度,一把小提琴的声音是弦长和衰减。 把这些关系写进代码,数学就不再停在纸上:它会变成可以转动的模型,也会成为一段声音。 A bicycle frame is a set of lengths and angles. A violin's voice is string length and decay. Write those relationships in code and the mathematics no longer sits on paper: it becomes a model you can turn, or a sound you can hear.
场景Scenes
可以走进去的世界worlds you can step into游戏Games
可以玩的世界worlds you can play物件Objects
可以拿近了看的东西things made to be seen up close// radius in tangent units, then as an angle. frameFraction = tan(α/2)/tan(fovY/2), solved for α.
export const MOON_T_R = MOON.frameH * TAN_Y; // 0.079192
export const MOON_ANG_R = Math.atan(MOON_T_R); // 4.528° — half-angle
export const MOON_T_Y = (MOON.centreY - 0.5) * 2 * TAN_Y; // centre, above the lens axis
// The brief said 8.8°; that is the small-angle read of the same equation (8.8/18 ≈ ½). Solved
// through the tangent it is 9.06°, and the difference is 1.4% of the frame's height. The exact
// one is used, because every other number below divides by it.
export const MOON_MAGNIFICATION = (MOON_ANG_R * 2) / rad(0.53); // ≈ 17×, vs the real moon先定 18° 的竖直视角,再定「月盘占画面高度的一半」,反解出来月亮的角半径只有一个值:4.528°,大约是真实月亮的 17 倍。树线的高度、车的位置、窗灯落在画面几成高,都从这个数往下算。 Fix an 18° vertical field, then fix the disc at half the frame height, and the moon's angular radius solves to one value: 4.528°, about 17× life size. The treeline, the bike and the window lights are all worked out from there.
// ---- the points, derived in order ----
export const P = {};
P.rearAxle = [0, WHEEL.R];
P.frontAxle = [GEO.wheelbase, WHEEL.R];
P.bbY = WHEEL.R - GEO.drop;
// rule 1: the BB is where the chainstay reaches
P.bbX = Math.sqrt(GEO.chainstay ** 2 - GEO.drop ** 2);
P.bb = [P.bbX, P.bbY];
// rule 2: walk back up the steering axis from the front axle, then back off the rake
const a = rad(GEO.headAngle);
const axisUp = [-Math.cos(a), Math.sin(a)]; // up-and-back along the steerer
const rakeDir = [Math.sin(a), Math.cos(a)]; // perpendicular, pointing forward
P.crown = [
P.frontAxle[0] + axisUp[0] * GEO.forkLen - rakeDir[0] * GEO.rake,
P.frontAxle[1] + axisUp[1] * GEO.forkLen - rakeDir[1] * GEO.rake,
];
P.headTop = [P.crown[0] + axisUp[0] * GEO.headTube, P.crown[1] + axisUp[1] * GEO.headTube];
// the head tube does NOT start at the crown: the fork crown, its race and the lower headset cup
// all stack below it. Start the tube on the crown and there is nowhere for the front brake's
// barrel adjuster to be except inside the head tube — which is exactly where its cable vanished.
P.headBottom = [P.crown[0] + axisUp[0] * 0.0290, P.crown[1] + axisUp[1] * 0.0290];五通的位置是勾股定理。后下叉长度和落差定了,它就只能在那儿。前叉冠也一样:从前轴沿转向轴往回走一个前叉长度,再减掉前倾量。车上每个管端都是这么定下来的点。 The bottom bracket is Pythagoras. Fix a chainstay length and a drop and it can only be in one place. The fork crown too: walk back up the steering axis from the front axle, then off the rake. Every tube end on the bike is a point placed this way.
// rule 2 — the finder has to see over the throat
if (PRISM.apexY <= MOUNT.axisY + MOUNT.throatR)
throw new Error('params: the prism is shorter than the mount throat it has to look over');
// rule 3a — parked, the lever tip must stay inside the body outline
{
const a = (CONTROLS.windRest * Math.PI) / 180;
const tipX = CONTROLS.windPivotX + CONTROLS.windLen * Math.cos(a);
const tipZ = CONTROLS.windPivotZ + CONTROLS.windLen * Math.sin(a);
if (Math.abs(tipX) > BODY.w / 2 - 0.001 || Math.abs(tipZ) > BODY.d / 2 - 0.001)
throw new Error(`params: the parked wind lever hangs off the body (${tipX.toFixed(4)}, ${tipZ.toFixed(4)})`);
// rule 3b — swung out, it must not sweep through the prism
const b = a + (CONTROLS.windThrow * Math.PI) / 180;
const endX = CONTROLS.windPivotX + CONTROLS.windLen * Math.cos(b);
if (endX < PRISM.w0 / 2)
throw new Error('params: the wind lever ends its throw inside the pentaprism');五棱镜要高过卡口喉部,不然取景器看到的是顶盖内侧。过片扳手停下时不能露在机身外面,扳到底也不能撞进棱镜。三条都写成了断言,数不对就不给渲染。 The prism has to clear the mount throat, or the finder looks into the top plate. The wind lever has to park inside the body and stop short of the prism at the end of its throw. All three are assertions: bad numbers don't render.
// rule 1 — the arm is as long as it has to be, and no longer
ARM.reach = (PROP.R + PROP.clearance / 2) / Math.sin((ARM.spreadDeg * Math.PI) / 180);
export const MOTOR = [];
{
const a = (ARM.spreadDeg * Math.PI) / 180;
const sx = Math.sin(a) * ARM.reach, sz = Math.cos(a) * ARM.reach;
// front-left, front-right, back-right, back-left; handedness alternates (rule 3)
MOTOR.push({ x: -sx, z: sz, spin: 1 });
MOTOR.push({ x: sx, z: sz, spin: -1 });
MOTOR.push({ x: sx, z: -sz, spin: 1 });
MOTOR.push({ x: -sx, z: -sz, spin: -1 });桨不能互相打到,所以机臂长度是解出来的:reach = (桨半径 + 间隙/2) / sin(展角)。四个电机的旋向交替,用来抵消偏航力矩。文件末尾会把两条对角线都再量一遍。 Props must not overlap, so the arm length is solved for: reach = (prop radius + clearance/2) / sin(spread). The four motors alternate handedness to cancel the yaw torque. An assertion at the foot of the file measures both diagonals again.
/** The one point on the platen surface that the ribbon, the bar tips and the sheet all meet. */
export const TYPE_POINT = {
y: PLATEN.y + CARRIAGE.platenR * Math.cos(rad(TYPEBARS.typeAngle)),
z: PLATEN.z + CARRIAGE.platenR * Math.sin(rad(TYPEBARS.typeAngle)),
};
/** Bar length is the reach from the segment fulcrum to the type point — derived, never typed.
* A bar rotated to 0° puts its head exactly on the platen surface; `rest` swings it back. */
export const BAR_LEN = Math.hypot(
TYPE_POINT.y - TYPEBARS.fulcrumY,
TYPE_POINT.z - TYPEBARS.fulcrumZ,
);
/** Angle the raised bar makes with vertical, so the head can be squared up to the platen. */
export const BAR_LEAN = Math.atan2(
TYPE_POINT.z - TYPEBARS.fulcrumZ,色带、字杆头和纸要在同一个点上相遇。这个点由压纸辊的半径和打字角决定,字杆长度就是支点到它的距离。换一根粗一点的压纸辊,字杆、色带和纸的落点一起动。 The ribbon, the typebar head and the sheet all meet at one point. That point comes from the platen's radius and the type angle, and the bar length is the distance from the fulcrum to it. Fit a thicker platen and the bars, the ribbon and the paper all move with it.
/** Rule 1: the string length, and where it puts everything. */
export const SCALE = {
vibrating: 0.3250, // = neckStop + bodyStop, and the two are in the maker's 2:3
neckStop: 0.1300, // nut → the body's edge at the neck
bodyStop: 0.1950, // that edge → the bridge
fingerboard: 0.2700,
fbWidthNut: 0.0240, fbWidthEnd: 0.0420,
fbThick: 0.0060,
};
if (Math.abs(SCALE.neckStop + SCALE.bodyStop - SCALE.vibrating) > 1e-9)
throw new Error('params: the neck stop and the body stop do not add up to the string length');振动弦长 325 毫米,按 2:3 分给颈和身。琴码的位置由这两个数算出来,不是量出来的。最后一行断言保证它们一直对得上,对不上就不渲染。 A 325 mm vibrating string, split 2:3 between neck and body. The bridge's position comes out of those two numbers instead of being measured. The assertion at the end keeps them adding up; when they stop, it doesn't render.
export function halfBeam(z) {
const t = (z - HULL.zMaxBeam) / (HULL.lod / 2);
if (t >= 0) {
// Forward of max beam. Two terms, and the second one is the whole argument:
// · a cubic that leaves max beam flat and arrives at the stem with a FINITE slope — that
// slope is the half-angle of entrance, 14°, and it is what "sharp bow" actually means;
// · a bump SUBTRACTED over the forward third. A subtracted bump makes the curve convex
// there, and a convex plan curve lies inside its own chord — which is the definition of
// a hollow waterline. Fineness alone is not hollow: the first cut of this hull was fine
// everywhere and curved the same way from end to end, and read as a leaf.
const u = Math.min(1, t / (1 - HULL.zMaxBeam / (HULL.lod / 2)));
const cubic = 1 - 1.5 * u ** 2 + 0.5 * u ** 3;
const s = Math.max(0, (u - 0.55) / 0.45);
const hollow = 0.090 * Math.sin(Math.PI * Math.min(1, s)) ** 2;
return (HULL.beam / 2) * Math.max(0, cubic - hollow);最宽处往前是一段三次曲线,再在前三分之一处减掉一个正弦包。减出来的那块让曲线在那里外凸,而外凸的平面曲线落在自己的弦以内,这就是凹形水线。只是瘦不等于削首。 Forward of max beam: a cubic, minus a sine bump over the forward third. Subtracting it makes the curve convex there, and a convex plan curve lies inside its own chord. That is a hollow waterline. Fine is not the same as hollow.