Created
May 10, 2026 22:22
-
-
Save KoStard/ef927d3f9193770d7cbc472536c5aa85 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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