home back

Basic Objects

This chapter covers the fundamental building blocks of JMathAnim animations. All drawable objects inherit from the MathObject class (or implement the Drawable interface), which provides common methods for transformations like scale, rotate, and shift.

How to read this chapter: it's a catalog, not a novel. Read the Vec, Point, Shape and LatexMathObject sections first (you will use them in every single animation), skim the rest, and come back when you need an arrow or a grid. And remember: almost every object here has a ready-made snippet in the editor (press Alt+S, type the first letters of the object name, and the code writes itself). If a snippet inserts a DSL block, Ctrl+Space inside it shows all the available parameters.

Table of Contents

The Vec Class

The Vec class represents a 2D vector (with an additional z-coordinate for future 3D support). It's not at object can be added to scene or drawn, but it's the fundamental class for representing coordinates in the math view and defining parameters in most objects.

Creating Vectors

def v = Vec.to(4, 5)        // Creates vector (4, 5)
def w = Vec.to(2, -1)       // Creates vector (2, -1)
def origin = Vec.origin()   // Creates vector (0, 0)
def random = Vec.random()   // Creates a vector in a random screen location

Accessing Components

def x = v.x                 // Get x-coordinate
def y = v.y                 // Get y-coordinate
def norm = v.norm()         // Get Euclidean norm (length)

Vector Operations

JMathAnim provides two styles of vector operations:

  1. Immutable operations - Return a new vector, leaving the original unchanged:

    def sum = v.add(w)             // Returns v + w (new vector)
    def scaled = v.copy().scale(3) // Returns 3v (new vector, v unchanged)
    def diff = v.to(w)             // Returns w - v (new vector)
    

  2. In-place operations - Modify the original vector:

    v.scale(3)                  // Multiplies v by 3 (modifies v)
    v.shift(w)                  // Adds w to v (modifies v)
    v.rotate(45 * DEGREES)      // Rotates v by 45° (modifies v)
    

Additional Methods

def dot = v.dot(w)          // Dot product of v and w
def angle = v.getAngle()    // Angle in radians (0 to 2π)

Note: The DEGREES constant converts degrees to radians. Use it for all angle operations: angle * DEGREES.


The Point Class

The Point class is the most basic MathObject, representing a single point in space. The main difference between a Point and a Vec class is that the Vec is a lightweight object to hold coordinates, while Point is a MathObject that can be drawn on screen.

Creating Points

def p = Point.at(1, 1)      // Point at coordinates (1, 1)
def q = Point.at(v)         //Point at coordinates given by vector v
def r = Point.origin()      // Point at (0, 0)
def s = Point.random()      // Point at random screen location

Accessing Coordinates

Points contain a Vec object storing their position:

def v = p.getVec()          // Get position vector
def x = p.getVec().x        // Get x-coordinate
def d = p.norm()            // Distance from origin
def AB = A.to(B)            // Vector from point A to point B

Point Styles

Points can be displayed in various styles using the DotStyle enum:

def A = Point.at(-1.75, 0).dotStyle(DotStyle.CIRCLE)
def B = Point.at(-1.25, 0).dotStyle(DotStyle.CROSS)
def C = Point.at(-0.75, 0).dotStyle(DotStyle.PLUS)
def D = Point.at(-0.25, 0).dotStyle(DotStyle.RING)
def E = Point.at(0.25, 0).dotStyle(DotStyle.TRIANGLE_UP_HOLLOW)
def F = Point.at(0.75, 0).dotStyle(DotStyle.TRIANGLE_DOWN_HOLLOW)
def G = Point.at(1.25, 0).dotStyle(DotStyle.TRIANGLE_UP_FILLED)
def H = Point.at(1.75, 0).dotStyle(DotStyle.TRIANGLE_DOWN_FILLED)

scene.add(A, B, C, D, E, F, G, H)

// Add reference axis
scene.add(
    Line.XAxis()
        .thickness(2)
        .drawColor("gray")
        .layer(-1)  // Draw behind points
)

dots

I showed you this 'manual code', which describes each point individually, to demonstrate the various style names. Of course, a similar image could also be created using loops:

def x0=-1.75 //Initial value 0f x0
for (style in DotStyle) { //Iterate over DotStyle values
    def P=Point.at(x0,0).dotStyle(style)
    scene.add(P)
    x0+=0.5 //Add 0.5 to x0
}

//The axis code is the same...

Note: Points require higher thickness values (typically 20-40) to be clearly visible, as the thickness represents the point's diameter.

You can also create it using the DSL:

def A = point(at: [-1.75, 0], style: [dotStyle: "cross", color: "blue", thickness: 30])

The Shape Class

The Shape class is one of the most important classes in JMathAnim. It represents curves (closed or open) and provides extensive functionality for creating and manipulating 2D shapes.

Basic Shape Constructors

JMathAnim provides convenient static methods for common shapes:

// Circle with radius 1, centered at origin
def circ = Shape.circle()

// Unit square with lower-left corner at origin
def sq = Shape.square()

// Regular pentagon with first two vertices at (0,0) and (1,0)
def reg = Shape.regularPolygon(5)

// Triangle from specified vertices
// Accepts Vec, Point, or any Coordinates object
def poly = Shape.polygon(
    Vec.to(0.25, -0.5),
    Vec.to(1.25, -0.5),
    Vec.to(0.25, 0.5)
)

// Axis-aligned rectangle
// Parameters: lower-left corner, upper-right corner
def rect = Shape.rectangle(Vec.to(1, 2), Vec.to(3, 5))

// Line segment between two points
def seg = Shape.segment(Vec.to(-1, -1), Vec.to(-0.5, 1.5))

// Circular arc
// Parameters: arc length in radians
def arc = Shape.arc(PI / 4)

scene.add(circ, sq, reg, poly, rect, seg, arc)

basicShapes

Creating Shapes with LOGO Commands

Shapes can also be created using LOGO turtle graphics commands, which is useful for complex or iterative patterns:

// CLO (or CLOSE) is a non-standard command to close the path
def logoCmd = "REPEAT 12 [FD .5 RT 150 FD .5 LT 120] CLO"
def logoShape = Shape.logo(logoCmd)

scene.add(
    logoShape
        .center()
        .style("solidblue")
)

Example of Shape created with LOGO commands

Supported LOGO Commands

Command Aliases Description
FORWARD n FD n Move forward n units
BACKWARD n BK n Move backward n units
RIGHT angle RT angle Turn right by angle degrees
LEFT angle LT angle Turn left by angle degrees
REPEAT n [commands] Execute commands n times
PENUP PU Lift pen (move without drawing)
PENDOWN PD Lower pen (resume drawing)
LARC / RARC angle radius Left/Right arc (JMathAnim extension)
CLOSE CLO Close the path (JMathAnim extension)

Commands are case-insensitive. Full reference available at Logo Commands Reference.

Storing and Restoring Shapes with SVG Path Commands

Any Shape can be encoded as a string of SVG path commands, the same syntax used by the d attribute of a path element in a SVG file. This is the most compact way to keep a Shape in a text file, or even in a plain String inside the code, and rebuild it later:

def heart = Shape.logo("REPEAT 12 [FD .5 RT 150 FD .5 LT 120] CLO")

// Geometry as a String, with 6 decimals by default
def code = heart.toSVGPath()      // "M0 0L.5 0C..."

// And back to a Shape
def restored = Shape.fromSVGPath(code)

Only the geometry travels in the string, no style at all. toSVGPath(decimals) sets the precision of the coordinates, which is where most of the size comes from: toSVGPath(3) is usually enough for a drawing and gives a much shorter string. Straight segments are stored as L commands, so they carry no control points.

Since the syntax is standard, the string can be pasted into a SVG file, and a path copied from a vector editor such as Inkscape can be read directly:

def star = Shape.fromSVGPath("M50 5L61 40H98L68 62L79 97L50 75L21 97L32 62L2 40H39Z")
scene.add(star.center().height(2))

The DSL form is shape(type: "svgpath", path: "...").

Compressed Paths

For a detailed shape, with hundreds of points, the commands string gets long. toCompressedPath() deflates it and encodes the result in Base64, which typically divides the size by 3 to 5:

def code = heart.toCompressedPath()   // "JMA1:eJx1kMFqwzAQ..."
def restored = Shape.fromSVGPath(code)

The compressed string is tagged with a JMA1: prefix, so Shape.fromSVGPath (and the svgpath DSL type) tells the two forms apart and reads either one. It only holds letters, digits and the characters -, _ and :, so it needs no escaping and can be stored in a String, a properties file, a YAML file or a URL. Whitespace inside it is ignored, and it can be split over several lines.

toCompressedPath(decimals) sets the precision, exactly as toSVGPath(decimals) does.

Storing the Style Too

The methods above keep only the geometry. toStyledPath() stores the style as well, as a list of properties in front of the path commands:

def s = Shape.circle().thickness(4).drawColor("maroon").fillColor("deeppink1")

def code = s.toStyledPath()
// "drawColor=#800000FF;fillColor=#FF1493FF;thickness=4;dashStyle=SOLID;...|M1 0C1 .5523..."

def restored = Shape.fromSVGPath(code)   // same drawing, same style

The property names are the ones used in the style maps of the DSL and in the styles of the config files: drawColor, fillColor, thickness, dashStyle, lineCap, lineJoin, strokeType, layer, thicknessTracksZoom and thicknessTracksScale. Only the properties actually set are written, and colors are stored as #RRGGBBAA. A gradient or an image pattern does not fit in a single value, so it is skipped with a warning and only the rest of the style travels.

toCompressedStyledPath() is the compressed version, and Shape.fromSVGPath reads any of the four codes, telling them apart by the JMA1: prefix and by the | separator:

Method Style Compressed
toSVGPath() no no
toCompressedPath() no yes
toStyledPath() yes no
toCompressedStyledPath() yes yes

When the code has no style, the default one is applied. In the DSL, shape(type: "svgpath", path: code, style: [...]) applies the stored style first, so the style parameter still has the last word.

DSL Syntax

Since core version 1.3.5, the script interpreter admits several commands defined in DSL syntax to build objects in a more accessible way:

def s = shape(
    type: "circle",
    segments: 30,
    style:[thickness: 30, drawcolor: 'maroon',fillcolor: 'deeppink1'],
    addToScene: true
)

Is equivalent to

def s=Shape.circle(30).thickness(30).drawColor('maroon').fillColor('deeppink1')
scene.add(s)

The gui editor has built autocomplete for most DSL commands as well as a snippet sidebar to speed code generation. You can see a cheatsheet of DSL commands here

Working with Shape Paths

Each Shape contains a JMPath object that manages its path. Points can be accessed using zero-based circular indexing:

def pentagon = Shape.regularPolygon(5) //Has points with indices 0, 1, 2, 3, 4

def p0 = pentagon.getPoint(0)    // First vertex
def p1 = pentagon.getPoint(1)    // Second vertex
def p5 = pentagon.getPoint(5)    // Wraps around to first vertex (same as p0)

Important: Shape points use circular array indexing, so getPoint(n) and getPoint(n + vertexCount) return the same point.

Shape Centers

Shapes provide two methods for finding their center:

def bbox_center = shape.getCenter()     // Center of bounding box (returns a Vec object)
def geometric_center = shape.getCentroid()  // Average of all vertices

Note: For regular polygons, .getCentroid() returns the true geometric center, while .getCenter() returns the center of the bounding box, which may differ for rotated shapes.

Measuring a shape

Every shape answers for the area it encloses and the length of its path, which is the perimeter when the path is closed:

def a = pentagon.area       // area enclosed, signed by the orientation of the path
def per = pentagon.length   // length of the path: the perimeter, as it is closed
def arcLength = arc.length  // an open path simply gives its length

Both are properties, so they read as shown, without get and parentheses.


The LatexMathObject Class

The LatexMathObject class renders mathematical expressions using LaTeX, allowing you to display formulas and text with professional typesetting.

Creating LaTeX Objects

def text = LatexMathObject.make("This is a \\LaTeX equation \$e^{i\\pi}+1=0\$")
scene.add(text)

LaTeX 1

You can also create it using the DSL, with the latex(...) command (a plain, non-LaTeX text object is available through text(...)):

def text = latex(text: r"This is a \LaTeX equation $e^{i\pi}+1=0$")

Handling Special Characters

LaTeX uses $ and \ as special characters. In Groovy, these must be escaped:

// INCORRECT - Groovy will interpret $ and \ as escape sequences
LatexMathObject.make("$e^{i\pi}+1=0$")  // ERROR!

// CORRECT - Escape the special characters
LatexMathObject.make("\$e^{i\\pi}+1=0\$")  // Works!

// BETTER - Use raw strings (prefix with 'r')
LatexMathObject.make(r"$e^{i\pi}+1=0$")  // Easiest!

Tip: Raw strings (prefixed with r) ignore escape sequences. Use either single or double quotes:

LatexMathObject.make(r"$e^{i\pi}+1=0$")  // Both work
LatexMathObject.make(r'$e^{i\pi}+1=0$')  // identically

Let the editor do it: if writing LaTeX by hand is not your idea of fun, place the cursor inside the string and open the LaTeX Editor (Tools -> LaTeX Editor..., or Ctrl+Shift+L). You get a live preview while you type, and the Insert Code button writes the (correctly escaped) string back into your script for you.

Also, you can use triple quotes to enable multiline strings:

def text = LatexMathObject.make(r'''
$$
ax^2+bx+c=0
$$
''')
scene.add(text)

Compilation Modes

JMathAnim offers two LaTeX compilation methods:

  1. Default (JLaTeXMath) - Fast, built-in renderer for most formulas:

    def formula = LatexMathObject.make(r'$e^{i\pi}+1=0$')
    

  2. External LaTeX - Uses system LaTeX for advanced features:

    def formula = LatexMathObject.make(
        r'$e^{i\pi}+1=0$',
        CompileMode.CompileFile
    )
    

The external compiler:

  • Requires a LaTeX distribution installed on your system
  • Compiles to .dvi, converts to .svg, imports as MultiShapeObject
  • Caches .svg files for reuse in subsequent runs
  • Supports commands not available in JLaTeXMath (e.g., \begin{verbatim}), but in most cases JLaTexMath will be enough.

Recommendation: Use the default mode (JLaTeXMath) unless you need advanced LaTeX features.


The Line Class

The Line class represents infinite mathematical lines. In practice, it draws the visible portion within the current view.

Creating Lines

def A = Point.at(1, 1)
def B = Point.at(0, 1)
def v = Vec.to(1, 0.2)

// Line through points A and B
def line1 = Line.make(A, B)
    .drawColor("red")
    .thickness(20)

// Line through A in direction v
def line2 = Line.make(A, A.add(v))
    .drawColor("blue")
    .thickness(10)

// Predefined axes
def line3 = Line.XAxis().drawColor("darkorange")        // y = 0
def line4 = Line.YAxis().drawColor("darkmagenta")       // x = 0
def line5 = Line.XYBisector().drawColor("darkgreen")    // y = x

scene.add(line1, line2, line3, line4, line5)
play.shift(5, -1, -1.5, A)  // Animate point A

Line01

Note: Lines use direction vectors only at construction time. Moving a point after creating the line won't update the line's direction (unlike constraints, which are covered in advanced topics).

The Ray Class

Similar to Line, the Ray class represents half-infinite rays with syntax identical to Line constructors.


The Axes Class

The Axes class creates Cartesian coordinate axes with customizable ticks and labels. It's essentially a container managing multiple Line, Shape, and LatexMathObject instances.

Creating Axes

def axes = Axes.make()

// Add primary ticks
axes.generatePrimaryXTicks(-2, 2, 0.5)  // x: -2, -1.5, -1, ..., 2
axes.generatePrimaryYTicks(-2, 2, 0.5)  // y: -2, -1.5, -1, ..., 2

// Add custom ticks
axes.addXTicksLegend(0.75, TickType.PRIMARY)  // Tick at x = 0.75
axes.addYTicksLegend(r"$\pi/4$", PI / 4, TickType.PRIMARY)  // Custom label

scene.add(
    axes,
    Shape.circle()
        .scale(0.5)
        .drawColor("darkblue")
)

axes01

Note: By default, axes are created without ticks. Use generatePrimaryXTicks and generatePrimaryYTicks to add them.

Using the DSL format is even easier:

def axes = axes(
    range:[-2, 2],
    secondary: [maxwidth:0],
    ticksx: [0.75],
    ticksy: [[at: PI/4, label: '$\\pi/4$', type:"sec"]]
)

The CartesianGrid Class

Creates a grid with primary and secondary divisions, useful for reference and measurements.

// Grid centered at origin with subdivisions
def grid = CartesianGrid.make(
    0, 0,       // Center point (usually origin)
    2,          // Horizontal subdivisions for secondary grid
    2           // Vertical subdivisions for secondary grid
)

scene.add(
    grid,
    Shape.circle()
        .drawColor("blue")
        .fillColor("blue")
        .fillAlpha(0.5)
)

Cartesian grid example

Styling: Grids use the gridPrimaryDefault and gridSecondaryDefault styles defined in your config files. See the Styling chapter for customization details.

You can also use the DSL version

def grid = grid(
    center:[0, 0], //Optional
    divisions: 2 //equivalent to divisions:[2,2]
)

Looking for arrows, connectors or delimiters? Those objects (which point at, join or bracket other objects) now live in their own chapter: Arrows, connectors and delimiters. Function graphs and other calculus-flavored objects are in Calculus objects.


Boards

While a grid extends over the whole view, a board is a bounded rectangle divided into cells: the chessboard, a table, the cells of a game. The board(...) DSL builds it as a compound of Shapes, so it enters the scene, moves and is styled as a single object.

def b = board(width: 4, columns: 8)   //4x4 chessboard, 8x8 cells, centered at the origin

The size is given with width and height (which defaults to width, so a single number gives a square board) around a center, or with rect for the whole region at once. The number of cells is columns and rows (which defaults to columns), or divisions as a shorthand for both.

def b1 = board(width: 6, height: 3, columns: 4, rows: 2)
def b2 = board(rect: [-2, -1, 2, 1], divisions: [8, 4])
def b3 = board(divisions: 3)          //unit 3x3 board: the tic-tac-toe grid

Every piece of the compound has a name of its own. The outer border is a single closed rectangle named border, and the inner lines are segments: v1 to v<columns-1> for the vertical ones, left to right, and h1 to h<rows-1> for the horizontal ones, bottom to top. So an 8-column board has the border plus 7 inner vertical lines. That gives each piece to parts: and to the subscript operator:

def b = board(width: 4, columns: 8,
              style: [thickness: 2],
              parts: ["border": [color: "red", thickness: 5], "h*": [color: "gray"]])
b["v4"].thickness(6)                  //the middle line, thicker

The glob selectors make it easy to give a different color to each family of pieces:

def b = board(width: 2, columns: 8, rows: 8,
      style: [thickness: 10],
      parts: [
          "border": [drawcolor: "orangered4",
              fillColor: 'antiquewhite'],
          "v*": [color: "gray",//style for all vertical lines
              layer: 1,
              thickness: 10],
          "h*": [color: "blue"],//style for all horizontal lines
          "h3": [dashstyle: "dashed",//syle for third only
              drawcolor: 'forestgreen']
      ]
)
scene.add(b)

board1


Importing Images

JMathAnim supports both bitmap and vector images. Images are loaded from the resources/images/ folder by default (configurable in styling settings).

The image DSL

One call covers both kinds: the importer is chosen from the file extension (.svg means vector, anything else bitmap) and can be forced with type:.

def img = image(file: "euler.jpg", transform: [height: 2, center: true])
def svg = image(file: "knuth.svg", transform: [height: 2], addToScene: true)

appear(obj: img, type: "movein", from: "left", runtime: 2)

Sizing goes through the usual transform: map (height, width, scale), and stack: places the image like any other object. Two parameters are specific to images:

  • preserveRatio: true keeps the aspect ratio of a bitmap when it is resized.
  • adjustTo: [A, B] puts the lower left and lower right corners of the image on those two points, shifting, rotating and scaling it in one go. It replaces any previous placement, so it is applied before transform and stack.
image(file: "photo.jpg", adjustTo: [[-1, -1], [1, -1]])
image(file: "!C:/tmp/absolute.png")     // the "!" prefix takes an absolute path

Images take no label: (they are not label-locatable): build a separate latex(...) or text(...) and stack it to the image instead.

Bitmap Images

Import any bitmap format supported by JavaFX (PNG, JPG, etc.):

def img = image(
      file: "euler.jpg",
      transform: [height: 1.5, center: true, rotate: -5 * DEGREES],
)

def text = latex(
      text: "All hail the great Euler!",
      stack: [to: img, gaps: .1, destinyanchor: "lower"]
)
play {
    appear(
          runtime: 2,
          type: "movein", from: "left",
          obj: img
    )
    appear(
          runtime: 2,
          type: "showcreation",
          obj: text
    )
}
animWait(3)

image01

SVG Images

Import and animate SVG vector graphics:

def svg=image(
    file: "donaldKnuth.svg",
    transform: [height: 2, center: true]
)
appear(
    runtime: 1,
    type: "showcreation",
    obj: svg
)
animWait(2)

svgCreation

File Location: Place SVG files in <project_root>/resources/images/

SVG Import Details: - Creates a MultiShape object containing multiple Shape instances - Each SVG element becomes a separate Shape with a JMPath - Supports standard transformations and animations - Limitation: Some elements (e.g., gradients) may not import with 100% accuracy


The MathObjectGroup Class

MathObjectGroup manages collections of MathObject instances as a single entity, enabling batch operations and layouts.

Creating Groups

def square = Shape.square()
def triangle = Shape.regularPolygon(3)
def circle = Shape.circle()

def group = MathObjectGroup.make(square, triangle, circle)

Group Operations

Groups support all standard MathObject operations:

group.shift(1, 1)              // Move entire group
group.rotate(45 * DEGREES)      // Rotate all members
group.scale(2)                  // Scale all members
group.setFillColor("cadetblue") // Apply to all members

Aligning the elements

align(...) moves every element of the group against the bounding box of the group itself, so all of them end up sharing the same side or centerline. It takes an AlignType, or a String naming one: upper, lower, left, right, hcenter, vcenter or baseline.

group.align("left")                     // every element to the left side of the group
group.align(AlignType.VCENTER)          // all of them centered vertically

The same thing is a key of the group(...) block, applied after the layout, and of apply(...), where several objects are aligned among themselves and a single group has its elements aligned:

def g = group(obj: [a, b, c], layout: "lower", align: "left")
apply(obj: g, align: "hcenter")
apply(obj: [a, b, c], align: "upper")

baseline aligns the baselines of the texts with each other, the same thing alignBaselines() does. Elements that are empty are left where they are.

Adding Groups to Scene

Adding a group is equivalent to adding all its members:

scene.add(group)  // Same as: scene.add(square, triangle, circle)

Note: The MathObjectGroup is a logical group. It does not represent an object by itself and cannot be added to the scene. It is just a way to tell the interpreter "work with this group of objects".

Why Use Groups?

Groups excel at: - Batch transformations: Transform multiple objects together - Layout management: Arrange objects automatically (see TransformingObjects chapter) - Animation coordination: Animate multiple objects as one unit

Example using layouts:

def group = MathObjectGroup.make(square, triangle, circle)
group.setLayout(LayoutType.LOWER, 0.1, 0.1)  // Arrange vertically
scene.add(group) //The same as scene.add(square, triangle, circle)

Naming the elements

The group(...) DSL block takes a Map in obj: instead of a list, and stores each key in the dictionary of the group. The elements keep their order, and each one can be reached, styled or animated by its name instead of by its index:

def g = group(obj: [body: shape(type: "circle"),
                    head: shape(type: "square")],
              layout: "upper", gap: 0.1)

g["head"].color("red")                       // the element, by name
apply(obj: g, parts: [head: [fillColor: "gold"]])
g.parts()                                    // name -> element

The same names work in every parts: map (see the Styling chapter), so a group built this way behaves like the built-in composite objects.


Compound Objects

A group is a logical container. When you need the opposite, an object of your own made of several pieces that behaves as one thing, the compound(...) block builds a CompoundMathObject:

def balance = compound(
    obj: [base: shape(type: "square"),
          arm : shape(type: "segment", from: [-2, 1], to: [2, 1]),
          panL: shape(type: "polygon", points: [[-2,1],[-1.2,1],[-1.35,.65],[-1.85,.65]]),
          panR: shape(type: "polygon", points: [[1.2,1],[2,1],[1.85,.65],[1.35,.65]])],
    style: [color: "gray"],
    parts: [panL: [fillColor: "red"], "pan*": [thickness: 3]],
    addToScene: true)

The parameters are the same ones group(...) takes (obj, copies and number, layout, gap, homogeneize, align, plus the common style, parts, transform, stack...). What changes is where the pieces live:

group(...) compound(...)
scene.add(...) adds the members, one by one adds the compound itself
Drawing each member draws itself the compound draws its pieces
Removing it removes the members removes the whole object
Its pieces scene objects of their own internal to the compound

So group is the tool to operate in bulk on objects that are their own citizens of the scene, and compound is the tool to build a new object out of parts. Reaching the pieces works the same in both:

balance["arm"].color("black")                     // a piece, by its name
balance["panL", "panR"].color("red")              // several at once: a MathObjectGroup
apply(obj: balance, parts: [base: [color: "brown"]])
animRotate(obj: balance, angle: 7 * DEGREES, center: [0, 1])   // the whole object moves as one

A subscript returns the piece when the selector matches one and a group with all of them when it matches several (balance["pan*"], balance["panL,panR"]), so a selector never silently drops matches. That group is logical: the pieces stay inside the compound, which is still the one drawing them.

The pieces are written in drawing order, so panL above is drawn over base. Their layer orders them among themselves only; the layer of the compound is what places it among the objects of the scene. Note that compound(layer: n) also flattens the layers of the pieces, so per-piece layers are set afterwards:

balance.layer(2)                 // the whole object, over the layer-1 ones
balance["arm"].layer(1)          // and inside it, the arm over the rest

Compounds that do something

A compound can derive the position of its pieces from a few named values instead of being placed once and for all. state: declares them and rebuild: says where the pieces go:

def box = compound(
    obj:   [bottom: shape(type: "rectangle", rect: [-1, -.5, 1, -.4]),
            left:   shape(type: "rectangle", rect: [-1, -.5, -.9, .5]),
            right:  shape(type: "rectangle", rect: [.9, -.5, 1, .5]),
            lid:    shape(type: "rectangle", rect: [-1, .4, 1, .5])],
    state: [aperture: 0],                     // 0 closed, 1 open
    rebuild: { b ->
        b["lid"].rotate(b["left"].boundingBox.upperLeft,
                        125*DEGREES * b.delta("aperture"))
    })

animState(obj: box, to: [aperture: 1], runtime: 1.5)   // and the box opens

The value is what gets animated; the geometry follows. That is what makes the opening interruptible (closing halfway continues from where it is instead of jumping) and consistent with animations that save and restore states.

Inside rebuild: there are three readings of a value: b.state("n") the current one, b.previousState("n") the one the geometry on screen was built from, and b.delta("n") the difference. The difference is what an incremental rebuild uses: turning a piece by it lands the piece exactly where the value asks for, however many frames it took to get there. rebuild: runs once at the start and then only when a value has really changed, so a compound nobody touched costs nothing per frame.

Two more ways in:

apply(obj: box, state: [aperture: 1])     // set it without animating
box.state("aperture")                     // read it

Note that the pieces and the values live in different namespaces: box["lid"] is a piece, box.state("aperture") is a value.

For an object with methods of its own (box.openBox(1.5), box.isOpen()) the closure is not enough and you need a class: subclass StatefulCompound, declare the values in the constructor and implement rebuild(). The same apply(state:) and animState(...) work on it, so the two ways are the same thing with and without methods.

The chapter on compound objects builds one of these step by step.


The Rect Class

The Rect class represents axis-aligned bounding boxes. While not directly drawable, it's essential for positioning and collision detection or understanding how stacking and positioning works.

Getting Bounding Boxes

Every MathObject (and anything implementing Boxable) has a bounding box:

def ellipse = Shape.circle()
    .scale(1, 0.5)
    .rotate(45 * DEGREES)
    .style("solidorange")

def bbox = ellipse.getBoundingBox()  // Returns Rect
def rect = Shape.rectangle(bbox).drawColor("blue")

scene.add(ellipse, rect)

Bounding box example

Rect Properties and Methods

// Corner coordinates
def xMin = bbox.xmin     // Left edge
def xMax = bbox.xmax     // Right edge  
def yMin = bbox.ymin     // Bottom edge
def yMax = bbox.ymax     // Top edge

// Dimensions
def width = bbox.getWidth()  //...or bbox.width
def height = bbox.getHeight() //...or bbox.height

// Corner points
def center = bbox.getCenter() //or bbox.center
def upperLeft = bbox.getUpperLeft() // ... or bbox.upperLeft
def upperRight = bbox.getUpperRight() // ... bbox.upperRight
def lowerLeft = bbox.getLowerLeft()  // ... or bbox.lowerLeft
def lowerRight = bbox.getLowerRight() //... or bbox.lowerRight

// Relative positioning
def point = bbox.getReltoAbsCoordinates(0.25, 0.75)  // 25% across, 75% up
def centerAlt = bbox.getReltoAbsCoordinates(0.5, 0.5)  // Same as getCenter()

// Transformations
def expanded = bbox.addGap(0.1, 0.2)  // Add 0.1 horizontal, 0.2 vertical gap
bbox.centerAt(destination)  // Move center to destination
def rotated = bbox.getRotatedRect(45 * DEGREES)  // Smallest rect containing rotated bbox

Working with the Math View

The math view (visible screen area) is represented as a Rect:

def mathView = scene.getMathView()

// Create points at key screen positions
def center = Point.at(mathView.getCenter())
    .dotStyle(DotStyle.PLUS)

def corners = [
    Point.at(mathView.getUpperLeft()),
    Point.at(mathView.getUpperRight()),
    Point.at(mathView.getLowerLeft()),
    Point.at(mathView.getLowerRight())
]

// Points at 25% and 75% positions
def Q1 = Point.at(mathView.getReltoAbsCoordinates(0.25, 0.25))
    .drawColor("red")
def Q2 = Point.at(mathView.getReltoAbsCoordinates(0.75, 0.25))
    .drawColor("green")
def Q3 = Point.at(mathView.getReltoAbsCoordinates(0.75, 0.75))
    .drawColor("yellow")
def Q4 = Point.at(mathView.getReltoAbsCoordinates(0.25, 0.75))
    .drawColor("blue")

scene.add(center, *corners, Q1, Q2, Q3, Q4)
scene.add(
    Shape.rectangle(mathView)
        .scale(0.9)  // 90% of screen size
)

Math view example

Default Math View Coordinates

At initialization (16:9 aspect ratio): - Center: (0, 0) - X range: -2 to 2 - Y range: -1.125 to 1.125

Note: Y boundaries adjust automatically based on aspect ratio while maintaining the center and X boundaries.


Next Steps

Now that you understand the basic objects, explore: - Styling: Customize colors, thickness, and visual properties - Transforming Objects: Advanced positioning, scaling, and animation - Adding labels: Put names on vertices, sides and braces

home back