Skip to content

Instantly share code, notes, and snippets.

@KoStard
Created May 10, 2026 22:22
Show Gist options
  • Select an option

  • Save KoStard/ef927d3f9193770d7cbc472536c5aa85 to your computer and use it in GitHub Desktop.

Select an option

Save KoStard/ef927d3f9193770d7cbc472536c5aa85 to your computer and use it in GitHub Desktop.
// Road Bike Bottle Cage — single-body traditional cage for a 74 mm bottle
// Reference-driven dimensions come from the supplied design sheet.
// Coordinates: X = left/right, Y = front/back, Z = cage height.
const bottleDiameter = Param.number('Bottle diameter', 74, { min: 70, max: 78, step: 0.5, unit: 'mm' });
const cageHeight = Param.number('Overall cage height', 128, { min: 118, max: 138, step: 1, unit: 'mm' });
const armWidth = Param.number('Arm band width', 10, { min: 7, max: 14, step: 0.5, unit: 'mm' });
const armThickness = Param.number('Arm thickness', 5.2, { min: 4.6, max: 6.2, step: 0.1, unit: 'mm' });
const backPlateWidth = Param.number('Back plate width', 30, { min: 26, max: 36, step: 0.5, unit: 'mm' });
const backPlateThickness = Param.number('Back plate thickness', 5.4, { min: 4.5, max: 6.5, step: 0.1, unit: 'mm' });
const mountSpacing = Param.number('M5 boss spacing', 64, { min: 60, max: 68, step: 0.5, unit: 'mm' });
const slotTravel = Param.number('M5 slot vertical travel', 6, { min: 3, max: 9, step: 0.5, unit: 'mm' });
const showBottleGhost = Param.bool('Show 74 mm bottle fit ghost', true);
const showHardwareGhosts = Param.bool('Show M5 bolt ghosts', true);
const viewMode = Param.choice('Review view', 'fit-review', ['fit-review', 'cage-only', 'back-mount-review']);
const cageColor = '#2f3133';
const cageMaterial = { metalness: 0.04, roughness: 0.74, clearcoat: 0.12, clearcoatRoughness: 0.55 };
const ghostMaterial = { opacity: 0.26, roughness: 0.2, transmission: 0.35 };
const steelGhostMaterial = { opacity: 0.46, metalness: 0.65, roughness: 0.28 };
const bottleRadius = bottleDiameter / 2;
const carrierClearance = 0.8; // tight retention posture, not a loose bottle clearance
const carrierDiameter = bottleDiameter + carrierClearance;
const carrierRadius = carrierDiameter / 2;
const backPlateFrontY = -carrierRadius;
const backPlateBackY = backPlateFrontY - backPlateThickness;
const m5SlotWidth = 5.8;
const m5SlotLength = m5SlotWidth + slotTravel;
const counterboreDepth = 1.8;
const counterboreWidth = 10.8;
const lowerCupWallHeight = 18;
function withCageFinish(shapeOrGroup) {
const colored = shapeOrGroup.color(cageColor);
if (colored && typeof colored.material === 'function') {
return colored.material(cageMaterial);
}
return colored;
}
function renderableChild(name, value) {
if (value && value.children) return { name, group: value };
return { name, shape: value };
}
function verticalProfileToShape(profile, thicknessY, frontY, centerZ) {
// Build in X/Y as a flat plate, extrude in local +Z, then rotate so local Y becomes world Z
// and local extrusion thickness becomes world -Y. frontY is the world Y of the front face.
return profile
.extrude(thicknessY)
.rotateX(90)
.translate(0, frontY, centerZ);
}
function makeBackPlateExact() {
// Same as makeBackPlate(), but preserves the already-cut 3D raw plate rather than re-projecting.
// Kept separate so the through-slots and shallow counterbores stay as true 3D subtractions.
const fullH = cageHeight - 2;
const core = roundedRect(backPlateWidth, fullH, 5.5);
const upperLoadPad = roundedRect(backPlateWidth + 9, 36, 8).translate(0, fullH / 2 - 20);
const midBossPad = roundedRect(backPlateWidth + 8, 76, 10).translate(0, 0);
const lowerHeelPad = roundedRect(backPlateWidth + 18, 36, 9).translate(0, -fullH / 2 + 18);
const profile = union2d(core, upperLoadPad, midBossPad, lowerHeelPad);
const cutters = [];
for (const zOffset of [-mountSpacing / 2, mountSpacing / 2]) {
cutters.push(
slot(m5SlotLength, m5SlotWidth)
.rotate(90)
.translate(0, zOffset)
.extrude(backPlateThickness + 2)
.translate(0, 0, -1),
);
}
cutters.push(
slot(48, 10.5)
.rotate(90)
.extrude(backPlateThickness + 2)
.translate(0, 0, -1),
);
cutters.push(
slot(13, 4.5)
.rotate(90)
.translate(0, -fullH / 2 + 8)
.extrude(backPlateThickness + 2)
.translate(0, 0, -1),
);
let raw = difference(profile.extrude(backPlateThickness), ...cutters);
const cbCutters = [];
for (const zOffset of [-mountSpacing / 2, mountSpacing / 2]) {
cbCutters.push(
slot(m5SlotLength + 5, counterboreWidth)
.rotate(90)
.translate(0, zOffset)
.extrude(counterboreDepth + 0.25)
.translate(0, 0, backPlateThickness - counterboreDepth),
);
}
raw = difference(raw, ...cbCutters);
return raw
.rotateX(90)
.translate(0, backPlateFrontY, cageHeight / 2);
}
function makeSurfaceArms() {
const carrier = Carrier.cylinder('74-mm-bottle-envelope')
.diameter(carrierDiameter)
.height(cageHeight);
const sectionMain = { width: armWidth, thickness: armThickness, edgeRadius: 0.9 };
const sectionRail = { width: armWidth * 0.9, thickness: armThickness, edgeRadius: 0.9 };
const sectionHoop = { width: armWidth + 2, thickness: armThickness, edgeRadius: 1.0 };
const sectionLip = { width: 7.5, thickness: armThickness + 1.0, edgeRadius: 0.8 };
const sectionCup = { width: 14, thickness: armThickness, edgeRadius: 1.0 };
const arms = SurfaceBody('traditional-single-piece-bottle-cage')
.carrier(carrier)
// Rear member overlaps the front face of the flat back plate; this is the fused one-piece spine.
.member('rear-spine')
.band()
.path(carrier.path().from({ angle: -90, z: 8 }).to({ angle: -90, z: cageHeight - 10 }))
.section({ width: 9, thickness: armThickness, edgeRadius: 0.8 })
// Lower cup and lower seating straps.
.member('lower-cup-hoop')
.band()
.path(carrier.path().around({ z: 15, fromAngle: 24, toAngle: 156 }))
.section(sectionCup)
.member('bottom-seat-strap')
.band()
.path(carrier.path().around({ z: 5.5, fromAngle: 42, toAngle: 138 }))
.section({ width: 10, thickness: armThickness, edgeRadius: 0.9 })
// Straight side rails preserve the traditional vertical bottle extraction path.
.member('right-side-rail')
.band()
.path(carrier.path().from({ angle: 28, z: 14 }).through({ angle: 31, z: 58 }).to({ angle: 34, z: 104 }))
.section(sectionRail)
.member('left-side-rail')
.band()
.path(carrier.path().from({ angle: 152, z: 14 }).through({ angle: 149, z: 58 }).to({ angle: 146, z: 104 }))
.section(sectionRail)
// Swept broad arms create the large side windows and carry load from the back spine to the upper hoop.
.member('right-swept-arm')
.band()
.path(carrier.path()
.from({ angle: -82, z: 20 })
.through({ angle: -28, z: 40 })
.through({ angle: 12, z: 70 })
.to({ angle: 34, z: 104 }))
.section(sectionMain)
.member('left-swept-arm')
.band()
.path(carrier.path()
.from({ angle: -98, z: 20 })
.through({ angle: -152, z: 40 })
.through({ angle: -192, z: 70 })
.to({ angle: -214, z: 104 }))
.section(sectionMain)
// Upper cage hoop and tight retention lip.
.member('upper-front-hoop')
.band()
.path(carrier.path().around({ z: 104, fromAngle: 34, toAngle: 146 }))
.section(sectionHoop)
.member('upper-retention-lip')
.band()
.path(carrier.path().around({ z: 112, fromAngle: 42, toAngle: 138 }))
.section(sectionLip)
// Blend the most important structural intersections. The members also overlap slightly by design.
.join('right-side-rail', 'upper-front-hoop').blend({ radius: 3.5 })
.join('left-side-rail', 'upper-front-hoop').blend({ radius: 3.5 })
.join('right-side-rail', 'lower-cup-hoop').blend({ radius: 3.0 })
.join('left-side-rail', 'lower-cup-hoop').blend({ radius: 3.0 })
.join('right-swept-arm', 'upper-front-hoop').blend({ radius: 3.2 })
.join('left-swept-arm', 'upper-front-hoop').blend({ radius: 3.2 })
.join('right-swept-arm', 'rear-spine').blend({ radius: 3.0 })
.join('left-swept-arm', 'rear-spine').blend({ radius: 3.0 })
.build();
return withCageFinish(arms);
}
function makeLowerCupShell() {
const outerR = carrierRadius + armThickness;
const innerR = Math.max(1, carrierRadius - 0.9);
const fullShell = difference(
cylinder(lowerCupWallHeight, outerR, outerR, 112),
cylinder(lowerCupWallHeight + 2, innerR, innerR, 112).translate(0, 0, -1),
);
// Keep only the front-and-side U cup; leave the direct rear mostly open for the flat back plate.
const keepDepth = 78;
const keepY = 9;
const frontAndSideWindow = box(outerR * 2 + 6, keepDepth, lowerCupWallHeight + 4).translate(0, keepY, -2);
const cupShell = intersection(fullShell, frontAndSideWindow);
// A low front toe gives the bottle base a positive seat and visually matches the deep lower cup callout.
const toe = roundedRect(30, 9, 4.5)
.extrude(7)
.rotateX(90)
.translate(0, outerR - 4, 3.5);
return withCageFinish(union(cupShell, toe));
}
function makeBottleGhost() {
const translucent = '#dce9f7';
const bodyH = 122;
const shoulderH = 12;
const neckH = 23;
const capH = 12;
const body = cylinder(bodyH, bottleRadius, bottleRadius, 96)
.translate(0, 0, -6)
.color(translucent)
.material(ghostMaterial);
const shoulder = cylinder(shoulderH, bottleRadius, bottleRadius * 0.76, 96)
.translate(0, 0, bodyH - 6)
.color(translucent)
.material(ghostMaterial);
const neck = cylinder(neckH, bottleRadius * 0.34, bottleRadius * 0.34, 64)
.translate(0, 0, bodyH + shoulderH - 6)
.color(translucent)
.material({ opacity: 0.3, roughness: 0.16, transmission: 0.4 });
const cap = cylinder(capH, bottleRadius * 0.52, bottleRadius * 0.46, 64)
.translate(0, 0, bodyH + shoulderH + neckH - 8)
.color('#f6f7f8')
.material({ opacity: 0.38, roughness: 0.22, transmission: 0.22 });
return group(
{ name: 'Bottle body 74 mm', shape: body },
{ name: 'Bottle shoulder', shape: shoulder },
{ name: 'Bottle neck', shape: neck },
{ name: 'Bottle cap', shape: cap },
);
}
function makeHardwareGhosts() {
const headDia = counterboreWidth - 0.5;
const shaftDia = 4.9;
const headColor = '#b8bec4';
const objects = [];
for (const z of [cageHeight / 2 - mountSpacing / 2, cageHeight / 2 + mountSpacing / 2]) {
const shaft = cylinder(20, shaftDia / 2, shaftDia / 2, 48)
.rotateX(-90)
.translate(0, backPlateBackY - 0.2, z)
.color(headColor)
.material(steelGhostMaterial);
const head = cylinder(counterboreDepth, headDia / 2, headDia / 2, 64)
.rotateX(-90)
.translate(0, backPlateBackY, z)
.color(headColor)
.material({ opacity: 0.58, metalness: 0.7, roughness: 0.22 });
objects.push({ name: `M5 shaft ghost ${z.toFixed(0)}mm`, shape: shaft });
objects.push({ name: `Low-profile head ghost ${z.toFixed(0)}mm`, shape: head });
}
return group(...objects);
}
const backPlate = withCageFinish(makeBackPlateExact());
const surfaceArms = makeSurfaceArms();
const lowerCupShell = makeLowerCupShell();
const cageBody = group(
{ name: 'Back plate with M5 slots', shape: backPlate },
renderableChild('Conformal cage arms and retention lip', surfaceArms),
{ name: 'Deep lower cup shell', shape: lowerCupShell },
);
bom(1, 'single-piece road bike bottle cage body', {
material: 'PA12 / CF-nylon class polymer',
process: 'SLS, MJF, molded nylon, or composite prototype process',
notes: 'One continuous cage body with 74 mm bottle envelope, broad arms, deep cup, and M5 slotted back plate.',
});
bom(2, 'M5 low-profile bottle cage bolt, stainless', {
material: 'stainless steel',
length: 12,
diameter: 5,
notes: 'Bolts shown as optional ghosts for mount fit review only.',
});
const bb = cageBody.boundingBox();
const measuredWidth = bb.max[0] - bb.min[0];
const measuredDepth = bb.max[1] - bb.min[1];
const measuredHeight = bb.max[2] - bb.min[2];
verify.equal('Bottle diameter target is 74 mm', bottleDiameter, 74, 0.5);
verify.equal('M5 boss spacing target is 64 mm', mountSpacing, 64, 0.5);
verify.inRange('Overall cage width remains near 84 mm target', measuredWidth, 80, 89);
verify.inRange('Overall cage depth remains near 86 mm target', measuredDepth, 78, 91);
verify.inRange('Overall cage height remains near 128 mm target', measuredHeight, 120, 132);
verify.inRange('Back plate thickness in sheet range', backPlateThickness, 5.0, 5.8);
verify.inRange('Arm band thickness in sheet range', armThickness, 5.0, 5.5);
scene({
background: { top: '#c6ced8', bottom: '#606b76' },
camera: { position: [185, -260, 165], target: [0, 4, 64], fov: 36 },
views: {
hero: {
camera: { position: [185, -260, 165], target: [0, 4, 64], up: [0, 0, 1], fov: 36 },
},
front: {
camera: { position: [0, 310, 78], target: [0, 0, 64], up: [0, 0, 1], fov: 26 },
},
side: {
camera: { position: [300, 0, 78], target: [0, -8, 64], up: [0, 0, 1], fov: 26 },
},
backMount: {
camera: { position: [0, -330, 72], target: [0, -39, 64], up: [0, 0, 1], fov: 24 },
},
},
environment: { preset: 'studio', intensity: 0.18, background: false },
lights: [
{ type: 'ambient', color: '#efe7dc', intensity: 0.16 },
{ type: 'directional', position: [240, -310, 360], color: '#ffe0b8', intensity: 2.7, castShadow: true },
{ type: 'directional', position: [-230, 230, 210], color: '#d8e9ff', intensity: 0.82 },
{ type: 'hemisphere', skyColor: '#c9d5e1', groundColor: '#4c5662', intensity: 0.14 },
],
ground: { visible: true, color: '#7f8a95', height: -12, receiveShadow: true },
postProcessing: {
bloom: { intensity: 0.03, threshold: 0.94, radius: 0.25 },
vignette: { darkness: 0.38, offset: 0.34 },
toneMappingExposure: 1.12,
},
});
const result = [
{ name: 'Bottle Cage Body — single continuous cage', group: cageBody },
];
if (showBottleGhost && viewMode !== 'cage-only') {
result.push({ name: '74 mm Bottle Fit Ghost', group: makeBottleGhost() });
}
if (showHardwareGhosts || viewMode === 'back-mount-review') {
result.push({ name: 'M5 Mount Hardware Ghosts', group: makeHardwareGhosts() });
}
return result;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment