Pen Settings

HTML

CSS

CSS Base

Vendor Prefixing

Add External Stylesheets/Pens

Any URLs added here will be added as <link>s in order, and before the CSS in the editor. You can use the CSS from another Pen by using its URL and the proper URL extension.

+ add another resource

JavaScript

Babel includes JSX processing.

Add External Scripts/Pens

Any URL's added here will be added as <script>s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.

+ add another resource

Packages

Add Packages

Search for and use JavaScript packages from npm here. By selecting a package, an import statement will be added to the top of the JavaScript editor for this package.

Behavior

Auto Save

If active, Pens will autosave every 30 seconds after being saved once.

Auto-Updating Preview

If enabled, the preview panel updates automatically as you code. If disabled, use the "Run" button to update.

Format on Save

If enabled, your code will be formatted when you actively save your Pen. Note: your code becomes un-folded during formatting.

Editor Settings

Code Indentation

Want to change your Syntax Highlighting theme, Fonts and more?

Visit your global Editor Settings.

HTML

              
                <!DOCTYPE html>
<html lang="en">
    <head>
        <title>"Catenary playground</title>
        <link rel="shortcut icon" href="https://vielzutun.ch/favicon.ico" />
        <meta charset="utf-8">
            
        <meta property="og:description" content="This is my catenary playground. It displays a catenary between two suspension points having a given length. It has drag controls for position manipulation (height, distance) of suspension points as well as sliders for position components and chain length." />
        <meta property="og:image" content="https://vielzutun.ch/wordpress/wp-content/uploads/2025/04/Catenary_playground.png" />
            
        <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
        <noscript>********* Benötigt Javascript *********</noscript>
        <link type="text/css" rel="stylesheet" href="main.css">
    </head>
    <body>
        <div id="info">
			  <a href="https://en.wikipedia.org/wiki/Catenary" target="_blank" rel="Danni">Catenary</a> Playground by <a href="https://vielzutun.ch" target="_blank" rel="vielzutun.ch">vielzutun.ch</a>, powered by <a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> <br />
        </div>
        
        <script type="importmap">
          {
            "imports": {
                "three": "https://cdn.jsdelivr.net/npm/three@0.174.0/build/three.module.js",
                "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.174.0/examples/jsm/"
            }
          }
        </script>

        <script type="module" src="./Main.js"></script>

    </body>
</html>

              
            
!

CSS

              
                body {
    margin: 0;
    background-color: #000;
    color: #fff;
    font-family: Monospace;
    font-size: 13px;
    line-height: 24px;
    overscroll-behavior: none;
}

a {
    color: #ff0;
    text-decoration: none;
}

#info {
    position: absolute;
    top: 0px;
    width: 100%;
    padding: 10px;
    box-sizing: border-box;
    text-align: center;
    -moz-user-select: none;
    -webkit-user-select: none;
    -ms-user-select: none;
    user-select: none;
    pointer-events: none;
    z-index: 1; /* TODO Solve this in HTML */
}


              
            
!

JS

              
                import * as THREE from "three";
import { OrbitControls }        from "three/addons/controls/OrbitControls.js";
import { TransformControls }    from 'three/addons/controls/TransformControls.js';
import { GUI }                  from 'three/addons/libs/lil-gui.module.min.js';

let container;

THREE.Cache.enabled = true;


let camera, cameraTarget, orbit, control, control2, scene, grid, renderer;
let curView, cameraFOV;
curView = 5;           // default orthographic view is 'Right'
cameraFOV = 50;        // default vertical FOV in [°]
let saveMatrix = new THREE.Matrix4();


let Achsen_Group;

let catGeometry;
let catPositions;
let catPositionAttribute;
let catMaterial;
let drawCount;
let catLine;

// ******************************** Basic constants ***************************************************

let P1x = -200;
let P1y = 0;
let P2x = 100;
let P2y = 0;
let guiP1x;
let guiP1y;
let guiP2x;
let guiP2y;
let catLength;

let L = 350.0;

let dx = 0;
let dy = 0;

let startP1x = P1x;
let startP1y = P1y;
let startP2x = P2x;
let startP2y = P2y;

// *****************************************************************************************************

let axesHelper = false;

let gui;
let helperFolder, positionFolder, lengthFolder;

const points = [];


init();
buildGUI();
// no animation loop - purely on-demand rendering once, on any change


function init() {
    
    let mesh;

    renderer = new THREE.WebGLRenderer( { antialias: true, alpha: true } );
    renderer.setPixelRatio( window.devicePixelRatio );
    renderer.setSize( window.innerWidth, window.innerHeight );
    

    renderer.toneMapping = THREE.LinearToneMapping;

    container = document.createElement( 'div' );
    document.body.appendChild( container );
    container.appendChild( renderer.domElement );
    
    scene = new THREE.Scene();
    scene.background = new THREE.Color( 0x191919 );
    grid = new THREE.GridHelper( 1000, 10, 0xAAAAAA, 0x444444 );   // 100 units wide, 10 subdivisions
    grid.rotation.x = Math.PI / 2;
    scene.add( grid );

    camera = new THREE.PerspectiveCamera(cameraFOV, window.innerWidth / window.innerHeight, 1, 2000 );
    camera.position.set( 0, 0, 1100 );

    scene.add( camera );

    Achsen_Group = new THREE.Group();               Achsen_Group.name = "Achsen";
    scene.add( Achsen_Group );
    
    /***********************  allocate BufferGeometry for chain line (catenary) **********************/

    const MAX_POINTS = 1000;

    catGeometry = new THREE.BufferGeometry();
    catPositions = new Float32Array( MAX_POINTS * 3 ); // 3 floats (x, y and z) per point
    catGeometry.setAttribute( 'position', new THREE.BufferAttribute( catPositions, 3 ) );
    catMaterial = new THREE.LineBasicMaterial( { color: 0xff0000 } );

    catLine = new THREE.Line( catGeometry, catMaterial );
    catLine.name = 'Catenary';
    scene.add( catLine );
    
    /*********************** end of BufferGeometry for chain line (catenary) **********************/
    

    /******************************* Orbit- / Transform Controls **********************************/
    
    orbit = new OrbitControls( camera, renderer.domElement );
    orbit.addEventListener( 'change', render );
    orbit.minDistance = 10;
    orbit.maxDistance = 1500;
    orbit.enablePan = true;
    orbit.target = new THREE.Vector3( 0, 0, 0 );
    orbit.update();

    control = new TransformControls( camera, renderer.domElement );
    control.setSize( 0.3 );
    control.addEventListener( 'change', function ( event ) {
        
        let rmax = L;                                   // max distance for straight line connection between P1 and P2
        let P1_x = startP1x + event.target._offset.x;   // current (dragged) point's x-coordinate
        let P1_y = startP1y + event.target._offset.y;   // current (dragged) point's y-coordinate
        
        let dx = P1_x - P2x;                            // should be always negative, because P1 always left of P2
        let dy = P1_y - P2y;                            // negative for P1_y < P2y, positive otherwise
        
        if ( Math.sqrt( dx * dx + dy * dy ) < L ) {     // current (dragged) point is NOT too far out
            
            P1x = startP1x + event.target._offset.x;
            P1y = startP1y + event.target._offset.y;
            control.minX = -Infinity;
            control.maxX =  P2x;                        // P1 must always stay always left of P2
            control.minY = -Infinity;
            control.maxY =  Infinity;

        } else {                                        // prospective point P1_x, P1_y would fall beyond allowed radius
            /*
             We can't blame solely x- or y- for this but must share the burden of correction in a fair way.
             So I take the ray from P2x,y to the prospective P1_x,y and scale that down to be slightly shorter than "L"
             */
            let shrinkFac = L / Math.sqrt( dx * dx + dy * dy );
            
            dx *= shrinkFac;
            dy *= shrinkFac;
            
            P1x = Math.floor( 10 * ( dx + P2x ) ) / 10;    // round down to the next 1/10 unit
            control.minX = P1x;
            control.maxX = P1x;

            if ( dy >= 0 ) {                                // if prospective P1_ is ABOVE P2 ...
                
                P1y = Math.floor( 10 * ( dy + P2y ) ) / 10; // ... stay a little bit _below_ what was attempted
                control.maxY = P1y;

            } else {                                        // if prospective P1_ is BELOW P2 ...
                
                P1y = Math.ceil( 10 * ( dy + P2y ) ) / 10;  // ... stay a little bit _above_ what was attempted
                control.minY = P1y;

            }
            
            positionFolder.controllers[0].object.P1x = P1x;
            positionFolder.controllers[0].object.P1y = P1y;

        }
        
        render();
        
    } );
    
    control.addEventListener( 'dragging-changed', function ( event ) {

        orbit.enabled = ! event.value;
        
        event.target._offset.x = 0;      // workaround for suspected error in TransformControls.js
        startP1x = P1x;
        control.minX = P1x;
        control.maxX = P1x;
        
        event.target._offset.y = 0;      // workaround for suspected error in TransformControls.js
        startP1y = P1y;
        control.minY = P1y;
        control.maxY = P1y;

    } );


    control2 = new TransformControls( camera, renderer.domElement );
    control2.setSize( 0.3 );
    control2.addEventListener( 'change', function ( event ) {
        
        let rmax = L;                                   // max distance for straight line connection between P1 and P2
        let P2_x = startP2x + event.target._offset.x;   // current (dragged) point's x-coordinate
        let P2_y = startP2y + event.target._offset.y;   // current (dragged) point's y-coordinate
        
        let dx = P2_x - P1x;                            // should be always positive, because P2 always right of P1
        let dy = P2_y - P1y;                            // negative for P2_y < P1y, positive otherwise
        
        if ( Math.sqrt( dx * dx + dy * dy ) < L ) {     // current (dragged) point is NOT too far out
            
            P2x = startP2x + event.target._offset.x;
            P2y = startP2y + event.target._offset.y;
            control2.minX =  P1x;                       // P2 must always stay always right of P1
            control2.maxX =  Infinity;
            control2.minY = -Infinity;
            control2.maxY =  Infinity;

        } else {                                        // prospective point P2_x, P2_y would fall beyond allowed radius
            /*
             We can't blame solely x- or y- for this but must share the burden of correction in a fair way.
             So I take the ray from P1x,y to the prospective P2_x,y and scale that down to be slightly shorter than "L"
             */
            let shrinkFac = L / Math.sqrt( dx * dx + dy * dy );
            
            dx *= shrinkFac;
            dy *= shrinkFac;
            
            P2x = Math.floor( 10 * ( dx + P1x ) ) / 10;
            control2.minX = P2x;
            control2.maxX = P2x;

            if ( dy >= 0 ) {                                // if prospective P2_ is ABOVE P1 ...
                
                P2y = Math.floor( 10 * ( dy + P1y ) ) / 10; // ... stay a little bit _below_ what was attempted
                control2.maxY = P2y;

            } else {                                        // if prospective P2_ is BELOW P1 ...
                
                P2y = Math.ceil( 10 * ( dy + P1y ) ) / 10;  // ... stay a little bit _above_ what was attempted
                control2.minY = P2y;

            }
            
            positionFolder.controllers[0].object.P2x = P2x;
            positionFolder.controllers[0].object.P2y = P2y;

        }

        render();
        
    } );
    control2.addEventListener( 'dragging-changed', function ( event ) {

        orbit.enabled = ! event.value;
        
        event.target._offset.x = 0;      // workaround for suspected error in TransformControls.js
        startP2x = P2x;
        control.minX = P2x;
        control.maxX = P2x;
        
        event.target._offset.y = 0;      // workaround for suspected error in TransformControls.js
        startP2y = P2y;
        control.minY = P2y;
        control.maxY = P2y;
    } );


    /***************************** Axes Helper*********************************/
    // my own Axes Helper; RGB are used for three.js' X-Y-Z directions, respectively
    let headLength = 4;
    let headWidth = 2;
                                                
    let dir = new THREE.Vector3( 1, 0, 0 );     // my X-direction
    let origin = new THREE.Vector3( -20, 0, 0 );
    let length = 80;
    let hex = 0xff0000;
    let xHelper = new THREE.ArrowHelper( dir, origin, length, hex, headLength, headWidth );
    Achsen_Group.add( xHelper );

    dir = new THREE.Vector3( 0, 1, 0 );        // my Y-direction
    origin.set( 0, -20, 0 );
    length = 80;
    hex = 0x00ff00;
    let yHelper = new THREE.ArrowHelper( dir, origin, length, hex, headLength, headWidth );
    Achsen_Group.add( yHelper );

    dir = new THREE.Vector3( 0, 0, 1 );         // my Z-direction
    origin.set( 0, 0, - 30 );
    length = 80;
    hex = 0x0000ff;
    let zHelper = new THREE.ArrowHelper( dir, origin, length, hex, headLength, headWidth );
    Achsen_Group.add( zHelper );
    
    Achsen_Group.visible = axesHelper;
   
    /***************************** end of my Axes Helper*********************************/
    
    /***************************** visible markers + gizmos for P1, P2 ******************/
    
    const geometry = new THREE.SphereGeometry( 5, 16, 8 );
    const material = new THREE.MeshPhongMaterial( { color: 0xff0000, flatShading: true } );
    const sphere1 = new THREE.Mesh( geometry, material );
    sphere1.position.x = P1x;
    sphere1.position.y = P1y;
    sphere1.name = "P1";
    scene.add( sphere1 );
    
    control.attach( sphere1 );
    
    const gizmo = control.getHelper();
    scene.add( gizmo );

    control.setMode( 'translate' );
    control.showZ = false;          // allow only movemnts along x- and y-axis
    
    
    const geometry2 = new THREE.SphereGeometry( 5, 16, 8 );
    const sphere2 = new THREE.Mesh( geometry2, material );
    sphere2.position.x = P2x;
    sphere2.position.y = P2y;
    sphere2.name = "P2";
    scene.add( sphere2 );
    
    control2.attach( sphere2 );
    
    const gizmo2 = control2.getHelper();
    scene.add( gizmo2 );

    control2.setMode( 'translate' );
    control2.showZ = false;          // allow only movemnts along x- and y-axis
    
    /*********************** end of visible markers + gizmos for P1, P2 ******************/
    

    // Lights

    const light = new THREE.AmbientLight( 0xffffff, 0.3 );
    camera.add( light );

    const directionalLight = new THREE.DirectionalLight( 0xffffff, 2.5 );
    directionalLight.position.set( 0.5, 0.0, 0.866 );
    camera.add( directionalLight );
    
    window.addEventListener( 'resize', onWindowResize, false );

    render();

}


function onWindowResize() {

    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    orbit.update();

    renderer.setSize( window.innerWidth, window.innerHeight );
    render();

}


function recomputeCatenary() {

    // shamelessly adapted from: https://trinket.io/glowscript/f938182b85
    // who was inspired by this author: https://math.stackexchange.com/questions/3557767/how-to-construct-a-catenary-of-a-specified-length-through-two-specified-points

    dx = P2x - P1x;
    let xb = ( P2x + P1x ) / 2;

    dy = P2y - P1y;
    let yb = ( P2y + P1y ) / 2;

    let r = Math.sqrt( L * L - dy * dy ) / dx;

    let A = 0.01;
    let dA = 0.0001;
    
    let left = r * A;
    let right = Math.sinh( A );
    
    while ( left > right ) {
        left = r * A;
        right = Math.sinh( A );
        A = A + dA;
    }

    let a = dx / ( 2 * A );
    let b = xb - a * Math.atanh( dy / L );
    let c = P1y - a * Math.cosh( ( P1x - b ) / a );
    
    catPositionAttribute = catLine.geometry.getAttribute( 'position' );
    let i = 0;
    
    let x = P1x;
    let ddx = 1.0;
    let y = 0.0;

    while ( x < P2x ) {
        
        y = a * Math.cosh( ( x - b ) / a ) + c;
        
        catPositionAttribute.setXYZ( i, x, y, 0.0 );
        
        x = x + ddx;
        i++;
        
    }
    
    catLine.geometry.setDrawRange( 0, i );
    catPositionAttribute.needsUpdate = true;

}


function render() {

    if ( positionFolder ) {
        positionFolder.controllers[0].object.P1X = scene.children[4].position.x; // update the UI display to reflect the current value
        positionFolder.controllers[0].object.P1Y = scene.children[4].position.y; // update the UI display to reflect the current value
        guiP1x.updateDisplay();
        guiP1y.updateDisplay();
        
        positionFolder.controllers[0].object.P2X = scene.children[6].position.x; // update the UI display to reflect the current value
        positionFolder.controllers[0].object.P2Y = scene.children[6].position.y; // update the UI display to reflect the current value
        guiP2x.updateDisplay();
        guiP2y.updateDisplay();
    }
    
    recomputeCatenary();
    
    renderer.render( scene, camera );

}


function buildGUI() {

    gui = new GUI( { width: 300 } );

    let params = {

        'Helpers': axesHelper,
        'P1X': P1x,
        'P1Y': P1y,
        'P2X': P2x,
        'P2Y': P2y,
        'Length': L

    };

    helperFolder = gui.addFolder( 'Helper' );

    helperFolder.add( params, 'Helpers' ).onChange( setupAxes );

    function setupAxes( val ) {

        axesHelper = true;
        Achsen_Group.visible = val;
        render();

    }

    positionFolder = gui.addFolder( 'Positions' );

    guiP1x = positionFolder.add( params, 'P1X', -500, 0, 1  ).onChange( function ( val ) {

        let dxmax = Math.sqrt( L * L - dy * dy );  // max distance for straight line connection between P1 and P2
        
        if ( val >= (P2x - dxmax) ) {
            
            P1x = val;
            
        } else {
            
            P1x = Math.ceil( 10 * (P2x - dxmax)) / 10;         // round down to next 1/10th unit to be "not too long"
            positionFolder.controllers[0].object.P1x = P1x;
            catLength.updateDisplay();              // update the UI display to reflect the current value
        }
        
        startP1x = P1x;
        scene.children[4].position.x = P1x;
        render();

    } );

    guiP1y = positionFolder.add( params, 'P1Y', -500, 500, 1  ).onChange( function ( val ) {

        let dymax = Math.sqrt( L * L - dx * dx );   // max distance for straight line connection between P1 and P2
        
        if ( val >= ( P2y - dymax) &&
             val <= ( P2y + dymax) ) {
            
            P1y = val;                              // P1y manipulation is allowed within less than P2y ± dymax
            
        } else {
            
            if ( val < ( P2y - dymax ) ) {
                P1y = Math.ceil( 10 * (P2y - dymax)) / 10;
            } else if ( val > (P2y + dymax) ) {
                P1y = Math.floor( 10 * (P2y + dymax)) / 10;
            }
            
            positionFolder.controllers[0].object.P1y = P1y;
            catLength.updateDisplay();              // update the UI display to reflect the current value
        }
        
        startP1y = P1y;
        scene.children[4].position.y = P1y;
        render();

    } );

    guiP2x = positionFolder.add( params, 'P2X', 0, 500, 1  ).onChange( function ( val ) {

        let dxmax = Math.sqrt( L * L - dy * dy );  // max distance for straight line connection between P1 and P2
        
        if ( val <= ( P1x + dxmax ) ) {
            
            P2x = val;
            
        } else {
            
            P2x = Math.floor( 10 * (P1x + dxmax)) / 10;         // round down to next 1/10th unit to be "not too long"
            positionFolder.controllers[0].object.P2x = P2x;
            catLength.updateDisplay();              // update the UI display to reflect the current value
        }

        startP2x = P2x;
        scene.children[6].position.x = P2x;
        render();

    } );

    guiP2y = positionFolder.add( params, 'P2Y', -500, 500, 1  ).onChange( function ( val ) {

        let dymax = Math.sqrt( L * L - dx * dx );   // max distance for straight line connection between P1 and P2
                
        
        
        if ( val >= ( P1y - dymax) &&
             val <= ( P1y + dymax) ) {
            
            P2y = val;                              // P2y manipulation is allowed within less than P1y ± dymax
            
        } else {
            
            if ( val < ( P1y - dymax ) ) {
                P2y = Math.ceil( 10 * (P1y - dymax)) / 10;
            } else if ( val > (P1y + dymax) ) {
                P2y = Math.floor( 10 * (P1y + dymax)) / 10;
            }
            
            positionFolder.controllers[0].object.P2y = P2y;
            catLength.updateDisplay();              // update the UI display to reflect the current value
        }
        
        startP2y = P2y;
        scene.children[6].position.y = P2y;
        render();

    } );
    
    lengthFolder = gui.addFolder( 'Length' );

    catLength = lengthFolder.add( params, 'Length', 1, 1500, 0.1  ).onChange( function ( val ) {

        let Lmin = Math.sqrt( dx * dx + dy * dy );  // straight line connection between P1 and P2
        
        if ( val >= Lmin ) {
            
            L = val;
            
        } else {
            
            L = Math.ceil( 10 * Lmin) / 10;         // round up to next 1/10th unit to be "long enough"
            lengthFolder.controllers[0].object.Length = L;
            catLength.updateDisplay();              // update the UI display to reflect the current value
        }
        
        render();

    } );




    helperFolder.close();
    positionFolder.open();

    gui.open();

}

              
            
!
999px

Console