Display

Stone's display system renders graphs, canvases, and animations. When you call show(), Stone spawns a local server and opens an HTML page in your browser with the rendered output.

There are two rendering systems: graph terminals for data plotting and canvas terminals for shape rendering. Both use a declarative pattern: create display records with configuration and data, then pass them to show().

// Graph terminals (data plotting)
import graph2d, graph3d, line, scatter, surface from plot
import show, vary, reveal from display

// Canvas terminals (shape rendering)
import canvas2d, canvas3d, show, vary, reveal from display
import circle, rect, group from draw2d
import box, sphere, cylinder from draw3d

vary/reveal originate from the display module. The plot module re-exports them for convenience, so import vary from plot also works.

The show() Function

Displays are first-class values. You create them, configure them, and explicitly show them:

import canvas2d, show from display
import circle from draw2d

view = canvas2d({
    title = "My Canvas",
    objects = [circle({ center = [0, 0], radius = 50 })]
})
show(view)

Multiple calls to show() create separate terminals:

show(canvas2d({ title = "View 1", objects = shapes_a }))
show(canvas2d({ title = "View 2", objects = shapes_b }))

save() and capture()

show() has two peers that take the same first-class display.

save(display, path, width?, height?, view?, frame?) writes the display to a file. The format follows the extension: .png (default) and .jpg are raster, .svg is a standalone vector file (canvas3d rejects .svg — WebGL has no vector output).

save(scene, "scene.png", width = 1280, height = 480)
save(scene, "corner.png", view = "front-top-right")   // isometric
save(scene, "scene.png", view = "front,top,front-right")  // one file per angle

view (3D only) takes a preset string, a CameraConfig record, or "" for the display's own camera. Presets compose from the six base directions — front, back, left, right, top, bottom — joined with - for corners. A comma-separated list writes one file per angle, with the angle name inserted before the extension. For an animated display, frame selects which frame to capture: "first", "last" (the default), an integer, or a per-domain record.

capture(display, width?, height?, view?, frame?) renders in-process and returns the pixels as an H×W×3 array with values in [0, 1], so rendered geometry can be fed straight into image/vision.

img = capture(scene, width = 640, height = 480, view = "top")

Graph Terminals

Graph terminals render data plots (lines, scatter, bar charts, surfaces). Import from plot.

graph2d

import graph2d, line, scatter, bar, heatmap, contour, show from plot

plot = graph2d({
    title = "My Plot",
    x_label = "x",
    y_label = "y",
    plots = [
        line({ x = xs, y = ys }),
        scatter({ x = xs, y = ys })
    ]
})
show(plot)

Configuration fields: title, theme ("" / "light" / "dark"), background, x_label, y_label, x_scale ("linear" or "log"), y_scale, x_min, x_max, y_min, y_max, grid, legend, domains.

2D plot constructors (all from plot):

line({ x = xs, y = ys })
line({ points = [[0, 1], [1, 2]] })
scatter({ x = xs, y = ys })
bar({ labels = ["A", "B", "C"], values = [10, 20, 30] })
heatmap({ z = data, x = x_vals, y = y_vals })
contour({ z = data })
area({ x = xs, y = ys })

Field plot grids are axis-first: use z[x][y] for heatmap, contour, and surface. If the source data is a row/column matrix or raster shaped matrix[row][col], import transpose from array, bind the plot grid as plot_z: array<num, 2> = transpose(matrix), and pass plot_z.

Legend entries come from the top-level label field (it defaults to the plot type). Visual options live in a nested style record:

line({ x = xs, y = ys, label = "signal", style = { color = "red", line_width = 3 } })
scatter({ x = xs, y = ys, label = "samples", style = { color = "blue", marker = { shape = "triangle", size = 10 } } })

marker takes a Marker{ shape, size }, where shape is circle, square, triangle, diamond, cross, or x and size is a diameter in pixels. Scatter always draws markers; on a line, marker is also the on/off switch (false, the default, is a bare line).

graph3d

import graph3d, surface, line, scatter, sphere, box, show from plot

scene = graph3d({
    title = "Scene",
    theme = "light",
    plots = [
        surface({ z = z_data, x = x_vals, y = y_vals }),
        line({ x = xs, y = ys, z = zs })
    ]
})
show(scene)

Coordinate system: Z is up. Coordinates are [x, y, z].

Configuration fields: title, theme ("" / "light" / "dark"), background (a color), x_label, y_label, z_label, x_min/x_max (and y, z), axes, grid, camera, domains.

camera is a CameraConfig record — { position, target, up, fov, near, far, auto_fit } — not the separate camera_x/target_y scalars of older versions. axes and grid are Style | bool: true (the default) for theme defaults, false to hide, or a record (AxisStyle is {color, line_width, font_size, tick_labels}, GridStyle is {color, line_width}).

3D plot constructors:

surface({ z = z_data, x = x_vals, y = y_vals })
line({ x = xs, y = ys, z = zs })
scatter({ x = xs, y = ys, z = zs })
sphere({ center = [0, 0, 0], radius = 1.0 })
box({ center = [2, 0, 0], size = [1, 1, 1] })
text({ position = [0, 0, 2], text = "Label" })
axes({})

Canvas Terminals

Canvas terminals render geometric shapes (draw2d/draw3d). Use canvas for scene composition, use graphs for data plotting.

All canvas shapes use array coordinates: [x, y] for 2D, [x, y, z] for 3D.

canvas2d

import canvas2d, show from display
import circle, rect, polygon, text, group from draw2d

view = canvas2d({
    title = "My Canvas",
    origin = "center",
    width = 800,
    height = 600,
    objects = [
        circle({ center = [0, 0], radius = 50 }),
        rect({ center = [100, 100], width = 80, height = 60 })
    ]
})
show(view)

Configuration fields: title, theme ("" / "light" / "dark"), width (800), height (600), background, origin ("top-left", "center", "bottom-left"; default "bottom-left"), axes, grid, pixel_ratio, domains, layers.

canvas3d

import canvas3d, show from display
import box, sphere, cylinder, group from draw3d

scene = canvas3d({
    title = "3D Scene",
    camera = { position = [8, 6, 4], target = [0, 0, 0] },
    grid = { plane = "xy", size = 20 },  // xy is the floor (Z is up); xz/yz are walls
    axes = true,
    objects = [
        box({ center = [0, 0, 0], size = [2, 2, 2] }),
        sphere({ center = [3, 0, 1], radius = 1 })
    ]
})
show(scene)

Coordinate system: Z is up.

Configuration fields: title, theme ("" / "light" / "dark"), background, camera, environment, controls ({orbit, pan, zoom}), grid, axes, antialias, domains, layers.

grid is GridConfig | boolfalse/omitted for none, true for defaults, or a record {plane, size, cell_size, color, center_color}. size is the total extent in world units (or "auto" / "infinite"); cell_size is the world size of one cell, so density is a measurement rather than a division count.

Environment (environment)

The whole atmosphere of a 3D canvas — lighting and backdrop — is one environment bundle. Use a preset (studio(), outdoor(), sunset(), night()), or write the record for fine control. Its fields:

  • lighting{ auto_lights, ambient, hemisphere, lights }. auto_lights (default true) is the built-in ambient+key+fill setup; lights add directional/point/spot lights; hemisphere is a sky/ground ambient gradient. Set auto_lights = false for full manual control.
  • reflections — image-based reflection/light strength (0 off, 1 normal). Metals/glossy surfaces reflect it, and it adds ambient fill; it does not replace background or sky.
  • tone_mapping"none"|"aces"|"neutral". Output color remapping.
  • exposure — non-negative camera exposure multiplier. 1 is normal; typical useful range is 0.25..4.
  • sky{ top, horizon }. A gradient backdrop (overrides background).
  • ground{ plane, color, size, height, receive_shadow, opacity }. A solid floor (auto-sized to the scene), distinct from the wireframe grid; also catches shadows.
  • fog{ color, near, far }. Distance fade; near/far auto-fit the scene when 0, color defaults to the backdrop.
  • shadows{ softness, resolution, bias }. Opt a light in with cast_shadow (the automatic key light casts if none is set). Meshes cast and receive automatically; the ground only receives.

ground, fog and shadows are each a Config | bool union: omitted or false is off, true is on with defaults, and supplying a record is what turns the feature on and configures it. None of them has an enabled field.

import canvas3d, outdoor, options, night, show from display

// A preset:
show(canvas3d({ environment = outdoor(), objects = [ sphere({ radius = 1, style = { metalness = 0.9 } }) ] }))

// Or fine control:
show(canvas3d({
    environment = {
        reflections = 1,
        tone_mapping = "neutral",
        exposure = 1,
        sky     = { top = "#1a3a6a", horizon = "#bcd4ee" },
        ground  = { color = "slate" },
        fog     = { color = "#bcd4ee" },
        shadows = { bias = -0.0005 },
        lighting = { auto_lights = false, lights = [{ type = "directional", direction = [-1, -1, -2], cast_shadow = true }] }
    },
    objects = [ sphere({ radius = 1, style = { metalness = 0.9, roughness = 0.15 } }) ]
}))

Both environment and camera accept options({...}, default) — a named, switchable set the viewer flips between live (environment = options({ day = outdoor(), night = night() }, "day")).

2D Shapes (draw2d)

All constructors take a single record argument. Style is set inline via an optional style field.

import circle, rect, ellipse, line, arc, polygon, polyline, text, bitmap, group from draw2d

circle({ center = [x, y], radius = num })

rect({ center = [x, y], width = num, height = num, rotation = num? })

ellipse({ center = [x, y], rx = num, ry = num, rotation = num? })

line({ start = [x, y], end = [x, y] })

arc({ center = [x, y], radius = num, start_angle = num, end_angle = num })

polygon({ points = [[x, y], ...], closed = bool? })

polyline({ points = [[x, y], ...] })

text({ position = [x, y], text = string, font_size = num? })

2D Style

circle({
    center = [0, 0],
    radius = 50,
    style = { fill = "red", stroke = "slate", stroke_width = 2, fill_opacity = 0.8 }
})

Style properties: fill, fill_opacity, stroke, stroke_width, stroke_opacity, dash, stroke_cap ("butt"/"round"/"square"), stroke_join ("miter"/"round"/"bevel").

dash is the shared dash vocabulary: false (solid), true (a default dash), a named line type ("dash", "dot", "dash_dot"), or a DashStyle { size, gap } in pixels.

Text shapes with no fill/stroke automatically use the theme foreground color.

Transform Functions (draw2d)

translate(shape, [dx, dy]), rotate(shape, angle, pivot?), scale(shape, factor, pivot?), mirror_x(shape, x?), mirror_y(shape, y?).

3D Shapes (draw3d)

All constructors take a single record argument. Appearance is set inline via an optional style field.

import box, sphere, cylinder, cone, torus, mesh, line, polyline, point_cloud, text, group from draw3d

box({ center = [x,y,z], size = [w,h,d], rotation = [rx,ry,rz]? })

sphere({ center = [x,y,z], radius = num })

cylinder({ start = [x,y,z], end = [x,y,z], radius = num }) -- uses start/end, NOT center/height

cone({ start = [x,y,z], end = [x,y,z], radius = num }) -- uses start/end, NOT center/height

torus({ center = [x,y,z], major_radius = num, minor_radius = num, axis = [x,y,z]? })

mesh({ vertices = [[x,y,z], ...], indices = [[a,b,c], ...] }) -- normals auto-computed if omitted, uvs optional. Indices can also be flat: [a, b, c, d, e, f, ...].

line({ start = [x,y,z], end = [x,y,z] })

polyline({ points = [[x,y,z], ...], closed = bool? })

point_cloud({ points = [[x,y,z], ...], point_size = num? })

text({ position = [x,y,z], text = string })

3D Style

sphere({
    center = [0, 0, 0],
    radius = 1,
    style = { color = "red", metalness = 0.3, roughness = 0.7, opacity = 0.9 }
})

Style properties: color, opacity, metalness (0-1), roughness (0-1), wireframe, emissive, side, edges.

edges adds a CAD/FEA-style outline over the shape: true for a plain 1px line, or a LineStyle ({ color, width, opacity, dash }) to style it.

Shapes with no style color get a neutral theme-adaptive default.

Groups and Transforms

A group holds child shapes and applies a shared transform. Available in both draw2d and draw3d.

import group from draw3d

group({
    objects = [shape1, shape2],
    position = [x, y, z],
    rotation = [rx, ry, rz],
    scale = 1,
    anchor = [0, 0, 0]
})

2D groups use position = [x, y], rotation = angle (single number), scale = num, anchor = [x, y].

3D groups use position = [x, y, z], rotation = [rx, ry, rz] (Euler XYZ degrees), scale = num, anchor = [x, y, z].

Groups can nest. Each child group's transform is relative to its parent, creating hierarchies for articulated assemblies (robot arms, solar system orbits). The anchor field sets the pivot point for rotation and scale.

Panels (Synchronized Animation)

Multiple displays can share animation domains for synchronized playback:

import canvas2d, canvas3d, panel, show, vary from display
import circle from draw2d
import sphere from draw3d

t_vals = [0, 1, 2, 3, 4, 5]
radii_2d = [10, 15, 20, 25, 30, 35]
radii_3d = [1, 1.5, 2, 2.5, 3, 3.5]

view_2d = canvas2d({ title = "2D", objects = [circle({ radius = vary(radii_2d, "t") })] })
view_3d = canvas3d({ title = "3D", objects = [sphere({ radius = vary(radii_3d, "t") })] })

synced = panel({
    title = "Synced Views",
    displays = [view_2d, view_3d],
    domains = [
        { name = "t", values = t_vals, playback = { duration = 2, mode = "loop", auto_play = true } }
    ]
})
show(synced)

All displays in a panel share the same domain controls - when you move the slider or play the animation, all displays update together.

Color Palette

Stone provides theme-adaptive named colors that look good in both light and dark mode:

"blue", "red", "green", "orange", "purple", "teal", "pink", "amber", "indigo", "cyan", "rose", "slate", "black", "white", "foreground", "background".

Use palette names instead of hex codes. Explicit hex like "#e74c3c" passes through unchanged. Graph traces auto-cycle through the palette when no color is specified.

Animation (Domains, Vary, Reveal)

Animation uses domains defined in the display config. Every domain is a slider that drives values through your shapes; the playback field decides whether it is a scrubber or a timeline.

  • Scrubber (the default). With no playback — or playback = false — the domain is a plain slider: no play button, nothing moves on its own. Right for sweeping a parameter or picking a scenario.
  • Timeline. Add playback to give the domain a transport. playback = true uses default settings; a record configures them: duration is seconds per full sweep, mode is "once", "loop", or "bounce", and auto_play = true starts it playing when the display appears.

auto_play is opt-in — a transport stays paused until you press play, and a domain with no playback has no transport at all.

scene = canvas3d({
    domains = [
        {
            name = "t",
            values = range(0, 100),
            label = "Time",
            playback = { duration = 2, mode = "bounce", auto_play = true }
        },
        { name = "scenario", values = ["base", "stress"] }   // scrubber: no play button
    ],
    objects = [...]
})
show(scene)

domains is an array of named records — each entry carries its own name, which is what vary(..., "name") references.

vary()

vary(data, "domain_name") selects from an array by the domain's current index. The first dimension of data maps to the domain.

// Swap entire shapes per frame
shapes = [mesh1, mesh2, mesh3, ...]
view = canvas3d({ objects = [vary(shapes, "t")] })
show(view)

// Animate individual properties
animated_circle = circle({
    center = vary([[0, 0], [50, 50], [100, 0]], "t"),
    radius = 20
})
view = canvas2d({ objects = [animated_circle] })
show(view)

// Animate group transforms
arm_group = group({ objects = [arm], rotation = vary(angles, "joint") })
view = canvas3d({ objects = [arm_group] })
show(view)

Multi-domain: vary(data, {t = 0, scenario = 1}) maps each domain to a tensor dimension.

Animatable 2D fields: center, radius, rx, ry, width, height, rotation, position, scale, points.

Animatable 3D fields: center, radius, start, end, size, rotation, position, scale, points.

reveal()

reveal(data, "domain_name") shows elements 0 through the domain's current index, for progressive build-up. One element of data is one item to draw.

// Progressively reveal shapes in a group
revealed = group({ objects = reveal(shape_array, "t") })
show(canvas3d({ objects = [revealed] }))

// Progressively reveal points in a point cloud
cloud = { type = "point_cloud", points = reveal(pts, "draw"), point_size = 3 }
show(canvas3d({ objects = [cloud] }))

Use reveal() on array properties within shapes, not on entire shapes arrays.

accumulate()

accumulate(data, "domain_name") is the third wrapper, for data that arrives in groups. Where reveal treats one element of data as one item, accumulate treats one element as one step's whole batch, and index k yields batches 0..k joined into a single list.

// each scan[k] is a whole frame of returns, not a single point
show(canvas3d({
    domains = [{ name = "scan", values = range(0, n) }],
    objects = [point_cloud({ points = accumulate(scans, "scan") })]
}))

Each group holds only what its step contributed, so every item is stored once and the union is assembled at draw time — storing the running total per step instead costs the square of the step count.

The three wrappers are picked by the shape of your data: one element is the whole value (vary), one item (reveal), or one step's batch (accumulate). accumulate comes from display; plot re-exports only vary and reveal.

Layers

A layer is a named visibility category — the 3D/CAD sense (a cross-cutting show/hide group, not z-order). Tag any drawable with layer = "name" and the viewer gives that group a checkbox; everything sharing the name toggles together, wherever it sits in the object tree.

show(canvas3d({
    layers = [{ name = "guides", label = "Guides", visible = false }],  // optional: starts hidden
    objects = [
        box({ size = [1, 1, 1] }),                                      // untagged → always shown
        sphere({ center = [2, 0, 0], radius = 0.5, layer = "markers" }),
        group({ layer = "guides", objects = [line({ start = [-3, 0, 0], end = [3, 0, 0] })] })
    ]
}))

Groups pass layer to their children, and a child's own layer overrides its group. A bare tag with no layers entry auto-registers, shown by default; declare a LayerConfig ({name, label, color, visible}) only to rename, recolor, reorder, or start a layer hidden. Toggling is a pure view-side filter — it never recomputes anything.

Animation Example

import canvas3d, show from display
import sin, cos, PI from math

n = 300
indices = range(0, n)
t[k] = indices[k] * 10 * PI / n
pts[k] = [cos(t[k]) * 2, sin(t[k]) * 2, t[k] / PI - 5]

cloud = point_cloud({
    points = reveal(pts, "draw"),
    point_size = 3,
    style = { color = "red" }
})

scene = canvas3d({
    camera = { position = [6, 4, 3], target = [0, 0, 0] },
    domains = [
        {
            name = "draw",
            values = range(1, n + 1),
            label = "Build",
            playback = { duration = 3, mode = "loop", auto_play = true }
        }
    ],
    objects = [cloud]
})
show(scene)

Library Functions Returning Displays

Since displays are first-class values, library functions can create and return them:

// helper.stn
import canvas2d from display
import circle from draw2d

export fn make_scatter_view(points, title) {
    dots[i] = circle({ center = points[i], radius = 3 })
    canvas2d({ title = title, objects = dots })
}

// main.stn
import make_scatter_view from helper
import show from display

show(make_scatter_view(my_points, "Data"))

This enables reusable visualization components and clean separation of concerns.