Browse the docs
Scripting
Repair, remesh, mesh and check parts from a program: loop over files, try sizes until a result is close enough, and decide on what the commands report.
A script is a text file of Starlark, a small language that reads like Python. Its functions are Betti's commands. betti script runs it:
betti script refine.star part.stlThis page has two halves: how a script uses Betti, and the language itself, explained from the start. Worked scripts close it.
A first script
# Remesh finer until the result is within 0.2% of the part's size,
# then fill it with tetrahedra.
# betti script refine.star part.stl
m = fix.repair(read(args[0]))
for size in ["4%", "2%", "1%"]:
e = mesh.remesh(m, size = size)
d = verify(m, e)
print(size, e.faces, "faces, off by", d.hausdorff)
if d.hausdorff < 0.002:
break
v = volume.tet(e, size = size)
print("cells:", volume.info(v).cells)
save(e, "surface.obj")
save(v, "part.msh")args holds the words after the script's name, so args[0] is part.stl. The meshes stay in memory between the operations; only save writes files. The steps of each operation are shown as the command shows them, on standard error, while print writes to standard output, so betti script -q report.star parts/*.stl > report.csv keeps a clean table. -v shows the stages of each step. Ctrl-C stops a script within moments.
Operations
Every command is a function. The core commands are called by name: info, box, verify, convert. The others are in their module, named as on the command line: fix.repair, mesh.remesh, shape.cut, cad.heal, additive.slice, particles.fill, volume.tet, dfm.check. Each command's page in these docs lists its flags, and the flags are the function's options.
- Inputs come first, without names: meshes and models the script holds, or file names, read as the command reads them.
shape.boolean("union", a, b)takes its operation as a word, andmesh.assemble([a, b, c], out = True)takes a list. - Options are named as the command's flags, with
_for-:mesh.remesh(m, size = "1%", feature = 40, quads = True). Lengths are numbers in the model's units or percentages of its size ("1%"); points and directions are lists (up = [0, 0, 1]); a flag that can be given more than once takes a list of them.skipswitches steps off for that one call. - What a command makes comes back as its value:
e = mesh.remesh(m). Two results come back as a pair,below, above = shape.cut(m, plane = "z=10"), and a part the command would not have written, such as nothing above the plane, isNone. Withreport = Truethe call returns its report instead, with what it made under the names of its files:mesh.remesh(m, size = 2, report = True).out. - What a command reports comes back when it only reports:
info(m).holes,verify(a, b).hausdorff,cad.check(model). Its fields are the keys the command prints with-json, and what is nested under them is a dict:info(m).aspect_ratio["max"]. Output flags that name files, such as-mapor-thin, return values when givenTrue:additive.thickness(m, min = 0.8, thin = True).thin.
An operation never changes what it is given. It works on a copy, so a mesh is the same after every call it is passed to, and can be tried several ways.
A failing operation stops the script with the command's own message and the line it was called from. Each operation checks its module's licence when it is called, as the command does; betti script itself needs none.
Values
| Value | Made by | Fields |
|---|---|---|
| surface mesh | read(path), and every operation that makes a surface | faces, vertices, area, volume, holes, bounds (two corners), name |
| CAD model | read_cad(path) (STEP, IGES, JT, Parasolid), cad.heal | kind, name |
| volume mesh | read_volume(path), volume.tet, volume.poly, volume.hex | kind, name; volume.info(v) reports the rest |
| particles, packings, layers, centrelines and others | their operations | kind, name |
save(value, path) writes any of them in the format the extension of path names, as the command that made it would have written it: meshes in every format but STL, CAD models as STEP, volume meshes as Gmsh, VTK, Abaqus, Nastran or OpenFOAM, slice layers as SVG, PNG or 3MF.
The language
Starlark is a small, regular language with Python's syntax. A script is run from top to bottom; there is nothing to compile or install. Someone who knows Python can skip to what differs from Python.
Names, values and blocks
# A # starts a comment, to the end of the line.
size = 2.5 # a number
part = "bracket.stl" # a string
done = False # True or False
nothing = None # no value
sizes = [4, 2, 1] # a list, which can change
corner = (0, 0, 10) # a tuple, which cannot
limits = {"min": 0.8, "max": 3} # a dict: keys to values
lo, hi = m.bounds # a pair taken apart
count = 0
count += 1 # also -=, *=, /=, //=, %=Blocks are indented under a line that ends in a colon. Use four spaces, and the same indent for every line of a block:
if m.holes > 0:
print(m.name, "has", m.holes, "holes")
m = fix.repair(m)Numbers
Integers (3, -7, 0x1f) are of any size; floats (2.5, 1e-3) are double precision. +, -, * work as usual. / always gives a float, so 7 / 2 is 3.5; // divides rounding down, so 7 // 2 is 3 and -7 // 2 is -4; % is the remainder. There is no power operator: write x * x, math.pow(x, 3) or math.sqrt(x).
int(x) drops the fraction, float(n) makes a float, abs, min and max do what they say, and math.round, math.floor and math.ceil round. math also has pi, e, sin, cos, tan, atan2, hypot, exp, log, degrees and radians.
Comparisons and logic
==, !=, <, <=, >, >= compare; in and not in test membership of a list, a dict's keys or a string. and, or and not combine. Comparisons do not chain: write 0 < x and x < 1, not 0 < x < 1. A choice between two values is a if condition else b.
In a condition, 0, 0.0, "", empty lists and dicts, None and False count as false; everything else as true. Test for a missing result with if part == None:.
Loops and decisions
for path in args: # each item of a list
m = read(path)
if m.faces > 1000000:
print(path, "is too big")
continue # on to the next item
elif m.holes:
m = fix.repair(m)
else:
pass # do nothing
for i in range(3): # 0, 1, 2; range(a, b, step) too
print(i)
tries = 0
while tries < 5:
tries += 1
if good_enough():
break # out of the loopenumerate(l) gives pairs of index and item, and zip(a, b) pairs of items: for i, path in enumerate(args):. A list must not change while a loop runs over it.
Strings
Strings are written in double or single quotes, in triple quotes over several lines, and with r"..." to keep backslashes as they are. \n is a new line and \t a tab.
path = "parts/bracket.stl"
path[0] # "p"
path[-3:] # "stl", the last three
len(path) # 17
"bracket" in path # True
path.removesuffix(".stl") # "parts/bracket"
path.split("/") # ["parts", "bracket.stl"]
"-".join(["a", "b"]) # "a-b"
path.upper(), path.strip(), path.replace("parts", "out")
path.startswith("parts"), path.endswith(".stl"), path.find("/")
name + "-fixed.obj" # joinedA string is not a list of characters: to loop over its characters use for c in s.elems():. Numbers become strings with str(x), and strings numbers with int(s) and float(s).
Build text with % or format:
"%s has %d faces" % (m.name, m.faces)
"%s,%d,%s" % (path, m.faces, m.volume) # a line of CSV
"{} faces, {} holes".format(m.faces, m.holes)
"{name} is {n}".format(name = "part", n = 3)%s is any value, %d an integer, %f, %e and %g a float, %r a value as written in a script, and %% a percent sign. Widths and precisions (%.3f, {:.3f}) are not supported; round first, as math.round(x * 1000) / 1000 for three decimals.
Lists, tuples, dicts and sets
l = [3, 1, 2]
l[0], l[-1], l[1:] # 3, 2, [1, 2]
l.append(4) # [3, 1, 2, 4]
l.extend([5, 6]); l.insert(0, 9); l.remove(9); l.pop()
l.index(2), len(l), 2 in l
sorted(l), sorted(l, reverse = True)
sorted(parts, key = lambda m: m.volume) # by a field
l + [7], [0] * 3 # joined, repeated
t = (1, 2) # a tuple: a list that cannot change
x, y = t
d = {"faces": 10}
d["holes"] = 0 # add or change
d.get("volume", 0) # 0 when the key is missing
"faces" in d
for key, value in d.items():
print(key, value)
d.keys(), d.values(), d.pop("holes"), d.update({"area": 2})
s = set([1, 2]) # a set: each value once
s.add(3); 2 in s; s | set([4])Dicts keep their keys in the order they were added. Lists and dicts can change; strings, numbers and tuples cannot.
Comprehensions
A list or dict can be built from a loop in one line:
meshes = [read(p) for p in args]
closed = [m for m in meshes if m.holes == 0]
faces = {m.name: m.faces for m in meshes}Functions
def sum_of(values):
total = 0
for v in values:
total += v
return total
def prepare(path, size = "2%"): # size has a default
m = fix.repair(path)
return mesh.remesh(m, size = size)
e = prepare("part.stl")
f = prepare("part.stl", size = "1%")
double = lambda x: x * 2 # a one-line functionOptions are given by name, as size = "1%", in any order after the values given by position. A function can take any number of values with *rest, and of named ones with **named. A function may call itself. It can read the script's names and change the lists and dicts they hold, but cannot assign them anew: return the value instead, as count = add(count).
Records, JSON and errors
struct(name = "bracket", faces = 120) makes a record with fields, read as r.name. Reports are records too: dir(r) lists their fields, getattr(r, "holes") reads one by name, and hasattr(r, "holes") asks whether it is there.
json.encode(value) writes lists, dicts, numbers, strings and records as JSON, json.indent lays it out, and json.decode(text) reads it back: print(json.encode(info(m).quality)).
There are no exceptions to catch. A failing operation stops the script with its message, so test what can be tested first (if m.holes:). fail("message") stops the script on purpose, with that message and a failing exit status for whatever ran it.
What differs from Python
- No
import, and no modules but Betti's,jsonandmath; a script is one file. - No classes; use dicts, records (
struct) and functions. - No
tryorexcept, nowith, noglobal, no generators. - No
**, nois, no chained comparisons, no f-strings, noround(usemath.round). - Strings are not iterable (
s.elems()), and formats take no widths or precisions. - No files, clock or environment but through
read,save,argsand the operations: the same script on the same files gives the same result on every machine.
The built-in functions are len, range, sorted, reversed, enumerate, zip, min, max, abs, any, all, int, float, str, bool, list, dict, tuple, set, repr, type, dir, getattr, hasattr, print and fail, with Betti's read, read_cad, read_volume, save, args and struct. betti help script has this page in brief.
Worked scripts
Each of these runs as shown with the current release; the examples page has more, with chains and prompts for coding agents.
Repair a folder and report it as CSV
# Repair every part given, save each, and report them as CSV.
# betti script -q batch.star parts/*.stl > report.csv
print("part,faces,holes,volume")
for path in args:
m = fix.repair(path)
print("%s,%d,%d,%s" % (path, m.faces, m.holes, math.round(m.volume * 1000) / 1000))
save(m, path.removesuffix(".stl") + "-fixed.obj")Cut a part in half and remesh each half
# Cut a part through the middle of its height and remesh each half.
# betti script split.star part.stl
m = fix.repair(read(args[0]))
lo, hi = m.bounds
middle = (lo[2] + hi[2]) / 2
below, above = shape.cut(m, plane = "z=%s" % middle)
for name, part in [("below", below), ("above", above)]:
if part == None:
print(name, "is empty")
continue
save(mesh.remesh(part, size = "2%"), name + ".obj")
print(name, part.faces, "faces")From STEP to a volume mesh
# Heal a STEP model, mesh its surface and fill it with tetrahedra.
# betti script cadmesh.star part.step
model = cad.heal(read_cad(args[0]))
surface = cad.mesh(model, size = "3%")
if surface.holes:
fail("the surface of %s is open" % args[0])
r = volume.tet(surface, size = "3%", report = True)
print("cells:", r.cells)
save(model, "healed.step")
save(r.out, "part.msh")volume.tet with report = True returns its report, with the mesh it made as r.out, after the name of its output file in the command's usage.