cute_drones

Ausführen ZIP (lokal ausführbar)
Embed
<iframe src="https://code.turboflip.de/run/a867ff3c74ca2e7d/" sandbox="allow-scripts allow-forms allow-modals" loading="lazy" style="border:0;width:100%;height:480px"></iframe>
index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Drone Choreography Simulator</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <div id="app">
    <canvas id="canvas"></canvas>

    <div id="hud">
      <div id="hud-status">
        <span id="hud-mode" class="chip">MANUAL</span>
        <span id="hud-rec" class="chip hidden">● REC</span>
        <span id="hud-input" class="chip">keyboard</span>
      </div>
      <div id="hud-stats">
        <span id="hud-fps">-- fps</span>
        <span id="hud-drones">1 drone</span>
      </div>
    </div>

    <button id="panelToggle" title="Toggle panel (Esc)">☰</button>

    <aside id="panel">
      <div id="panelHeader">
        <div>
          <h1>Drone Sim</h1>
          <p class="subtitle">Quadcopter choreography prototype</p>
        </div>
        <button id="panelClose" title="Close">✕</button>
      </div>
      <div id="panelContent"></div>
    </aside>
  </div>

  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
  <script src="js/noise.js"></script>
  <script src="js/config.js"></script>
  <script src="js/physics.js"></script>
  <script src="js/flags.js"></script>
  <script src="js/input.js"></script>
  <script src="js/choreography.js"></script>
  <script src="js/bots.js"></script>
  <script src="js/ui.js"></script>
  <script src="js/main.js"></script>
</body>
</html>
js/bots.js
/**
 * bots.js — autonomous drones (bots) with offsets + swarm behaviour.
 *
 * Each bot has a config (offset, behaviour, follow strength, colour) that
 * defines an *anchor target* (its role in the choreography). On top of that a
 * Boids-style swarm layer (separation / cohesion / alignment + mosquito-like
 * jitter) makes the drones avoid each other and move as a natural swarm.
 * The result is converted into a desired up-vector + yaw + throttle and fed
 * into the same Quadcopter.step used by the manual lead drone.
 *
 * Realism notes:
 *  - the desired tilt is limited to the drone's flight envelope (maxTilt)
 *  - the desired yaw is a *state* that turns at a limited rate (botYawRate),
 *    so bots can't snap around when their velocity direction flips
 *  - formation offsets follow the lead's *heading* only, not its tilt, so
 *    the lead's wobble is not amplified into the whole swarm
 */
(function () {
  'use strict';

  const AXIS_Y = new THREE.Vector3(0, 1, 0);
  const _off = new THREE.Vector3();
  const _tgt = new THREE.Vector3();
  const _hA = new THREE.Vector3();
  const _desUp = new THREE.Vector3();
  const _mirror = new THREE.Vector3(-1, 1, -1);
  const _q = new THREE.Quaternion();
  const _qYaw = new THREE.Quaternion();
  const clamp = THREE.MathUtils.clamp;

  function wrapPI(a) {
    a = (a + Math.PI) % (Math.PI * 2);
    if (a < 0) a += Math.PI * 2;
    return a - Math.PI;
  }

  /**
   * Compute the anchor target (position + yaw) for a bot from its behaviour.
   */
  function behaviorTarget(cfg, lead, bot, time, P) {
    const off = cfg.offset;
    let yaw = lead.yaw;
    _qYaw.setFromAxisAngle(AXIS_Y, lead.yaw); // heading only (no tilt)

    switch (cfg.behavior) {
      case 'follow':
        _tgt.copy(lead.pos).add(_off.set(off.x, off.y, off.z).applyQuaternion(_qYaw));
        break;

      case 'hover':
        _tgt.copy(bot.anchor);
        break;

      case 'orbit': {
        const ang = time * THREE.MathUtils.degToRad(P.orbitSpeed);
        const r = Math.max(0.4, Math.hypot(off.x, off.z));
        _tgt.set(
          lead.pos.x + Math.cos(ang) * r,
          lead.pos.y + off.y,
          lead.pos.z + Math.sin(ang) * r
        );
        const dx = -Math.sin(ang), dz = Math.cos(ang);
        yaw = Math.atan2(-dz, dx);
        break;
      }

      case 'bob':
        _tgt.copy(lead.pos).add(_off.set(off.x, off.y, off.z).applyQuaternion(_qYaw));
        _tgt.y += Math.sin(time * Math.PI * 2 * P.bobFreq) * P.bobAmp;
        break;

      case 'mirror':
        _tgt.copy(lead.pos).multiply(_mirror).add(_off.set(off.x, off.y, off.z));
        break;

      default:
        _tgt.copy(lead.pos).add(_off.set(off.x, off.y, off.z).applyQuaternion(_qYaw));
    }

    return { pos: _tgt, yaw };
  }

  /**
   * Turn the accumulated horizontal accel (_hA) + vertical accel into
   * { throttle, up }. Limits accel and tilt to the flight envelope.
   */
  function buildControl(drone, vA, P) {
    const g = P.gravity;
    vA = clamp(vA, -g * 0.85, g * 3);

    let hMag = Math.hypot(_hA.x, _hA.z);
    if (hMag > P.botMaxAccel) {
      const s = P.botMaxAccel / hMag;
      _hA.x *= s; _hA.z *= s; hMag = P.botMaxAccel;
    }

    // never ask for more tilt than the envelope allows (stay a bit inside it)
    const upAcc = g + vA;
    const hLimit = upAcc * Math.tan(THREE.MathUtils.degToRad(P.maxTilt) * 0.9);
    if (hMag > hLimit) {
      const s = hLimit / hMag;
      _hA.x *= s; _hA.z *= s;
    }

    _desUp.set(_hA.x, upAcc, _hA.z).normalize();
    const thrust = (drone.params.mass * upAcc) / Math.max(0.15, _desUp.y);
    const throttle = clamp(thrust / P.maxThrust, 0, 1);
    return { throttle, up: _desUp.clone() };
  }

  /**
   * Swarm autopilot -> control inputs (desired up + yaw hint + throttle).
   * Combines target-seeking PD with Boids steering (separation, cohesion,
   * alignment) and a smooth per-bot jitter (mosquito-like wandering).
   */
  function swarmControl(bot, targetPos, targetYaw, fs, P, positions, velocities, selfIdx, time, botIdx) {
    // ---- target seeking (PD on position) ----
    _hA.set(targetPos.x - bot.pos.x, 0, targetPos.z - bot.pos.z).multiplyScalar(P.botKp * fs);
    _hA.x -= bot.vel.x * P.botKd * fs;
    _hA.z -= bot.vel.z * P.botKd * fs;
    let vA = (targetPos.y - bot.pos.y) * P.botKpV - bot.vel.y * P.botKdV;

    // ---- boids ----
    const sepR = P.swarmSepRadius;
    const cohR = P.swarmNeighborRadius, cohR2 = cohR * cohR;
    let sepX = 0, sepY = 0, sepZ = 0;
    let cx = 0, cy = 0, cz = 0, vx = 0, vy = 0, vz = 0, nN = 0;

    for (let k = 0; k < positions.length; k++) {
      if (k === selfIdx) continue;
      const o = positions[k];
      const dx = o.x - bot.pos.x, dy = o.y - bot.pos.y, dz = o.z - bot.pos.z;
      const d2 = dx * dx + dy * dy + dz * dz;
      if (d2 >= cohR2 || d2 < 1e-6) continue;
      const d = Math.sqrt(d2);
      if (d < sepR) {
        // separation: push away, linear falloff to 0 at sepR
        const w = (1 - d / sepR) * P.swarmSepWeight / d;
        sepX -= dx * w; sepY -= dy * w; sepZ -= dz * w;
      }
      cx += o.x; cy += o.y; cz += o.z;
      vx += velocities[k].x; vy += velocities[k].y; vz += velocities[k].z;
      nN++;
    }

    if (nN > 0) {
      cx /= nN; cy /= nN; cz /= nN;
      vx /= nN; vy /= nN; vz /= nN;
      _hA.x += (cx - bot.pos.x) * P.swarmCohWeight + (vx - bot.vel.x) * P.swarmAlignWeight;
      _hA.z += (cz - bot.pos.z) * P.swarmCohWeight + (vz - bot.vel.z) * P.swarmAlignWeight;
      vA += (cy - bot.pos.y) * P.swarmCohWeight + (vy - bot.vel.y) * P.swarmAlignWeight;
    }
    _hA.x += sepX; _hA.z += sepZ;
    vA += sepY;

    // ---- mosquito-like jitter (smooth, per-bot, calmer when crowded) ----
    const crowd = nN > 0 ? clamp(nN / 8, 0, 1) : 0;
    const jit = P.swarmJitter * (1 - 0.75 * crowd);
    _hA.x += Math.sin(time * 1.6 + botIdx * 2.1) * Math.cos(time * 1.2 + botIdx * 1.9) * jit;
    _hA.z += Math.sin(time * 1.4 + botIdx * 1.5) * Math.cos(time * 1.8 + botIdx * 2.6) * jit;
    vA += Math.sin(time * 1.1 + botIdx * 3.1) * 0.5 * jit;

    const ctrl = buildControl(bot, vA, P);

    // face the direction of travel when moving (smooth blend), else the
    // behaviour yaw. The caller rate-limits the resulting yaw.
    const hv = Math.hypot(bot.vel.x, bot.vel.z);
    const wv = clamp((hv - 0.25) / 0.35, 0, 1);
    const velYaw = Math.atan2(-bot.vel.z, bot.vel.x);
    ctrl.yaw = targetYaw + wrapPI(velYaw - targetYaw) * wv;
    return ctrl;
  }

  /**
   * Pure target-seeking autopilot (no boids/jitter) — used for the lead drone
   * and its position/altitude hold. Returns desired up + yaw + throttle.
   */
  function autopilotControl(bot, targetPos, targetYaw, fs, P) {
    _hA.set(targetPos.x - bot.pos.x, 0, targetPos.z - bot.pos.z).multiplyScalar(P.botKp * fs);
    _hA.x -= bot.vel.x * P.botKd * fs;
    _hA.z -= bot.vel.z * P.botKd * fs;
    const vA = (targetPos.y - bot.pos.y) * P.botKpV - bot.vel.y * P.botKdV;

    const ctrl = buildControl(bot, vA, P);
    ctrl.yaw = targetYaw;
    return ctrl;
  }

  class BotManager {
    constructor(scene, params) {
      this.scene = scene;
      this.params = params;
      this.bots = []; // { config, drone }
      this._positions = [];
      this._velocities = [];
    }

    count() { return this.bots.length; }

    addBot(config, leadPos, droneFactory) {
      const mesh = droneFactory(config.color);
      this.scene.add(mesh);
      const drone = new Quadcopter(this.params, mesh);
      drone.reset(leadPos.clone().add(_off.set(config.offset.x, config.offset.y, config.offset.z)), 0);
      const hover = this.params.mass * this.params.gravity / this.params.maxThrust;
      const bot = {
        config, drone,
        anchor: new THREE.Vector3(),
        smoothUp: new THREE.Vector3(0, 1, 0),
        smoothThrottle: hover,
        yawCmd: 0, // rate-limited heading command
      };
      bot.anchor.copy(drone.pos);
      this.bots.push(bot);
      return bot;
    }

    removeBot(index) {
      const bot = this.bots[index];
      if (!bot) return;
      this.scene.remove(bot.drone.mesh);
      this.bots.splice(index, 1);
    }

    sync(configs, leadPos, droneFactory) {
      while (this.bots.length < configs.length) {
        const cfg = configs[this.bots.length];
        this.addBot(cfg, leadPos, droneFactory);
      }
      while (this.bots.length > configs.length) {
        this.removeBot(this.bots.length - 1);
      }
      for (let i = 0; i < configs.length; i++) {
        const bot = this.bots[i];
        const cfg = configs[i];
        bot.config = cfg;
        if (bot.drone.mesh.userData.color !== cfg.color) {
          bot.drone.mesh.userData.setCamo(cfg.color);
          bot.drone.mesh.userData.color = cfg.color;
        }
      }
    }

    resetAll(leadPos, leadYaw) {
      const leadQ = _q.setFromAxisAngle(AXIS_Y, leadYaw);
      const hover = this.params.mass * this.params.gravity / this.params.maxThrust;
      for (const bot of this.bots) {
        _off.set(bot.config.offset.x, bot.config.offset.y, bot.config.offset.z).applyQuaternion(leadQ);
        bot.drone.reset(leadPos.clone().add(_off), leadYaw);
        bot.anchor.copy(bot.drone.pos);
        bot.yawCmd = leadYaw;
        bot.smoothUp.set(0, 1, 0);
        bot.smoothThrottle = hover;
      }
    }

    setAnchors(leadPos) {
      for (const bot of this.bots) {
        _off.set(bot.config.offset.x, bot.config.offset.y, bot.config.offset.z);
        bot.anchor.copy(leadPos).add(_off);
      }
    }

    /**
     * @param {object} lead { pos, quat, yaw, vel }
     * @param {number} dt
     * @param {number} time simulation time
     * @param {object} P config values
     * @param {WindField} wind
     */
    update(lead, dt, time, P, wind) {
      const n = this.bots.length;
      // build neighbour arrays (lead is index 0)
      const positions = this._positions;
      const velocities = this._velocities;
      positions.length = n + 1;
      velocities.length = n + 1;
      positions[0] = lead.pos; velocities[0] = lead.vel;
      for (let i = 0; i < n; i++) {
        positions[i + 1] = this.bots[i].drone.pos;
        velocities[i + 1] = this.bots[i].drone.vel;
      }

      const maxTurn = THREE.MathUtils.degToRad(P.botYawRate);
      const alpha = 1 - Math.exp(-P.botSmoothing * dt);

      for (let i = 0; i < n; i++) {
        const bot = this.bots[i];
        if (bot.config.enabled === false) {
          bot.drone.syncMesh();
          continue;
        }
        const target = behaviorTarget(bot.config, lead, bot, time, P);
        const ctrl = swarmControl(
          bot.drone, target.pos, target.yaw, bot.config.followStrength,
          P, positions, velocities, i + 1, time, i
        );

        // smooth the control signal (low-pass) to remove jitter
        bot.smoothUp.lerp(ctrl.up, alpha).normalize();
        bot.smoothThrottle += (ctrl.throttle - bot.smoothThrottle) * alpha;
        ctrl.up.copy(bot.smoothUp);
        ctrl.throttle = bot.smoothThrottle;

        // turn towards the wanted heading at a limited rate (no snapping)
        const dy = wrapPI(ctrl.yaw - bot.yawCmd);
        bot.yawCmd += clamp(dy * 4, -maxTurn, maxTurn) * dt;
        ctrl.yaw = bot.yawCmd;
        ctrl.maxYawRate = P.botYawRate; // physical turn-rate limit too (no catch-up spin)

        const w = wind.sample(bot.drone.pos, time, P);
        bot.drone.step(dt, ctrl, w);
        bot.drone.updateRotors(dt);
        bot.drone.syncMesh();
      }
    }

    syncMeshes() {
      for (const bot of this.bots) bot.drone.syncMesh();
    }
  }

  window.behaviorTarget = behaviorTarget;
  window.swarmControl = swarmControl;
  window.autopilotControl = autopilotControl;
  window.BotManager = BotManager;
})();
js/choreography.js
/**
 * choreography.js — recording & playback of flight tracks.
 *
 * A Track is a list of timestamped poses (position + quaternion) sampled from
 * the lead drone. Playback interpolates between samples, optionally looping.
 * This is the foundation of the "choreography" idea: record your flight once,
 * then replay it as the lead while bots follow with their offsets/behaviors.
 */
(function () {
  'use strict';

  class Recorder {
    constructor() {
      this.recording = false;
      this.t = 0;
      this.frames = [];
      this.interval = 1 / 30; // sample every ~30ms
      this._sinceSample = 0;
    }

    start() {
      this.frames = [];
      this.t = 0;
      this.recording = true;
      this._sinceSample = 0;
    }

    stop() {
      this.recording = false;
      return new Track(this.frames);
    }

    clear() {
      this.frames = [];
      this.t = 0;
      this.recording = false;
    }

    /**
     * @param {number} dt simulation dt
     * @param {THREE.Vector3} pos
     * @param {THREE.Quaternion} quat
     */
    update(dt, pos, quat) {
      if (!this.recording) return;
      this.t += dt;
      this._sinceSample += dt;
      if (this._sinceSample >= this.interval) {
        this._sinceSample -= this.interval;
        this.frames.push({
          t: this.t,
          px: pos.x, py: pos.y, pz: pos.z,
          qx: quat.x, qy: quat.y, qz: quat.z, qw: quat.w,
        });
      }
    }

    get duration() { return this.t; }
  }

  class Track {
    constructor(frames) {
      this.frames = frames || [];
      this.duration = this.frames.length ? this.frames[this.frames.length - 1].t : 0;
    }

    get empty() { return this.frames.length === 0; }

    /**
     * Sample the track at time `t`.
     * @param {number} t
     * @param {boolean} loop
     * @param {THREE.Vector3} outPos
     * @param {THREE.Quaternion} outQuat
     * @returns {boolean} whether a sample was produced
     */
    sample(t, loop, outPos, outQuat) {
      if (this.empty) return false;
      if (loop && this.duration > 0) {
        t = t % this.duration;
        if (t < 0) t += this.duration;
      } else if (t < 0) {
        t = 0;
      }
      if (!loop && t >= this.duration) t = this.duration;

      const frames = this.frames;
      // clamp / find segment
      if (t <= frames[0].t) {
        outPos.set(frames[0].px, frames[0].py, frames[0].pz);
        outQuat.set(frames[0].qx, frames[0].qy, frames[0].qz, frames[0].qw);
        return true;
      }
      const last = frames[frames.length - 1];
      if (t >= last.t) {
        outPos.set(last.px, last.py, last.pz);
        outQuat.set(last.qx, last.qy, last.qz, last.qw);
        return true;
      }

      // binary search
      let lo = 0, hi = frames.length - 1;
      while (hi - lo > 1) {
        const mid = (lo + hi) >> 1;
        if (frames[mid].t <= t) lo = mid; else hi = mid;
      }
      const a = frames[lo], b = frames[hi];
      const f = (t - a.t) / Math.max(1e-6, b.t - a.t);
      outPos.set(
        a.px + (b.px - a.px) * f,
        a.py + (b.py - a.py) * f,
        a.pz + (b.pz - a.pz) * f
      );
      outQuat.set(a.qx, a.qy, a.qz, a.qw).slerp(
        _q.set(b.qx, b.qy, b.qz, b.qw), f
      );
      return true;
    }

    toJSON() {
      return {
        interval: 1 / 30,
        frames: this.frames,
      };
    }

    static fromJSON(data) {
      if (!data || !data.frames) return new Track([]);
      return new Track(data.frames);
    }
  }

  const _q = new THREE.Quaternion();
  const _pos = new THREE.Vector3();

  window.Recorder = Recorder;
  window.Track = Track;
})();
js/config.js
/**
 * config.js — central parameter schema + live Config store + bot defaults.
 * The UI is generated from PARAM_DEFS and BEHAVIORS. Save/load serializes
 * Config.values and Config.bots.
 */
(function () {
  'use strict';

  const PARAM_DEFS = {
    // ---- Physics ----
    gravity:      { label: 'Gravity',          group: 'physics', min: 0,    max: 25,   step: 0.1,   value: 9.81,  unit: 'm/s²' },
    mass:         { label: 'Mass',             group: 'physics', min: 0.1,  max: 3,    step: 0.01,  value: 0.65,  unit: 'kg' },
    maxThrust:    { label: 'Max thrust',       group: 'physics', min: 5,    max: 100,  step: 0.5,   value: 26,    unit: 'N' },
    maxTilt:      { label: 'Max tilt',         group: 'physics', min: 5,    max: 65,   step: 1,     value: 38,    unit: '°' },
    yawRate:      { label: 'Max yaw rate',     group: 'physics', min: 30,   max: 720,  step: 5,     value: 220,   unit: '°/s' },
    maxRate:      { label: 'Max tilt rate',    group: 'physics', min: 60,   max: 720,  step: 5,     value: 220,   unit: '°/s' },
    drag:         { label: 'Linear drag',      group: 'physics', min: 0,    max: 4,    step: 0.01,  value: 0.6,   unit: '' },
    dragQuad:     { label: 'Quadratic drag',   group: 'physics', min: 0,    max: 1,    step: 0.005, value: 0.04,  unit: '' },
    Kp:           { label: 'Attitude P',       group: 'physics', min: 1,    max: 30,   step: 0.5,   value: 7,     unit: '1/s' },
    Kd:           { label: 'Rate gain',        group: 'physics', min: 2,    max: 60,   step: 0.5,   value: 18,    unit: '1/s' },
    maxTorque:    { label: 'Max tilt torque',  group: 'physics', min: 0.05, max: 5,    step: 0.05,  value: 0.6,   unit: 'N·m' },
    maxYawTorque: { label: 'Max yaw torque',   group: 'physics', min: 0.01, max: 1,    step: 0.01,  value: 0.12,  unit: 'N·m' },
    motorLag:     { label: 'Motor lag',        group: 'physics', min: 0.005,max: 0.15, step: 0.005, value: 0.035, unit: 's' },
    inertiaPitch: { label: 'Inertia (tilt)',   group: 'physics', min: 0.002,max: 0.3,  step: 0.001, value: 0.013, unit: 'kg·m²' },
    inertiaYaw:   { label: 'Inertia (yaw)',    group: 'physics', min: 0.002,max: 0.5,  step: 0.001, value: 0.022, unit: 'kg·m²' },

    // ---- Wind & turbulence (calm indoor values) ----
    windEnabled:  { label: 'Enable wind',      group: 'wind', type: 'bool', value: true },
    windSpeed:    { label: 'Wind speed',       group: 'wind', min: 0,    max: 18,  step: 0.1,   value: 0.4,   unit: 'm/s' },
    windHeading:  { label: 'Wind heading',     group: 'wind', min: 0,    max: 360, step: 1,     value: 40,    unit: '°' },
    windGust:     { label: 'Gust strength',    group: 'wind', min: 0,    max: 12,  step: 0.1,   value: 0.12,  unit: 'm/s' },
    turbulence:   { label: 'Turbulence force', group: 'wind', min: 0,    max: 8,   step: 0.01,  value: 0.03,  unit: 'N' },
    turbTorque:   { label: 'Turbulence torque',group: 'wind', min: 0,    max: 5,   step: 0.005, value: 0.015, unit: 'N·m' },
    noiseScale:   { label: 'Noise scale',      group: 'wind', min: 0.002,max: 0.6, step: 0.002, value: 0.05,  unit: '' },
    timeScale:    { label: 'Wind time scale',  group: 'wind', min: 0.005,max: 1,   step: 0.005, value: 0.08,  unit: '' },

    // ---- Camera (fixed) ----
    fov:          { label: 'Field of view',    group: 'camera', min: 40,  max: 120, step: 1,   value: 75,   unit: '°' },
    camDistance:  { label: 'Distance (zoom)',  group: 'camera', min: 2,   max: 30,  step: 0.1, value: 7.3,  unit: 'm' },

    // ---- Autopilot (target seeking / position hold) ----
    botKp:        { label: 'Target P',         group: 'autopilot', min: 0.2, max: 20, step: 0.1, value: 4,    unit: '' },
    botKd:        { label: 'Target D',         group: 'autopilot', min: 0,   max: 15, step: 0.1, value: 3.5,  unit: '' },
    botKpV:       { label: 'Vertical P',       group: 'autopilot', min: 0.2, max: 25, step: 0.1, value: 5,    unit: '' },
    botKdV:       { label: 'Vertical D',       group: 'autopilot', min: 0,   max: 15, step: 0.1, value: 4,    unit: '' },
    botMaxAccel:  { label: 'Max accel',        group: 'autopilot', min: 2,   max: 60, step: 0.5, value: 24,   unit: 'm/s²' },
    botSmoothing: { label: 'Control smoothing',group: 'autopilot', min: 1,   max: 40, step: 0.5, value: 12,   unit: '' },
    botYawRate:   { label: 'Bot turn rate',    group: 'autopilot', min: 10,  max: 360,step: 5,   value: 90,   unit: '°/s' },
    leadMaxSpeed: { label: 'Lead max speed',   group: 'autopilot', min: 0.5, max: 10, step: 0.1, value: 3,    unit: 'm/s' },
    orbitSpeed:   { label: 'Orbit speed',      group: 'autopilot', min: 10,  max: 360,step: 5,   value: 90,   unit: '°/s' },
    bobFreq:      { label: 'Bob frequency',    group: 'autopilot', min: 0.1, max: 4,  step: 0.05,value: 0.7,  unit: 'Hz' },
    bobAmp:       { label: 'Bob amplitude',    group: 'autopilot', min: 0.1, max: 3,  step: 0.05,value: 0.5,  unit: 'm' },

    // ---- Swarm (boids) ----
    swarmSepWeight:   { label: 'Separation',      group: 'swarm', min: 0, max: 10, step: 0.1,  value: 1.8,  unit: '' },
    swarmCohWeight:   { label: 'Cohesion',        group: 'swarm', min: 0, max: 6,  step: 0.05, value: 1.0,  unit: '' },
    swarmAlignWeight: { label: 'Alignment',       group: 'swarm', min: 0, max: 6,  step: 0.05, value: 1.2,  unit: '' },
    swarmNeighborRadius: { label: 'Neighbor radius', group: 'swarm', min: 0.2, max: 5, step: 0.05, value: 1.4, unit: 'm' },
    swarmSepRadius:   { label: 'Separation radius', group: 'swarm', min: 0.1, max: 3, step: 0.05, value: 0.45, unit: 'm' },
    swarmJitter:      { label: 'Jitter (mücken)',   group: 'swarm', min: 0, max: 3,  step: 0.05, value: 0.2,  unit: '' },

    // ---- Flags (cloth) ----
    flagsEnabled: { label: 'Show flags',       group: 'flags', type: 'bool', value: true },
    flagLength:   { label: 'Flag length',      group: 'flags', min: 0.05, max: 1.5,  step: 0.01,  value: 0.35, unit: 'm' },
    flagWidth:    { label: 'Flag width',       group: 'flags', min: 0.04, max: 0.3,  step: 0.005, value: 0.12, unit: 'm' },
    flagWeight:   { label: 'Cloth weight',     group: 'flags', min: 20,   max: 600,  step: 5,     value: 120,  unit: 'g/m²' },
    flagDrag:     { label: 'Air drag',         group: 'flags', min: 0,    max: 3,    step: 0.05,  value: 1,    unit: '' },
    flagDamping:  { label: 'Cloth damping',    group: 'flags', min: 0,    max: 3,    step: 0.05,  value: 0.7,  unit: '1/s' },

    // ---- Scene / simulation ----
    simSpeed:     { label: 'Sim speed',        group: 'scene', min: 0.1, max: 3, step: 0.05, value: 1,  unit: '×' },
    showGrid:     { label: 'Ground grid',      group: 'scene', type: 'bool', value: true },
    showTrails:   { label: 'Trajectory trails',group:'scene', type: 'bool', value: false },

    // ---- Playback ----
    playbackSpeed:{ label: 'Playback speed',   group: 'playback', min: 0.1, max: 4, step: 0.05, value: 1, unit: '×' },
  };

  const GROUP_ORDER = ['physics', 'wind', 'camera', 'autopilot', 'swarm', 'flags', 'scene', 'playback'];
  const GROUP_LABELS = {
    physics: 'Physics',
    wind: 'Wind & Turbulence',
    camera: 'Camera',
    autopilot: 'Autopilot',
    swarm: 'Swarm (Boids)',
    flags: 'Flags (Cloth)',
    scene: 'Scene & Simulation',
    playback: 'Playback',
  };

  const BEHAVIORS = [
    { id: 'follow', label: 'Follow (formation)' },
    { id: 'hover',  label: 'Hover (anchor)' },
    { id: 'orbit',  label: 'Orbit' },
    { id: 'bob',    label: 'Bob' },
    { id: 'mirror', label: 'Mirror' },
  ];

  // Pastel palette for the default swarm.
  const PASTEL_PALETTE = [
    '#f4a7b9', '#f7c59f', '#f9e3a0', '#a8d5a2', '#9fc5e8',
    '#c3a6e0', '#f2a6c9', '#9fd8d0', '#f4b8a0', '#d4c1f0',
    '#b8e0c9', '#a8d8e8',
  ];

  // 47 bots + 1 lead = 48 drones total.
  function defaultBots() {
    const bots = [];
    const N = 47;
    for (let i = 0; i < N; i++) {
      const ang = Math.random() * Math.PI * 2;
      const r = 0.3 + Math.random() * 1.3;
      const behavior = (i % 9 === 0) ? 'orbit' : (i % 13 === 0 ? 'bob' : 'follow');
      bots.push({
        name: 'Bot ' + (i + 1),
        color: PASTEL_PALETTE[i % PASTEL_PALETTE.length],
        behavior,
        offset: {
          x: Math.cos(ang) * r,
          y: 0,
          z: Math.sin(ang) * r,
        },
        followStrength: 0.7 + Math.random() * 0.6,
        enabled: true,
      });
    }
    return bots;
  }

  function buildValues() {
    const v = {};
    for (const key in PARAM_DEFS) v[key] = PARAM_DEFS[key].value;
    return v;
  }

  const Config = {
    values: buildValues(),
    bots: defaultBots(),
  };

  function cloneBot(b) {
    return {
      name: b.name,
      color: b.color,
      behavior: b.behavior,
      offset: { x: b.offset.x, y: b.offset.y, z: b.offset.z },
      followStrength: b.followStrength,
      enabled: b.enabled !== false,
    };
  }

  function resetToDefaults() {
    // mutate in place — drones hold a reference to Config.values
    const defs = buildValues();
    for (const key in Config.values) delete Config.values[key];
    Object.assign(Config.values, defs);
    Config.bots = defaultBots();
  }

  window.PARAM_DEFS = PARAM_DEFS;
  window.GROUP_ORDER = GROUP_ORDER;
  window.GROUP_LABELS = GROUP_LABELS;
  window.BEHAVIORS = BEHAVIORS;
  window.PASTEL_PALETTE = PASTEL_PALETTE;
  window.BOT_COLORS = PASTEL_PALETTE; // ui.js "+ Add bot" uses this name
  window.Config = Config;
  window.cloneBot = cloneBot;
  window.resetToDefaults = resetToDefaults;
})();
js/flags.js
/**
 * flags.js — small cloth flags hanging from every drone.
 *
 * Each flag is a 3 × 10 particle grid (slightly tapered towards the tip),
 * simulated with position-based dynamics in world space:
 *   - the top row is pinned to a mount under the drone body (follows its
 *     position AND attitude, interpolated across sub-steps)
 *   - gravity, damping, and per-triangle aerodynamic drag (normal force from
 *     the velocity relative to the wind) -> inertia, trailing, fluttering
 *   - structural + shear + bending distance constraints
 * Length / width / weight / drag are live parameters; the rest lengths are
 * recomputed on change, so the cloth simply relaxes into the new size.
 * The mesh takes the colour of its drone.
 */
(function () {
  'use strict';

  const COLS = 3;             // across
  const ROWS = 10;            // along the length
  const N = COLS * ROWS;
  const TAPER = 0.55;         // tip width relative to top width

  const MAX_SUBSTEP = 1 / 120;
  const MAX_SUBSTEPS = 4;
  const ITERATIONS = 6;
  const RHO_CD = 0.5 * 1.2 * 1.1; // ½ · air density · drag coefficient
  const FLOOR = 0.01;
  const JUMP = 0.6;           // anchor moved further than this in one frame = teleport
  const MAX_ACC = 200;        // m/s², safety clamp

  // mount point in the drone body frame (world-scale metres, drone mesh is scaled 0.62)
  const ATTACH = new THREE.Vector3(-0.012, -0.085, 0);
  const DRONE_SCALE = 0.62;

  const idx = (i, j) => j * COLS + i;

  /* ---- static topology ---- */
  const cA = [], cB = [], cK = [];
  function addCon(a, b, k) { cA.push(a); cB.push(b); cK.push(k); }
  for (let j = 0; j < ROWS; j++) {
    for (let i = 0; i < COLS; i++) {
      if (i < COLS - 1) addCon(idx(i, j), idx(i + 1, j), 1);              // horizontal
      if (j < ROWS - 1) addCon(idx(i, j), idx(i, j + 1), 1);              // vertical
      if (i < COLS - 1 && j < ROWS - 1) {                                 // shear
        addCon(idx(i, j), idx(i + 1, j + 1), 0.8);
        addCon(idx(i + 1, j), idx(i, j + 1), 0.8);
      }
      if (j < ROWS - 2) addCon(idx(i, j), idx(i, j + 2), 0.25);           // bend (vertical)
      if (i < COLS - 2) addCon(idx(i, j), idx(i + 2, j), 0.25);           // bend (horizontal)
    }
  }
  const NC = cA.length;
  const CA = new Int32Array(cA), CB = new Int32Array(cB), CK = new Float32Array(cK);

  const tris = [];
  for (let j = 0; j < ROWS - 1; j++) {
    for (let i = 0; i < COLS - 1; i++) {
      const a = idx(i, j), b = idx(i + 1, j), c = idx(i, j + 1), d = idx(i + 1, j + 1);
      tris.push(a, c, b, b, c, d);
    }
  }
  const TRIS = new Uint16Array(tris);

  const _v = new THREE.Vector3();

  class Flag {
    constructor(scene, color) {
      this.scene = scene;
      this.pos = new Float32Array(N * 3);
      this.vel = new Float32Array(N * 3);
      this.pred = new Float32Array(N * 3);
      this.acc = new Float32Array(N * 3);
      this.lz = new Float32Array(N);   // rest layout: lateral offset
      this.ly = new Float32Array(N);   // rest layout: distance down
      this.rest = new Float32Array(NC);
      this.anchorPrev = new Float32Array(COLS * 3);
      this.anchorCur = new Float32Array(COLS * 3);
      this.length = -1;
      this.width = -1;
      this.area = 0.01;
      this.color = null;
      this.stale = true;
      this.wind = new THREE.Vector3();

      const geo = new THREE.BufferGeometry();
      this.posAttr = new THREE.BufferAttribute(this.pos, 3);
      this.posAttr.setUsage(THREE.DynamicDrawUsage);
      geo.setAttribute('position', this.posAttr);
      geo.setIndex(new THREE.BufferAttribute(TRIS, 1));
      this.mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
        color: new THREE.Color(color), side: THREE.DoubleSide, roughness: 0.85, metalness: 0,
      }));
      this.mesh.frustumCulled = false;
      scene.add(this.mesh);
      this.setColor(color);
    }

    setColor(hex) {
      if (this.color === hex) return;
      this.color = hex;
      this.mesh.material.color.set(hex);
    }

    setDimensions(length, width) {
      this.length = length;
      this.width = width;
      for (let j = 0; j < ROWS; j++) {
        const t = j / (ROWS - 1);
        const w = width * (1 - (1 - TAPER) * t);
        for (let i = 0; i < COLS; i++) {
          const k = idx(i, j);
          this.lz[k] = (i / (COLS - 1) - 0.5) * w;
          this.ly[k] = -t * length;
        }
      }
      for (let c = 0; c < NC; c++) {
        const a = CA[c], b = CB[c];
        this.rest[c] = Math.hypot(this.lz[a] - this.lz[b], this.ly[a] - this.ly[b]);
      }
      this.area = length * width * (1 - (1 - TAPER) * 0.5);
    }

    _computeAnchors(drone, out) {
      for (let i = 0; i < COLS; i++) {
        _v.set(ATTACH.x, ATTACH.y, this.lz[idx(i, 0)]).applyQuaternion(drone.quat).add(drone.pos);
        out[i * 3] = _v.x; out[i * 3 + 1] = _v.y; out[i * 3 + 2] = _v.z;
      }
    }

    /** Hang the cloth straight down from the current mount. */
    reinit(drone) {
      for (let j = 0; j < ROWS; j++) {
        for (let i = 0; i < COLS; i++) {
          const k = idx(i, j), o = k * 3;
          _v.set(ATTACH.x, ATTACH.y, this.lz[k]).applyQuaternion(drone.quat).add(drone.pos);
          _v.y = Math.max(FLOOR, _v.y + this.ly[k]);
          this.pos[o] = _v.x; this.pos[o + 1] = _v.y; this.pos[o + 2] = _v.z;
          this.vel[o] = drone.vel.x; this.vel[o + 1] = drone.vel.y; this.vel[o + 2] = drone.vel.z;
        }
      }
      this._computeAnchors(drone, this.anchorPrev);
      this.anchorCur.set(this.anchorPrev);
      this.stale = false;
    }

    update(drone, dt, time, P, wind) {
      if (P.flagLength !== this.length || P.flagWidth !== this.width) {
        this.setDimensions(P.flagLength, P.flagWidth);
      }
      if (this.stale) this.reinit(drone);

      this._computeAnchors(drone, this.anchorCur);
      const dx = this.anchorCur[0] - this.anchorPrev[0];
      const dy = this.anchorCur[1] - this.anchorPrev[1];
      const dz = this.anchorCur[2] - this.anchorPrev[2];
      if (dx * dx + dy * dy + dz * dz > JUMP * JUMP) this.reinit(drone); // teleport (reset / playback loop)

      // air velocity at the drone (flags see the same wind, without the fast turbulence)
      this.wind.copy(wind.sample(drone.pos, time, P).vel);

      const pm = Math.max(1e-6, (P.flagWeight * 0.001 * this.area) / N); // kg per particle
      const n = Math.min(MAX_SUBSTEPS, Math.max(1, Math.ceil(dt / MAX_SUBSTEP)));
      const h = dt / n;
      for (let s = 1; s <= n; s++) this._substep(h, s / n, P, pm);

      this.anchorPrev.set(this.anchorCur);

      // guard against numerical blow-up
      const last = (N - 1) * 3 + 1;
      if (!isFinite(this.pos[last]) || Math.abs(this.pos[last]) > 50) this.reinit(drone);

      this.posAttr.needsUpdate = true;
      this.mesh.geometry.computeVertexNormals();
    }

    _substep(h, f, P, pm) {
      const pos = this.pos, vel = this.vel, pred = this.pred, acc = this.acc;
      const g = P.gravity;
      acc.fill(0);

      // ---- aerodynamic drag per triangle (normal force from relative air speed) ----
      const kAero = RHO_CD * P.flagDrag;
      if (kAero > 0) {
        const wx = this.wind.x, wy = this.wind.y, wz = this.wind.z;
        for (let t = 0; t < TRIS.length; t += 3) {
          const a = TRIS[t] * 3, b = TRIS[t + 1] * 3, c = TRIS[t + 2] * 3;
          const e1x = pos[b] - pos[a], e1y = pos[b + 1] - pos[a + 1], e1z = pos[b + 2] - pos[a + 2];
          const e2x = pos[c] - pos[a], e2y = pos[c + 1] - pos[a + 1], e2z = pos[c + 2] - pos[a + 2];
          let nx = e1y * e2z - e1z * e2y;
          let ny = e1z * e2x - e1x * e2z;
          let nz = e1x * e2y - e1y * e2x;
          const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
          if (len < 1e-9) continue;
          nx /= len; ny /= len; nz /= len;
          const area = 0.5 * len;
          const rvx = (vel[a] + vel[b] + vel[c]) / 3 - wx;
          const rvy = (vel[a + 1] + vel[b + 1] + vel[c + 1]) / 3 - wy;
          const rvz = (vel[a + 2] + vel[b + 2] + vel[c + 2]) / 3 - wz;
          const vn = rvx * nx + rvy * ny + rvz * nz;
          const fa = (-kAero * area * Math.abs(vn) * vn) / (3 * pm);
          acc[a] += nx * fa; acc[a + 1] += ny * fa; acc[a + 2] += nz * fa;
          acc[b] += nx * fa; acc[b + 1] += ny * fa; acc[b + 2] += nz * fa;
          acc[c] += nx * fa; acc[c + 1] += ny * fa; acc[c + 2] += nz * fa;
        }
      }

      // ---- predict ----
      const damp = Math.exp(-P.flagDamping * h);
      for (let j = 0; j < ROWS; j++) {
        for (let i = 0; i < COLS; i++) {
          const o = idx(i, j) * 3;
          if (j === 0) {
            // pinned to the drone (mount interpolated across the frame)
            const ao = i * 3;
            pred[o]     = this.anchorPrev[ao]     + (this.anchorCur[ao]     - this.anchorPrev[ao])     * f;
            pred[o + 1] = this.anchorPrev[ao + 1] + (this.anchorCur[ao + 1] - this.anchorPrev[ao + 1]) * f;
            pred[o + 2] = this.anchorPrev[ao + 2] + (this.anchorCur[ao + 2] - this.anchorPrev[ao + 2]) * f;
          } else {
            let ax = acc[o], ay = acc[o + 1] - g, az = acc[o + 2];
            const am2 = ax * ax + ay * ay + az * az;
            if (am2 > MAX_ACC * MAX_ACC) { const s = MAX_ACC / Math.sqrt(am2); ax *= s; ay *= s; az *= s; }
            vel[o]     = (vel[o]     + ax * h) * damp;
            vel[o + 1] = (vel[o + 1] + ay * h) * damp;
            vel[o + 2] = (vel[o + 2] + az * h) * damp;
            pred[o]     = pos[o]     + vel[o]     * h;
            pred[o + 1] = pos[o + 1] + vel[o + 1] * h;
            pred[o + 2] = pos[o + 2] + vel[o + 2] * h;
          }
        }
      }

      // ---- constraints (row 0 = pinned, inverse mass 0) ----
      const rest = this.rest;
      for (let it = 0; it < ITERATIONS; it++) {
        for (let c = 0; c < NC; c++) {
          const a = CA[c], b = CB[c];
          const wa = a < COLS ? 0 : 1, wb = b < COLS ? 0 : 1;
          const ws = wa + wb;
          if (ws === 0) continue;
          const ao = a * 3, bo = b * 3;
          const dx = pred[bo] - pred[ao];
          const dy = pred[bo + 1] - pred[ao + 1];
          const dz = pred[bo + 2] - pred[ao + 2];
          const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
          if (d < 1e-9) continue;
          const s = (CK[c] * (d - rest[c])) / (d * ws);
          pred[ao]     += dx * s * wa; pred[ao + 1] += dy * s * wa; pred[ao + 2] += dz * s * wa;
          pred[bo]     -= dx * s * wb; pred[bo + 1] -= dy * s * wb; pred[bo + 2] -= dz * s * wb;
        }
      }

      // ---- floor (with friction) ----
      for (let k = COLS; k < N; k++) {
        const o = k * 3;
        if (pred[o + 1] < FLOOR) {
          pred[o + 1] = FLOOR;
          pred[o]     = pos[o]     + (pred[o]     - pos[o])     * 0.6;
          pred[o + 2] = pos[o + 2] + (pred[o + 2] - pos[o + 2]) * 0.6;
        }
      }

      // ---- commit ----
      const inv = 1 / h;
      for (let o = 0; o < N * 3; o++) {
        vel[o] = (pred[o] - pos[o]) * inv;
        pos[o] = pred[o];
      }
    }

    dispose() {
      this.scene.remove(this.mesh);
      this.mesh.geometry.dispose();
      this.mesh.material.dispose();
    }
  }

  class FlagSystem {
    constructor(scene) {
      this.scene = scene;
      this.flags = [];
    }

    _ensure(count) {
      while (this.flags.length < count) this.flags.push(new Flag(this.scene, '#cccccc'));
      while (this.flags.length > count) this.flags.pop().dispose();
    }

    /**
     * @param {Quadcopter} lead
     * @param {string} leadColor
     * @param {Array} bots BotManager.bots ({config, drone})
     */
    update(lead, leadColor, bots, dt, time, P, wind) {
      const count = 1 + bots.length;
      this._ensure(count);
      const on = P.flagsEnabled !== false;

      for (let i = 0; i < count; i++) {
        const drone = i === 0 ? lead : bots[i - 1].drone;
        const color = i === 0 ? leadColor : bots[i - 1].config.color;
        const flag = this.flags[i];

        const rod = drone.mesh && drone.mesh.userData.flagRod;
        if (rod) rod.visible = on;

        if (!on) { flag.mesh.visible = false; flag.stale = true; continue; }
        flag.mesh.visible = true;
        flag.setColor(color);
        flag.update(drone, dt, time, P, wind);
      }
    }

    /** Re-hang all flags on the next update (after a reset / teleport). */
    reset() {
      for (const f of this.flags) f.stale = true;
    }
  }

  window.FlagSystem = FlagSystem;
  window.FLAG_ATTACH = ATTACH;
  window.FLAG_DRONE_SCALE = DRONE_SCALE;
})();
js/input.js
/**
 * input.js — Xbox gamepad + keyboard fallback.
 *
 * Produces a *velocity-command* control (DJI-style position/altitude hold):
 *   fwd    -1..1   forward/backward  (neutral = hold position)
 *   strafe -1..1   left/right        (neutral = hold position)
 *   lift   -1..1   up/down           (neutral = hold altitude)
 *   yaw    -1..1   rotate
 *
 * Gamepad (Mode 2):
 *   left stick  Y -> lift, X -> yaw
 *   right stick Y -> forward, X -> strafe
 * Keyboard:
 *   W/S   lift · A/D yaw · arrows forward/strafe · Space/Shift lift
 *   R reset · G record · P play · L loop · C clear · T trails
 */
(function () {
  'use strict';

  const DEADZONE = 0.12;

  class InputManager {
    constructor() {
      this.gamepadIndex = null;
      this.gamepadActive = false;

      this.keys = {};
      this.justPressed = new Set();

      // smoothed keyboard velocity commands (return to 0 = hold)
      this.kb = { fwd: 0, strafe: 0, lift: 0, yaw: 0 };

      this._prevButtons = new Array(17).fill(false);
      this._bind();
    }

    _bind() {
      window.addEventListener('gamepadconnected', (e) => {
        this.gamepadIndex = e.gamepad.index;
        this.gamepadActive = true;
        this._emitInputState();
      });
      window.addEventListener('gamepaddisconnected', (e) => {
        if (this.gamepadIndex === e.gamepad.index) {
          this.gamepadIndex = null;
          this.gamepadActive = false;
          this._emitInputState();
        }
      });
      window.addEventListener('keydown', (e) => {
        if (e.repeat) return;
        this.keys[e.code] = true;
        this._handleActionKey(e.code);
      });
      window.addEventListener('keyup', (e) => { this.keys[e.code] = false; });
      window.addEventListener('blur', () => { this.keys = {}; });
    }

    _emitInputState() {
      window.dispatchEvent(new CustomEvent('inputchange', { detail: this.gamepadActive ? 'gamepad' : 'keyboard' }));
    }

    _handleActionKey(code) {
      const map = {
        'KeyR': 'reset', 'KeyG': 'record', 'KeyP': 'play',
        'KeyL': 'loop', 'KeyC': 'clear', 'KeyT': 'trails',
      };
      if (map[code]) this.justPressed.add(map[code]);
    }

    _applyDeadzone(v) {
      if (Math.abs(v) < DEADZONE) return 0;
      return (v - Math.sign(v) * DEADZONE) / (1 - DEADZONE);
    }

    poll(dt) {
      const actions = new Set(this.justPressed);
      this.justPressed.clear();

      const pads = navigator.getGamepads ? navigator.getGamepads() : [];
      let gp = null;
      if (this.gamepadIndex != null) gp = pads[this.gamepadIndex];
      if (!gp) {
        for (let i = 0; i < pads.length; i++) {
          if (pads[i] && pads[i].connected) { gp = pads[i]; this.gamepadIndex = i; break; }
        }
      }

      let ctrl;
      if (gp) {
        if (!this.gamepadActive) { this.gamepadActive = true; this._emitInputState(); }
        ctrl = this._gamepadCtrl(gp);
        this._gamepadActions(gp, actions);
      } else {
        if (this.gamepadActive) { this.gamepadActive = false; this._emitInputState(); }
        ctrl = this._keyboardCtrl(dt);
      }

      return { ctrl, actions };
    }

    _gamepadCtrl(gp) {
      const ax = gp.axes;
      const lx = this._applyDeadzone(ax[0] || 0); // left X -> yaw
      const ly = this._applyDeadzone(ax[1] || 0); // left Y -> lift (up = -1)
      const rx = this._applyDeadzone(ax[2] || 0); // right X -> strafe
      const ry = this._applyDeadzone(ax[3] || 0); // right Y -> forward (up = -1)
      return {
        fwd: -ry,
        strafe: rx,
        lift: -ly,
        yaw: -lx,
      };
    }

    _keyboardCtrl(dt) {
      const k = this.keys;
      const kb = this.kb;
      const ramp = 7 * dt;

      const target = (neg, pos) => ((neg ? -1 : 0) + (pos ? 1 : 0));
      const rampTo = (cur, t) => {
        const diff = t - cur;
        if (Math.abs(diff) < 1e-6) return t;
        return cur + THREE.MathUtils.clamp(diff, -ramp, ramp);
      };

      kb.fwd = rampTo(kb.fwd, target(k['ArrowDown'], k['ArrowUp']));
      kb.strafe = rampTo(kb.strafe, target(k['ArrowLeft'], k['ArrowRight']));
      kb.yaw = rampTo(kb.yaw, target(k['KeyA'], k['KeyD']));

      const liftTarget = THREE.MathUtils.clamp(
        (k['KeyW'] ? 1 : 0) - (k['KeyS'] ? 1 : 0) + (k['Space'] ? 1 : 0) - (k['ShiftLeft'] || k['ShiftRight'] ? 1 : 0),
        -1, 1);
      kb.lift = rampTo(kb.lift, liftTarget);

      return { fwd: kb.fwd, strafe: kb.strafe, lift: kb.lift, yaw: kb.yaw };
    }

    _gamepadActions(gp, actions) {
      const btn = gp.buttons;
      const map = { 0: 'reset', 3: 'record', 2: 'play', 1: 'loop', 8: 'clear', 9: 'trails' };
      for (const idx in map) {
        const pressed = btn[idx] && btn[idx].pressed;
        if (pressed && !this._prevButtons[idx]) actions.add(map[idx]);
        this._prevButtons[idx] = pressed;
      }
    }

    activeLabel() { return this.gamepadActive ? 'gamepad' : 'keyboard'; }
  }

  window.InputManager = InputManager;
})();
js/main.js
/**
 * main.js — scene setup, render loop, and wiring of everything together:
 * manual lead drone + autonomous bots + wind + recording/playback choreography
 * + cloth flags.
 */
(function () {
  'use strict';

  /* ------------------------------------------------------------------ *
   * Renderer / scene / camera / lights
   * ------------------------------------------------------------------ */
  const canvas = document.getElementById('canvas');
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.outputEncoding = THREE.sRGBEncoding;

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xffffff);

  const camera = new THREE.PerspectiveCamera(
    Config.values.fov, window.innerWidth / window.innerHeight, 0.1, 200);
  // fixed, axis-aligned view: forward (+X) into the screen, +Z to the right.
  // The camera sits along a fixed direction from a target and zooms by distance.
  const camTarget = new THREE.Vector3(0, 1.2, 0);
  const camDir = new THREE.Vector3(-6.5, 3.0, 1.3).normalize();
  function applyCamera() {
    camera.position.copy(camTarget).addScaledVector(camDir, Config.values.camDistance);
    camera.lookAt(camTarget);

camera.position.set(0, 2, 4);   // frontal von oben
camera.up.set(0, 0, -1);         // wichtig! sonst kippt die Ansicht
camera.lookAt(0, 0, 0);
  }
  applyCamera();

  // zoom with the mouse wheel
  canvas.addEventListener('wheel', (e) => {
    e.preventDefault();
    const d = THREE.MathUtils.clamp(
      Config.values.camDistance * Math.exp(e.deltaY * 0.0012), 2, 30);
    Config.values.camDistance = d;
    applyCamera();
    UI.setControl('camDistance', d);
  }, { passive: false });

  scene.add(new THREE.HemisphereLight(0xffffff, 0xe5e7eb, 0.9));
  const sun = new THREE.DirectionalLight(0xffffff, 0.55);
  sun.position.set(12, 28, 10);
  scene.add(sun);

  // ground (room floor)
  const ground = new THREE.Mesh(
    new THREE.PlaneGeometry(5, 5),
    new THREE.MeshStandardMaterial({ color: 0xf5f5f6, roughness: 1, metalness: 0 })
  );
  ground.rotation.x = -Math.PI / 2;
  scene.add(ground);

  const grid = new THREE.GridHelper(5, 10, 0xd8d8dd, 0xececef);
  grid.position.y = 0.01;
  grid.visible = Config.values.showGrid;
  scene.add(grid);

  // subtle room outline (5 × 5 × 3 m)
  const roomEdges = new THREE.LineSegments(
    new THREE.EdgesGeometry(new THREE.BoxGeometry(5, 3, 5)),
    new THREE.LineBasicMaterial({ color: 0x9ca3af, transparent: true, opacity: 0.28 })
  );
  roomEdges.position.set(0, 1.5, 0);
  scene.add(roomEdges);

  /* ------------------------------------------------------------------ *
   * Drone mesh factory — smooth low-poly 4-arm body, pastel camo + eyes
   * ------------------------------------------------------------------ */
  const DRONE_SCALE = 0.62;
  const LEAD_COLOR = '#334155';
  const FORWARD_COLOR = 0xff2d55; // vivid, opaque heading arrow on the lead

  function hashStr(s) {
    let h = 2166136261;
    for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
    return h >>> 0;
  }
  function mulberry32(a) {
    return function () {
      a |= 0; a = (a + 0x6D2B79F5) | 0;
      let t = Math.imul(a ^ (a >>> 15), 1 | a);
      t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
  }

  function makeCamoTexture(hex, isLead) {
    const size = 64;
    const c = document.createElement('canvas');
    c.width = c.height = size;
    const ctx = c.getContext('2d');
    const base = new THREE.Color(hex);
    const hsl = {};
    base.getHSL(hsl);
    const rng = mulberry32(hashStr(hex));

    // pastel variants for the swarm; darker/muted for the lead
    const lMin = isLead ? 0.30 : 0.62;
    const lMax = isLead ? 0.52 : 0.88;
    const sMax = isLead ? 0.50 : 0.60;
    const sMin = isLead ? 0.25 : 0.28;

    // 4 variants of the base hue
    const cols = [];
    for (let i = 0; i < 4; i++) {
      let h = hsl.h + (i - 1.5) * 0.09;
      h = h - Math.floor(h);
      const s = THREE.MathUtils.clamp(hsl.s * 0.9, sMin, sMax);
      const l = THREE.MathUtils.clamp(hsl.l + (i - 1.5) * 0.11, lMin, lMax);
      const col = new THREE.Color().setHSL(h, s, l);
      cols.push(col);
    }

    // camo blobs
    for (let i = 0; i < 6; i++) {
      const col = cols[i % cols.length];
      ctx.fillStyle = '#' + col.getHexString();
      const bx = rng() * size;
      const by = rng() * size;
      const rx = size * (0.22 + rng() * 0.28);
      const ry = rx * (0.65 + rng() * 0.45);
      ctx.beginPath();
      ctx.ellipse(bx, by, rx, ry, rng() * Math.PI, 0, Math.PI * 2);
      ctx.fill();
    }

    const tex = new THREE.CanvasTexture(c);
    tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
    tex.encoding = THREE.sRGBEncoding;
    return tex;
  }

  function createDroneMesh(colorHex, isLead) {
    const g = new THREE.Group();

    const camoMaterials = [];
    function makeCamoMat(hex) {
      const mat = new THREE.MeshStandardMaterial({ map: makeCamoTexture(hex, isLead), roughness: 0.72, metalness: 0.04 });
      camoMaterials.push(mat);
      return mat;
    }
    // lead: solid black so it stands out; bots: pastel camo
    const bodyMat = isLead
      ? new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.45, metalness: 0.3 })
      : makeCamoMat(colorHex);
    const propMat = new THREE.MeshStandardMaterial({
      color: 0xe8e8ec, roughness: 0.35, metalness: 0.15, transparent: true, opacity: 0.55,
    });
    const eyeMat = new THREE.MeshBasicMaterial({ color: 0x111111 });

    // central pod (smooth, flattened sphere)
    const pod = new THREE.Mesh(new THREE.SphereGeometry(0.11, 10, 8), bodyMat);
    pod.scale.set(1, 0.62, 1);
    pod.userData.isBody = true;
    g.add(pod);

    // four tapered arms (X-configuration)
    const armGeo = new THREE.CylinderGeometry(0.048, 0.02, 0.16, 5);
    const dirs = [[1, 1], [1, -1], [-1, 1], [-1, -1]];
    const props = [];
    dirs.forEach(([dx, dz]) => {
      const ang = Math.atan2(dz, dx);
      const armGroup = new THREE.Group();
      armGroup.rotation.y = -ang;

      const arm = new THREE.Mesh(armGeo, bodyMat);
      arm.rotation.z = -Math.PI / 2; // cylinder Y-axis -> +X
      arm.position.x = 0.075;
      arm.userData.isBody = true;
      armGroup.add(arm);

      // prop group at arm tip
      const prop = new THREE.Group();
      prop.position.set(0.15, 0.035, 0);
      const disc = new THREE.Mesh(new THREE.CylinderGeometry(0.085, 0.085, 0.005, 12), propMat);
      const blade = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.004, 0.034), propMat);
      prop.add(disc, blade);
      armGroup.add(prop);
      props.push(prop);

      g.add(armGroup);
    });
    g.props = props;

    // two cute black button eyes on the front (+X)
    const eyeGeo = new THREE.SphereGeometry(0.027, 8, 6);
    const eye1 = new THREE.Mesh(eyeGeo, eyeMat);
    eye1.position.set(0.085, 0.035, 0.052);
    const eye2 = new THREE.Mesh(eyeGeo, eyeMat);
    eye2.position.set(0.085, 0.035, -0.052);
    g.add(eye1, eye2);

    // white catchlight glints so the eyes read on the black lead
    if (isLead) {
      const glintGeo = new THREE.SphereGeometry(0.009, 4, 4);
      const glintMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
      const gl1 = new THREE.Mesh(glintGeo, glintMat);
      gl1.position.set(0.095, 0.045, 0.058);
      const gl2 = new THREE.Mesh(glintGeo, glintMat);
      gl2.position.set(0.095, 0.045, -0.058);
      g.add(gl1, gl2);
    }

    // flag mount: thin rod from the pod down to where the flag's top edge is pinned
    // (FLAG_ATTACH is in world metres; the drone group is scaled, so convert)
    {
      const ay = FLAG_ATTACH.y / DRONE_SCALE;     // negative = below the body
      const ax = FLAG_ATTACH.x / DRONE_SCALE;
      const top = -0.04;
      const rodLen = top - ay;
      const rod = new THREE.Mesh(
        new THREE.CylinderGeometry(0.007, 0.007, rodLen, 6),
        new THREE.MeshStandardMaterial({ color: 0xb8bcc4, roughness: 0.6, metalness: 0.1 })
      );
      rod.position.set(ax, (top + ay) / 2, 0);
      g.add(rod);
      g.userData.flagRod = rod;
    }

    g.userData.color = colorHex;
    g.userData.setCamo = function (hex) {
      for (const m of camoMaterials) {
        m.map.dispose();
        m.map = makeCamoTexture(hex, isLead);
        m.needsUpdate = true;
      }
    };

    // prominent forward-direction arrow on the lead: solid shaft + cone,
    // opaque and saturated so attitude wobble is easy to read
    if (isLead) {
      const fwdMat = new THREE.MeshBasicMaterial({ color: FORWARD_COLOR });
      const start = 0.14, shaftLen = 1.05;
      const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, shaftLen, 12), fwdMat);
      shaft.rotation.z = -Math.PI / 2; // cylinder Y-axis -> +X
      shaft.position.set(start + shaftLen / 2, 0, 0);
      g.add(shaft);
      const head = new THREE.Mesh(new THREE.ConeGeometry(0.1, 0.26, 16), fwdMat);
      head.rotation.z = -Math.PI / 2;
      head.position.set(start + shaftLen + 0.13, 0, 0);
      g.add(head);
    }

    // keep them small — like a swarm
    g.scale.setScalar(DRONE_SCALE);

    return g;
  }

  /* ------------------------------------------------------------------ *
   * Drones
   * ------------------------------------------------------------------ */
  const leadMesh = createDroneMesh(LEAD_COLOR, true);
  scene.add(leadMesh);
  const lead = new Quadcopter(Config.values, leadMesh);
  lead.reset(new THREE.Vector3(0, 0.12, 0), 0);
  const leadHold = new THREE.Vector3(0, 0.12, 0); // position-hold target for the lead

  const bots = new BotManager(scene, Config.values);
  bots.sync(Config.bots, lead.pos, createDroneMesh);

  // cloth flags (one per drone, lead included)
  const flags = new FlagSystem(scene);

  /* ------------------------------------------------------------------ *
   * Wind / input / recording / playback
   * ------------------------------------------------------------------ */
  const wind = new WindField();
  const input = new InputManager();

  const recorder = new Recorder();
  let currentTrack = new Track([]);
  let playing = false;
  let loop = false;
  let playTime = 0;
  let simTime = 0;

  const _sampPos = new THREE.Vector3();
  const _sampQuat = new THREE.Quaternion();

  // heading of the body +X axis, projected on the ground plane.
  // (+X axis in world = (R00, R10, R20); yaw = atan2(-R20, R00))
  function yawFromQuat(q) {
    const nx = 1 - 2 * (q.y * q.y + q.z * q.z);
    const nz = 2 * (q.x * q.z - q.w * q.y);
    return Math.atan2(-nz, nx);
  }

  /* ------------------------------------------------------------------ *
   * Trails (optional trajectory visualization)
   * ------------------------------------------------------------------ */
  class Trail {
    constructor(scene, color) {
      this.pts = [];
      this.MAX = 240;
      this.geo = new THREE.BufferGeometry();
      this.posAttr = new THREE.BufferAttribute(new Float32Array(this.MAX * 3), 3);
      this.posAttr.setUsage(THREE.DynamicDrawUsage);
      this.geo.setAttribute('position', this.posAttr);
      this.geo.setDrawRange(0, 0);
      this.line = new THREE.Line(this.geo, new THREE.LineBasicMaterial({
        color: new THREE.Color(color), transparent: true, opacity: 0.3,
      }));
      this.line.visible = Config.values.showTrails;
      scene.add(this.line);
    }
    push(p) {
      this.pts.push(p.x, p.y, p.z);
      if (this.pts.length > this.MAX * 3) this.pts.splice(0, this.pts.length - this.MAX * 3);
    }
    flush() {
      const n = this.pts.length / 3;
      for (let i = 0; i < this.pts.length; i++) this.posAttr.array[i] = this.pts[i];
      this.posAttr.needsUpdate = true;
      this.geo.setDrawRange(0, n);
    }
    clear() { this.pts.length = 0; this.geo.setDrawRange(0, 0); }
  }

  const leadTrail = new Trail(scene, '#1f2937');
  const botTrails = bots.bots.map((b) => new Trail(scene, b.config.color));

  function setTrailsVisible(v) {
    leadTrail.line.visible = v;
    botTrails.forEach((t) => { t.line.visible = v; });
  }

  function updateTrails() {
    leadTrail.push(lead.pos);
    leadTrail.flush();
    bots.bots.forEach((b, i) => {
      if (!botTrails[i]) return;
      botTrails[i].push(b.drone.pos);
      botTrails[i].flush();
    });
  }

  /* ------------------------------------------------------------------ *
   * Hooks (called by the UI)
   * ------------------------------------------------------------------ */
  function choreoInfo() {
    return {
      recording: recorder.recording,
      playing,
      loop,
      hasTrack: !currentTrack.empty,
      duration: currentTrack.duration,
      frames: currentTrack.frames.length,
    };
  }

  function updateHudMode() {
    document.getElementById('hud-mode').textContent =
      playing ? 'PLAYBACK' : (recorder.recording ? 'RECORDING' : 'MANUAL');
    document.getElementById('hud-rec').classList.toggle('hidden', !recorder.recording);
  }

  function toggleRecord() {
    if (recorder.recording) {
      currentTrack = recorder.stop();
    } else {
      playing = false;
      leadHold.copy(lead.pos);
      recorder.start();
    }
    UI.setChoreo(choreoInfo());
    updateHudMode();
  }

  function togglePlay() {
    if (currentTrack.empty) return;
    if (playing) {
      playing = false;
      leadHold.copy(lead.pos);
    } else {
      playing = true;
      recorder.recording = false;
      playTime = 0;
    }
    UI.setChoreo(choreoInfo());
    updateHudMode();
  }

  function clearTrack() {
    playing = false;
    leadHold.copy(lead.pos);
    recorder.clear();
    currentTrack = new Track([]);
    playTime = 0;
    leadTrail.clear();
    botTrails.forEach((t) => t.clear());
    UI.setChoreo(choreoInfo());
    updateHudMode();
  }

  function resetAll() {
    playing = false;
    lead.reset(new THREE.Vector3(0, 0.12, 0), 0);
    lead.vel.set(0, 0, 0);
    leadHold.copy(lead.pos);
    bots.resetAll(lead.pos, lead.yaw);
    flags.reset();
    playTime = 0;
    leadTrail.clear();
    botTrails.forEach((t) => t.clear());
    UI.setChoreo(choreoInfo());
    updateHudMode();
  }

  function applyToScene() {
    camera.fov = Config.values.fov;
    camera.updateProjectionMatrix();
    grid.visible = Config.values.showGrid;
    setTrailsVisible(Config.values.showTrails);
    applyCamera();
  }

  function serializeState() {
    return {
      version: 1,
      savedAt: Date.now(),
      values: Object.assign({}, Config.values),
      bots: Config.bots.map(cloneBot),
      track: currentTrack.toJSON(),
    };
  }

  function applyState(state) {
    if (!state) return;
    if (state.values) Object.assign(Config.values, state.values); // mutate in place
    if (state.bots) Config.bots = state.bots.map(cloneBot);
    if (state.track) currentTrack = Track.fromJSON(state.track);
    playing = false;
    playTime = 0;
    leadHold.copy(lead.pos);
    bots.sync(Config.bots, lead.pos, createDroneMesh);
    syncBotTrails();
    applyToScene();
    updateHudMode();
    UI.setChoreo(choreoInfo());
    UI.setControl('loop', loop);
  }

  const hooks = {
    onParamChanged(key, value) {
      if (key === 'fov') { camera.fov = value; camera.updateProjectionMatrix(); }
      else if (key === 'camDistance') { applyCamera(); }
      else if (key === 'showGrid') { grid.visible = value; }
      else if (key === 'showTrails') { setTrailsVisible(value); }
    },
    onBotsChanged() {
      bots.sync(Config.bots, lead.pos, createDroneMesh);
      // keep trail colors/length in sync
      syncBotTrails();
    },
    onRecord: toggleRecord,
    onPlay: togglePlay,
    onClear: clearTrack,
    setLoop(v) { loop = v; },
    onReset: resetAll,
    onSetAnchors() { bots.setAnchors(lead.pos); },
    onResetDefaults() {
      resetToDefaults();
      leadHold.copy(lead.pos);
      bots.sync(Config.bots, lead.pos, createDroneMesh);
      syncBotTrails();
      applyToScene();
      updateHudMode();
      UI.setChoreo(choreoInfo());
    },
    serialize: serializeState,
    applyState,
  };

  function syncBotTrails() {
    // remove extra / add missing trail objects
    while (botTrails.length > bots.bots.length) {
      const t = botTrails.pop();
      scene.remove(t.line);
    }
    while (botTrails.length < bots.bots.length) {
      botTrails.push(new Trail(scene, bots.bots[botTrails.length].config.color));
    }
    bots.bots.forEach((b, i) => {
      botTrails[i].line.material.color.set(b.config.color);
    });
  }

  /* ------------------------------------------------------------------ *
   * HUD
   * ------------------------------------------------------------------ */
  const fpsEl = document.getElementById('hud-fps');
  const dronesEl = document.getElementById('hud-drones');
  let frames = 0, fpsTime = 0, fps = 0;

  function updateHud(dt) {
    frames++;
    fpsTime += dt;
    if (fpsTime >= 0.5) {
      fps = Math.round(frames / fpsTime);
      frames = 0;
      fpsTime = 0;
      fpsEl.textContent = fps + ' fps';
    }
    dronesEl.textContent = (1 + bots.count()) + ' drones';
  }

  function handleActions(actions) {
    if (actions.has('reset')) resetAll();
    if (actions.has('record')) toggleRecord();
    if (actions.has('play')) togglePlay();
    if (actions.has('loop')) { loop = !loop; UI.setControl('loop', loop); UI.setChoreo(choreoInfo()); }
    if (actions.has('clear')) clearTrack();
    if (actions.has('trails')) {
      Config.values.showTrails = !Config.values.showTrails;
      setTrailsVisible(Config.values.showTrails);
      UI.setControl('showTrails', Config.values.showTrails);
    }
  }

  /* ------------------------------------------------------------------ *
   * Main loop
   * ------------------------------------------------------------------ */
  function updateLeadManual(simDt, ctrl) {
    const P = Config.values;
    const maxSpeed = P.leadMaxSpeed;
    const yaw = lead.yaw;

    // body-frame velocity command -> world, integrated into the hold target
    const fx = Math.cos(yaw), fz = -Math.sin(yaw);
    const rx = Math.sin(yaw), rz = Math.cos(yaw);
    leadHold.x += (fx * ctrl.fwd + rx * ctrl.strafe) * maxSpeed * simDt;
    leadHold.z += (fz * ctrl.fwd + rz * ctrl.strafe) * maxSpeed * simDt;
    leadHold.y += ctrl.lift * maxSpeed * simDt;

    // keep the hold target inside the room
    const b = ROOM_BOUNDS, m = 0.25;
    leadHold.x = THREE.MathUtils.clamp(leadHold.x, b.min.x + m, b.max.x - m);
    leadHold.z = THREE.MathUtils.clamp(leadHold.z, b.min.z + m, b.max.z - m);
    leadHold.y = THREE.MathUtils.clamp(leadHold.y, b.min.y + 0.2, b.max.y - 0.25);

    // yaw
    lead.yaw += ctrl.yaw * THREE.MathUtils.degToRad(P.yawRate) * simDt;

    const ac = autopilotControl(lead, leadHold, lead.yaw, 1, P);
    const w = wind.sample(lead.pos, simTime, P);
    lead.step(simDt, ac, w);
  }

  let last = performance.now();

  function animate(now) {
    requestAnimationFrame(animate);

    let dt = (now - last) / 1000;
    last = now;
    if (dt > 0.05) dt = 0.05;
    const simDt = dt * Config.values.simSpeed;

    const { ctrl, actions } = input.poll(dt);
    handleActions(actions);
    UI.setInputLabel(input.activeLabel());

    simTime += simDt;

    // ---- lead drone ----
    if (playing && !currentTrack.empty) {
      playTime += simDt * Config.values.playbackSpeed;
      const ok = currentTrack.sample(playTime, loop, _sampPos, _sampQuat);
      if (ok) {
        lead.pos.copy(_sampPos);
        lead.quat.copy(_sampQuat);
        lead.yaw = yawFromQuat(_sampQuat);
        lead.vel.set(0, 0, 0);
        lead.rotorSpeed = 150;
      } else {
        playing = false;
        leadHold.copy(lead.pos);
        UI.setChoreo(choreoInfo());
        updateHudMode();
      }
    } else {
      updateLeadManual(simDt, ctrl);
    }
    lead.updateRotors(dt);
    lead.syncMesh();

    // ---- record lead pose ----
    recorder.update(simDt, lead.pos, lead.quat);

    // ---- bots ----
    bots.update({ pos: lead.pos, quat: lead.quat, yaw: lead.yaw, vel: lead.vel }, simDt, simTime, Config.values, wind);

    // ---- flags (cloth) ----
    flags.update(lead, LEAD_COLOR, bots.bots, simDt, simTime, Config.values, wind);

    // ---- trails ----
    if (Config.values.showTrails) updateTrails();

    // ---- render ----
    renderer.render(scene, camera);

    updateHud(dt);
  }

  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });

  /* ------------------------------------------------------------------ *
   * Boot
   * ------------------------------------------------------------------ */
  UI.init(hooks);
  UI.setChoreo(choreoInfo());
  updateHudMode();
  UI.setInputLabel(input.activeLabel());
  requestAnimationFrame(animate);
})();
js/noise.js
/**
 * noise.js — Seeded 3D Perlin noise + a wind/turbulence field built on top of it.
 * Used to generate realistic, slowly-varying disturbances (wind + turbulence).
 */
(function () {
  'use strict';

  /* ------------------------------------------------------------------ *
   * Perlin noise (improved Perlin, Ken Perlin 2002), seeded.
   * ------------------------------------------------------------------ */
  class PerlinNoise {
    constructor(seed = 0) {
      this.perm = new Uint8Array(512);
      let s = seed >>> 0;
      const rand = () => {
        // xorshift-ish deterministic PRNG
        s = (s * 1664525 + 1013904223) >>> 0;
        return s / 4294967296;
      };
      const p = new Uint8Array(256);
      for (let i = 0; i < 256; i++) p[i] = i;
      for (let i = 255; i > 0; i--) {
        const j = Math.floor(rand() * (i + 1));
        const t = p[i]; p[i] = p[j]; p[j] = t;
      }
      for (let i = 0; i < 512; i++) this.perm[i] = p[i & 255];
    }

    fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
    lerp(t, a, b) { return a + t * (b - a); }
    grad(h, x, y, z) {
      h &= 15;
      const u = h < 8 ? x : y;
      const v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
      return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v);
    }

    noise(x, y, z) {
      const p = this.perm;
      const X = Math.floor(x) & 255;
      const Y = Math.floor(y) & 255;
      const Z = Math.floor(z) & 255;
      x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
      const u = this.fade(x), v = this.fade(y), w = this.fade(z);
      const A = p[X] + Y, AA = p[A] + Z, AB = p[A + 1] + Z;
      const B = p[X + 1] + Y, BA = p[B] + Z, BB = p[B + 1] + Z;
      return this.lerp(w,
        this.lerp(v,
          this.lerp(u, this.grad(p[AA], x, y, z), this.grad(p[BA], x - 1, y, z)),
          this.lerp(u, this.grad(p[AB], x, y - 1, z), this.grad(p[BB], x - 1, y - 1, z))),
        this.lerp(v,
          this.lerp(u, this.grad(p[AA + 1], x, y, z - 1), this.grad(p[BA + 1], x - 1, y, z - 1)),
          this.lerp(u, this.grad(p[AB + 1], x, y - 1, z - 1), this.grad(p[BB + 1], x - 1, y - 1, z - 1))));
    }
  }

  /* ------------------------------------------------------------------ *
   * WindField — combines a base wind vector, gusts and high-frequency
   * turbulence (force + torque), all derived from Perlin noise.
   * ------------------------------------------------------------------ */
  class WindField {
    constructor(seed = 42) {
      // low-frequency noise for base wind + gusts
      this.gx = new PerlinNoise(seed);
      this.gy = new PerlinNoise(seed + 101);
      this.gz = new PerlinNoise(seed + 202);
      // higher-frequency noise for turbulence
      this.tx = new PerlinNoise(seed + 303);
      this.ty = new PerlinNoise(seed + 404);
      this.tz = new PerlinNoise(seed + 505);
      this.rx = new PerlinNoise(seed + 606);
      this.ry = new PerlinNoise(seed + 707);
      this.rz = new PerlinNoise(seed + 808);

      // reusable outputs to avoid per-frame allocation
      this.vel = new THREE.Vector3();
      this.turb = new THREE.Vector3();
      this.torque = new THREE.Vector3();
    }

    /**
     * Compute wind velocity + turbulence (force & torque) at a world position.
     * @param {THREE.Vector3} pos
     * @param {number} t elapsed simulation time (seconds)
     * @param {object} P config values
     */
    sample(pos, t, P) {
      const px = pos.x, py = pos.y, pz = pos.z;

      if (!P.windEnabled) {
        this.vel.set(0, 0, 0);
        this.turb.set(0, 0, 0);
        this.torque.set(0, 0, 0);
        return this;
      }

      const s = P.noiseScale;
      const ts = P.timeScale;
      const heading = THREE.MathUtils.degToRad(P.windHeading);
      const bx = Math.sin(heading) * P.windSpeed;
      const bz = Math.cos(heading) * P.windSpeed;
      const g = P.windGust;

      // base wind + low-frequency gusts
      this.vel.x = bx + this.gx.noise(px * s, py * s * 0.6, t * ts) * g;
      this.vel.y = this.gy.noise(px * s + 50, py * s + 50, t * ts * 1.3) * g * 0.35;
      this.vel.z = bz + this.gz.noise(px * s + 120, py * s * 0.6 + 120, t * ts * 0.9) * g;

      // turbulence — higher spatial & temporal frequency
      const st = s * 5;
      const tf = ts * 5;
      const T = P.turbulence;
      this.turb.x = this.tx.noise(px * st, py * st, t * tf) * T;
      this.turb.y = this.ty.noise(px * st + 77, py * st + 77, t * tf * 1.2) * T * 0.6;
      this.turb.z = this.tz.noise(px * st + 154, py * st + 154, t * tf * 0.8) * T;

      const TT = P.turbTorque;
      this.torque.x = this.rx.noise(px * st, py * st, t * tf * 1.1) * TT;
      this.torque.y = this.ry.noise(px * st + 31, py * st + 31, t * tf) * TT * 0.7;
      this.torque.z = this.rz.noise(px * st + 62, py * st + 62, t * tf * 0.9) * TT;

      return this;
    }
  }

  window.PerlinNoise = PerlinNoise;
  window.WindField = WindField;
})();
js/physics.js
/**
 * physics.js — rigid-body quadcopter dynamics.
 *
 * The drone is a point mass with a full 3D attitude (quaternion).
 * Control input is "desired up vector + desired yaw + throttle":
 *   - Manual input supplies roll/pitch tilt + yaw rate + throttle.
 *   - Autopilot (bots) supplies a desired up vector + yaw + throttle directly.
 *
 * Attitude control is a cascade, like a real flight controller:
 *   attitude error (rotation vector, body frame)
 *     -> P  -> desired body rates (clamped: max tilt rate, max yaw rate)
 *     -> rate loop -> torque command (clamped: max tilt torque, max yaw torque)
 *     -> first-order motor lag -> real torque -> angular acceleration
 * Thrust also goes through the motor lag. Everything is integrated in fixed
 * sub-steps (<= 1/120 s), so the loop stays stable at any frame rate.
 *
 * The desired orientation is built directly from (up vector, heading) as a
 * rotation basis — no Euler angles, no twist coupling between tilt and yaw.
 */
(function () {
  'use strict';

  const AXIS_Y = new THREE.Vector3(0, 1, 0);
  const ZERO = new THREE.Vector3();
  const DEG = Math.PI / 180;
  const clamp = THREE.MathUtils.clamp;

  const GROUND_Y = 0;

  // Flight volume: 5 × 5 × 3 m (X × Z × Y), centred on the XZ origin.
  const ROOM_BOUNDS = {
    min: new THREE.Vector3(-2.5, 0, -2.5),
    max: new THREE.Vector3(2.5, 3, 2.5),
  };

  const MAX_SUBSTEP = 1 / 120;
  const MAX_SUBSTEPS = 8;

  class Quadcopter {
    constructor(params, mesh, bounds) {
      this.params = params; // Config.values reference
      this.mesh = mesh;     // THREE.Group with .props (array of spin groups)
      this.bounds = bounds || ROOM_BOUNDS;
      this.pos = new THREE.Vector3();
      this.vel = new THREE.Vector3();
      this.quat = new THREE.Quaternion();
      this.angVel = new THREE.Vector3(); // world frame
      this.yaw = 0;
      this.rotorSpeed = 0;

      // motor state (first-order lag)
      this.thrustAct = 0;                     // N
      this.torqueAct = new THREE.Vector3();   // body frame, N·m

      // current target (set once per step)
      this._thrustCmd = 0;
      this._yawRateLimit = 0;
      this._qDes = new THREE.Quaternion();

      // scratch (avoid per-frame allocation)
      this._up = new THREE.Vector3();
      this._hd = new THREE.Vector3();
      this._xb = new THREE.Vector3();
      this._zb = new THREE.Vector3();
      this._v1 = new THREE.Vector3();
      this._v2 = new THREE.Vector3();
      this._v3 = new THREE.Vector3();
      this._v4 = new THREE.Vector3();
      this._v5 = new THREE.Vector3();
      this._q1 = new THREE.Quaternion();
      this._q2 = new THREE.Quaternion();
      this._dq = new THREE.Quaternion();
      this._m4 = new THREE.Matrix4();
    }

    reset(pos, yaw = 0) {
      this.pos.copy(pos);
      this.vel.set(0, 0, 0);
      this.quat.setFromAxisAngle(AXIS_Y, yaw);
      this.angVel.set(0, 0, 0);
      this.yaw = yaw;
      this.torqueAct.set(0, 0, 0);
      this.thrustAct = this.params.mass * this.params.gravity; // start at hover thrust
    }

    /**
     * Advance the simulation.
     * @param {number} dt
     * @param {object} ctrl
     *   { throttle, roll, pitch, yawRate }  (manual)
     *   or { throttle, up, yaw }            (autopilot)
     * @param {object|null} wind { vel, turb, torque } (THREE.Vector3, may be null)
     */
    step(dt, ctrl, wind) {
      const p = this.params;
      this._resolveTarget(ctrl, dt);

      const n = Math.min(MAX_SUBSTEPS, Math.max(1, Math.ceil(dt / MAX_SUBSTEP)));
      const h = dt / n;
      for (let i = 0; i < n; i++) this._substep(h, wind);

      this.rotorSpeed = 25 + clamp(this.thrustAct / p.maxThrust, 0, 1) * 220;
    }

    /** Build target attitude (from up vector + heading) and thrust command. */
    _resolveTarget(ctrl, dt) {
      const p = this.params;
      const maxTilt = p.maxTilt * DEG;
      const up = this._up;
      let yawT;

      if (ctrl.up) {
        up.copy(ctrl.up);
        yawT = ctrl.yaw;
      } else {
        // manual: tilt is relative to the drone's current yaw
        up.set(Math.sin(ctrl.pitch * maxTilt), 1, Math.sin(ctrl.roll * maxTilt));
        this.yaw += ctrl.yawRate * p.yawRate * DEG * dt;
        yawT = this.yaw;
        up.normalize().applyAxisAngle(AXIS_Y, yawT);
      }
      up.normalize();

      // hard tilt limit — no source may ask for more than the envelope allows
      const s = Math.hypot(up.x, up.z);
      if (s > 1e-6) {
        const tilt = Math.atan2(s, up.y);
        if (tilt > maxTilt) {
          const k = Math.sin(maxTilt) / s;
          up.set(up.x * k, Math.cos(maxTilt), up.z * k);
        }
      }

      // desired rotation basis: columns = (forward, up, right)
      //   right   = heading × up
      //   forward = up × right   (= heading projected onto the plane ⟂ up)
      this._hd.set(Math.cos(yawT), 0, -Math.sin(yawT));
      const zb = this._zb.crossVectors(this._hd, up);
      if (zb.lengthSq() < 1e-8) zb.set(0, 0, 1);
      zb.normalize();
      const xb = this._xb.crossVectors(up, zb);
      this._m4.makeBasis(xb, up, zb);
      this._qDes.setFromRotationMatrix(this._m4);

      this._thrustCmd = clamp(ctrl.throttle, 0, 1) * p.maxThrust;

      // per-drone yaw-rate limit (bots turn slower than the lead), never above the airframe limit
      this._yawRateLimit = Math.min(p.yawRate, ctrl.maxYawRate || p.yawRate) * DEG;
    }

    _substep(h, wind) {
      const p = this.params;
      const m = p.mass;

      // ---- attitude error as rotation vector in the body frame ----
      const qInv = this._q1.copy(this.quat).invert();
      const qErr = this._q2.multiplyQuaternions(qInv, this._qDes);
      let vx = qErr.x, vy = qErr.y, vz = qErr.z, w = qErr.w;
      if (w < 0) { vx = -vx; vy = -vy; vz = -vz; w = -w; } // shortest path
      const sn = Math.sqrt(vx * vx + vy * vy + vz * vz);
      let ex = 0, ey = 0, ez = 0;
      if (sn > 1e-9) {
        const k = (2 * Math.atan2(sn, w)) / sn;
        ex = vx * k; ey = vy * k; ez = vz * k;
      }

      // ---- outer loop: attitude -> desired body rates ----
      const wb = this._v1.copy(this.angVel).applyQuaternion(qInv);
      const maxRate = p.maxRate * DEG;
      const maxYawRate = this._yawRateLimit;
      let rdx = p.Kp * ex, rdz = p.Kp * ez;
      const rm = Math.hypot(rdx, rdz);
      if (rm > maxRate) { rdx *= maxRate / rm; rdz *= maxRate / rm; }
      const rdy = clamp(p.Kp * ey, -maxYawRate, maxYawRate);

      // ---- inner loop: rate error -> torque command (body: x roll, y yaw, z pitch) ----
      let tx = p.Kd * (rdx - wb.x) * p.inertiaPitch;
      let tz = p.Kd * (rdz - wb.z) * p.inertiaPitch;
      let ty = p.Kd * (rdy - wb.y) * p.inertiaYaw;
      const tm = Math.hypot(tx, tz);
      if (tm > p.maxTorque) { const k = p.maxTorque / tm; tx *= k; tz *= k; }
      ty = clamp(ty, -p.maxYawTorque, p.maxYawTorque);

      // ---- motor lag (thrust + torque) ----
      const kl = 1 - Math.exp(-h / Math.max(1e-3, p.motorLag));
      const ta = this.torqueAct;
      ta.x += (tx - ta.x) * kl;
      ta.y += (ty - ta.y) * kl;
      ta.z += (tz - ta.z) * kl;
      this.thrustAct += (this._thrustCmd - this.thrustAct) * kl;

      // ---- disturbance torque (turbulence), not lagged ----
      let Tx = ta.x, Ty = ta.y, Tz = ta.z;
      if (wind) {
        const tw = this._v3.copy(wind.torque).applyQuaternion(qInv);
        Tx += tw.x; Ty += tw.y; Tz += tw.z;
      }

      // angular acceleration (body) -> world
      const ab = this._v2.set(Tx / p.inertiaPitch, Ty / p.inertiaYaw, Tz / p.inertiaPitch);
      const alphaW = ab.applyQuaternion(this.quat);
      this.angVel.addScaledVector(alphaW, h);

      // integrate orientation (exponential map, exact for constant rate)
      const aw = this.angVel.length();
      if (aw > 1e-9) {
        this._dq.setFromAxisAngle(this._v4.copy(this.angVel).multiplyScalar(1 / aw), aw * h);
        this.quat.premultiply(this._dq).normalize();
      }

      // ---- forces ----
      const F = this._v5.set(0, 0, 0);
      const upWorld = this._v4.set(0, 1, 0).applyQuaternion(this.quat);
      F.addScaledVector(upWorld, this.thrustAct); // rotor thrust
      F.y -= m * p.gravity;                       // gravity

      // drag relative to wind (air speed)
      const relVel = this._v3.copy(this.vel).sub(wind ? wind.vel : ZERO);
      const speed = relVel.length();
      F.addScaledVector(relVel, -(p.drag + p.dragQuad * speed));

      if (wind) F.add(wind.turb); // turbulence force

      // integrate velocity & position
      this.vel.addScaledVector(F, h / m);
      this.pos.addScaledVector(this.vel, h);

      // room boundary (soft reflection on all six faces, floor included)
      const b = this.bounds;
      if (this.pos.x < b.min.x) { this.pos.x = b.min.x; if (this.vel.x < 0) this.vel.x *= -0.25; }
      if (this.pos.x > b.max.x) { this.pos.x = b.max.x; if (this.vel.x > 0) this.vel.x *= -0.25; }
      if (this.pos.y < b.min.y) { this.pos.y = b.min.y; if (this.vel.y < 0) this.vel.y *= -0.25; }
      if (this.pos.y > b.max.y) { this.pos.y = b.max.y; if (this.vel.y > 0) this.vel.y *= -0.25; }
      if (this.pos.z < b.min.z) { this.pos.z = b.min.z; if (this.vel.z < 0) this.vel.z *= -0.25; }
      if (this.pos.z > b.max.z) { this.pos.z = b.max.z; if (this.vel.z > 0) this.vel.z *= -0.25; }
    }

    updateRotors(dt) {
      if (!this.mesh || !this.mesh.props) return;
      const spin = this.rotorSpeed * dt;
      for (const prop of this.mesh.props) prop.rotation.y += spin;
    }

    syncMesh() {
      if (!this.mesh) return;
      this.mesh.position.copy(this.pos);
      this.mesh.quaternion.copy(this.quat);
    }
  }

  window.Quadcopter = Quadcopter;
  window.ROOM_BOUNDS = ROOM_BOUNDS;
  window.GROUND_Y = GROUND_Y;
})();
js/ui.js
/**
 * ui.js — builds the side panel (parameter sliders, bot cards, choreography
 * controls and state save/load) and wires it to the simulation via hooks.
 */
(function () {
  'use strict';

  const STATES_KEY = 'droneSim.states';

  function el(tag, cls, text) {
    const node = document.createElement(tag);
    if (cls) node.className = cls;
    if (text != null) node.textContent = text;
    return node;
  }

  function fmt(v, unit) {
    const a = Math.abs(v);
    let s = a >= 100 ? v.toFixed(0) : a >= 10 ? v.toFixed(1) : v.toFixed(2);
    if (v !== 0 && a < 1) s = v.toFixed(3);
    return unit ? s + ' ' + unit : s;
  }

  const UI = {
    hooks: null,
    container: null,
    _bindings: {},

    init(hooks) {
      this.hooks = hooks;
      this.container = document.getElementById('panelContent');
      this.container.innerHTML = '';
      this._bindings = {};
      this._buildControls();
      this._buildBots();
      this._buildChoreo();
      this._buildStates();
      this._bindPanelToggle();
    },

    /* ---------------------------------------------------------------- */
    _bindPanelToggle() {
      const panel = document.getElementById('panel');
      const toggle = document.getElementById('panelToggle');
      const close = document.getElementById('panelClose');
      const setOpen = (open) => {
        panel.classList.toggle('closed', !open);
        toggle.style.display = open ? 'none' : 'flex';
      };
      toggle.addEventListener('click', () => setOpen(true));
      close.addEventListener('click', () => setOpen(false));
      window.addEventListener('keydown', (e) => {
        if (e.code === 'Escape' && !panel.classList.contains('closed')) setOpen(false);
      });
    },

    /* ---------------------------------------------------------------- */
    _section(title) {
      const sec = el('section', 'section');
      const head = el('button', 'section-head');
      head.innerHTML = '<span>' + title + '</span><span class="chev">▾</span>';
      const body = el('div', 'section-body');
      head.addEventListener('click', () => sec.classList.toggle('closed'));
      sec.append(head, body);
      this.container.append(sec);
      return body;
    },

    _slider(label, min, max, step, value, unit, onChange, key) {
      const field = el('div', 'field');
      const row = el('div', 'field-row');
      const lbl = el('span', 'field-label', label);
      const val = el('span', 'field-value', fmt(value, unit));
      row.append(lbl, val);

      const range = el('input');
      range.type = 'range';
      range.min = min; range.max = max; range.step = step;
      range.value = value;
      range.addEventListener('input', () => {
        const v = parseFloat(range.value);
        val.textContent = fmt(v, unit);
        onChange(v);
      });
      field.append(row, range);

      if (key) {
        this._bindings[key] = { set: (v) => { range.value = v; val.textContent = fmt(v, unit); } };
      }
      return field;
    },

    _toggle(label, checked, onChange, key) {
      const lab = el('label', 'switch');
      const input = el('input');
      input.type = 'checkbox';
      input.checked = checked;
      const track = el('span', 'track');
      lab.append(input, track, document.createTextNode(' ' + label));
      input.addEventListener('change', () => onChange(input.checked));
      if (key) {
        this._bindings[key] = { set: (v) => { input.checked = v; } };
      }
      return { el: lab, input };
    },

    /** Update slider/checkbox positions to match Config.values (after load). */
    refreshParams() {
      for (const key in this._bindings) {
        if (key in Config.values) this._bindings[key].set(Config.values[key]);
      }
    },

    /** Update a single control without firing its change handler. */
    setControl(key, v) {
      if (this._bindings[key]) this._bindings[key].set(v);
    },

    /* ---------------------------------------------------------------- */
    _buildControls() {
      // Flight controls help
      const flight = this._section('Flight Controls');
      flight.append(el('p', 'hint',
        'Xbox (Mode 2): left stick = up/down + yaw, right stick = forward/strafe. ' +
        'Neutral sticks = hold position & altitude. ' +
        'A reset · Y record · X play · B loop · Back clear.'));
      flight.append(el('p', 'hint',
        'Keyboard: W/S up/down · A/D yaw · arrows forward/strafe · Space/Shift up/down. ' +
        'Release = hold. R reset · G record · P play · L loop · C clear · T trails · Esc panel.'));

      const btnRow = el('div', 'btn-row');
      const resetBtn = el('button', 'btn', '↺ Reset drones');
      resetBtn.addEventListener('click', () => this.hooks.onReset());
      btnRow.append(resetBtn);
      flight.append(btnRow);

      // parameter groups
      for (const group of GROUP_ORDER) {
        const body = this._section(GROUP_LABELS[group]);
        for (const key in PARAM_DEFS) {
          const def = PARAM_DEFS[key];
          if (def.group !== group) continue;
          if (def.type === 'bool') {
            const wrap = el('div', 'field');
            const t = this._toggle(def.label, Config.values[key], (v) => {
              Config.values[key] = v;
              this.hooks.onParamChanged(key, v);
            }, key);
            wrap.append(t.el);
            body.append(wrap);
          } else {
            body.append(this._slider(def.label, def.min, def.max, def.step,
              Config.values[key], def.unit, (v) => {
                Config.values[key] = v;
                this.hooks.onParamChanged(key, v);
              }, key));
          }
        }
      }
    },

    /* ---------------------------------------------------------------- */
    _buildBots() {
      const body = this._section('Drones & Offsets');
      this.botsBody = body;

      const addBtn = el('button', 'btn primary', '+ Add bot');
      addBtn.addEventListener('click', () => {
        const color = BOT_COLORS[Config.bots.length % BOT_COLORS.length];
        Config.bots.push({
          name: 'Bot ' + (Config.bots.length + 1),
          color,
          behavior: 'follow',
          offset: { x: 1.0, y: 0, z: 0 },
          followStrength: 1,
          enabled: true,
        });
        this.hooks.onBotsChanged();
        this.refreshBots();
      });
      body.append(addBtn);
      body.append(el('div', 'sep'));

      const anchorRow = el('div', 'btn-row');
      const anchorBtn = el('button', 'btn', '⌖ Set hover anchors');
      anchorBtn.addEventListener('click', () => this.hooks.onSetAnchors());
      anchorRow.append(anchorBtn);
      body.append(anchorRow);

      this.botsList = el('div');
      body.append(this.botsList);
      this.refreshBots();
    },

    refreshBots() {
      if (!this.botsList) return;
      this.botsList.innerHTML = '';
      Config.bots.forEach((bot, index) => {
        this.botsList.append(this._botCard(bot, index));
      });
    },

    _botCard(bot, index) {
      const hooks = this.hooks;
      const card = el('div', 'bot-card');

      const head = el('div', 'bot-card-head');
      const swatch = el('input', 'bot-swatch');
      swatch.type = 'color';
      swatch.value = bot.color;
      swatch.addEventListener('input', () => { bot.color = swatch.value; hooks.onBotsChanged(); });

      const name = el('input', 'bot-name');
      name.type = 'text';
      name.value = bot.name;
      name.addEventListener('change', () => { bot.name = name.value || ('Bot ' + (index + 1)); });

      const enable = el('label', 'switch');
      const enIn = el('input');
      enIn.type = 'checkbox';
      enIn.checked = bot.enabled !== false;
      const enTrack = el('span', 'track');
      enIn.addEventListener('change', () => { bot.enabled = enIn.checked; hooks.onBotsChanged(); });
      enable.append(enIn, enTrack);

      const rm = el('button', 'bot-remove', '✕');
      rm.title = 'Remove bot';
      rm.addEventListener('click', () => {
        Config.bots.splice(index, 1);
        hooks.onBotsChanged();
        this.refreshBots();
      });

      head.append(swatch, name, enable, rm);
      card.append(head);

      const behField = el('div', 'field');
      const sel = el('select');
      BEHAVIORS.forEach((b) => {
        const opt = el('option', null, b.label);
        opt.value = b.id;
        if (bot.behavior === b.id) opt.selected = true;
        sel.append(opt);
      });
      sel.addEventListener('change', () => { bot.behavior = sel.value; hooks.onBotsChanged(); });
      behField.append(sel);
      card.append(behField);

      const off = (axis, label) => this._slider(label, -2.5, 2.5, 0.05, bot.offset[axis], 'm', (v) => {
        bot.offset[axis] = v;
        hooks.onBotsChanged();
      });
      card.append(off('x', 'Offset X (fwd)'));
      card.append(off('y', 'Offset Y (up)'));
      card.append(off('z', 'Offset Z (right)'));

      card.append(this._slider('Follow strength', 0.1, 3, 0.05, bot.followStrength, '', (v) => {
        bot.followStrength = v;
        hooks.onBotsChanged();
      }));

      return card;
    },

    /* ---------------------------------------------------------------- */
    _buildChoreo() {
      const body = this._section('Choreography');

      this.recBtn = el('button', 'btn', '● Record');
      this.recBtn.addEventListener('click', () => this.hooks.onRecord());

      this.playBtn = el('button', 'btn', '▶ Play');
      this.playBtn.addEventListener('click', () => this.hooks.onPlay());

      const clearBtn = el('button', 'btn danger', 'Clear track');
      clearBtn.addEventListener('click', () => this.hooks.onClear());

      const loopT = this._toggle('Loop', false, (v) => this.hooks.setLoop(v));
      this.loopInput = loopT.input;

      const row1 = el('div', 'btn-row');
      row1.append(this.recBtn, this.playBtn);
      body.append(row1);

      const row2 = el('div', 'btn-row');
      row2.append(clearBtn);
      body.append(row2);

      const loopWrap = el('div', 'field');
      loopWrap.append(loopT.el);
      body.append(loopWrap);

      this.choreoInfo = el('p', 'hint', 'No track recorded yet.');
      body.append(this.choreoInfo);

      body.append(el('div', 'sep'));

      const pb = PARAM_DEFS.playbackSpeed;
      body.append(this._slider(pb.label, pb.min, pb.max, pb.step,
        Config.values.playbackSpeed, pb.unit, (v) => {
          Config.values.playbackSpeed = v;
          this.hooks.onParamChanged('playbackSpeed', v);
        }, 'playbackSpeed'));

      body.append(el('p', 'hint',
        'Record your flight, then press Play. The lead drone follows the track ' +
        'while bots keep their offsets & behaviors — that is your choreography.'));
    },

    setChoreo(info) {
      if (!this.recBtn) return;
      this.recBtn.classList.toggle('active', info.recording);
      this.recBtn.textContent = info.recording ? '■ Stop' : '● Record';
      this.playBtn.classList.toggle('active', info.playing);
      this.playBtn.textContent = info.playing ? '⏸ Stop' : '▶ Play';
      this.loopInput.checked = info.loop;
      if (info.hasTrack) {
        this.choreoInfo.textContent =
          'Track: ' + info.duration.toFixed(1) + 's · ' + info.frames + ' frames' +
          (info.playing ? ' · playing' : '');
      } else {
        this.choreoInfo.textContent = 'No track recorded yet.';
      }
    },

    /* ---------------------------------------------------------------- */
    _buildStates() {
      const body = this._section('States');

      const nameRow = el('div', 'btn-row');
      this.stateName = el('input', 'state-name');
      this.stateName.type = 'text';
      this.stateName.placeholder = 'State name…';
      const saveBtn = el('button', 'btn primary', 'Save');
      saveBtn.addEventListener('click', () => this._saveCurrent());
      nameRow.append(this.stateName, saveBtn);
      body.append(nameRow);

      const ioRow = el('div', 'btn-row');
      const exportBtn = el('button', 'btn', 'Export');
      exportBtn.addEventListener('click', () => this._export());
      const importBtn = el('button', 'btn', 'Import');
      importBtn.addEventListener('click', () => this._import());
      const defBtn = el('button', 'btn', 'Defaults');
      defBtn.addEventListener('click', () => {
        this.hooks.onResetDefaults();
        this.refreshBots();
        this.refreshParams();
      });
      ioRow.append(exportBtn, importBtn, defBtn);
      body.append(ioRow);

      this.statesList = el('div');
      body.append(this.statesList);
      this.refreshStates();
    },

    _readStates() {
      try { return JSON.parse(localStorage.getItem(STATES_KEY)) || {}; }
      catch (e) { return {}; }
    },
    _writeStates(s) { localStorage.setItem(STATES_KEY, JSON.stringify(s)); },

    _saveCurrent() {
      const name = (this.stateName.value || '').trim() || ('State ' + Date.now());
      const states = this._readStates();
      states[name] = this.hooks.serialize();
      this._writeStates(states);
      this.stateName.value = '';
      this.refreshStates();
    },

    refreshStates() {
      if (!this.statesList) return;
      this.statesList.innerHTML = '';
      const states = this._readStates();
      const names = Object.keys(states);
      if (names.length === 0) {
        this.statesList.append(el('p', 'hint', 'No saved states.'));
        return;
      }
      names.forEach((name) => {
        const item = el('div', 'state-item');
        const st = states[name];
        const bots = st.bots ? st.bots.length : 0;
        const meta = st.savedAt ? new Date(st.savedAt).toLocaleString() : '';
        const nm = el('span', 'name', name);
        const mt = el('span', 'meta', bots + ' drones · ' + meta);
        const load = el('button', 'btn small', 'Load');
        load.addEventListener('click', () => {
          this.hooks.applyState(st);
          this.refreshParams();
          this.refreshBots();
        });
        const del = el('button', 'btn small danger', '✕');
        del.title = 'Delete';
        del.addEventListener('click', () => {
          const s2 = this._readStates();
          delete s2[name];
          this._writeStates(s2);
          this.refreshStates();
        });
        item.append(nm, mt, load, del);
        this.statesList.append(item);
      });
    },

    _export() {
      const data = this.hooks.serialize();
      const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
      const url = URL.createObjectURL(blob);
      const a = el('a');
      a.href = url;
      a.download = 'drone-state.json';
      a.click();
      URL.revokeObjectURL(url);
    },

    _import() {
      const input = el('input');
      input.type = 'file';
      input.accept = 'application/json';
      input.addEventListener('change', () => {
        const file = input.files && input.files[0];
        if (!file) return;
        const reader = new FileReader();
        reader.onload = () => {
          try {
            const data = JSON.parse(reader.result);
            this.hooks.applyState(data);
            this.refreshParams();
            this.refreshBots();
          } catch (e) {
            alert('Invalid state file.');
          }
        };
        reader.readAsText(file);
      });
      input.click();
    },

    setInputLabel(label) {
      const elNode = document.getElementById('hud-input');
      if (elNode) elNode.textContent = label;
    },
  };

  window.UI = UI;
})();
style.css
:root {
  --bg: #ffffff;
  --panel-bg: #ffffff;
  --border: #e5e7eb;
  --border-strong: #d1d5db;
  --text: #111827;
  --text-muted: #6b7280;
  --accent: #111827;
  --accent-soft: #f3f4f6;
  --rec: #ef4444;
  --shadow: 0 1px 3px rgba(0,0,0,0.08), 0 8px 24px rgba(0,0,0,0.06);
  --radius: 10px;
  --mono: ui-monospace, "SF Mono", "Cascadia Code", "Consolas", monospace;
  --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  height: 100%;
  overflow: hidden;
  font-family: var(--sans);
  color: var(--text);
  background: var(--bg);
  -webkit-font-smoothing: antialiased;
}

#app { position: fixed; inset: 0; }

#canvas { display: block; width: 100%; height: 100%; }

/* ---------- HUD ---------- */
#hud {
  position: absolute;
  top: 16px;
  left: 16px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  pointer-events: none;
  user-select: none;
}

#hud-status { display: flex; gap: 6px; align-items: center; }

.chip {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.02em;
  padding: 4px 9px;
  border-radius: 999px;
  background: rgba(255,255,255,0.85);
  border: 1px solid var(--border);
  color: var(--text-muted);
  text-transform: uppercase;
}

.chip.hidden { display: none; }

#hud-rec { color: var(--rec); border-color: rgba(239,68,68,0.4); }

#hud-stats {
  font-size: 11px;
  color: var(--text-muted);
  font-family: var(--mono);
  display: flex;
  gap: 12px;
}

/* ---------- Panel toggle ---------- */
#panelToggle {
  position: absolute;
  top: 16px;
  right: 16px;
  width: 38px;
  height: 38px;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: #fff;
  color: var(--text);
  font-size: 16px;
  cursor: pointer;
  box-shadow: var(--shadow);
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform 0.15s ease;
}
#panelToggle:hover { transform: scale(1.05); }

/* ---------- Panel ---------- */
#panel {
  position: absolute;
  top: 0;
  right: 0;
  height: 100%;
  width: 340px;
  background: var(--panel-bg);
  border-left: 1px solid var(--border);
  box-shadow: var(--shadow);
  display: flex;
  flex-direction: column;
  transform: translateX(0);
  transition: transform 0.25s cubic-bezier(0.4,0,0.2,1);
  z-index: 10;
}
#panel.closed { transform: translateX(100%); }

#panelHeader {
  padding: 18px 18px 14px;
  border-bottom: 1px solid var(--border);
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
}
#panelHeader h1 { margin: 0; font-size: 17px; font-weight: 700; letter-spacing: -0.01em; }
#panelHeader .subtitle { margin: 3px 0 0; font-size: 12px; color: var(--text-muted); }
#panelClose {
  border: none;
  background: transparent;
  color: var(--text-muted);
  font-size: 14px;
  cursor: pointer;
  padding: 4px;
}
#panelClose:hover { color: var(--text); }

#panelContent {
  flex: 1;
  overflow-y: auto;
  padding: 6px 0 24px;
}

/* ---------- Sections ---------- */
.section { border-bottom: 1px solid var(--border); }
.section:last-child { border-bottom: none; }

.section-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  padding: 14px 18px;
  background: none;
  border: none;
  cursor: pointer;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text);
}
.section-head:hover { background: var(--accent-soft); }
.section-head .chev { transition: transform 0.2s ease; color: var(--text-muted); font-size: 10px; }
.section.closed .chev { transform: rotate(-90deg); }
.section.closed .section-body { display: none; }

.section-body { padding: 2px 18px 16px; }

.hint {
  font-size: 12px;
  color: var(--text-muted);
  line-height: 1.5;
  margin: 0 0 10px;
}

/* ---------- Controls ---------- */
.field { margin-bottom: 12px; }

.field-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 4px;
}
.field-label { font-size: 12px; color: var(--text); }
.field-value {
  font-size: 11px;
  font-family: var(--mono);
  color: var(--text-muted);
  background: var(--accent-soft);
  padding: 1px 6px;
  border-radius: 4px;
  min-width: 46px;
  text-align: right;
}

input[type="range"] {
  -webkit-appearance: none;
  width: 100%;
  height: 4px;
  border-radius: 4px;
  background: var(--border);
  outline: none;
  cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid #fff;
  box-shadow: 0 0 0 1px var(--border-strong);
  cursor: pointer;
}
input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.1); }

input[type="number"] {
  width: 100%;
  padding: 6px 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: 12px;
  font-family: var(--mono);
  color: var(--text);
  background: #fff;
}
input[type="number"]:focus { outline: none; border-color: var(--accent); }

input[type="text"] {
  width: 100%;
  padding: 6px 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: 12px;
  color: var(--text);
}

select {
  width: 100%;
  padding: 6px 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: 12px;
  color: var(--text);
  background: #fff;
  cursor: pointer;
}

/* checkbox */
.switch {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  font-size: 12px;
}
.switch input { display: none; }
.switch .track {
  width: 30px;
  height: 18px;
  border-radius: 999px;
  background: var(--border-strong);
  position: relative;
  transition: background 0.2s ease;
}
.switch .track::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: #fff;
  transition: transform 0.2s ease;
}
.switch input:checked + .track { background: var(--accent); }
.switch input:checked + .track::after { transform: translateX(12px); }

/* ---------- Buttons ---------- */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 7px 12px;
  border-radius: 8px;
  border: 1px solid var(--border-strong);
  background: #fff;
  color: var(--text);
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s ease, transform 0.1s ease;
}
.btn:hover { background: var(--accent-soft); }
.btn:active { transform: scale(0.97); }
.btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn.primary:hover { background: #1f2937; }
.btn.danger { color: var(--rec); border-color: rgba(239,68,68,0.4); }
.btn.danger:hover { background: rgba(239,68,68,0.08); }
.btn.active { background: var(--rec); color: #fff; border-color: var(--rec); }
.btn.small { padding: 4px 9px; font-size: 11px; }

.btn-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.btn-row .btn { flex: 1; }

/* ---------- Bot cards ---------- */
.bot-card {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 12px;
  margin-bottom: 10px;
  background: #fafafa;
}
.bot-card-head {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}
.bot-swatch {
  width: 14px;
  height: 14px;
  border-radius: 4px;
  border: 1px solid var(--border-strong);
  flex-shrink: 0;
}
.bot-name {
  font-size: 13px;
  font-weight: 600;
  flex: 1;
  border: 1px solid transparent;
  background: transparent;
  padding: 3px 5px;
  border-radius: 5px;
  color: var(--text);
  min-width: 0;
}
.bot-name:hover { border-color: var(--border); }
.bot-name:focus { outline: none; border-color: var(--accent); background: #fff; }

input[type="color"].bot-swatch {
  -webkit-appearance: none;
  appearance: none;
  width: 20px;
  height: 20px;
  padding: 0;
  border: 1px solid var(--border-strong);
  border-radius: 5px;
  background: none;
  cursor: pointer;
  flex-shrink: 0;
}
input[type="color"].bot-swatch::-webkit-color-swatch-wrapper { padding: 0; }
input[type="color"].bot-swatch::-webkit-color-swatch { border: none; border-radius: 3px; }
input[type="color"].bot-swatch::-moz-color-swatch { border: none; border-radius: 3px; }
.bot-remove {
  border: none;
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  font-size: 14px;
  padding: 2px 4px;
}
.bot-remove:hover { color: var(--rec); }

/* ---------- State list ---------- */
.state-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  margin-bottom: 6px;
  font-size: 12px;
}
.state-item .name { flex: 1; font-weight: 500; }
.state-item .meta { font-size: 10px; color: var(--text-muted); font-family: var(--mono); }

.sep { height: 1px; background: var(--border); margin: 12px 0; }

::-webkit-scrollbar { width: 10px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 8px; border: 2px solid #fff; }
::-webkit-scrollbar-track { background: transparent; }