///|
/// Millisecond clock for the four-segment attribution timing (design.md
/// 「定位」: scene build / render_at / SVG serialization / DOM update).
extern "js" fn now() -> Double =
  #| () => performance.now()

///|
/// Register the playground API on the JS global object.
extern "js" fn register_api(
  render : (String, String, String, Double) -> String,
  duration : (String, String, String) -> Double,
  prepare : (String, String, String) -> Double,
  frame : (Double, Double, Double, Bool, Bool) -> String,
  last_svg : () -> String,
  last_render_ms : () -> Double,
  last_svg_ms : () -> Double,
) -> Unit =
  #| (render, duration, prepare, frame, last_svg, last_render_ms, last_svg_ms) => {
  #|   globalThis.moonim = { render, duration, prepare, frame, last_svg, last_render_ms, last_svg_ms };
  #| }

///|
/// Human-readable render error for the playground page.
fn err_msg(e : @tex.RenderError) -> String {
  match e {
    EmptyFormula => "empty formula"
    UnsupportedSyntax(s) => "unsupported syntax: " + s
    UnbalancedBraces => "unbalanced braces"
  }
}

///|
/// Typeset `formula` at `size` and center it at `center`.
fn tex_at(
  renderer : @tex.MiniTex,
  formula : String,
  size : Double,
  center : @math.Vec2,
) -> Result[@mobject.Mobject, String] {
  match renderer.render_symbols(formula, size) {
    Err(e) => Err("render error in \{formula}: " + err_msg(e))
    Ok(symbols) =>
      Ok(@mobject.Mobject::tex(formula, symbols).move_center_to(center))
  }
}

///|
/// Standardized benchmark suite (design.md「定位」节的性能判断标准):
/// deterministic scenes that ignore the formula inputs, so frame-time
/// numbers stay comparable across runs and engine changes.
/// - bench-light:    2 layers, a shape morph + a 3-glyph formula morph
/// - bench-standard: 5 layers, formula morph + axes + grow/rotate/wiggle
/// - bench-heavy:    27 layers, long-formula morph + 25 staggered circles
///   + a continuously rotating frame (the "beyond scope" probe)
fn build_bench(scene_id : String) -> Result[@anim.Scene, String] {
  let scene = @anim.Scene::new()
  let renderer = @tex.MiniTex::new()
  match scene_id {
    "bench-light" => {
      let circle = @mobject.Mobject::vmobject(
        @geom.Path::circle(@math.Vec2::new(320.0, 270.0), 90.0),
        fill=Some(@math.Color::blue()),
      )
      let square = @mobject.Mobject::vmobject(
        @geom.Path::rect(@math.Vec2::new(230.0, 180.0), 180.0, 180.0),
        fill=Some(@math.Color::yellow()),
      )
      scene.add_layer(
        @anim.Layer::new(circle, [
          @anim.Animation::wait(duration=0.5),
          @anim.Animation::transform_to(square, duration=2.0),
          @anim.Animation::wait(duration=0.5),
        ]),
      )
      match
        (
          tex_at(renderer, "x^2", 48.0, @math.Vec2::new(660.0, 270.0)),
          tex_at(renderer, "2x", 48.0, @math.Vec2::new(660.0, 270.0)),
        ) {
        (Ok(ma), Ok(mb)) => {
          scene.add_layer(
            @anim.Layer::new(ma, [
              @anim.Animation::wait(duration=0.5),
              @anim.Animation::transform_matching(mb, duration=2.0),
              @anim.Animation::wait(duration=0.5),
            ]),
          )
          Ok(scene)
        }
        (Err(e), _) | (_, Err(e)) => Err(e)
      }
    }
    "bench-standard" => {
      // 1) formula morph, top center
      match
        (
          tex_at(
            renderer,
            "\\frac{x^2}{2}",
            56.0,
            @math.Vec2::new(480.0, 140.0),
          ),
          tex_at(
            renderer,
            "\\frac{x^3}{3}",
            56.0,
            @math.Vec2::new(480.0, 140.0),
          ),
        ) {
        (Ok(ma), Ok(mb)) =>
          scene.add_layer(
            @anim.Layer::new(ma, [
              @anim.Animation::wait(duration=0.5),
              @anim.Animation::transform_matching(mb, duration=3.0),
              @anim.Animation::wait(duration=1.5),
            ]),
          )
        (Err(e), _) | (_, Err(e)) => return Err(e)
      }
      // 2) axes with ticks, stroked
      let axes_path = @geom.Path::new()
      axes_path.move_to(@math.Vec2::new(120.0, 400.0))
      axes_path.line_to(@math.Vec2::new(840.0, 400.0))
      axes_path.move_to(@math.Vec2::new(480.0, 230.0))
      axes_path.line_to(@math.Vec2::new(480.0, 470.0))
      for i in 0..<=8 {
        let x = 200.0 + i.to_double() * 80.0
        axes_path.move_to(@math.Vec2::new(x, 394.0))
        axes_path.line_to(@math.Vec2::new(x, 406.0))
      }
      let axes = @mobject.Mobject::vmobject(
        axes_path,
        fill=None,
        stroke=Some((@math.Color::new(0.6, 0.6, 0.65), 2.0)),
      )
      scene.add_layer(
        @anim.Layer::new(axes, [
          @anim.Animation::fade_in(duration=1.0),
          @anim.Animation::wait(duration=4.0),
        ]),
      )
      // 3) circle: grow, then pulse
      let circle = @mobject.Mobject::vmobject(
        @geom.Path::circle(@math.Vec2::new(240.0, 330.0), 42.0),
        fill=Some(@math.Color::green()),
      )
      scene.add_layer(
        @anim.Layer::new(circle, [
          @anim.Animation::grow_from_center(duration=1.0),
          @anim.Animation::wait(duration=1.0),
          @anim.Animation::scale_in_place(
            1.4,
            duration=2.0,
            easing=@anim.there_and_back,
          ),
          @anim.Animation::wait(duration=1.0),
        ]),
      )
      // 4) square: fade in, then quarter-turn about its center
      let square = @mobject.Mobject::vmobject(
        @geom.Path::rect(@math.Vec2::new(690.0, 300.0), 60.0, 60.0),
        fill=Some(@math.Color::yellow()),
      )
      scene.add_layer(
        @anim.Layer::new(square, [
          @anim.Animation::fade_in(duration=1.0),
          @anim.Animation::rotate(@core_math.PI / 2.0, duration=2.0),
          @anim.Animation::wait(duration=2.0),
        ]),
      )
      // 5) triangle: fade in, then wiggle (indicate)
      let tri_path = @geom.Path::new()
      tri_path.move_to(@math.Vec2::new(480.0, 280.0))
      tri_path.line_to(@math.Vec2::new(450.0, 330.0))
      tri_path.line_to(@math.Vec2::new(510.0, 330.0))
      tri_path.close()
      let tri = @mobject.Mobject::vmobject(
        tri_path,
        fill=Some(@math.Color::red()),
      )
      scene.add_layer(
        @anim.Layer::new(tri, [
          @anim.Animation::wait(duration=1.0),
          @anim.Animation::fade_in(duration=0.8),
          @anim.Animation::rotate(0.5, duration=2.0, easing=fn(t) {
            @anim.wiggle(t)
          }),
          @anim.Animation::wait(duration=1.2),
        ]),
      )
      Ok(scene)
    }
    "bench-heavy" => {
      // 1) long-formula morph, top center
      match
        (
          tex_at(
            renderer,
            "\\alpha^2+\\beta^2=\\gamma^2",
            40.0,
            @math.Vec2::new(480.0, 130.0),
          ),
          tex_at(
            renderer,
            "\\frac{\\alpha^2+\\beta^2}{\\gamma^2}",
            40.0,
            @math.Vec2::new(480.0, 130.0),
          ),
        ) {
        (Ok(ma), Ok(mb)) =>
          scene.add_layer(
            @anim.Layer::new(ma, [
              @anim.Animation::wait(duration=0.5),
              @anim.Animation::transform_matching(mb, duration=4.0),
              @anim.Animation::wait(duration=1.5),
            ]),
          )
        (Err(e), _) | (_, Err(e)) => return Err(e)
      }
      // 2) 5x5 grid of staggered grow/pulse/fade circles
      for row in 0..<5 {
        for col in 0..<5 {
          let idx = row * 5 + col
          let center = @math.Vec2::new(
            480.0 + (col.to_double() - 2.0) * 72.0,
            360.0 + (row.to_double() - 2.0) * 44.0,
          )
          let fill = if idx % 2 == 0 {
            @math.Color::blue()
          } else {
            @math.Color::green()
          }
          let dot = @mobject.Mobject::vmobject(
            @geom.Path::circle(center, 20.0),
            fill=Some(fill),
          )
          scene.add_layer(
            @anim.Layer::new(dot, [
              @anim.Animation::wait(duration=0.08 * idx.to_double()),
              @anim.Animation::grow_from_center(duration=0.6),
              @anim.Animation::scale_in_place(
                1.25,
                duration=1.2,
                easing=@anim.there_and_back,
              ),
              @anim.Animation::fade_out(duration=0.8),
            ]),
          )
        }
      }
      // 3) continuously rotating stroked frame (Rotating = rotate + linear)
      let frame = @mobject.Mobject::vmobject(
        @geom.Path::rect(@math.Vec2::new(230.0, 230.0), 500.0, 260.0),
        fill=None,
        stroke=Some((@math.Color::blue(), 2.0)),
      )
      scene.add_layer(
        @anim.Layer::new(frame, [
          @anim.Animation::rotate(
            2.0 * @core_math.PI,
            duration=6.0,
            easing=@anim.linear,
          ),
        ]),
      )
      Ok(scene)
    }
    _ => Err("unknown bench scene: " + scene_id)
  }
}

///|
/// Build the scene identified by `scene_id`. `a` / `b` are the formula
/// texts for the tex-driven scenes; morph and the bench scenes ignore them.
fn build_scene(
  scene_id : String,
  a : String,
  b : String,
) -> Result[@anim.Scene, String] {
  match scene_id {
    "bench-light" | "bench-standard" | "bench-heavy" => build_bench(scene_id)
    "attention" => {
      // Indication-family demo: the formula pulses (indicate), gets
      // circumscribed, a flash fires at its left, focus_on converges at
      // its right, and a counter ticks 0 -> 100 below.
      let scene = @anim.Scene::new()
      let renderer = @tex.MiniTex::new()
      match tex_at(renderer, a, 56.0, @math.Vec2::new(480.0, 320.0)) {
        Err(e) => return Err(e)
        Ok(m) =>
          scene.add_layer(
            @anim.Layer::new(m, [
              @anim.Animation::wait(duration=0.5),
              @anim.Animation::indicate(duration=1.2),
              @anim.Animation::wait(duration=0.3),
              @anim.Animation::circumscribe(duration=1.5),
              @anim.Animation::wait(duration=1.5),
            ]),
          )
      }
      let empty = @mobject.Mobject::group([])
      scene.add_layer(
        @anim.Layer::new(empty, [
          @anim.Animation::wait(duration=2.0),
          @anim.Animation::flash(@math.Vec2::new(240.0, 320.0), duration=1.5),
          @anim.Animation::wait(duration=1.5),
        ]),
      )
      scene.add_layer(
        @anim.Layer::new(empty, [
          @anim.Animation::wait(duration=3.0),
          @anim.Animation::focus_on(@math.Vec2::new(720.0, 320.0), duration=1.5),
          @anim.Animation::wait(duration=0.5),
        ]),
      )
      let counter_probe = @mobject.Mobject::vmobject(
        @geom.Path::circle(@math.Vec2::new(480.0, 130.0), 1.0),
      )
      scene.add_layer(
        @anim.Layer::new(counter_probe, [
          @graph.count(0.0, 100.0, duration=4.0, easing=@anim.linear),
          @anim.Animation::wait(duration=1.0),
        ]),
      )
      Ok(scene)
    }
    "graph" => {
      // Teaching-content demo: axes fade in, a sine curve writes itself,
      // a dot rides the curve, and an arrow marks a point on it.
      let scene = @anim.Scene::new()
      let origin = @math.Vec2::new(480.0, 270.0)
      let ax = @graph.axes(
        x_min=-5.0,
        x_max=5.0,
        y_min=-2.0,
        y_max=2.0,
        origin~,
        unit=80.0,
      )
      scene.add_layer(
        @anim.Layer::new(ax, [
          @anim.Animation::fade_in(duration=1.0),
          @anim.Animation::wait(duration=4.0),
        ]),
      )
      let curve_path = @graph.plot(
        fn(x) { @core_math.sin(x) },
        x_min=-5.0,
        x_max=5.0,
        origin~,
        unit=80.0,
      )
      let curve = @mobject.Mobject::vmobject(
        curve_path,
        fill=None,
        stroke=Some((@math.Color::yellow(), 2.5)),
      )
      scene.add_layer(
        @anim.Layer::new(curve, [
          @anim.Animation::wait(duration=0.5),
          @anim.Animation::show_creation(duration=2.5),
          @anim.Animation::wait(duration=2.0),
        ]),
      )
      let dot = @mobject.Mobject::vmobject(
        @geom.Path::circle(
          @graph.map_point(origin, 80.0, None, -5.0, @core_math.sin(-5.0)),
          10.0,
        ),
        fill=Some(@math.Color::red()),
      )
      scene.add_layer(
        @anim.Layer::new(dot, [
          @anim.Animation::wait(duration=1.5),
          @anim.Animation::move_along_path(
            curve_path,
            duration=3.0,
            easing=@anim.linear,
          ),
          @anim.Animation::wait(duration=0.5),
        ]),
      )
      let arrow = @mobject.Mobject::arrow(
        @graph.map_point(origin, 80.0, None, 0.0, 0.0),
        @graph.map_point(origin, 80.0, None, 2.0, 1.2),
        color=@math.Color::green(),
        width=2.5,
      )
      scene.add_layer(
        @anim.Layer::new(arrow, [
          @anim.Animation::wait(duration=3.5),
          @anim.Animation::fade_in_from_point(origin, duration=1.0),
          @anim.Animation::wait(duration=0.5),
        ]),
      )
      Ok(scene)
    }
    "unwrite" => {
      // The formula writes itself, holds, then retreats (Uncreate).
      let scene = @anim.Scene::new()
      let renderer = @tex.MiniTex::new()
      match tex_at(renderer, a, 64.0, @math.Vec2::new(480.0, 270.0)) {
        Err(e) => Err(e)
        Ok(m) => {
          scene.add_layer(
            @anim.Layer::new(m, [
              @anim.Animation::show_creation(duration=1.8),
              @anim.Animation::wait(duration=0.8),
              @anim.Animation::unwrite(duration=1.8),
            ]),
          )
          Ok(scene)
        }
      }
    }
    "gallery" => {
      // Showcase of the ported primitives (batches 0-2): dashed square
      // write-on, fade-in-from-point + pulse, wiggle indicate, and a dot
      // riding a dashed sine track.
      let scene = @anim.Scene::new()
      let square = @mobject.Mobject::vmobject(
        @geom.Path::rect(@math.Vec2::new(180.0, 240.0), 120.0, 120.0).to_dashed(),
        fill=None,
        stroke=Some((@math.Color::blue(), 2.0)),
      )
      scene.add_layer(
        @anim.Layer::new(square, [
          @anim.Animation::show_creation(duration=2.0),
          @anim.Animation::wait(duration=3.0),
        ]),
      )
      let circle = @mobject.Mobject::vmobject(
        @geom.Path::circle(@math.Vec2::new(700.0, 320.0), 50.0),
        fill=Some(@math.Color::green()),
      )
      scene.add_layer(
        @anim.Layer::new(circle, [
          @anim.Animation::fade_in_from_point(
            @math.Vec2::new(700.0, 120.0),
            duration=1.5,
          ),
          @anim.Animation::wait(duration=0.5),
          @anim.Animation::scale_in_place(
            1.3,
            duration=1.5,
            easing=@anim.there_and_back,
          ),
          @anim.Animation::wait(duration=1.5),
        ]),
      )
      let tri_path = @geom.Path::new()
      tri_path.move_to(@math.Vec2::new(480.0, 240.0))
      tri_path.line_to(@math.Vec2::new(440.0, 180.0))
      tri_path.line_to(@math.Vec2::new(520.0, 180.0))
      tri_path.close()
      let tri = @mobject.Mobject::vmobject(
        tri_path,
        fill=Some(@math.Color::red()),
      )
      scene.add_layer(
        @anim.Layer::new(tri, [
          @anim.Animation::fade_in(duration=1.0),
          @anim.Animation::wait(duration=1.0),
          @anim.Animation::rotate(0.4, duration=1.6, easing=fn(t) {
            @anim.wiggle(t)
          }),
          @anim.Animation::wait(duration=1.4),
        ]),
      )
      let sine = @geom.Path::new()
      for i in 0..<=40 {
        let x = 80.0 + i.to_double() * 20.0
        let y = 440.0 +
          40.0 * @core_math.sin(i.to_double() / 40.0 * 2.0 * @core_math.PI)
        if i == 0 {
          sine.move_to(@math.Vec2::new(x, y))
        } else {
          sine.line_to(@math.Vec2::new(x, y))
        }
      }
      let track = @mobject.Mobject::vmobject(
        sine.to_dashed(dash=8.0, gap=6.0),
        fill=None,
        stroke=Some((@math.Color::new(0.6, 0.6, 0.65), 1.5)),
      )
      scene.add_layer(
        @anim.Layer::new(track, [
          @anim.Animation::show_creation(duration=2.5),
          @anim.Animation::wait(duration=2.5),
        ]),
      )
      let dot = @mobject.Mobject::vmobject(
        @geom.Path::circle(@math.Vec2::new(80.0, 440.0), 12.0),
        fill=Some(@math.Color::yellow()),
      )
      scene.add_layer(
        @anim.Layer::new(dot, [
          @anim.Animation::wait(duration=1.0),
          @anim.Animation::move_along_path(
            sine,
            duration=3.0,
            easing=@anim.linear,
          ),
          @anim.Animation::wait(duration=1.0),
        ]),
      )
      Ok(scene)
    }
    "write" => {
      // Arc-length creation demo: the formula writes itself (glyph by
      // glyph, layout order), then a dot rides a lap around it.
      let scene = @anim.Scene::new()
      let renderer = @tex.MiniTex::new()
      match tex_at(renderer, a, 56.0, @math.Vec2::new(480.0, 300.0)) {
        Err(e) => Err(e)
        Ok(m) => {
          scene.add_layer(
            @anim.Layer::new(m, [
              @anim.Animation::show_creation(duration=2.5),
              @anim.Animation::wait(duration=2.0),
            ]),
          )
          let dot = @mobject.Mobject::vmobject(
            @geom.Path::circle(@math.Vec2::new(600.0, 300.0), 14.0),
            fill=Some(@math.Color::green()),
          )
          let track = @geom.Path::circle(@math.Vec2::new(480.0, 300.0), 120.0)
          scene.add_layer(
            @anim.Layer::new(dot, [
              @anim.Animation::wait(duration=1.5),
              @anim.Animation::move_along_path(track, duration=3.0),
            ]),
          )
          Ok(scene)
        }
      }
    }
    "pointer" => { // Interaction-env demo (design.md「在线开发与交互外壳」): a dot
      // follows the pointer and a ring reacts to the primary button.
      // Nothing here animates over time — only env drives the frame.
      let scene = @anim.Scene::new()
      let axes_path = @geom.Path::new()
      axes_path.move_to(@math.Vec2::new(60.0, 270.0))
      axes_path.line_to(@math.Vec2::new(900.0, 270.0))
      axes_path.move_to(@math.Vec2::new(480.0, 60.0))
      axes_path.line_to(@math.Vec2::new(480.0, 480.0))
      let axes = @mobject.Mobject::vmobject(
        axes_path,
        fill=None,
        stroke=Some((@math.Color::new(0.4, 0.4, 0.45), 1.5)),
      )
      scene.add_layer(
        @anim.Layer::new(axes, [@anim.Animation::wait(duration=3600.0)]),
      )
      let dot = @mobject.Mobject::vmobject(
        @geom.Path::circle(@math.Vec2::new(480.0, 270.0), 26.0),
        fill=Some(@math.Color::green()),
      )
      scene.add_layer(
        @anim.Layer::new(dot, [
          @anim.Animation::new(
            fn(start, _p, env) {
              match env.pointer {
                Some(p) => start.move_center_to(p)
                None => start
              }
            },
            duration=3600.0,
            easing=@anim.linear,
          ),
        ]),
      )
      let ring = @mobject.Mobject::vmobject(
        @geom.Path::circle(@math.Vec2::new(480.0, 270.0), 44.0),
        fill=None,
        stroke=Some((@math.Color::blue(), 3.0)),
      )
      scene.add_layer(
        @anim.Layer::new(ring, [
          @anim.Animation::new(
            fn(start, _p, env) {
              match env.pointer {
                Some(p) => {
                  let k = if env.pointer_down { 1.35 } else { 1.0 }
                  start.move_center_to(p).scale_about(p, k)
                }
                None => start
              }
            },
            duration=3600.0,
            easing=@anim.linear,
          ),
        ]),
      )
      Ok(scene)
    }
    "morph" => {
      let circle = @mobject.Mobject::vmobject(
        @geom.Path::circle(@math.Vec2::new(480.0, 270.0), 120.0),
        fill=Some(@math.Color::blue()),
      )
      let square = @mobject.Mobject::vmobject(
        @geom.Path::rect(@math.Vec2::new(360.0, 150.0), 240.0, 240.0),
        fill=Some(@math.Color::yellow()),
      )
      let scene = @anim.Scene::new()
      scene.add_layer(
        @anim.Layer::new(circle, [
          @anim.Animation::wait(duration=1.0),
          @anim.Animation::transform_to(square, duration=2.0),
          @anim.Animation::wait(duration=1.0),
        ]),
      )
      Ok(scene)
    }
    "formula" => {
      let renderer = @tex.MiniTex::new()
      match renderer.render_symbols(a, 64.0) {
        Err(e) => Err("render error: " + err_msg(e))
        Ok(symbols) => {
          let m = @mobject.Mobject::tex(a, symbols).move_center_to(
            @math.Vec2::new(480.0, 270.0),
          )
          let scene = @anim.Scene::new()
          scene.add_layer(
            @anim.Layer::new(m, [@anim.Animation::wait(duration=1.0)]),
          )
          Ok(scene)
        }
      }
    }
    "texmorph" => {
      let renderer = @tex.MiniTex::new()
      match
        (renderer.render_symbols(a, 64.0), renderer.render_symbols(b, 64.0)) {
        (Err(e), _) => Err("render error in first formula: " + err_msg(e))
        (_, Err(e)) => Err("render error in second formula: " + err_msg(e))
        (Ok(sa), Ok(sb)) => {
          let center = @math.Vec2::new(480.0, 270.0)
          let ma = @mobject.Mobject::tex(a, sa).move_center_to(center)
          let mb = @mobject.Mobject::tex(b, sb).move_center_to(center)
          let scene = @anim.Scene::new()
          scene.add_layer(
            @anim.Layer::new(ma, [
              @anim.Animation::wait(duration=0.5),
              @anim.Animation::transform_matching(mb, duration=2.0),
              @anim.Animation::wait(duration=0.5),
            ]),
          )
          Ok(scene)
        }
      }
    }
    _ => Err("unknown scene: " + scene_id)
  }
}

///|
/// The prepared scene, keyed by its inputs. `prepare` rebuilds only when
/// the key changes, so per-frame costs exclude scene construction — which
/// is timed and reported separately.
priv struct CachedScene {
  key : (String, String, String)
  result : Result[@anim.Scene, String]
}

///|
let scene_cache : Ref[CachedScene?] = { val: None }

///|
/// (svg, render_ms, svg_ms) of the most recent `frame` call.
let last_frame : Ref[(String, Double, Double)] = { val: ("", 0.0, 0.0) }

///|
/// Build the scene for the given inputs and cache it. Returns the build
/// time in ms, 0.0 on a cache hit, or -1.0 on a build error (the error is
/// kept in the cache and reported by `frame`).
fn prepare(scene_id : String, a : String, b : String) -> Double {
  let key = (scene_id, a, b)
  let hit = match scene_cache.val {
    Some(c) => c.key == key
    None => false
  }
  if hit {
    return 0.0
  }
  let t0 = now()
  let result = build_scene(scene_id, a, b)
  let ms = now() - t0
  scene_cache.val = Some({ key, result })
  match result {
    Ok(_) => ms
    Err(_) => -1.0
  }
}

///|
/// Render one frame of the prepared scene into `last_frame`, under the
/// interaction environment described by the pointer arguments (scene
/// coordinates, y-up). Returns "" on success or "ERROR: ..."; timings are
/// read back via the getters below.
fn frame(
  t : Double,
  px : Double,
  py : Double,
  pointer_active : Bool,
  pointer_down : Bool,
) -> String {
  match scene_cache.val {
    None => "ERROR: no scene prepared"
    Some(c) =>
      match c.result {
        Err(msg) => "ERROR: " + msg
        Ok(scene) => {
          let env : @anim.Env = {
            pointer: if pointer_active {
              Some(@math.Vec2::new(px, py))
            } else {
              None
            },
            pointer_down,
          }
          let t0 = now()
          let m = scene.render_at_env(t, env)
          let t1 = now()
          let svg = @svg.render_svg(m)
          let t2 = now()
          last_frame.val = (svg, t1 - t0, t2 - t1)
          ""
        }
      }
  }
}

///|
fn last_svg() -> String {
  last_frame.val.0
}

///|
fn last_render_ms() -> Double {
  last_frame.val.1
}

///|
fn last_svg_ms() -> Double {
  last_frame.val.2
}

///|
/// One SVG frame of the scene at time `t`. Errors are reported as an
/// "ERROR: ..." string so the page can display them. Kept for backward
/// compatibility; the page prefers the prepare/frame split so that scene
/// construction is not paid per frame.
fn render(scene_id : String, a : String, b : String, t : Double) -> String {
  ignore(prepare(scene_id, a, b))
  match frame(t, 0.0, 0.0, false, false) {
    "" => last_svg()
    err => err
  }
}

///|
/// Total duration of the scene in seconds (0 on error).
fn duration(scene_id : String, a : String, b : String) -> Double {
  ignore(prepare(scene_id, a, b))
  match scene_cache.val {
    Some({ result: Ok(scene), .. }) => scene.duration()
    _ => 0.0
  }
}

///|
fn main {
  register_api(
    render, duration, prepare, frame, last_svg, last_render_ms, last_svg_ms,
  )
}