Spray painting¶
botrail can check a spray program the way a paint engineer would — is the gun the right distance away, square enough on, and pointed at the part — and then bake the cycle and integrate the film it leaves: microns per patch, in-spec area, holidays, the paint bill, and where the overspray went. All of it deterministic, so it regression-tests in CI like the rest of a cell.
examples/painting/painting_hood_demo.py: a wrapped raster on a curved hood, per-
stroke triggered, the film building up against a 20–30 µm spec (neutral on
target, blue thin, red thick) and the spraying lane in the dock.
What it answers, and what it does not¶
The film model is calibrated geometry, not fluid dynamics. An applicator carries the footprint measured on a coupon at a known standoff; the integrator projects that footprint onto the surface along the spray axis, scales it for range and incidence, and integrates over the time the gun was actually spraying — on the baked trajectory, so a stroke that lost speed is a stroke that laid on more paint. There is no air flow and no electrostatic field, so an ESTA bell's wrap around an edge is not modeled, and absolute microns are only as good as the coupon fed in.
| Question | Confidence |
|---|---|
| Does every stroke reach, and does the gun clear the fixtures? | certain — the ordinary reach and clearance checks |
| Is the standoff / incidence within the shop's rules? | certain — geometry (check_paint) |
| Are there holidays; is the lap ripple in band? | high — coverage is what the model is good at |
| The film's relative distribution (lap streaks, starved ends, dwell) | high |
| Absolute film thickness | as good as the calibration |
| Electrostatic wrap, sags, dry spray, colour | not modeled |
Two lessons the model teaches quickly: paint is conserved, so on a gentle curve the standoff and angle rules move the mean film very little (they protect what the geometry does not carry — transfer efficiency, sags, dry spray); and the arm's slowdowns land wherever the turnarounds are, so overtravel is what keeps them off the part.
The applicator¶
import botrail as bt
# From the shop's static-pattern coupon: radius/film pairs, meters, after
# spraying `seconds` at `standoff`. Shape *and* delivery rate come off it.
pattern = bt.paint.from_profile("coupon.csv", standoff=0.25, seconds=3.0)
bell = bt.paint.applicator(pattern, transfer_efficiency=0.85)
# Or an analytic fit, before anyone has sprayed anything.
bell = bt.paint.applicator(bt.paint.bell(0.16), standoff=0.25,
flow=25e-6 * 0.15 * 0.064 / 0.85, transfer_efficiency=0.85)
fan = bt.paint.applicator(bt.paint.fan(0.30, 0.08), standoff=0.25, flow=200e-6)
bell is axisymmetric (a rotary atomizer), so the 5-DOF solver keeps the
spin about the tool axis free; fan is a flat fan and wants its spin
pinned across the direction of travel (spin="fan" on the generators).
The tool frame convention is the toolpath solver's: the TCP's +Z runs from
the nozzle tip toward the gun body, paint travels along -Z.
Strokes from the surface¶
Painting has no CAM. Rasters come from the surface and the shop's rules — pattern width, lap overlap, gun speed, standoff, overtravel:
tp = bt.paint.strokes((0.24, 0.18), standoff=0.25, pattern_width=0.16,
overlap=0.6, speed=0.15, overtravel=0.10, frame="part")
tp = bt.paint.wrap_strokes(0.5, 0.24, standoff=0.25, pattern_width=0.16,
overlap=0.6, speed=0.15, overtravel=0.10,
arc=(-0.37, 0.37), center=(0, 0, -0.5), axis="x",
frame="part", brush="top")
scene.add_toolpath("coat", tp)
strokes rasters a flat area; wrap_strokes wraps the same raster onto a
cylinder so the gun stays radial. Both return an ordinary
toolpath, authored in a part frame — move the
fixture and the program re-solves.
Brushes: the program's own trigger¶
A brush (ABB's word) is a named process setting: an applicator, a flow multiplier, and the trigger's lead and lag. Declared on the scene, referenced from strokes:
scene.define_applicator("bell", bell)
scene.define_brush("primer", applicator="bell", flow=0.6)
scene.define_brush("top", applicator="bell", flow=1.0, lead=0.25, lag=0.25)
Once any stroke of a toolpath names a brush, the program triggers per
stroke: the laps spray with their brush and a feed move without one runs at
speed with the gun off — which is how wrap_strokes(brush=...) leaves
the turnarounds dry. Two triggers decide when paint flows and both must
agree: the PLC's enable signal, and the program's own strokes. The approach
the rollout plans in from wherever the robot stood, and the rapids, never
spray the part however the enable was authored.
Check before you bake¶
report = scene.check_paint("coat", "hood", standoff=(0.23, 0.27),
max_incidence=math.radians(10))
report.ok, report.in_band_ratio, report.on_target_ratio
report.spans("too_far") # stretches of the path, meters along it
Pure geometry — no robot involved, so it is the same answer whichever arm ends up carrying the gun. Off-target stretches (a raster's overtravel) are reported but do not fail the check: whether the gun should be closed there is a triggering question. In the studio the findings sit on the path as coloured points.
Bake, and read the film¶
sq = scene.sequence("cycle")
sq.step("purge", actions=[bt.seq.set_signal("purge")], transition=bt.seq.elapsed(2.0))
sq.step("ready", actions=[bt.seq.set_signal("purge", False), bt.seq.set_signal("gun_on")])
sq.step("spray", actions=[bt.seq.toolpath("coat")], transition=bt.seq.done())
sq.step("close", actions=[bt.seq.set_signal("gun_on", False)])
tl = sq.simulate()
film = tl.spray_coat("hood", gate="gun_on", spec=(20e-6, 30e-6),
facing=(0, 0, 1))
film.mean, film.sigma, film.in_spec_ratio, film.uncoated_area
film.sprayed_volume, film.deposited_volume, film.effective_transfer_efficiency
film.overspray() # {"bench": 3.8e-6, "mask": 1e-7}: where the rest went
film.sprayed_by_brush() # per brush
facing names the job by the way it faces (the top of a panel), so the
statistics do not depend on how far the raster overtravels. spec turns
the film map diverging — neutral on target, blue thin, red thick — and gives
you in_spec_ratio, the headline number. The baked twin of the pre-bake
check is tl.paint_report(...).
Because the bake is deterministic, these are your tests:
def test_hood_makes_spec():
tl = bake()
film = tl.spray_coat("hood", gate="gun_on", spec=SPEC, facing=(0, 0, 1))
assert film.in_spec_ratio > 0.99
assert film.uncoated_area == 0.0
assert scene.check_paint("coat", "hood", **RULES).ok
Show it¶
scene.show_film(film) # the film map, with its key
tl = scene.animate_paint(tl, "hood", gate="gun_on", spec=SPEC,
facing=(0, 0, 1), trigger_signal="spraying")
scene.add_spray_cone("jet", "spraying", scene.robots[0], length=0.25, radius=0.08)
tl.export_usd("cell_painting.usdc")
animate_paint re-walks the coat in stages and swaps the visible mesh along
the timeline, so the film builds up during playback — in the studio, in the
exported USD (visibility-switched stages), and in a replayed recording. It
also writes the effective trigger as a signal lane (spraying = enable AND
program), which is what the timing chart shows and what the spray cone
follows. Declare the signal and the cone with the cell: a timeline exports
the scene it was baked from.
Two readings of the same film, picked with style: amount (a sequential
ramp, light to dark — how much paint is there; pass paint_color= and it
runs from a light wash to the paint's own colour, so the part visibly takes
the paint as the coat goes on) and spec (diverging over the band —
neutral on target, blue thin, red thick: the verdict). spray_coat and
show_film default to spec when a spec was given, animate_paint to
amount. Bare, never-sprayed patches wear the part's own colour. The spray
cone's ring is the pattern's footprint at the calibrated standoff — the
range the gun works over on the part.
Two worked examples: examples/painting/painting_demo.py (a flat panel: calibration,
lap overlap, gun speed) and examples/painting/painting_hood_demo.py (a curved hood:
the pre-bake check, brushes and the trigger, the paint bill, the build-up).