{
  "schemaVersion": 1,
  "id": "pendulum-wave",
  "version": "1.0.0",
  "status": "published",
  "kind": "object",
  "title": "Pendulum Wave",
  "summary": "A row of pendulums released together that drift through waves, snakes and chaos and line up again, as a perfect loop.",
  "description": "The classic pendulum-wave desk toy: a row of balls on V-shaped strings under a bar. Each one swings slightly faster than its neighbour, so the row ripples into travelling waves, a snake, splits into two and three groups, looks like chaos, and then falls back into one straight line exactly as the loop ends. Choose chrome, brass or two-colour gradient balls, how many there are, how fast the pattern plays and the camera view.",
  "role": "Hero object: a satisfying pendulum-wave apparatus that loops exactly.",
  "collections": [
    "satisfying",
    "hypnotic-loops"
  ],
  "tags": [
    "pendulum",
    "pendulum wave",
    "satisfying",
    "physics",
    "wave",
    "desk toy",
    "kinetic sculpture",
    "loop",
    "chrome",
    "3d"
  ],
  "synonyms": [
    "pendulum waves",
    "swinging balls",
    "harmonic pendulums",
    "wave machine",
    "newton cradle",
    "satisfying loop",
    "kinetic art",
    "rhythm pendulums"
  ],
  "useCases": [
    "A satisfying loop for a reel or an ad break",
    "Background motion for science or education content",
    "A calm, hypnotic idle screen"
  ],
  "featured": null,
  "publishedAt": "2026-10-05",
  "implementation": {
    "target": "three",
    "runtime": "browser",
    "language": "javascript",
    "module": "esm",
    "entry": "src/pendulum-wave.js",
    "exports": [
      {
        "name": "createPendulumWave",
        "signature": "createPendulumWave({ count?, swings?, spacing?, longest?, realism?, barHeight?, bobRadius?, vSpread?, bobMaterial?, bobColors?, bobFinish?, frameMaterial?, stringColor?, frame? }) => { group, lengths, update(progress, amplitude?), dispose() }",
        "description": "The apparatus as a THREE.Group (row along X, swings toward ±Z, stands on y = 0). update(progress 0…1) poses every pendulum."
      },
      {
        "name": "pendulumAngles",
        "signature": "pendulumAngles(progress, { count?, swings?, amplitude? }) => number[]",
        "description": "Swing angles in radians; pendulum i makes swings + i whole swings per loop, so progress 0 and 1 match."
      },
      {
        "name": "pendulumLengths",
        "signature": "pendulumLengths({ count?, swings?, longest?, realism? }) => number[]",
        "description": "String lengths, longest first, graded like the real apparatus."
      },
      {
        "name": "gradeColors",
        "signature": "gradeColors(count, stops) => THREE.Color[]",
        "description": "Colours graded across the row through two or more stops."
      }
    ],
    "reusable": [
      "src/pendulum-wave.js"
    ],
    "demo": "demo.js",
    "harness": "index.html"
  },
  "dependencies": [
    {
      "id": "three",
      "version": "0.185.1",
      "reason": "Geometry, physical materials and shadows."
    },
    {
      "id": "chrome-material",
      "version": "1.1.0",
      "scope": "demo",
      "reason": "Demo uses the shared studio (environment light, cove, floor) and the chrome material for the balls."
    },
    {
      "id": "apw-runtime",
      "version": "1.0.0",
      "scope": "demo",
      "reason": "Demo harness."
    }
  ],
  "params": [
    {
      "key": "balls",
      "label": "Balls",
      "type": "select",
      "default": "chrome",
      "options": [
        {
          "value": "chrome",
          "label": "Chrome"
        },
        {
          "value": "brass",
          "label": "Brass"
        },
        {
          "value": "gradient",
          "label": "Two-colour gradient"
        }
      ]
    },
    {
      "key": "colorA",
      "label": "First ball colour",
      "help": "Used by the two-colour gradient.",
      "type": "color",
      "default": "#ff6b5e"
    },
    {
      "key": "colorB",
      "label": "Last ball colour",
      "help": "Used by the two-colour gradient.",
      "type": "color",
      "default": "#6b5cff"
    },
    {
      "key": "count",
      "label": "Pendulums",
      "type": "integer",
      "min": 8,
      "max": 24,
      "step": 1,
      "default": 15
    },
    {
      "key": "swings",
      "label": "Pattern speed",
      "help": "Swings the longest pendulum makes in one loop. More swings play the pattern faster.",
      "type": "integer",
      "min": 6,
      "max": 30,
      "step": 1,
      "default": 12
    },
    {
      "key": "amplitude",
      "label": "Swing",
      "help": "How far the balls swing, in degrees.",
      "type": "number",
      "min": 8,
      "max": 35,
      "step": 1,
      "default": 24
    },
    {
      "key": "view",
      "label": "Camera",
      "type": "select",
      "default": "three",
      "options": [
        {
          "value": "three",
          "label": "Three-quarter"
        },
        {
          "value": "front",
          "label": "Front"
        },
        {
          "value": "along",
          "label": "Along the row"
        }
      ]
    },
    {
      "key": "background",
      "label": "Background",
      "type": "color",
      "default": "#0b0b10"
    }
  ],
  "presets": [
    {
      "id": "default",
      "label": "Chrome on black",
      "params": {}
    },
    {
      "id": "brass",
      "label": "Brass desk toy",
      "params": {
        "balls": "brass",
        "count": 12,
        "background": "#17110c",
        "view": "front"
      }
    },
    {
      "id": "candy",
      "label": "Coral to violet",
      "params": {
        "balls": "gradient",
        "count": 20,
        "swings": 14,
        "background": "#ece7f4",
        "view": "along"
      }
    }
  ],
  "output": {
    "width": 1080,
    "height": 1080,
    "fps": 30,
    "durationSeconds": 12,
    "posterSeconds": 3.4,
    "alpha": false,
    "motion": "procedural",
    "loop": {
      "seamless": true,
      "cycleSeconds": 12,
      "method": "Pendulum i makes a whole number of swings per loop (Pattern speed + i), all released together, so at the end every ball is back in line where it started; the camera drift is one whole sine cycle."
    },
    "seed": null
  },
  "composition": {
    "inputs": "Loop progress 0…1 (frame / total frames) and the swing amplitude in radians. Needs scene.environment for metal balls.",
    "outputs": "A THREE.Group: the A-frame stand, the bar and the pendulums.",
    "coordinates": "Row along X centred on the origin (width ≈ count × 0.26), bar at y = 2.5, balls swing toward ±Z, the stand's feet at y = 0 and z = ±0.62.",
    "lighting": "Image-based (scene.environment) for chrome and brass; a shadow-casting directional light gives the swinging floor shadows.",
    "timing": "update(progress) is a pure function of progress; frames render in any order, and any clip length loops.",
    "compatibleWith": [
      "chrome-material",
      "orbit-camera",
      "kinetic-title",
      "gradient-background"
    ],
    "notes": [
      "Keep Pattern speed whole: the loop is exact only for whole numbers of swings.",
      "At short loops the swings are faster than real pendulums of these lengths; lengths still grade like the real apparatus."
    ]
  },
  "reuse": [
    "Satisfying loop: render the chrome version on black for a reel; the line-up at the end makes a natural cut point.",
    "Brand colours: set the two gradient colours to a brand palette for an idle or loading loop.",
    "Science explainer: pair it with Kinetic Title to label the moments when the row splits into two and three groups."
  ],
  "integration": {
    "newProject": "Create a Three.js scene with an environment map (createStudioEnvironment from Chrome Material), add createPendulumWave().group and call update(frame / totalFrames) every frame.",
    "existingProject": "Copy src/pendulum-wave.js. Add wave.group to your scene, scale it to fit, and call wave.update(progress, amplitude) from your frame loop with progress running 0…1 over your loop."
  },
  "performance": {
    "notes": "About 90 meshes for 15 pendulums; nothing is computed per frame except one rotation per pendulum.",
    "limits": "Shadow maps are the main cost; drop them or lower the map size on phones."
  },
  "limitations": [
    "Not a physics simulation: the swings are exact closed-form motion, which is what makes the loop perfect, but the balls never collide or lose energy.",
    "Each pendulum swings in one plane only, as on the real bifilar apparatus."
  ],
  "license": {
    "spdx": "MIT",
    "notice": "Copyright (c) 2026 AI Pixel Wizard. Released under the MIT License. See LICENSE in this folder.",
    "thirdParty": [
      {
        "name": "three.js",
        "version": "0.185.1",
        "license": "MIT",
        "url": "https://github.com/mrdoob/three.js"
      }
    ]
  },
  "provenance": "Adapted from the owner's threejs-sketches/audio-musical/rhythm-pendulums (MIT), rebuilt for AI Pixel Wizard in October 2026 as the real bifilar pendulum-wave apparatus with exact whole-swing timing, the shared studio and the frame/time contract.",
  "changelog": [
    {
      "version": "1.0.0",
      "date": "2026-10-05",
      "notes": "Initial release."
    }
  ]
}
